import { pool } from './db'; /** * Every admin-configurable setting, in one table. * * `cart_expiry_hours` used to be read by an inline query in two places, each * with its own `|| '24'`. With several settings and read sites scattered across * routes and the cron job that stops being tenable: a default written twice is * a default that will eventually disagree with itself. Adding a setting means * adding a row here and nothing else. * * Values are stored as text, so each row declares how to read it back. Numbers * were the only kind until the greeting format arrived; typing it per setting * rather than assuming means the next string one costs nothing. */ const DEFINITIONS = [ { key: 'cart_expiry_hours', name: 'cartExpiryHours', type: 'hours', fallback: 24 }, { key: 'verify_token_hours', name: 'verifyTokenHours', type: 'hours', fallback: 24 }, { key: 'password_reset_hours', name: 'passwordResetHours', type: 'hours', fallback: 1 }, { key: 'greeting_format', name: 'greetingFormat', type: 'text', fallback: 'Hi {{firstName}},' }, { key: 'greeting_fallback', name: 'greetingFallback', type: 'text', fallback: 'Hi,' } ] as const; type Definition = (typeof DEFINITIONS)[number]; export type SettingName = Definition['name']; export type HoursSettingName = Extract['name']; export type TextSettingName = Extract['name']; export type AdminSettings = Record & Record; export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter( (d): d is Extract => d.type === 'hours' ).map(d => d.name); export const TEXT_SETTINGS: readonly TextSettingName[] = DEFINITIONS.filter( (d): d is Extract => d.type === 'text' ).map(d => d.name); export async function getSettings(): Promise { const { rows } = await pool.query(`SELECT key, value FROM admin_settings`); const stored = new Map(rows.map(r => [r.key, r.value])); const settings = {} as Record; for (const { key, name, type, fallback } of DEFINITIONS) { const raw = stored.get(key); if (type === 'text') { // An empty format would render every greeting as nothing at all, which // reads as a bug in the email rather than a setting someone cleared. settings[name] = raw !== undefined && raw.trim() !== '' ? raw : fallback; continue; } const parsed = parseFloat(raw ?? ''); // A row that is present but unparseable falls back rather than yielding // NaN, which would otherwise reach Date arithmetic and mint a token with an // Invalid Date expiry that no query could ever match. settings[name] = Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } return settings as AdminSettings; } /** * Writes the supplied settings, ignoring names that were not sent. * * Partial rather than whole-object so a caller updating one field does not have * to know the current value of the others to avoid clobbering them. */ export async function updateSettings( values: Partial> ): Promise { for (const { key, name } of DEFINITIONS) { const value = values[name]; if (value === undefined) continue; await pool.query( `INSERT INTO admin_settings (key, value, updated_at) VALUES ($1, $2, now()) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now()`, [key, String(value)] ); } }