Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 48 additions & 1 deletion backend/src/controllers/forecastController.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
// Modified 2026-10-05: use the class-owned Redis client in unbound route handlers.
import { Request, Response } from 'express';
import { ForecastingService } from '../services/forecasting/forecastingService.js';
import tenantConfigService from '../services/tenantConfigService.js';
import { RedisClient } from '../services/rateLimitService.js';
import logger from '../utils/logger.js';

export class ForecastController {
private static readonly redis = RedisClient.getInstance();

static async getForecast(req: Request, res: Response): Promise<void> {
try {
const organizationId = req.user?.organizationId;
Expand Down Expand Up @@ -37,7 +42,40 @@ export class ForecastController {
return;
}

const settings = await tenantConfigService.getConfig(organizationId, 'liquidity_settings');
const cacheKey = `cache:organization-settings:${organizationId}:liquidity-settings`;
let settings: any | null = null;

if (ForecastController.redis) {
try {
const cached = await ForecastController.redis.get(cacheKey);
if (cached !== null) {
logger.info('Cache hit', { cache: 'organization-settings', organizationId });
settings = JSON.parse(cached);
} else {
logger.info('Cache miss', { cache: 'organization-settings', organizationId });
}
} catch (error) {
logger.warn('Organization settings cache read failed', { organizationId, error });
}
} else {
logger.info('Cache miss', {
cache: 'organization-settings',
organizationId,
reason: 'redis_not_configured',
});
}

if (settings === null) {
settings = await tenantConfigService.getConfig(organizationId, 'liquidity_settings');
if (ForecastController.redis && settings !== null) {
try {
await ForecastController.redis.setex(cacheKey, 30 * 60, JSON.stringify(settings));
} catch (error) {
logger.warn('Organization settings cache write failed', { organizationId, error });
}
}
}

res.status(200).json({ success: true, data: settings || null });
} catch (error: any) {
res.status(500).json({
Expand Down Expand Up @@ -84,6 +122,15 @@ export class ForecastController {
};

await tenantConfigService.setConfig(organizationId, 'liquidity_settings', payload);

if (ForecastController.redis) {
try {
await ForecastController.redis.del(`cache:organization-settings:${organizationId}:liquidity-settings`);
} catch (error) {
logger.warn('Organization settings cache invalidation failed', { organizationId, error });
}
}

res.status(200).json({ success: true, data: payload });
} catch (error: any) {
res.status(500).json({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ jest.mock('../../config/database', () => ({
jest.mock('../employeeService', () => ({
employeeService: {
create: jest.fn(),
invalidateListCache: jest.fn(),
},
}));

Expand Down
1 change: 1 addition & 0 deletions backend/src/services/csvPayrollImportService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export class CsvPayrollImportService {
successCount++;
}
await client.query('COMMIT');
await employeeService.invalidateListCache(organizationId);
} catch (error) {
await client.query('ROLLBACK');
logger.error('Bulk import transaction failed', error);
Expand Down
82 changes: 79 additions & 3 deletions backend/src/services/employeeService.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,46 @@
import { pool } from '../config/database.js';
import { RedisClient } from './rateLimitService.js';
import logger from '../utils/logger.js';
import {
CreateEmployeeInput,
UpdateEmployeeInput,
EmployeeQueryInput,
} from '../schemas/employeeSchema.js';

export class EmployeeService {
private readonly redis = RedisClient.getInstance();

private listCacheKey(organizationId: number, params: EmployeeQueryInput): string {
const { page = 1, limit = 10, search, status, department } = params;
const fingerprint = JSON.stringify([
page,
limit,
search ?? '',
status ?? '',
department ?? '',
]);
return `cache:employees:${organizationId}:${Buffer.from(fingerprint).toString('base64url')}`;
}

async invalidateListCache(organizationId: number): Promise<void> {
if (!this.redis) return;

const pattern = `cache:employees:${organizationId}:*`;
try {
let cursor = '0';
do {
const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;
const firstKey = keys[0];
if (firstKey) {
await this.redis.del(firstKey, ...keys.slice(1));
}
} while (cursor !== '0');
} catch (error) {
logger.warn('Employee cache invalidation failed', { organizationId, error });
}
}

async create(data: CreateEmployeeInput, dbClient?: any) {
const executor = dbClient || pool;
const {
Expand Down Expand Up @@ -43,11 +78,34 @@ export class EmployeeService {
];

const result = await executor.query(query, values);
if (!dbClient) {
await this.invalidateListCache(organization_id);
}
return result.rows[0];
}

async findAll(organization_id: number, params: EmployeeQueryInput) {
const { page = 1, limit = 10, search, status, department } = params;
const cacheKey = this.listCacheKey(organization_id, params);

if (this.redis) {
try {
const cached = await this.redis.get(cacheKey);
if (cached !== null) {
logger.info('Cache hit', { cache: 'employee-list', organizationId: organization_id });
return JSON.parse(cached);
}
logger.info('Cache miss', { cache: 'employee-list', organizationId: organization_id });
} catch (error) {
logger.warn('Employee cache read failed', { organizationId: organization_id, error });
}
} else {
logger.info('Cache miss', {
cache: 'employee-list',
organizationId: organization_id,
reason: 'redis_not_configured',
});
}
const offset = (page - 1) * limit;

let query = `
Expand Down Expand Up @@ -97,7 +155,7 @@ export class EmployeeService {
return employee;
});

return {
const response = {
data: employees,
pagination: {
total,
Expand All @@ -106,6 +164,16 @@ export class EmployeeService {
totalPages: Math.ceil(total / limit),
},
};

if (this.redis) {
try {
await this.redis.setex(cacheKey, 5 * 60, JSON.stringify(response));
} catch (error) {
logger.warn('Employee cache write failed', { organizationId: organization_id, error });
}
}

return response;
}

async findById(id: number, organization_id: number) {
Expand Down Expand Up @@ -140,7 +208,11 @@ export class EmployeeService {
`;

const result = await pool.query(query, values);
return result.rows[0] || null;
const updated = result.rows[0] || null;
if (updated) {
await this.invalidateListCache(organization_id);
}
return updated;
}

async delete(id: number, organization_id: number) {
Expand All @@ -151,7 +223,11 @@ export class EmployeeService {
RETURNING *;
`;
const result = await pool.query(query, [id, organization_id]);
return result.rows[0] || null;
const deleted = result.rows[0] || null;
if (deleted) {
await this.invalidateListCache(organization_id);
}
return deleted;
}
}

Expand Down
42 changes: 40 additions & 2 deletions backend/src/services/forecasting/fxRateService.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,45 @@
import { pool } from '../../config/database.js';
import { RedisClient } from '../rateLimitService.js';
import logger from '../../utils/logger.js';

export interface FxRatePoint {
rateDate: string;
rate: number;
}

export class FxRateService {
private static readonly redis = RedisClient.getInstance();

static async getDailyRates(
baseCurrency: string,
quoteCurrency: string,
startDate: string,
endDate: string
): Promise<FxRatePoint[]> {
const normalizedBase = baseCurrency.toUpperCase();
const normalizedQuote = quoteCurrency.toUpperCase();
const cacheKey = `cache:fx-rates:${normalizedBase}:${normalizedQuote}:${startDate}:${endDate}`;

if (this.redis) {
try {
const cached = await this.redis.get(cacheKey);
if (cached !== null) {
logger.info('Cache hit', { cache: 'fx-rates', baseCurrency: normalizedBase, quoteCurrency: normalizedQuote });
return JSON.parse(cached) as FxRatePoint[];
}
logger.info('Cache miss', { cache: 'fx-rates', baseCurrency: normalizedBase, quoteCurrency: normalizedQuote });
} catch (error) {
logger.warn('FX rate cache read failed', { baseCurrency: normalizedBase, quoteCurrency: normalizedQuote, error });
}
} else {
logger.info('Cache miss', {
cache: 'fx-rates',
baseCurrency: normalizedBase,
quoteCurrency: normalizedQuote,
reason: 'redis_not_configured',
});
}

const result = await pool.query(
`SELECT rate_date, rate
FROM fx_rates
Expand All @@ -20,13 +48,23 @@ export class FxRateService {
AND rate_date >= $3
AND rate_date <= $4
ORDER BY rate_date ASC`,
[baseCurrency.toUpperCase(), quoteCurrency.toUpperCase(), startDate, endDate]
[normalizedBase, normalizedQuote, startDate, endDate]
);

return result.rows.map((r: any) => ({
const rates = result.rows.map((r: any) => ({
rateDate: new Date(r.rate_date).toISOString().slice(0, 10),
rate: Number(r.rate),
}));

if (this.redis) {
try {
await this.redis.setex(cacheKey, 5 * 60, JSON.stringify(rates));
} catch (error) {
logger.warn('FX rate cache write failed', { baseCurrency: normalizedBase, quoteCurrency: normalizedQuote, error });
}
}

return rates;
}

static calculateDailyReturnsVolatility(rates: FxRatePoint[]): number | null {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/rateLimitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const RATE_LIMIT_TIERS = {

export type RateLimitTierName = keyof typeof RATE_LIMIT_TIERS;

class RedisClient {
export class RedisClient {
private static instance: Redis | null = null;

static getInstance(): Redis | null {
Expand Down