feat(projection): wire transactionsUnclassifiedCount to executive dashboard

This commit is contained in:
admin-valentin 2026-07-31 10:46:11 +00:00
parent bdcf63e2a6
commit 281fb9e780

View file

@ -3,10 +3,12 @@ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from '../db/schema';
import {
executiveDashboardProjection,
goals,
organizations,
researchBriefs,
savedSegments,
tasks,
transactions,
} from '../db/schema';
type Tx = NodePgDatabase<typeof schema>;
@ -19,33 +21,17 @@ export interface ProjectionEvent {
createdAt: Date;
}
/**
* rebuildStrategy explica de unde se poate reconstrui proiectia:
* - 'canonical-tables' = recalculeaza din tabelele canonice (mereu corect)
* - 'events' = aplica incremental evenimentele (mai rapid, poate deriva)
* - 'hybrid' = snapshot + evenimente
*/
export interface ProjectionDefinition {
projectionName: string;
version: number;
subscribedEvents: string[];
rebuildStrategy: 'events' | 'canonical-tables' | 'hybrid';
/** Aplica efectul unui eveniment. Trebuie sa fie idempotent. */
apply(tx: Tx, event: ProjectionEvent): Promise<void>;
/** Recalculeaza complet pentru un tenant (folosit de rebuild). */
rebuildTenant(tx: Tx, tenantId: string, workspaceId: string): Promise<void>;
}
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
/**
* Recalculeaza contorii din tabelele canonice pentru un tenant+workspace.
*
* Alegere deliberata: recalculare, nu incrementare. 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 -- acceptabil la volumul actual, de reevaluat cand creste.
*/
async function recomputeExecutiveDashboard(
tx: Tx,
tenantId: string,
@ -60,7 +46,15 @@ async function recomputeExecutiveDashboard(
ne(tasks.status, 'cancelled'),
);
const [openTasks, overdueTasks, upcoming, orgs, segments, briefs] = await Promise.all([
const [
openTasks,
overdueTasks,
upcoming,
orgs,
segments,
briefs,
txUnclassified,
] = await Promise.all([
tx.select({ n: count() }).from(tasks).where(activeTask),
tx.select({ n: count() }).from(tasks).where(and(activeTask, lt(tasks.dueAt, now))),
tx
@ -73,6 +67,16 @@ async function recomputeExecutiveDashboard(
.where(and(eq(organizations.tenantId, tenantId), isNull(organizations.deletedAt))),
tx.select({ n: count() }).from(savedSegments).where(eq(savedSegments.tenantId, tenantId)),
tx.select({ n: count() }).from(researchBriefs).where(eq(researchBriefs.tenantId, tenantId)),
tx
.select({ n: count() })
.from(transactions)
.where(
and(
eq(transactions.tenantId, tenantId),
isNull(transactions.deletedAt),
eq(transactions.evidenceStatus, 'missing'),
),
),
]);
const row = {
@ -84,11 +88,11 @@ async function recomputeExecutiveDashboard(
organizationsCount: orgs[0]?.n ?? 0,
segmentsCount: segments[0]?.n ?? 0,
researchBriefsCount: briefs[0]?.n ?? 0,
transactionsUnclassifiedCount: txUnclassified[0]?.n ?? 0,
generatedAt: new Date(),
projectionVersion: 1,
};
// UPSERT, nu insert orb (spec §5.3).
await tx
.insert(executiveDashboardProjection)
.values(row)
@ -115,11 +119,13 @@ export const EXECUTIVE_DASHBOARD_PROJECTION: ProjectionDefinition = {
'research_brief.created',
'research_brief.deleted',
'user.onboarded',
'transaction.created',
'transaction.evidence_updated',
'goal.created',
'goal.deleted',
],
async apply(tx, event) {
if (!event.workspaceId) {
// Evenimente scrise inainte de Platform Kernel nu au workspace; le sarim
// in loc sa ghicim un workspace si sa scriem intr-o partitie gresita.
return;
}
await recomputeExecutiveDashboard(tx, event.tenantId, event.workspaceId);