Files
redefined-designs/backend/src/adminSettings.ts
T
bermudalambandClaude Opus 5 be6800181d
Linting / lint (pull_request) Successful in 8m44s
SonarQube Analysis / sonarqube (pull_request) Successful in 40m1s
fix(admin): let a setting whose default is empty actually be cleared (#280)
intakeNotifyEmail and intakeCeilingResetAt both document empty as their default and as a working configuration — no notification address, and no ceiling reset recorded. The validator refused every empty text value, so either could be set and then never removed through the admin at all; the only way back was a DELETE against admin_settings. An admin who turned intake notifications on could not turn them off.

Whether empty is a mistake is a fact about the setting rather than about its type, so it is now declared on the setting, in the DEFINITIONS row that already carries its type and fallback. A new setting states it once, in the place someone adding one is already editing, and nothing else has to know. That is what makes this different from special-casing two names in the validator, which would have left the next such setting to rediscover the same bug.

The blanket refusal stays the default, because for a setting with a non-empty fallback an empty value really is a mistake: an empty greeting format renders every greeting as nothing at all, which reads as a broken email rather than as something a person cleared. Both those cases keep their tests.

Whitespace is normalised to empty rather than stored. Somebody clearing a field they cannot see the end of leaves spaces behind, and they meant cleared.

The tests check that the clearing survives the request rather than only being echoed back — the last one sets a value, clears it, and then reads it again through GET, which is the assertion that would have caught this had it existed.

Closes #280

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:51:32 -05:00

194 lines
8.6 KiB
TypeScript

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: '', mayBeEmpty: true },
// 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: '', mayBeEmpty: true }
] as const;
type Definition = (typeof DEFINITIONS)[number];
export type SettingName = Definition['name'];
export type HoursSettingName = Extract<Definition, { type: 'hours' }>['name'];
export type TextSettingName = Extract<Definition, { type: 'text' }>['name'];
export type ChoiceSettingName = Extract<Definition, { type: 'choice' }>['name'];
export type CountSettingName = Extract<Definition, { type: 'count' }>['name'];
export type AdminSettings = Record<HoursSettingName, number> &
Record<TextSettingName, string> &
Record<ChoiceSettingName, string> &
Record<CountSettingName, number>;
export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'hours' }> => d.type === 'hours'
).map(d => d.name);
/**
* The text settings for which empty is a value rather than a mistake.
*
* Declared on the setting, beside its type and fallback, rather than in the
* validator — whether a setting may be cleared is a fact about that setting,
* and a new one should state it once in the row it already has. The blanket
* refusal stays the default, because for a setting with a non-empty fallback
* an empty value really is a mistake: an empty greeting format renders every
* greeting as nothing, which reads as a broken email. See #280.
*/
export function mayBeEmpty(name: SettingName): boolean {
return DEFINITIONS.some((d) => d.name === name && 'mayBeEmpty' in d && d.mayBeEmpty);
}
export const TEXT_SETTINGS: readonly TextSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'text' }> => d.type === 'text'
).map(d => d.name);
export const CHOICE_SETTINGS: readonly ChoiceSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'choice' }> => 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<Record<ChoiceSettingName, readonly string[]>> = {
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<AdminSettings> {
const { rows } = await pool.query<SettingRow>(`SELECT key, value FROM admin_settings`);
const stored = new Map<string, string>(rows.map(r => [r.key, r.value]));
const settings = {} as Record<SettingName, number | string>;
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<Record<SettingName, number | string>>
): Promise<void> {
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)]
);
}
}