feat(intake): settings for the submission ceiling (#227)

A count type beside the existing hours, text and choice readers. resolveHours would have worked — it is parseFloat with a positive guard — but calling a submission ceiling an "hours" setting is a lie in the type name that every later reader has to decode. Whole numbers only, so a ceiling of 12.5 is a typo rather than a preference, and a malformed value falls back rather than yielding a NaN that compares false against everything and silently disables the limit.

resetDb is widened to clear intake_ settings as well as email_ ones. It deliberately does not truncate admin_settings, so a ceiling of 1 left behind by one suite would make every later suite's submissions refuse with a 503, in files that never mention a ceiling. The comment there already records that exact failure happening once with an email template subject, which reached the favorite-alert tests and failed five of them somewhere else entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 16:19:45 -05:00
co-authored by Claude Opus 5
parent 722ff91378
commit 22215f32eb
3 changed files with 43 additions and 4 deletions
+32 -2
View File
@@ -40,7 +40,24 @@ const DEFINITIONS = [
// 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: '' }
{ 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];
@@ -50,10 +67,12 @@ 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<ChoiceSettingName, string> &
Record<CountSettingName, number>;
export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter(
(d): d is Extract<Definition, { type: 'hours' }> => d.type === 'hours'
@@ -99,6 +118,15 @@ function resolveHours(raw: string | undefined, fallback: number): number {
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.
@@ -119,6 +147,8 @@ export async function getSettings(): Promise<AdminSettings> {
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 {