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>
194 lines
8.6 KiB
TypeScript
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)]
|
|
);
|
|
}
|
|
}
|