From 320f0b22ac6c64378c23ba3745b26f8518a62f6c Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 19:57:17 -0400 Subject: [PATCH 01/10] refactor: expose existing Redis client --- backend/src/services/rateLimitService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/services/rateLimitService.ts b/backend/src/services/rateLimitService.ts index f90773e7..23ce8d8b 100644 --- a/backend/src/services/rateLimitService.ts +++ b/backend/src/services/rateLimitService.ts @@ -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 { From aa280712f3e753df577be98875fbefcdf16b28aa Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 19:58:21 -0400 Subject: [PATCH 02/10] perf: cache employee list reads --- backend/src/services/employeeService.ts | 82 ++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/backend/src/services/employeeService.ts b/backend/src/services/employeeService.ts index ba2a7489..edde581d 100644 --- a/backend/src/services/employeeService.ts +++ b/backend/src/services/employeeService.ts @@ -1,4 +1,6 @@ import { pool } from '../config/database.js'; +import { RedisClient } from './rateLimitService.js'; +import logger from '../utils/logger.js'; import { CreateEmployeeInput, UpdateEmployeeInput, @@ -6,6 +8,39 @@ import { } 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 { + 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 { @@ -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 = ` @@ -97,7 +155,7 @@ export class EmployeeService { return employee; }); - return { + const response = { data: employees, pagination: { total, @@ -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) { @@ -114,7 +182,11 @@ export class EmployeeService { WHERE id = $1 AND organization_id = $2 AND deleted_at IS NULL `; 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; } async update(id: number, organization_id: number, data: UpdateEmployeeInput) { @@ -140,7 +212,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) { From bc4937e7b5c3ccd1bb6c4fe1f0ee7ccf0311ffdb Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 19:58:30 -0400 Subject: [PATCH 03/10] perf: invalidate employee cache after bulk import --- backend/src/services/csvPayrollImportService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/services/csvPayrollImportService.ts b/backend/src/services/csvPayrollImportService.ts index 0c97bf9e..ff66d737 100644 --- a/backend/src/services/csvPayrollImportService.ts +++ b/backend/src/services/csvPayrollImportService.ts @@ -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); From 3c802e14832ac3d6907c71f5d5a2127a6cc2a029 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 19:58:33 -0400 Subject: [PATCH 04/10] test: mock employee cache invalidation --- backend/src/services/__tests__/csvPayrollImportService.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/services/__tests__/csvPayrollImportService.test.ts b/backend/src/services/__tests__/csvPayrollImportService.test.ts index ced93c3a..3941f186 100644 --- a/backend/src/services/__tests__/csvPayrollImportService.test.ts +++ b/backend/src/services/__tests__/csvPayrollImportService.test.ts @@ -14,6 +14,7 @@ jest.mock('../../config/database', () => ({ jest.mock('../employeeService', () => ({ employeeService: { create: jest.fn(), + invalidateListCache: jest.fn(), }, })); From aa1ca777efe3f0e1751e1065795c3323f71fb63a Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 19:58:45 -0400 Subject: [PATCH 05/10] perf: cache FX rate reads for five minutes --- .../src/services/forecasting/fxRateService.ts | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/backend/src/services/forecasting/fxRateService.ts b/backend/src/services/forecasting/fxRateService.ts index cc3c54ee..cd7ba9e1 100644 --- a/backend/src/services/forecasting/fxRateService.ts +++ b/backend/src/services/forecasting/fxRateService.ts @@ -1,4 +1,6 @@ import { pool } from '../../config/database.js'; +import { RedisClient } from '../rateLimitService.js'; +import logger from '../../utils/logger.js'; export interface FxRatePoint { rateDate: string; @@ -6,12 +8,38 @@ export interface FxRatePoint { } export class FxRateService { + private static readonly redis = RedisClient.getInstance(); + static async getDailyRates( baseCurrency: string, quoteCurrency: string, startDate: string, endDate: string ): Promise { + 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 @@ -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 { From 42692e0fdcc3d23800f0825aa3fc30ab81971bcd Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 20:00:08 -0400 Subject: [PATCH 06/10] perf: cache organization liquidity settings --- backend/src/controllers/forecastController.ts | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/backend/src/controllers/forecastController.ts b/backend/src/controllers/forecastController.ts index 2504df0d..01ac3327 100644 --- a/backend/src/controllers/forecastController.ts +++ b/backend/src/controllers/forecastController.ts @@ -1,8 +1,12 @@ 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 { try { const organizationId = req.user?.organizationId; @@ -37,7 +41,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 (this.redis) { + try { + const cached = await this.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 (this.redis && settings !== null) { + try { + await this.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({ @@ -84,6 +121,15 @@ export class ForecastController { }; await tenantConfigService.setConfig(organizationId, 'liquidity_settings', payload); + + if (this.redis) { + try { + await this.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({ From 64f5f850a04e2040e9c4cb884618ecbb8c3233d6 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 20:01:37 -0400 Subject: [PATCH 07/10] fix: invalidate employee list cache only on writes --- backend/src/services/employeeService.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/src/services/employeeService.ts b/backend/src/services/employeeService.ts index edde581d..85092f50 100644 --- a/backend/src/services/employeeService.ts +++ b/backend/src/services/employeeService.ts @@ -182,11 +182,7 @@ export class EmployeeService { WHERE id = $1 AND organization_id = $2 AND deleted_at IS NULL `; const result = await pool.query(query, [id, organization_id]); - const deleted = result.rows[0] || null; - if (deleted) { - await this.invalidateListCache(organization_id); - } - return deleted; + return result.rows[0] || null; } async update(id: number, organization_id: number, data: UpdateEmployeeInput) { @@ -227,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; } } From 7b529822be87de9743a3d1bb588afa7b1a47d13e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 03:33:52 -0400 Subject: [PATCH 08/10] fix(forecast): preserve Redis access in route callbacks Reference the ForecastController-owned Redis client explicitly in the two unbound settings handlers. This keeps GET from failing before configuration loading and PUT from reporting a receiver error after saving without invalidating the cache. Continues the existing PayD #696 / #533 submission and preserves its original implementation and publication credit. Static source and actual route registration inspected; no runtime, compiler, test, workflow or deployment execution was performed for this correction. --- backend/src/controllers/forecastController.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/backend/src/controllers/forecastController.ts b/backend/src/controllers/forecastController.ts index 01ac3327..4ffcc9e4 100644 --- a/backend/src/controllers/forecastController.ts +++ b/backend/src/controllers/forecastController.ts @@ -1,3 +1,4 @@ +// 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'; @@ -44,9 +45,9 @@ export class ForecastController { const cacheKey = `cache:organization-settings:${organizationId}:liquidity-settings`; let settings: any | null = null; - if (this.redis) { + if (ForecastController.redis) { try { - const cached = await this.redis.get(cacheKey); + const cached = await ForecastController.redis.get(cacheKey); if (cached !== null) { logger.info('Cache hit', { cache: 'organization-settings', organizationId }); settings = JSON.parse(cached); @@ -66,9 +67,9 @@ export class ForecastController { if (settings === null) { settings = await tenantConfigService.getConfig(organizationId, 'liquidity_settings'); - if (this.redis && settings !== null) { + if (ForecastController.redis && settings !== null) { try { - await this.redis.setex(cacheKey, 30 * 60, JSON.stringify(settings)); + await ForecastController.redis.setex(cacheKey, 30 * 60, JSON.stringify(settings)); } catch (error) { logger.warn('Organization settings cache write failed', { organizationId, error }); } @@ -122,9 +123,9 @@ export class ForecastController { await tenantConfigService.setConfig(organizationId, 'liquidity_settings', payload); - if (this.redis) { + if (ForecastController.redis) { try { - await this.redis.del(`cache:organization-settings:${organizationId}:liquidity-settings`); + await ForecastController.redis.del(`cache:organization-settings:${organizationId}:liquidity-settings`); } catch (error) { logger.warn('Organization settings cache invalidation failed', { organizationId, error }); } From 38b6e417edd5eb8fd25b781e91dd31b085f68dc8 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 14:52:03 -0400 Subject: [PATCH 09/10] perf(backend): version employee caches on writes Replace keyspace scans with per-organization generation tokens. Keep in-flight cache fills on their captured generation so completed writes cannot be undone by late list reads. Preserve the five-minute data TTL and database fallback when Redis reads fail. --- backend/src/services/employeeService.ts | 45 ++++++++++++++++--------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/backend/src/services/employeeService.ts b/backend/src/services/employeeService.ts index 85092f50..c49fe3ce 100644 --- a/backend/src/services/employeeService.ts +++ b/backend/src/services/employeeService.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { pool } from '../config/database.js'; import { RedisClient } from './rateLimitService.js'; import logger from '../utils/logger.js'; @@ -10,7 +11,26 @@ import { export class EmployeeService { private readonly redis = RedisClient.getInstance(); - private listCacheKey(organizationId: number, params: EmployeeQueryInput): string { + private listGenerationKey(organizationId: number): string { + return `cache:employees:${organizationId}:generation`; + } + + private async listCacheKey( + organizationId: number, + params: EmployeeQueryInput + ): Promise { + if (!this.redis) return null; + + const generationKey = this.listGenerationKey(organizationId); + let generation = await this.redis.get(generationKey); + if (generation === null) { + const candidate = randomUUID(); + const created = await this.redis.set(generationKey, candidate, 'NX'); + generation = created === 'OK' ? candidate : await this.redis.get(generationKey); + } + // A missing/evicted marker must never resurrect an older cached generation. + if (generation === null) return null; + const { page = 1, limit = 10, search, status, department } = params; const fingerprint = JSON.stringify([ page, @@ -19,23 +39,16 @@ export class EmployeeService { status ?? '', department ?? '', ]); - return `cache:employees:${organizationId}:${Buffer.from(fingerprint).toString('base64url')}`; + return `cache:employees:${organizationId}:${generation}:${Buffer.from(fingerprint).toString('base64url')}`; } async invalidateListCache(organizationId: number): Promise { 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'); + // One write invalidates every page/filter; in-flight fills retain the old + // generation and expire under the existing five-minute data TTL. + await this.redis.set(this.listGenerationKey(organizationId), randomUUID()); } catch (error) { logger.warn('Employee cache invalidation failed', { organizationId, error }); } @@ -86,17 +99,19 @@ export class EmployeeService { async findAll(organization_id: number, params: EmployeeQueryInput) { const { page = 1, limit = 10, search, status, department } = params; - const cacheKey = this.listCacheKey(organization_id, params); + let cacheKey: string | null = null; if (this.redis) { try { - const cached = await this.redis.get(cacheKey); + cacheKey = await this.listCacheKey(organization_id, params); + const cached = cacheKey === null ? null : 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) { + cacheKey = null; logger.warn('Employee cache read failed', { organizationId: organization_id, error }); } } else { @@ -165,7 +180,7 @@ export class EmployeeService { }, }; - if (this.redis) { + if (this.redis && cacheKey !== null) { try { await this.redis.setex(cacheKey, 5 * 60, JSON.stringify(response)); } catch (error) { From 0e0b0da481e9410b8cd7863fe7256ad2e547c2f8 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 14:54:37 -0400 Subject: [PATCH 10/10] fix(backend): invalidate liquidity settings at the service boundary Clear the existing organization-scoped cache after successful generic configuration writes and deletes, not only the forecast controller route. Preserve committed results on Redis failures and avoid duplicate eviction. Restore rate-limit override methods to the configuration service class. Add four focused invalidation regressions. --- backend/src/controllers/forecastController.ts | 10 +--- .../__tests__/tenantConfigCache.test.ts | 59 +++++++++++++++++++ backend/src/services/tenantConfigService.ts | 21 ++++++- 3 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 backend/src/services/__tests__/tenantConfigCache.test.ts diff --git a/backend/src/controllers/forecastController.ts b/backend/src/controllers/forecastController.ts index 4ffcc9e4..e53740ab 100644 --- a/backend/src/controllers/forecastController.ts +++ b/backend/src/controllers/forecastController.ts @@ -1,4 +1,4 @@ -// Modified 2026-10-05: use the class-owned Redis client in unbound route handlers. +// Modified 2026-10-05: keep unbound handlers safe; invalidate cache at the service write boundary. import { Request, Response } from 'express'; import { ForecastingService } from '../services/forecasting/forecastingService.js'; import tenantConfigService from '../services/tenantConfigService.js'; @@ -123,14 +123,6 @@ 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({ diff --git a/backend/src/services/__tests__/tenantConfigCache.test.ts b/backend/src/services/__tests__/tenantConfigCache.test.ts new file mode 100644 index 00000000..679d286e --- /dev/null +++ b/backend/src/services/__tests__/tenantConfigCache.test.ts @@ -0,0 +1,59 @@ +import { TenantConfigService } from '../tenantConfigService.js'; +import { RedisClient } from '../rateLimitService.js'; +import logger from '../../utils/logger.js'; + +jest.mock('../../config/database.js', () => ({ pool: {} })); +jest.mock('../rateLimitService.js', () => ({ RedisClient: { getInstance: jest.fn() } })); +jest.mock('../../utils/logger.js', () => ({ + __esModule: true, + default: { warn: jest.fn() }, +})); + +describe('tenant configuration cache invalidation', () => { + const query = jest.fn(); + const del = jest.fn(); + const service = new TenantConfigService({ query } as any); + const key = 'cache:organization-settings:7:liquidity-settings'; + + beforeEach(() => { + jest.resetAllMocks(); + (RedisClient.getInstance as jest.Mock).mockReturnValue({ del }); + del.mockResolvedValue(1); + }); + + it('invalidates the same org after a successful direct setConfig', async () => { + const saved = { id: 1, config_value: { assetCode: 'USD' } }; + query.mockResolvedValue({ rows: [saved] }); + await expect(service.setConfig(7, 'liquidity_settings', saved.config_value)).resolves.toBe(saved); + expect(del).toHaveBeenCalledTimes(1); + expect(del).toHaveBeenCalledWith(key); + expect(query.mock.invocationCallOrder[0]).toBeLessThan(del.mock.invocationCallOrder[0]!); + }); + + it('invalidates direct deletes, including stale cache entries when no row remains', async () => { + query.mockResolvedValueOnce({ rowCount: 1 }).mockResolvedValueOnce({ rowCount: 0 }); + await expect(service.deleteConfig(7, 'liquidity_settings')).resolves.toBe(true); + await expect(service.deleteConfig(7, 'liquidity_settings')).resolves.toBe(false); + expect(del.mock.calls).toEqual([[key], [key]]); + }); + + it('does not evict for other configurations or for failed database writes', async () => { + query.mockResolvedValue({ rows: [{}], rowCount: 1 }); + await service.setConfig(7, 'branding', {}); + await service.deleteConfig(7, 'branding'); + query.mockRejectedValue(new Error('database unavailable')); + await expect(service.setConfig(7, 'liquidity_settings', {})).rejects.toThrow('database unavailable'); + await expect(service.deleteConfig(7, 'liquidity_settings')).rejects.toThrow('database unavailable'); + expect(del).not.toHaveBeenCalled(); + }); + + it('preserves committed results when Redis is absent or invalidation fails', async () => { + const saved = { id: 1 }; + query.mockResolvedValue({ rows: [saved], rowCount: 1 }); + (RedisClient.getInstance as jest.Mock).mockReturnValueOnce(null); + await expect(service.setConfig(7, 'liquidity_settings', {})).resolves.toBe(saved); + del.mockRejectedValueOnce(new Error('cache unavailable')); + await expect(service.deleteConfig(7, 'liquidity_settings')).resolves.toBe(true); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/src/services/tenantConfigService.ts b/backend/src/services/tenantConfigService.ts index e77f0d5f..5e4ca458 100644 --- a/backend/src/services/tenantConfigService.ts +++ b/backend/src/services/tenantConfigService.ts @@ -1,5 +1,8 @@ +// Modified 2026-10-05: invalidate cached liquidity settings after service writes. import { Pool } from 'pg'; import { pool } from '../config/database.js'; +import { RedisClient } from './rateLimitService.js'; +import logger from '../utils/logger.js'; export interface TenantConfig { id: number; @@ -77,6 +80,21 @@ export class TenantConfigService { return configs; } + private async invalidateConfigCache(organizationId: number, configKey: string): Promise { + // This is the only tenant configuration currently cached by ForecastController. + if (configKey !== 'liquidity_settings') return; + + try { + const redis = RedisClient.getInstance(); + if (redis) { + await redis.del(`cache:organization-settings:${organizationId}:liquidity-settings`); + } + } catch (error) { + // The database write has already succeeded; cache failure must not undo its result. + logger.warn('Organization settings cache invalidation failed', { organizationId, error }); + } + } + /** * Set or update a configuration */ @@ -104,6 +122,7 @@ export class TenantConfigService { description, ]); + await this.invalidateConfigCache(organizationId, configKey); return result.rows[0]; } @@ -118,6 +137,7 @@ export class TenantConfigService { `; const result = await this.pool.query(query, [organizationId, configKey]); + await this.invalidateConfigCache(organizationId, configKey); return result.rowCount !== null && result.rowCount > 0; } @@ -196,7 +216,6 @@ export class TenantConfigService { const updated = { ...current, ...settings }; return this.setConfig(organizationId, 'branding', updated); } -} // ─── Rate limit overrides (Part 49) ─────────────────────────────────────────