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