Files
redefined-designs/backend/src/adminSettings.ts
T
bermudalambandClaude Opus 5 c8fb610c70 feat(admin): choose the drafting model from Settings (#223)
The model was going to be an environment variable, which meant a redeploy to change it. It is now an admin setting, so it can be changed from the Settings page like the cart expiry and the greeting.

A dropdown validated on the server, not a free-text field. The API only rejects an unknown model at the point of use, so a typo would be stored happily and then fail on every submission, surfacing as drafts quietly not appearing rather than as an error anybody could act on. The PUT refuses anything outside the offered set, and getSettings falls back rather than handing on a value that is no longer offered — drafting with the default beats drafting with a model the API will refuse.

One catalogue rather than two lists. The dropdown needs the models, costMicros needs their rates, and the price shown beside a model in Admin has to be the price it is actually billed at, which it cannot be if the two are maintained separately. Rates were confirmed against the pricing page rather than recalled: Sonnet 5 $2/$10, Opus 5 $5/$25, Haiku 4.5 $1/$5 per million tokens. The unknown-model fallback is deliberately the most expensive rate and never zero, because a budget that reads as unspent however much was spent is the one failure a spend guard cannot have.

Adding a third setting type pushed getSettings past the cognitive complexity limit, so the per-type resolution moved out into one small function each — the same shape the definitions block above it already argues for.

The exhaustive assertion in the GET test gained the new field rather than being loosened. It exists to catch a setting silently vanishing from the response, and that is worth more than not having to touch it here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:43:40 -05:00

144 lines
5.9 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
}
] 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 AdminSettings = Record<HoursSettingName, number> &
Record<TextSettingName, string> &
Record<ChoiceSettingName, string>;
export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'hours' }> => d.type === 'hours'
).map(d => d.name);
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;
}
// 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 === '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)]
);
}
}