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.
98 lines
3.3 KiB
TypeScript
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;
|
|
}
|
|
}
|