feat: multi-tenant core -- session auth, RBAC, organizations, tasks, audit
- SessionGuard resolves Supabase JWT (local HS256 verify, GoTrue fallback) and loads the tenant membership from x-tenant-id; TenantGuard keeps deny-by-default and rejects client-supplied tenant_id (blueprint 11.3). - New bootstrap routes: GET /v1/me, POST/GET /v1/tenants, tenant member management (list/add/remove) with owner/admin RBAC. - Organizations and Tasks modules: full CRUD scoped to session.tenantId, soft delete, audit log + outbox events on every write. - AuditService (global) for blueprint 3.4 "100% audit on material ops". - jest + tenant.guard.spec covering deny-by-default and anti-IDOR cases.
This commit is contained in:
parent
4d7450f910
commit
0ea8e69233
28 changed files with 6218 additions and 8 deletions
2
drizzle/0001_lovely_brood.sql
Normal file
2
drizzle/0001_lovely_brood.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
CREATE UNIQUE INDEX IF NOT EXISTS "memberships_tenant_user_uq" ON "memberships" USING btree ("tenant_id","user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "memberships_user_idx" ON "memberships" USING btree ("user_id");
|
||||
1195
drizzle/meta/0001_snapshot.json
Normal file
1195
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,13 @@
|
|||
"when": 1784655760242,
|
||||
"tag": "0000_ancient_silver_surfer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1785264962186,
|
||||
"tag": "0001_lovely_brood",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
7
jest.config.js
Normal file
7
jest.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/*.spec.ts'],
|
||||
};
|
||||
3987
package-lock.json
generated
3987
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -31,6 +31,7 @@
|
|||
"drizzle-orm": "^0.36.0",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"jose": "^6.2.4",
|
||||
"js-yaml": "^5.2.1",
|
||||
"nestjs-pino": "^4.1.0",
|
||||
"pg": "^8.13.0",
|
||||
|
|
@ -42,9 +43,12 @@
|
|||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.11.10",
|
||||
"drizzle-kit": "^0.28.0",
|
||||
"jest": "^30.4.2",
|
||||
"ts-jest": "^29.4.12",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ import { HealthController } from './health/health.controller';
|
|||
import { AuthModule } from './auth/auth.module';
|
||||
import { EventsModule } from './events/events.module';
|
||||
import { AgentNichesModule } from './agent-niches/agent-niches.module';
|
||||
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 { SessionGuard } from './auth/session.guard';
|
||||
import { TenantGuard } from './auth/tenant.guard';
|
||||
|
||||
function parseRedisConnection(redisUrl: string | undefined) {
|
||||
|
|
@ -31,12 +36,19 @@ function parseRedisConnection(redisUrl: string | undefined) {
|
|||
ScheduleModule.forRoot(),
|
||||
BullModule.forRoot({ connection: parseRedisConnection(process.env.REDIS_URL) }),
|
||||
AuthModule,
|
||||
AuditModule,
|
||||
EventsModule,
|
||||
AgentNichesModule,
|
||||
TenantsModule,
|
||||
OrganizationsModule,
|
||||
TasksModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
// Ordinea conteaza: SessionGuard autentifica si rezolva membership-ul,
|
||||
// apoi TenantGuard aplica deny-by-default + anti-override pe tenant_id.
|
||||
{ provide: APP_GUARD, useClass: SessionGuard },
|
||||
{ provide: APP_GUARD, useClass: TenantGuard },
|
||||
],
|
||||
})
|
||||
|
|
|
|||
10
src/audit/audit.module.ts
Normal file
10
src/audit/audit.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
// Global: aproape fiecare modul de domeniu scrie audit; evitam importuri repetate.
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
35
src/audit/audit.service.ts
Normal file
35
src/audit/audit.service.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../db/client';
|
||||
import { auditLog } from '../db/schema';
|
||||
|
||||
type Transaction = Parameters<Parameters<(typeof db)['transaction']>[0]>[0];
|
||||
|
||||
export interface AuditEntry {
|
||||
tenantId: string;
|
||||
actorId: string;
|
||||
action: string;
|
||||
resource: string;
|
||||
reason?: string;
|
||||
correlationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blueprint 3.4: "Audit 100% pentru operatiuni materiale". Serviciile de domeniu
|
||||
* apeleaza explicit record() in aceeasi tranzactie cu scrierea de domeniu --
|
||||
* explicit in loc de interceptor magic, ca sa fie evident in code review ce
|
||||
* operatiuni sunt auditate si ce nu.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
async record(tx: Transaction | null, entry: AuditEntry): Promise<void> {
|
||||
const executor = tx ?? db;
|
||||
await executor.insert(auditLog).values({
|
||||
tenantId: entry.tenantId,
|
||||
actorId: entry.actorId,
|
||||
action: entry.action,
|
||||
resource: entry.resource,
|
||||
reason: entry.reason,
|
||||
correlationId: entry.correlationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
8
src/auth/auth-only.decorator.ts
Normal file
8
src/auth/auth-only.decorator.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_AUTH_ONLY_KEY = 'isAuthOnly';
|
||||
|
||||
// Rute care cer user autentificat dar NU cer un tenant activ (bootstrap:
|
||||
// GET /v1/me, POST /v1/tenants, GET /v1/tenants). Restul rutelor raman
|
||||
// deny-by-default pe tenant prin SessionGuard + TenantGuard.
|
||||
export const AuthOnly = () => SetMetadata(IS_AUTH_ONLY_KEY, true);
|
||||
16
src/auth/session.decorator.ts
Normal file
16
src/auth/session.decorator.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { AuthenticatedSession } from './tenant.guard';
|
||||
|
||||
// Injecteaza sesiunea populata de SessionGuard in handler:
|
||||
// method(@CurrentSession() session: AuthenticatedSession)
|
||||
export const CurrentSession = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): AuthenticatedSession => {
|
||||
const request = context.switchToHttp().getRequest<{ session?: AuthenticatedSession }>();
|
||||
if (!request.session) {
|
||||
// SessionGuard ruleaza inaintea oricarui handler ne-@Public; daca lipseste
|
||||
// sesiunea aici e un bug de configurare, nu o eroare de client.
|
||||
throw new Error('CurrentSession used on a route without SessionGuard');
|
||||
}
|
||||
return request.session;
|
||||
},
|
||||
);
|
||||
110
src/auth/session.guard.ts
Normal file
110
src/auth/session.guard.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { Request } from 'express';
|
||||
import { db } from '../db/client';
|
||||
import { memberships } 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';
|
||||
|
||||
export const TENANT_HEADER = 'x-tenant-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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populeaza request.session din JWT-ul Supabase + membership-ul tenantului activ.
|
||||
*
|
||||
* Ordinea verificarilor (blueprint 4.1: "JWT/session identifica user si membership"):
|
||||
* 1. Bearer token -> user_id. Verificare locala HS256 cu SUPABASE_JWT_SECRET cand
|
||||
* e configurat (fara round-trip); altfel fallback la GoTrue prin supabaseAdmin.
|
||||
* 2. Tenantul activ vine EXCLUSIV din headerul x-tenant-id (niciodata din body/query).
|
||||
* 3. Membership-ul (tenant_id, user_id) e citit din Postgres; rolul intra in sesiune.
|
||||
* Rutele @AuthOnly sar peste pasii 2-3 (bootstrap inainte de a avea un tenant).
|
||||
*/
|
||||
@Injectable()
|
||||
export class SessionGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<RequestWithSession>();
|
||||
const userId = await this.authenticateUser(request);
|
||||
|
||||
const isAuthOnly = this.reflector.getAllAndOverride<boolean>(IS_AUTH_ONLY_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isAuthOnly) {
|
||||
// Sesiune partiala: tenantId ramane gol; TenantGuard e sarit tot prin @AuthOnly.
|
||||
request.session = { userId, tenantId: '', role: 'member' };
|
||||
return true;
|
||||
}
|
||||
|
||||
const tenantId = request.header(TENANT_HEADER);
|
||||
if (!tenantId || !UUID_PATTERN.test(tenantId)) {
|
||||
throw new ForbiddenException(`Missing or invalid ${TENANT_HEADER} header`);
|
||||
}
|
||||
|
||||
const membership = await db.query.memberships.findFirst({
|
||||
where: and(eq(memberships.tenantId, tenantId), eq(memberships.userId, userId)),
|
||||
});
|
||||
if (!membership) {
|
||||
// Acelasi mesaj ca tenant inexistent -- nu confirmam existenta altor tenants (IDOR).
|
||||
throw new ForbiddenException('No membership for the requested tenant');
|
||||
}
|
||||
|
||||
request.session = { userId, tenantId, role: membership.role };
|
||||
return true;
|
||||
}
|
||||
|
||||
private async authenticateUser(request: Request): Promise<string> {
|
||||
const authHeader = request.header('authorization') ?? '';
|
||||
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Missing bearer token');
|
||||
}
|
||||
|
||||
const jwtSecret = process.env.SUPABASE_JWT_SECRET;
|
||||
if (jwtSecret) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, new TextEncoder().encode(jwtSecret), {
|
||||
// GoTrue emite aud="authenticated" pentru useri logati
|
||||
audience: 'authenticated',
|
||||
});
|
||||
if (typeof payload.sub !== 'string' || !UUID_PATTERN.test(payload.sub)) {
|
||||
throw new UnauthorizedException('Invalid token subject');
|
||||
}
|
||||
return payload.sub;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback fara secret local: intreaba GoTrue (mai lent, dar corect)
|
||||
const { data, error } = await supabaseAdmin.auth.getUser(token);
|
||||
if (error || !data.user) {
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
return data.user.id;
|
||||
}
|
||||
}
|
||||
78
src/auth/tenant.guard.spec.ts
Normal file
78
src/auth/tenant.guard.spec.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { ForbiddenException } from '@nestjs/common';
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import type { Reflector } from '@nestjs/core';
|
||||
import { TenantGuard, type AuthenticatedSession } from './tenant.guard';
|
||||
|
||||
interface FakeRequest {
|
||||
session?: AuthenticatedSession;
|
||||
body?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function contextFor(request: FakeRequest, meta: { isPublic?: boolean; isAuthOnly?: boolean } = {}) {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn((key: string) => {
|
||||
if (key === 'isPublic') {
|
||||
return meta.isPublic ?? false;
|
||||
}
|
||||
if (key === 'isAuthOnly') {
|
||||
return meta.isAuthOnly ?? false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
} as unknown as Reflector;
|
||||
|
||||
const context = {
|
||||
getHandler: () => ({}),
|
||||
getClass: () => ({}),
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
return { guard: new TenantGuard(reflector), context };
|
||||
}
|
||||
|
||||
const session: AuthenticatedSession = {
|
||||
userId: '11111111-1111-1111-1111-111111111111',
|
||||
tenantId: '22222222-2222-2222-2222-222222222222',
|
||||
role: 'member',
|
||||
};
|
||||
|
||||
describe('TenantGuard', () => {
|
||||
it('rejects requests without an active tenant session (deny-by-default)', () => {
|
||||
const { guard, context } = contextFor({ body: {}, query: {} });
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('rejects client-supplied tenantId that differs from the session tenant', () => {
|
||||
const { guard, context } = contextFor({
|
||||
session,
|
||||
body: { tenantId: '33333333-3333-3333-3333-333333333333' },
|
||||
query: {},
|
||||
});
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('rejects tenantId smuggled through the query string', () => {
|
||||
const { guard, context } = contextFor({
|
||||
session,
|
||||
body: {},
|
||||
query: { tenantId: '33333333-3333-3333-3333-333333333333' },
|
||||
});
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('allows a request whose session tenant matches', () => {
|
||||
const { guard, context } = contextFor({ session, body: {}, query: {} });
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows public routes without a session', () => {
|
||||
const { guard, context } = contextFor({}, { isPublic: true });
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows auth-only bootstrap routes without a tenant', () => {
|
||||
const { guard, context } = contextFor({}, { isAuthOnly: true });
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@
|
|||
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';
|
||||
|
||||
export interface AuthenticatedSession {
|
||||
userId: string;
|
||||
|
|
@ -33,6 +34,16 @@ export class TenantGuard implements CanActivate {
|
|||
return true;
|
||||
}
|
||||
|
||||
const isAuthOnly = this.reflector.getAllAndOverride<boolean>(IS_AUTH_ONLY_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isAuthOnly) {
|
||||
// Rutele de bootstrap (ex. POST /v1/tenants) au user dar inca nu au tenant;
|
||||
// SessionGuard a validat deja tokenul.
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<RequestWithSession>();
|
||||
const session = request.session;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,15 @@
|
|||
import { pgTable, pgEnum, uuid, text, timestamp, jsonb, integer, numeric } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
pgTable,
|
||||
pgEnum,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
integer,
|
||||
numeric,
|
||||
uniqueIndex,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
// --- Business Engine (blueprint sectiunea 5, 12): companii, tranzactii, documente, taskuri ---
|
||||
// Organizations e sursa de adevar in CEO Postgres pentru companii private (blueprint sectiunea 9);
|
||||
|
|
@ -96,13 +107,22 @@ export const membershipRole = pgEnum('membership_role', ['owner', 'admin', 'memb
|
|||
|
||||
// user_id refera auth.users din Supabase Auth (8.1: "user identity si sessions" = Supabase Auth,
|
||||
// nu duplicam identitatea aici, doar apartenenta la tenant + rol)
|
||||
export const memberships = pgTable('memberships', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
tenantId: uuid('tenant_id').notNull(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
role: membershipRole('role').notNull().default('member'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
export const memberships = pgTable(
|
||||
'memberships',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
tenantId: uuid('tenant_id').notNull(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
role: membershipRole('role').notNull().default('member'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
// un user are cel mult un membership per tenant; lookup-ul din SessionGuard
|
||||
// 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),
|
||||
],
|
||||
);
|
||||
|
||||
export const consentRecords = pgTable('consent_records', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'reflect-metadata';
|
||||
import helmet from 'helmet';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
|
|
@ -9,6 +10,12 @@ async function bootstrap() {
|
|||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||
app.useLogger(app.get(Logger));
|
||||
app.use(helmet());
|
||||
// Contracte API sub /v1 (blueprint 16); health ramane la radacina pentru
|
||||
// healthcheck-urile Coolify existente.
|
||||
app.setGlobalPrefix('v1', { exclude: ['health'] });
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||
);
|
||||
const allowedOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:3000').split(',').map((o) => o.trim());
|
||||
app.enableCors({ origin: allowedOrigins, credentials: true });
|
||||
|
||||
|
|
|
|||
60
src/organizations/dto.ts
Normal file
60
src/organizations/dto.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { IsISO31661Alpha2, IsObject, IsOptional, IsString, Length } from 'class-validator';
|
||||
|
||||
export class CreateOrganizationDto {
|
||||
@IsString()
|
||||
@Length(1, 250)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 250)
|
||||
legalName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO31661Alpha2()
|
||||
country?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 64)
|
||||
registryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 253)
|
||||
domain?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
externalIds?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class UpdateOrganizationDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 250)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 250)
|
||||
legalName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO31661Alpha2()
|
||||
country?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 64)
|
||||
registryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 253)
|
||||
domain?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
externalIds?: Record<string, string>;
|
||||
}
|
||||
45
src/organizations/organizations.controller.ts
Normal file
45
src/organizations/organizations.controller.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import { CurrentSession } from '../auth/session.decorator';
|
||||
import type { AuthenticatedSession } from '../auth/tenant.guard';
|
||||
import { CreateOrganizationDto, UpdateOrganizationDto } from './dto';
|
||||
import { OrganizationsService } from './organizations.service';
|
||||
|
||||
@Controller('organizations')
|
||||
export class OrganizationsController {
|
||||
constructor(private readonly organizationsService: OrganizationsService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentSession() session: AuthenticatedSession) {
|
||||
return this.organizationsService.list(session);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
getById(
|
||||
@CurrentSession() session: AuthenticatedSession,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.organizationsService.getById(session, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateOrganizationDto) {
|
||||
return this.organizationsService.create(session, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentSession() session: AuthenticatedSession,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateOrganizationDto,
|
||||
) {
|
||||
return this.organizationsService.update(session, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
softDelete(
|
||||
@CurrentSession() session: AuthenticatedSession,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.organizationsService.softDelete(session, id);
|
||||
}
|
||||
}
|
||||
11
src/organizations/organizations.module.ts
Normal file
11
src/organizations/organizations.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { EventsModule } from '../events/events.module';
|
||||
import { OrganizationsController } from './organizations.controller';
|
||||
import { OrganizationsService } from './organizations.service';
|
||||
|
||||
@Module({
|
||||
imports: [EventsModule],
|
||||
controllers: [OrganizationsController],
|
||||
providers: [OrganizationsService],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
124
src/organizations/organizations.service.ts
Normal file
124
src/organizations/organizations.service.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { db } from '../db/client';
|
||||
import { organizations } from '../db/schema';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { OutboxService } from '../events/outbox.service';
|
||||
import type { AuthenticatedSession } from '../auth/tenant.guard';
|
||||
import type { CreateOrganizationDto, UpdateOrganizationDto } from './dto';
|
||||
|
||||
/** Domeniile se normalizeaza la stocare (blueprint 11.6): lowercase, fara schema/path. */
|
||||
function normalizeDomain(domain: string | undefined): string | undefined {
|
||||
if (!domain) {
|
||||
return undefined;
|
||||
}
|
||||
return domain
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\/.*$/, '');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsService {
|
||||
constructor(
|
||||
private readonly outbox: OutboxService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async list(session: AuthenticatedSession) {
|
||||
return db.query.organizations.findMany({
|
||||
where: and(eq(organizations.tenantId, session.tenantId), isNull(organizations.deletedAt)),
|
||||
orderBy: desc(organizations.createdAt),
|
||||
});
|
||||
}
|
||||
|
||||
async getById(session: AuthenticatedSession, id: string) {
|
||||
const row = await db.query.organizations.findFirst({
|
||||
where: and(
|
||||
eq(organizations.id, id),
|
||||
eq(organizations.tenantId, session.tenantId),
|
||||
isNull(organizations.deletedAt),
|
||||
),
|
||||
});
|
||||
if (!row) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async create(session: AuthenticatedSession, dto: CreateOrganizationDto) {
|
||||
return this.outbox.withTransaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(organizations)
|
||||
.values({
|
||||
tenantId: session.tenantId,
|
||||
name: dto.name,
|
||||
legalName: dto.legalName,
|
||||
country: dto.country,
|
||||
registryId: dto.registryId,
|
||||
domain: normalizeDomain(dto.domain),
|
||||
externalIds: dto.externalIds ?? {},
|
||||
})
|
||||
.returning();
|
||||
await this.audit.record(tx, {
|
||||
tenantId: session.tenantId,
|
||||
actorId: session.userId,
|
||||
action: 'organization.created',
|
||||
resource: `organization:${row.id}`,
|
||||
});
|
||||
await this.outbox.record(tx, {
|
||||
tenantId: session.tenantId,
|
||||
eventType: 'organization.created',
|
||||
subjectId: row.id,
|
||||
payload: { name: row.name, country: row.country },
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
async update(session: AuthenticatedSession, id: string, dto: UpdateOrganizationDto) {
|
||||
await this.getById(session, id);
|
||||
return this.outbox.withTransaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(organizations)
|
||||
.set({
|
||||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||
...(dto.legalName !== undefined ? { legalName: dto.legalName } : {}),
|
||||
...(dto.country !== undefined ? { country: dto.country } : {}),
|
||||
...(dto.registryId !== undefined ? { registryId: dto.registryId } : {}),
|
||||
...(dto.domain !== undefined ? { domain: normalizeDomain(dto.domain) } : {}),
|
||||
...(dto.externalIds !== undefined ? { externalIds: dto.externalIds } : {}),
|
||||
updatedAt: new Date(),
|
||||
version: sql`${organizations.version} + 1`,
|
||||
})
|
||||
.where(and(eq(organizations.id, id), eq(organizations.tenantId, session.tenantId)))
|
||||
.returning();
|
||||
await this.audit.record(tx, {
|
||||
tenantId: session.tenantId,
|
||||
actorId: session.userId,
|
||||
action: 'organization.updated',
|
||||
resource: `organization:${id}`,
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
/** Soft delete (blueprint 12.1); hard delete doar prin workflow controlat, nu prin API. */
|
||||
async softDelete(session: AuthenticatedSession, id: string) {
|
||||
await this.getById(session, id);
|
||||
await this.outbox.withTransaction(async (tx) => {
|
||||
await tx
|
||||
.update(organizations)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(and(eq(organizations.id, id), eq(organizations.tenantId, session.tenantId)));
|
||||
await this.audit.record(tx, {
|
||||
tenantId: session.tenantId,
|
||||
actorId: session.userId,
|
||||
action: 'organization.deleted',
|
||||
resource: `organization:${id}`,
|
||||
});
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
50
src/tasks/dto.ts
Normal file
50
src/tasks/dto.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { IsIn, IsInt, IsISO8601, IsOptional, IsString, IsUUID, Length, Max, Min } from 'class-validator';
|
||||
|
||||
export const TASK_STATUSES = ['open', 'in_progress', 'blocked', 'done', 'cancelled'] as const;
|
||||
export type TaskStatusValue = (typeof TASK_STATUSES)[number];
|
||||
|
||||
export class CreateTaskDto {
|
||||
@IsString()
|
||||
@Length(1, 500)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
priority?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
dueAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 64)
|
||||
sourceEntityType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
sourceEntityId?: string;
|
||||
}
|
||||
|
||||
export class UpdateTaskDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 500)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
priority?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
dueAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(TASK_STATUSES as unknown as string[])
|
||||
status?: TaskStatusValue;
|
||||
}
|
||||
43
src/tasks/tasks.controller.ts
Normal file
43
src/tasks/tasks.controller.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { CurrentSession } from '../auth/session.decorator';
|
||||
import type { AuthenticatedSession } from '../auth/tenant.guard';
|
||||
import { CreateTaskDto, TASK_STATUSES, UpdateTaskDto, type TaskStatusValue } from './dto';
|
||||
import { TasksService } from './tasks.service';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
@Controller('tasks')
|
||||
export class TasksController {
|
||||
constructor(private readonly tasksService: TasksService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentSession() session: AuthenticatedSession, @Query('status') status?: string) {
|
||||
if (status && !TASK_STATUSES.includes(status as TaskStatusValue)) {
|
||||
throw new BadRequestException(`status must be one of: ${TASK_STATUSES.join(', ')}`);
|
||||
}
|
||||
return this.tasksService.list(session, status as TaskStatusValue | undefined);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
getById(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.tasksService.getById(session, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateTaskDto) {
|
||||
return this.tasksService.create(session, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentSession() session: AuthenticatedSession,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateTaskDto,
|
||||
) {
|
||||
return this.tasksService.update(session, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
softDelete(@CurrentSession() session: AuthenticatedSession, @Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.tasksService.softDelete(session, id);
|
||||
}
|
||||
}
|
||||
11
src/tasks/tasks.module.ts
Normal file
11
src/tasks/tasks.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { EventsModule } from '../events/events.module';
|
||||
import { TasksController } from './tasks.controller';
|
||||
import { TasksService } from './tasks.service';
|
||||
|
||||
@Module({
|
||||
imports: [EventsModule],
|
||||
controllers: [TasksController],
|
||||
providers: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
103
src/tasks/tasks.service.ts
Normal file
103
src/tasks/tasks.service.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { db } from '../db/client';
|
||||
import { tasks } from '../db/schema';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { OutboxService } from '../events/outbox.service';
|
||||
import type { AuthenticatedSession } from '../auth/tenant.guard';
|
||||
import type { CreateTaskDto, TaskStatusValue, UpdateTaskDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class TasksService {
|
||||
constructor(
|
||||
private readonly outbox: OutboxService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async list(session: AuthenticatedSession, status?: TaskStatusValue) {
|
||||
return db.query.tasks.findMany({
|
||||
where: and(
|
||||
eq(tasks.tenantId, session.tenantId),
|
||||
isNull(tasks.deletedAt),
|
||||
...(status ? [eq(tasks.status, status)] : []),
|
||||
),
|
||||
orderBy: [asc(tasks.priority), asc(tasks.dueAt)],
|
||||
});
|
||||
}
|
||||
|
||||
async getById(session: AuthenticatedSession, id: string) {
|
||||
const row = await db.query.tasks.findFirst({
|
||||
where: and(eq(tasks.id, id), eq(tasks.tenantId, session.tenantId), isNull(tasks.deletedAt)),
|
||||
});
|
||||
if (!row) {
|
||||
throw new NotFoundException('Task not found');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async create(session: AuthenticatedSession, dto: CreateTaskDto) {
|
||||
return this.outbox.withTransaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(tasks)
|
||||
.values({
|
||||
tenantId: session.tenantId,
|
||||
ownerUserId: session.userId,
|
||||
title: dto.title,
|
||||
priority: dto.priority ?? 3,
|
||||
dueAt: dto.dueAt ? new Date(dto.dueAt) : undefined,
|
||||
sourceEntityType: dto.sourceEntityType,
|
||||
sourceEntityId: dto.sourceEntityId,
|
||||
})
|
||||
.returning();
|
||||
await this.audit.record(tx, {
|
||||
tenantId: session.tenantId,
|
||||
actorId: session.userId,
|
||||
action: 'task.created',
|
||||
resource: `task:${row.id}`,
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
async update(session: AuthenticatedSession, id: string, dto: UpdateTaskDto) {
|
||||
await this.getById(session, id);
|
||||
return this.outbox.withTransaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(tasks)
|
||||
.set({
|
||||
...(dto.title !== undefined ? { title: dto.title } : {}),
|
||||
...(dto.priority !== undefined ? { priority: dto.priority } : {}),
|
||||
...(dto.dueAt !== undefined ? { dueAt: new Date(dto.dueAt) } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
updatedAt: new Date(),
|
||||
version: sql`${tasks.version} + 1`,
|
||||
})
|
||||
.where(and(eq(tasks.id, id), eq(tasks.tenantId, session.tenantId)))
|
||||
.returning();
|
||||
await this.audit.record(tx, {
|
||||
tenantId: session.tenantId,
|
||||
actorId: session.userId,
|
||||
action: dto.status !== undefined ? `task.status_changed:${dto.status}` : 'task.updated',
|
||||
resource: `task:${id}`,
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
async softDelete(session: AuthenticatedSession, id: string) {
|
||||
await this.getById(session, id);
|
||||
await this.outbox.withTransaction(async (tx) => {
|
||||
await tx
|
||||
.update(tasks)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(and(eq(tasks.id, id), eq(tasks.tenantId, session.tenantId)));
|
||||
await this.audit.record(tx, {
|
||||
tenantId: session.tenantId,
|
||||
actorId: session.userId,
|
||||
action: 'task.deleted',
|
||||
resource: `task:${id}`,
|
||||
});
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
15
src/tenants/dto.ts
Normal file
15
src/tenants/dto.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { IsEmail, IsIn, IsString, Length } from 'class-validator';
|
||||
|
||||
export class CreateTenantDto {
|
||||
@IsString()
|
||||
@Length(2, 120)
|
||||
name!: string;
|
||||
}
|
||||
|
||||
export class AddMemberDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsIn(['admin', 'member'])
|
||||
role!: 'admin' | 'member';
|
||||
}
|
||||
57
src/tenants/tenants.controller.ts
Normal file
57
src/tenants/tenants.controller.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||
import { AuthOnly } from '../auth/auth-only.decorator';
|
||||
import { CurrentSession } from '../auth/session.decorator';
|
||||
import type { AuthenticatedSession } from '../auth/tenant.guard';
|
||||
import { supabaseAdmin } from '../supabase/supabase.client';
|
||||
import { AddMemberDto, CreateTenantDto } from './dto';
|
||||
import { TenantsService } from './tenants.service';
|
||||
|
||||
@Controller()
|
||||
export class TenantsController {
|
||||
constructor(private readonly tenantsService: TenantsService) {}
|
||||
|
||||
/** Bootstrap pentru ceo-web: cine sunt si ce tenants am. */
|
||||
@AuthOnly()
|
||||
@Get('me')
|
||||
async me(@CurrentSession() session: AuthenticatedSession) {
|
||||
const [{ data }, tenants] = await Promise.all([
|
||||
supabaseAdmin.auth.admin.getUserById(session.userId),
|
||||
this.tenantsService.listMyTenants(session.userId),
|
||||
]);
|
||||
return {
|
||||
userId: session.userId,
|
||||
email: data.user?.email ?? null,
|
||||
tenants,
|
||||
};
|
||||
}
|
||||
|
||||
@AuthOnly()
|
||||
@Post('tenants')
|
||||
createTenant(@CurrentSession() session: AuthenticatedSession, @Body() dto: CreateTenantDto) {
|
||||
return this.tenantsService.createTenant(session.userId, dto.name);
|
||||
}
|
||||
|
||||
@AuthOnly()
|
||||
@Get('tenants')
|
||||
listTenants(@CurrentSession() session: AuthenticatedSession) {
|
||||
return this.tenantsService.listMyTenants(session.userId);
|
||||
}
|
||||
|
||||
@Get('tenants/current/members')
|
||||
listMembers(@CurrentSession() session: AuthenticatedSession) {
|
||||
return this.tenantsService.listMembers(session);
|
||||
}
|
||||
|
||||
@Post('tenants/current/members')
|
||||
addMember(@CurrentSession() session: AuthenticatedSession, @Body() dto: AddMemberDto) {
|
||||
return this.tenantsService.addMember(session, dto.email, dto.role);
|
||||
}
|
||||
|
||||
@Delete('tenants/current/members/:userId')
|
||||
removeMember(
|
||||
@CurrentSession() session: AuthenticatedSession,
|
||||
@Param('userId', ParseUUIDPipe) userId: string,
|
||||
) {
|
||||
return this.tenantsService.removeMember(session, userId);
|
||||
}
|
||||
}
|
||||
11
src/tenants/tenants.module.ts
Normal file
11
src/tenants/tenants.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { EventsModule } from '../events/events.module';
|
||||
import { TenantsController } from './tenants.controller';
|
||||
import { TenantsService } from './tenants.service';
|
||||
|
||||
@Module({
|
||||
imports: [EventsModule],
|
||||
controllers: [TenantsController],
|
||||
providers: [TenantsService],
|
||||
})
|
||||
export class TenantsModule {}
|
||||
171
src/tenants/tenants.service.ts
Normal file
171
src/tenants/tenants.service.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { db } from '../db/client';
|
||||
import { memberships, tenants } from '../db/schema';
|
||||
import { supabaseAdmin } from '../supabase/supabase.client';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { OutboxService } from '../events/outbox.service';
|
||||
import type { AuthenticatedSession } from '../auth/tenant.guard';
|
||||
|
||||
export interface TenantMembershipView {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
role: 'owner' | 'admin' | 'member';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TenantsService {
|
||||
constructor(
|
||||
private readonly outbox: OutboxService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
/** Tenant nou + membership owner pentru creator, atomic (blueprint 4: tenant personal la onboarding). */
|
||||
async createTenant(userId: string, name: string): Promise<TenantMembershipView> {
|
||||
return this.outbox.withTransaction(async (tx) => {
|
||||
const [tenant] = await tx.insert(tenants).values({ name }).returning();
|
||||
await tx.insert(memberships).values({ tenantId: tenant.id, userId, role: 'owner' });
|
||||
await this.audit.record(tx, {
|
||||
tenantId: tenant.id,
|
||||
actorId: userId,
|
||||
action: 'tenant.created',
|
||||
resource: `tenant:${tenant.id}`,
|
||||
});
|
||||
await this.outbox.record(tx, {
|
||||
tenantId: tenant.id,
|
||||
eventType: 'user.onboarded',
|
||||
subjectId: tenant.id,
|
||||
payload: { userId, tenantName: name },
|
||||
});
|
||||
return { tenantId: tenant.id, tenantName: tenant.name, role: 'owner' as const };
|
||||
});
|
||||
}
|
||||
|
||||
async listMyTenants(userId: string): Promise<TenantMembershipView[]> {
|
||||
const rows = await db.query.memberships.findMany({ where: eq(memberships.userId, userId) });
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const tenantRows = await db.query.tenants.findMany({
|
||||
where: inArray(tenants.id, rows.map((row) => row.tenantId)),
|
||||
});
|
||||
const nameById = new Map(tenantRows.map((tenant) => [tenant.id, tenant.name]));
|
||||
return rows.map((row) => ({
|
||||
tenantId: row.tenantId,
|
||||
tenantName: nameById.get(row.tenantId) ?? '(unknown)',
|
||||
role: row.role,
|
||||
}));
|
||||
}
|
||||
|
||||
async listMembers(session: AuthenticatedSession) {
|
||||
const rows = await db.query.memberships.findMany({
|
||||
where: eq(memberships.tenantId, session.tenantId),
|
||||
});
|
||||
// Emailurile vin din Supabase Auth (sursa de adevar pentru identity, blueprint 9)
|
||||
const members = await Promise.all(
|
||||
rows.map(async (row) => {
|
||||
const { data } = await supabaseAdmin.auth.admin.getUserById(row.userId);
|
||||
return {
|
||||
userId: row.userId,
|
||||
email: data.user?.email ?? null,
|
||||
role: row.role,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return members;
|
||||
}
|
||||
|
||||
/** Adauga membru dupa email; userul trebuie sa existe deja in Supabase Auth. */
|
||||
async addMember(session: AuthenticatedSession, email: string, role: 'admin' | 'member') {
|
||||
this.requireAdmin(session);
|
||||
|
||||
const userId = await this.findUserIdByEmail(email);
|
||||
if (!userId) {
|
||||
throw new NotFoundException('No account with this email; the user must sign up first');
|
||||
}
|
||||
|
||||
const existing = await db.query.memberships.findFirst({
|
||||
where: and(eq(memberships.tenantId, session.tenantId), eq(memberships.userId, userId)),
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('User is already a member of this tenant');
|
||||
}
|
||||
|
||||
return this.outbox.withTransaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(memberships)
|
||||
.values({ tenantId: session.tenantId, userId, role })
|
||||
.returning();
|
||||
await this.audit.record(tx, {
|
||||
tenantId: session.tenantId,
|
||||
actorId: session.userId,
|
||||
action: 'membership.added',
|
||||
resource: `membership:${row.id}`,
|
||||
reason: `role=${role}`,
|
||||
});
|
||||
return { userId, email, role };
|
||||
});
|
||||
}
|
||||
|
||||
async removeMember(session: AuthenticatedSession, memberUserId: string) {
|
||||
this.requireAdmin(session);
|
||||
if (memberUserId === session.userId) {
|
||||
throw new ForbiddenException('Use tenant transfer/deletion flows to remove yourself');
|
||||
}
|
||||
|
||||
const target = await db.query.memberships.findFirst({
|
||||
where: and(eq(memberships.tenantId, session.tenantId), eq(memberships.userId, memberUserId)),
|
||||
});
|
||||
if (!target) {
|
||||
throw new NotFoundException('Membership not found');
|
||||
}
|
||||
if (target.role === 'owner') {
|
||||
throw new ForbiddenException('Owner membership cannot be removed');
|
||||
}
|
||||
|
||||
await this.outbox.withTransaction(async (tx) => {
|
||||
await tx
|
||||
.delete(memberships)
|
||||
.where(and(eq(memberships.tenantId, session.tenantId), eq(memberships.userId, memberUserId)));
|
||||
await this.audit.record(tx, {
|
||||
tenantId: session.tenantId,
|
||||
actorId: session.userId,
|
||||
action: 'membership.removed',
|
||||
resource: `membership:${target.id}`,
|
||||
});
|
||||
});
|
||||
return { removed: true };
|
||||
}
|
||||
|
||||
private requireAdmin(session: AuthenticatedSession): void {
|
||||
if (session.role !== 'owner' && session.role !== 'admin') {
|
||||
throw new ForbiddenException('Requires owner or admin role');
|
||||
}
|
||||
}
|
||||
|
||||
private async findUserIdByEmail(email: string): Promise<string | null> {
|
||||
// GoTrue admin API nu are lookup direct pe email in versiunea curenta de
|
||||
// supabase-js; paginam (suficient la scara MVP, sub cateva sute de useri).
|
||||
const normalized = email.trim().toLowerCase();
|
||||
for (let page = 1; page <= 10; page += 1) {
|
||||
const { data, error } = await supabaseAdmin.auth.admin.listUsers({ page, perPage: 200 });
|
||||
if (error) {
|
||||
throw new Error(`Supabase admin listUsers failed: ${error.message}`);
|
||||
}
|
||||
const match = data.users.find((user) => user.email?.toLowerCase() === normalized);
|
||||
if (match) {
|
||||
return match.id;
|
||||
}
|
||||
if (data.users.length < 200) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue