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:
@@ -40,7 +40,24 @@ const DEFINITIONS = [
|
|||||||
// changed by whoever runs the shop, not by whoever deploys it, and a redeploy
|
// 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
|
// to change an address would be absurd. Empty means do not notify, which is
|
||||||
// the default and a working configuration.
|
// 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;
|
] as const;
|
||||||
|
|
||||||
type Definition = (typeof DEFINITIONS)[number];
|
type Definition = (typeof DEFINITIONS)[number];
|
||||||
@@ -50,10 +67,12 @@ export type SettingName = Definition['name'];
|
|||||||
export type HoursSettingName = Extract<Definition, { type: 'hours' }>['name'];
|
export type HoursSettingName = Extract<Definition, { type: 'hours' }>['name'];
|
||||||
export type TextSettingName = Extract<Definition, { type: 'text' }>['name'];
|
export type TextSettingName = Extract<Definition, { type: 'text' }>['name'];
|
||||||
export type ChoiceSettingName = Extract<Definition, { type: 'choice' }>['name'];
|
export type ChoiceSettingName = Extract<Definition, { type: 'choice' }>['name'];
|
||||||
|
export type CountSettingName = Extract<Definition, { type: 'count' }>['name'];
|
||||||
|
|
||||||
export type AdminSettings = Record<HoursSettingName, number> &
|
export type AdminSettings = Record<HoursSettingName, number> &
|
||||||
Record<TextSettingName, string> &
|
Record<TextSettingName, string> &
|
||||||
Record<ChoiceSettingName, string>;
|
Record<ChoiceSettingName, string> &
|
||||||
|
Record<CountSettingName, number>;
|
||||||
|
|
||||||
export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter(
|
export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter(
|
||||||
(d): d is Extract<Definition, { type: 'hours' }> => d.type === 'hours'
|
(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;
|
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
|
// 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
|
// chosen — falls back rather than being handed on. Drafting with the default
|
||||||
// beats drafting with a model the API will refuse.
|
// 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);
|
const raw = stored.get(definition.key);
|
||||||
if (definition.type === 'choice') {
|
if (definition.type === 'choice') {
|
||||||
settings[definition.name] = resolveChoice(definition.name, raw, definition.fallback);
|
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') {
|
} else if (definition.type === 'text') {
|
||||||
settings[definition.name] = resolveText(raw, definition.fallback);
|
settings[definition.name] = resolveText(raw, definition.fallback);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ describe('GET /api/admin/settings', () => {
|
|||||||
draftingModel: 'claude-sonnet-5',
|
draftingModel: 'claude-sonnet-5',
|
||||||
// Empty by default: nowhere to send the intake notification is a working
|
// Empty by default: nowhere to send the intake notification is a working
|
||||||
// configuration, and means simply do not send one (#224).
|
// configuration, and means simply do not send one (#224).
|
||||||
intakeNotifyEmail: ''
|
intakeNotifyEmail: '',
|
||||||
|
intakeDailyCeiling: 100,
|
||||||
|
intakeLinkAlertThreshold: 20,
|
||||||
|
intakeCeilingResetAt: ''
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,13 @@ export async function resetDb(): Promise<void> {
|
|||||||
// it silently changes the mail every later suite asserts on. That is not
|
// it silently changes the mail every later suite asserts on. That is not
|
||||||
// hypothetical: a subject of "Gone" written by the template tests reached the
|
// hypothetical: a subject of "Gone" written by the template tests reached the
|
||||||
// favorite-alert tests and made five of them fail somewhere else entirely.
|
// favorite-alert tests and made five of them fail somewhere else entirely.
|
||||||
await testPool.query(`DELETE FROM admin_settings WHERE key LIKE 'email\_%'`);
|
//
|
||||||
|
// Same reasoning, same failure, for the intake settings (#227): a ceiling of
|
||||||
|
// 1 left behind by the ceiling suite makes every later submission refuse with
|
||||||
|
// a 503, in files that never mention a ceiling.
|
||||||
|
await testPool.query(
|
||||||
|
`DELETE FROM admin_settings WHERE key LIKE 'email\_%' OR key LIKE 'intake\_%'`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeDb(): Promise<void> {
|
export async function closeDb(): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user