From 841996d876fa19156d06b77a3abc58cabe1de843 Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:02:27 -0600 Subject: [PATCH 01/23] feat(types): add analytics domain types AnalyticsInput/Query/Scope/Snapshot and every breakdown/series/trend shape, plus AlertRule/AlertBreach, SavedView and ScheduledReportConfig, for issue #44's analytics & BI platform. --- packages/types/src/analytics.ts | 237 ++++++++++++++++++++++++++++ packages/types/types/analytics.d.ts | 197 +++++++++++++++++++++++ packages/types/types/analytics.js | 2 + 3 files changed, 436 insertions(+) create mode 100644 packages/types/src/analytics.ts create mode 100644 packages/types/types/analytics.d.ts create mode 100644 packages/types/types/analytics.js diff --git a/packages/types/src/analytics.ts b/packages/types/src/analytics.ts new file mode 100644 index 0000000..ffb69ec --- /dev/null +++ b/packages/types/src/analytics.ts @@ -0,0 +1,237 @@ +import type { BondStatus, BondToken } from './bond'; +import type { Transfer, TransferStatus } from './transfer'; +import type { MonthlyReport, PeriodCompliance } from './report'; +import type { CountryCode } from './country'; +import type { Role } from './roles'; +import type { TransferLifecycleStep } from './provenance'; + +/** + * Analytics & BI model (issue #44). + * + * `AnalyticsSnapshot` is a pure, deterministic aggregation over bonds, transfers + * and reports — never touching Supabase directly. Like `ProvenanceInput` + * (issue #36), `AnalyticsInput` is shaped so fixtures and the pure engine + * (`apps/api/src/analytics/engine/`) share one contract; the engine never + * imports `Role` or any authorization concept — RBAC scoping happens before + * the input reaches the engine (see `AnalyticsScope`). + */ + +// ─── Inputs & query ──────────────────────────────────────────────────────────── + +/** Raw inputs the aggregation engine consumes. Sourced from Supabase in + * production (mocked in tests); fixture-fed in the engine's own unit tests. */ +export interface AnalyticsInput { + bonds: BondToken[]; + transfers: Transfer[]; + reports: MonthlyReport[]; +} + +export type AnalyticsBucket = 'day' | 'week' | 'month'; + +/** Filters applied to `AnalyticsInput` before aggregation. All optional. */ +export interface AnalyticsQuery { + /** ISO date (inclusive). Filters bonds/transfers/reports by their created/period date. */ + from?: string | null; + /** ISO date (inclusive). */ + to?: string | null; + country?: CountryCode | null; + partyId?: string | null; + status?: BondStatus | TransferStatus | null; + bucket?: AnalyticsBucket; +} + +/** + * Resolved access scope, computed from the caller's role/party BEFORE the + * engine runs. The engine only ever sees `AnalyticsScope`, never `Role` — + * keeps aggregation authz-agnostic, same discipline as the provenance engine. + */ +export type AnalyticsScope = { kind: 'all' } | { kind: 'party'; partyId: string }; + +// ─── Breakdown results ───────────────────────────────────────────────────────── + +export interface BondStatusBreakdown { + status: BondStatus; + count: number; + faceValue: number; +} + +export interface PartyBreakdown { + partyId: string; + bondsCount: number; + emittedValue: number; + salesCount: number; + volumeMoved: number; +} + +export interface CountryBreakdown { + country: CountryCode; + bondsCount: number; + emittedValue: number; + salesCount: number; + volumeMoved: number; +} + +export interface ValueVolumeAggregate { + totalBonds: number; + totalEmittedValue: number; + totalTransfers: number; + totalSales: number; + totalVolumeMoved: number; +} + +// ─── Transfer funnel ──────────────────────────────────────────────────────────── + +export interface TransferFunnelStage { + step: TransferLifecycleStep; + /** Count of transfers whose current status is at or past this step. */ + reachedCount: number; + /** % of `totalStarted` that reached this step. */ + conversionFromStartPct: number; + /** % drop from the previous step to this one (0 for the first step). */ + dropOffPct: number; +} + +export interface TransferFunnel { + totalStarted: number; + stages: TransferFunnelStage[]; + rejectedCount: number; + cancelledCount: number; + completedCount: number; +} + +// ─── Time series & trends ────────────────────────────────────────────────────── + +export interface TimeSeriesPoint { + /** ISO date of the bucket's start (UTC, YYYY-MM-DD). */ + bucketStart: string; + value: number; + count: number; +} + +export interface TrendDelta { + current: number; + previous: number; + deltaAbs: number; + /** null when `previous` is 0 (undefined percentage change). */ + deltaPct: number | null; +} + +export interface MovingAveragePoint { + bucketStart: string; + average: number; +} + +export interface TopNEntry { + key: string; + label: string; + value: number; +} + +// ─── Compliance ───────────────────────────────────────────────────────────────── + +export interface PartyComplianceSummary { + partyId: string; + periods: PeriodCompliance[]; + onTimeCount: number; + lateCount: number; + overdueCount: number; + missingCount: number; +} + +export interface ComplianceSummary { + parties: PartyComplianceSummary[]; +} + +// ─── Root snapshot ────────────────────────────────────────────────────────────── + +export interface AnalyticsSnapshot { + query: AnalyticsQuery; + scope: AnalyticsScope; + valueVolume: ValueVolumeAggregate; + bondStatusBreakdown: BondStatusBreakdown[]; + partyBreakdown: PartyBreakdown[]; + countryBreakdown: CountryBreakdown[]; + funnel: TransferFunnel; + issuanceSeries: TimeSeriesPoint[]; + transferSeries: TimeSeriesPoint[]; + escrowResolutionSeries: TimeSeriesPoint[]; + compliance: ComplianceSummary; + topBonds: TopNEntry[]; + /** ISO-8601 timestamp of when the snapshot was computed. */ + generatedAt: string; +} + +// ─── Threshold alerting ───────────────────────────────────────────────────────── + +export type AlertComparator = 'gt' | 'lt' | 'gte' | 'lte'; + +export interface AlertRule { + id: string; + name: string; + /** Dot-path into `AnalyticsSnapshot`, e.g. "valueVolume.totalVolumeMoved". */ + metricPath: string; + comparator: AlertComparator; + threshold: number; + scope: AnalyticsScope; + notifyUserIds: string[]; + createdAt: string; + updatedAt: string; +} + +/** Payload for creating/editing a rule (no id/timestamps). */ +export interface AlertRuleInput { + name: string; + metricPath: string; + comparator: AlertComparator; + threshold: number; + scope: AnalyticsScope; + notifyUserIds: string[]; +} + +export interface AlertBreach { + ruleId: string; + ruleName: string; + metricPath: string; + value: number; + threshold: number; + comparator: AlertComparator; + at: string; +} + +// ─── Saved views ──────────────────────────────────────────────────────────────── + +export interface SavedView { + id: string; + ownerId: string; + role: Role; + name: string; + query: AnalyticsQuery; + createdAt: string; + updatedAt: string; +} + +/** Payload for creating a saved view (no id/owner/timestamps). */ +export interface SavedViewInput { + name: string; + query: AnalyticsQuery; +} + +// ─── Scheduled report generation (interface + stub, no vendor) ───────────────── + +export type ScheduledReportCadence = 'weekly' | 'monthly'; +export type ScheduledReportFormat = 'csv' | 'pdf'; + +export interface ScheduledReportConfig { + id: string; + cadence: ScheduledReportCadence; + format: ScheduledReportFormat; + scope: AnalyticsScope; + recipients: string[]; +} + +export interface ScheduledReportResult { + filename: string; + mimeType: string; + encoding: 'utf-8' | 'base64'; + content: string; +} diff --git a/packages/types/types/analytics.d.ts b/packages/types/types/analytics.d.ts new file mode 100644 index 0000000..4be2528 --- /dev/null +++ b/packages/types/types/analytics.d.ts @@ -0,0 +1,197 @@ +import type { BondStatus, BondToken } from './bond'; +import type { Transfer, TransferStatus } from './transfer'; +import type { MonthlyReport, PeriodCompliance } from './report'; +import type { CountryCode } from './country'; +import type { Role } from './roles'; +import type { TransferLifecycleStep } from './provenance'; +/** + * Analytics & BI model (issue #44). + * + * `AnalyticsSnapshot` is a pure, deterministic aggregation over bonds, transfers + * and reports — never touching Supabase directly. Like `ProvenanceInput` + * (issue #36), `AnalyticsInput` is shaped so fixtures and the pure engine + * (`apps/api/src/analytics/engine/`) share one contract; the engine never + * imports `Role` or any authorization concept — RBAC scoping happens before + * the input reaches the engine (see `AnalyticsScope`). + */ +/** Raw inputs the aggregation engine consumes. Sourced from Supabase in + * production (mocked in tests); fixture-fed in the engine's own unit tests. */ +export interface AnalyticsInput { + bonds: BondToken[]; + transfers: Transfer[]; + reports: MonthlyReport[]; +} +export type AnalyticsBucket = 'day' | 'week' | 'month'; +/** Filters applied to `AnalyticsInput` before aggregation. All optional. */ +export interface AnalyticsQuery { + /** ISO date (inclusive). Filters bonds/transfers/reports by their created/period date. */ + from?: string | null; + /** ISO date (inclusive). */ + to?: string | null; + country?: CountryCode | null; + partyId?: string | null; + status?: BondStatus | TransferStatus | null; + bucket?: AnalyticsBucket; +} +/** + * Resolved access scope, computed from the caller's role/party BEFORE the + * engine runs. The engine only ever sees `AnalyticsScope`, never `Role` — + * keeps aggregation authz-agnostic, same discipline as the provenance engine. + */ +export type AnalyticsScope = { + kind: 'all'; +} | { + kind: 'party'; + partyId: string; +}; +export interface BondStatusBreakdown { + status: BondStatus; + count: number; + faceValue: number; +} +export interface PartyBreakdown { + partyId: string; + bondsCount: number; + emittedValue: number; + salesCount: number; + volumeMoved: number; +} +export interface CountryBreakdown { + country: CountryCode; + bondsCount: number; + emittedValue: number; + salesCount: number; + volumeMoved: number; +} +export interface ValueVolumeAggregate { + totalBonds: number; + totalEmittedValue: number; + totalTransfers: number; + totalSales: number; + totalVolumeMoved: number; +} +export interface TransferFunnelStage { + step: TransferLifecycleStep; + /** Count of transfers whose current status is at or past this step. */ + reachedCount: number; + /** % of `totalStarted` that reached this step. */ + conversionFromStartPct: number; + /** % drop from the previous step to this one (0 for the first step). */ + dropOffPct: number; +} +export interface TransferFunnel { + totalStarted: number; + stages: TransferFunnelStage[]; + rejectedCount: number; + cancelledCount: number; + completedCount: number; +} +export interface TimeSeriesPoint { + /** ISO date of the bucket's start (UTC, YYYY-MM-DD). */ + bucketStart: string; + value: number; + count: number; +} +export interface TrendDelta { + current: number; + previous: number; + deltaAbs: number; + /** null when `previous` is 0 (undefined percentage change). */ + deltaPct: number | null; +} +export interface MovingAveragePoint { + bucketStart: string; + average: number; +} +export interface TopNEntry { + key: string; + label: string; + value: number; +} +export interface PartyComplianceSummary { + partyId: string; + periods: PeriodCompliance[]; + onTimeCount: number; + lateCount: number; + overdueCount: number; + missingCount: number; +} +export interface ComplianceSummary { + parties: PartyComplianceSummary[]; +} +export interface AnalyticsSnapshot { + query: AnalyticsQuery; + scope: AnalyticsScope; + valueVolume: ValueVolumeAggregate; + bondStatusBreakdown: BondStatusBreakdown[]; + partyBreakdown: PartyBreakdown[]; + countryBreakdown: CountryBreakdown[]; + funnel: TransferFunnel; + issuanceSeries: TimeSeriesPoint[]; + transferSeries: TimeSeriesPoint[]; + escrowResolutionSeries: TimeSeriesPoint[]; + compliance: ComplianceSummary; + topBonds: TopNEntry[]; + /** ISO-8601 timestamp of when the snapshot was computed. */ + generatedAt: string; +} +export type AlertComparator = 'gt' | 'lt' | 'gte' | 'lte'; +export interface AlertRule { + id: string; + name: string; + /** Dot-path into `AnalyticsSnapshot`, e.g. "valueVolume.totalVolumeMoved". */ + metricPath: string; + comparator: AlertComparator; + threshold: number; + scope: AnalyticsScope; + notifyUserIds: string[]; + createdAt: string; + updatedAt: string; +} +/** Payload for creating/editing a rule (no id/timestamps). */ +export interface AlertRuleInput { + name: string; + metricPath: string; + comparator: AlertComparator; + threshold: number; + scope: AnalyticsScope; + notifyUserIds: string[]; +} +export interface AlertBreach { + ruleId: string; + ruleName: string; + metricPath: string; + value: number; + threshold: number; + comparator: AlertComparator; + at: string; +} +export interface SavedView { + id: string; + ownerId: string; + role: Role; + name: string; + query: AnalyticsQuery; + createdAt: string; + updatedAt: string; +} +/** Payload for creating a saved view (no id/owner/timestamps). */ +export interface SavedViewInput { + name: string; + query: AnalyticsQuery; +} +export type ScheduledReportCadence = 'weekly' | 'monthly'; +export type ScheduledReportFormat = 'csv' | 'pdf'; +export interface ScheduledReportConfig { + id: string; + cadence: ScheduledReportCadence; + format: ScheduledReportFormat; + scope: AnalyticsScope; + recipients: string[]; +} +export interface ScheduledReportResult { + filename: string; + mimeType: string; + encoding: 'utf-8' | 'base64'; + content: string; +} diff --git a/packages/types/types/analytics.js b/packages/types/types/analytics.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/packages/types/types/analytics.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); From 7a1cdd9a5dfd4b9924df7a5ac2cbbe920597d791 Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:02:35 -0600 Subject: [PATCH 02/23] feat(types): add deterministic analytics fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three parties across two countries, bonds spanning several statuses, transfers covering the full transfer funnel, and reports spanning on-time/late/missing compliance outcomes — same discipline as fixtures/provenance.ts, so the engine and API can be built and tested with no VELAR database or credentials. --- packages/types/src/fixtures/analytics.ts | 299 +++++++++++++++++++ packages/types/types/fixtures/analytics.d.ts | 22 ++ packages/types/types/fixtures/analytics.js | 292 ++++++++++++++++++ 3 files changed, 613 insertions(+) create mode 100644 packages/types/src/fixtures/analytics.ts create mode 100644 packages/types/types/fixtures/analytics.d.ts create mode 100644 packages/types/types/fixtures/analytics.js diff --git a/packages/types/src/fixtures/analytics.ts b/packages/types/src/fixtures/analytics.ts new file mode 100644 index 0000000..809b5f8 --- /dev/null +++ b/packages/types/src/fixtures/analytics.ts @@ -0,0 +1,299 @@ +import type { AnalyticsInput } from '../analytics'; + +/** + * Development/testing fixture for the analytics engine (issue #44). + * + * Three parties across two countries, bonds spanning several statuses, + * transfers covering the full funnel (including a rejection and a + * cancellation), and reports spanning on-time/late/missing compliance + * outcomes. NOT production data — exists so the engine, API and UI can be + * built and tested locally with no VELAR database, secrets or external APIs. + * + * Edge cases (empty dataset, single-item dataset, sparse periods) are derived + * in test files by deep-cloning and mutating this base fixture — same + * discipline as `fixtures/provenance.ts`. + */ + +const PARTY_LIBERTAD = 'party-libertad-fixture'; +const PARTY_RENOVACION = 'party-renovacion-fixture'; +const PARTY_AVANZA = 'party-avanza-fixture'; + +const BUYER_1 = 'buyer-juan-fixture'; +const BUYER_2 = 'buyer-maria-fixture'; +const BUYER_3 = 'buyer-carlos-fixture'; + +const TSE = 'tse-authority-fixture'; + +export const analyticsFixture: AnalyticsInput = { + bonds: [ + { + tokenId: 'bond-token-a1', + bondId: 'BOND-2026-A1', + issuerPartyId: PARTY_LIBERTAD, + country: 'CR', + currentOwner: BUYER_1, + status: 'transferido', + documentHash: 'sha256-bonddoc-a1', + faceValue: 1_000_000, + currency: 'CRC', + createdAt: '2026-01-10T09:00:00.000Z', + updatedAt: '2026-02-15T12:00:00.000Z', + }, + { + tokenId: 'bond-token-a2', + bondId: 'BOND-2026-A2', + issuerPartyId: PARTY_LIBERTAD, + country: 'CR', + currentOwner: PARTY_LIBERTAD, + status: 'activo', + documentHash: 'sha256-bonddoc-a2', + faceValue: 500_000, + currency: 'CRC', + createdAt: '2026-02-01T09:00:00.000Z', + updatedAt: '2026-02-01T09:00:00.000Z', + }, + { + tokenId: 'bond-token-a3', + bondId: 'BOND-2026-A3', + issuerPartyId: PARTY_LIBERTAD, + country: 'CR', + currentOwner: PARTY_LIBERTAD, + status: 'en_escrow', + documentHash: 'sha256-bonddoc-a3', + faceValue: 750_000, + currency: 'CRC', + createdAt: '2026-03-01T09:00:00.000Z', + updatedAt: '2026-03-10T09:00:00.000Z', + }, + { + tokenId: 'bond-token-b1', + bondId: 'BOND-2026-B1', + issuerPartyId: PARTY_RENOVACION, + country: 'CR', + currentOwner: BUYER_2, + status: 'transferido', + documentHash: 'sha256-bonddoc-b1', + faceValue: 1_200_000, + currency: 'CRC', + createdAt: '2026-01-20T09:00:00.000Z', + updatedAt: '2026-03-01T12:00:00.000Z', + }, + { + tokenId: 'bond-token-b2', + bondId: 'BOND-2026-B2', + issuerPartyId: PARTY_RENOVACION, + country: 'CR', + currentOwner: PARTY_RENOVACION, + status: 'cancelado', + documentHash: 'sha256-bonddoc-b2', + faceValue: 300_000, + currency: 'CRC', + createdAt: '2026-02-10T09:00:00.000Z', + updatedAt: '2026-02-20T09:00:00.000Z', + }, + { + tokenId: 'bond-token-c1', + bondId: 'BOND-2026-C1', + issuerPartyId: PARTY_AVANZA, + country: 'CO', + currentOwner: PARTY_AVANZA, + status: 'emitido', + documentHash: 'sha256-bonddoc-c1', + faceValue: 8_000_000, + currency: 'COP', + createdAt: '2026-04-01T09:00:00.000Z', + updatedAt: '2026-04-01T09:00:00.000Z', + }, + { + tokenId: 'bond-token-c2', + bondId: 'BOND-2026-C2', + issuerPartyId: PARTY_AVANZA, + country: 'CO', + currentOwner: BUYER_3, + status: 'transferido', + documentHash: 'sha256-bonddoc-c2', + faceValue: 5_000_000, + currency: 'COP', + createdAt: '2026-04-05T09:00:00.000Z', + updatedAt: '2026-05-01T12:00:00.000Z', + }, + ], + + transfers: [ + { + id: 'transfer-a1-1', + bondTokenId: 'bond-token-a1', + fromOwner: PARTY_LIBERTAD, + toOwner: BUYER_1, + status: 'liberada', + amount: 1_050_000, + createdAt: '2026-01-15T10:00:00.000Z', + updatedAt: '2026-02-15T12:00:00.000Z', + }, + { + id: 'transfer-b1-1', + bondTokenId: 'bond-token-b1', + fromOwner: PARTY_RENOVACION, + toOwner: BUYER_2, + status: 'liberada', + amount: 1_250_000, + createdAt: '2026-01-25T10:00:00.000Z', + updatedAt: '2026-03-01T12:00:00.000Z', + }, + { + id: 'transfer-c2-1', + bondTokenId: 'bond-token-c2', + fromOwner: PARTY_AVANZA, + toOwner: BUYER_3, + status: 'liberada', + amount: 5_200_000, + createdAt: '2026-04-10T10:00:00.000Z', + updatedAt: '2026-05-01T12:00:00.000Z', + }, + { + id: 'transfer-a3-1', + bondTokenId: 'bond-token-a3', + fromOwner: PARTY_LIBERTAD, + toOwner: BUYER_1, + status: 'en_escrow', + amount: 780_000, + createdAt: '2026-03-05T10:00:00.000Z', + updatedAt: '2026-03-10T09:00:00.000Z', + }, + { + id: 'transfer-a2-1', + bondTokenId: 'bond-token-a2', + fromOwner: PARTY_LIBERTAD, + toOwner: BUYER_2, + status: 'pago_registrado', + amount: 520_000, + createdAt: '2026-03-15T10:00:00.000Z', + updatedAt: '2026-03-20T10:00:00.000Z', + }, + { + id: 'transfer-b2-1', + bondTokenId: 'bond-token-b2', + fromOwner: PARTY_RENOVACION, + toOwner: BUYER_2, + status: 'cancelada', + amount: 310_000, + createdAt: '2026-02-12T10:00:00.000Z', + updatedAt: '2026-02-18T10:00:00.000Z', + }, + { + id: 'transfer-a1-2', + bondTokenId: 'bond-token-a1', + fromOwner: BUYER_1, + toOwner: BUYER_2, + status: 'rechazada', + amount: 1_100_000, + createdAt: '2026-02-20T10:00:00.000Z', + updatedAt: '2026-02-22T10:00:00.000Z', + }, + { + id: 'transfer-c1-1', + bondTokenId: 'bond-token-c1', + fromOwner: PARTY_AVANZA, + toOwner: BUYER_3, + status: 'solicitada', + amount: 8_200_000, + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + { + id: 'transfer-a2-2', + bondTokenId: 'bond-token-a2', + fromOwner: PARTY_LIBERTAD, + toOwner: BUYER_3, + status: 'contraoferta', + amount: 540_000, + createdAt: '2026-05-01T10:00:00.000Z', + updatedAt: '2026-05-03T10:00:00.000Z', + }, + ], + + reports: [ + { + id: 'report-libertad-2026-01', + partyId: PARTY_LIBERTAD, + periodYear: 2026, + periodMonth: 1, + status: 'aprobado', + currentVersion: 1, + title: 'Reporte enero 2026', + submittedBy: 'user-libertad-oficial-fixture', + submittedAt: '2026-02-10T12:00:00.000Z', + reviewedBy: TSE, + reviewedAt: '2026-02-12T09:00:00.000Z', + tseNotes: null, + createdAt: '2026-02-01T09:00:00.000Z', + updatedAt: '2026-02-12T09:00:00.000Z', + }, + { + id: 'report-libertad-2026-02', + partyId: PARTY_LIBERTAD, + periodYear: 2026, + periodMonth: 2, + status: 'aprobado', + currentVersion: 1, + title: 'Reporte febrero 2026', + submittedBy: 'user-libertad-oficial-fixture', + submittedAt: '2026-03-20T12:00:00.000Z', + reviewedBy: TSE, + reviewedAt: '2026-03-22T09:00:00.000Z', + tseNotes: null, + createdAt: '2026-03-01T09:00:00.000Z', + updatedAt: '2026-03-22T09:00:00.000Z', + }, + { + id: 'report-libertad-2026-03', + partyId: PARTY_LIBERTAD, + periodYear: 2026, + periodMonth: 3, + status: 'borrador', + currentVersion: 0, + title: 'Reporte marzo 2026', + submittedBy: null, + submittedAt: null, + reviewedBy: null, + reviewedAt: null, + tseNotes: null, + createdAt: '2026-04-01T09:00:00.000Z', + updatedAt: '2026-04-01T09:00:00.000Z', + }, + { + id: 'report-renovacion-2026-01', + partyId: PARTY_RENOVACION, + periodYear: 2026, + periodMonth: 1, + status: 'aprobado', + currentVersion: 1, + title: 'Reporte enero 2026', + submittedBy: 'user-renovacion-oficial-fixture', + submittedAt: '2026-02-08T09:00:00.000Z', + reviewedBy: TSE, + reviewedAt: '2026-02-09T09:00:00.000Z', + tseNotes: null, + createdAt: '2026-02-01T09:00:00.000Z', + updatedAt: '2026-02-09T09:00:00.000Z', + }, + ], +}; + +/** Stable identifiers referenced by tests. */ +export const analyticsFixtureIds = { + parties: { + libertad: PARTY_LIBERTAD, + renovacion: PARTY_RENOVACION, + avanza: PARTY_AVANZA, + }, + buyers: { buyer1: BUYER_1, buyer2: BUYER_2, buyer3: BUYER_3 }, + tse: TSE, +}; + +/** + * Reference "now" for deterministic compliance/time-series tests. Chosen so + * `report-libertad-2026-03` (due 2026-04-15, +5 days grace) is clearly past + * its grace period, i.e. `missing`. + */ +export const analyticsFixtureNow = '2026-07-01T00:00:00.000Z'; diff --git a/packages/types/types/fixtures/analytics.d.ts b/packages/types/types/fixtures/analytics.d.ts new file mode 100644 index 0000000..f5cedb0 --- /dev/null +++ b/packages/types/types/fixtures/analytics.d.ts @@ -0,0 +1,22 @@ +import type { AnalyticsInput } from '../analytics'; +export declare const analyticsFixture: AnalyticsInput; +/** Stable identifiers referenced by tests. */ +export declare const analyticsFixtureIds: { + parties: { + libertad: string; + renovacion: string; + avanza: string; + }; + buyers: { + buyer1: string; + buyer2: string; + buyer3: string; + }; + tse: string; +}; +/** + * Reference "now" for deterministic compliance/time-series tests. Chosen so + * `report-libertad-2026-03` (due 2026-04-15, +5 days grace) is clearly past + * its grace period, i.e. `missing`. + */ +export declare const analyticsFixtureNow = "2026-07-01T00:00:00.000Z"; diff --git a/packages/types/types/fixtures/analytics.js b/packages/types/types/fixtures/analytics.js new file mode 100644 index 0000000..a95aa2e --- /dev/null +++ b/packages/types/types/fixtures/analytics.js @@ -0,0 +1,292 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.analyticsFixtureNow = exports.analyticsFixtureIds = exports.analyticsFixture = void 0; +/** + * Development/testing fixture for the analytics engine (issue #44). + * + * Three parties across two countries, bonds spanning several statuses, + * transfers covering the full funnel (including a rejection and a + * cancellation), and reports spanning on-time/late/missing compliance + * outcomes. NOT production data — exists so the engine, API and UI can be + * built and tested locally with no VELAR database, secrets or external APIs. + * + * Edge cases (empty dataset, single-item dataset, sparse periods) are derived + * in test files by deep-cloning and mutating this base fixture — same + * discipline as `fixtures/provenance.ts`. + */ +const PARTY_LIBERTAD = 'party-libertad-fixture'; +const PARTY_RENOVACION = 'party-renovacion-fixture'; +const PARTY_AVANZA = 'party-avanza-fixture'; +const BUYER_1 = 'buyer-juan-fixture'; +const BUYER_2 = 'buyer-maria-fixture'; +const BUYER_3 = 'buyer-carlos-fixture'; +const TSE = 'tse-authority-fixture'; +exports.analyticsFixture = { + bonds: [ + { + tokenId: 'bond-token-a1', + bondId: 'BOND-2026-A1', + issuerPartyId: PARTY_LIBERTAD, + country: 'CR', + currentOwner: BUYER_1, + status: 'transferido', + documentHash: 'sha256-bonddoc-a1', + faceValue: 1000000, + currency: 'CRC', + createdAt: '2026-01-10T09:00:00.000Z', + updatedAt: '2026-02-15T12:00:00.000Z', + }, + { + tokenId: 'bond-token-a2', + bondId: 'BOND-2026-A2', + issuerPartyId: PARTY_LIBERTAD, + country: 'CR', + currentOwner: PARTY_LIBERTAD, + status: 'activo', + documentHash: 'sha256-bonddoc-a2', + faceValue: 500000, + currency: 'CRC', + createdAt: '2026-02-01T09:00:00.000Z', + updatedAt: '2026-02-01T09:00:00.000Z', + }, + { + tokenId: 'bond-token-a3', + bondId: 'BOND-2026-A3', + issuerPartyId: PARTY_LIBERTAD, + country: 'CR', + currentOwner: PARTY_LIBERTAD, + status: 'en_escrow', + documentHash: 'sha256-bonddoc-a3', + faceValue: 750000, + currency: 'CRC', + createdAt: '2026-03-01T09:00:00.000Z', + updatedAt: '2026-03-10T09:00:00.000Z', + }, + { + tokenId: 'bond-token-b1', + bondId: 'BOND-2026-B1', + issuerPartyId: PARTY_RENOVACION, + country: 'CR', + currentOwner: BUYER_2, + status: 'transferido', + documentHash: 'sha256-bonddoc-b1', + faceValue: 1200000, + currency: 'CRC', + createdAt: '2026-01-20T09:00:00.000Z', + updatedAt: '2026-03-01T12:00:00.000Z', + }, + { + tokenId: 'bond-token-b2', + bondId: 'BOND-2026-B2', + issuerPartyId: PARTY_RENOVACION, + country: 'CR', + currentOwner: PARTY_RENOVACION, + status: 'cancelado', + documentHash: 'sha256-bonddoc-b2', + faceValue: 300000, + currency: 'CRC', + createdAt: '2026-02-10T09:00:00.000Z', + updatedAt: '2026-02-20T09:00:00.000Z', + }, + { + tokenId: 'bond-token-c1', + bondId: 'BOND-2026-C1', + issuerPartyId: PARTY_AVANZA, + country: 'CO', + currentOwner: PARTY_AVANZA, + status: 'emitido', + documentHash: 'sha256-bonddoc-c1', + faceValue: 8000000, + currency: 'COP', + createdAt: '2026-04-01T09:00:00.000Z', + updatedAt: '2026-04-01T09:00:00.000Z', + }, + { + tokenId: 'bond-token-c2', + bondId: 'BOND-2026-C2', + issuerPartyId: PARTY_AVANZA, + country: 'CO', + currentOwner: BUYER_3, + status: 'transferido', + documentHash: 'sha256-bonddoc-c2', + faceValue: 5000000, + currency: 'COP', + createdAt: '2026-04-05T09:00:00.000Z', + updatedAt: '2026-05-01T12:00:00.000Z', + }, + ], + transfers: [ + { + id: 'transfer-a1-1', + bondTokenId: 'bond-token-a1', + fromOwner: PARTY_LIBERTAD, + toOwner: BUYER_1, + status: 'liberada', + amount: 1050000, + createdAt: '2026-01-15T10:00:00.000Z', + updatedAt: '2026-02-15T12:00:00.000Z', + }, + { + id: 'transfer-b1-1', + bondTokenId: 'bond-token-b1', + fromOwner: PARTY_RENOVACION, + toOwner: BUYER_2, + status: 'liberada', + amount: 1250000, + createdAt: '2026-01-25T10:00:00.000Z', + updatedAt: '2026-03-01T12:00:00.000Z', + }, + { + id: 'transfer-c2-1', + bondTokenId: 'bond-token-c2', + fromOwner: PARTY_AVANZA, + toOwner: BUYER_3, + status: 'liberada', + amount: 5200000, + createdAt: '2026-04-10T10:00:00.000Z', + updatedAt: '2026-05-01T12:00:00.000Z', + }, + { + id: 'transfer-a3-1', + bondTokenId: 'bond-token-a3', + fromOwner: PARTY_LIBERTAD, + toOwner: BUYER_1, + status: 'en_escrow', + amount: 780000, + createdAt: '2026-03-05T10:00:00.000Z', + updatedAt: '2026-03-10T09:00:00.000Z', + }, + { + id: 'transfer-a2-1', + bondTokenId: 'bond-token-a2', + fromOwner: PARTY_LIBERTAD, + toOwner: BUYER_2, + status: 'pago_registrado', + amount: 520000, + createdAt: '2026-03-15T10:00:00.000Z', + updatedAt: '2026-03-20T10:00:00.000Z', + }, + { + id: 'transfer-b2-1', + bondTokenId: 'bond-token-b2', + fromOwner: PARTY_RENOVACION, + toOwner: BUYER_2, + status: 'cancelada', + amount: 310000, + createdAt: '2026-02-12T10:00:00.000Z', + updatedAt: '2026-02-18T10:00:00.000Z', + }, + { + id: 'transfer-a1-2', + bondTokenId: 'bond-token-a1', + fromOwner: BUYER_1, + toOwner: BUYER_2, + status: 'rechazada', + amount: 1100000, + createdAt: '2026-02-20T10:00:00.000Z', + updatedAt: '2026-02-22T10:00:00.000Z', + }, + { + id: 'transfer-c1-1', + bondTokenId: 'bond-token-c1', + fromOwner: PARTY_AVANZA, + toOwner: BUYER_3, + status: 'solicitada', + amount: 8200000, + createdAt: '2026-04-15T10:00:00.000Z', + updatedAt: '2026-04-15T10:00:00.000Z', + }, + { + id: 'transfer-a2-2', + bondTokenId: 'bond-token-a2', + fromOwner: PARTY_LIBERTAD, + toOwner: BUYER_3, + status: 'contraoferta', + amount: 540000, + createdAt: '2026-05-01T10:00:00.000Z', + updatedAt: '2026-05-03T10:00:00.000Z', + }, + ], + reports: [ + { + id: 'report-libertad-2026-01', + partyId: PARTY_LIBERTAD, + periodYear: 2026, + periodMonth: 1, + status: 'aprobado', + currentVersion: 1, + title: 'Reporte enero 2026', + submittedBy: 'user-libertad-oficial-fixture', + submittedAt: '2026-02-10T12:00:00.000Z', + reviewedBy: TSE, + reviewedAt: '2026-02-12T09:00:00.000Z', + tseNotes: null, + createdAt: '2026-02-01T09:00:00.000Z', + updatedAt: '2026-02-12T09:00:00.000Z', + }, + { + id: 'report-libertad-2026-02', + partyId: PARTY_LIBERTAD, + periodYear: 2026, + periodMonth: 2, + status: 'aprobado', + currentVersion: 1, + title: 'Reporte febrero 2026', + submittedBy: 'user-libertad-oficial-fixture', + submittedAt: '2026-03-20T12:00:00.000Z', + reviewedBy: TSE, + reviewedAt: '2026-03-22T09:00:00.000Z', + tseNotes: null, + createdAt: '2026-03-01T09:00:00.000Z', + updatedAt: '2026-03-22T09:00:00.000Z', + }, + { + id: 'report-libertad-2026-03', + partyId: PARTY_LIBERTAD, + periodYear: 2026, + periodMonth: 3, + status: 'borrador', + currentVersion: 0, + title: 'Reporte marzo 2026', + submittedBy: null, + submittedAt: null, + reviewedBy: null, + reviewedAt: null, + tseNotes: null, + createdAt: '2026-04-01T09:00:00.000Z', + updatedAt: '2026-04-01T09:00:00.000Z', + }, + { + id: 'report-renovacion-2026-01', + partyId: PARTY_RENOVACION, + periodYear: 2026, + periodMonth: 1, + status: 'aprobado', + currentVersion: 1, + title: 'Reporte enero 2026', + submittedBy: 'user-renovacion-oficial-fixture', + submittedAt: '2026-02-08T09:00:00.000Z', + reviewedBy: TSE, + reviewedAt: '2026-02-09T09:00:00.000Z', + tseNotes: null, + createdAt: '2026-02-01T09:00:00.000Z', + updatedAt: '2026-02-09T09:00:00.000Z', + }, + ], +}; +/** Stable identifiers referenced by tests. */ +exports.analyticsFixtureIds = { + parties: { + libertad: PARTY_LIBERTAD, + renovacion: PARTY_RENOVACION, + avanza: PARTY_AVANZA, + }, + buyers: { buyer1: BUYER_1, buyer2: BUYER_2, buyer3: BUYER_3 }, + tse: TSE, +}; +/** + * Reference "now" for deterministic compliance/time-series tests. Chosen so + * `report-libertad-2026-03` (due 2026-04-15, +5 days grace) is clearly past + * its grace period, i.e. `missing`. + */ +exports.analyticsFixtureNow = '2026-07-01T00:00:00.000Z'; From c216b6acce3ffe7004a808635d8f1f278dc599d3 Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:02:42 -0600 Subject: [PATCH 03/23] feat(types): export analytics module from the package barrel --- packages/types/src/index.ts | 2 ++ packages/types/types/index.d.ts | 2 ++ packages/types/types/index.js | 2 ++ 3 files changed, 6 insertions(+) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 872ab3f..9abd99d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -14,6 +14,8 @@ export * from './contract-reader'; export * from './fixtures/contract-reader'; export * from './provenance'; export * from './fixtures/provenance'; +export * from './analytics'; +export * from './fixtures/analytics'; export * from './schemas/common'; export * from './schemas/auth'; export * from './schemas/bonds'; diff --git a/packages/types/types/index.d.ts b/packages/types/types/index.d.ts index 872ab3f..9abd99d 100644 --- a/packages/types/types/index.d.ts +++ b/packages/types/types/index.d.ts @@ -14,6 +14,8 @@ export * from './contract-reader'; export * from './fixtures/contract-reader'; export * from './provenance'; export * from './fixtures/provenance'; +export * from './analytics'; +export * from './fixtures/analytics'; export * from './schemas/common'; export * from './schemas/auth'; export * from './schemas/bonds'; diff --git a/packages/types/types/index.js b/packages/types/types/index.js index a4572fb..af3925b 100644 --- a/packages/types/types/index.js +++ b/packages/types/types/index.js @@ -30,6 +30,8 @@ __exportStar(require("./contract-reader"), exports); __exportStar(require("./fixtures/contract-reader"), exports); __exportStar(require("./provenance"), exports); __exportStar(require("./fixtures/provenance"), exports); +__exportStar(require("./analytics"), exports); +__exportStar(require("./fixtures/analytics"), exports); __exportStar(require("./schemas/common"), exports); __exportStar(require("./schemas/auth"), exports); __exportStar(require("./schemas/bonds"), exports); From 5121dd4aa3aa1da18774a20f4149ccd56baac46f Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:02:50 -0600 Subject: [PATCH 04/23] feat(types): add analytics threshold-breach notification type New NotificationType.ANALYTICS_THRESHOLD_BREACHED, delivered through the existing NotificationsService.emit() when an alert rule breaches. Updates NotificationBell's exhaustive label map accordingly. --- apps/web/components/NotificationBell.tsx | 1 + packages/types/src/notification.ts | 1 + packages/types/types/contracts.d.ts | 11 ++++++----- packages/types/types/notification.d.ts | 1 + packages/types/types/notification.js | 1 + packages/types/types/schemas/notifications.d.ts | 2 ++ packages/types/types/schemas/reports.d.ts | 6 +++--- 7 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/web/components/NotificationBell.tsx b/apps/web/components/NotificationBell.tsx index 08b9971..9522152 100644 --- a/apps/web/components/NotificationBell.tsx +++ b/apps/web/components/NotificationBell.tsx @@ -28,6 +28,7 @@ const LABELS: Record = { report_observed: 'Reporte observado por el TSE', report_approved: 'Reporte aprobado', report_resubmitted: 'Reporte reenviado', + analytics_threshold_breached: 'Alerta de analítica', }; function str(v: unknown): string | undefined { diff --git a/packages/types/src/notification.ts b/packages/types/src/notification.ts index 4626b2c..e006452 100644 --- a/packages/types/src/notification.ts +++ b/packages/types/src/notification.ts @@ -14,6 +14,7 @@ export const NotificationType = { REPORT_OBSERVED: 'report_observed', REPORT_APPROVED: 'report_approved', REPORT_RESUBMITTED: 'report_resubmitted', + ANALYTICS_THRESHOLD_BREACHED: 'analytics_threshold_breached', } as const; export type NotificationType = (typeof NotificationType)[keyof typeof NotificationType]; diff --git a/packages/types/types/contracts.d.ts b/packages/types/types/contracts.d.ts index 0343c24..0f90dcd 100644 --- a/packages/types/types/contracts.d.ts +++ b/packages/types/types/contracts.d.ts @@ -1335,8 +1335,8 @@ export declare const apiContracts: { status: z.ZodEnum<{ aprobado: "aprobado"; enviado: "enviado"; - revisado: "revisado"; observado: "observado"; + revisado: "revisado"; }>; reviewed_by: z.ZodNullable; reviewed_at: z.ZodNullable; @@ -1373,8 +1373,8 @@ export declare const apiContracts: { status: z.ZodEnum<{ aprobado: "aprobado"; enviado: "enviado"; - revisado: "revisado"; observado: "observado"; + revisado: "revisado"; }>; reviewed_by: z.ZodNullable; reviewed_at: z.ZodNullable; @@ -1406,8 +1406,8 @@ export declare const apiContracts: { status: z.ZodEnum<{ aprobado: "aprobado"; enviado: "enviado"; - revisado: "revisado"; observado: "observado"; + revisado: "revisado"; }>; reviewed_by: z.ZodNullable; reviewed_at: z.ZodNullable; @@ -1424,8 +1424,8 @@ export declare const apiContracts: { body: z.ZodObject<{ status: z.ZodEnum<{ aprobado: "aprobado"; - revisado: "revisado"; observado: "observado"; + revisado: "revisado"; }>; notes: z.ZodOptional; }, z.core.$strict>; @@ -1446,8 +1446,8 @@ export declare const apiContracts: { status: z.ZodEnum<{ aprobado: "aprobado"; enviado: "enviado"; - revisado: "revisado"; observado: "observado"; + revisado: "revisado"; }>; reviewed_by: z.ZodNullable; reviewed_at: z.ZodNullable; @@ -1480,6 +1480,7 @@ export declare const apiContracts: { readonly REPORT_OBSERVED: "report_observed"; readonly REPORT_APPROVED: "report_approved"; readonly REPORT_RESUBMITTED: "report_resubmitted"; + readonly ANALYTICS_THRESHOLD_BREACHED: "analytics_threshold_breached"; }>; payload: z.ZodRecord; read: z.ZodBoolean; diff --git a/packages/types/types/notification.d.ts b/packages/types/types/notification.d.ts index 536b592..dbbe04a 100644 --- a/packages/types/types/notification.d.ts +++ b/packages/types/types/notification.d.ts @@ -14,6 +14,7 @@ export declare const NotificationType: { readonly REPORT_OBSERVED: "report_observed"; readonly REPORT_APPROVED: "report_approved"; readonly REPORT_RESUBMITTED: "report_resubmitted"; + readonly ANALYTICS_THRESHOLD_BREACHED: "analytics_threshold_breached"; }; export type NotificationType = (typeof NotificationType)[keyof typeof NotificationType]; export interface Notification { diff --git a/packages/types/types/notification.js b/packages/types/types/notification.js index b84652b..cacb817 100644 --- a/packages/types/types/notification.js +++ b/packages/types/types/notification.js @@ -17,4 +17,5 @@ exports.NotificationType = { REPORT_OBSERVED: 'report_observed', REPORT_APPROVED: 'report_approved', REPORT_RESUBMITTED: 'report_resubmitted', + ANALYTICS_THRESHOLD_BREACHED: 'analytics_threshold_breached', }; diff --git a/packages/types/types/schemas/notifications.d.ts b/packages/types/types/schemas/notifications.d.ts index 4253738..8e5f3b9 100644 --- a/packages/types/types/schemas/notifications.d.ts +++ b/packages/types/types/schemas/notifications.d.ts @@ -14,6 +14,7 @@ export declare const notificationRowSchema: z.ZodObject<{ readonly REPORT_OBSERVED: "report_observed"; readonly REPORT_APPROVED: "report_approved"; readonly REPORT_RESUBMITTED: "report_resubmitted"; + readonly ANALYTICS_THRESHOLD_BREACHED: "analytics_threshold_breached"; }>; payload: z.ZodRecord; read: z.ZodBoolean; @@ -35,6 +36,7 @@ export declare const notificationsResponseSchema: z.ZodObject<{ readonly REPORT_OBSERVED: "report_observed"; readonly REPORT_APPROVED: "report_approved"; readonly REPORT_RESUBMITTED: "report_resubmitted"; + readonly ANALYTICS_THRESHOLD_BREACHED: "analytics_threshold_breached"; }>; payload: z.ZodRecord; read: z.ZodBoolean; diff --git a/packages/types/types/schemas/reports.d.ts b/packages/types/types/schemas/reports.d.ts index 6d88509..d4dfca4 100644 --- a/packages/types/types/schemas/reports.d.ts +++ b/packages/types/types/schemas/reports.d.ts @@ -2,8 +2,8 @@ import { z } from 'zod'; export declare const reportStatusSchema: z.ZodEnum<{ aprobado: "aprobado"; enviado: "enviado"; - revisado: "revisado"; observado: "observado"; + revisado: "revisado"; }>; export declare const createReportRequestSchema: z.ZodObject<{ title: z.ZodString; @@ -16,8 +16,8 @@ export declare const createReportRequestSchema: z.ZodObject<{ export declare const reviewReportRequestSchema: z.ZodObject<{ status: z.ZodEnum<{ aprobado: "aprobado"; - revisado: "revisado"; observado: "observado"; + revisado: "revisado"; }>; notes: z.ZodOptional; }, z.core.$strict>; @@ -34,8 +34,8 @@ export declare const reportRowSchema: z.ZodObject<{ status: z.ZodEnum<{ aprobado: "aprobado"; enviado: "enviado"; - revisado: "revisado"; observado: "observado"; + revisado: "revisado"; }>; reviewed_by: z.ZodNullable; reviewed_at: z.ZodNullable; From 5a42cf4af7a26bda0c7e05b8c826afcf0dbbe149 Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:02:58 -0600 Subject: [PATCH 05/23] feat(api): add pure aggregation and funnel analytics engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aggregateByBondStatus/Party/Country and aggregateValueVolume, plus computeTransferFunnel (reusing TRANSFER_LIFECYCLE_STEPS/ TERMINAL_TRANSFER_STATUSES from the provenance work instead of redefining them). No I/O, no Supabase, no Role — same discipline as provenance-engine.ts. --- apps/api/src/analytics/engine/aggregations.ts | 86 +++++++++++++++++++ apps/api/src/analytics/engine/funnel.ts | 41 +++++++++ apps/api/src/analytics/engine/util.ts | 11 +++ 3 files changed, 138 insertions(+) create mode 100644 apps/api/src/analytics/engine/aggregations.ts create mode 100644 apps/api/src/analytics/engine/funnel.ts create mode 100644 apps/api/src/analytics/engine/util.ts diff --git a/apps/api/src/analytics/engine/aggregations.ts b/apps/api/src/analytics/engine/aggregations.ts new file mode 100644 index 0000000..91568cf --- /dev/null +++ b/apps/api/src/analytics/engine/aggregations.ts @@ -0,0 +1,86 @@ +import type { + BondStatus, + BondStatusBreakdown, + BondToken, + CountryBreakdown, + CountryCode, + PartyBreakdown, + Transfer, + ValueVolumeAggregate, +} from '@velar/types'; +import { DEFAULT_COUNTRY } from '@velar/types'; + +/** + * Pure aggregation functions over bonds/transfers (issue #44). No I/O, no + * Supabase, no Role/RBAC concept — deterministic given the same inputs. + */ + +function faceValueOf(bond: BondToken): number { + return Number(bond.faceValue) || 0; +} + +function amountOf(transfer: Transfer): number { + return Number(transfer.amount) || 0; +} + +function liberadas(transfers: Transfer[]): Transfer[] { + return transfers.filter((t) => t.status === 'liberada'); +} + +export function aggregateByBondStatus(bonds: BondToken[]): BondStatusBreakdown[] { + const map = new Map(); + for (const bond of bonds) { + const cur = map.get(bond.status) ?? { count: 0, faceValue: 0 }; + cur.count += 1; + cur.faceValue += faceValueOf(bond); + map.set(bond.status, cur); + } + return [...map.entries()].map(([status, v]) => ({ status, ...v })); +} + +export function aggregateByParty(bonds: BondToken[], transfers: Transfer[]): PartyBreakdown[] { + const partyIds = [...new Set(bonds.map((b) => b.issuerPartyId))]; + return partyIds + .map((partyId) => { + const partyBonds = bonds.filter((b) => b.issuerPartyId === partyId); + const tokenIds = new Set(partyBonds.map((b) => b.tokenId)); + const sales = liberadas(transfers).filter((t) => tokenIds.has(t.bondTokenId)); + return { + partyId, + bondsCount: partyBonds.length, + emittedValue: partyBonds.reduce((s, b) => s + faceValueOf(b), 0), + salesCount: sales.length, + volumeMoved: sales.reduce((s, t) => s + amountOf(t), 0), + }; + }) + .sort((a, b) => b.volumeMoved - a.volumeMoved); +} + +export function aggregateByCountry(bonds: BondToken[], transfers: Transfer[]): CountryBreakdown[] { + const countries = [...new Set(bonds.map((b) => (b.country as CountryCode) ?? DEFAULT_COUNTRY))]; + return countries + .map((country) => { + const countryBonds = bonds.filter((b) => ((b.country as CountryCode) ?? DEFAULT_COUNTRY) === country); + const tokenIds = new Set(countryBonds.map((b) => b.tokenId)); + const sales = liberadas(transfers).filter((t) => tokenIds.has(t.bondTokenId)); + return { + country, + bondsCount: countryBonds.length, + emittedValue: countryBonds.reduce((s, b) => s + faceValueOf(b), 0), + salesCount: sales.length, + volumeMoved: sales.reduce((s, t) => s + amountOf(t), 0), + }; + }) + .sort((a, b) => b.volumeMoved - a.volumeMoved); +} + +export function aggregateValueVolume(bonds: BondToken[], transfers: Transfer[]): ValueVolumeAggregate { + const sales = liberadas(transfers); + return { + totalBonds: bonds.length, + totalEmittedValue: bonds.reduce((s, b) => s + faceValueOf(b), 0), + totalTransfers: transfers.length, + totalSales: sales.length, + totalVolumeMoved: sales.reduce((s, t) => s + amountOf(t), 0), + }; +} diff --git a/apps/api/src/analytics/engine/funnel.ts b/apps/api/src/analytics/engine/funnel.ts new file mode 100644 index 0000000..2727a07 --- /dev/null +++ b/apps/api/src/analytics/engine/funnel.ts @@ -0,0 +1,41 @@ +import type { Transfer, TransferFunnel, TransferFunnelStage } from '@velar/types'; +import { TRANSFER_LIFECYCLE_STEPS } from '@velar/types'; +import { round2 } from './util'; + +/** + * Transfer funnel / conversion (issue #44). A transfer only has its CURRENT + * status in `AnalyticsInput` (no per-stage audit events by design — see + * docs/BACKEND.md), so stage-reached is approximated from + * `TRANSFER_LIFECYCLE_STEPS`'s index of the current status: a transfer whose + * current status is at or past step `i` counts as having reached it. + * Off-path/terminal-negative statuses (`contraoferta`, `rechazada`, + * `cancelada`) never map to a happy-path index and are reported separately. + */ + +function stepIndexOf(status: string): number { + return (TRANSFER_LIFECYCLE_STEPS as readonly string[]).indexOf(status); +} + +export function computeTransferFunnel(transfers: Transfer[]): TransferFunnel { + const totalStarted = transfers.length; + const rejectedCount = transfers.filter((t) => t.status === 'rechazada').length; + const cancelledCount = transfers.filter((t) => t.status === 'cancelada').length; + const completedCount = transfers.filter((t) => t.status === 'liberada').length; + + const stages: TransferFunnelStage[] = TRANSFER_LIFECYCLE_STEPS.map((step, i) => { + const reachedCount = transfers.filter((t) => { + const idx = stepIndexOf(t.status); + return idx >= i; + }).length; + const conversionFromStartPct = totalStarted > 0 ? round2((reachedCount / totalStarted) * 100) : 0; + return { step, reachedCount, conversionFromStartPct, dropOffPct: 0 }; + }); + + for (let i = 1; i < stages.length; i++) { + const prev = stages[i - 1].reachedCount; + const cur = stages[i].reachedCount; + stages[i].dropOffPct = prev > 0 ? round2(((prev - cur) / prev) * 100) : 0; + } + + return { totalStarted, stages, rejectedCount, cancelledCount, completedCount }; +} diff --git a/apps/api/src/analytics/engine/util.ts b/apps/api/src/analytics/engine/util.ts new file mode 100644 index 0000000..ebba115 --- /dev/null +++ b/apps/api/src/analytics/engine/util.ts @@ -0,0 +1,11 @@ +/** Shared pure helpers for the analytics engine. No I/O, no DB, no Nest. */ + +export function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +/** Whole days between two ISO timestamps (to - from). Negative if `to` precedes `from`. */ +export function daysBetweenIso(fromIso: string, toIso: string): number { + const ms = new Date(toIso).getTime() - new Date(fromIso).getTime(); + return Math.round(ms / 86_400_000); +} From 6deb3977cb0bd79e99c017a08b2cedf12d8bae8d Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:03:07 -0600 Subject: [PATCH 06/23] feat(api): add analytics time-series and trend engine UTC-based day/week/month bucketing (issuance, transfer, and escrow-resolution series) plus generic trend helpers: period-over-period delta, moving average, top-N, and simple threshold anomaly detection. --- apps/api/src/analytics/engine/timeseries.ts | 76 +++++++++++++++++++++ apps/api/src/analytics/engine/trends.ts | 39 +++++++++++ 2 files changed, 115 insertions(+) create mode 100644 apps/api/src/analytics/engine/timeseries.ts create mode 100644 apps/api/src/analytics/engine/trends.ts diff --git a/apps/api/src/analytics/engine/timeseries.ts b/apps/api/src/analytics/engine/timeseries.ts new file mode 100644 index 0000000..021354a --- /dev/null +++ b/apps/api/src/analytics/engine/timeseries.ts @@ -0,0 +1,76 @@ +import type { AnalyticsBucket, BondToken, TimeSeriesPoint, Transfer } from '@velar/types'; +import { TERMINAL_TRANSFER_STATUSES } from '@velar/types'; +import { daysBetweenIso } from './util'; + +/** + * Time-series bucketing (issue #44). All bucketing is UTC-based, deterministic + * and pure — no timezone/locale dependence, matching `reports/domain/deadlines.ts`'s + * discipline. + */ + +function startOfDayUtc(iso: string): number { + const d = new Date(iso); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} + +function startOfWeekUtc(iso: string): number { + const dayStart = startOfDayUtc(iso); + const d = new Date(dayStart); + const day = d.getUTCDay(); // 0=Sun..6=Sat + const diffToMonday = day === 0 ? 6 : day - 1; + return dayStart - diffToMonday * 86_400_000; +} + +function startOfMonthUtc(iso: string): number { + const d = new Date(iso); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1); +} + +function bucketStartMs(iso: string, bucket: AnalyticsBucket): number { + if (bucket === 'week') return startOfWeekUtc(iso); + if (bucket === 'month') return startOfMonthUtc(iso); + return startOfDayUtc(iso); +} + +function toIsoDate(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} + +/** Generic bucketer: groups `items` by date into `TimeSeriesPoint`s, summing `valueOf`. */ +export function bucketByDate( + items: T[], + dateOf: (item: T) => string, + valueOf: (item: T) => number, + bucket: AnalyticsBucket = 'day', +): TimeSeriesPoint[] { + const map = new Map(); + for (const item of items) { + const ms = bucketStartMs(dateOf(item), bucket); + const cur = map.get(ms) ?? { value: 0, count: 0 }; + cur.value += valueOf(item); + cur.count += 1; + map.set(ms, cur); + } + return [...map.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([ms, v]) => ({ bucketStart: toIsoDate(ms), value: v.value, count: v.count })); +} + +export function issuanceTimeSeries(bonds: BondToken[], bucket: AnalyticsBucket = 'day'): TimeSeriesPoint[] { + return bucketByDate(bonds, (b) => b.createdAt, (b) => Number(b.faceValue) || 0, bucket); +} + +export function transferTimeSeries(transfers: Transfer[], bucket: AnalyticsBucket = 'day'): TimeSeriesPoint[] { + const liberadas = transfers.filter((t) => t.status === 'liberada'); + return bucketByDate(liberadas, (t) => t.createdAt, (t) => Number(t.amount) || 0, bucket); +} + +/** + * Escrow "throughput" v1: approximated as resolution time (createdAt→updatedAt, + * in days) for terminal transfers, bucketed by resolution date. Does not + * depend on `AuditEvent` — scope is bonds/transfers/reports only. + */ +export function escrowResolutionTimeSeries(transfers: Transfer[], bucket: AnalyticsBucket = 'day'): TimeSeriesPoint[] { + const terminal = transfers.filter((t) => (TERMINAL_TRANSFER_STATUSES as readonly string[]).includes(t.status)); + return bucketByDate(terminal, (t) => t.updatedAt, (t) => daysBetweenIso(t.createdAt, t.updatedAt), bucket); +} diff --git a/apps/api/src/analytics/engine/trends.ts b/apps/api/src/analytics/engine/trends.ts new file mode 100644 index 0000000..8e40306 --- /dev/null +++ b/apps/api/src/analytics/engine/trends.ts @@ -0,0 +1,39 @@ +import type { MovingAveragePoint, TimeSeriesPoint, TopNEntry, TrendDelta } from '@velar/types'; +import { round2 } from './util'; + +/** Generic trend/analysis helpers (issue #44). Pure, reusable across metrics. */ + +export function periodOverPeriodDelta(current: number, previous: number): TrendDelta { + const deltaAbs = current - previous; + const deltaPct = previous !== 0 ? round2((deltaAbs / previous) * 100) : null; + return { current, previous, deltaAbs, deltaPct }; +} + +/** Trailing moving average; windows shorter than `windowSize` at the series start use what's available. */ +export function movingAverage(series: TimeSeriesPoint[], windowSize: number): MovingAveragePoint[] { + if (windowSize <= 0) throw new Error('windowSize debe ser mayor a 0'); + return series.map((point, i) => { + const start = Math.max(0, i - windowSize + 1); + const window = series.slice(start, i + 1); + const average = window.reduce((s, p) => s + p.value, 0) / window.length; + return { bucketStart: point.bucketStart, average: round2(average) }; + }); +} + +export function topN( + items: T[], + keyOf: (item: T) => string, + labelOf: (item: T) => string, + valueOf: (item: T) => number, + n: number, +): TopNEntry[] { + return [...items] + .map((item) => ({ key: keyOf(item), label: labelOf(item), value: valueOf(item) })) + .sort((a, b) => b.value - a.value) + .slice(0, n); +} + +/** Simple threshold anomaly detection: points whose value exceeds `threshold`. */ +export function detectThresholdAnomalies(series: TimeSeriesPoint[], threshold: number): TimeSeriesPoint[] { + return series.filter((p) => p.value > threshold); +} From f8605e36d1f60f36f3492197a5c1e291f9f4fa4a Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:03:17 -0600 Subject: [PATCH 07/23] feat(api): add analytics compliance, alerting, and snapshot composition compliance.ts adapts the existing computeComplianceForPeriods (reports/domain/deadlines.ts) instead of reimplementing deadline logic. alerts.ts evaluates dot-path metric rules against a snapshot, pure and I/O-free. index.ts composes everything into buildAnalyticsSnapshot, applying RBAC scope and query filters without ever importing Role. --- apps/api/src/analytics/engine/alerts.ts | 47 ++++++++ apps/api/src/analytics/engine/compliance.ts | 40 +++++++ apps/api/src/analytics/engine/index.ts | 115 ++++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 apps/api/src/analytics/engine/alerts.ts create mode 100644 apps/api/src/analytics/engine/compliance.ts create mode 100644 apps/api/src/analytics/engine/index.ts diff --git a/apps/api/src/analytics/engine/alerts.ts b/apps/api/src/analytics/engine/alerts.ts new file mode 100644 index 0000000..6fdf565 --- /dev/null +++ b/apps/api/src/analytics/engine/alerts.ts @@ -0,0 +1,47 @@ +import type { AlertBreach, AlertComparator, AlertRule, AnalyticsSnapshot } from '@velar/types'; + +/** + * Threshold alerting (issue #44). Pure comparator logic — no I/O, no + * notification delivery (that's the service's job, via `NotificationsService`). + */ + +function getMetric(snapshot: AnalyticsSnapshot, path: string): number | undefined { + const value = path + .split('.') + .reduce((acc, key) => (acc != null && typeof acc === 'object' ? (acc as Record)[key] : undefined), snapshot); + return typeof value === 'number' ? value : undefined; +} + +function compare(value: number, comparator: AlertComparator, threshold: number): boolean { + switch (comparator) { + case 'gt': + return value > threshold; + case 'lt': + return value < threshold; + case 'gte': + return value >= threshold; + case 'lte': + return value <= threshold; + } +} + +/** Evaluates every rule against the snapshot; rules whose metric path resolves to a non-number are skipped. */ +export function evaluateAlertRules(snapshot: AnalyticsSnapshot, rules: AlertRule[], now = new Date()): AlertBreach[] { + const breaches: AlertBreach[] = []; + for (const rule of rules) { + const value = getMetric(snapshot, rule.metricPath); + if (value === undefined) continue; + if (compare(value, rule.comparator, rule.threshold)) { + breaches.push({ + ruleId: rule.id, + ruleName: rule.name, + metricPath: rule.metricPath, + value, + threshold: rule.threshold, + comparator: rule.comparator, + at: now.toISOString(), + }); + } + } + return breaches; +} diff --git a/apps/api/src/analytics/engine/compliance.ts b/apps/api/src/analytics/engine/compliance.ts new file mode 100644 index 0000000..ca4481d --- /dev/null +++ b/apps/api/src/analytics/engine/compliance.ts @@ -0,0 +1,40 @@ +import type { ComplianceSummary, DeadlineConfig, MonthlyReport, PartyComplianceSummary } from '@velar/types'; +import { computeComplianceForPeriods } from '../../reports/domain/deadlines'; + +/** + * Compliance metrics adapter (issue #44). Reuses the existing pure + * `computeComplianceForPeriods` engine (apps/api/src/reports/domain/deadlines.ts) + * — does not reimplement deadline/grace logic. Parties with zero reports in + * the input simply do not appear (there is no "expected period" concept + * without a report to anchor it). + */ +export function computeComplianceSummary( + reports: MonthlyReport[], + config: DeadlineConfig, + now: string, +): ComplianceSummary { + const partyIds = [...new Set(reports.map((r) => r.partyId))]; + + const parties: PartyComplianceSummary[] = partyIds.map((partyId) => { + const partyReports = reports.filter((r) => r.partyId === partyId); + const periods = computeComplianceForPeriods( + partyReports.map((r) => ({ + periodYear: r.periodYear, + periodMonth: r.periodMonth, + submittedAt: r.submittedAt, + })), + config, + now, + ); + return { + partyId, + periods, + onTimeCount: periods.filter((p) => p.status === 'on_time').length, + lateCount: periods.filter((p) => p.status === 'late').length, + overdueCount: periods.filter((p) => p.status === 'overdue').length, + missingCount: periods.filter((p) => p.status === 'missing').length, + }; + }); + + return { parties }; +} diff --git a/apps/api/src/analytics/engine/index.ts b/apps/api/src/analytics/engine/index.ts new file mode 100644 index 0000000..fa95a93 --- /dev/null +++ b/apps/api/src/analytics/engine/index.ts @@ -0,0 +1,115 @@ +import type { + AnalyticsInput, + AnalyticsQuery, + AnalyticsScope, + AnalyticsSnapshot, + DeadlineConfig, +} from '@velar/types'; +import { aggregateByBondStatus, aggregateByCountry, aggregateByParty, aggregateValueVolume } from './aggregations'; +import { computeComplianceSummary } from './compliance'; +import { computeTransferFunnel } from './funnel'; +import { escrowResolutionTimeSeries, issuanceTimeSeries, transferTimeSeries } from './timeseries'; +import { topN } from './trends'; + +export * from './aggregations'; +export * from './alerts'; +export * from './compliance'; +export * from './funnel'; +export * from './timeseries'; +export * from './trends'; + +/** + * Root composition of the pure analytics engine (issue #44). Given raw + * bonds/transfers/reports, an access scope and an optional query, computes + * the full `AnalyticsSnapshot`. Never mutates `input`; RBAC scoping (which + * party can see what) is resolved by the caller into `AnalyticsScope` before + * this function runs — the engine itself never imports `Role`. + */ + +/** Same deadline calendar used by the reports module (apps/api/src/reports/domain/deadlines.spec.ts). */ +export const DEFAULT_DEADLINE_CONFIG: DeadlineConfig = { dueDayOfMonth: 15, graceDays: 5 }; + +export function applyScope(input: AnalyticsInput, scope: AnalyticsScope): AnalyticsInput { + if (scope.kind === 'all') return input; + const { partyId } = scope; + const bonds = input.bonds.filter((b) => b.issuerPartyId === partyId); + const bondTokenIds = new Set(bonds.map((b) => b.tokenId)); + return { + bonds, + transfers: input.transfers.filter((t) => bondTokenIds.has(t.bondTokenId)), + reports: input.reports.filter((r) => r.partyId === partyId), + }; +} + +/** + * Applies `AnalyticsQuery` filters. `country`/`partyId`/`from`/`to` narrow the + * bond set first, transfers follow via `bondTokenId`; `status` (which can be a + * `BondStatus` or `TransferStatus`) then filters each collection independently + * against its own status domain, since the two enums mostly don't overlap. + */ +export function applyQueryFilters(input: AnalyticsInput, query: AnalyticsQuery): AnalyticsInput { + let bonds = input.bonds; + let reports = input.reports; + + if (query.country) bonds = bonds.filter((b) => b.country === query.country); + if (query.partyId) { + bonds = bonds.filter((b) => b.issuerPartyId === query.partyId); + reports = reports.filter((r) => r.partyId === query.partyId); + } + if (query.from) bonds = bonds.filter((b) => b.createdAt >= query.from!); + if (query.to) bonds = bonds.filter((b) => b.createdAt <= query.to!); + + const bondTokenIds = new Set(bonds.map((b) => b.tokenId)); + let transfers = input.transfers.filter((t) => bondTokenIds.has(t.bondTokenId)); + if (query.from) transfers = transfers.filter((t) => t.createdAt >= query.from!); + if (query.to) transfers = transfers.filter((t) => t.createdAt <= query.to!); + + if (query.status) { + bonds = bonds.filter((b) => b.status === query.status); + transfers = transfers.filter((t) => t.status === query.status); + } + + return { bonds, transfers, reports }; +} + +export function buildAnalyticsSnapshot( + input: AnalyticsInput, + query: AnalyticsQuery = {}, + scope: AnalyticsScope = { kind: 'all' }, + now: Date = new Date(), + deadlineConfig: DeadlineConfig = DEFAULT_DEADLINE_CONFIG, +): AnalyticsSnapshot { + const scoped = applyScope(input, scope); + const filtered = applyQueryFilters(scoped, query); + const bucket = query.bucket ?? 'day'; + + const topBonds = topN( + filtered.bonds.map((b) => ({ + tokenId: b.tokenId, + bondId: b.bondId, + volume: filtered.transfers + .filter((t) => t.bondTokenId === b.tokenId && t.status === 'liberada') + .reduce((s, t) => s + (Number(t.amount) || 0), 0), + })), + (x) => x.tokenId, + (x) => x.bondId, + (x) => x.volume, + 5, + ); + + return { + query, + scope, + valueVolume: aggregateValueVolume(filtered.bonds, filtered.transfers), + bondStatusBreakdown: aggregateByBondStatus(filtered.bonds), + partyBreakdown: aggregateByParty(filtered.bonds, filtered.transfers), + countryBreakdown: aggregateByCountry(filtered.bonds, filtered.transfers), + funnel: computeTransferFunnel(filtered.transfers), + issuanceSeries: issuanceTimeSeries(filtered.bonds, bucket), + transferSeries: transferTimeSeries(filtered.transfers, bucket), + escrowResolutionSeries: escrowResolutionTimeSeries(filtered.transfers, bucket), + compliance: computeComplianceSummary(filtered.reports, deadlineConfig, now.toISOString()), + topBonds, + generatedAt: now.toISOString(), + }; +} From cc92a4d3cede2297c0c8c668db7001b3420e97cd Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:03:25 -0600 Subject: [PATCH 08/23] test(api): add unit tests for aggregation and funnel engine Fixture-driven, including empty/single-item/off-path edge cases. --- .../src/analytics/engine/aggregations.spec.ts | 83 +++++++++++++++++++ apps/api/src/analytics/engine/funnel.spec.ts | 72 ++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 apps/api/src/analytics/engine/aggregations.spec.ts create mode 100644 apps/api/src/analytics/engine/funnel.spec.ts diff --git a/apps/api/src/analytics/engine/aggregations.spec.ts b/apps/api/src/analytics/engine/aggregations.spec.ts new file mode 100644 index 0000000..7c0c659 --- /dev/null +++ b/apps/api/src/analytics/engine/aggregations.spec.ts @@ -0,0 +1,83 @@ +import type { AnalyticsInput } from '@velar/types'; +import { analyticsFixture, analyticsFixtureIds } from '@velar/types'; +import { aggregateByBondStatus, aggregateByCountry, aggregateByParty, aggregateValueVolume } from './aggregations'; + +const clone = (input: AnalyticsInput): AnalyticsInput => JSON.parse(JSON.stringify(input)); +const ids = analyticsFixtureIds; + +describe('aggregateByBondStatus', () => { + it('groups bonds by status with count and summed face value', () => { + const result = aggregateByBondStatus(clone(analyticsFixture).bonds); + const byStatus = Object.fromEntries(result.map((r) => [r.status, r])); + expect(byStatus['transferido']).toEqual({ status: 'transferido', count: 3, faceValue: 7_200_000 }); + expect(byStatus['activo']).toEqual({ status: 'activo', count: 1, faceValue: 500_000 }); + expect(byStatus['en_escrow']).toEqual({ status: 'en_escrow', count: 1, faceValue: 750_000 }); + expect(byStatus['cancelado']).toEqual({ status: 'cancelado', count: 1, faceValue: 300_000 }); + expect(byStatus['emitido']).toEqual({ status: 'emitido', count: 1, faceValue: 8_000_000 }); + }); + + it('returns an empty array for no bonds', () => { + expect(aggregateByBondStatus([])).toEqual([]); + }); +}); + +describe('aggregateByParty', () => { + it('sums emitted value and sales volume per party, sorted by volume desc', () => { + const { bonds, transfers } = clone(analyticsFixture); + const result = aggregateByParty(bonds, transfers); + expect(result.map((r) => r.partyId)).toEqual([ids.parties.avanza, ids.parties.renovacion, ids.parties.libertad]); + + const libertad = result.find((r) => r.partyId === ids.parties.libertad)!; + expect(libertad).toMatchObject({ bondsCount: 3, emittedValue: 2_250_000, salesCount: 1, volumeMoved: 1_050_000 }); + + const renovacion = result.find((r) => r.partyId === ids.parties.renovacion)!; + expect(renovacion).toMatchObject({ bondsCount: 2, emittedValue: 1_500_000, salesCount: 1, volumeMoved: 1_250_000 }); + + const avanza = result.find((r) => r.partyId === ids.parties.avanza)!; + expect(avanza).toMatchObject({ bondsCount: 2, emittedValue: 13_000_000, salesCount: 1, volumeMoved: 5_200_000 }); + }); + + it('single-party dataset produces one entry', () => { + const { bonds, transfers } = clone(analyticsFixture); + const onlyLibertad = bonds.filter((b) => b.issuerPartyId === ids.parties.libertad); + const result = aggregateByParty(onlyLibertad, transfers); + expect(result).toHaveLength(1); + }); +}); + +describe('aggregateByCountry', () => { + it('sums per country, sorted by volume desc', () => { + const { bonds, transfers } = clone(analyticsFixture); + const result = aggregateByCountry(bonds, transfers); + expect(result.map((r) => r.country)).toEqual(['CO', 'CR']); + + const cr = result.find((r) => r.country === 'CR')!; + expect(cr).toMatchObject({ bondsCount: 5, emittedValue: 3_750_000, salesCount: 2, volumeMoved: 2_300_000 }); + + const co = result.find((r) => r.country === 'CO')!; + expect(co).toMatchObject({ bondsCount: 2, emittedValue: 13_000_000, salesCount: 1, volumeMoved: 5_200_000 }); + }); +}); + +describe('aggregateValueVolume', () => { + it('computes totals across all bonds and transfers', () => { + const { bonds, transfers } = clone(analyticsFixture); + expect(aggregateValueVolume(bonds, transfers)).toEqual({ + totalBonds: 7, + totalEmittedValue: 16_750_000, + totalTransfers: 9, + totalSales: 3, + totalVolumeMoved: 7_500_000, + }); + }); + + it('empty dataset yields all-zero totals', () => { + expect(aggregateValueVolume([], [])).toEqual({ + totalBonds: 0, + totalEmittedValue: 0, + totalTransfers: 0, + totalSales: 0, + totalVolumeMoved: 0, + }); + }); +}); diff --git a/apps/api/src/analytics/engine/funnel.spec.ts b/apps/api/src/analytics/engine/funnel.spec.ts new file mode 100644 index 0000000..2a19d55 --- /dev/null +++ b/apps/api/src/analytics/engine/funnel.spec.ts @@ -0,0 +1,72 @@ +import type { AnalyticsInput } from '@velar/types'; +import { analyticsFixture } from '@velar/types'; +import { computeTransferFunnel } from './funnel'; + +const clone = (input: AnalyticsInput): AnalyticsInput => JSON.parse(JSON.stringify(input)); + +describe('computeTransferFunnel', () => { + it('counts totals, stages, conversion and drop-off over the fixture', () => { + const { transfers } = clone(analyticsFixture); + const funnel = computeTransferFunnel(transfers); + + expect(funnel.totalStarted).toBe(9); + expect(funnel.rejectedCount).toBe(1); + expect(funnel.cancelledCount).toBe(1); + expect(funnel.completedCount).toBe(3); + + expect(funnel.stages.map((s) => s.step)).toEqual([ + 'solicitada', + 'aceptada', + 'en_escrow', + 'pago_registrado', + 'pago_validado', + 'liberada', + ]); + + const byStep = Object.fromEntries(funnel.stages.map((s) => [s.step, s])); + expect(byStep['solicitada']).toMatchObject({ reachedCount: 6, conversionFromStartPct: 66.67, dropOffPct: 0 }); + expect(byStep['aceptada']).toMatchObject({ reachedCount: 5, conversionFromStartPct: 55.56, dropOffPct: 16.67 }); + expect(byStep['en_escrow']).toMatchObject({ reachedCount: 5, conversionFromStartPct: 55.56, dropOffPct: 0 }); + expect(byStep['pago_registrado']).toMatchObject({ reachedCount: 4, conversionFromStartPct: 44.44, dropOffPct: 20 }); + expect(byStep['pago_validado']).toMatchObject({ reachedCount: 3, conversionFromStartPct: 33.33, dropOffPct: 25 }); + expect(byStep['liberada']).toMatchObject({ reachedCount: 3, conversionFromStartPct: 33.33, dropOffPct: 0 }); + }); + + it('empty transfer list yields zeroed funnel with no drop-off', () => { + const funnel = computeTransferFunnel([]); + expect(funnel.totalStarted).toBe(0); + expect(funnel.rejectedCount).toBe(0); + expect(funnel.cancelledCount).toBe(0); + expect(funnel.completedCount).toBe(0); + for (const stage of funnel.stages) { + expect(stage.reachedCount).toBe(0); + expect(stage.conversionFromStartPct).toBe(0); + expect(stage.dropOffPct).toBe(0); + } + }); + + it('a single completed transfer reaches every stage with no drop-off', () => { + const { transfers } = clone(analyticsFixture); + const single = transfers.filter((t) => t.id === 'transfer-a1-1'); + const funnel = computeTransferFunnel(single); + expect(funnel.totalStarted).toBe(1); + expect(funnel.completedCount).toBe(1); + for (const stage of funnel.stages) { + expect(stage.reachedCount).toBe(1); + expect(stage.conversionFromStartPct).toBe(100); + expect(stage.dropOffPct).toBe(0); + } + }); + + it('off-path statuses (contraoferta) never count toward any stage', () => { + const { transfers } = clone(analyticsFixture); + const single = transfers.filter((t) => t.id === 'transfer-a2-2'); // status: contraoferta + const funnel = computeTransferFunnel(single); + expect(funnel.totalStarted).toBe(1); + expect(funnel.rejectedCount).toBe(0); + expect(funnel.cancelledCount).toBe(0); + for (const stage of funnel.stages) { + expect(stage.reachedCount).toBe(0); + } + }); +}); From 89b6d7afdabef7b00eda3ae14019056dd1803b76 Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:03:53 -0600 Subject: [PATCH 09/23] test(api): add unit tests for timeseries, trends, compliance, and alerts Timeseries/trends use small hand-crafted datasets for exact, hand-verifiable day/week/month bucketing; compliance and alerts use the shared fixture. --- apps/api/src/analytics/engine/alerts.spec.ts | 80 +++++++++++++++ .../src/analytics/engine/compliance.spec.ts | 41 ++++++++ .../src/analytics/engine/timeseries.spec.ts | 99 +++++++++++++++++++ apps/api/src/analytics/engine/trends.spec.ts | 84 ++++++++++++++++ 4 files changed, 304 insertions(+) create mode 100644 apps/api/src/analytics/engine/alerts.spec.ts create mode 100644 apps/api/src/analytics/engine/compliance.spec.ts create mode 100644 apps/api/src/analytics/engine/timeseries.spec.ts create mode 100644 apps/api/src/analytics/engine/trends.spec.ts diff --git a/apps/api/src/analytics/engine/alerts.spec.ts b/apps/api/src/analytics/engine/alerts.spec.ts new file mode 100644 index 0000000..29c98ad --- /dev/null +++ b/apps/api/src/analytics/engine/alerts.spec.ts @@ -0,0 +1,80 @@ +import type { AlertRule, AnalyticsInput } from '@velar/types'; +import { analyticsFixture } from '@velar/types'; +import { evaluateAlertRules } from './alerts'; +import { buildAnalyticsSnapshot } from './index'; + +const clone = (input: AnalyticsInput): AnalyticsInput => JSON.parse(JSON.stringify(input)); +const FIXED_NOW = new Date('2026-07-01T00:00:00.000Z'); + +function makeRule(over: Partial): AlertRule { + return { + id: 'rule-1', + name: 'Test rule', + metricPath: 'valueVolume.totalVolumeMoved', + comparator: 'gt', + threshold: 0, + scope: { kind: 'all' }, + notifyUserIds: [], + createdAt: FIXED_NOW.toISOString(), + updatedAt: FIXED_NOW.toISOString(), + ...over, + }; +} + +describe('evaluateAlertRules', () => { + const snapshot = buildAnalyticsSnapshot(clone(analyticsFixture), {}, { kind: 'all' }, FIXED_NOW); + // snapshot.valueVolume.totalVolumeMoved === 7_500_000, totalBonds === 7 (see aggregations.spec.ts) + + it('reports a breach when the metric exceeds the threshold (gt)', () => { + const rule = makeRule({ metricPath: 'valueVolume.totalVolumeMoved', comparator: 'gt', threshold: 5_000_000 }); + const breaches = evaluateAlertRules(snapshot, [rule], FIXED_NOW); + expect(breaches).toEqual([ + { + ruleId: 'rule-1', + ruleName: 'Test rule', + metricPath: 'valueVolume.totalVolumeMoved', + value: 7_500_000, + threshold: 5_000_000, + comparator: 'gt', + at: FIXED_NOW.toISOString(), + }, + ]); + }); + + it('does not breach when the condition is not met', () => { + const rule = makeRule({ metricPath: 'valueVolume.totalVolumeMoved', comparator: 'gt', threshold: 100_000_000 }); + expect(evaluateAlertRules(snapshot, [rule], FIXED_NOW)).toEqual([]); + }); + + it.each([ + ['lt', 10, true], + ['lt', 5, false], + ['gte', 7, true], + ['gte', 8, false], + ['lte', 7, true], + ['lte', 6, false], + ] as const)('comparator %s with threshold %d on totalBonds=7 breaches=%s', (comparator, threshold, expected) => { + const rule = makeRule({ metricPath: 'valueVolume.totalBonds', comparator, threshold }); + const breaches = evaluateAlertRules(snapshot, [rule], FIXED_NOW); + expect(breaches.length > 0).toBe(expected); + }); + + it('skips rules whose metric path does not resolve to a number, without throwing', () => { + const rule = makeRule({ metricPath: 'valueVolume.doesNotExist' }); + expect(() => evaluateAlertRules(snapshot, [rule], FIXED_NOW)).not.toThrow(); + expect(evaluateAlertRules(snapshot, [rule], FIXED_NOW)).toEqual([]); + }); + + it('empty rule list yields no breaches', () => { + expect(evaluateAlertRules(snapshot, [], FIXED_NOW)).toEqual([]); + }); + + it('evaluates multiple rules independently', () => { + const rules = [ + makeRule({ id: 'r1', metricPath: 'valueVolume.totalBonds', comparator: 'gt', threshold: 1 }), + makeRule({ id: 'r2', metricPath: 'valueVolume.totalBonds', comparator: 'gt', threshold: 100 }), + ]; + const breaches = evaluateAlertRules(snapshot, rules, FIXED_NOW); + expect(breaches.map((b) => b.ruleId)).toEqual(['r1']); + }); +}); diff --git a/apps/api/src/analytics/engine/compliance.spec.ts b/apps/api/src/analytics/engine/compliance.spec.ts new file mode 100644 index 0000000..d3d864f --- /dev/null +++ b/apps/api/src/analytics/engine/compliance.spec.ts @@ -0,0 +1,41 @@ +import type { AnalyticsInput, DeadlineConfig } from '@velar/types'; +import { analyticsFixture, analyticsFixtureIds, analyticsFixtureNow } from '@velar/types'; +import { computeComplianceSummary } from './compliance'; + +const clone = (input: AnalyticsInput): AnalyticsInput => JSON.parse(JSON.stringify(input)); +const ids = analyticsFixtureIds; +const config: DeadlineConfig = { dueDayOfMonth: 15, graceDays: 5 }; + +describe('computeComplianceSummary', () => { + it('summarizes on-time/late/missing periods per party over the fixture', () => { + const { reports } = clone(analyticsFixture); + const summary = computeComplianceSummary(reports, config, analyticsFixtureNow); + + // Avanza has zero reports in the fixture, so it has no expected periods to + // anchor a compliance computation and simply does not appear. + expect(summary.parties.map((p) => p.partyId).sort()).toEqual( + [ids.parties.libertad, ids.parties.renovacion].sort(), + ); + + const libertad = summary.parties.find((p) => p.partyId === ids.parties.libertad)!; + expect(libertad.periods).toHaveLength(3); + expect(libertad).toMatchObject({ onTimeCount: 1, lateCount: 1, overdueCount: 0, missingCount: 1 }); + + const renovacion = summary.parties.find((p) => p.partyId === ids.parties.renovacion)!; + expect(renovacion.periods).toHaveLength(1); + expect(renovacion).toMatchObject({ onTimeCount: 1, lateCount: 0, overdueCount: 0, missingCount: 0 }); + }); + + it('no reports at all yields no parties', () => { + expect(computeComplianceSummary([], config, analyticsFixtureNow)).toEqual({ parties: [] }); + }); + + it('a single sparse period is summarized correctly', () => { + const { reports } = clone(analyticsFixture); + const single = reports.filter((r) => r.id === 'report-renovacion-2026-01'); + const summary = computeComplianceSummary(single, config, analyticsFixtureNow); + expect(summary.parties).toHaveLength(1); + expect(summary.parties[0].periods).toHaveLength(1); + expect(summary.parties[0].onTimeCount).toBe(1); + }); +}); diff --git a/apps/api/src/analytics/engine/timeseries.spec.ts b/apps/api/src/analytics/engine/timeseries.spec.ts new file mode 100644 index 0000000..fe6f9bd --- /dev/null +++ b/apps/api/src/analytics/engine/timeseries.spec.ts @@ -0,0 +1,99 @@ +import type { AnalyticsInput, Transfer } from '@velar/types'; +import { analyticsFixture } from '@velar/types'; +import { bucketByDate, escrowResolutionTimeSeries, issuanceTimeSeries, transferTimeSeries } from './timeseries'; + +const clone = (input: AnalyticsInput): AnalyticsInput => JSON.parse(JSON.stringify(input)); + +describe('bucketByDate', () => { + const items = [ + { date: '2026-01-01T00:00:00.000Z', value: 10 }, + { date: '2026-01-01T18:00:00.000Z', value: 5 }, + { date: '2026-01-02T00:00:00.000Z', value: 1 }, + ]; + + it('day bucket groups by calendar date', () => { + const points = bucketByDate(items, (i) => i.date, (i) => i.value, 'day'); + expect(points).toEqual([ + { bucketStart: '2026-01-01', value: 15, count: 2 }, + { bucketStart: '2026-01-02', value: 1, count: 1 }, + ]); + }); + + it('week bucket groups nearby dates together and separates a date 10 days later', () => { + const weekItems = [ + { date: '2026-06-10T00:00:00.000Z', value: 10 }, // Wednesday + { date: '2026-06-11T00:00:00.000Z', value: 5 }, // Thursday, same week + { date: '2026-06-21T00:00:00.000Z', value: 7 }, // 10 days later, different week + ]; + const points = bucketByDate(weekItems, (i) => i.date, (i) => i.value, 'week'); + expect(points).toHaveLength(2); + expect(points[0]).toMatchObject({ value: 15, count: 2 }); + expect(points[1]).toMatchObject({ value: 7, count: 1 }); + expect(points[0].bucketStart).not.toBe(points[1].bucketStart); + }); + + it('month bucket groups the whole month together', () => { + const points = bucketByDate( + [{ date: '2026-03-01T00:00:00.000Z', value: 1 }, { date: '2026-03-31T00:00:00.000Z', value: 2 }], + (i) => i.date, + (i) => i.value, + 'month', + ); + expect(points).toEqual([{ bucketStart: '2026-03-01', value: 3, count: 2 }]); + }); + + it('empty input yields no points', () => { + expect(bucketByDate([], () => '2026-01-01', () => 1)).toEqual([]); + }); +}); + +describe('issuanceTimeSeries', () => { + it('buckets bond face value by month over the fixture', () => { + const { bonds } = clone(analyticsFixture); + const points = issuanceTimeSeries(bonds, 'month'); + expect(points).toEqual([ + { bucketStart: '2026-01-01', value: 2_200_000, count: 2 }, + { bucketStart: '2026-02-01', value: 800_000, count: 2 }, + { bucketStart: '2026-03-01', value: 750_000, count: 1 }, + { bucketStart: '2026-04-01', value: 13_000_000, count: 2 }, + ]); + }); +}); + +describe('transferTimeSeries', () => { + it('buckets only liberada transfer amounts by month', () => { + const { transfers } = clone(analyticsFixture); + const points = transferTimeSeries(transfers, 'month'); + expect(points).toEqual([ + { bucketStart: '2026-01-01', value: 2_300_000, count: 2 }, + { bucketStart: '2026-04-01', value: 5_200_000, count: 1 }, + ]); + }); +}); + +describe('escrowResolutionTimeSeries', () => { + const makeTransfer = (over: Partial): Transfer => ({ + id: 'synthetic', + bondTokenId: 'bond-x', + fromOwner: 'owner-a', + toOwner: 'owner-b', + status: 'liberada', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...over, + }); + + it('buckets resolution days (createdAt→updatedAt) of terminal transfers only, excluding still-open ones', () => { + const transfers: Transfer[] = [ + makeTransfer({ id: 't1', status: 'liberada', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-05T00:00:00.000Z' }), // 4 days + makeTransfer({ id: 't2', status: 'cancelada', createdAt: '2026-01-10T00:00:00.000Z', updatedAt: '2026-02-02T00:00:00.000Z' }), // 23 days + makeTransfer({ id: 't3', status: 'rechazada', createdAt: '2026-02-01T00:00:00.000Z', updatedAt: '2026-02-10T00:00:00.000Z' }), // 9 days + makeTransfer({ id: 't4', status: 'en_escrow', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z' }), // excluded + ]; + const points = escrowResolutionTimeSeries(transfers, 'month'); + expect(points).toEqual([ + { bucketStart: '2026-01-01', value: 4, count: 1 }, + { bucketStart: '2026-02-01', value: 32, count: 2 }, + ]); + }); +}); diff --git a/apps/api/src/analytics/engine/trends.spec.ts b/apps/api/src/analytics/engine/trends.spec.ts new file mode 100644 index 0000000..a5a56b0 --- /dev/null +++ b/apps/api/src/analytics/engine/trends.spec.ts @@ -0,0 +1,84 @@ +import type { TimeSeriesPoint } from '@velar/types'; +import { detectThresholdAnomalies, movingAverage, periodOverPeriodDelta, topN } from './trends'; + +describe('periodOverPeriodDelta', () => { + it('computes absolute and percentage change', () => { + expect(periodOverPeriodDelta(120, 100)).toEqual({ current: 120, previous: 100, deltaAbs: 20, deltaPct: 20 }); + }); + + it('handles a decrease', () => { + expect(periodOverPeriodDelta(80, 100)).toEqual({ current: 80, previous: 100, deltaAbs: -20, deltaPct: -20 }); + }); + + it('deltaPct is null when previous is 0 (undefined percentage change)', () => { + expect(periodOverPeriodDelta(50, 0)).toEqual({ current: 50, previous: 0, deltaAbs: 50, deltaPct: null }); + }); +}); + +describe('movingAverage', () => { + const series: TimeSeriesPoint[] = [10, 20, 30, 40].map((value, i) => ({ + bucketStart: `2026-01-0${i + 1}`, + value, + count: 1, + })); + + it('computes a trailing average with a normal window', () => { + expect(movingAverage(series, 2).map((p) => p.average)).toEqual([10, 15, 25, 35]); + }); + + it('a window larger than the series just averages what is available (cumulative)', () => { + expect(movingAverage(series, 10).map((p) => p.average)).toEqual([10, 15, 20, 25]); + }); + + it('empty series yields no points', () => { + expect(movingAverage([], 3)).toEqual([]); + }); + + it('throws on a non-positive window size', () => { + expect(() => movingAverage(series, 0)).toThrow(); + }); +}); + +describe('topN', () => { + const items = [ + { id: 'a', name: 'A', v: 10 }, + { id: 'b', name: 'B', v: 30 }, + { id: 'c', name: 'C', v: 20 }, + ]; + + it('returns the top N sorted descending by value', () => { + const result = topN(items, (i) => i.id, (i) => i.name, (i) => i.v, 2); + expect(result).toEqual([ + { key: 'b', label: 'B', value: 30 }, + { key: 'c', label: 'C', value: 20 }, + ]); + }); + + it('n larger than the item count returns everything', () => { + expect(topN(items, (i) => i.id, (i) => i.name, (i) => i.v, 10)).toHaveLength(3); + }); + + it('empty input yields an empty result', () => { + expect(topN([], (i: never) => '', (i: never) => '', (i: never) => 0, 5)).toEqual([]); + }); +}); + +describe('detectThresholdAnomalies', () => { + const series: TimeSeriesPoint[] = [ + { bucketStart: '2026-01-01', value: 5, count: 1 }, + { bucketStart: '2026-01-02', value: 15, count: 1 }, + { bucketStart: '2026-01-03', value: 25, count: 1 }, + ]; + + it('flags points strictly above the threshold', () => { + expect(detectThresholdAnomalies(series, 10)).toEqual([series[1], series[2]]); + }); + + it('empty series yields no anomalies', () => { + expect(detectThresholdAnomalies([], 10)).toEqual([]); + }); + + it('a threshold above every value yields no anomalies', () => { + expect(detectThresholdAnomalies(series, 100)).toEqual([]); + }); +}); From 55b502bb7865d8a8711c652421a8c1c17c096139 Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:04:01 -0600 Subject: [PATCH 10/23] test(api): add unit tests for the snapshot composition root Covers applyScope/applyQueryFilters and buildAnalyticsSnapshot end to end over the fixture, including scoped/unscoped and empty-dataset cases, and asserts the input is never mutated. --- apps/api/src/analytics/engine/index.spec.ts | 113 ++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 apps/api/src/analytics/engine/index.spec.ts diff --git a/apps/api/src/analytics/engine/index.spec.ts b/apps/api/src/analytics/engine/index.spec.ts new file mode 100644 index 0000000..4422b6f --- /dev/null +++ b/apps/api/src/analytics/engine/index.spec.ts @@ -0,0 +1,113 @@ +import type { AnalyticsInput } from '@velar/types'; +import { analyticsFixture, analyticsFixtureIds } from '@velar/types'; +import { applyQueryFilters, applyScope, buildAnalyticsSnapshot } from './index'; + +const clone = (input: AnalyticsInput): AnalyticsInput => JSON.parse(JSON.stringify(input)); +const ids = analyticsFixtureIds; +const FIXED_NOW = new Date('2026-07-01T00:00:00.000Z'); + +describe('applyScope', () => { + it('"all" scope returns the input untouched (same reference)', () => { + const input = clone(analyticsFixture); + expect(applyScope(input, { kind: 'all' })).toBe(input); + }); + + it('"party" scope keeps only that party\'s bonds, transfers and reports', () => { + const input = clone(analyticsFixture); + const scoped = applyScope(input, { kind: 'party', partyId: ids.parties.libertad }); + expect(scoped.bonds.every((b) => b.issuerPartyId === ids.parties.libertad)).toBe(true); + expect(scoped.bonds).toHaveLength(3); + expect(scoped.reports.every((r) => r.partyId === ids.parties.libertad)).toBe(true); + const scopedTokenIds = new Set(scoped.bonds.map((b) => b.tokenId)); + expect(scoped.transfers.every((t) => scopedTokenIds.has(t.bondTokenId))).toBe(true); + }); + + it('an unknown party yields empty collections', () => { + const input = clone(analyticsFixture); + const scoped = applyScope(input, { kind: 'party', partyId: 'no-such-party' }); + expect(scoped).toEqual({ bonds: [], transfers: [], reports: [] }); + }); +}); + +describe('applyQueryFilters', () => { + it('filters bonds/transfers by country', () => { + const input = clone(analyticsFixture); + const filtered = applyQueryFilters(input, { country: 'CO' }); + expect(filtered.bonds.map((b) => b.tokenId).sort()).toEqual(['bond-token-c1', 'bond-token-c2']); + expect(filtered.transfers.map((t) => t.id).sort()).toEqual(['transfer-c1-1', 'transfer-c2-1']); + }); + + it('filters by partyId, scoping reports too', () => { + const input = clone(analyticsFixture); + const filtered = applyQueryFilters(input, { partyId: ids.parties.renovacion }); + expect(filtered.bonds.map((b) => b.tokenId).sort()).toEqual(['bond-token-b1', 'bond-token-b2']); + expect(filtered.reports.every((r) => r.partyId === ids.parties.renovacion)).toBe(true); + }); + + it('filters by date range (from/to)', () => { + const input = clone(analyticsFixture); + const filtered = applyQueryFilters(input, { from: '2026-04-01T00:00:00.000Z' }); + expect(filtered.bonds.every((b) => b.createdAt >= '2026-04-01T00:00:00.000Z')).toBe(true); + expect(filtered.bonds).toHaveLength(2); // c1, c2 + }); + + it('no filters returns everything', () => { + const input = clone(analyticsFixture); + const filtered = applyQueryFilters(input, {}); + expect(filtered.bonds).toHaveLength(input.bonds.length); + expect(filtered.transfers).toHaveLength(input.transfers.length); + expect(filtered.reports).toHaveLength(input.reports.length); + }); +}); + +describe('buildAnalyticsSnapshot', () => { + it('composes every sub-aggregate for the full (unscoped) fixture', () => { + const snapshot = buildAnalyticsSnapshot(clone(analyticsFixture), {}, { kind: 'all' }, FIXED_NOW); + expect(snapshot.valueVolume).toEqual({ + totalBonds: 7, + totalEmittedValue: 16_750_000, + totalTransfers: 9, + totalSales: 3, + totalVolumeMoved: 7_500_000, + }); + expect(snapshot.compliance.parties).toHaveLength(2); + expect(snapshot.generatedAt).toBe(FIXED_NOW.toISOString()); + expect(snapshot.scope).toEqual({ kind: 'all' }); + expect(snapshot.topBonds.slice(0, 3).map((b) => b.key)).toEqual([ + 'bond-token-c2', + 'bond-token-b1', + 'bond-token-a1', + ]); + expect(snapshot.topBonds).toHaveLength(5); + }); + + it('restricts everything to a single party when scoped', () => { + const snapshot = buildAnalyticsSnapshot( + clone(analyticsFixture), + {}, + { kind: 'party', partyId: ids.parties.libertad }, + FIXED_NOW, + ); + expect(snapshot.partyBreakdown).toHaveLength(1); + expect(snapshot.partyBreakdown[0].partyId).toBe(ids.parties.libertad); + expect(snapshot.valueVolume.totalBonds).toBe(3); + }); + + it('never mutates the input', () => { + const input = clone(analyticsFixture); + const before = JSON.stringify(input); + buildAnalyticsSnapshot(input, {}, { kind: 'all' }, FIXED_NOW); + expect(JSON.stringify(input)).toBe(before); + }); + + it('an empty dataset yields a well-formed, all-zero snapshot', () => { + const empty: AnalyticsInput = { bonds: [], transfers: [], reports: [] }; + const snapshot = buildAnalyticsSnapshot(empty, {}, { kind: 'all' }, FIXED_NOW); + expect(snapshot.valueVolume.totalBonds).toBe(0); + expect(snapshot.bondStatusBreakdown).toEqual([]); + expect(snapshot.partyBreakdown).toEqual([]); + expect(snapshot.funnel.totalStarted).toBe(0); + expect(snapshot.compliance.parties).toEqual([]); + expect(snapshot.topBonds).toEqual([]); + }); +}); From 3d8d1dd3590f1c44fd141939436f7b4d63ad906d Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:04:09 -0600 Subject: [PATCH 11/23] feat(api): isolate Supabase access in AnalyticsDataService The only place in the analytics module that touches SupabaseService.admin.from(...); maps bonds/transfers/reports rows to the @velar/types shapes the pure engine consumes, and filters out legacy free-text reports with no period_year/period_month. --- .../analytics/analytics-data.service.spec.ts | 152 ++++++++++++++++++ .../src/analytics/analytics-data.service.ts | 105 ++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 apps/api/src/analytics/analytics-data.service.spec.ts create mode 100644 apps/api/src/analytics/analytics-data.service.ts diff --git a/apps/api/src/analytics/analytics-data.service.spec.ts b/apps/api/src/analytics/analytics-data.service.spec.ts new file mode 100644 index 0000000..b32194d --- /dev/null +++ b/apps/api/src/analytics/analytics-data.service.spec.ts @@ -0,0 +1,152 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AnalyticsDataService } from './analytics-data.service'; +import { SupabaseService } from '../common/supabase/supabase.service'; + +const rawBond = { + token_id: 'tok-1', + bond_id: 'BOND-1', + issuer_party_id: 'party-1', + country: 'CR', + current_owner: 'owner-1', + status: 'activo', + document_hash: 'sha256-x', + face_value: 1000, + currency: 'CRC', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-02T00:00:00.000Z', +}; + +const rawTransfer = { + id: 'transfer-1', + bond_token_id: 'tok-1', + from_owner: 'owner-1', + to_owner: 'owner-2', + status: 'liberada', + amount: 1200, + created_at: '2026-01-05T00:00:00.000Z', + updated_at: '2026-01-10T00:00:00.000Z', +}; + +const rawReportWithPeriod = { + id: 'report-1', + party_id: 'party-1', + period_year: 2026, + period_month: 3, + status: 'aprobado', + current_version: 1, + title: 'Reporte marzo', + submitted_by: 'user-1', + submitted_at: '2026-04-10T00:00:00.000Z', + reviewed_by: 'tse-1', + reviewed_at: '2026-04-12T00:00:00.000Z', + tse_notes: null, + created_at: '2026-04-01T00:00:00.000Z', + updated_at: '2026-04-12T00:00:00.000Z', +}; + +const rawLegacyReport = { + id: 'report-legacy', + party_id: 'party-1', + period_year: null, + period_month: null, + status: 'enviado', + title: 'Reporte legado sin período', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', +}; + +function makeFromMock(overrides: Partial> = {}) { + const defaults = { + bonds: { data: [rawBond], error: null }, + transfers: { data: [rawTransfer], error: null }, + reports: { data: [rawReportWithPeriod, rawLegacyReport], error: null }, + }; + const tables = { ...defaults, ...overrides }; + return jest.fn((table: keyof typeof tables) => ({ + select: () => Promise.resolve(tables[table]), + })); +} + +describe('AnalyticsDataService', () => { + let service: AnalyticsDataService; + + async function build(fromMock: jest.Mock) { + const module: TestingModule = await Test.createTestingModule({ + providers: [AnalyticsDataService, { provide: SupabaseService, useValue: { admin: { from: fromMock } } }], + }).compile(); + return module.get(AnalyticsDataService); + } + + it('maps bonds/transfers/reports rows to the @velar/types camelCase shapes', async () => { + service = await build(makeFromMock()); + const input = await service.getAnalyticsInput(); + + expect(input.bonds).toEqual([ + { + tokenId: 'tok-1', + bondId: 'BOND-1', + issuerPartyId: 'party-1', + country: 'CR', + currentOwner: 'owner-1', + status: 'activo', + documentHash: 'sha256-x', + metadataUri: null, + faceValue: 1000, + certificateNumber: null, + currency: 'CRC', + interestRate: null, + series: null, + issueDate: null, + maturityDate: null, + stellarStatus: null, + stellarTransactionHash: null, + stellarLedger: null, + stellarAssetCode: null, + stellarIssuerPublicKey: null, + stellarOwnerPublicKey: null, + stellarRegisteredAt: null, + stellarError: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + ]); + + expect(input.transfers).toEqual([ + { + id: 'transfer-1', + bondTokenId: 'tok-1', + fromOwner: 'owner-1', + toOwner: 'owner-2', + status: 'liberada', + escrowContractId: null, + paymentEvidenceHash: null, + validatedBy: null, + amount: 1200, + counterOfferAmount: null, + sellerMessage: null, + buyerMessage: null, + createdAt: '2026-01-05T00:00:00.000Z', + updatedAt: '2026-01-10T00:00:00.000Z', + }, + ]); + }); + + it('excludes legacy reports with no period_year/period_month', async () => { + service = await build(makeFromMock()); + const input = await service.getAnalyticsInput(); + expect(input.reports).toHaveLength(1); + expect(input.reports[0]).toMatchObject({ id: 'report-1', periodYear: 2026, periodMonth: 3, currentVersion: 1 }); + }); + + it('returns empty arrays when tables have no rows', async () => { + service = await build(makeFromMock({ bonds: { data: [], error: null }, transfers: { data: null, error: null }, reports: { data: [], error: null } })); + const input = await service.getAnalyticsInput(); + expect(input).toEqual({ bonds: [], transfers: [], reports: [] }); + }); + + it('propagates a Supabase error as BadRequestException', async () => { + service = await build(makeFromMock({ bonds: { data: null, error: { message: 'boom' } } })); + await expect(service.getAnalyticsInput()).rejects.toThrow(BadRequestException); + }); +}); diff --git a/apps/api/src/analytics/analytics-data.service.ts b/apps/api/src/analytics/analytics-data.service.ts new file mode 100644 index 0000000..d50dca8 --- /dev/null +++ b/apps/api/src/analytics/analytics-data.service.ts @@ -0,0 +1,105 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import type { AnalyticsInput, BondToken, MonthlyReport, Transfer } from '@velar/types'; +import { SupabaseService } from '../common/supabase/supabase.service'; + +/** + * The ONLY place in the analytics module that touches Supabase (issue #44). + * Maps snake_case rows to the `@velar/types` shapes the pure engine consumes. + * Mirrors the mapper style of `AuditService`/`ReportLifecycleService` — each + * module keeps its own small row mapper rather than sharing one. + */ +@Injectable() +export class AnalyticsDataService { + constructor(private supabase: SupabaseService) {} + + async getAnalyticsInput(): Promise { + const [bondsRes, transfersRes, reportsRes] = await Promise.all([ + this.supabase.admin.from('bonds').select('*'), + this.supabase.admin.from('transfers').select('*'), + this.supabase.admin.from('reports').select('*'), + ]); + + if (bondsRes.error) throw new BadRequestException(bondsRes.error.message); + if (transfersRes.error) throw new BadRequestException(transfersRes.error.message); + if (reportsRes.error) throw new BadRequestException(reportsRes.error.message); + + return { + bonds: (bondsRes.data ?? []).map((b: any) => this.mapBond(b)), + transfers: (transfersRes.data ?? []).map((t: any) => this.mapTransfer(t)), + // `reports` also holds rows from the legacy free-text model (pre-lifecycle + // migration) which have no period_year/period_month — those can't feed + // compliance/period aggregation, so they're excluded here. + reports: (reportsRes.data ?? []) + .filter((r: any) => r.period_year != null && r.period_month != null) + .map((r: any) => this.mapReport(r)), + }; + } + + private mapBond(bond: any): BondToken { + return { + tokenId: bond.token_id, + bondId: bond.bond_id, + issuerPartyId: bond.issuer_party_id, + country: bond.country ?? null, + currentOwner: bond.current_owner, + status: bond.status, + documentHash: bond.document_hash, + metadataUri: bond.metadata_uri ?? null, + faceValue: bond.face_value ?? null, + certificateNumber: bond.certificate_number ?? null, + currency: bond.currency ?? null, + interestRate: bond.interest_rate ?? null, + series: bond.series ?? null, + issueDate: bond.issue_date ?? null, + maturityDate: bond.maturity_date ?? null, + stellarStatus: bond.stellar_status ?? null, + stellarTransactionHash: bond.stellar_transaction_hash ?? null, + stellarLedger: bond.stellar_ledger ?? null, + stellarAssetCode: bond.stellar_asset_code ?? null, + stellarIssuerPublicKey: bond.stellar_issuer_public_key ?? null, + stellarOwnerPublicKey: bond.stellar_owner_public_key ?? null, + stellarRegisteredAt: bond.stellar_registered_at ?? null, + stellarError: bond.stellar_error ?? null, + createdAt: bond.created_at, + updatedAt: bond.updated_at, + }; + } + + private mapTransfer(transfer: any): Transfer { + return { + id: transfer.id, + bondTokenId: transfer.bond_token_id, + fromOwner: transfer.from_owner, + toOwner: transfer.to_owner, + status: transfer.status, + escrowContractId: transfer.escrow_contract_id ?? null, + paymentEvidenceHash: transfer.payment_evidence_hash ?? null, + validatedBy: transfer.validated_by ?? null, + amount: transfer.amount ?? null, + counterOfferAmount: transfer.counter_offer_amount ?? null, + sellerMessage: transfer.seller_message ?? null, + buyerMessage: transfer.buyer_message ?? null, + createdAt: transfer.created_at, + updatedAt: transfer.updated_at, + }; + } + + private mapReport(r: any): MonthlyReport { + return { + id: r.id, + partyId: r.party_id, + periodYear: r.period_year, + periodMonth: r.period_month, + status: r.status, + currentVersion: r.current_version ?? 0, + title: r.title, + submittedBy: r.submitted_by ?? null, + submittedAt: r.submitted_at ?? null, + reviewedBy: r.reviewed_by ?? null, + reviewedAt: r.reviewed_at ?? null, + tseNotes: r.tse_notes ?? null, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; + } +} From ea1280dd077765513cf848a134446871c782c1cf Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:04:20 -0600 Subject: [PATCH 12/23] feat(api): add deterministic CSV snapshot export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure renderSnapshotCsv(snapshot) — fully fixture-testable, unlike the legacy exportTransfersCsv which needs live-joined Supabase rows for seller/buyer names (kept separately for drill-down compatibility). --- .../src/analytics/csv/analytics-csv.spec.ts | 43 ++++++++++++++++ apps/api/src/analytics/csv/analytics-csv.ts | 51 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 apps/api/src/analytics/csv/analytics-csv.spec.ts create mode 100644 apps/api/src/analytics/csv/analytics-csv.ts diff --git a/apps/api/src/analytics/csv/analytics-csv.spec.ts b/apps/api/src/analytics/csv/analytics-csv.spec.ts new file mode 100644 index 0000000..b132a85 --- /dev/null +++ b/apps/api/src/analytics/csv/analytics-csv.spec.ts @@ -0,0 +1,43 @@ +import type { AnalyticsInput } from '@velar/types'; +import { analyticsFixture } from '@velar/types'; +import { buildAnalyticsSnapshot } from '../engine'; +import { renderSnapshotCsv } from './analytics-csv'; + +const clone = (input: AnalyticsInput): AnalyticsInput => JSON.parse(JSON.stringify(input)); +const FIXED_NOW = new Date('2026-07-01T00:00:00.000Z'); + +describe('renderSnapshotCsv', () => { + it('renders a deterministic, BOM-prefixed CSV with every section', () => { + const snapshot = buildAnalyticsSnapshot(clone(analyticsFixture), {}, { kind: 'all' }, FIXED_NOW); + const csv = renderSnapshotCsv(snapshot); + + expect(csv.startsWith('\uFEFFgenerated_at,2026-07-01T00:00:00.000Z')).toBe(true); + expect(csv).toContain('section,status,count,face_value'); + expect(csv).toContain('bond_status,transferido,3,7200000'); + expect(csv).toContain('section,party_id,bonds_count,emitted_value,sales_count,volume_moved'); + expect(csv).toContain('section,country,bonds_count,emitted_value,sales_count,volume_moved'); + expect(csv).toContain('value_volume,total_bonds,7'); + expect(csv).toContain('value_volume,total_volume_moved,7500000'); + }); + + it('is deterministic: same snapshot produces byte-identical CSV', () => { + const snapshot = buildAnalyticsSnapshot(clone(analyticsFixture), {}, { kind: 'all' }, FIXED_NOW); + expect(renderSnapshotCsv(snapshot)).toBe(renderSnapshotCsv(snapshot)); + }); + + it('escapes commas/quotes in cell values', () => { + const snapshot = buildAnalyticsSnapshot(clone(analyticsFixture), {}, { kind: 'all' }, FIXED_NOW); + snapshot.partyBreakdown = [ + { partyId: 'party, "weird"', bondsCount: 1, emittedValue: 100, salesCount: 0, volumeMoved: 0 }, + ]; + const csv = renderSnapshotCsv(snapshot); + expect(csv).toContain('"party, ""weird""",1,100,0,0'); + }); + + it('an empty snapshot still renders every header row', () => { + const snapshot = buildAnalyticsSnapshot({ bonds: [], transfers: [], reports: [] }, {}, { kind: 'all' }, FIXED_NOW); + const csv = renderSnapshotCsv(snapshot); + expect(csv).toContain('value_volume,total_bonds,0'); + expect(csv).not.toContain('bond_status,'); // no status rows for an empty dataset + }); +}); diff --git a/apps/api/src/analytics/csv/analytics-csv.ts b/apps/api/src/analytics/csv/analytics-csv.ts new file mode 100644 index 0000000..afa7121 --- /dev/null +++ b/apps/api/src/analytics/csv/analytics-csv.ts @@ -0,0 +1,51 @@ +import type { AnalyticsSnapshot } from '@velar/types'; + +/** + * Deterministic CSV export of an `AnalyticsSnapshot` (issue #44). Pure — no + * I/O — so it's fully fixture-testable, unlike the old `exportTransfersCsv` + * which needed live joined Supabase rows (seller/buyer names) that don't + * exist in the fixture-fed `AnalyticsInput` model. + */ + +function csvCell(value: string | number): string { + const s = String(value); + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +function row(cells: (string | number)[]): string { + return cells.map(csvCell).join(','); +} + +export function renderSnapshotCsv(snapshot: AnalyticsSnapshot): string { + const lines: string[] = []; + + lines.push(row(['generated_at', snapshot.generatedAt])); + lines.push(''); + + lines.push(row(['section', 'status', 'count', 'face_value'])); + for (const b of snapshot.bondStatusBreakdown) { + lines.push(row(['bond_status', b.status, b.count, b.faceValue])); + } + lines.push(''); + + lines.push(row(['section', 'party_id', 'bonds_count', 'emitted_value', 'sales_count', 'volume_moved'])); + for (const p of snapshot.partyBreakdown) { + lines.push(row(['party', p.partyId, p.bondsCount, p.emittedValue, p.salesCount, p.volumeMoved])); + } + lines.push(''); + + lines.push(row(['section', 'country', 'bonds_count', 'emitted_value', 'sales_count', 'volume_moved'])); + for (const c of snapshot.countryBreakdown) { + lines.push(row(['country', c.country, c.bondsCount, c.emittedValue, c.salesCount, c.volumeMoved])); + } + lines.push(''); + + lines.push(row(['section', 'metric', 'value'])); + lines.push(row(['value_volume', 'total_bonds', snapshot.valueVolume.totalBonds])); + lines.push(row(['value_volume', 'total_emitted_value', snapshot.valueVolume.totalEmittedValue])); + lines.push(row(['value_volume', 'total_transfers', snapshot.valueVolume.totalTransfers])); + lines.push(row(['value_volume', 'total_sales', snapshot.valueVolume.totalSales])); + lines.push(row(['value_volume', 'total_volume_moved', snapshot.valueVolume.totalVolumeMoved])); + + return `\uFEFF${lines.join('\r\n')}\r\n`; +} From aa7cf2baefec568bdef2c67b80ed8f651f9751f1 Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:04:28 -0600 Subject: [PATCH 13/23] feat(api): add PDF snapshot export via pdf-lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderAnalyticsPdf(snapshot) — pure Node, no headless browser. PDF internal object ordering isn't byte-stable across runs, so tests are structural (valid %PDF- header, re-parseable, expected page count) rather than byte-exact snapshots. --- apps/api/package.json | 1 + .../src/analytics/pdf/analytics-pdf.spec.ts | 46 +++++++++++++++ apps/api/src/analytics/pdf/analytics-pdf.ts | 59 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 apps/api/src/analytics/pdf/analytics-pdf.spec.ts create mode 100644 apps/api/src/analytics/pdf/analytics-pdf.ts diff --git a/apps/api/package.json b/apps/api/package.json index 33e6b06..d06fad0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -43,6 +43,7 @@ "express": "^5.1.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pdf-lib": "^1.17.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "ws": "^8.21.0", diff --git a/apps/api/src/analytics/pdf/analytics-pdf.spec.ts b/apps/api/src/analytics/pdf/analytics-pdf.spec.ts new file mode 100644 index 0000000..2e70ae8 --- /dev/null +++ b/apps/api/src/analytics/pdf/analytics-pdf.spec.ts @@ -0,0 +1,46 @@ +import { PDFDocument } from 'pdf-lib'; +import type { AnalyticsInput } from '@velar/types'; +import { analyticsFixture } from '@velar/types'; +import { buildAnalyticsSnapshot } from '../engine'; +import { renderAnalyticsPdf } from './analytics-pdf'; + +const clone = (input: AnalyticsInput): AnalyticsInput => JSON.parse(JSON.stringify(input)); +const FIXED_NOW = new Date('2026-07-01T00:00:00.000Z'); + +/** + * PDF internal object ordering/IDs are not byte-stable across runs, so these + * assertions are STRUCTURAL (valid PDF, re-parseable, has content) rather than + * byte-exact snapshots — per the plan's documented trade-off. + */ +describe('renderAnalyticsPdf', () => { + it('produces a well-formed, re-parseable PDF with at least one page', async () => { + const snapshot = buildAnalyticsSnapshot(clone(analyticsFixture), {}, { kind: 'all' }, FIXED_NOW); + const buffer = await renderAnalyticsPdf(snapshot); + + expect(buffer.length).toBeGreaterThan(0); + expect(buffer.subarray(0, 5).toString('latin1')).toBe('%PDF-'); + + const reloaded = await PDFDocument.load(buffer); + expect(reloaded.getPageCount()).toBeGreaterThanOrEqual(1); + }); + + it('an empty snapshot still produces a valid single-page PDF', async () => { + const snapshot = buildAnalyticsSnapshot({ bonds: [], transfers: [], reports: [] }, {}, { kind: 'all' }, FIXED_NOW); + const buffer = await renderAnalyticsPdf(snapshot); + const reloaded = await PDFDocument.load(buffer); + expect(reloaded.getPageCount()).toBe(1); + }); + + it('a snapshot with many rows overflows onto more than one page', async () => { + const manyStatuses = Array.from({ length: 60 }, (_, i) => ({ + status: 'activo' as const, + count: i, + faceValue: i * 100, + })); + const snapshot = buildAnalyticsSnapshot({ bonds: [], transfers: [], reports: [] }, {}, { kind: 'all' }, FIXED_NOW); + snapshot.bondStatusBreakdown = manyStatuses; + const buffer = await renderAnalyticsPdf(snapshot); + const reloaded = await PDFDocument.load(buffer); + expect(reloaded.getPageCount()).toBeGreaterThan(1); + }); +}); diff --git a/apps/api/src/analytics/pdf/analytics-pdf.ts b/apps/api/src/analytics/pdf/analytics-pdf.ts new file mode 100644 index 0000000..5b20e89 --- /dev/null +++ b/apps/api/src/analytics/pdf/analytics-pdf.ts @@ -0,0 +1,59 @@ +import { PDFDocument, PDFFont, StandardFonts, rgb } from 'pdf-lib'; +import type { AnalyticsSnapshot } from '@velar/types'; + +/** + * Deterministic PDF export of an `AnalyticsSnapshot` (issue #44). Pure Node, + * no headless browser/vendor. Content is deterministic given the same + * snapshot; internal PDF object ordering/IDs are not byte-stable across + * pdf-lib versions, so tests assert structurally (see analytics-pdf.spec.ts). + */ +export async function renderAnalyticsPdf(snapshot: AnalyticsSnapshot): Promise { + const doc = await PDFDocument.create(); + const font = await doc.embedFont(StandardFonts.Helvetica); + const bold = await doc.embedFont(StandardFonts.HelveticaBold); + + const margin = 50; + let page = doc.addPage(); + let y = page.getSize().height - margin; + + const draw = (text: string, size = 11, useFont: PDFFont = font) => { + if (y < margin + size) { + page = doc.addPage(); + y = page.getSize().height - margin; + } + page.drawText(text, { x: margin, y, size, font: useFont, color: rgb(0, 0, 0) }); + y -= size + 6; + }; + + draw('VELAR — Reporte de analítica', 16, bold); + draw(`Generado: ${snapshot.generatedAt}`); + draw(''); + + draw('Resumen', 13, bold); + draw(`Bonos totales: ${snapshot.valueVolume.totalBonds}`); + draw(`Valor emitido: ${snapshot.valueVolume.totalEmittedValue}`); + draw(`Transferencias: ${snapshot.valueVolume.totalTransfers}`); + draw(`Ventas liberadas: ${snapshot.valueVolume.totalSales}`); + draw(`Volumen movido: ${snapshot.valueVolume.totalVolumeMoved}`); + draw(''); + + draw('Bonos por estado', 13, bold); + for (const b of snapshot.bondStatusBreakdown) { + draw(`${b.status}: ${b.count} bono(s), valor ${b.faceValue}`); + } + draw(''); + + draw('Por partido', 13, bold); + for (const p of snapshot.partyBreakdown) { + draw(`${p.partyId}: ${p.bondsCount} bono(s), volumen movido ${p.volumeMoved}`); + } + draw(''); + + draw('Embudo de transferencias', 13, bold); + for (const stage of snapshot.funnel.stages) { + draw(`${stage.step}: ${stage.reachedCount} (${stage.conversionFromStartPct}%)`); + } + + const bytes = await doc.save(); + return Buffer.from(bytes); +} From 45ce672e57b8218b8a4742051b59204c4730eb7c Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:04:37 -0600 Subject: [PATCH 14/23] feat(api): add scheduled report generator interface and manual stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScheduledReportGenerator + SCHEDULED_REPORT_GENERATOR DI token, same interface+stub discipline as the antivirus hook in reports/files/file-scanner.ts. ManualScheduledReportGenerator produces a CSV/PDF summary from the current snapshot on demand — no cron, no vendor. --- .../manual-scheduled-report.generator.spec.ts | 39 +++++++++++++++++++ .../manual-scheduled-report.generator.ts | 32 +++++++++++++++ .../scheduled-report-generator.interface.ts | 13 +++++++ 3 files changed, 84 insertions(+) create mode 100644 apps/api/src/analytics/scheduled-report/manual-scheduled-report.generator.spec.ts create mode 100644 apps/api/src/analytics/scheduled-report/manual-scheduled-report.generator.ts create mode 100644 apps/api/src/analytics/scheduled-report/scheduled-report-generator.interface.ts diff --git a/apps/api/src/analytics/scheduled-report/manual-scheduled-report.generator.spec.ts b/apps/api/src/analytics/scheduled-report/manual-scheduled-report.generator.spec.ts new file mode 100644 index 0000000..21a6eb5 --- /dev/null +++ b/apps/api/src/analytics/scheduled-report/manual-scheduled-report.generator.spec.ts @@ -0,0 +1,39 @@ +import { PDFDocument } from 'pdf-lib'; +import type { AnalyticsInput, ScheduledReportConfig } from '@velar/types'; +import { analyticsFixture } from '@velar/types'; +import { buildAnalyticsSnapshot } from '../engine'; +import { ManualScheduledReportGenerator } from './manual-scheduled-report.generator'; + +const clone = (input: AnalyticsInput): AnalyticsInput => JSON.parse(JSON.stringify(input)); +const FIXED_NOW = new Date('2026-07-01T00:00:00.000Z'); + +describe('ManualScheduledReportGenerator', () => { + const generator = new ManualScheduledReportGenerator(); + const snapshot = buildAnalyticsSnapshot(clone(analyticsFixture), {}, { kind: 'all' }, FIXED_NOW); + + const baseConfig: Omit = { + id: 'config-1', + cadence: 'monthly', + scope: { kind: 'all' }, + recipients: [], + }; + + it('generates a CSV result', async () => { + const result = await generator.generate({ ...baseConfig, format: 'csv' }, snapshot); + expect(result.mimeType).toBe('text/csv; charset=utf-8'); + expect(result.encoding).toBe('utf-8'); + expect(result.filename).toBe('velar-analytics-monthly-2026-07-01.csv'); + expect(result.content).toContain('value_volume,total_bonds,7'); + }); + + it('generates a PDF result, base64-encoded and re-parseable', async () => { + const result = await generator.generate({ ...baseConfig, format: 'pdf' }, snapshot); + expect(result.mimeType).toBe('application/pdf'); + expect(result.encoding).toBe('base64'); + expect(result.filename).toBe('velar-analytics-monthly-2026-07-01.pdf'); + + const buffer = Buffer.from(result.content, 'base64'); + const doc = await PDFDocument.load(buffer); + expect(doc.getPageCount()).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/apps/api/src/analytics/scheduled-report/manual-scheduled-report.generator.ts b/apps/api/src/analytics/scheduled-report/manual-scheduled-report.generator.ts new file mode 100644 index 0000000..9b2994c --- /dev/null +++ b/apps/api/src/analytics/scheduled-report/manual-scheduled-report.generator.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import type { AnalyticsSnapshot, ScheduledReportConfig, ScheduledReportResult } from '@velar/types'; +import { renderSnapshotCsv } from '../csv/analytics-csv'; +import { renderAnalyticsPdf } from '../pdf/analytics-pdf'; +import type { ScheduledReportGenerator } from './scheduled-report-generator.interface'; + +/** + * Manual-trigger stub implementation (issue #44): produces a CSV/PDF summary + * from the CURRENT snapshot on demand (`POST /analytics/scheduled-reports/run`). + * No cron, no delivery — that's future work behind this same interface. + */ +@Injectable() +export class ManualScheduledReportGenerator implements ScheduledReportGenerator { + async generate(config: ScheduledReportConfig, snapshot: AnalyticsSnapshot): Promise { + const timestamp = snapshot.generatedAt.slice(0, 10); + if (config.format === 'pdf') { + const buffer = await renderAnalyticsPdf(snapshot); + return { + filename: `velar-analytics-${config.cadence}-${timestamp}.pdf`, + mimeType: 'application/pdf', + encoding: 'base64', + content: buffer.toString('base64'), + }; + } + return { + filename: `velar-analytics-${config.cadence}-${timestamp}.csv`, + mimeType: 'text/csv; charset=utf-8', + encoding: 'utf-8', + content: renderSnapshotCsv(snapshot), + }; + } +} diff --git a/apps/api/src/analytics/scheduled-report/scheduled-report-generator.interface.ts b/apps/api/src/analytics/scheduled-report/scheduled-report-generator.interface.ts new file mode 100644 index 0000000..230ac4e --- /dev/null +++ b/apps/api/src/analytics/scheduled-report/scheduled-report-generator.interface.ts @@ -0,0 +1,13 @@ +import type { AnalyticsSnapshot, ScheduledReportConfig, ScheduledReportResult } from '@velar/types'; + +/** + * Scheduled report generation, behind an INTERFACE + STUB (issue #44), same + * discipline as `apps/api/src/reports/files/file-scanner.ts`'s antivirus hook: + * no real cron/vendor. In production, a concrete cron-backed implementation + * is injected without touching `AnalyticsService`. + */ +export const SCHEDULED_REPORT_GENERATOR = Symbol('SCHEDULED_REPORT_GENERATOR'); + +export interface ScheduledReportGenerator { + generate(config: ScheduledReportConfig, snapshot: AnalyticsSnapshot): Promise; +} From ac64e18fe57db26124908138987c7d8745de293b Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:04:46 -0600 Subject: [PATCH 15/23] feat(db): add migration for analytics saved views and alert rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New tables analytics_saved_views (RLS: owner) and analytics_alert_rules (RLS: tse/admin), reusing the existing set_updated_at() trigger and public.auth_role() helper. Additive only — no existing migration is touched. --- .../20260728000000_analytics_saved_views.sql | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 supabase/migrations/20260728000000_analytics_saved_views.sql diff --git a/supabase/migrations/20260728000000_analytics_saved_views.sql b/supabase/migrations/20260728000000_analytics_saved_views.sql new file mode 100644 index 0000000..ab40dc2 --- /dev/null +++ b/supabase/migrations/20260728000000_analytics_saved_views.sql @@ -0,0 +1,59 @@ +-- VELAR: soporte de persistencia para el dashboard de analítica (issue #44). +-- Dos tablas nuevas, append/update-only en el sentido de que no se toca ninguna +-- migración existente (docs/AGENTS.md §3): vistas guardadas del dashboard +-- (por usuario) y reglas de alerta de umbral (configuración, TSE/admin). +-- Usa public.auth_role() (20260602000001_fix_profiles_rls_recursion.sql) para +-- evitar la recursión de RLS sobre `profiles` documentada en docs/AGENTS.md §5. + +-- ── 1. Vistas guardadas del dashboard (por usuario) ──────────────────────── +CREATE TABLE IF NOT EXISTS analytics_saved_views ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + role text NOT NULL, + name text NOT NULL, + query jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_analytics_saved_views_owner ON analytics_saved_views(owner_id); + +ALTER TABLE analytics_saved_views ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS analytics_saved_views_owner_rw ON analytics_saved_views; +CREATE POLICY analytics_saved_views_owner_rw ON analytics_saved_views + FOR ALL TO authenticated + USING (owner_id = auth.uid()) + WITH CHECK (owner_id = auth.uid()); + +-- ── 2. Reglas de alerta de umbral (configuración, TSE/admin) ─────────────── +CREATE TABLE IF NOT EXISTS analytics_alert_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + metric_path text NOT NULL, + comparator text NOT NULL CHECK (comparator IN ('gt', 'lt', 'gte', 'lte')), + threshold numeric NOT NULL, + scope jsonb NOT NULL DEFAULT '{"kind":"all"}'::jsonb, + notify_user_ids uuid[] NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE analytics_alert_rules ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS analytics_alert_rules_tse_admin ON analytics_alert_rules; +CREATE POLICY analytics_alert_rules_tse_admin ON analytics_alert_rules + FOR ALL TO authenticated + USING (public.auth_role() IN ('tse', 'admin')) + WITH CHECK (public.auth_role() IN ('tse', 'admin')); + +-- ── 3. updated_at automático en ambas tablas ──────────────────────────────── +-- Reutiliza set_updated_at() (definida en 20260601000000_initial_schema.sql). +DROP TRIGGER IF EXISTS trg_analytics_saved_views_updated_at ON analytics_saved_views; +CREATE TRIGGER trg_analytics_saved_views_updated_at + BEFORE UPDATE ON analytics_saved_views + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_analytics_alert_rules_updated_at ON analytics_alert_rules; +CREATE TRIGGER trg_analytics_alert_rules_updated_at + BEFORE UPDATE ON analytics_alert_rules + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); From 782e31aeb788d62d1e5212bcaa65d154723e392c Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:04:55 -0600 Subject: [PATCH 16/23] feat(api): rewrite analytics service and controller with RBAC scoping resolveScope: TSE/admin see every party, emisor sees only their own party, every other role is forbidden from aggregate analytics. Adds GET /analytics/snapshot, snapshot-based CSV/PDF export, saved-views and alert-rule CRUD (@Roles('tse','admin') on privileged config routes), and manual scheduled-report trigger. Legacy bond-detail drill-down routes (price-history/owners/top-bonds) and the named-transfer CSV export are kept working unchanged, moved to /legacy-export. --- .../api/src/analytics/analytics.controller.ts | 136 ++++++-- apps/api/src/analytics/analytics.module.ts | 14 +- apps/api/src/analytics/analytics.service.ts | 293 ++++++++++++------ 3 files changed, 322 insertions(+), 121 deletions(-) diff --git a/apps/api/src/analytics/analytics.controller.ts b/apps/api/src/analytics/analytics.controller.ts index 5815e75..ad74d24 100644 --- a/apps/api/src/analytics/analytics.controller.ts +++ b/apps/api/src/analytics/analytics.controller.ts @@ -1,22 +1,48 @@ -import { Controller, Get, Param, Query, StreamableFile, UseGuards } from '@nestjs/common'; -import { AnalyticsService } from './analytics.service'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, StreamableFile, UseGuards } from '@nestjs/common'; +import type { AlertRuleInput, AnalyticsBucket, AnalyticsQuery, Role, SavedViewInput, ScheduledReportConfig } from '@velar/types'; import { AuthGuard } from '../auth/auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; -import { Role } from '@velar/types'; +import { Roles } from '../auth/roles.decorator'; +import { AnalyticsService } from './analytics.service'; + +/** Raw query-string shape as Express/Nest hands it to us — everything is a string or absent. */ +interface RawAnalyticsQuery { + from?: string; + to?: string; + country?: string; + partyId?: string; + status?: string; + bucket?: string; +} + +function toAnalyticsQuery(q: RawAnalyticsQuery): AnalyticsQuery { + return { + from: q.from ?? null, + to: q.to ?? null, + country: (q.country as AnalyticsQuery['country']) ?? null, + partyId: q.partyId ?? null, + status: (q.status as AnalyticsQuery['status']) ?? null, + bucket: (q.bucket as AnalyticsBucket) ?? undefined, + }; +} @Controller('analytics') @UseGuards(AuthGuard) export class AnalyticsController { constructor(private analytics: AnalyticsService) {} - @Get('overview') - overview(@CurrentUser() user: any) { - return this.analytics.overview(user.profile?.role as Role); + // ─── Snapshot ─────────────────────────────────────────────────────────────── + + @Get('snapshot') + snapshot(@Query() q: RawAnalyticsQuery, @CurrentUser() user: any) { + return this.analytics.getSnapshot(user.profile?.role as Role, user.profile?.party_id ?? null, toAnalyticsQuery(q)); } - @Get('by-party') - byParty(@CurrentUser() user: any) { - return this.analytics.byParty(user.profile?.role as Role); + // ─── Legacy bond-detail drill-down (unchanged routes/behavior) ───────────── + + @Get('top-bonds') + legacyTopBonds(@Query('limit') limit: string | undefined, @CurrentUser() user: any) { + return this.analytics.topBonds(user.profile?.role as Role, limit ? Number(limit) : 5); } @Get('bonds/:tokenId/price-history') @@ -29,23 +55,93 @@ export class AnalyticsController { return this.analytics.bondOwners(tokenId, user.profile?.role as Role); } - @Get('top-bonds') - top(@Query('limit') limit: string | undefined, @CurrentUser() user: any) { - return this.analytics.topBonds(user.profile?.role as Role, limit ? Number(limit) : 5); + @Get('legacy-export') + async legacyExport(@Query('format') format: string | undefined, @CurrentUser() user: any) { + const csv = await this.analytics.exportTransfersCsv(user.profile?.role as Role, format); + const filename = `velar-transfers-${new Date().toISOString().slice(0, 10)}.csv`; + return new StreamableFile(Buffer.from(csv, 'utf-8'), { + type: 'text/csv; charset=utf-8', + disposition: `attachment; filename="${filename}"`, + }); } - @Get('volume-over-time') - volume(@Query('days') days: string | undefined, @CurrentUser() user: any) { - return this.analytics.volumeOverTime(user.profile?.role as Role, days ? Number(days) : 30); - } + // ─── Snapshot-based CSV/PDF export (issue #44) ───────────────────────────── @Get('export') - async export(@Query('format') format: string | undefined, @CurrentUser() user: any) { - const csv = await this.analytics.exportTransfersCsv(user.profile?.role as Role, format); - const filename = `velar-transfers-${new Date().toISOString().slice(0, 10)}.csv`; + async export(@Query('format') format: string | undefined, @Query() q: RawAnalyticsQuery, @CurrentUser() user: any) { + const role = user.profile?.role as Role; + const partyId = user.profile?.party_id ?? null; + const query = toAnalyticsQuery(q); + const timestamp = new Date().toISOString().slice(0, 10); + + if (format === 'pdf') { + const buffer = await this.analytics.exportPdf(role, partyId, query); + return new StreamableFile(buffer, { + type: 'application/pdf', + disposition: `attachment; filename="velar-analytics-${timestamp}.pdf"`, + }); + } + const csv = await this.analytics.exportCsv(role, partyId, query); return new StreamableFile(Buffer.from(csv, 'utf-8'), { type: 'text/csv; charset=utf-8', - disposition: `attachment; filename="${filename}"`, + disposition: `attachment; filename="velar-analytics-${timestamp}.csv"`, }); } + + // ─── Saved views (owner-scoped, any authenticated role) ──────────────────── + + @Get('views') + listViews(@CurrentUser() user: any) { + return this.analytics.listSavedViews(user.id); + } + + @Post('views') + createView(@Body() body: SavedViewInput, @CurrentUser() user: any) { + return this.analytics.createSavedView(user.id, user.profile?.role as Role, body); + } + + @Delete('views/:id') + deleteView(@Param('id') id: string, @CurrentUser() user: any) { + return this.analytics.deleteSavedView(id, user.id); + } + + // ─── Alert rules (config is TSE/admin-only privileged action) ────────────── + + @Get('alert-rules') + @Roles('tse', 'admin') + listAlertRules() { + return this.analytics.listAlertRules(); + } + + @Post('alert-rules') + @Roles('tse', 'admin') + createAlertRule(@Body() body: AlertRuleInput) { + return this.analytics.createAlertRule(body); + } + + @Patch('alert-rules/:id') + @Roles('tse', 'admin') + updateAlertRule(@Param('id') id: string, @Body() body: Partial) { + return this.analytics.updateAlertRule(id, body); + } + + @Delete('alert-rules/:id') + @Roles('tse', 'admin') + deleteAlertRule(@Param('id') id: string) { + return this.analytics.deleteAlertRule(id); + } + + @Post('alert-rules/:id/evaluate') + @Roles('tse', 'admin') + evaluateAlertRule(@Param('id') id: string) { + return this.analytics.evaluateAlertRule(id); + } + + // ─── Scheduled report (manual trigger, no cron) ──────────────────────────── + + @Post('scheduled-reports/run') + @Roles('tse', 'admin') + runScheduledReport(@Body() body: Omit) { + return this.analytics.runScheduledReport({ id: `manual-${Date.now()}`, ...body }); + } } diff --git a/apps/api/src/analytics/analytics.module.ts b/apps/api/src/analytics/analytics.module.ts index 2ba2695..9a6de2e 100644 --- a/apps/api/src/analytics/analytics.module.ts +++ b/apps/api/src/analytics/analytics.module.ts @@ -1,11 +1,19 @@ import { Module } from '@nestjs/common'; +import { SupabaseModule } from '../common/supabase/supabase.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { AnalyticsController } from './analytics.controller'; +import { AnalyticsDataService } from './analytics-data.service'; import { AnalyticsService } from './analytics.service'; -import { SupabaseModule } from '../common/supabase/supabase.module'; +import { ManualScheduledReportGenerator } from './scheduled-report/manual-scheduled-report.generator'; +import { SCHEDULED_REPORT_GENERATOR } from './scheduled-report/scheduled-report-generator.interface'; @Module({ - imports: [SupabaseModule], + imports: [SupabaseModule, NotificationsModule], controllers: [AnalyticsController], - providers: [AnalyticsService], + providers: [ + AnalyticsService, + AnalyticsDataService, + { provide: SCHEDULED_REPORT_GENERATOR, useClass: ManualScheduledReportGenerator }, + ], }) export class AnalyticsModule {} diff --git a/apps/api/src/analytics/analytics.service.ts b/apps/api/src/analytics/analytics.service.ts index 2cf7b75..e3d958d 100644 --- a/apps/api/src/analytics/analytics.service.ts +++ b/apps/api/src/analytics/analytics.service.ts @@ -1,90 +1,222 @@ -import { BadRequestException, Injectable, ForbiddenException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import type { + AlertBreach, + AlertRule, + AlertRuleInput, + AnalyticsQuery, + AnalyticsScope, + AnalyticsSnapshot, + Role, + SavedView, + SavedViewInput, + ScheduledReportConfig, + ScheduledReportResult, +} from '@velar/types'; +import { NotificationType } from '@velar/types'; import { SupabaseService } from '../common/supabase/supabase.service'; -import { Role } from '@velar/types'; +import { NotificationsService } from '../notifications/notifications.service'; +import { AnalyticsDataService } from './analytics-data.service'; +import { renderSnapshotCsv } from './csv/analytics-csv'; +import { applyScope, buildAnalyticsSnapshot, evaluateAlertRules } from './engine'; +import { renderAnalyticsPdf } from './pdf/analytics-pdf'; +import { SCHEDULED_REPORT_GENERATOR, ScheduledReportGenerator } from './scheduled-report/scheduled-report-generator.interface'; const AUTHORITY: Role[] = ['tse', 'admin']; +/** Legacy CSV columns, kept for the (unchanged) transfer-detail drill-down export. */ const CSV_HEADERS = ['bond_id', 'transfer_date', 'seller_name', 'buyer_name', 'amount_colones', 'party_name'] as const; @Injectable() export class AnalyticsService { - constructor(private supabase: SupabaseService) {} + constructor( + private data: AnalyticsDataService, + private supabase: SupabaseService, + private notifications: NotificationsService, + @Inject(SCHEDULED_REPORT_GENERATOR) private scheduledReportGenerator: ScheduledReportGenerator, + ) {} private assertAuth(role: Role) { if (!AUTHORITY.includes(role)) throw new ForbiddenException('Solo TSE/admin'); } - private assertTseOnly(role: Role) { - if (role !== 'tse') throw new ForbiddenException('Solo TSE'); + /** TSE/admin see every party; `emisor` sees only their own party's data. Everyone else is out of scope for aggregate analytics. */ + private resolveScope(role: Role, partyId: string | null): AnalyticsScope { + if (AUTHORITY.includes(role)) return { kind: 'all' }; + if (role === 'emisor') { + if (!partyId) throw new ForbiddenException('Tu perfil no tiene un partido asociado'); + return { kind: 'party', partyId }; + } + throw new ForbiddenException('No autorizado para ver analítica'); } - /** Overview general del sistema. */ - async overview(role: Role) { - this.assertAuth(role); - const db = this.supabase.admin; + // ─── Snapshot & exports ───────────────────────────────────────────────────── - const [bondsRes, transfersRes, requestsRes] = await Promise.all([ - db.from('bonds').select('*, parties(id, name)'), - db.from('transfers').select('amount, status, from_owner, to_owner, bond_token_id, created_at, bonds(issuer_party_id, parties(name))'), - db.from('bond_requests').select('id, status'), - ]); + async getSnapshot(role: Role, partyId: string | null, query: AnalyticsQuery = {}): Promise { + const scope = this.resolveScope(role, partyId); + const input = await this.data.getAnalyticsInput(); + return buildAnalyticsSnapshot(input, query, scope); + } + + async exportCsv(role: Role, partyId: string | null, query: AnalyticsQuery = {}): Promise { + return renderSnapshotCsv(await this.getSnapshot(role, partyId, query)); + } - const bonds = bondsRes.data ?? []; - const transfers = transfersRes.data ?? []; - const requests = requestsRes.data ?? []; + async exportPdf(role: Role, partyId: string | null, query: AnalyticsQuery = {}): Promise { + return renderAnalyticsPdf(await this.getSnapshot(role, partyId, query)); + } - const liberadas = transfers.filter((t: any) => t.status === 'liberada'); - const totalVolumen = liberadas.reduce((s: number, t: any) => s + (Number(t.amount) || 0), 0); - const valorEmitido = bonds.reduce((s: number, b: any) => s + (Number(b.face_value) || 0), 0); + // ─── Alert rules (TSE/admin only — enforced at the controller via @Roles) ── + private mapAlertRule(row: any): AlertRule { return { - total_bonds: bonds.length, - total_volume_crc: totalVolumen, - total_emitted_crc: valorEmitido, - total_transfers: transfers.length, - total_sales: liberadas.length, - pending_requests: requests.filter((r: any) => r.status === 'pendiente').length, - approved_requests: requests.filter((r: any) => r.status === 'aprobado').length, - rejected_requests: requests.filter((r: any) => r.status === 'rechazado').length, - bonds_by_status: this.groupCount(bonds, (b: any) => b.status), + id: row.id, + name: row.name, + metricPath: row.metric_path, + comparator: row.comparator, + threshold: Number(row.threshold), + scope: row.scope ?? { kind: 'all' }, + notifyUserIds: row.notify_user_ids ?? [], + createdAt: row.created_at, + updatedAt: row.updated_at, }; } - /** Métricas por partido. */ - async byParty(role: Role) { - this.assertAuth(role); - const db = this.supabase.admin; + async listAlertRules(): Promise { + const { data, error } = await this.supabase.admin + .from('analytics_alert_rules') + .select('*') + .order('created_at', { ascending: false }); + if (error) throw new BadRequestException(error.message); + return (data ?? []).map((r: any) => this.mapAlertRule(r)); + } - const [partiesRes, bondsRes, transfersRes] = await Promise.all([ - db.from('parties').select('id, name, code'), - db.from('bonds').select('issuer_party_id, face_value, status'), - db.from('transfers').select('amount, status, bonds!inner(issuer_party_id)'), - ]); + async createAlertRule(input: AlertRuleInput): Promise { + if (!input.name?.trim()) throw new BadRequestException('El nombre es obligatorio'); + if (!input.metricPath?.trim()) throw new BadRequestException('metricPath es obligatorio'); + const { data, error } = await this.supabase.admin + .from('analytics_alert_rules') + .insert({ + name: input.name.trim(), + metric_path: input.metricPath.trim(), + comparator: input.comparator, + threshold: input.threshold, + scope: input.scope, + notify_user_ids: input.notifyUserIds ?? [], + }) + .select() + .single(); + if (error) throw new BadRequestException(error.message); + return this.mapAlertRule(data); + } - const parties = partiesRes.data ?? []; - const bonds = bondsRes.data ?? []; - const transfers = (transfersRes.data ?? []) as any[]; + async updateAlertRule(id: string, input: Partial): Promise { + const patch: Record = {}; + if (input.name !== undefined) patch.name = input.name; + if (input.metricPath !== undefined) patch.metric_path = input.metricPath; + if (input.comparator !== undefined) patch.comparator = input.comparator; + if (input.threshold !== undefined) patch.threshold = input.threshold; + if (input.scope !== undefined) patch.scope = input.scope; + if (input.notifyUserIds !== undefined) patch.notify_user_ids = input.notifyUserIds; - return parties.map((p: any) => { - const partyBonds = bonds.filter((b: any) => b.issuer_party_id === p.id); - const partyTransfers = transfers.filter((t: any) => t.bonds?.issuer_party_id === p.id); - const sales = partyTransfers.filter((t: any) => t.status === 'liberada'); - const volume = sales.reduce((s: number, t: any) => s + (Number(t.amount) || 0), 0); - const emitted = partyBonds.reduce((s: number, b: any) => s + (Number(b.face_value) || 0), 0); + const { data, error } = await this.supabase.admin + .from('analytics_alert_rules') + .update(patch) + .eq('id', id) + .select() + .single(); + if (error || !data) throw new NotFoundException('Regla no encontrada'); + return this.mapAlertRule(data); + } - return { - party_id: p.id, - party_name: p.name, - party_code: p.code, - bonds_count: partyBonds.length, - emitted_value: emitted, - sales_count: sales.length, - volume_moved: volume, - active_bonds: partyBonds.filter((b: any) => ['activo', 'en_venta'].includes(b.status)).length, - sold_bonds: partyBonds.filter((b: any) => b.status === 'vendido').length, - }; - }).sort((a, b) => b.volume_moved - a.volume_moved); + async deleteAlertRule(id: string): Promise<{ ok: true }> { + const { error } = await this.supabase.admin.from('analytics_alert_rules').delete().eq('id', id); + if (error) throw new BadRequestException(error.message); + return { ok: true }; + } + + private async evaluateAndNotify(rules: AlertRule[]): Promise { + if (rules.length === 0) return []; + const input = await this.data.getAnalyticsInput(); + const allBreaches: AlertBreach[] = []; + for (const rule of rules) { + const scopedInput = applyScope(input, rule.scope); + const snapshot = buildAnalyticsSnapshot(scopedInput, {}, rule.scope); + const breaches = evaluateAlertRules(snapshot, [rule]); + for (const breach of breaches) { + for (const userId of rule.notifyUserIds) { + await this.notifications.emit(userId, NotificationType.ANALYTICS_THRESHOLD_BREACHED, { ...breach }); + } + } + allBreaches.push(...breaches); + } + return allBreaches; + } + + async evaluateAlertRule(id: string): Promise { + const { data, error } = await this.supabase.admin.from('analytics_alert_rules').select('*').eq('id', id).single(); + if (error || !data) throw new NotFoundException('Regla no encontrada'); + return this.evaluateAndNotify([this.mapAlertRule(data)]); + } + + async evaluateAllAlertRules(): Promise { + return this.evaluateAndNotify(await this.listAlertRules()); } + // ─── Saved views (owner-scoped) ───────────────────────────────────────────── + + private mapSavedView(row: any): SavedView { + return { + id: row.id, + ownerId: row.owner_id, + role: row.role, + name: row.name, + query: row.query ?? {}, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + async listSavedViews(ownerId: string): Promise { + const { data, error } = await this.supabase.admin + .from('analytics_saved_views') + .select('*') + .eq('owner_id', ownerId) + .order('created_at', { ascending: false }); + if (error) throw new BadRequestException(error.message); + return (data ?? []).map((r: any) => this.mapSavedView(r)); + } + + async createSavedView(ownerId: string, role: Role, input: SavedViewInput): Promise { + if (!input.name?.trim()) throw new BadRequestException('El nombre es obligatorio'); + const { data, error } = await this.supabase.admin + .from('analytics_saved_views') + .insert({ owner_id: ownerId, role, name: input.name.trim(), query: input.query ?? {} }) + .select() + .single(); + if (error) throw new BadRequestException(error.message); + return this.mapSavedView(data); + } + + async deleteSavedView(id: string, ownerId: string): Promise<{ ok: true }> { + const { error } = await this.supabase.admin + .from('analytics_saved_views') + .delete() + .eq('id', id) + .eq('owner_id', ownerId); + if (error) throw new BadRequestException(error.message); + return { ok: true }; + } + + // ─── Scheduled report (manual trigger, no cron) ──────────────────────────── + + async runScheduledReport(config: ScheduledReportConfig): Promise { + const input = await this.data.getAnalyticsInput(); + const scopedInput = applyScope(input, config.scope); + const snapshot = buildAnalyticsSnapshot(scopedInput, {}, config.scope); + return this.scheduledReportGenerator.generate(config, snapshot); + } + + // ─── Legacy bond-detail drill-down (unchanged, TSE/admin only, live Supabase) ─ + /** Histórico de precios y % de cambio de un bono. */ async bondPriceHistory(tokenId: string, role: Role) { this.assertAuth(role); @@ -145,10 +277,8 @@ export class AnalyticsService { const liberadas = ((transfers ?? []) as any[]).filter((t: any) => t.status === 'liberada'); - // Construir cadena de propietarios const owners: any[] = []; if (liberadas.length === 0) { - // Solo el partido (emisor original) if (bond.profiles) { owners.push({ name: bond.profiles.full_name, @@ -160,14 +290,13 @@ export class AnalyticsService { }); } } else { - // Primer dueño = vendedor de la primera transferencia const first = liberadas[0]; owners.push({ name: first.from_profile?.full_name, email: first.from_profile?.email, since: bond.created_at, until: first.created_at, - paid: null, // emitido, no comprado + paid: null, current: false, }); liberadas.forEach((t: any, i: number) => { @@ -191,7 +320,7 @@ export class AnalyticsService { }; } - /** Top N bonos más movidos. */ + /** Top N bonos más movidos (detalle legado con nombre de partido, para drill-down). */ async topBonds(role: Role, limit = 5) { this.assertAuth(role); const { data: transfers } = await this.supabase.admin @@ -210,9 +339,9 @@ export class AnalyticsService { return [...agg.values()].sort((a, b) => b.volume - a.volume).slice(0, limit); } - /** CSV de transferencias liberadas para auditores externos. Solo rol TSE. */ + /** CSV legado de transferencias liberadas con nombres, para auditores externos. Solo rol TSE. */ async exportTransfersCsv(role: Role, format: string | undefined) { - this.assertTseOnly(role); + if (role !== 'tse') throw new ForbiddenException('Solo TSE'); if (format !== 'csv') throw new BadRequestException('format=csv requerido'); const { data, error } = await this.supabase.admin @@ -241,38 +370,6 @@ export class AnalyticsService { return `\uFEFF${[CSV_HEADERS.join(','), ...rows.map((row) => row.map((cell) => this.csvCell(cell)).join(','))].join('\r\n')}\r\n`; } - /** Serie temporal del volumen movido por día. */ - async volumeOverTime(role: Role, days = 30) { - this.assertAuth(role); - const since = new Date(); - since.setDate(since.getDate() - days); - - const { data } = await this.supabase.admin - .from('transfers') - .select('amount, created_at') - .eq('status', 'liberada') - .gte('created_at', since.toISOString()) - .order('created_at', { ascending: true }); - - const byDay = new Map(); - ((data ?? []) as any[]).forEach((t: any) => { - const day = (t.created_at ?? '').slice(0, 10); - const cur = byDay.get(day) ?? { date: day, volume: 0, sales: 0 }; - cur.volume += Number(t.amount) || 0; - cur.sales += 1; - byDay.set(day, cur); - }); - return [...byDay.values()]; - } - - private groupCount(arr: T[], key: (x: T) => string): Record { - return arr.reduce>((acc, x) => { - const k = key(x) ?? 'unknown'; - acc[k] = (acc[k] ?? 0) + 1; - return acc; - }, {}); - } - private csvCell(value: string | number): string { const s = String(value); return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; From 084a676dc34bd08de0a4e0c27951a4d967fe6bc5 Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:05:03 -0600 Subject: [PATCH 17/23] test(api): add RBAC, export, and alert-rule tests for analytics Service tests cover scope resolution per role, CSV/PDF export scoping, alert evaluation + notification emission, and the scheduled-report stub. Controller tests cover the legacy/new export routes and prove @Roles('tse','admin') actually blocks a non-privileged role on the alert-rules routes. --- .../analytics/analytics.controller.spec.ts | 101 ++++++- .../src/analytics/analytics.service.spec.ts | 255 ++++++++++++++---- 2 files changed, 304 insertions(+), 52 deletions(-) diff --git a/apps/api/src/analytics/analytics.controller.spec.ts b/apps/api/src/analytics/analytics.controller.spec.ts index 4a6db51..90d635c 100644 --- a/apps/api/src/analytics/analytics.controller.spec.ts +++ b/apps/api/src/analytics/analytics.controller.spec.ts @@ -1,11 +1,13 @@ import { ForbiddenException, INestApplication } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; import * as request from 'supertest'; import { AnalyticsController } from './analytics.controller'; import { AnalyticsService } from './analytics.service'; import { AuthGuard } from '../auth/auth.guard'; +import { RolesGuard } from '../auth/roles.guard'; -describe('AnalyticsController export', () => { +describe('AnalyticsController legacy-export (transfer-detail CSV with names)', () => { let app: INestApplication; let role: string; const exportTransfersCsv = jest.fn(); @@ -36,12 +38,12 @@ describe('AnalyticsController export', () => { await app.close(); }); - it('GET /api/analytics/export?format=csv devuelve CSV con nombre de archivo del dia', async () => { + it('GET /api/analytics/legacy-export?format=csv devuelve CSV con nombre de archivo del dia', async () => { const csv = '\uFEFFbond_id,transfer_date,seller_name,buyer_name,amount_colones,party_name\r\nBONO-001,2026-06-10,Partido,Comprador,100000,PLN\r\n'; exportTransfersCsv.mockResolvedValue(csv); const today = new Date().toISOString().slice(0, 10); - const res = await request(app.getHttpServer()).get('/api/analytics/export?format=csv').expect(200); + const res = await request(app.getHttpServer()).get('/api/analytics/legacy-export?format=csv').expect(200); expect(res.headers['content-type']).toContain('text/csv'); expect(res.headers['content-disposition']).toContain(`filename="velar-transfers-${today}.csv"`); @@ -53,7 +55,98 @@ describe('AnalyticsController export', () => { role = 'comprador'; exportTransfersCsv.mockRejectedValue(new ForbiddenException('Solo TSE')); - await request(app.getHttpServer()).get('/api/analytics/export?format=csv').expect(403); + await request(app.getHttpServer()).get('/api/analytics/legacy-export?format=csv').expect(403); expect(exportTransfersCsv).toHaveBeenCalledWith('comprador', 'csv'); }); }); + +describe('AnalyticsController export (snapshot-based CSV/PDF, issue #44)', () => { + let app: INestApplication; + const exportCsv = jest.fn(); + const exportPdf = jest.fn(); + + beforeEach(async () => { + exportCsv.mockReset(); + exportPdf.mockReset(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AnalyticsController], + providers: [{ provide: AnalyticsService, useValue: { exportCsv, exportPdf } }], + }) + .overrideGuard(AuthGuard) + .useValue({ + canActivate: (ctx: any) => { + ctx.switchToHttp().getRequest().user = { id: 'user-1', profile: { role: 'tse', party_id: null } }; + return true; + }, + }) + .compile(); + + app = module.createNestApplication(); + app.setGlobalPrefix('api'); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('GET /api/analytics/export?format=csv delegates to exportCsv with the resolved role/party/query', async () => { + exportCsv.mockResolvedValue('csv-content'); + await request(app.getHttpServer()).get('/api/analytics/export?format=csv&country=CR').expect(200); + expect(exportCsv).toHaveBeenCalledWith('tse', null, expect.objectContaining({ country: 'CR' })); + }); + + it('GET /api/analytics/export?format=pdf delegates to exportPdf and sets a pdf content-type', async () => { + exportPdf.mockResolvedValue(Buffer.from('%PDF-1.7 stub')); + const res = await request(app.getHttpServer()).get('/api/analytics/export?format=pdf').expect(200); + expect(res.headers['content-type']).toContain('application/pdf'); + expect(exportPdf).toHaveBeenCalled(); + }); +}); + +describe('AnalyticsController alert-rules RBAC (@Roles TSE/admin only)', () => { + let app: INestApplication; + let role: string; + const listAlertRules = jest.fn().mockResolvedValue([]); + + beforeEach(async () => { + role = 'emisor'; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AnalyticsController], + providers: [{ provide: AnalyticsService, useValue: { listAlertRules } }, Reflector, RolesGuard], + }) + // The real AuthGuard is neutralized here (controller-scoped phase); the + // fake global guard below sets req.user BEFORE RolesGuard runs, mirroring + // production's actual global guard order (AuthGuard, then RolesGuard — + // see app.module.ts's APP_GUARD registration). + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + app = module.createNestApplication(); + const fakeAuthGuard = { + canActivate: (ctx: any) => { + ctx.switchToHttp().getRequest().user = { profile: { role } }; + return true; + }, + }; + app.useGlobalGuards(fakeAuthGuard, module.get(RolesGuard)); + app.setGlobalPrefix('api'); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('blocks a non-privileged role (emisor) from GET /api/analytics/alert-rules', async () => { + await request(app.getHttpServer()).get('/api/analytics/alert-rules').expect(403); + }); + + it('allows tse through to GET /api/analytics/alert-rules', async () => { + role = 'tse'; + await request(app.getHttpServer()).get('/api/analytics/alert-rules').expect(200); + }); +}); diff --git a/apps/api/src/analytics/analytics.service.spec.ts b/apps/api/src/analytics/analytics.service.spec.ts index bc5e0ff..4cb6a3b 100644 --- a/apps/api/src/analytics/analytics.service.spec.ts +++ b/apps/api/src/analytics/analytics.service.spec.ts @@ -1,66 +1,225 @@ -import { BadRequestException, ForbiddenException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import type { AnalyticsInput } from '@velar/types'; +import { analyticsFixture, analyticsFixtureIds } from '@velar/types'; import { AnalyticsService } from './analytics.service'; -import { SupabaseService } from '../common/supabase/supabase.service'; -describe('AnalyticsService exportTransfersCsv', () => { - let service: AnalyticsService; +const clone = (input: AnalyticsInput): AnalyticsInput => JSON.parse(JSON.stringify(input)); +const ids = analyticsFixtureIds; + +/** Thenable Supabase query-builder mock: every chain method returns itself, and + * awaiting the chain (or calling `.single()`) resolves to the configured result. */ +function makeChain(result: { data: any; error: any }) { + const chain: any = { + select: jest.fn(() => chain), + eq: jest.fn(() => chain), + order: jest.fn(() => chain), + insert: jest.fn(() => chain), + update: jest.fn(() => chain), + delete: jest.fn(() => chain), + single: jest.fn(() => Promise.resolve(result)), + then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject), + }; + return chain; +} + +describe('AnalyticsService', () => { + let dataService: { getAnalyticsInput: jest.Mock }; + let notifications: { emit: jest.Mock }; + let scheduledReportGenerator: { generate: jest.Mock }; let fromMock: jest.Mock; + let service: AnalyticsService; - beforeEach(async () => { - fromMock = jest.fn(); - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AnalyticsService, - { - provide: SupabaseService, - useValue: { - admin: { from: fromMock }, - }, - }, - ], - }).compile(); + function build(tableResults: Record = {}) { + dataService = { getAnalyticsInput: jest.fn().mockResolvedValue(clone(analyticsFixture)) }; + notifications = { emit: jest.fn().mockResolvedValue(undefined) }; + scheduledReportGenerator = { + generate: jest.fn().mockResolvedValue({ filename: 'x.csv', mimeType: 'text/csv', encoding: 'utf-8', content: 'stub' }), + }; + fromMock = jest.fn((table: string) => makeChain(tableResults[table] ?? { data: [], error: null })); + const supabase = { admin: { from: fromMock } }; + service = new AnalyticsService(dataService as any, supabase as any, notifications as any, scheduledReportGenerator as any); + return service; + } - service = module.get(AnalyticsService); + beforeEach(() => { + service = build(); }); - it('rechaza roles distintos de TSE', async () => { - await expect(service.exportTransfersCsv('comprador', 'csv')).rejects.toThrow(ForbiddenException); - await expect(service.exportTransfersCsv('admin', 'csv')).rejects.toThrow(ForbiddenException); + // ─── RBAC / scoping ─────────────────────────────────────────────────────── + + describe('getSnapshot RBAC', () => { + it('tse sees every party', async () => { + const snapshot = await service.getSnapshot('tse', null, {}); + expect(snapshot.scope).toEqual({ kind: 'all' }); + expect(snapshot.partyBreakdown).toHaveLength(3); + }); + + it('admin sees every party', async () => { + const snapshot = await service.getSnapshot('admin', null, {}); + expect(snapshot.scope).toEqual({ kind: 'all' }); + }); + + it('emisor with a party_id is scoped to only that party', async () => { + const snapshot = await service.getSnapshot('emisor', ids.parties.libertad, {}); + expect(snapshot.scope).toEqual({ kind: 'party', partyId: ids.parties.libertad }); + expect(snapshot.partyBreakdown).toHaveLength(1); + expect(snapshot.partyBreakdown[0].partyId).toBe(ids.parties.libertad); + }); + + it('emisor with no party_id is forbidden', async () => { + await expect(service.getSnapshot('emisor', null, {})).rejects.toThrow(ForbiddenException); + }); + + it.each(['comprador', 'recomprador', 'validador'] as const)('%s has no aggregate-analytics access', async (role) => { + await expect(service.getSnapshot(role, null, {})).rejects.toThrow(ForbiddenException); + }); }); - it('rechaza formatos distintos de csv', async () => { - await expect(service.exportTransfersCsv('tse', 'pdf')).rejects.toThrow(BadRequestException); + // ─── Exports ────────────────────────────────────────────────────────────── + + describe('exportCsv / exportPdf', () => { + it('exportCsv renders the snapshot as CSV, scoped by role', async () => { + const csv = await service.exportCsv('tse', null, {}); + expect(csv).toContain('value_volume,total_bonds,7'); + }); + + it('exportCsv respects party scoping', async () => { + const csv = await service.exportCsv('emisor', ids.parties.renovacion, {}); + expect(csv).toContain('value_volume,total_bonds,2'); + }); + + it('exportPdf renders a valid PDF buffer', async () => { + const buffer = await service.exportPdf('tse', null, {}); + expect(buffer.subarray(0, 5).toString('latin1')).toBe('%PDF-'); + }); + + it('exportCsv is forbidden for roles with no analytics access', async () => { + await expect(service.exportCsv('comprador', null, {})).rejects.toThrow(ForbiddenException); + }); }); - it('genera CSV con columnas requeridas y solo transferencias liberadas', async () => { - const order = jest.fn().mockResolvedValue({ - data: [ - { - amount: 150000, - created_at: '2026-06-10T15:30:00.000Z', - from_profile: { full_name: 'Partido ABC' }, - to_profile: { full_name: 'Juan Pérez' }, - bonds: { bond_id: 'BONO-001', parties: { name: 'Partido XYZ' } }, + describe('legacy exportTransfersCsv', () => { + it('rejects roles other than tse', async () => { + await expect(service.exportTransfersCsv('admin', 'csv')).rejects.toThrow(ForbiddenException); + }); + + it('rejects formats other than csv', async () => { + await expect(service.exportTransfersCsv('tse', 'pdf')).rejects.toThrow(BadRequestException); + }); + + it('generates a CSV with the required columns for tse + format=csv', async () => { + service = build({ + transfers: { + data: [ + { + amount: 150000, + created_at: '2026-06-10T15:30:00.000Z', + from_profile: { full_name: 'Partido ABC' }, + to_profile: { full_name: 'Juan Pérez' }, + bonds: { bond_id: 'BONO-001', parties: { name: 'Partido XYZ' } }, + }, + ], + error: null, }, - { - amount: 200000, - created_at: '2026-06-11T09:00:00.000Z', - from_profile: { full_name: 'María, "La Vendedora"' }, - to_profile: { full_name: 'Carlos López' }, - bonds: { bond_id: 'BONO-002', parties: { name: 'Partido XYZ' } }, + }); + const csv = await service.exportTransfersCsv('tse', 'csv'); + expect(csv.startsWith('\uFEFFbond_id,transfer_date,seller_name,buyer_name,amount_colones,party_name')).toBe(true); + expect(csv).toContain('BONO-001,2026-06-10,Partido ABC,Juan Pérez,150000,Partido XYZ'); + }); + }); + + // ─── Alert rules: evaluation + notification emission ───────────────────── + + describe('alert rule evaluation', () => { + const ruleRow = { + id: 'rule-1', + name: 'Volumen alto', + metric_path: 'valueVolume.totalVolumeMoved', + comparator: 'gt', + threshold: 1_000_000, + scope: { kind: 'all' }, + notify_user_ids: ['user-a', 'user-b'], + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }; + + it('emits a notification per recipient when the rule breaches', async () => { + service = build({ analytics_alert_rules: { data: ruleRow, error: null } }); + const breaches = await service.evaluateAlertRule('rule-1'); + expect(breaches).toHaveLength(1); + expect(notifications.emit).toHaveBeenCalledTimes(2); + expect(notifications.emit).toHaveBeenCalledWith('user-a', 'analytics_threshold_breached', expect.objectContaining({ ruleId: 'rule-1' })); + expect(notifications.emit).toHaveBeenCalledWith('user-b', 'analytics_threshold_breached', expect.objectContaining({ ruleId: 'rule-1' })); + }); + + it('does not notify when the rule does not breach', async () => { + service = build({ analytics_alert_rules: { data: { ...ruleRow, threshold: 100_000_000 }, error: null } }); + const breaches = await service.evaluateAlertRule('rule-1'); + expect(breaches).toHaveLength(0); + expect(notifications.emit).not.toHaveBeenCalled(); + }); + + it('respects the rule scope when evaluating', async () => { + service = build({ + analytics_alert_rules: { + data: { ...ruleRow, scope: { kind: 'party', partyId: ids.parties.libertad }, threshold: 500_000 }, + error: null, }, - ], - error: null, + }); + // Libertad's own volumeMoved is 1_050_000 > 500_000 → breaches. + const breaches = await service.evaluateAlertRule('rule-1'); + expect(breaches).toHaveLength(1); + }); + + it('throws NotFoundException for an unknown rule id', async () => { + service = build({ analytics_alert_rules: { data: null, error: null } }); + await expect(service.evaluateAlertRule('missing')).rejects.toThrow(NotFoundException); + }); + }); + + // ─── Scheduled report stub ──────────────────────────────────────────────── + + describe('runScheduledReport', () => { + it('builds a scope-filtered snapshot and delegates to the generator', async () => { + const config = { id: 'cfg-1', cadence: 'monthly' as const, format: 'csv' as const, scope: { kind: 'all' as const }, recipients: [] }; + const result = await service.runScheduledReport(config); + expect(result.filename).toBe('x.csv'); + expect(scheduledReportGenerator.generate).toHaveBeenCalledTimes(1); + const [passedConfig, passedSnapshot] = scheduledReportGenerator.generate.mock.calls[0]; + expect(passedConfig).toBe(config); + expect(passedSnapshot.valueVolume.totalBonds).toBe(7); + }); + + it('scopes the snapshot to a single party when the config scope is party-based', async () => { + const config = { + id: 'cfg-2', + cadence: 'weekly' as const, + format: 'pdf' as const, + scope: { kind: 'party' as const, partyId: ids.parties.avanza }, + recipients: [], + }; + await service.runScheduledReport(config); + const [, passedSnapshot] = scheduledReportGenerator.generate.mock.calls[0]; + expect(passedSnapshot.valueVolume.totalBonds).toBe(2); }); - const eq = jest.fn().mockReturnValue({ order }); - fromMock.mockReturnValue({ select: jest.fn().mockReturnValue({ eq }) }); + }); + + // ─── Saved views ────────────────────────────────────────────────────────── - const csv = await service.exportTransfersCsv('tse', 'csv'); + describe('saved views', () => { + it('createSavedView rejects an empty name', async () => { + await expect(service.createSavedView('owner-1', 'tse', { name: ' ', query: {} })).rejects.toThrow(BadRequestException); + }); - expect(eq).toHaveBeenCalledWith('status', 'liberada'); - expect(csv.startsWith('\uFEFFbond_id,transfer_date,seller_name,buyer_name,amount_colones,party_name')).toBe(true); - expect(csv).toContain('BONO-001,2026-06-10,Partido ABC,Juan Pérez,150000,Partido XYZ'); - expect(csv).toContain('"María, ""La Vendedora""",Carlos López,200000,Partido XYZ'); + it('createSavedView persists owner/role/name/query', async () => { + service = build({ + analytics_saved_views: { + data: { id: 'view-1', owner_id: 'owner-1', role: 'tse', name: 'Mi vista', query: { country: 'CR' }, created_at: 'x', updated_at: 'x' }, + error: null, + }, + }); + const view = await service.createSavedView('owner-1', 'tse', { name: 'Mi vista', query: { country: 'CR' } }); + expect(view).toMatchObject({ id: 'view-1', ownerId: 'owner-1', name: 'Mi vista' }); + }); }); }); From 0d450d23cf3ed53ce0bdf12fe9befdd8add083f2 Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:05:16 -0600 Subject: [PATCH 18/23] fix(web): restore design-system color tokens dropped by Tailwind pruning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit secondary/secondaryContainer/secondaryFixed/tertiary/tertiaryContainer/ primaryFixedDim were declared in tokens.ts and @theme but silently stripped from the compiled CSS by Tailwind v4 (unused-theme-key pruning) since no utility class referenced them — colorVar() resolved them to an empty string, rendering black/invalid in charts. warning was missing entirely. Re-declared in the plain :root/[data-theme='dark'] blocks, which Tailwind never prunes. --- apps/web/app/globals.css | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 05d51b9..1587bae 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -44,6 +44,19 @@ } :root { + /* Tokens que colorVar() promete exponer (components/ui/tokens.ts) pero que + Tailwind v4 poda de @theme si ninguna clase utilitaria los referencia + (solo se usan vía var() en JS/charts, issue #44). Mismos valores que + @theme/[data-theme='dark'] arriba — no son tokens nuevos, solo se + garantiza que sobrevivan el build sea cual sea el uso de utilidades. */ + --color-secondary: #52627A; + --color-secondary-container: #D9E6F8; + --color-secondary-fixed: #EEF5FF; + --color-tertiary: #174EA6; + --color-tertiary-container: #2563EB; + --color-primary-fixed-dim: #8DBBFF; + --color-warning: #B7791F; + --velar-canvas: #F7FAFF; --velar-section: #EEF5FF; --velar-surface: #FFFFFF; @@ -72,6 +85,10 @@ (var(--color-*)) y los helpers .velar-*. No se agregan tokens nuevos: solo cambian los valores según el tema activo en . */ [data-theme='dark'] { + /* Ídem al comentario del :root claro: garantiza que estos tokens de + colorVar() sobrevivan la poda de Tailwind también en modo oscuro. */ + --color-warning: #E0A82E; + --color-primary: #8DBBFF; --color-primary-container: #2563EB; --color-primary-hover: #3B82F6; From 798cca4e22232ea8d30866f50ded2b2985956e4b Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:05:26 -0600 Subject: [PATCH 19/23] feat(web): add recharts and analytics chart components First third-party UI dependency in the frontend's design system, chosen over hand-rolling 5 chart types. BarChart/LineChart/AreaChart/ PieChart/StackedBarChart, all wrapped in ChartFrame: role="img" + aria-label, a screen-reader-only fallback with the same data, and a shared chartTooltipStyle/cursor so Recharts' hardcoded-white defaults follow the theme instead. --- .../components/analytics/charts/AreaChart.tsx | 56 ++++++++++++ .../components/analytics/charts/BarChart.tsx | 69 +++++++++++++++ .../analytics/charts/ChartFrame.tsx | 88 +++++++++++++++++++ .../components/analytics/charts/LineChart.tsx | 54 ++++++++++++ .../components/analytics/charts/PieChart.tsx | 64 ++++++++++++++ .../analytics/charts/StackedBarChart.tsx | 71 +++++++++++++++ 6 files changed, 402 insertions(+) create mode 100644 apps/web/components/analytics/charts/AreaChart.tsx create mode 100644 apps/web/components/analytics/charts/BarChart.tsx create mode 100644 apps/web/components/analytics/charts/ChartFrame.tsx create mode 100644 apps/web/components/analytics/charts/LineChart.tsx create mode 100644 apps/web/components/analytics/charts/PieChart.tsx create mode 100644 apps/web/components/analytics/charts/StackedBarChart.tsx diff --git a/apps/web/components/analytics/charts/AreaChart.tsx b/apps/web/components/analytics/charts/AreaChart.tsx new file mode 100644 index 0000000..f712e80 --- /dev/null +++ b/apps/web/components/analytics/charts/AreaChart.tsx @@ -0,0 +1,56 @@ +'use client'; +import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { colorVar } from '../../ui/tokens'; +import { ChartFrame, ChartTableFallback, chartTooltipStyle } from './ChartFrame'; +import type { SeriesPoint } from './LineChart'; + +export function AnalyticsAreaChart({ + title, + description, + data, + valueLabel = 'Valor', +}: { + title: string; + description?: string; + data: SeriesPoint[]; + valueLabel?: string; +}) { + return ( + d.bucketStart }, + { key: 'value', label: valueLabel, render: (d) => String(d.value) }, + ]} + /> + } + > +
+ {data.length === 0 ? ( +

Sin datos todavía.

+ ) : ( + + + + + + + + + + + + [v.toLocaleString('es-CR'), valueLabel]} {...chartTooltipStyle} /> + + + + )} +
+
+ ); +} diff --git a/apps/web/components/analytics/charts/BarChart.tsx b/apps/web/components/analytics/charts/BarChart.tsx new file mode 100644 index 0000000..1c2346f --- /dev/null +++ b/apps/web/components/analytics/charts/BarChart.tsx @@ -0,0 +1,69 @@ +'use client'; +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { colorVar } from '../../ui/tokens'; +import { ChartFrame, ChartTableFallback, chartTooltipStyle } from './ChartFrame'; + +export interface BarDatum { + key: string; + label: string; + value: number; +} + +export function AnalyticsBarChart({ + title, + description, + data, + valueLabel = 'Valor', + onBarClick, +}: { + title: string; + description?: string; + data: BarDatum[]; + valueLabel?: string; + onBarClick?: (d: BarDatum) => void; +}) { + return ( + d.label }, + { key: 'value', label: valueLabel, render: (d) => String(d.value) }, + ]} + /> + } + > +
+ {data.length === 0 ? ( +

Sin datos todavía.

+ ) : ( + + { + const idx = state?.activeTooltipIndex; + if (idx != null && onBarClick) onBarClick(data[idx]); + }} + > + + + + [v.toLocaleString('es-CR'), valueLabel]} {...chartTooltipStyle} /> + + + + )} +
+
+ ); +} diff --git a/apps/web/components/analytics/charts/ChartFrame.tsx b/apps/web/components/analytics/charts/ChartFrame.tsx new file mode 100644 index 0000000..ef593a1 --- /dev/null +++ b/apps/web/components/analytics/charts/ChartFrame.tsx @@ -0,0 +1,88 @@ +'use client'; +import type { ReactNode } from 'react'; +import { colorVar } from '../../ui/tokens'; + +/** + * Shared chart wrapper (issue #44): title/description, an accessible SVG + * region (`role="img"`, `aria-label`), and a screen-reader-only `
` + * fallback with the exact same data — always in the DOM, no toggle needed. + */ + +/** + * Recharts' `` ships a hardcoded white content box by default — + * unreadable on the dark theme. Pass this to every `` so it follows + * the theme like the rest of the chart. + */ +export const chartTooltipStyle = { + contentStyle: { + background: colorVar('surface'), + border: `1px solid ${colorVar('outlineVariant')}`, + borderRadius: 8, + fontSize: 12, + }, + labelStyle: { color: colorVar('onSurface') }, + itemStyle: { color: colorVar('onSurface') }, + /** Recharts' default hover "cursor" is an opaque gray box — themed + translucent instead. */ + cursor: { fill: colorVar('outline'), opacity: 0.15 }, +} as const; + +export interface ChartTableColumn { + key: string; + label: string; + render: (row: T) => string; +} + +export function ChartTableFallback({ + rows, + columns, + caption, +}: { + rows: T[]; + columns: ChartTableColumn[]; + caption: string; +}) { + return ( +
+ + + + {columns.map((c) => ( + + ))} + + + + {rows.map((row, i) => ( + + {columns.map((c) => ( + + ))} + + ))} + +
{caption}
{c.label}
{c.render(row)}
+ ); +} + +export function ChartFrame({ + title, + description, + children, + table, +}: { + title: string; + description?: string; + children: ReactNode; + table: ReactNode; +}) { + return ( +
+

{title}

+ {description &&

{description}

} +
+ {children} +
+ {table} +
+ ); +} diff --git a/apps/web/components/analytics/charts/LineChart.tsx b/apps/web/components/analytics/charts/LineChart.tsx new file mode 100644 index 0000000..4a87f66 --- /dev/null +++ b/apps/web/components/analytics/charts/LineChart.tsx @@ -0,0 +1,54 @@ +'use client'; +import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { colorVar } from '../../ui/tokens'; +import { ChartFrame, ChartTableFallback, chartTooltipStyle } from './ChartFrame'; + +export interface SeriesPoint { + bucketStart: string; + value: number; +} + +export function AnalyticsLineChart({ + title, + description, + data, + valueLabel = 'Valor', +}: { + title: string; + description?: string; + data: SeriesPoint[]; + valueLabel?: string; +}) { + return ( + d.bucketStart }, + { key: 'value', label: valueLabel, render: (d) => String(d.value) }, + ]} + /> + } + > +
+ {data.length === 0 ? ( +

Sin datos todavía.

+ ) : ( + + + + + + [v.toLocaleString('es-CR'), valueLabel]} {...chartTooltipStyle} /> + + + + )} +
+
+ ); +} diff --git a/apps/web/components/analytics/charts/PieChart.tsx b/apps/web/components/analytics/charts/PieChart.tsx new file mode 100644 index 0000000..a60f5a0 --- /dev/null +++ b/apps/web/components/analytics/charts/PieChart.tsx @@ -0,0 +1,64 @@ +'use client'; +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; +import { colorVar } from '../../ui/tokens'; +import { ChartFrame, ChartTableFallback, chartTooltipStyle } from './ChartFrame'; +import type { BarDatum } from './BarChart'; + +const PALETTE = ['primary', 'primaryContainer', 'tertiary', 'secondary', 'success', 'warning'] as const; + +export function AnalyticsPieChart({ + title, + description, + data, + valueLabel = 'Valor', + onSliceClick, +}: { + title: string; + description?: string; + data: BarDatum[]; + valueLabel?: string; + onSliceClick?: (d: BarDatum) => void; +}) { + return ( + d.label }, + { key: 'value', label: valueLabel, render: (d) => String(d.value) }, + ]} + /> + } + > +
+ {data.length === 0 ? ( +

Sin datos todavía.

+ ) : ( + + + onSliceClick?.(data[index])} + > + {data.map((d, i) => ( + + ))} + + [v.toLocaleString('es-CR'), valueLabel]} {...chartTooltipStyle} /> + + + )} +
+
+ ); +} diff --git a/apps/web/components/analytics/charts/StackedBarChart.tsx b/apps/web/components/analytics/charts/StackedBarChart.tsx new file mode 100644 index 0000000..9f078fe --- /dev/null +++ b/apps/web/components/analytics/charts/StackedBarChart.tsx @@ -0,0 +1,71 @@ +'use client'; +import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { colorVar } from '../../ui/tokens'; +import { ChartFrame, ChartTableFallback, chartTooltipStyle } from './ChartFrame'; + +export interface StackedSegmentDef { + key: string; + label: string; + color: Parameters[0]; +} + +export interface StackedDatum { + label: string; + [segmentKey: string]: string | number; +} + +export function AnalyticsStackedBarChart({ + title, + description, + data, + segments, +}: { + title: string; + description?: string; + data: StackedDatum[]; + segments: StackedSegmentDef[]; +}) { + return ( + d.label }, + ...segments.map((s) => ({ key: s.key, label: s.label, render: (d: StackedDatum) => String(d[s.key] ?? 0) })), + ]} + /> + } + > +
+ {data.length === 0 ? ( +

Sin datos todavía.

+ ) : ( + + + + + + + + {segments.map((s, i) => ( + + ))} + + + )} +
+
+ ); +} From eb0ebc1d3d2f866a752dc63b8a3da3ebc53d677f Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:05:34 -0600 Subject: [PATCH 20/23] chore(web): add recharts dependency, refresh lockfile Lockfile reflects both new dependencies added on this branch (pdf-lib in apps/api, recharts in apps/web). --- apps/web/package.json | 8 +- package-lock.json | 414 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 418 insertions(+), 4 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index b868681..2f5fddc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,6 +25,7 @@ "ogl": "^1.0.11", "react": "19.2.4", "react-dom": "19.2.4", + "recharts": "^3.10.1", "zod": "^4.4.3" }, "devDependencies": { @@ -44,7 +45,12 @@ "testEnvironment": "node", "testRegex": ".*\\.spec\\.ts$", "transform": { - "^.+\\.ts$": ["ts-jest", { "tsconfig": "tsconfig.test.json" }] + "^.+\\.ts$": [ + "ts-jest", + { + "tsconfig": "tsconfig.test.json" + } + ] }, "moduleNameMapper": { "^@/(.*)$": "/$1" diff --git a/package-lock.json b/package-lock.json index e1b422d..35ce7f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "express": "^5.1.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pdf-lib": "^1.17.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "ws": "^8.21.0", @@ -88,6 +89,7 @@ "ogl": "^1.0.11", "react": "19.2.4", "react-dom": "19.2.4", + "recharts": "^3.10.1", "zod": "^4.4.3" }, "devDependencies": { @@ -6746,6 +6748,24 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@phosphor-icons/webcomponents": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@phosphor-icons/webcomponents/-/webcomponents-2.1.5.tgz", @@ -6878,6 +6898,32 @@ "license": "BSD-3-Clause", "peer": true }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@renovatebot/pep440": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-4.2.1.tgz", @@ -8788,7 +8834,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@stellar/js-xdr": { @@ -11668,6 +11719,69 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -11959,6 +12073,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -16760,6 +16880,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -16879,6 +17120,12 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -19850,6 +20097,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -19963,6 +20220,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -24167,6 +24433,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -24392,6 +24664,24 @@ "node": ">= 0.10" } }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -25194,7 +25484,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/react-is-18": { @@ -25213,6 +25502,29 @@ "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -25250,6 +25562,60 @@ "node": ">= 12.13.0" } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -25339,6 +25705,12 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "license": "ISC" }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -27190,6 +27562,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tiny-secp256k1": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-1.1.7.tgz", @@ -28226,6 +28604,15 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf-8-validate": { "version": "6.0.6", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", @@ -28438,6 +28825,28 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/viem": { "version": "2.53.1", "resolved": "https://registry.npmjs.org/viem/-/viem-2.53.1.tgz", @@ -29207,7 +29616,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" From 68dd2834410a502ab0e5ab4c078e1c3898bd1425 Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:06:00 -0600 Subject: [PATCH 21/23] feat(web): add analytics API client, query helpers, and dashboard components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/analytics/{query,client}.ts: pure AnalyticsQuery <-> URLSearchParams (de)serialization (also the shape a saved view stores) and a thin wrapper over apiFetch/apiDownload — no parallel HTTP client. components/analytics/: KpiCard, FilterBar (date range/country/party/ status/bucket), SavedViewsMenu, ExportButtons, DrillDownPanel (reuses the legacy price-history/owners endpoints for real drill-down), and AnalyticsDashboard composing all of it — shared by both role pages. --- .../analytics/AnalyticsDashboard.tsx | 223 ++++++++++++++++++ .../components/analytics/DrillDownPanel.tsx | 93 ++++++++ .../components/analytics/ExportButtons.tsx | 50 ++++ apps/web/components/analytics/FilterBar.tsx | 105 +++++++++ apps/web/components/analytics/KpiCard.tsx | 27 +++ .../components/analytics/SavedViewsMenu.tsx | 105 +++++++++ apps/web/lib/analytics/client.ts | 51 ++++ apps/web/lib/analytics/query.spec.ts | 71 ++++++ apps/web/lib/analytics/query.ts | 37 +++ 9 files changed, 762 insertions(+) create mode 100644 apps/web/components/analytics/AnalyticsDashboard.tsx create mode 100644 apps/web/components/analytics/DrillDownPanel.tsx create mode 100644 apps/web/components/analytics/ExportButtons.tsx create mode 100644 apps/web/components/analytics/FilterBar.tsx create mode 100644 apps/web/components/analytics/KpiCard.tsx create mode 100644 apps/web/components/analytics/SavedViewsMenu.tsx create mode 100644 apps/web/lib/analytics/client.ts create mode 100644 apps/web/lib/analytics/query.spec.ts create mode 100644 apps/web/lib/analytics/query.ts diff --git a/apps/web/components/analytics/AnalyticsDashboard.tsx b/apps/web/components/analytics/AnalyticsDashboard.tsx new file mode 100644 index 0000000..6710419 --- /dev/null +++ b/apps/web/components/analytics/AnalyticsDashboard.tsx @@ -0,0 +1,223 @@ +'use client'; +import { useEffect, useMemo, useState } from 'react'; +import { Activity, BarChart3, Boxes, DollarSign } from 'lucide-react'; +import type { AnalyticsQuery, AnalyticsSnapshot } from '@velar/types'; +import { getCountryProfile } from '@velar/types'; +import { apiFetch } from '../../lib/api'; +import { fetchSnapshot } from '../../lib/analytics/client'; +import { KpiCard } from './KpiCard'; +import { FilterBar } from './FilterBar'; +import { SavedViewsMenu } from './SavedViewsMenu'; +import { ExportButtons } from './ExportButtons'; +import { DrillDownPanel } from './DrillDownPanel'; +import { AnalyticsBarChart } from './charts/BarChart'; +import { AnalyticsPieChart } from './charts/PieChart'; +import { AnalyticsAreaChart } from './charts/AreaChart'; +import { AnalyticsLineChart } from './charts/LineChart'; +import { AnalyticsStackedBarChart } from './charts/StackedBarChart'; + +const fmtCRC = (n: number) => new Intl.NumberFormat('es-CR', { style: 'currency', currency: 'CRC', maximumFractionDigits: 0 }).format(n || 0); +const fmtNum = (n: number) => new Intl.NumberFormat('es-CR').format(n || 0); + +/** + * BI dashboard body shared by `/tse/analytics` and `/partido/analytics` + * (issue #44). RBAC scoping happens entirely on the backend (docs/AGENTS.md + * §5) — the frontend only decides whether to SHOW the country/party filters + * and party-name column; a `partido` caller's snapshot is already restricted + * to their own party regardless of what this component renders. + */ +export function AnalyticsDashboard({ token, showPartyControls }: { token: string; showPartyControls: boolean }) { + const [query, setQuery] = useState({}); + const [snapshot, setSnapshot] = useState(null); + const [parties, setParties] = useState<{ id: string; name: string }[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [drillDownTokenId, setDrillDownTokenId] = useState(null); + + useEffect(() => { + if (!showPartyControls) return; + apiFetch(token, 'GET', '/parties') + .then((rows) => setParties((rows ?? []).map((p: any) => ({ id: p.id, name: p.name })))) + .catch(() => setParties([])); + }, [token, showPartyControls]); + + useEffect(() => { + let active = true; + setLoading(true); + setError(''); + fetchSnapshot(token, query) + .then((s) => { + if (active) setSnapshot(s); + }) + .catch((e) => { + if (active) setError(e.message ?? 'No se pudo cargar la analítica'); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [token, query]); + + const partyNameById = useMemo(() => Object.fromEntries(parties.map((p) => [p.id, p.name])), [parties]); + const labelForParty = (partyId: string) => partyNameById[partyId] ?? partyId; + + return ( + <> +
+
+

Análisis de bonos

+

Panel de inteligencia de negocio del ecosistema

+
+
+ + +
+
+ +
+ + + {error &&

{error}

} + + {loading || !snapshot ? ( +
+ +
+ ) : ( + <> +
+ + + + +
+ +
+ ({ key: b.status, label: b.status, value: b.count }))} + valueLabel="Bonos" + /> + ({ + key: c.country, + label: `${getCountryProfile(c.country).flag} ${getCountryProfile(c.country).name}`, + value: c.volumeMoved, + }))} + valueLabel="Volumen" + /> +
+ +
+ ({ + label: labelForParty(p.partyId), + emittedValue: p.emittedValue, + volumeMoved: p.volumeMoved, + }))} + segments={[ + { key: 'emittedValue', label: 'Emitido', color: 'secondaryContainer' }, + { key: 'volumeMoved', label: 'Movido', color: 'primary' }, + ]} + /> +
+ +
+ + +
+ +
+

Embudo de transferencias

+

+ {snapshot.funnel.totalStarted} transferencias iniciadas · {snapshot.funnel.completedCount} completadas ·{' '} + {snapshot.funnel.rejectedCount} rechazadas · {snapshot.funnel.cancelledCount} canceladas +

+ {snapshot.funnel.totalStarted === 0 ? ( +

Sin transferencias todavía.

+ ) : ( +
+ {snapshot.funnel.stages.map((s) => ( +
+
+ {s.step} + + {s.reachedCount} · {s.conversionFromStartPct}% + +
+
+
+
+
+ ))} +
+ )} +
+ +
+

Top bonos más movidos

+

Clic para ver histórico de precios y propietarios

+ {snapshot.topBonds.length === 0 ? ( +

Sin ventas todavía.

+ ) : ( +
+ {snapshot.topBonds.map((b, i) => ( + + ))} +
+ )} +
+ + {snapshot.compliance.parties.length > 0 && ( +
+

Cumplimiento de reportes

+

Estado de los reportes mensuales por partido

+
+ {snapshot.compliance.parties.map((p) => ( +
+ {labelForParty(p.partyId)} + + {p.onTimeCount} a tiempo + {p.lateCount} tarde + {p.missingCount} sin enviar + +
+ ))} +
+
+ )} + + )} +
+ + setDrillDownTokenId(null)} /> + + ); +} diff --git a/apps/web/components/analytics/DrillDownPanel.tsx b/apps/web/components/analytics/DrillDownPanel.tsx new file mode 100644 index 0000000..28ce04b --- /dev/null +++ b/apps/web/components/analytics/DrillDownPanel.tsx @@ -0,0 +1,93 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { TrendingDown, TrendingUp, Users } from 'lucide-react'; +import { Modal } from '../ui/Modal'; +import { apiFetch } from '../../lib/api'; + +const fmtCRC = (n: number) => new Intl.NumberFormat('es-CR', { style: 'currency', currency: 'CRC', maximumFractionDigits: 0 }).format(n || 0); + +/** + * Drill-down from a chart segment (a top bond) into its underlying set: + * price history + ownership chain. Reuses the legacy bond-detail endpoints + * (kept for exactly this purpose — see analytics.controller.ts). + */ +export function DrillDownPanel({ token, tokenId, onClose }: { token: string; tokenId: string | null; onClose: () => void }) { + const [priceHistory, setPriceHistory] = useState(null); + const [owners, setOwners] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!tokenId) { + setPriceHistory(null); + setOwners(null); + return; + } + setLoading(true); + Promise.all([ + apiFetch(token, 'GET', `/analytics/bonds/${tokenId}/price-history`).catch(() => null), + apiFetch(token, 'GET', `/analytics/bonds/${tokenId}/owners`).catch(() => null), + ]) + .then(([ph, ow]) => { + setPriceHistory(ph); + setOwners(ow); + }) + .finally(() => setLoading(false)); + }, [token, tokenId]); + + return ( + + {loading &&

Cargando…

} + {!loading && priceHistory && ( +
+
+
+

Histórico de precios

+ = 0 ? 'text-success' : 'text-error'}`}> + {priceHistory.total_change_pct >= 0 ? : } + {priceHistory.total_change_pct > 0 ? '+' : ''} + {priceHistory.total_change_pct}% + +
+ {priceHistory.points.length === 0 ? ( +

Sin ventas registradas todavía.

+ ) : ( +
    + {priceHistory.points.map((pt: any, i: number) => ( +
  • + Venta #{pt.index} + {fmtCRC(pt.price)} +
  • + ))} +
+ )} +
+ + {owners && ( +
+
+ +

Propietarios históricos

+
+
    + {owners.owners.map((o: any, i: number) => ( +
  • +

    + {o.name ?? 'Sin dato'} + {o.current && ACTUAL} +

    +
  • + ))} +
+
+ )} +
+ )} + {!loading && !priceHistory &&

No se pudo cargar el detalle.

} +
+ ); +} diff --git a/apps/web/components/analytics/ExportButtons.tsx b/apps/web/components/analytics/ExportButtons.tsx new file mode 100644 index 0000000..09c02c5 --- /dev/null +++ b/apps/web/components/analytics/ExportButtons.tsx @@ -0,0 +1,50 @@ +'use client'; +import { useState } from 'react'; +import { Download, FileText } from 'lucide-react'; +import type { AnalyticsQuery } from '@velar/types'; +import { downloadCsv, downloadPdf } from '../../lib/analytics/client'; + +export function ExportButtons({ token, query }: { token: string; query: AnalyticsQuery }) { + const [busy, setBusy] = useState<'csv' | 'pdf' | null>(null); + const [error, setError] = useState(''); + const today = new Date().toISOString().slice(0, 10); + + const run = async (format: 'csv' | 'pdf') => { + setBusy(format); + setError(''); + try { + if (format === 'csv') await downloadCsv(token, query, `velar-analytics-${today}.csv`); + else await downloadPdf(token, query, `velar-analytics-${today}.pdf`); + } catch (e: any) { + setError(e.message ?? 'No se pudo exportar'); + } finally { + setBusy(null); + } + }; + + return ( +
+
+ + +
+ {error &&

{error}

} +
+ ); +} diff --git a/apps/web/components/analytics/FilterBar.tsx b/apps/web/components/analytics/FilterBar.tsx new file mode 100644 index 0000000..e7813e7 --- /dev/null +++ b/apps/web/components/analytics/FilterBar.tsx @@ -0,0 +1,105 @@ +'use client'; +import type { AnalyticsQuery } from '@velar/types'; +import { BondStatus, COUNTRY_CODES, TransferStatus } from '@velar/types'; + +const STATUS_OPTIONS = [...new Set([...Object.values(BondStatus), ...Object.values(TransferStatus)])]; + +export function FilterBar({ + query, + onChange, + showCountry = true, + showParty = false, + partyOptions = [], +}: { + query: AnalyticsQuery; + onChange: (q: AnalyticsQuery) => void; + showCountry?: boolean; + showParty?: boolean; + partyOptions?: { id: string; name: string }[]; +}) { + const update = (patch: Partial) => onChange({ ...query, ...patch }); + const inputClass = 'rounded-lg border border-outline-variant/40 bg-surface px-2.5 py-1.5 text-sm text-on-surface'; + const labelClass = 'flex flex-col gap-1 text-xs font-medium text-on-surface-variant'; + + return ( +
+ + + + {showCountry && ( + + )} + + {showParty && ( + + )} + + + + + + +
+ ); +} diff --git a/apps/web/components/analytics/KpiCard.tsx b/apps/web/components/analytics/KpiCard.tsx new file mode 100644 index 0000000..bc8a663 --- /dev/null +++ b/apps/web/components/analytics/KpiCard.tsx @@ -0,0 +1,27 @@ +import type { LucideIcon } from 'lucide-react'; + +export function KpiCard({ + label, + value, + Icon, + color = 'text-primary', + bg = 'bg-primary/10', +}: { + label: string; + value: string; + Icon: LucideIcon; + color?: string; + bg?: string; +}) { + return ( +
+
+ +
+
+

{label}

+

{value}

+
+
+ ); +} diff --git a/apps/web/components/analytics/SavedViewsMenu.tsx b/apps/web/components/analytics/SavedViewsMenu.tsx new file mode 100644 index 0000000..a5c07b7 --- /dev/null +++ b/apps/web/components/analytics/SavedViewsMenu.tsx @@ -0,0 +1,105 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { Bookmark, Trash2 } from 'lucide-react'; +import type { AnalyticsQuery, SavedView } from '@velar/types'; +import { createSavedView, deleteSavedView, listSavedViews } from '../../lib/analytics/client'; + +export function SavedViewsMenu({ + token, + query, + onApply, +}: { + token: string; + query: AnalyticsQuery; + onApply: (q: AnalyticsQuery) => void; +}) { + const [views, setViews] = useState([]); + const [name, setName] = useState(''); + const [saving, setSaving] = useState(false); + const [open, setOpen] = useState(false); + + useEffect(() => { + listSavedViews(token).then(setViews).catch(() => setViews([])); + }, [token]); + + const handleSave = async () => { + if (!name.trim()) return; + setSaving(true); + try { + const view = await createSavedView(token, { name: name.trim(), query }); + setViews((v) => [view, ...v]); + setName(''); + } catch { + // Silently ignored — the input keeps its value so the user can retry. + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: string) => { + setViews((v) => v.filter((x) => x.id !== id)); + await deleteSavedView(token, id).catch(() => {}); + }; + + return ( +
+ + {open && ( +
+
+ setName(e.target.value)} + placeholder="Nombre de la vista" + aria-label="Nombre de la vista" + className="flex-1 rounded-lg border border-outline-variant/40 px-2 py-1.5 text-sm" + /> + +
+ {views.length === 0 ? ( +

Sin vistas guardadas.

+ ) : ( +
    + {views.map((v) => ( +
  • + + +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/lib/analytics/client.ts b/apps/web/lib/analytics/client.ts new file mode 100644 index 0000000..9c3a134 --- /dev/null +++ b/apps/web/lib/analytics/client.ts @@ -0,0 +1,51 @@ +import type { AlertRuleInput, AnalyticsQuery, AnalyticsSnapshot, SavedView, SavedViewInput } from '@velar/types'; +import { apiDownload, apiFetch } from '../api'; +import { queryToQueryString, queryToSearchParams } from './query'; + +/** Thin wrapper over `apiFetch`/`apiDownload` for the analytics endpoints — no parallel HTTP client. */ + +export async function fetchSnapshot(token: string, query: AnalyticsQuery = {}): Promise { + return apiFetch(token, 'GET', `/analytics/snapshot${queryToQueryString(query)}`); +} + +function withFormat(query: AnalyticsQuery, format: 'csv' | 'pdf'): string { + const params = queryToSearchParams(query); + params.set('format', format); + return `?${params.toString()}`; +} + +export async function downloadCsv(token: string, query: AnalyticsQuery, filename: string) { + await apiDownload(token, `/analytics/export${withFormat(query, 'csv')}`, filename); +} + +export async function downloadPdf(token: string, query: AnalyticsQuery, filename: string) { + await apiDownload(token, `/analytics/export${withFormat(query, 'pdf')}`, filename); +} + +export async function listSavedViews(token: string): Promise { + return apiFetch(token, 'GET', '/analytics/views'); +} + +export async function createSavedView(token: string, input: SavedViewInput): Promise { + return apiFetch(token, 'POST', '/analytics/views', input); +} + +export async function deleteSavedView(token: string, id: string): Promise<{ ok: true }> { + return apiFetch(token, 'DELETE', `/analytics/views/${id}`); +} + +export async function listAlertRules(token: string) { + return apiFetch(token, 'GET', '/analytics/alert-rules'); +} + +export async function createAlertRule(token: string, input: AlertRuleInput) { + return apiFetch(token, 'POST', '/analytics/alert-rules', input); +} + +export async function deleteAlertRule(token: string, id: string) { + return apiFetch(token, 'DELETE', `/analytics/alert-rules/${id}`); +} + +export async function evaluateAlertRule(token: string, id: string) { + return apiFetch(token, 'POST', `/analytics/alert-rules/${id}/evaluate`); +} diff --git a/apps/web/lib/analytics/query.spec.ts b/apps/web/lib/analytics/query.spec.ts new file mode 100644 index 0000000..5c4eee8 --- /dev/null +++ b/apps/web/lib/analytics/query.spec.ts @@ -0,0 +1,71 @@ +import type { AnalyticsQuery } from '@velar/types'; +import { queryToQueryString, queryToSearchParams, searchParamsToQuery } from './query'; + +describe('queryToSearchParams', () => { + it('includes only the fields that are set', () => { + const params = queryToSearchParams({ country: 'CR', bucket: 'week' }); + expect(params.get('country')).toBe('CR'); + expect(params.get('bucket')).toBe('week'); + expect(params.has('from')).toBe(false); + expect(params.has('partyId')).toBe(false); + }); + + it('omits null/undefined/empty-string fields', () => { + const params = queryToSearchParams({ from: null, to: undefined, country: null, status: '' as any }); + expect([...params.keys()]).toEqual([]); + }); + + it('an empty query yields empty params', () => { + expect(queryToSearchParams({}).toString()).toBe(''); + }); +}); + +describe('searchParamsToQuery', () => { + it('parses a fully populated query string', () => { + const params = new URLSearchParams('from=2026-01-01&to=2026-02-01&country=CO&partyId=party-1&status=activo&bucket=month'); + expect(searchParamsToQuery(params)).toEqual({ + from: '2026-01-01', + to: '2026-02-01', + country: 'CO', + partyId: 'party-1', + status: 'activo', + bucket: 'month', + }); + }); + + it('missing fields become null (or undefined for bucket)', () => { + expect(searchParamsToQuery(new URLSearchParams())).toEqual({ + from: null, + to: null, + country: null, + partyId: null, + status: null, + bucket: undefined, + }); + }); +}); + +describe('queryToQueryString', () => { + it('prefixes with ? when non-empty', () => { + expect(queryToQueryString({ country: 'CR' })).toBe('?country=CR'); + }); + + it('is empty string (no ?) for an empty query', () => { + expect(queryToQueryString({})).toBe(''); + }); +}); + +describe('round-trip', () => { + it('query → params → query is stable', () => { + const original: AnalyticsQuery = { from: '2026-01-01', country: 'AR', bucket: 'day' }; + const roundTripped = searchParamsToQuery(queryToSearchParams(original)); + expect(roundTripped).toEqual({ + from: '2026-01-01', + to: null, + country: 'AR', + partyId: null, + status: null, + bucket: 'day', + }); + }); +}); diff --git a/apps/web/lib/analytics/query.ts b/apps/web/lib/analytics/query.ts new file mode 100644 index 0000000..67a9e41 --- /dev/null +++ b/apps/web/lib/analytics/query.ts @@ -0,0 +1,37 @@ +import type { AnalyticsQuery } from '@velar/types'; + +/** + * Pure (de)serialization of `AnalyticsQuery` to/from `URLSearchParams` (issue #44). + * Used both for the filter bar's shareable URL state and for saved views + * (a saved view is just a stored `AnalyticsQuery`). + */ + +const KEYS = ['from', 'to', 'country', 'partyId', 'status', 'bucket'] as const; + +export function queryToSearchParams(query: AnalyticsQuery): URLSearchParams { + const params = new URLSearchParams(); + for (const key of KEYS) { + const value = query[key]; + if (value !== undefined && value !== null && value !== '') { + params.set(key, String(value)); + } + } + return params; +} + +export function searchParamsToQuery(params: URLSearchParams): AnalyticsQuery { + return { + from: params.get('from') || null, + to: params.get('to') || null, + country: (params.get('country') as AnalyticsQuery['country']) || null, + partyId: params.get('partyId') || null, + status: (params.get('status') as AnalyticsQuery['status']) || null, + bucket: (params.get('bucket') as AnalyticsQuery['bucket']) || undefined, + }; +} + +/** `?a=1&b=2`, or `''` when the query is empty (no trailing `?`). */ +export function queryToQueryString(query: AnalyticsQuery): string { + const s = queryToSearchParams(query).toString(); + return s ? `?${s}` : ''; +} From e858ebf306bf6f30e6fadc9ff3570a6bb2831933 Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:06:11 -0600 Subject: [PATCH 22/23] feat(web): wire analytics dashboard into TSE and partido shells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites /tse/analytics in place (KPIs, filters, all 5 chart types, funnel, top-bond drill-down, compliance) and adds /partido/analytics (same component tree, party-scoped by the backend — no party/country filter shown since it's always the caller's own party). Adds the "Análisis" nav link to PartidoShell (TSEShell already had one). --- apps/web/app/partido/analytics/page.tsx | 22 ++ apps/web/app/tse/analytics/page.tsx | 277 +----------------------- apps/web/components/PartidoShell.tsx | 3 +- 3 files changed, 27 insertions(+), 275 deletions(-) create mode 100644 apps/web/app/partido/analytics/page.tsx diff --git a/apps/web/app/partido/analytics/page.tsx b/apps/web/app/partido/analytics/page.tsx new file mode 100644 index 0000000..16cce05 --- /dev/null +++ b/apps/web/app/partido/analytics/page.tsx @@ -0,0 +1,22 @@ +'use client'; +import { PartidoShell } from '../../../components/PartidoShell'; +import { useSession } from '../../../lib/api'; +import { AnalyticsDashboard } from '../../../components/analytics/AnalyticsDashboard'; + +export default function PartidoAnalyticsPage() { + const { token, me, loading, error } = useSession(); + + if (loading || !token || !me) { + return ( +
+ {error ?

{error}

: } +
+ ); + } + + return ( + + + + ); +} diff --git a/apps/web/app/tse/analytics/page.tsx b/apps/web/app/tse/analytics/page.tsx index b69beb0..0afdfdf 100644 --- a/apps/web/app/tse/analytics/page.tsx +++ b/apps/web/app/tse/analytics/page.tsx @@ -1,48 +1,10 @@ 'use client'; -import { useEffect, useState } from 'react'; -import Link from 'next/link'; -import { TrendingUp, TrendingDown, DollarSign, Activity, Boxes, Users, BarChart3, Download } from 'lucide-react'; import { TSEShell } from '../../../components/TSEShell'; -import { useSession, apiFetch, apiDownload } from '../../../lib/api'; - -const fmtCRC = (n: number) => new Intl.NumberFormat('es-CR', { style: 'currency', currency: 'CRC', maximumFractionDigits: 0 }).format(n || 0); -const fmtNum = (n: number) => new Intl.NumberFormat('es-CR').format(n || 0); +import { useSession } from '../../../lib/api'; +import { AnalyticsDashboard } from '../../../components/analytics/AnalyticsDashboard'; export default function AnalyticsPage() { const { token, me, loading, error } = useSession(); - const [overview, setOverview] = useState(null); - const [byParty, setByParty] = useState([]); - const [topBonds, setTopBonds] = useState([]); - const [volume, setVolume] = useState([]); - const [selBond, setSelBond] = useState(null); - const [priceHistory, setPriceHistory] = useState(null); - const [owners, setOwners] = useState(null); - const [exporting, setExporting] = useState(false); - const [exportError, setExportError] = useState(''); - - useEffect(() => { - if (!token) return; - Promise.all([ - apiFetch(token, 'GET', '/analytics/overview').catch(() => null), - apiFetch(token, 'GET', '/analytics/by-party').catch(() => []), - apiFetch(token, 'GET', '/analytics/top-bonds?limit=5').catch(() => []), - apiFetch(token, 'GET', '/analytics/volume-over-time?days=30').catch(() => []), - ]).then(([ov, bp, tb, vol]) => { - setOverview(ov); - setByParty(bp); - setTopBonds(tb); - setVolume(vol); - if (tb && tb[0]) setSelBond(tb[0].token_id); - }); - }, [token]); // eslint-disable-line - - useEffect(() => { - if (!token || !selBond) return; - Promise.all([ - apiFetch(token, 'GET', `/analytics/bonds/${selBond}/price-history`).catch(() => null), - apiFetch(token, 'GET', `/analytics/bonds/${selBond}/owners`).catch(() => null), - ]).then(([ph, ow]) => { setPriceHistory(ph); setOwners(ow); }); - }, [token, selBond]); if (loading || !token || !me) { return ( @@ -52,242 +14,9 @@ export default function AnalyticsPage() { ); } - const maxVolume = Math.max(...byParty.map((p) => p.volume_moved), 1); - const maxDayVol = Math.max(...volume.map((v) => v.volume), 1); - const exportFilename = `velar-transfers-${new Date().toISOString().slice(0, 10)}.csv`; - - const handleExportCsv = async () => { - if (!token) return; - setExporting(true); - setExportError(''); - try { - await apiDownload(token, '/analytics/export?format=csv', exportFilename); - } catch (e: any) { - setExportError(e.message ?? 'No se pudo exportar el CSV'); - } finally { - setExporting(false); - } - }; - return ( -
-
-

Análisis de bonos

-

Métricas, precios y propietarios

-
-
- - {exportError &&

{exportError}

} -
-
- -
- {/* Overview cards */} - {overview && ( -
- {[ - { label: 'Volumen movido', value: fmtCRC(overview.total_volume_crc), Icon: DollarSign, color: 'text-emerald-600', bg: 'bg-emerald-50' }, - { label: 'Valor emitido', value: fmtCRC(overview.total_emitted_crc), Icon: Boxes, color: 'text-blue-500', bg: 'bg-blue-50' }, - { label: 'Bonos emitidos', value: fmtNum(overview.total_bonds), Icon: BarChart3, color: 'text-primary', bg: 'bg-blue-50' }, - { label: 'Ventas completadas', value: fmtNum(overview.total_sales), Icon: Activity, color: 'text-teal-500', bg: 'bg-teal-50' }, - ].map(({ label, value, Icon, color, bg }) => ( -
-
- -
-
-

{label}

-

{value}

-
-
- ))} -
- )} - -
- {/* Volumen por partido */} -
-

Volumen movido por partido

-

Suma de ventas liberadas por partido emisor

- {byParty.length === 0 ? ( -

Sin datos todavía.

- ) : ( -
- {byParty.map((p) => ( -
-
- {p.party_name} - {fmtCRC(p.volume_moved)} -
-
-
-
-
- {p.bonds_count} bonos - {p.sales_count} ventas - Emitido: {fmtCRC(p.emitted_value)} -
-
- ))} -
- )} -
- - {/* Top bonos */} -
-

Top bonos más movidos

-

Por volumen acumulado

- {topBonds.length === 0 ? ( -

Sin ventas todavía.

- ) : ( -
- {topBonds.map((b, i) => ( - - ))} -
- )} -
-
- - {/* Volumen en el tiempo */} - {volume.length > 0 && ( -
-

Volumen últimos 30 días

-

Total CRC movido por día

-
- {volume.map((v) => ( -
-
- - {fmtCRC(v.volume)} - -
- ))} -
-
- {volume[0]?.date} - {volume[volume.length - 1]?.date} -
-
- )} - - {/* Detalle del bono seleccionado */} - {priceHistory && ( -
- {/* Histórico de precios */} -
-
-
-

Histórico de precios

-

{priceHistory.bond_id} · {priceHistory.party_name}

-
-
-

{fmtCRC(priceHistory.current_price)}

-

= 0 ? 'text-emerald-600' : 'text-red-500'}`}> - {priceHistory.total_change_pct >= 0 ? : } - {priceHistory.total_change_pct > 0 ? '+' : ''}{priceHistory.total_change_pct}% vs facial -

-
-
- - {priceHistory.points.length === 0 ? ( -

No hay ventas registradas todavía.

- ) : ( - <> - {/* Mini chart */} -
- {priceHistory.points.map((pt: any, i: number) => { - const max = Math.max(priceHistory.facial_value, ...priceHistory.points.map((p: any) => p.price)); - const h = (pt.price / max) * 100; - return ( -
-
= 0 ? 'bg-emerald-500' : 'bg-red-400'}`} - style={{ height: `${h}%` }} - /> - - {fmtCRC(pt.price)} · {pt.change_pct > 0 ? '+' : ''}{pt.change_pct}% - -
- ); - })} -
- -
- {priceHistory.points.map((pt: any, i: number) => ( -
- Venta #{pt.index} - {fmtCRC(pt.price)} - = 0 ? 'text-emerald-600' : 'text-red-500'}`}> - {pt.change_pct >= 0 ? : } - {pt.change_pct > 0 ? '+' : ''}{pt.change_pct}% - -
- ))} -
- - )} -
- - {/* Propietarios históricos */} - {owners && ( -
-
- -

Propietarios históricos

-
-

{owners.owners_count} propietario{owners.owners_count !== 1 ? 's' : ''} en la historia del bono

- -
- {owners.owners.map((o: any, i: number) => ( -
-
-

- {o.name ?? 'Sin dato'} - {o.current && DUEÑO ACTUAL} -

- {o.paid != null && {fmtCRC(o.paid)}} -
-

- Desde {new Date(o.since).toLocaleDateString('es-CR')} - {o.until && <> hasta {new Date(o.until).toLocaleDateString('es-CR')}} -

-
- ))} -
-
- )} -
- )} -
+ ); } diff --git a/apps/web/components/PartidoShell.tsx b/apps/web/components/PartidoShell.tsx index 0e31195..0619a84 100644 --- a/apps/web/components/PartidoShell.tsx +++ b/apps/web/components/PartidoShell.tsx @@ -3,7 +3,7 @@ import { ReactNode } from 'react'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { - LayoutDashboard, FileText, Wallet, Handshake, Waypoints, History, Settings, LogOut, ShieldCheck, Send, + LayoutDashboard, FileText, Wallet, Handshake, Waypoints, History, Settings, LogOut, ShieldCheck, Send, BarChart3, } from 'lucide-react'; import { createClient } from '../lib/supabase/client'; import type { Me } from '../lib/api'; @@ -19,6 +19,7 @@ const NAV = [ { href: '/partido/mis-bonos', label: 'Mis bonos', Icon: Wallet }, { href: '/partido/negociaciones', label: 'Negociaciones', Icon: Handshake }, { href: '/partido/trazabilidad', label: 'Trazabilidad', Icon: Waypoints }, + { href: '/partido/analytics', label: 'Análisis', Icon: BarChart3 }, { href: '/partido/reportes', label: 'Reportes al TSE', Icon: Send }, { href: '/partido/historial', label: 'Historial', Icon: History }, { href: '/partido/configuracion', label: 'Configuración', Icon: Settings }, From 31b641c3842999172f74b88887e5377c11659907 Mon Sep 17 00:00:00 2001 From: KevinLatino Date: Tue, 28 Jul 2026 18:06:20 -0600 Subject: [PATCH 23/23] docs: document the analytics & BI epic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BACKEND.md §10: engine file map, RBAC/data-access design, endpoints, alerting/scheduled-report stub, exports, migration, types, and how to verify locally with no credentials. FRONTEND_GUIDE.md §15: dashboard components, integration, and a colorVar() note for chart colors. --- docs/BACKEND.md | 87 ++++++++++++++++++++++++++++++++++++++++++ docs/FRONTEND_GUIDE.md | 38 ++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/docs/BACKEND.md b/docs/BACKEND.md index 88ecfc3..ca34f37 100644 --- a/docs/BACKEND.md +++ b/docs/BACKEND.md @@ -401,3 +401,90 @@ trazabilidad) y corre el motor. ### Comprobación local sin credenciales `npx jest` en `apps/api` (motor + servicio con `AuditService` mockeado). El motor no toca la red ni la base; el servicio se prueba con dobles. + +## 10. Analítica & BI: motor de agregación, alertas y reportes programados (issue #44) + +Plataforma de analítica en tiempo real para el ecosistema de bonos: KPIs, breakdowns +por partido/país, embudo de transferencias, series de tiempo, cumplimiento de reportes, +alertas de umbral y exportación CSV/PDF. Mismo patrón que procedencia (§9): motor **puro** +fixture-testeado, sin tocar Supabase, con el acceso a datos aislado en un servicio propio. + +### Motor puro (`analytics/engine/`) +Funciones sin dependencias, una por archivo: +- `aggregations.ts` — `aggregateByBondStatus`, `aggregateByParty`, `aggregateByCountry`, + `aggregateValueVolume`. +- `funnel.ts` — `computeTransferFunnel`: cuenta transferencias por etapa usando el índice + de `TRANSFER_LIFECYCLE_STEPS` (reutilizado de `@velar/types`, no redefinido). Sin eventos + de auditoría en el input, los estados fuera del camino feliz (`contraoferta`, `rechazada`, + `cancelada`) se cuentan aparte y no en las etapas. +- `timeseries.ts` — `bucketByDate` genérico (día/semana/mes, UTC) + series de emisión, + transferencias liberadas y "throughput" de escrow (aproximado como `createdAt`→`updatedAt` + de transferencias terminales; sin dependencia de `AuditEvent`). +- `trends.ts` — `periodOverPeriodDelta`, `movingAverage`, `topN`, `detectThresholdAnomalies`. +- `compliance.ts` — adapta `computeComplianceForPeriods` (`reports/domain/deadlines.ts`, + reutilizado, no reimplementado) para agrupar por partido. +- `alerts.ts` — `evaluateAlertRules`: compara un `AnalyticsSnapshot` contra reglas por + dot-path de métrica (`valueVolume.totalVolumeMoved`, etc.), sin I/O. +- `index.ts` — `buildAnalyticsSnapshot(input, query, scope, now, deadlineConfig)`: aplica + `AnalyticsScope` (RBAC ya resuelto, el motor nunca importa `Role`) y `AnalyticsQuery`, + compone todo lo anterior. Nunca muta `input`. + +### Datos, servicio y RBAC (`analytics/`) +- `analytics-data.service.ts` — único lugar que toca `SupabaseService.admin.from(...)`; + mapea `bonds`/`transfers`/`reports` a los tipos de `@velar/types`. Filtra reportes del + modelo legado sin `period_year`/`period_month` (no alimentan cumplimiento). +- `analytics.service.ts` — `resolveScope(role, partyId)`: TSE/admin ven todo; `emisor` ve + solo su partido; el resto no tiene acceso a analítica agregada. También: export CSV/PDF, + CRUD de vistas guardadas (`analytics_saved_views`, por dueño) y reglas de alerta + (`analytics_alert_rules`, TSE/admin), evaluación de alertas (emite notificaciones vía + `NotificationsService.emit` con `NotificationType.ANALYTICS_THRESHOLD_BREACHED`, nunca + lanza) y reporte programado manual. Mantiene sin cambios los endpoints legados de + drill-down (`bonds/:tokenId/price-history`, `bonds/:tokenId/owners`, `top-bonds`, + `legacy-export`) que usan Supabase en vivo con nombres de perfiles. +- `analytics.controller.ts` — rutas de solo-lectura resueltas por rol dentro del servicio; + `@Roles('tse','admin')` (vía `RolesGuard`, global) en configuración privilegiada + (reglas de alerta, disparo de reporte programado). + +``` +GET /api/analytics/snapshot (?from&to&country&partyId&status&bucket) +GET /api/analytics/export?format=csv|pdf (mismo query, snapshot-based) +GET /api/analytics/top-bonds (legado, detalle con nombres) +GET /api/analytics/bonds/:tokenId/price-history (legado, drill-down) +GET /api/analytics/bonds/:tokenId/owners (legado, drill-down) +GET /api/analytics/legacy-export?format=csv (legado, CSV con nombres) + +GET /api/analytics/views | POST | DELETE /:id (vistas guardadas, por dueño) +GET /api/analytics/alert-rules | POST | PATCH | DELETE /:id (tse/admin) +POST /api/analytics/alert-rules/:id/evaluate (tse/admin) +POST /api/analytics/scheduled-reports/run (tse/admin, manual) +``` + +### Alertas y reporte programado: interfaz + stub +`ScheduledReportGenerator` (interfaz + token `SCHEDULED_REPORT_GENERATOR`, mismo patrón +que el hook de antivirus en `reports/files/file-scanner.ts`) tiene una única implementación +manual (`ManualScheduledReportGenerator`): genera CSV/PDF del snapshot actual bajo demanda. +No hay cron ni vendor — se dispara solo por `POST /scheduled-reports/run`. + +### Exportación +- `csv/analytics-csv.ts` — `renderSnapshotCsv`: CSV determinístico del snapshot completo + (breakdowns + totales), puro, sin nombres de perfiles (por eso convive con el CSV legado). +- `pdf/analytics-pdf.ts` — `renderAnalyticsPdf` con `pdf-lib` (JS puro, sin navegador headless). + El contenido es determinístico; el orden interno de objetos del PDF no lo es entre + ejecuciones, así que las pruebas son **estructurales** (header `%PDF-`, `PDFDocument.load` + reabre el archivo, cuenta de páginas), no snapshots de bytes. + +### Migración +`supabase/migrations/20260728000000_analytics_saved_views.sql` — tablas +`analytics_saved_views` (RLS: dueño) y `analytics_alert_rules` (RLS: TSE/admin). Aditiva, +no toca ninguna migración existente. Sin vista materializada de pre-agregación en v1 (el +volumen de datos de la demo no lo justifica todavía). + +### Tipos (`@velar/types`) +`analytics.ts` — `AnalyticsInput`, `AnalyticsQuery`, `AnalyticsScope`, `AnalyticsSnapshot` +y cada breakdown/serie/trend, `AlertRule`/`AlertBreach`, `SavedView`, `ScheduledReportConfig`/ +`Result`. Fixture en `fixtures/analytics.ts` (`analyticsFixture`, 3 partidos en 2 países, +todo el embudo de transferencias, cumplimiento on-time/late/missing). + +### Comprobación local sin credenciales +`npx jest src/analytics` en `apps/api` (motor puro fixture-driven, capa de datos y servicio +con `SupabaseService`/`NotificationsService` mockeados, exportación CSV/PDF determinística). diff --git a/docs/FRONTEND_GUIDE.md b/docs/FRONTEND_GUIDE.md index 7d8cd94..98a255f 100644 --- a/docs/FRONTEND_GUIDE.md +++ b/docs/FRONTEND_GUIDE.md @@ -393,3 +393,41 @@ Galería viva en **`/design-system`** (cada variante + estado). exportado en `index.ts`, y agregado a `/design-system`. - Nuevo token → declaralo en `globals.css` (con override dark/país si aplica) **y** en `tokens.ts` con el mismo nombre. No dejes valores que puedan divergir. +- **Charts (Recharts, §15):** los colores de series/ejes/grid se pasan siempre vía + `colorVar('primary')`, etc. — nunca hex literal — para que los gráficos respeten el tema + claro/oscuro igual que el resto de la UI. + +## 15. Dashboard de analítica & BI (issue #44) + +Panel de inteligencia de negocio para TSE (`/tse/analytics`, todo el ecosistema) y partido +(`/partido/analytics`, solo su propia data — el backend la restringe, el frontend no filtra +por seguridad). KPIs, 5 tipos de gráfico, filtros, drill-down, vistas guardadas y export +CSV/PDF, todo sobre `GET /analytics/snapshot`. + +### Helpers puros — `lib/analytics/` +- **`query.ts`** — `AnalyticsQuery` ⇄ `URLSearchParams` (`queryToSearchParams`, + `searchParamsToQuery`, `queryToQueryString`). Es lo que se guarda como "vista guardada". +- **`client.ts`** — wrapper delgado sobre `apiFetch`/`apiDownload` (`lib/api.ts`) — no crea + un cliente HTTP paralelo: `fetchSnapshot`, `downloadCsv`/`downloadPdf`, + `listSavedViews`/`createSavedView`/`deleteSavedView`, `listAlertRules`/`createAlertRule`/ + `deleteAlertRule`/`evaluateAlertRule`. + +### Componentes — `components/analytics/` +- **`AnalyticsDashboard`** — el árbol completo (KPIs, filtros, charts, embudo, top bonos, + cumplimiento), compartido por ambas páginas vía la prop `showPartyControls` (oculta + filtro de país/partido en la vista del partido, que siempre ve solo lo suyo). +- **`KpiCard`**, **`FilterBar`** (fecha desde/hasta, país, partido, estado, bucket), + **`SavedViewsMenu`** (listar/guardar/borrar vistas), **`ExportButtons`** (CSV/PDF), + **`DrillDownPanel`** — modal (`components/ui/Modal.tsx`) que, al hacer clic en un "top + bono", reutiliza los endpoints legados de detalle (`price-history`/`owners`) para mostrar + histórico de precios y cadena de propietarios. +- **`charts/`** — `BarChart`, `LineChart`, `AreaChart`, `PieChart`, `StackedBarChart` + (Recharts). Cada uno se apoya en `ChartFrame`, que agrega `role="img"` + `aria-label` y un + `` con los mismos datos como fallback de accesibilidad — no hay + toggle, siempre está en el DOM. + +### Integración +`TSEShell`/`PartidoShell` ya tienen el link "Análisis" en el nav. RBAC es 100% backend +(`docs/AGENTS.md` §5): el `AnalyticsScope` que decide qué partido ve qué se resuelve en +`AnalyticsService.resolveScope`, nunca en el cliente. Sin `service_role` ni secretos en el +frontend. Estados de carga/vacío/error en cada sección (no pantallas en blanco).