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,
|
"when": 1785270653404,
|
||||||
"tag": "0003_ai_gateway_requests",
|
"tag": "0003_ai_gateway_requests",
|
||||||
"breakpoints": true
|
"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 { OrganizationsModule } from './organizations/organizations.module';
|
||||||
import { TasksModule } from './tasks/tasks.module';
|
import { TasksModule } from './tasks/tasks.module';
|
||||||
import { TenantsModule } from './tenants/tenants.module';
|
import { TenantsModule } from './tenants/tenants.module';
|
||||||
|
import { NavigationModule } from './navigation/navigation.module';
|
||||||
import { IntelligenceModule } from './intelligence/intelligence.module';
|
import { IntelligenceModule } from './intelligence/intelligence.module';
|
||||||
import { SegmentsModule } from './segments/segments.module';
|
import { SegmentsModule } from './segments/segments.module';
|
||||||
import { ResearchBriefsModule } from './research-briefs/research-briefs.module';
|
import { ResearchBriefsModule } from './research-briefs/research-briefs.module';
|
||||||
|
|
@ -44,6 +45,7 @@ function parseRedisConnection(redisUrl: string | undefined) {
|
||||||
EventsModule,
|
EventsModule,
|
||||||
AgentNichesModule,
|
AgentNichesModule,
|
||||||
TenantsModule,
|
TenantsModule,
|
||||||
|
NavigationModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
TasksModule,
|
TasksModule,
|
||||||
IntelligenceModule,
|
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,
|
ForbiddenException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { jwtVerify } from 'jose';
|
import { jwtVerify } from 'jose';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, asc, eq, isNull } from 'drizzle-orm';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { db } from '../db/client';
|
import { db } from '../db/client';
|
||||||
import { memberships } from '../db/schema';
|
import { memberships, workspaces } from '../db/schema';
|
||||||
import { supabaseAdmin } from '../supabase/supabase.client';
|
import { supabaseAdmin } from '../supabase/supabase.client';
|
||||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||||
import { IS_AUTH_ONLY_KEY } from './auth-only.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 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;
|
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 {
|
interface RequestWithSession extends Request {
|
||||||
session?: AuthenticatedSession;
|
session?: CeoExecutionContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -54,9 +60,28 @@ export class SessionGuard implements CanActivate {
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
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) {
|
if (isAuthOnly) {
|
||||||
// Sesiune partiala: tenantId ramane gol; TenantGuard e sarit tot prin @AuthOnly.
|
// Bootstrap: userul e autentificat dar inca nu a ales tenant/workspace.
|
||||||
request.session = { userId, tenantId: '', role: 'member' };
|
// TenantGuard e sarit tot prin @AuthOnly.
|
||||||
|
request.session = {
|
||||||
|
...base,
|
||||||
|
tenantId: '',
|
||||||
|
workspaceId: '',
|
||||||
|
membershipId: '',
|
||||||
|
role: 'member',
|
||||||
|
roles: ['member'],
|
||||||
|
permissions: [],
|
||||||
|
purpose: 'bootstrap',
|
||||||
|
};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,10 +98,81 @@ export class SessionGuard implements CanActivate {
|
||||||
throw new ForbiddenException('No membership for the requested tenant');
|
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;
|
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> {
|
private async authenticateUser(request: Request): Promise<string> {
|
||||||
const authHeader = request.header('authorization') ?? '';
|
const authHeader = request.header('authorization') ?? '';
|
||||||
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,19 @@ function contextFor(request: FakeRequest, meta: { isPublic?: boolean; isAuthOnly
|
||||||
}
|
}
|
||||||
|
|
||||||
const session: AuthenticatedSession = {
|
const session: AuthenticatedSession = {
|
||||||
|
requestId: '00000000-0000-0000-0000-0000000000r1',
|
||||||
|
correlationId: '00000000-0000-0000-0000-0000000000c1',
|
||||||
|
sessionId: 'sess-1',
|
||||||
userId: '11111111-1111-1111-1111-111111111111',
|
userId: '11111111-1111-1111-1111-111111111111',
|
||||||
tenantId: '22222222-2222-2222-2222-222222222222',
|
tenantId: '22222222-2222-2222-2222-222222222222',
|
||||||
|
workspaceId: '44444444-4444-4444-4444-444444444444',
|
||||||
|
membershipId: '55555555-5555-5555-5555-555555555555',
|
||||||
role: 'member',
|
role: 'member',
|
||||||
|
roles: ['member'],
|
||||||
|
permissions: ['task:read'],
|
||||||
|
purpose: 'user_request',
|
||||||
|
timezone: 'Europe/Bucharest',
|
||||||
|
source: 'web',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('TenantGuard', () => {
|
describe('TenantGuard', () => {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ import { Reflector } from '@nestjs/core';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||||
import { IS_AUTH_ONLY_KEY } from './auth-only.decorator';
|
import { IS_AUTH_ONLY_KEY } from './auth-only.decorator';
|
||||||
|
import type { ExecutionContext as CeoExecutionContext } from './execution-context';
|
||||||
|
|
||||||
export interface AuthenticatedSession {
|
/**
|
||||||
userId: string;
|
* Sesiunea request-ului ESTE acum ExecutionContext-ul complet (spec sectiunea 3).
|
||||||
tenantId: string;
|
* Numele vechi ramane exportat de aici pentru ca toate modulele de domeniu il
|
||||||
role: 'owner' | 'admin' | 'member';
|
* importa deja; nu are rost sa schimb ~15 fisiere ca sa redenumesc un tip.
|
||||||
}
|
*/
|
||||||
|
export type AuthenticatedSession = CeoExecutionContext;
|
||||||
|
|
||||||
interface RequestWithSession extends Request {
|
interface RequestWithSession extends Request {
|
||||||
session?: AuthenticatedSession;
|
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(),
|
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']);
|
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,
|
// 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(),
|
tenantId: uuid('tenant_id').notNull(),
|
||||||
userId: uuid('user_id').notNull(),
|
userId: uuid('user_id').notNull(),
|
||||||
role: membershipRole('role').notNull().default('member'),
|
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(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
|
|
@ -121,6 +155,7 @@ export const memberships = pgTable(
|
||||||
// e pe exact aceasta pereche la fiecare request
|
// e pe exact aceasta pereche la fiecare request
|
||||||
uniqueIndex('memberships_tenant_user_uq').on(table.tenantId, table.userId),
|
uniqueIndex('memberships_tenant_user_uq').on(table.tenantId, table.userId),
|
||||||
index('memberships_user_idx').on(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") ---
|
// --- Event Fabric / Outbox (blueprint sectiunea 9.1, principiul 2.1 "Events before intelligence") ---
|
||||||
|
|
||||||
export const outboxEvents = pgTable('outbox_events', {
|
// Event envelope complet (spec sectiunea 11). Evenimentele se numesc ca FAPTE
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
// petrecute (document.uploaded), niciodata ca instructiuni (process.document.now).
|
||||||
tenantId: uuid('tenant_id').notNull(),
|
export const outboxEvents = pgTable(
|
||||||
eventType: text('event_type').notNull(),
|
'outbox_events',
|
||||||
eventVersion: integer('event_version').notNull().default(1),
|
{
|
||||||
subjectId: uuid('subject_id'),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
payload: jsonb('payload').notNull(),
|
tenantId: uuid('tenant_id').notNull(),
|
||||||
correlationId: uuid('correlation_id'),
|
workspaceId: uuid('workspace_id'),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
eventType: text('event_type').notNull(),
|
||||||
processedAt: timestamp('processed_at'),
|
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) ---
|
// --- Audit (blueprint sectiunea 31.4) ---
|
||||||
|
|
||||||
export const auditLog = pgTable('audit_log', {
|
// Append-oriented (spec sectiunea 29): se scrie, nu se modifica si nu se sterge.
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
export const auditLog = pgTable(
|
||||||
tenantId: uuid('tenant_id').notNull(),
|
'audit_log',
|
||||||
actorId: uuid('actor_id'),
|
{
|
||||||
action: text('action').notNull(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
resource: text('resource').notNull(),
|
tenantId: uuid('tenant_id').notNull(),
|
||||||
reason: text('reason'),
|
workspaceId: uuid('workspace_id'),
|
||||||
correlationId: uuid('correlation_id'),
|
/** user | agent | automation | connector | system */
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
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") ---
|
// --- Intelligence Engine / Faza A2 (blueprint sectiunea 10.3, 17 "Lead Intelligence") ---
|
||||||
// Apollo/ClickHouse raman sursa de adevar pentru datele B2B (blueprint 9: "Private data !=
|
// 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';
|
} from '@nestjs/common';
|
||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
import { db } from '../db/client';
|
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 { supabaseAdmin } from '../supabase/supabase.client';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { OutboxService } from '../events/outbox.service';
|
import { OutboxService } from '../events/outbox.service';
|
||||||
|
|
@ -25,10 +25,22 @@ export class TenantsService {
|
||||||
private readonly audit: AuditService,
|
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> {
|
async createTenant(userId: string, name: string): Promise<TenantMembershipView> {
|
||||||
return this.outbox.withTransaction(async (tx) => {
|
return this.outbox.withTransaction(async (tx) => {
|
||||||
const [tenant] = await tx.insert(tenants).values({ name }).returning();
|
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 tx.insert(memberships).values({ tenantId: tenant.id, userId, role: 'owner' });
|
||||||
await this.audit.record(tx, {
|
await this.audit.record(tx, {
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue