Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
841996d
feat(types): add analytics domain types
KevinLatino Jul 29, 2026
7a1cdd9
feat(types): add deterministic analytics fixture
KevinLatino Jul 29, 2026
c216b6a
feat(types): export analytics module from the package barrel
KevinLatino Jul 29, 2026
5121dd4
feat(types): add analytics threshold-breach notification type
KevinLatino Jul 29, 2026
5a42cf4
feat(api): add pure aggregation and funnel analytics engine
KevinLatino Jul 29, 2026
6deb397
feat(api): add analytics time-series and trend engine
KevinLatino Jul 29, 2026
f8605e3
feat(api): add analytics compliance, alerting, and snapshot composition
KevinLatino Jul 29, 2026
cc92a4d
test(api): add unit tests for aggregation and funnel engine
KevinLatino Jul 29, 2026
89b6d7a
test(api): add unit tests for timeseries, trends, compliance, and alerts
KevinLatino Jul 29, 2026
55b502b
test(api): add unit tests for the snapshot composition root
KevinLatino Jul 29, 2026
3d8d1dd
feat(api): isolate Supabase access in AnalyticsDataService
KevinLatino Jul 29, 2026
ea1280d
feat(api): add deterministic CSV snapshot export
KevinLatino Jul 29, 2026
aa7cf2b
feat(api): add PDF snapshot export via pdf-lib
KevinLatino Jul 29, 2026
45ce672
feat(api): add scheduled report generator interface and manual stub
KevinLatino Jul 29, 2026
ac64e18
feat(db): add migration for analytics saved views and alert rules
KevinLatino Jul 29, 2026
782e31a
feat(api): rewrite analytics service and controller with RBAC scoping
KevinLatino Jul 29, 2026
084a676
test(api): add RBAC, export, and alert-rule tests for analytics
KevinLatino Jul 29, 2026
0d450d2
fix(web): restore design-system color tokens dropped by Tailwind pruning
KevinLatino Jul 29, 2026
798cca4
feat(web): add recharts and analytics chart components
KevinLatino Jul 29, 2026
eb0ebc1
chore(web): add recharts dependency, refresh lockfile
KevinLatino Jul 29, 2026
68dd283
feat(web): add analytics API client, query helpers, and dashboard com…
KevinLatino Jul 29, 2026
e858ebf
feat(web): wire analytics dashboard into TSE and partido shells
KevinLatino Jul 29, 2026
31b641c
docs: document the analytics & BI epic
KevinLatino Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
152 changes: 152 additions & 0 deletions apps/api/src/analytics/analytics-data.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Record<'bonds' | 'transfers' | 'reports', { data: any[] | null; error: any }>> = {}) {

Check warning on line 59 in apps/api/src/analytics/analytics-data.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint (ESLint)

Unexpected any. Specify a different type

Check warning on line 59 in apps/api/src/analytics/analytics-data.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint (ESLint)

Unexpected any. Specify a different type
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);
});
});
105 changes: 105 additions & 0 deletions apps/api/src/analytics/analytics-data.service.ts
Original file line number Diff line number Diff line change
@@ -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<AnalyticsInput> {
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)),

Check warning on line 27 in apps/api/src/analytics/analytics-data.service.ts

View workflow job for this annotation

GitHub Actions / Lint (ESLint)

Unexpected any. Specify a different type
transfers: (transfersRes.data ?? []).map((t: any) => this.mapTransfer(t)),

Check warning on line 28 in apps/api/src/analytics/analytics-data.service.ts

View workflow job for this annotation

GitHub Actions / Lint (ESLint)

Unexpected any. Specify a different type
// `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)

Check warning on line 33 in apps/api/src/analytics/analytics-data.service.ts

View workflow job for this annotation

GitHub Actions / Lint (ESLint)

Unexpected any. Specify a different type
.map((r: any) => this.mapReport(r)),

Check warning on line 34 in apps/api/src/analytics/analytics-data.service.ts

View workflow job for this annotation

GitHub Actions / Lint (ESLint)

Unexpected any. Specify a different type
};
}

private mapBond(bond: any): BondToken {

Check warning on line 38 in apps/api/src/analytics/analytics-data.service.ts

View workflow job for this annotation

GitHub Actions / Lint (ESLint)

Unexpected any. Specify a different type
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 {

Check warning on line 68 in apps/api/src/analytics/analytics-data.service.ts

View workflow job for this annotation

GitHub Actions / Lint (ESLint)

Unexpected any. Specify a different type
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 {

Check warning on line 87 in apps/api/src/analytics/analytics-data.service.ts

View workflow job for this annotation

GitHub Actions / Lint (ESLint)

Unexpected any. Specify a different type
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,
};
}
}
101 changes: 97 additions & 4 deletions apps/api/src/analytics/analytics.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -36,12 +38,12 @@
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"`);
Expand All @@ -53,7 +55,98 @@
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) => {

Check warning on line 78 in apps/api/src/analytics/analytics.controller.spec.ts

View workflow job for this annotation

GitHub Actions / Lint (ESLint)

Unexpected any. Specify a different type
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);
});
});
Loading
Loading