Skip to content
Open
33 changes: 33 additions & 0 deletions migration/1779221816705-AddLogFinancialIndex.js

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adapt to PSQL

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddLogFinancialIndex1779221816705 {
name = 'AddLogFinancialIndex1779221816705';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
// Composite index to accelerate dashboard-financial queries:
// - getLatestFinancialLog (system/subsystem/severity, ORDER BY id DESC)
// - getFinancialLogs / getFinancialChangesLogs (range scans on created with daily/minute bucketing)
// - log-job.service.maxEntity lookups (every minute on the same predicates)
// The log table grows by ~525k rows/year (one cron entry per minute), so a non-covered scan is expensive.
await queryRunner.query(
`CREATE INDEX "IDX_7765c3f5f663a0c6d250d28255" ON "log" ("subsystem", "severity", "created")`,
);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "IDX_7765c3f5f663a0c6d250d28255" ON "log"`);
}
};
36 changes: 36 additions & 0 deletions migration/1779221847925-AddLogFinancialAggregates.js

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adapt to PSQL

Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddLogFinancialAggregates1779221847925 {
name = 'AddLogFinancialAggregates1779221847925';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
// Denormalised aggregates for the FinancialDataLog cron rows, consumed by the dashboard chart
// endpoint. Nullable so older rows continue to work via a JSON.parse fallback in the service.
await queryRunner.query(`ALTER TABLE "log" ADD "totalBalanceChf" float`);
await queryRunner.query(`ALTER TABLE "log" ADD "plusBalanceChf" float`);
await queryRunner.query(`ALTER TABLE "log" ADD "minusBalanceChf" float`);
await queryRunner.query(`ALTER TABLE "log" ADD "btcPriceChf" float`);
await queryRunner.query(`ALTER TABLE "log" ADD "balancesByTypeJson" nvarchar(4000)`);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "log" DROP COLUMN "balancesByTypeJson"`);
await queryRunner.query(`ALTER TABLE "log" DROP COLUMN "btcPriceChf"`);
await queryRunner.query(`ALTER TABLE "log" DROP COLUMN "minusBalanceChf"`);
await queryRunner.query(`ALTER TABLE "log" DROP COLUMN "plusBalanceChf"`);
await queryRunner.query(`ALTER TABLE "log" DROP COLUMN "totalBalanceChf"`);
}
};
88 changes: 87 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"coingecko-api-v3": "^0.0.25",
"compression": "^1.8.1",
"cors": "^2.8.5",
"ebics-client": "^5.0.0",
"ethers": "^5.8.0",
Expand Down Expand Up @@ -137,6 +138,7 @@
"@nestjs/cli": "^9.5.0",
"@nestjs/schematics": "^9.2.0",
"@nestjs/testing": "^9.4.3",
"@types/compression": "^1.8.1",
"@types/express": "^4.17.25",
"@types/express-useragent": "^1.0.5",
"@types/google-libphonenumber": "^7.4.30",
Expand Down
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as AppInsights from 'applicationinsights';
import { spawnSync } from 'child_process';
import { useContainer } from 'class-validator';
import compression from 'compression';
import cors from 'cors';
import { json, raw, text } from 'express';
import helmet from 'helmet';
Expand Down Expand Up @@ -62,6 +63,7 @@ async function bootstrap() {

app.use(morgan('dev'));
app.use(helmet());
app.use(compression());
app.use(
cors({
exposedHeaders: ['content-disposition'],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { createMock } from '@golevelup/ts-jest';
import { Test, TestingModule } from '@nestjs/testing';
import { AssetService } from 'src/shared/models/asset/asset.service';
import { RefRewardService } from 'src/subdomains/core/referral/reward/services/ref-reward.service';
import { createCustomLog } from '../../log/__mocks__/log.entity.mock';
import { LogService } from '../../log/log.service';
import { DashboardFinancialService } from '../dashboard-financial.service';

describe('DashboardFinancialService', () => {
let service: DashboardFinancialService;

let logService: LogService;
let assetService: AssetService;
let refRewardService: RefRewardService;

beforeEach(async () => {
logService = createMock<LogService>();
assetService = createMock<AssetService>();
refRewardService = createMock<RefRewardService>();

const module: TestingModule = await Test.createTestingModule({
providers: [
DashboardFinancialService,
{ provide: LogService, useValue: logService },
{ provide: AssetService, useValue: assetService },
{ provide: RefRewardService, useValue: refRewardService },
],
}).compile();

service = module.get(DashboardFinancialService);
});

describe('getFinancialLog (chart fast path)', () => {
it('uses denormalised aggregate columns without parsing the message JSON', async () => {
const log = createCustomLog({
id: 42,
created: new Date('2026-05-19T12:00:00Z'),
message: '{"this":"must not be parsed"}',
totalBalanceChf: 1_000_000,
plusBalanceChf: 1_200_000,
minusBalanceChf: 200_000,
btcPriceChf: 90_000,
balancesByTypeJson: JSON.stringify({ EUR: { plusBalanceChf: 50, minusBalanceChf: 10 } }),
});

jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([log]);
jest.spyOn(assetService, 'getBtcCoin').mockResolvedValue({ id: 1 } as never);

const result = await service.getFinancialLog(new Date(), false);

expect(result.entries).toHaveLength(1);
expect(result.entries[0]).toEqual({
timestamp: log.created,
totalBalanceChf: 1_000_000,
plusBalanceChf: 1_200_000,
minusBalanceChf: 200_000,
btcPriceChf: 90_000,
balancesByType: { EUR: { plusBalanceChf: 50, minusBalanceChf: 10 } },
});
});

it('falls back to JSON.parse for legacy rows without denormalised columns', async () => {
const message = JSON.stringify({
balancesByFinancialType: { EUR: { plusBalanceChf: 100, minusBalanceChf: 20 } },
balancesTotal: { plusBalanceChf: 100, minusBalanceChf: 20, totalBalanceChf: 80 },
assets: { 1: { priceChf: 90_000 } },
});

const legacyLog = createCustomLog({
id: 1,
created: new Date('2024-01-01T00:00:00Z'),
message,
totalBalanceChf: null,
plusBalanceChf: null,
minusBalanceChf: null,
btcPriceChf: null,
balancesByTypeJson: null,
});

jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([legacyLog]);
jest.spyOn(assetService, 'getBtcCoin').mockResolvedValue({ id: 1 } as never);

const result = await service.getFinancialLog(new Date(), false);

expect(result.entries).toHaveLength(1);
expect(result.entries[0]).toMatchObject({
totalBalanceChf: 80,
plusBalanceChf: 100,
minusBalanceChf: 20,
btcPriceChf: 90_000,
balancesByType: { EUR: { plusBalanceChf: 100, minusBalanceChf: 20 } },
});
});

it('caches results within the TTL window', async () => {
const log = createCustomLog({
id: 1,
created: new Date(),
totalBalanceChf: 1,
plusBalanceChf: 1,
minusBalanceChf: 0,
btcPriceChf: 0,
});

const spy = jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([log]);
jest.spyOn(assetService, 'getBtcCoin').mockResolvedValue({ id: 1 } as never);

const from = new Date();
await service.getFinancialLog(from, false);
await service.getFinancialLog(from, false);

expect(spy).toHaveBeenCalledTimes(1);
});

it('uses different cache entries for different query parameters', async () => {
const log = createCustomLog({
id: 1,
created: new Date(),
totalBalanceChf: 1,
plusBalanceChf: 1,
minusBalanceChf: 0,
btcPriceChf: 0,
});

const spy = jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([log]);
jest.spyOn(assetService, 'getBtcCoin').mockResolvedValue({ id: 1 } as never);

await service.getFinancialLog(new Date('2026-01-01'), false);
await service.getFinancialLog(new Date('2026-01-02'), false);
await service.getFinancialLog(new Date('2026-01-01'), true);

expect(spy).toHaveBeenCalledTimes(3);
});
});
});
Loading