ceo-api/src/organizations/organizations.service.ts
valentinbvro 0ea8e69233 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.
2026-07-28 21:15:43 +02:00

124 lines
4.3 KiB
TypeScript

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