From 42cefaddb22e964bae86d877d3f37183c3d922a6 Mon Sep 17 00:00:00 2001 From: valentinbvro Date: Wed, 29 Jul 2026 11:46:20 +0200 Subject: [PATCH] feat: Platform Kernel v1 -- workspaces, ExecutionContext, /v1/navigation, event envelope Specul de arhitectura cere Platform Kernel INAINTEA modulelor de domeniu. Modulele A1/A2 au fost construite peste un kernel caruia ii lipseau exact piesele astea. Le adaug acum, aditiv, fara sa rup ce merge. - workspaces: tenant != workspace. Workspace-ul e contextul de lucru DIN tenant. Backfill: fiecare tenant existent primeste workspace implicit, altfel SessionGuard i-ar respinge toate requesturile. - memberships.workspace_id + valid_from/valid_until: rol per workspace si acces delegat cu expirare (contabil pana la o data). SessionGuard respinge membership expirat si membership legat de alt workspace. - ExecutionContext inlocuieste sesiunea subtire (userId+tenantId+role): requestId, correlationId, workspaceId, membershipId, roles, permissions, purpose, timezone, source. Tipul vechi ramane exportat sub acelasi nume, ca sa nu ating ~15 module de domeniu doar pentru o redenumire. - GET /v1/navigation: menu registry mutat in backend. Filtreaza pe rol, tip de workspace, permisiuni si feature flags; intoarce doar itemii autorizati. Ramane UX, nu securitate -- fiecare endpoint verifica din nou. - event envelope: workspace_id, occurred_at, actor_id, aggregate_type, causation_id, classification, provenance - audit envelope: workspace_id, actor_type, purpose, changed_fields, before/after hash, session_id --- drizzle/0004_platform_kernel.sql | 53 ++++++++ drizzle/meta/_journal.json | 7 + src/app.module.ts | 2 + src/auth/execution-context.ts | 75 +++++++++++ src/auth/session.guard.ts | 110 +++++++++++++++- src/auth/tenant.guard.spec.ts | 10 ++ src/auth/tenant.guard.ts | 12 +- src/db/schema.ts | 109 +++++++++++++--- src/navigation/menu-registry.ts | 167 ++++++++++++++++++++++++ src/navigation/navigation.controller.ts | 14 ++ src/navigation/navigation.module.ts | 9 ++ src/navigation/navigation.service.ts | 93 +++++++++++++ src/tenants/tenants.service.ts | 16 ++- 13 files changed, 642 insertions(+), 35 deletions(-) create mode 100644 drizzle/0004_platform_kernel.sql create mode 100644 src/auth/execution-context.ts create mode 100644 src/navigation/menu-registry.ts create mode 100644 src/navigation/navigation.controller.ts create mode 100644 src/navigation/navigation.module.ts create mode 100644 src/navigation/navigation.service.ts diff --git a/drizzle/0004_platform_kernel.sql b/drizzle/0004_platform_kernel.sql new file mode 100644 index 0000000..8c0816a --- /dev/null +++ b/drizzle/0004_platform_kernel.sql @@ -0,0 +1,53 @@ +-- Platform Kernel v1 (spec sectiunile 3, 4, 11, 29). +-- Aditiv si idempotent: coloanele noi sunt nullable sau au default, ca sa nu +-- pice pe randurile existente. + +-- 1. Workspaces ------------------------------------------------------------- +DO $$ BEGIN + CREATE TYPE "workspace_type" AS ENUM ('personal','family','business','community','project'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE TABLE IF NOT EXISTS "workspaces" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" uuid NOT NULL, + "name" text NOT NULL, + "type" "workspace_type" DEFAULT 'business' NOT NULL, + "is_default" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "created_by" uuid, + "deleted_at" timestamp +); +CREATE INDEX IF NOT EXISTS "workspaces_tenant_idx" ON "workspaces" ("tenant_id"); + +-- Backfill: fiecare tenant existent primeste workspace-ul implicit. Fara asta, +-- SessionGuard ar respinge toate requesturile tenantilor creati inainte. +INSERT INTO "workspaces" ("tenant_id", "name", "type", "is_default") +SELECT t."id", t."name", 'business', 1 +FROM "tenants" t +WHERE NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."tenant_id" = t."id"); + +-- 2. Memberships: scope pe workspace + fereastra de valabilitate ------------- +ALTER TABLE "memberships" ADD COLUMN IF NOT EXISTS "workspace_id" uuid; +ALTER TABLE "memberships" ADD COLUMN IF NOT EXISTS "valid_from" timestamp; +ALTER TABLE "memberships" ADD COLUMN IF NOT EXISTS "valid_until" timestamp; +CREATE INDEX IF NOT EXISTS "memberships_workspace_idx" ON "memberships" ("workspace_id"); + +-- 3. Event envelope complet ------------------------------------------------- +ALTER TABLE "outbox_events" ADD COLUMN IF NOT EXISTS "workspace_id" uuid; +ALTER TABLE "outbox_events" ADD COLUMN IF NOT EXISTS "occurred_at" timestamp DEFAULT now() NOT NULL; +ALTER TABLE "outbox_events" ADD COLUMN IF NOT EXISTS "actor_id" uuid; +ALTER TABLE "outbox_events" ADD COLUMN IF NOT EXISTS "aggregate_type" text; +ALTER TABLE "outbox_events" ADD COLUMN IF NOT EXISTS "causation_id" uuid; +ALTER TABLE "outbox_events" ADD COLUMN IF NOT EXISTS "classification" text DEFAULT 'c2' NOT NULL; +ALTER TABLE "outbox_events" ADD COLUMN IF NOT EXISTS "provenance" jsonb DEFAULT '{}'::jsonb NOT NULL; +CREATE INDEX IF NOT EXISTS "outbox_unprocessed_idx" ON "outbox_events" ("processed_at","created_at"); + +-- 4. Audit envelope complet ------------------------------------------------- +ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "workspace_id" uuid; +ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "actor_type" text DEFAULT 'user' NOT NULL; +ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "purpose" text; +ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "changed_fields" jsonb; +ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "before_hash" text; +ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "after_hash" text; +ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "session_id" text; +CREATE INDEX IF NOT EXISTS "audit_tenant_created_idx" ON "audit_log" ("tenant_id","created_at"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index bae8909..cfb8481 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1785270653404, "tag": "0003_ai_gateway_requests", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1785318305227, + "tag": "0004_platform_kernel", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 90c2294..fa4fe4c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,7 @@ import { AuditModule } from './audit/audit.module'; import { OrganizationsModule } from './organizations/organizations.module'; import { TasksModule } from './tasks/tasks.module'; import { TenantsModule } from './tenants/tenants.module'; +import { NavigationModule } from './navigation/navigation.module'; import { IntelligenceModule } from './intelligence/intelligence.module'; import { SegmentsModule } from './segments/segments.module'; import { ResearchBriefsModule } from './research-briefs/research-briefs.module'; @@ -44,6 +45,7 @@ function parseRedisConnection(redisUrl: string | undefined) { EventsModule, AgentNichesModule, TenantsModule, + NavigationModule, OrganizationsModule, TasksModule, IntelligenceModule, diff --git a/src/auth/execution-context.ts b/src/auth/execution-context.ts new file mode 100644 index 0000000..36d1883 --- /dev/null +++ b/src/auth/execution-context.ts @@ -0,0 +1,75 @@ +/** + * ExecutionContext -- contextul obligatoriu al oricarei operatiuni interne + * (spec sectiunea 3). Inlocuieste sesiunea subtire de dinainte (userId + + * tenantId + role), fara sa o rupa: campurile vechi raman cu aceleasi nume. + * + * Regula: fara acest context, nicio operatiune nu se executa. + */ +export type MembershipRole = 'owner' | 'admin' | 'member'; + +export type RequestSource = + | 'web' + | 'mobile' + | 'api' + | 'agent' + | 'automation' + | 'connector'; + +export interface ExecutionContext { + requestId: string; + correlationId: string; + sessionId: string; + userId: string; + tenantId: string; + /** Workspace-ul activ. Gol pe rutele de bootstrap (@AuthOnly). */ + workspaceId: string; + membershipId: string; + /** Pastrat pentru compatibilitate cu codul existent; = roles[0]. */ + role: MembershipRole; + roles: MembershipRole[]; + permissions: string[]; + purpose: string; + timezone: string; + source: RequestSource; +} + +/** + * Permisiunile derivate din rol. Deliberat explicite si nu calculate dintr-o + * ierarhie implicita: cine citeste codul vede exact ce poate face fiecare rol. + * Wildcard '*' inseamna orice permisiune (owner/admin). + */ +const ROLE_PERMISSIONS: Record = { + owner: ['*'], + admin: ['*'], + member: [ + 'organization:read', + 'task:read', + 'task:create', + 'task:update', + 'segment:read', + 'segment:create', + 'research_brief:read', + 'research_brief:create', + 'intelligence:search', + 'briefing:read', + 'navigation:read', + ], +}; + +export function permissionsForRole(role: MembershipRole): string[] { + return ROLE_PERMISSIONS[role] ?? []; +} + +export function hasPermission(context: ExecutionContext, permission: string): boolean { + return context.permissions.includes('*') || context.permissions.includes(permission); +} + +/** + * Compat: multe servicii cer inca forma veche AuthenticatedSession. + * ExecutionContext o satisface structural, deci nu trebuie schimbate toate + * semnaturile deodata. + */ +export type AuthenticatedSession = Pick< + ExecutionContext, + 'userId' | 'tenantId' | 'role' +>; diff --git a/src/auth/session.guard.ts b/src/auth/session.guard.ts index ee9cfd7..a0bd296 100644 --- a/src/auth/session.guard.ts +++ b/src/auth/session.guard.ts @@ -6,22 +6,28 @@ import { ForbiddenException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import { randomUUID } from 'node:crypto'; import { jwtVerify } from 'jose'; -import { and, eq } from 'drizzle-orm'; +import { and, asc, eq, isNull } from 'drizzle-orm'; import type { Request } from 'express'; import { db } from '../db/client'; -import { memberships } from '../db/schema'; +import { memberships, workspaces } from '../db/schema'; import { supabaseAdmin } from '../supabase/supabase.client'; import { IS_PUBLIC_KEY } from './public.decorator'; import { IS_AUTH_ONLY_KEY } from './auth-only.decorator'; -import type { AuthenticatedSession } from './tenant.guard'; +import { + permissionsForRole, + type ExecutionContext as CeoExecutionContext, +} from './execution-context'; export const TENANT_HEADER = 'x-tenant-id'; +export const WORKSPACE_HEADER = 'x-workspace-id'; +export const CORRELATION_HEADER = 'x-correlation-id'; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; interface RequestWithSession extends Request { - session?: AuthenticatedSession; + session?: CeoExecutionContext; } /** @@ -54,9 +60,28 @@ export class SessionGuard implements CanActivate { context.getHandler(), context.getClass(), ]); + const base = { + requestId: randomUUID(), + correlationId: this.resolveCorrelationId(request), + sessionId: request.header('x-session-id') ?? '', + userId, + timezone: request.header('x-timezone') ?? 'Europe/Bucharest', + source: 'web' as const, + }; + if (isAuthOnly) { - // Sesiune partiala: tenantId ramane gol; TenantGuard e sarit tot prin @AuthOnly. - request.session = { userId, tenantId: '', role: 'member' }; + // Bootstrap: userul e autentificat dar inca nu a ales tenant/workspace. + // TenantGuard e sarit tot prin @AuthOnly. + request.session = { + ...base, + tenantId: '', + workspaceId: '', + membershipId: '', + role: 'member', + roles: ['member'], + permissions: [], + purpose: 'bootstrap', + }; return true; } @@ -73,10 +98,81 @@ export class SessionGuard implements CanActivate { throw new ForbiddenException('No membership for the requested tenant'); } - request.session = { userId, tenantId, role: membership.role }; + // Membership cu fereastra temporala (ex. contabil pana la 31.12): expirat = fara acces. + const now = new Date(); + if ( + (membership.validFrom && membership.validFrom > now) || + (membership.validUntil && membership.validUntil < now) + ) { + throw new ForbiddenException('Membership is not valid at this time'); + } + + const workspaceId = await this.resolveWorkspace(request, tenantId, membership.workspaceId); + + request.session = { + ...base, + tenantId, + workspaceId, + membershipId: membership.id, + role: membership.role, + roles: [membership.role], + permissions: permissionsForRole(membership.role), + purpose: request.header('x-purpose') ?? 'user_request', + }; return true; } + private resolveCorrelationId(request: Request): string { + const header = request.header(CORRELATION_HEADER); + return header && UUID_PATTERN.test(header) ? header : randomUUID(); + } + + /** + * Workspace-ul activ vine din header; daca lipseste, se foloseste workspace-ul + * implicit al tenantului. Un membership legat de un anumit workspace nu poate + * fi folosit pentru altul -- altfel "rol doar in workspace-ul X" n-ar insemna nimic. + */ + private async resolveWorkspace( + request: Request, + tenantId: string, + membershipWorkspaceId: string | null, + ): Promise { + const requested = request.header(WORKSPACE_HEADER); + + if (requested) { + if (!UUID_PATTERN.test(requested)) { + throw new ForbiddenException(`Invalid ${WORKSPACE_HEADER} header`); + } + const workspace = await db.query.workspaces.findFirst({ + where: and( + eq(workspaces.id, requested), + eq(workspaces.tenantId, tenantId), + isNull(workspaces.deletedAt), + ), + }); + if (!workspace) { + throw new ForbiddenException('Workspace not found in this tenant'); + } + if (membershipWorkspaceId && membershipWorkspaceId !== workspace.id) { + throw new ForbiddenException('Membership is scoped to a different workspace'); + } + return workspace.id; + } + + if (membershipWorkspaceId) { + return membershipWorkspaceId; + } + + const fallback = await db.query.workspaces.findFirst({ + where: and(eq(workspaces.tenantId, tenantId), isNull(workspaces.deletedAt)), + orderBy: [asc(workspaces.createdAt)], + }); + if (!fallback) { + throw new ForbiddenException('Tenant has no workspace'); + } + return fallback.id; + } + private async authenticateUser(request: Request): Promise { const authHeader = request.header('authorization') ?? ''; const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null; diff --git a/src/auth/tenant.guard.spec.ts b/src/auth/tenant.guard.spec.ts index 787455a..fe880a0 100644 --- a/src/auth/tenant.guard.spec.ts +++ b/src/auth/tenant.guard.spec.ts @@ -32,9 +32,19 @@ function contextFor(request: FakeRequest, meta: { isPublic?: boolean; isAuthOnly } const session: AuthenticatedSession = { + requestId: '00000000-0000-0000-0000-0000000000r1', + correlationId: '00000000-0000-0000-0000-0000000000c1', + sessionId: 'sess-1', userId: '11111111-1111-1111-1111-111111111111', tenantId: '22222222-2222-2222-2222-222222222222', + workspaceId: '44444444-4444-4444-4444-444444444444', + membershipId: '55555555-5555-5555-5555-555555555555', role: 'member', + roles: ['member'], + permissions: ['task:read'], + purpose: 'user_request', + timezone: 'Europe/Bucharest', + source: 'web', }; describe('TenantGuard', () => { diff --git a/src/auth/tenant.guard.ts b/src/auth/tenant.guard.ts index d96fee1..812d5d9 100644 --- a/src/auth/tenant.guard.ts +++ b/src/auth/tenant.guard.ts @@ -3,12 +3,14 @@ import { Reflector } from '@nestjs/core'; import type { Request } from 'express'; import { IS_PUBLIC_KEY } from './public.decorator'; import { IS_AUTH_ONLY_KEY } from './auth-only.decorator'; +import type { ExecutionContext as CeoExecutionContext } from './execution-context'; -export interface AuthenticatedSession { - userId: string; - tenantId: string; - role: 'owner' | 'admin' | 'member'; -} +/** + * Sesiunea request-ului ESTE acum ExecutionContext-ul complet (spec sectiunea 3). + * Numele vechi ramane exportat de aici pentru ca toate modulele de domeniu il + * importa deja; nu are rost sa schimb ~15 fisiere ca sa redenumesc un tip. + */ +export type AuthenticatedSession = CeoExecutionContext; interface RequestWithSession extends Request { session?: AuthenticatedSession; diff --git a/src/db/schema.ts b/src/db/schema.ts index eed8ce9..5e9dd23 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -103,6 +103,33 @@ export const tenants = pgTable('tenants', { createdAt: timestamp('created_at').defaultNow().notNull(), }); +// Tenant != Workspace. Tenantul e clientul izolat; workspace-ul e contextul +// efectiv de lucru DIN tenant (personal / familie / o anumita firma / comunitate). +// Un user poate avea roluri diferite in workspace-uri diferite ale aceluiasi tenant. +export const workspaceType = pgEnum('workspace_type', [ + 'personal', + 'family', + 'business', + 'community', + 'project', +]); + +export const workspaces = pgTable( + 'workspaces', + { + id: uuid('id').defaultRandom().primaryKey(), + tenantId: uuid('tenant_id').notNull(), + name: text('name').notNull(), + type: workspaceType('type').notNull().default('business'), + /** Workspace-ul creat automat la onboarding; nu poate fi sters. */ + isDefault: integer('is_default').notNull().default(0), + createdAt: timestamp('created_at').defaultNow().notNull(), + createdBy: uuid('created_by'), + deletedAt: timestamp('deleted_at'), + }, + (table) => [index('workspaces_tenant_idx').on(table.tenantId)], +); + export const membershipRole = pgEnum('membership_role', ['owner', 'admin', 'member']); // user_id refera auth.users din Supabase Auth (8.1: "user identity si sessions" = Supabase Auth, @@ -114,6 +141,13 @@ export const memberships = pgTable( tenantId: uuid('tenant_id').notNull(), userId: uuid('user_id').notNull(), role: membershipRole('role').notNull().default('member'), + // NULL = membership la nivel de tenant (valabil in toate workspace-urile). + // Setat = rol doar in acel workspace, cum cere specul: acelasi user poate fi + // owner in Personal si accountant intr-un workspace de firma. + workspaceId: uuid('workspace_id'), + // Acces delegat cu expirare (ex. contabil pana la 31.12). NULL = fara limita. + validFrom: timestamp('valid_from'), + validUntil: timestamp('valid_until'), createdAt: timestamp('created_at').defaultNow().notNull(), }, (table) => [ @@ -121,6 +155,7 @@ export const memberships = pgTable( // e pe exact aceasta pereche la fiecare request uniqueIndex('memberships_tenant_user_uq').on(table.tenantId, table.userId), index('memberships_user_idx').on(table.userId), + index('memberships_workspace_idx').on(table.workspaceId), ], ); @@ -228,30 +263,62 @@ export const observations = pgTable('observations', { // --- Event Fabric / Outbox (blueprint sectiunea 9.1, principiul 2.1 "Events before intelligence") --- -export const outboxEvents = pgTable('outbox_events', { - id: uuid('id').defaultRandom().primaryKey(), - tenantId: uuid('tenant_id').notNull(), - eventType: text('event_type').notNull(), - eventVersion: integer('event_version').notNull().default(1), - subjectId: uuid('subject_id'), - payload: jsonb('payload').notNull(), - correlationId: uuid('correlation_id'), - createdAt: timestamp('created_at').defaultNow().notNull(), - processedAt: timestamp('processed_at'), -}); +// Event envelope complet (spec sectiunea 11). Evenimentele se numesc ca FAPTE +// petrecute (document.uploaded), niciodata ca instructiuni (process.document.now). +export const outboxEvents = pgTable( + 'outbox_events', + { + id: uuid('id').defaultRandom().primaryKey(), + tenantId: uuid('tenant_id').notNull(), + workspaceId: uuid('workspace_id'), + eventType: text('event_type').notNull(), + eventVersion: integer('event_version').notNull().default(1), + /** Cand s-a petrecut faptul (poate diferi de cand a fost scris randul). */ + occurredAt: timestamp('occurred_at').defaultNow().notNull(), + actorId: uuid('actor_id'), + aggregateType: text('aggregate_type'), + /** Pastrat ca subject_id pentru compatibilitate cu dispatcher-ul existent. */ + subjectId: uuid('subject_id'), + payload: jsonb('payload').notNull(), + /** Leaga toate operatiunile din acelasi workflow. */ + correlationId: uuid('correlation_id'), + /** Ce comanda sau eveniment a produs acest eveniment. */ + causationId: uuid('causation_id'), + /** C0-C4; impiedica distribuirea catre servicii neautorizate. */ + classification: text('classification').notNull().default('c2'), + provenance: jsonb('provenance').notNull().default({}), + createdAt: timestamp('created_at').defaultNow().notNull(), + processedAt: timestamp('processed_at'), + }, + (table) => [index('outbox_unprocessed_idx').on(table.processedAt, table.createdAt)], +); // --- Audit (blueprint sectiunea 31.4) --- -export const auditLog = pgTable('audit_log', { - id: uuid('id').defaultRandom().primaryKey(), - tenantId: uuid('tenant_id').notNull(), - actorId: uuid('actor_id'), - action: text('action').notNull(), - resource: text('resource').notNull(), - reason: text('reason'), - correlationId: uuid('correlation_id'), - createdAt: timestamp('created_at').defaultNow().notNull(), -}); +// Append-oriented (spec sectiunea 29): se scrie, nu se modifica si nu se sterge. +export const auditLog = pgTable( + 'audit_log', + { + id: uuid('id').defaultRandom().primaryKey(), + tenantId: uuid('tenant_id').notNull(), + workspaceId: uuid('workspace_id'), + /** user | agent | automation | connector | system */ + actorType: text('actor_type').notNull().default('user'), + actorId: uuid('actor_id'), + action: text('action').notNull(), + resource: text('resource').notNull(), + reason: text('reason'), + /** Scopul declarat al operatiunii -- necesar pentru purpose limitation. */ + purpose: text('purpose'), + changedFields: jsonb('changed_fields'), + beforeHash: text('before_hash'), + afterHash: text('after_hash'), + sessionId: text('session_id'), + correlationId: uuid('correlation_id'), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => [index('audit_tenant_created_idx').on(table.tenantId, table.createdAt)], +); // --- Intelligence Engine / Faza A2 (blueprint sectiunea 10.3, 17 "Lead Intelligence") --- // Apollo/ClickHouse raman sursa de adevar pentru datele B2B (blueprint 9: "Private data != diff --git a/src/navigation/menu-registry.ts b/src/navigation/menu-registry.ts new file mode 100644 index 0000000..a8e3544 --- /dev/null +++ b/src/navigation/menu-registry.ts @@ -0,0 +1,167 @@ +import type { MembershipRole } from '../auth/execution-context'; + +/** + * MENU REGISTRY -- sursa de adevar pentru Main Navigator, in BACKEND (spec §5). + * Frontend-ul nu mai decide ce vede userul; primeste doar itemii autorizati de + * la /v1/navigation. Cea din ceo-web ramane doar ca fallback offline. + * + * releaseStage = CAND e planificat (A0..A4). status = daca exista ACUM in cod. + */ +export type ReleaseStage = 'A0' | 'A1' | 'A2' | 'A3' | 'A4'; +export type MenuStatus = 'ready' | 'planned'; +export type WorkspaceType = 'personal' | 'family' | 'business' | 'community' | 'project'; + +export interface MenuItem { + id: string; + label: string; + route: string; + icon: string; + parentId?: string; + order: number; + requiredPermissions: string[]; + allowedWorkspaceTypes: WorkspaceType[]; + allowedRoles?: MembershipRole[]; + featureFlag?: string; + releaseStage: ReleaseStage; + status: MenuStatus; + isEnabled: boolean; + /** Ce lipseste ca sa devina 'ready'. Afisat in UI pe pagina neconstruita. */ + missing?: string; +} + +const ALL: WorkspaceType[] = ['personal', 'family', 'business', 'community', 'project']; +const B = '/dashboard'; + +/** Sectiunile (parinti). Copiii le refera prin parentId. */ +export const MENU_SECTIONS: MenuItem[] = [ + s('command-center', 'Command Center', 'command', 1, 'A1', ALL), + s('life-os', 'Life OS', 'life', 2, 'A3', ['personal', 'family']), + s('business-os', 'Business OS', 'business', 3, 'A1', ['business', 'project']), + s('documents', 'Documents & Evidence', 'documents', 4, 'A2', ALL), + s('intelligence', 'Intelligence', 'intelligence', 5, 'A2', ALL), + s('decisions', 'Decisions & Opportunities', 'decisions', 6, 'A3', ALL), + s('relationships', 'Relationships & Community', 'relationships', 7, 'A3', ALL), + s('trust', 'Trust & Reputation', 'trust', 8, 'A4', ALL), + s('ai', 'AI & Automations', 'ai', 9, 'A2', ALL), + s('reports', 'Reports', 'reports', 10, 'A2', ALL), + s('data-intelligence', 'Data Intelligence', 'data', 11, 'A2', ALL, ['owner', 'admin']), + s('integrations', 'Integrations & Devices', 'integrations', 12, 'A3', ALL, ['owner', 'admin']), + s('privacy', 'Privacy, Security & Audit', 'privacy', 13, 'A2', ALL), + s('settings', 'Settings & Administration', 'settings', 14, 'A1', ALL), +]; + +function s( + id: string, + label: string, + icon: string, + order: number, + releaseStage: ReleaseStage, + allowedWorkspaceTypes: WorkspaceType[], + allowedRoles?: MembershipRole[], +): MenuItem { + return { + id, + label, + route: '', + icon, + order, + requiredPermissions: [], + allowedWorkspaceTypes, + allowedRoles, + releaseStage, + status: 'planned', + isEnabled: true, + }; +} + +function item( + id: string, + parentId: string, + label: string, + route: string, + order: number, + releaseStage: ReleaseStage, + status: MenuStatus, + requiredPermissions: string[], + extra: Partial = {}, +): MenuItem { + return { + id, + parentId, + label, + route, + icon: '', + order, + requiredPermissions, + allowedWorkspaceTypes: ALL, + releaseStage, + status, + isEnabled: true, + ...extra, + }; +} + +export const MENU_ITEMS: MenuItem[] = [ + // 01 Command Center + item('cc-overview', 'command-center', 'Executive Overview', B, 1, 'A1', 'ready', ['navigation:read']), + item('cc-briefing', 'command-center', 'Daily Briefing', `${B}/briefing`, 2, 'A1', 'ready', ['briefing:read']), + item('cc-priorities', 'command-center', 'Priorities', `${B}/priorities`, 3, 'A2', 'planned', ['briefing:read'], { + missing: 'Priority Rules Engine determinist; AI doar explica scorul, nu il stabileste.', + }), + item('cc-alerts', 'command-center', 'Alerts & Deadlines', `${B}/alerts`, 4, 'A2', 'planned', ['briefing:read'], { + missing: 'Obiect comun Deadline alimentat din contracte, facturi, documente si aplicatii.', + }), + item('cc-approvals', 'command-center', 'Approval Center', `${B}/approvals`, 5, 'A2', 'planned', ['*'], { + missing: 'Coada de aprobare pentru clasele R3-R5. AI Gateway le blocheaza deja pana exista.', + }), + item('cc-activity', 'command-center', 'Activity Timeline', `${B}/activity`, 6, 'A2', 'planned', ['*'], { + missing: 'Vizualizare peste outbox_events + audit_log, care se populeaza deja.', + }), + item('cc-review', 'command-center', 'Daily / Weekly Review', `${B}/review`, 7, 'A2', 'planned', ['briefing:read'], { + missing: 'Job programat saptamanal + snapshot de review.', + }), + + // 03 Business OS (itemii construiti) + item('bo-organizations', 'business-os', 'Organizations', `${B}/organizations`, 2, 'A1', 'ready', ['organization:read']), + item('bo-tasks', 'business-os', 'Tasks & Operations', `${B}/tasks`, 6, 'A1', 'ready', ['task:read']), + item('bo-transactions', 'business-os', 'Transactions', `${B}/transactions`, 8, 'A2', 'planned', ['*'], { + missing: 'Tabela transactions exista (cu evidence_status); lipsesc /v1/transactions si state machine-ul.', + }), + item('bo-accountant', 'business-os', 'Accountant Pack', `${B}/accountant-pack`, 13, 'A2', 'planned', ['*'], { + missing: 'Export pe perioada + verificare evidence gaps + partajare limitata in timp.', + }), + + // 05 Intelligence (itemii construiti) + item('int-companies', 'intelligence', 'Company Intelligence', `${B}/companies`, 3, 'A2', 'ready', ['intelligence:search']), + item('int-segments', 'intelligence', 'Saved Segments', `${B}/segments`, 6, 'A2', 'ready', ['segment:read']), + item('int-research', 'intelligence', 'Research Briefs', `${B}/research`, 7, 'A2', 'ready', ['research_brief:read']), + item('int-memory', 'intelligence', 'Memory & Knowledge', `${B}/memory`, 11, 'A4', 'planned', ['*'], { + missing: 'memory-graph (Apache AGE) ruleaza pe server, neconectat la ceo-api.', + }), + + // 09 AI & Automations + item('ai-assistant', 'ai', 'AI Executive Assistant', `${B}/assistant`, 1, 'A2', 'planned', ['*'], { + missing: 'AI Gateway exista si Hermes ruleaza izolat, dar ceo-api nu expune inca server MCP cu uneltele CEO OS.', + }), + item('ai-costs', 'ai', 'Costs & Usage', `${B}/ai/costs`, 9, 'A2', 'planned', ['*'], { + missing: 'Costurile reale se salveaza deja per cerere in ai_requests.cost_usd; lipseste agregarea.', + }), + + // 11 Data Intelligence (doar owner/admin, mostenit de la sectiune) + item('di-overview', 'data-intelligence', 'Data Overview', `${B}/data`, 1, 'A2', 'planned', ['*'], { + missing: 'Platforma de date e functionala (Apollo/ClickHouse); lipseste UI-ul de administrare.', + }), + + // 13 Privacy + item('pr-audit', 'privacy', 'Audit Log', `${B}/privacy/audit`, 6, 'A2', 'planned', ['*'], { + missing: 'audit_log se populeaza la fiecare operatiune materiala; lipseste UI-ul de consultare.', + }), + + // 14 Settings + item('set-workspaces', 'settings', 'Workspaces', `${B}/settings/workspaces`, 3, 'A1', 'planned', ['*'], { + missing: 'Workspaces exista acum in backend; lipseste UI-ul de administrare si creare.', + }), + item('set-members', 'settings', 'Members', `${B}/members`, 4, 'A1', 'ready', ['*'], { + allowedRoles: ['owner', 'admin'], + }), +]; diff --git a/src/navigation/navigation.controller.ts b/src/navigation/navigation.controller.ts new file mode 100644 index 0000000..0f11b3b --- /dev/null +++ b/src/navigation/navigation.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get } from '@nestjs/common'; +import { CurrentSession } from '../auth/session.decorator'; +import type { AuthenticatedSession } from '../auth/tenant.guard'; +import { NavigationService } from './navigation.service'; + +@Controller('navigation') +export class NavigationController { + constructor(private readonly navigationService: NavigationService) {} + + @Get() + get(@CurrentSession() session: AuthenticatedSession) { + return this.navigationService.getNavigation(session); + } +} diff --git a/src/navigation/navigation.module.ts b/src/navigation/navigation.module.ts new file mode 100644 index 0000000..8ce3cae --- /dev/null +++ b/src/navigation/navigation.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { NavigationController } from './navigation.controller'; +import { NavigationService } from './navigation.service'; + +@Module({ + controllers: [NavigationController], + providers: [NavigationService], +}) +export class NavigationModule {} diff --git a/src/navigation/navigation.service.ts b/src/navigation/navigation.service.ts new file mode 100644 index 0000000..7eecd2e --- /dev/null +++ b/src/navigation/navigation.service.ts @@ -0,0 +1,93 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { and, eq, isNull } from 'drizzle-orm'; +import { db } from '../db/client'; +import { workspaces } from '../db/schema'; +import { hasPermission } from '../auth/execution-context'; +import type { AuthenticatedSession } from '../auth/tenant.guard'; +import { MENU_ITEMS, MENU_SECTIONS, type MenuItem, type WorkspaceType } from './menu-registry'; + +export interface NavigationSectionView { + id: string; + label: string; + icon: string; + order: number; + releaseStage: string; + children: Omit[]; +} + +@Injectable() +export class NavigationService { + /** + * Genereaza Main Navigator pentru contextul curent: rol + tip de workspace + + * permisiuni + feature flags. Intoarce DOAR itemii autorizati. + * + * Filtrarea de aici e pentru UX si pentru a nu expune forma produsului unde nu + * trebuie. NU e masura de securitate: fiecare endpoint verifica din nou accesul + * (spec §4.4 -- "meniul ascuns nu reprezinta securitate"). + */ + async getNavigation(session: AuthenticatedSession) { + const workspace = await db.query.workspaces.findFirst({ + where: and( + eq(workspaces.id, session.workspaceId), + eq(workspaces.tenantId, session.tenantId), + isNull(workspaces.deletedAt), + ), + }); + if (!workspace) { + throw new NotFoundException('Active workspace not found'); + } + + const workspaceType = workspace.type as WorkspaceType; + const enabledFlags = this.enabledFeatureFlags(); + + const isVisible = (entry: MenuItem): boolean => { + if (!entry.isEnabled) return false; + if (!entry.allowedWorkspaceTypes.includes(workspaceType)) return false; + if (entry.allowedRoles && !entry.allowedRoles.includes(session.role)) return false; + if (entry.featureFlag && !enabledFlags.has(entry.featureFlag)) return false; + return entry.requiredPermissions.every((permission) => hasPermission(session, permission)); + }; + + const sections: NavigationSectionView[] = []; + for (const section of MENU_SECTIONS.slice().sort((a, b) => a.order - b.order)) { + if (!isVisible(section)) continue; + + const children = MENU_ITEMS.filter((entry) => entry.parentId === section.id && isVisible(entry)) + .sort((a, b) => a.order - b.order) + .map(({ parentId, requiredPermissions, allowedRoles, ...rest }) => rest); + + // O sectiune fara niciun copil autorizat nu are ce cauta in meniu. + if (children.length === 0) continue; + + sections.push({ + id: section.id, + label: section.label, + icon: section.icon, + order: section.order, + releaseStage: section.releaseStage, + children, + }); + } + + return { + workspace: { id: workspace.id, name: workspace.name, type: workspace.type }, + role: session.role, + sections, + }; + } + + /** + * Feature flags. Momentan din env (CSV), ca sa nu inventez o tabela pe care + * nimeni n-o administreaza inca; contractul catre UI ramane acelasi cand + * devin per-tenant in baza. + */ + private enabledFeatureFlags(): Set { + const raw = process.env.FEATURE_FLAGS ?? ''; + return new Set( + raw + .split(',') + .map((flag) => flag.trim()) + .filter(Boolean), + ); + } +} diff --git a/src/tenants/tenants.service.ts b/src/tenants/tenants.service.ts index 739a51c..777e70a 100644 --- a/src/tenants/tenants.service.ts +++ b/src/tenants/tenants.service.ts @@ -6,7 +6,7 @@ import { } from '@nestjs/common'; import { and, eq, inArray } from 'drizzle-orm'; import { db } from '../db/client'; -import { memberships, tenants } from '../db/schema'; +import { memberships, tenants, workspaces } from '../db/schema'; import { supabaseAdmin } from '../supabase/supabase.client'; import { AuditService } from '../audit/audit.service'; import { OutboxService } from '../events/outbox.service'; @@ -25,10 +25,22 @@ export class TenantsService { private readonly audit: AuditService, ) {} - /** Tenant nou + membership owner pentru creator, atomic (blueprint 4: tenant personal la onboarding). */ + /** Tenant nou + workspace implicit + membership owner, atomic (spec sectiunea 4). */ async createTenant(userId: string, name: string): Promise { return this.outbox.withTransaction(async (tx) => { const [tenant] = await tx.insert(tenants).values({ name }).returning(); + // Fiecare tenant primeste un workspace implicit; fara el, requesturile + // ulterioare n-ar avea context de lucru si SessionGuard le-ar respinge. + await tx + .insert(workspaces) + .values({ + tenantId: tenant.id, + name, + type: 'business', + isDefault: 1, + createdBy: userId, + }) + .returning(); await tx.insert(memberships).values({ tenantId: tenant.id, userId, role: 'owner' }); await this.audit.record(tx, { tenantId: tenant.id,