From 722ff91378668056a0e24578eecb6d46e889c659 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 1 Sep 2026 16:18:39 -0500 Subject: [PATCH] docs(intake): plan the global submission ceiling (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six tasks: the settings, stopping them leaking between test suites, the count, the alerts, the refusal, and the manual reset. The count is derived from item_drafts rather than a tally, as the issue asks. That forces a decision it left open: a reset cannot delete anything, because the rows are real submissions whose items are sitting in the review queue. So a reset stores a timestamp and the window becomes the later of that and 24 hours ago — one derived count, no second tally, and a reset that is an auditable fact rather than a deletion. The refusal is ordered ahead of uploadImages, for the same reason requireUsableLink is: a refused submission must write zero bytes. Ordering it after would accept the upload, store the files and throw them away, which is the expensive half of the work the ceiling exists to prevent. The alert throttle is in memory rather than in the database, so a restart during an incident can send one extra alert. That is a better trade than writing to admin_settings from the request path on every refused submission, and it is noted that a replicated deployment would have to move it. Self-review caught two things against the tree. POST /api/admin/items answers 200 rather than 201, so that assertion was wrong. And resetDb deliberately does not truncate admin_settings — it deletes only the email_ rows — so a ceiling of 1 left behind would make every later suite's submissions refuse with a 503, in files that never mention a ceiling. The comment in that file records the same failure happening once already with an email template. Task 1b widens the cleanup. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-01-intake-ceiling.md | 736 ++++++++++++++++++ 1 file changed, 736 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-01-intake-ceiling.md diff --git a/docs/superpowers/plans/2026-09-01-intake-ceiling.md b/docs/superpowers/plans/2026-09-01-intake-ceiling.md new file mode 100644 index 0000000..8898e88 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-intake-ceiling.md @@ -0,0 +1,736 @@ +# Intake Submission Ceiling Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Submissions across every link are bounded over a rolling 24 hours, refusing with a `503` past the ceiling, and the admin is told when that happens — and told earlier when one link starts behaving like a leaked one. + +**Architecture:** A count derived from `item_drafts.created_at` rather than a new tally, a middleware in front of the intake POST that refuses past the ceiling, and two alert emails sent directly through `sendMail`. Nothing else in the app is affected. + +**Tech Stack:** Express 4, TypeScript, `pg`, nodemailer via the existing mailer, Jest + supertest. + +**Spec:** issue #227 (follow-on to #220) + +**Already verified against the tree, so no need to re-check:** the intake POST chain is `router.post('/:token', intakeSubmitLimiter, requireUsableLink, uploadImages, handler)` in `src/routes/intake.ts:85`, and `requireUsableLink` is deliberately ordered ahead of `uploadImages` so a refusal writes zero bytes. `item_drafts` has `created_at TIMESTAMPTZ NOT NULL DEFAULT now()` and `upload_link_id` (FK, nullable). `upload_links` has `label` and `submission_count`. Admin settings are rows in `DEFINITIONS` (`src/adminSettings.ts:23`) with types `hours | text | choice`, each resolved by its own module-level reader; `resolveHours` is `parseFloat` with a `> 0` guard. `sendMail(to, subject, html)` is exported from `src/mailer.ts:88` and callers use it fire-and-forget. `getSettings()` returns all settings resolved. + +## Global Constraints + +- **The ceiling must never apply to the admin upload path.** Intake being throttled is an inconvenience; the shop being unable to add stock is an outage. The check lives in the intake router only, and a test asserts the admin path is unaffected. +- **Count from `item_drafts.created_at`, never a separate tally.** The rows already exist, and a second counter is a second thing that can disagree with reality. +- **The refusal is a `503` saying to try again later**, not a `403`. Nothing the sender did is wrong, and the condition clears by itself. +- **The alert must be rate limited against itself.** An alert per submission during an incident is how a mailbox fills and the signal is lost. +- **Alerts use `sendMail` directly**, not the editable-template system. An abuse alert is not copy anyone will want to reword, and making it editable means it can be broken. +- **Recovery is automatic** as the window rolls. The manual reset is a convenience, not the mechanism. +- **Every route handler wrapped in `asyncRoute`** — `tests/unit/routesAreWrapped.test.ts` enforces it. +- **Commit style:** Conventional Commits, subject ending `(#227)`, no hard wrapping in bodies. + +## Two decisions this plan makes that the issue leaves open + +**What "manual reset" means when the count is derived.** The count comes from rows that exist, so a reset cannot delete anything — the submissions are real and their items are in the queue. Instead the reset stores a timestamp, and the window becomes "since the later of 24 hours ago and the reset". That keeps a single derived count with no second tally, and makes a reset an auditable fact rather than a deletion. + +**Where the alert's own throttle lives.** In memory, module-level, not in the database. It is lost on restart, so a deploy during an incident could send one extra alert — which is an acceptable cost against writing to `admin_settings` from the request path on every refused submission. This is a single-container deployment; were it ever replicated, each replica would alert once per window and this would need moving. + +## File Structure + +**Created:** +- `backend/src/intake/capacity.ts` — the window, the counts, the decision. +- `backend/src/intake/abuseAlert.ts` — the two alerts and their throttle. +- `backend/tests/unit/capacity.test.ts` +- `backend/tests/integration/intakeCeiling.integration.test.ts` + +**Modified:** +- `backend/src/adminSettings.ts` — a `count` type and three settings +- `backend/src/routes/intake.ts` — the middleware, before `uploadImages` +- `backend/src/routes/adminUploadLinks.ts` — the manual reset + +--- + +### Task 1: The settings + +**Files:** +- Modify: `backend/src/adminSettings.ts` +- Test: `backend/tests/integration/adminSettings.integration.test.ts` + +**Interfaces:** +- Produces: `intakeDailyCeiling: number`, `intakeLinkAlertThreshold: number`, `intakeCeilingResetAt: string` + +- [ ] **Step 1: Add a `count` type** + +`resolveHours` is `parseFloat` with a `> 0` guard — correct behaviour for a ceiling, but calling a submission count "hours" would be a lie in the type name that the next reader has to decode. Add a reader beside it: + +```ts +// 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 silently disables 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; +} +``` + +Extend the type machinery — `CountSettingName`, the `AdminSettings` intersection, and the branch in `getSettings`: + +```ts +export type CountSettingName = Extract['name']; + +export type AdminSettings = Record & + Record & + Record & + Record; + +export const COUNT_SETTINGS: readonly CountSettingName[] = DEFINITIONS.filter( + (d): d is Extract => d.type === 'count' +).map((d) => d.name); +``` + +and in `getSettings`, before the `else`: + +```ts + } else if (definition.type === 'count') { + settings[definition.name] = resolveCount(raw, definition.fallback); +``` + +- [ ] **Step 2: Add the three settings** + +```ts + // The whole intake surface over a rolling 24 hours, across every link. + // 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 whole revoke mechanism exists for, and which currently + // 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: '' } +``` + +- [ ] **Step 3: Update the settings guard test** + +`adminSettings.integration.test.ts` asserts the full resolved object, so it fails until the three are listed. Add to the expected object: + +```ts + intakeDailyCeiling: 100, + intakeLinkAlertThreshold: 20, + intakeCeilingResetAt: '' +``` + +- [ ] **Step 4: Verify** + +```bash +cd backend && npm run build && npm run test:unit +npx jest -c jest.integration.config.js --runInBand adminSettings +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/adminSettings.ts backend/tests/integration/adminSettings.integration.test.ts +git commit -m "feat(intake): settings for the submission ceiling (#227)" +``` + +--- + +### Task 1b: Stop the new settings leaking between suites + +`resetDb` deliberately does **not** truncate `admin_settings` — it holds the seeded `cart_expiry_hours` other suites read. It deletes the `email_%` rows for exactly the reason this task exists: a stored value outliving the suite that wrote it silently changes what every later suite sees. The comment there records that happening once already, where a template subject of "Gone" reached the favorite-alert tests and failed five of them somewhere else entirely. + +A ceiling of 1 left behind by these tests would do the same thing to `intake.integration.test.ts`, whose submissions would start refusing with a 503 for no reason visible in that file. + +- [ ] **Step 1: Extend the cleanup** + +In `backend/tests/integration/setup/testDb.ts`, widen the existing delete: + +```ts + // Same reasoning as the email_ rows above, and the same failure. A ceiling of + // 1 left behind by the intake-ceiling suite makes every later submission + // refuse with a 503, in a file that never mentions a ceiling. + await testPool.query( + `DELETE FROM admin_settings WHERE key LIKE 'email\\_%' OR key LIKE 'intake\\_%'` + ); +``` + +- [ ] **Step 2: Verify nothing else depended on those rows surviving** + +```bash +cd backend && npx jest -c jest.integration.config.js --runInBand +``` + +Expected: PASS. `intake_notify_email` is cleared by this too, which is correct — the notification tests assert the no-recipient path. + +- [ ] **Step 3: Commit** + +```bash +git add backend/tests/integration/setup/testDb.ts +git commit -m "test(intake): clear intake settings between integration tests (#227)" +``` + +--- + +### Task 2: Counting and deciding + +**Files:** +- Create: `backend/src/intake/capacity.ts`, `backend/tests/unit/capacity.test.ts` + +**Interfaces:** +- Produces: `windowStart(now: Date, resetAt: string): Date`, `countSince(start: Date): Promise`, `countForLinkSince(linkId: number, start: Date): Promise`, `checkCapacity(): Promise` + +- [ ] **Step 1: Write the failing unit test** + +`windowStart` is the only part worth testing in isolation — the rest is a query. It is also the part most likely to be quietly wrong. + +```ts +import { windowStart, WINDOW_MS } from '../../src/intake/capacity'; + +const NOW = new Date('2026-09-01T12:00:00.000Z'); +const DAY_AGO = new Date(NOW.getTime() - WINDOW_MS); + +describe('windowStart', () => { + it('is 24 hours ago when there has been no reset', () => { + expect(windowStart(NOW, '').toISOString()).toBe(DAY_AGO.toISOString()); + }); + + // A reset inside the window is the point of it: it forgives what came before + // without deleting anything. + it('is the reset when the reset is more recent', () => { + const reset = '2026-09-01T09:00:00.000Z'; + expect(windowStart(NOW, reset).toISOString()).toBe(reset); + }); + + // An old reset must not widen the window beyond 24 hours, which would make + // the ceiling stricter over time rather than rolling. + it('ignores a reset older than the window', () => { + expect(windowStart(NOW, '2026-08-01T00:00:00.000Z').toISOString()).toBe(DAY_AGO.toISOString()); + }); + + // A malformed stored value must not produce an Invalid Date, which compares + // false against everything and would silently disable the ceiling. + it('falls back to 24 hours ago for an unparseable reset', () => { + expect(windowStart(NOW, 'not-a-date').toISOString()).toBe(DAY_AGO.toISOString()); + }); + + it('falls back for a reset in the future', () => { + expect(windowStart(NOW, '2027-01-01T00:00:00.000Z').toISOString()).toBe(DAY_AGO.toISOString()); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +cd backend && npx jest -c jest.unit.config.js capacity +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Write it** + +```ts +import { pool } from '../db'; +import { getSettings } from '../adminSettings'; + +/** A rolling day. */ +export const WINDOW_MS = 24 * 60 * 60 * 1000; + +/** + * Where the counting window starts. + * + * The later of "24 hours ago" and an explicit reset, so a reset forgives what + * came before it without deleting anything — the submissions are real and their + * items are sitting in the review queue either way. + * + * A reset older than the window, or unparseable, or in the future, falls back + * to the rolling day. The malformed case matters most: an Invalid Date compares + * false against everything, so a typo in this setting would silently disable + * the ceiling it was written to impose. + */ +export function windowStart(now: Date, resetAt: string): Date { + const rolling = new Date(now.getTime() - WINDOW_MS); + if (resetAt.trim() === '') return rolling; + + const reset = new Date(resetAt); + if (Number.isNaN(reset.getTime())) return rolling; + if (reset > now) return rolling; + + return reset > rolling ? reset : rolling; +} + +/** + * Counted from the draft rows themselves rather than a tally. + * + * Every submission creates exactly one item_drafts row in the same transaction + * that creates the item, so the rows are the truth. A separate counter would be + * a second thing that can disagree with it, and the one that disagrees silently + * is always the counter. + */ +export async function countSince(start: Date): Promise { + const { rows } = await pool.query<{ count: string }>( + `SELECT count(*)::text AS count FROM item_drafts WHERE created_at > $1`, + [start] + ); + return Number(rows[0]?.count ?? 0); +} + +export async function countForLinkSince(linkId: number, start: Date): Promise { + const { rows } = await pool.query<{ count: string }>( + `SELECT count(*)::text AS count FROM item_drafts WHERE upload_link_id = $1 AND created_at > $2`, + [linkId, start] + ); + return Number(rows[0]?.count ?? 0); +} + +export interface CapacityVerdict { + allowed: boolean; + used: number; + ceiling: number; + start: Date; +} + +/** Whether the intake surface as a whole has room for one more. */ +export async function checkCapacity(now: Date = new Date()): Promise { + const { intakeDailyCeiling, intakeCeilingResetAt } = await getSettings(); + const start = windowStart(now, intakeCeilingResetAt); + const used = await countSince(start); + + return { allowed: used < intakeDailyCeiling, used, ceiling: intakeDailyCeiling, start }; +} +``` + +- [ ] **Step 4: Run to verify it passes** + +```bash +cd backend && npx jest -c jest.unit.config.js capacity +``` + +Expected: PASS, 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/intake/capacity.ts backend/tests/unit/capacity.test.ts +git commit -m "feat(intake): count submissions across every link (#227)" +``` + +--- + +### Task 3: The alerts + +**Files:** +- Create: `backend/src/intake/abuseAlert.ts` + +**Interfaces:** +- Consumes: `countForLinkSince` (Task 2) +- Produces: `alertCeilingReached(used, ceiling)`, `alertLinkThreshold(linkId, label, used, threshold)`, `resetAlertThrottleForTests()` + +- [ ] **Step 1: Write it** + +```ts +import { sendMail } from '../mailer'; +import { getSettings } from '../adminSettings'; + +/** + * Abuse alerts, sent directly rather than through the editable templates. + * + * An abuse alert is not copy anyone will want to reword, and making it editable + * means it can be broken — a required placeholder removed from an alert nobody + * reads until an incident is a bad way to discover the validation. + */ + +/** At most one of each kind per hour. */ +const ALERT_INTERVAL_MS = 60 * 60 * 1000; + +/** + * Throttled in memory rather than in the database. + * + * A restart loses it, so a deploy during an incident can send one extra alert. + * That is a far better trade than writing to admin_settings from the request + * path on every refused submission. This is a single-container deployment; were + * it ever replicated each replica would alert once per window, and this would + * have to move. + */ +const lastSent = new Map(); + +function shouldSend(key: string, now: number): boolean { + const previous = lastSent.get(key); + if (previous !== undefined && now - previous < ALERT_INTERVAL_MS) return false; + lastSent.set(key, now); + return true; +} + +/** Exported for tests, which need each case to start from silence. */ +export function resetAlertThrottleForTests(): void { + lastSent.clear(); +} + +async function send(key: string, subject: string, html: string): Promise { + const { intakeNotifyEmail } = await getSettings(); + const to = intakeNotifyEmail?.trim(); + if (!to) return; + if (!shouldSend(key, Date.now())) return; + + // Awaited rather than fire-and-forget, but the caller does not await this — + // an alert failing must never turn into a failed request for the sender, who + // has done nothing wrong. + await sendMail(to, subject, html); +} + +export async function alertCeilingReached(used: number, ceiling: number): Promise { + await send( + 'ceiling', + 'Intake submissions are being refused', + `

The intake surface has taken ${used} submissions in the last 24 hours, which is at or ` + + `over the ceiling of ${ceiling}. Further submissions are being refused with a 503 until ` + + `the window rolls.

` + + `

The storefront, checkout and admin are unaffected. If this is legitimate, raise the ` + + `ceiling or reset the window from the Upload links screen. If it is not, revoke the link ` + + `that is being used.

` + ); +} + +export async function alertLinkThreshold( + linkId: number, + label: string, + used: number, + threshold: number +): Promise { + await send( + `link:${linkId}`, + `An upload link is being used heavily: ${label}`, + `

The link ${label} has taken ${used} submissions in the last 24 hours, ` + + `past the alert threshold of ${threshold}.

` + + `

This is the signal that a link has been shared further than intended. If that is what ` + + `has happened, revoke it from the Upload links screen — the submissions already received ` + + `are kept and are in the review queue.

` + ); +} +``` + +- [ ] **Step 2: Verify it compiles and lints** + +```bash +cd backend && npm run build && npm run lint +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/src/intake/abuseAlert.ts +git commit -m "feat(intake): alert the admin about abnormal submission volume (#227)" +``` + +--- + +### Task 4: Refusing, and telling someone + +**Files:** +- Modify: `backend/src/routes/intake.ts` +- Create: `backend/tests/integration/intakeCeiling.integration.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import request from 'supertest'; +import app from '../../src/app'; +import { pool } from '../../src/db'; +import { resetDb, closeDb } from './setup/testDb'; +import { resetAlertThrottleForTests } from '../../src/intake/abuseAlert'; + +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' +); + +beforeEach(async () => { + await resetDb(); + resetAlertThrottleForTests(); +}); + +afterAll(async () => { + await pool.end(); + await closeDb(); +}); + +async function makeLink(): Promise { + const res = await request(app).post('/api/admin/upload-links').send({ label: 'ceiling spec' }); + return res.body.token; +} + +/** Fills the window with drafts that did not come through a link. */ +async function fillWindow(count: number): Promise { + for (let i = 0; i < count; i++) { + const { rows } = await pool.query<{ id: number }>( + `INSERT INTO items (name, status) VALUES ('filler', 'pending') RETURNING id` + ); + await pool.query(`INSERT INTO item_drafts (item_id) VALUES ($1)`, [rows[0]!.id]); + } +} + +async function setCeiling(value: number): Promise { + await pool.query( + `INSERT INTO admin_settings (key, value, updated_at) VALUES ('intake_daily_ceiling', $1, now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [String(value)] + ); +} + +const submit = (token: string) => + request(app).post(`/api/intake/${token}`).attach('images', PNG, 'a.png'); + +describe('the submission ceiling', () => { + it('accepts a submission below the ceiling', async () => { + await setCeiling(5); + const token = await makeLink(); + + const res = await submit(token); + + expect(res.status).toBe(201); + }); + + it('refuses with 503 once the ceiling is reached', async () => { + await setCeiling(2); + await fillWindow(2); + const token = await makeLink(); + + const res = await submit(token); + + expect(res.status).toBe(503); + expect(res.body.error).toMatch(/later/i); + }); + + // Nothing the sender did is wrong and the condition clears by itself, so this + // must not read as a rejection of them or of their link. + it('leaves the link usable, so it works again when the window rolls', async () => { + await setCeiling(1); + await fillWindow(1); + const token = await makeLink(); + + await submit(token); + await setCeiling(50); + + expect((await submit(token)).status).toBe(201); + }); + + // A refused submission must write nothing to disk. The check is ordered ahead + // of uploadImages for the same reason requireUsableLink is. + it('stores no item when it refuses', async () => { + await setCeiling(1); + await fillWindow(1); + const token = await makeLink(); + + await submit(token); + + const { rows } = await pool.query(`SELECT count(*)::int AS c FROM item_drafts`); + expect(rows[0]?.c).toBe(1); + }); + + /** + * The constraint that matters most. Intake being throttled is an + * inconvenience; the shop being unable to add stock is an outage. + */ + it('never applies to the admin upload path', async () => { + await setCeiling(1); + await fillWindow(5); + + const res = await request(app) + .post('/api/admin/items') + .field('name', 'admin adds stock') + .field('price', '42.00') + .attach('images', PNG, 'a.png'); + + // 200, not 201 — this route answers with the created item rather than a + // bare created status, unlike the intake route beside it. + expect(res.status).toBe(200); + }); + + // A reset forgives what came before without deleting it. + it('accepts again after the window is reset', async () => { + await setCeiling(1); + await fillWindow(1); + const token = await makeLink(); + expect((await submit(token)).status).toBe(503); + + await request(app).post('/api/admin/upload-links/reset-ceiling'); + + expect((await submit(token)).status).toBe(201); + }); + + it('keeps the submissions it counted after a reset', async () => { + await fillWindow(3); + await request(app).post('/api/admin/upload-links/reset-ceiling'); + + const { rows } = await pool.query(`SELECT count(*)::int AS c FROM item_drafts`); + expect(rows[0]?.c).toBe(3); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +cd backend && npx jest -c jest.integration.config.js --runInBand intakeCeiling +``` + +Expected: FAIL — no ceiling, so nothing refuses, and no reset endpoint. + +- [ ] **Step 3: Add the middleware** + +In `src/routes/intake.ts`, after `requireUsableLink`: + +```ts +/** + * Refuses when the intake surface as a whole is over its ceiling. + * + * Ordered ahead of `uploadImages` for the same reason `requireUsableLink` is: a + * refused submission must write zero bytes to disk. Ordering it after would + * accept the upload, store the files, and then throw them away — which is the + * expensive half of the work this exists to prevent. + * + * 503 rather than 403. The sender has done nothing wrong, their link is fine, + * and the condition clears by itself as the window rolls. + */ +const requireCapacity = asyncRoute( + async (req: Request, res: Response, next: NextFunction) => { + const verdict = await checkCapacity(); + if (verdict.allowed) { + next(); + return; + } + + // Not awaited: an alert that fails must not turn into a failed request for + // somebody who has done nothing wrong, and the refusal is already decided. + void alertCeilingReached(verdict.used, verdict.ceiling).catch((err) => + console.error('[intake] ceiling alert failed:', err) + ); + + res.status(503).json({ + error: 'we are not able to accept submissions right now — please try again later' + }); + } +); +``` + +with the imports: + +```ts +import { checkCapacity, countForLinkSince, windowStart } from '../intake/capacity'; +import { alertCeilingReached, alertLinkThreshold } from '../intake/abuseAlert'; +import { getSettings } from '../adminSettings'; +``` + +and put it in the chain, between the link check and the upload: + +```ts +router.post( + '/:token', + intakeSubmitLimiter, + requireUsableLink, + requireCapacity, + uploadImages, +``` + +- [ ] **Step 4: Alert on a link crossing its threshold** + +At the end of the POST handler, after the transaction commits and before the response — beside the existing `draftQueued` call, which is the same shape: + +```ts + // The signal that a link has been shared further than intended, which is + // the case the revoke mechanism exists for and which otherwise depends on + // somebody happening to look. Not awaited, for the same reason as above. + void (async () => { + const { intakeLinkAlertThreshold, intakeCeilingResetAt } = await getSettings(); + const used = await countForLinkSince(link.id, windowStart(new Date(), intakeCeilingResetAt)); + if (used >= intakeLinkAlertThreshold) { + await alertLinkThreshold(link.id, link.label, used, intakeLinkAlertThreshold); + } + })().catch((err) => console.error('[intake] link threshold alert failed:', err)); +``` + +- [ ] **Step 5: Run** + +```bash +cd backend && npx jest -c jest.integration.config.js --runInBand intakeCeiling +``` + +Expected: every test but the two reset ones passes; those need Task 5. + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/routes/intake.ts backend/tests/integration/intakeCeiling.integration.test.ts +git commit -m "feat(intake): refuse submissions past the daily ceiling (#227)" +``` + +--- + +### Task 5: The manual reset + +**Files:** +- Modify: `backend/src/routes/adminUploadLinks.ts` + +- [ ] **Step 1: Add the endpoint** + +```ts +/** + * Forgives the current window without deleting anything. + * + * The count is derived from item_drafts rows, which are real submissions with + * real items in the review queue — so a reset moves the window's start rather + * than removing rows. Recovery is automatic as the window rolls; this exists + * for the case where the ceiling was hit legitimately and waiting is not + * acceptable. + */ +router.post('/reset-ceiling', asyncRoute(async (_req: Request, res: Response) => { + await pool.query( + `INSERT INTO admin_settings (key, value, updated_at) + VALUES ('intake_ceiling_reset_at', $1, now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, + [new Date().toISOString()] + ); + res.json({ reset: true }); +})); +``` + +Place it **above** the `/:id/revoke` route. Express matches in order, and `reset-ceiling` would otherwise be read as an `:id`. + +- [ ] **Step 2: Run everything** + +```bash +cd backend +npx jest -c jest.integration.config.js --runInBand intakeCeiling +npm run test:unit +npx jest -c jest.integration.config.js --runInBand +npm run lint && npm run build +``` + +Expected: all pass. + +- [ ] **Step 3: Commit** + +```bash +git add backend/src/routes/adminUploadLinks.ts +git commit -m "feat(intake): reset the ceiling window from the admin (#227)" +``` + +--- + +## Done when + +- Submissions across every link are counted over a rolling 24 hours, from `item_drafts` rather than a tally. +- Past the ceiling, intake refuses with a `503` saying to try again later, and stores nothing. +- The admin upload path is unaffected, with a test saying so. +- An alert is sent when the ceiling is reached, and when one link crosses a threshold well below it, each at most once an hour. +- The window can be reset from the admin, and resetting deletes nothing. +- Unit, integration, lint and build all pass. + +## Not in this plan + +A frontend control for the reset and the two thresholds. The endpoint and the settings exist, so this is reachable, but putting them on the Upload links screen is its own piece of work — and the numbers are ones you set once rather than adjust often.