diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52029992..9d71b0a9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -115,4 +115,3 @@ jobs: run: npm test --if-present # Workflow run retention settings -retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/contract-release.yml b/.github/workflows/contract-release.yml index 2655fcbc..3f66b1a5 100644 --- a/.github/workflows/contract-release.yml +++ b/.github/workflows/contract-release.yml @@ -29,4 +29,3 @@ jobs: release_token: ${{ secrets.GITHUB_TOKEN }} # Workflow run retention settings -retention-days: 90 \ No newline at end of file diff --git a/.github/workflows/dapp-ipfs.yml b/.github/workflows/dapp-ipfs.yml index 29d16e55..42248532 100644 --- a/.github/workflows/dapp-ipfs.yml +++ b/.github/workflows/dapp-ipfs.yml @@ -67,4 +67,3 @@ jobs: echo "- URL: ${{ steps.storacha.outputs.url }}" >> "$GITHUB_STEP_SUMMARY" # Workflow run retention settings -retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/secrets-check.yml b/.github/workflows/secrets-check.yml index b8d3b7e8..c7a34807 100644 --- a/.github/workflows/secrets-check.yml +++ b/.github/workflows/secrets-check.yml @@ -22,4 +22,3 @@ jobs: run: ./scripts/check-k8s-secrets.sh # Workflow run retention settings -retention-days: 30 \ No newline at end of file diff --git a/backend/src/controllers/forecastController.ts b/backend/src/controllers/forecastController.ts index 2504df0d..9e4a9f43 100644 --- a/backend/src/controllers/forecastController.ts +++ b/backend/src/controllers/forecastController.ts @@ -1,3 +1,4 @@ +// Modified 2026-10-05: keep unbound handlers safe; tenant-config caching lives in the service. import { Request, Response } from 'express'; import { ForecastingService } from '../services/forecasting/forecastingService.js'; import tenantConfigService from '../services/tenantConfigService.js'; @@ -38,6 +39,7 @@ export class ForecastController { } const settings = await tenantConfigService.getConfig(organizationId, 'liquidity_settings'); + res.status(200).json({ success: true, data: settings || null }); } catch (error: any) { res.status(500).json({ @@ -84,6 +86,7 @@ export class ForecastController { }; await tenantConfigService.setConfig(organizationId, 'liquidity_settings', payload); + res.status(200).json({ success: true, data: payload }); } catch (error: any) { res.status(500).json({ 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(), }, })); diff --git a/backend/src/services/__tests__/tenantConfigCache.test.ts b/backend/src/services/__tests__/tenantConfigCache.test.ts new file mode 100644 index 00000000..fcdbc1c3 --- /dev/null +++ b/backend/src/services/__tests__/tenantConfigCache.test.ts @@ -0,0 +1,111 @@ +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: { info: jest.fn(), warn: jest.fn() }, +})); + +describe('tenant configuration cache', () => { + const query = jest.fn(); + const get = jest.fn(); + const setex = jest.fn(); + const del = jest.fn(); + const redis = { get, setex, del }; + const service = new TenantConfigService({ query } as any); + + beforeEach(() => { + jest.resetAllMocks(); + (RedisClient.getInstance as jest.Mock).mockReturnValue(redis); + setex.mockResolvedValue('OK'); + del.mockResolvedValue(2); + }); + + it('returns a cached setting without querying PostgreSQL', async () => { + get.mockResolvedValue(JSON.stringify({ primary_color: '#123456' })); + + await expect(service.getConfig(7, 'branding')).resolves.toEqual({ + primary_color: '#123456', + }); + + expect(get).toHaveBeenCalledWith('cache:organization-settings:7:branding'); + expect(query).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith('Cache hit', { + cache: 'organization-settings', + organizationId: 7, + configKey: 'branding', + }); + }); + + it('caches a database miss for any configuration key for 30 minutes', async () => { + get.mockResolvedValue(null); + query.mockResolvedValue({ rows: [{ config_value: { email_notifications: true } }] }); + + await expect(service.getConfig(7, 'notification_settings')).resolves.toEqual({ + email_notifications: true, + }); + + expect(query).toHaveBeenCalledTimes(1); + expect(setex).toHaveBeenCalledWith( + 'cache:organization-settings:7:notification_settings', + 30 * 60, + JSON.stringify({ email_notifications: true }) + ); + }); + + it('caches and reuses the aggregate tenant settings view', async () => { + get.mockResolvedValueOnce(null).mockResolvedValueOnce( + JSON.stringify({ branding: { primary_color: '#fff' } }) + ); + query.mockResolvedValue({ + rows: [{ config_key: 'branding', config_value: { primary_color: '#fff' } }], + }); + + await expect(service.getAllConfigs(7)).resolves.toEqual({ + branding: { primary_color: '#fff' }, + }); + await expect(service.getAllConfigs(7)).resolves.toEqual({ + branding: { primary_color: '#fff' }, + }); + + expect(query).toHaveBeenCalledTimes(1); + expect(setex).toHaveBeenCalledWith( + 'cache:organization-settings:7:all', + 30 * 60, + JSON.stringify({ branding: { primary_color: '#fff' } }) + ); + }); + + it('invalidates the changed key and aggregate cache after successful writes', async () => { + const saved = { id: 1, config_value: { primary_color: '#000' } }; + query + .mockResolvedValueOnce({ rows: [saved] }) + .mockResolvedValueOnce({ rowCount: 1 }); + + await expect(service.setConfig(7, 'branding', saved.config_value)).resolves.toBe(saved); + await expect(service.deleteConfig(7, 'branding')).resolves.toBe(true); + + expect(del.mock.calls).toEqual([ + ['cache:organization-settings:7:branding', 'cache:organization-settings:7:all'], + ['cache:organization-settings:7:branding', 'cache:organization-settings:7:all'], + ]); + }); + + it('falls back to PostgreSQL when Redis fails and never rolls back a committed write', async () => { + const saved = { id: 1, config_value: { require_2fa: true } }; + get.mockRejectedValueOnce(new Error('cache read failed')); + query + .mockResolvedValueOnce({ rows: [{ config_value: saved.config_value }] }) + .mockResolvedValueOnce({ rows: [saved] }); + setex.mockRejectedValueOnce(new Error('cache write failed')); + del.mockRejectedValueOnce(new Error('cache invalidate failed')); + + await expect(service.getConfig(7, 'security_settings')).resolves.toEqual(saved.config_value); + await expect(service.setConfig(7, 'security_settings', saved.config_value)).resolves.toBe(saved); + + expect(logger.warn).toHaveBeenCalledTimes(3); + }); +}); 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); diff --git a/backend/src/services/employeeService.ts b/backend/src/services/employeeService.ts index ba2a7489..c49fe3ce 100644 --- a/backend/src/services/employeeService.ts +++ b/backend/src/services/employeeService.ts @@ -1,4 +1,7 @@ +import { randomUUID } from 'node:crypto'; import { pool } from '../config/database.js'; +import { RedisClient } from './rateLimitService.js'; +import logger from '../utils/logger.js'; import { CreateEmployeeInput, UpdateEmployeeInput, @@ -6,6 +9,51 @@ import { } from '../schemas/employeeSchema.js'; export class EmployeeService { + private readonly redis = RedisClient.getInstance(); + + 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, + limit, + search ?? '', + status ?? '', + department ?? '', + ]); + return `cache:employees:${organizationId}:${generation}:${Buffer.from(fingerprint).toString('base64url')}`; + } + + async invalidateListCache(organizationId: number): Promise { + if (!this.redis) return; + + try { + // 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 }); + } + } + async create(data: CreateEmployeeInput, dbClient?: any) { const executor = dbClient || pool; const { @@ -43,11 +91,36 @@ 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; + let cacheKey: string | null = null; + + if (this.redis) { + try { + 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 { + logger.info('Cache miss', { + cache: 'employee-list', + organizationId: organization_id, + reason: 'redis_not_configured', + }); + } const offset = (page - 1) * limit; let query = ` @@ -97,7 +170,7 @@ export class EmployeeService { return employee; }); - return { + const response = { data: employees, pagination: { total, @@ -106,6 +179,16 @@ export class EmployeeService { totalPages: Math.ceil(total / limit), }, }; + + if (this.redis && cacheKey !== null) { + 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) { @@ -140,7 +223,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) { @@ -151,7 +238,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; } } diff --git a/backend/src/services/forecasting/fxRateService.ts b/backend/src/services/forecasting/fxRateService.ts index cc3c54ee..0c28ec73 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,27 +8,63 @@ 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 + `SELECT to_char(rate_date::timestamp, 'YYYY-MM-DD') AS rate_date_text, rate FROM fx_rates WHERE base_currency = $1 AND quote_currency = $2 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) => ({ - rateDate: new Date(r.rate_date).toISOString().slice(0, 10), + const rates = result.rows.map((r: any) => ({ + rateDate: r.rate_date_text, 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 { 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 { diff --git a/backend/src/services/tenantConfigService.ts b/backend/src/services/tenantConfigService.ts index e77f0d5f..2731b63c 100644 --- a/backend/src/services/tenantConfigService.ts +++ b/backend/src/services/tenantConfigService.ts @@ -1,5 +1,8 @@ +// Modified 2026-10-05: cache tenant configuration reads at the service boundary. 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; @@ -42,10 +45,71 @@ export class TenantConfigService { this.pool = dbPool; } + private static readonly CONFIG_CACHE_TTL_SECONDS = 30 * 60; + + private configCacheKey(organizationId: number, configKey: string): string { + return `cache:organization-settings:${organizationId}:${configKey}`; + } + + private allConfigsCacheKey(organizationId: number): string { + return `cache:organization-settings:${organizationId}:all`; + } + + private async readCache( + key: string, + metadata: Record + ): Promise<{ hit: boolean; value: T | null }> { + const redis = RedisClient.getInstance(); + if (!redis) { + logger.info('Cache miss', { + cache: 'organization-settings', + ...metadata, + reason: 'redis_not_configured', + }); + return { hit: false, value: null }; + } + + try { + const cached = await redis.get(key); + if (cached !== null) { + logger.info('Cache hit', { cache: 'organization-settings', ...metadata }); + return { hit: true, value: JSON.parse(cached) as T }; + } + logger.info('Cache miss', { cache: 'organization-settings', ...metadata }); + } catch (error) { + logger.warn('Organization settings cache read failed', { ...metadata, error }); + } + + return { hit: false, value: null }; + } + + private async writeCache( + key: string, + value: unknown, + metadata: Record + ): Promise { + const redis = RedisClient.getInstance(); + if (!redis) return; + + try { + await redis.setex( + key, + TenantConfigService.CONFIG_CACHE_TTL_SECONDS, + JSON.stringify(value) + ); + } catch (error) { + logger.warn('Organization settings cache write failed', { ...metadata, error }); + } + } + /** * Get a specific configuration by key */ async getConfig(organizationId: number, configKey: string): Promise { + const cacheKey = this.configCacheKey(organizationId, configKey); + const cached = await this.readCache(cacheKey, { organizationId, configKey }); + if (cached.hit) return cached.value; + const query = ` SELECT config_value FROM tenant_configurations @@ -53,13 +117,24 @@ export class TenantConfigService { `; const result = await this.pool.query(query, [organizationId, configKey]); - return result.rows[0]?.config_value || null; + const value = result.rows[0]?.config_value ?? null; + if (value !== null) { + await this.writeCache(cacheKey, value, { organizationId, configKey }); + } + return value; } /** * Get all configurations for a tenant */ async getAllConfigs(organizationId: number): Promise> { + const cacheKey = this.allConfigsCacheKey(organizationId); + const cached = await this.readCache>(cacheKey, { + organizationId, + configKey: '*', + }); + if (cached.hit) return cached.value ?? {}; + const query = ` SELECT config_key, config_value FROM tenant_configurations @@ -74,9 +149,25 @@ export class TenantConfigService { configs[row.config_key] = row.config_value; }); + await this.writeCache(cacheKey, configs, { organizationId, configKey: '*' }); return configs; } + private async invalidateConfigCache(organizationId: number, configKey: string): Promise { + try { + const redis = RedisClient.getInstance(); + if (redis) { + await redis.del( + this.configCacheKey(organizationId, configKey), + this.allConfigsCacheKey(organizationId) + ); + } + } catch (error) { + // The database write has already succeeded; cache failure must not undo its result. + logger.warn('Organization settings cache invalidation failed', { organizationId, configKey, error }); + } + } + /** * Set or update a configuration */ @@ -104,6 +195,7 @@ export class TenantConfigService { description, ]); + await this.invalidateConfigCache(organizationId, configKey); return result.rows[0]; } @@ -118,6 +210,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 +289,6 @@ export class TenantConfigService { const updated = { ...current, ...settings }; return this.setConfig(organizationId, 'branding', updated); } -} // ─── Rate limit overrides (Part 49) ─────────────────────────────────────────