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:
valentinbvro 2026-07-28 21:15:43 +02:00
parent 4d7450f910
commit 0ea8e69233
28 changed files with 6218 additions and 8 deletions

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

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View 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,
});
}
}

View 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);

View 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
View 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;
}
}

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

View file

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

View file

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

View file

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

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

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

View 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
View 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;
}

View 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
View 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
View 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
View 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';
}

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

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

View 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;
}
}