ceo-api/src/segments/segments.service.ts
valentinbvro eb652446f6 feat: Sprint 1 -- Projection Kernel (read models pentru dashboard)
Repara criteriul de acceptare "dashboardurile citesc read models, nu
interogheaza haotic toate modulele".

- projection_processed_events (unique: name+version+event_id) = garantia de
  idempotency. Checkpointul e doar optimizare de scanare, nu corectitudine:
  rescanam cu un safety lag de 60s si sarim ce s-a aplicat deja, ca sa nu
  pierdem evenimente comise dupa unul cu created_at mai mare.
- ProjectionRegistry cu ProjectionDefinition (name, version, subscribedEvents,
  rebuildStrategy, apply, rebuildTenant).
- executive_dashboard foloseste rebuildStrategy 'canonical-tables':
  recalculeaza contorii din tabelele canonice, nu incrementeaza. Contorii
  incrementali pot deriva daca un eveniment se pierde sau se dubleaza;
  recalcularea e corecta prin constructie si idempotenta natural. Costul e o
  interogare per eveniment relevant -- ok la volumul actual.
- GET /v1/dashboard/executive citeste proiectia. Cand proiectia inca n-a rulat
  pentru workspace, intoarce status 'building' explicit, NU zerouri care ar
  parea date reale.
- POST /v1/dashboard/executive/rebuild -- owner/admin, auditat.
- Serviciile de taskuri/organizatii/segmente emit acum evenimente in outbox
  (in aceeasi tranzactie cu scrierea). Fara ele proiectia era cod mort.
- Campurile din spec care depind de module neconstruite (documents,
  transactions, approvals) raman 0 explicit, nu inventate.
2026-07-29 12:03:34 +02:00

98 lines
3.3 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { and, desc, eq } from 'drizzle-orm';
import { db } from '../db/client';
import { savedSegments } from '../db/schema';
import { AuditService } from '../audit/audit.service';
import { OutboxService } from '../events/outbox.service';
import { IntelligenceService } from '../intelligence/intelligence.service';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import type { CreateSegmentDto } from './dto';
@Injectable()
export class SegmentsService {
constructor(
private readonly audit: AuditService,
private readonly intelligence: IntelligenceService,
private readonly outbox: OutboxService,
) {}
async list(session: AuthenticatedSession) {
return db.query.savedSegments.findMany({
where: eq(savedSegments.tenantId, session.tenantId),
orderBy: desc(savedSegments.createdAt),
});
}
async create(session: AuthenticatedSession, dto: CreateSegmentDto) {
return this.outbox.withTransaction(async (tx) => {
const [row] = await tx
.insert(savedSegments)
.values({
tenantId: session.tenantId,
createdByUserId: session.userId,
name: dto.name,
query: dto.query,
resultLimit: dto.resultLimit,
})
.returning();
await this.audit.record(tx, {
tenantId: session.tenantId,
actorId: session.userId,
action: 'segment.created',
resource: `segment:${row.id}`,
});
await this.outbox.record(tx, {
tenantId: session.tenantId,
workspaceId: session.workspaceId,
actorId: session.userId,
eventType: 'segment.created',
aggregateType: 'segment',
subjectId: row.id,
correlationId: session.correlationId,
payload: { name: row.name },
});
return row;
});
}
/** Re-ruleaza cautarea salvata contra intelligence-api si intoarce rezultate proaspete. */
async run(session: AuthenticatedSession, id: string) {
const segment = await this.getById(session, id);
const results = await this.intelligence.searchCompanies(segment.query, segment.resultLimit);
return { segment, ...results };
}
async remove(session: AuthenticatedSession, id: string) {
const segment = await this.getById(session, id);
await this.outbox.withTransaction(async (tx) => {
await tx.delete(savedSegments).where(eq(savedSegments.id, segment.id));
await this.audit.record(tx, {
tenantId: session.tenantId,
actorId: session.userId,
action: 'segment.deleted',
resource: `segment:${id}`,
});
await this.outbox.record(tx, {
tenantId: session.tenantId,
workspaceId: session.workspaceId,
actorId: session.userId,
eventType: 'segment.deleted',
aggregateType: 'segment',
subjectId: id,
correlationId: session.correlationId,
payload: {},
});
});
return { deleted: true };
}
private async getById(session: AuthenticatedSession, id: string) {
const segment = await db.query.savedSegments.findFirst({
where: and(eq(savedSegments.id, id), eq(savedSegments.tenantId, session.tenantId)),
});
if (!segment) {
throw new NotFoundException('Segment not found');
}
return segment;
}
}