import { pool } from './db'; import { DEFAULT_DRAFTING_MODEL, DRAFTING_MODELS, isDraftingModel } from './intake/models'; /** * 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. */ /** A row of the key/value store this module reads. */ interface SettingRow { key: string; value: string; } 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,' }, // A 'choice' rather than a 'text', so a mistyped model name is refused at the // edge instead of stored. It would otherwise fail on every submission and // show up only as drafts quietly not appearing (#223). { key: 'drafting_model', name: 'draftingModel', type: 'choice', fallback: DEFAULT_DRAFTING_MODEL }, // Where the intake notification goes (#224). A setting rather than an // environment variable, for the same reason drafting_model is one: it is // changed by whoever runs the shop, not by whoever deploys it, and a redeploy // to change an address would be absurd. Empty means do not notify, which is // the default and a working configuration. { key: 'intake_notify_email', name: 'intakeNotifyEmail', type: 'text', fallback: '' }, // The whole intake surface over a rolling 24 hours, across every link (#227). // Per-link caps bound each link, but links accumulate — twenty links at the // default 25 is five hundred submissions nobody decided to accept. { key: 'intake_daily_ceiling', name: 'intakeDailyCeiling', type: 'count', fallback: 100 }, // Well below the ceiling, because this is the one that catches a leaked link // early — the case the revoke mechanism exists for, and which otherwise // depends on somebody happening to look. { key: 'intake_link_alert_threshold', name: 'intakeLinkAlertThreshold', type: 'count', fallback: 20 }, // An ISO timestamp, or empty. The count is derived from rows that exist, so a // reset cannot delete anything — it moves the window's start instead, which // makes it an auditable fact rather than a deletion. { key: 'intake_ceiling_reset_at', name: 'intakeCeilingResetAt', type: 'text', fallback: '' } ] as const; type Definition = (typeof DEFINITIONS)[number]; export type SettingName = Definition['name']; export type HoursSettingName = Extract['name']; export type TextSettingName = Extract['name']; export type ChoiceSettingName = Extract['name']; export type CountSettingName = Extract['name']; export type AdminSettings = Record & Record & 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 const CHOICE_SETTINGS: readonly ChoiceSettingName[] = DEFINITIONS.filter( (d): d is Extract => d.type === 'choice' ).map(d => d.name); /** * The values each choice setting will accept, for the route to validate against * and the admin UI to offer. Derived from the model catalogue rather than * restated, so the dropdown cannot come to disagree with what is billable. */ export const CHOICE_OPTIONS: Readonly> = { draftingModel: DRAFTING_MODELS.map(m => m.id) }; export function isValidChoice(name: ChoiceSettingName, value: string): boolean { return name === 'draftingModel' ? isDraftingModel(value) : false; } // One reader per type, at module level rather than branched inline. The same // reasoning as the definitions above: getSettings should read as "look each one // up and resolve it", and a third type was enough to push the inline version // past the complexity limit. // 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. function resolveText(raw: string | undefined, fallback: string): string { return raw !== undefined && raw.trim() !== '' ? raw : fallback; } // 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. function resolveHours(raw: string | undefined, fallback: number): number { const parsed = parseFloat(raw ?? ''); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } // Whole submissions, so a ceiling of 12.5 is a typo rather than a preference. // Falls back rather than yielding NaN for the same reason resolveHours does: a // NaN ceiling compares false against everything and would silently disable the // limit it was set to impose. function resolveCount(raw: string | undefined, fallback: number): number { const parsed = parseInt(raw ?? '', 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } // A stored value that is no longer offered — a model retired since it was // chosen — falls back rather than being handed on. Drafting with the default // beats drafting with a model the API will refuse. function resolveChoice( name: ChoiceSettingName, raw: string | undefined, fallback: string ): string { return raw !== undefined && isValidChoice(name, raw) ? raw : fallback; } 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 definition of DEFINITIONS) { const raw = stored.get(definition.key); if (definition.type === 'choice') { settings[definition.name] = resolveChoice(definition.name, raw, definition.fallback); } else if (definition.type === 'count') { settings[definition.name] = resolveCount(raw, definition.fallback); } else if (definition.type === 'text') { settings[definition.name] = resolveText(raw, definition.fallback); } else { settings[definition.name] = resolveHours(raw, definition.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)] ); } }