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:
parent
3bc0d65c46
commit
42cefaddb2
13 changed files with 642 additions and 35 deletions
53
drizzle/0004_platform_kernel.sql
Normal file
53
drizzle/0004_platform_kernel.sql
Normal 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");
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
75
src/auth/execution-context.ts
Normal file
75
src/auth/execution-context.ts
Normal 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'
|
||||
>;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
109
src/db/schema.ts
109
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 !=
|
||||
|
|
|
|||
167
src/navigation/menu-registry.ts
Normal file
167
src/navigation/menu-registry.ts
Normal 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'],
|
||||
}),
|
||||
];
|
||||
14
src/navigation/navigation.controller.ts
Normal file
14
src/navigation/navigation.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
src/navigation/navigation.module.ts
Normal file
9
src/navigation/navigation.module.ts
Normal 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 {}
|
||||
93
src/navigation/navigation.service.ts
Normal file
93
src/navigation/navigation.service.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue