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
This commit is contained in:
valentinbvro 2026-07-29 11:46:20 +02:00
parent 3bc0d65c46
commit 42cefaddb2
13 changed files with 642 additions and 35 deletions

View file

@ -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");

View file

@ -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
}
]
}

View file

@ -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,

View file

@ -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<MembershipRole, string[]> = {
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'
>;

View file

@ -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<string> {
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<string> {
const authHeader = request.header('authorization') ?? '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;

View file

@ -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', () => {

View file

@ -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;

View file

@ -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 !=

View file

@ -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> = {},
): 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'],
}),
];

View file

@ -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);
}
}

View file

@ -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 {}

View file

@ -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<MenuItem, 'parentId' | 'requiredPermissions' | 'allowedRoles'>[];
}
@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<string> {
const raw = process.env.FEATURE_FLAGS ?? '';
return new Set(
raw
.split(',')
.map((flag) => flag.trim())
.filter(Boolean),
);
}
}

View file

@ -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<TenantMembershipView> {
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,