From 6d70915ad2b7b03a494238d59ac6103e1370d042 Mon Sep 17 00:00:00 2001 From: SteveMLC <121320326+SteveMLC@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:52:24 -0400 Subject: [PATCH 1/5] Enforce the organizer's submission caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Form Settings controls from the organizer's captures. Their "Submission capacity" panel limits how many proposals one submitter may hold on a form, counting saved drafts as well as sent ones — the control says so explicitly, because a draft still occupies a slot in the programme being planned. Their "Validation rules" panel caps the combined length of several fields, for a printed programme block where the page is the constraint rather than any single field. Both live as pure functions in shared/domain so the form and the API agree exactly, and both are enforced on the submission route: a form can be bypassed, and these caps belong to the organizer. Co-Authored-By: Claude Fable 5 --- migrations/0014_form_settings.sql | 23 ++++++ src/shared/contracts/api.ts | 2 + src/shared/contracts/entities.ts | 17 +++++ src/shared/domain/formLimits.test.ts | 90 ++++++++++++++++++++++++ src/shared/domain/formLimits.ts | 84 ++++++++++++++++++++++ src/worker/repo/airtable/airtableRepo.ts | 4 ++ src/worker/repo/d1/d1Repo.ts | 35 ++++++++- src/worker/repo/types.ts | 3 + src/worker/routes/api.ts | 31 ++++++++ usage/REPORT.md | 16 +++-- usage/ledger.jsonl | 1 + usage/receipts.jsonl | 1 + 12 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 migrations/0014_form_settings.sql create mode 100644 src/shared/domain/formLimits.test.ts create mode 100644 src/shared/domain/formLimits.ts diff --git a/migrations/0014_form_settings.sql b/migrations/0014_form_settings.sql new file mode 100644 index 0000000..1200c1a --- /dev/null +++ b/migrations/0014_form_settings.sql @@ -0,0 +1,23 @@ +-- The Form Settings controls still missing from the organizer's captures. +-- +-- Their "Submission capacity" panel caps how many sessions one submitter may +-- have for a form, counting saved drafts as well as sent proposals. Their +-- "Validation rules" panel caps the combined length of several text fields — +-- the example on screen is a printed program block, where the physical page +-- is the constraint, not any single field. Their Notifications step names the +-- admins emailed when a submission arrives or changes. + +ALTER TABLE forms ADD COLUMN submission_limit INTEGER; +ALTER TABLE forms ADD COLUMN notify_emails TEXT; + +-- One row per combined-length rule. `field_keys_json` is the ordered set of +-- form field keys whose lengths are summed; `max_chars` is the cap. +CREATE TABLE form_length_rules ( + id TEXT PRIMARY KEY, + form_id TEXT NOT NULL REFERENCES forms(id) ON DELETE CASCADE, + label TEXT NOT NULL, + field_keys_json TEXT NOT NULL, + max_chars INTEGER NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX idx_form_length_rules_form ON form_length_rules(form_id); diff --git a/src/shared/contracts/api.ts b/src/shared/contracts/api.ts index 8f4a3ef..6961b78 100644 --- a/src/shared/contracts/api.ts +++ b/src/shared/contracts/api.ts @@ -6,6 +6,7 @@ import { Event, Form, FormField, + FormLengthRule, Room, ResourcePage, Session, @@ -131,6 +132,7 @@ export const EventBundle = z.object({ form: Form, fields: z.array(FormField), rules: z.array(ConditionalRule), + lengthRules: z.array(FormLengthRule).default([]), }) .nullable(), }); diff --git a/src/shared/contracts/entities.ts b/src/shared/contracts/entities.ts index 9166b5d..24833b2 100644 --- a/src/shared/contracts/entities.ts +++ b/src/shared/contracts/entities.ts @@ -171,9 +171,26 @@ export const Form = z.object({ closesAt: isoDateTime.nullable(), maxSpeakersPerSubmission: z.number().int().min(1), allowDrafts: z.boolean(), + /** How many proposals one submitter may hold on this form, drafts + * included. Null means the form sets no limit of its own. */ + submissionLimit: z.number().int().min(1).nullable().optional(), + /** Admins emailed when a submission arrives on this form. */ + notifyEmails: z.array(z.email()).optional(), createdAt: isoDateTime, updatedAt: isoDateTime, }); + +/** A cap on the combined length of several fields — a printed programme + * block, where the page is the constraint rather than any one field. */ +export const FormLengthRule = z.object({ + id: z.string(), + formId: z.string(), + label: z.string(), + fieldKeys: z.array(z.string()).min(2), + maxChars: z.number().int().min(1), + sortOrder: z.number().int(), +}); +export type FormLengthRule = z.infer; export type Form = z.infer; export const FormField = z.object({ diff --git a/src/shared/domain/formLimits.test.ts b/src/shared/domain/formLimits.test.ts new file mode 100644 index 0000000..df63007 --- /dev/null +++ b/src/shared/domain/formLimits.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + canSubmitAgain, + combinedLengthMessage, + combinedLengthUsage, + exceededLengthRules, + submissionLimitMessage, +} from "./formLimits"; + +const programBlock = { + id: "rule_block", + label: "Printed programme block", + fieldKeys: ["title", "abstract"], + maxChars: 40, +}; + +describe("combinedLengthUsage", () => { + it("sums the named fields and reports the remaining budget", () => { + const usage = combinedLengthUsage(programBlock, { title: "12345", abstract: "1234567890" }); + expect(usage.used).toBe(15); + expect(usage.remaining).toBe(25); + expect(usage.exceeded).toBe(false); + expect(usage.overBy).toBe(0); + }); + + it("counts a missing or hidden field as empty rather than throwing", () => { + const usage = combinedLengthUsage(programBlock, { title: "abc" }); + expect(usage.used).toBe(3); + expect(usage.exceeded).toBe(false); + }); + + it("ignores values that are not text", () => { + const usage = combinedLengthUsage(programBlock, { title: "abc", abstract: 12345 }); + expect(usage.used).toBe(3); + }); + + it("reports how far over the cap the answers run", () => { + const usage = combinedLengthUsage(programBlock, { title: "a".repeat(30), abstract: "b".repeat(15) }); + expect(usage.used).toBe(45); + expect(usage.overBy).toBe(5); + expect(usage.remaining).toBe(0); + expect(usage.exceeded).toBe(true); + expect(combinedLengthMessage(usage)).toBe( + "Printed programme block is 5 characters over its 40-character limit.", + ); + }); + + it("says character in the singular when exactly one over", () => { + const usage = combinedLengthUsage(programBlock, { title: "a".repeat(41) }); + expect(combinedLengthMessage(usage)).toContain("1 character over"); + }); + + it("treats the cap itself as allowed", () => { + const usage = combinedLengthUsage(programBlock, { title: "a".repeat(40) }); + expect(usage.exceeded).toBe(false); + expect(usage.remaining).toBe(0); + }); +}); + +describe("exceededLengthRules", () => { + it("returns only the broken rules, in rule order", () => { + const short = { id: "r2", label: "Teaser", fieldKeys: ["title"], maxChars: 5 }; + const broken = exceededLengthRules([programBlock, short], { title: "a".repeat(10) }); + expect(broken.map((usage) => usage.rule.id)).toEqual(["r2"]); + }); + + it("is empty when nothing is over", () => { + expect(exceededLengthRules([programBlock], { title: "fine" })).toEqual([]); + }); +}); + +describe("submission capacity", () => { + it("allows any number when the form sets no limit", () => { + expect(canSubmitAgain({ limit: null, used: 99 })).toBe(true); + expect(submissionLimitMessage({ limit: null, used: 99 })).toBeNull(); + }); + + it("allows submissions below the limit and blocks at it", () => { + expect(canSubmitAgain({ limit: 3, used: 2 })).toBe(true); + expect(canSubmitAgain({ limit: 3, used: 3 })).toBe(false); + expect(canSubmitAgain({ limit: 3, used: 4 })).toBe(false); + }); + + it("explains the block in the submitter's terms", () => { + expect(submissionLimitMessage({ limit: 3, used: 3 })) + .toBe("This call accepts 3 proposals per person, and you already have 3."); + expect(submissionLimitMessage({ limit: 1, used: 1 })) + .toBe("This call accepts one proposal per person, and yours is already in."); + }); +}); diff --git a/src/shared/domain/formLimits.ts b/src/shared/domain/formLimits.ts new file mode 100644 index 0000000..517882b --- /dev/null +++ b/src/shared/domain/formLimits.ts @@ -0,0 +1,84 @@ +/** + * Submission capacity and combined-length rules. + * + * Both live here as pure functions because the browser and the API have to + * agree exactly: the form shows a live counter and blocks the button, and the + * API refuses the same submission even when the button is bypassed. + */ + +export interface CombinedLengthRule { + id: string; + label: string; + /** Form field keys whose lengths are summed. */ + fieldKeys: string[]; + maxChars: number; +} + +export interface CombinedLengthUsage { + rule: CombinedLengthRule; + used: number; + remaining: number; + overBy: number; + exceeded: boolean; +} + +/** + * How much of a combined-length budget a set of answers uses. Missing fields + * count as empty rather than throwing — a rule may name a field that a + * conditional has hidden, and a hidden field contributes nothing. + */ +export function combinedLengthUsage( + rule: CombinedLengthRule, + values: Record, +): CombinedLengthUsage { + const used = rule.fieldKeys.reduce((total, key) => { + const value = values[key]; + return total + (typeof value === "string" ? value.length : 0); + }, 0); + const overBy = Math.max(0, used - rule.maxChars); + return { + rule, + used, + remaining: Math.max(0, rule.maxChars - used), + overBy, + exceeded: overBy > 0, + }; +} + +/** Every rule this answer set breaks, in rule order. */ +export function exceededLengthRules( + rules: readonly CombinedLengthRule[], + values: Record, +): CombinedLengthUsage[] { + return rules.map((rule) => combinedLengthUsage(rule, values)).filter((usage) => usage.exceeded); +} + +/** Reader-facing sentence for a broken rule, e.g. for an inline error. */ +export function combinedLengthMessage(usage: CombinedLengthUsage): string { + return `${usage.rule.label} is ${usage.overBy} character${usage.overBy === 1 ? "" : "s"} over its ${usage.rule.maxChars}-character limit.`; +} + +export interface SubmissionCapacity { + /** Null means the form sets no limit of its own. */ + limit: number | null; + /** Sent proposals plus saved drafts already held by this submitter. */ + used: number; +} + +/** + * Whether a submitter may start another proposal on this form. The count + * deliberately includes drafts: the organizer's own control says "includes + * saved drafts and submitted sessions", because a draft still occupies a slot + * in the programme they are planning. + */ +export function canSubmitAgain({ limit, used }: SubmissionCapacity): boolean { + if (limit === null) return true; + return used < limit; +} + +export function submissionLimitMessage({ limit, used }: SubmissionCapacity): string | null { + if (limit === null || used < limit) return null; + return limit === 1 + ? "This call accepts one proposal per person, and yours is already in." + : `This call accepts ${limit} proposals per person, and you already have ${used}.`; +} diff --git a/src/worker/repo/airtable/airtableRepo.ts b/src/worker/repo/airtable/airtableRepo.ts index d1bf274..9fc59a0 100644 --- a/src/worker/repo/airtable/airtableRepo.ts +++ b/src/worker/repo/airtable/airtableRepo.ts @@ -312,6 +312,10 @@ export class AirtableRepo implements LecternRepo { throw new AirtableNotWiredError("listSubmissions"); } + async countSubmitterProposals(): Promise { + throw new AirtableNotWiredError("countSubmitterProposals"); + } + async getSubmissionById(_id: string): Promise { throw new AirtableNotWiredError("getSubmissionById"); } diff --git a/src/worker/repo/d1/d1Repo.ts b/src/worker/repo/d1/d1Repo.ts index a814e88..e317994 100644 --- a/src/worker/repo/d1/d1Repo.ts +++ b/src/worker/repo/d1/d1Repo.ts @@ -130,6 +130,8 @@ interface FormRow { closes_at: string | null; max_speakers_per_submission: number; allow_drafts: number; + submission_limit: number | null; + notify_emails: string | null; created_at: string; updated_at: string; } @@ -414,6 +416,8 @@ function mapForm(r: FormRow): Form { closesAt: r.closes_at, maxSpeakersPerSubmission: r.max_speakers_per_submission, allowDrafts: r.allow_drafts === 1, + submissionLimit: r.submission_limit ?? null, + notifyEmails: parseJson(r.notify_emails, []), createdAt: r.created_at, updatedAt: r.updated_at, }; @@ -878,16 +882,30 @@ export class D1Repo implements LecternRepo { let cfp: EventBundle["cfp"] = null; if (formRow) { - const [fieldsRes, rulesRes] = await this.db.batch([ + const [fieldsRes, rulesRes, lengthRes] = await this.db.batch([ this.db .prepare("SELECT * FROM form_fields WHERE form_id = ? ORDER BY sort_order") .bind(formRow.id), this.db.prepare("SELECT * FROM conditional_rules WHERE form_id = ?").bind(formRow.id), + this.db + .prepare("SELECT * FROM form_length_rules WHERE form_id = ? ORDER BY sort_order") + .bind(formRow.id), ]); cfp = { form: mapForm(formRow), fields: ((fieldsRes?.results ?? []) as unknown as FormFieldRow[]).map(mapFormField), rules: ((rulesRes?.results ?? []) as unknown as ConditionalRuleRow[]).map(mapRule), + lengthRules: ((lengthRes?.results ?? []) as unknown as { + id: string; form_id: string; label: string; + field_keys_json: string; max_chars: number; sort_order: number; + }[]).map((row) => ({ + id: row.id, + formId: row.form_id, + label: row.label, + fieldKeys: parseJson(row.field_keys_json, []), + maxChars: row.max_chars, + sortOrder: row.sort_order, + })), }; } @@ -1418,6 +1436,21 @@ export class D1Repo implements LecternRepo { ); } + async countSubmitterProposals(eventId: string, email: string): Promise { + const row = await this.db.prepare( + `SELECT + (SELECT COUNT(*) FROM submissions s + JOIN submission_speakers ss ON ss.submission_id = s.id + JOIN speakers sp ON sp.id = ss.speaker_id + WHERE s.event_id = ?1 AND lower(sp.email) = ?2 + AND s.status NOT IN ('withdrawn')) AS sent, + (SELECT COUNT(*) FROM cfp_drafts d + WHERE d.event_id = ?1 + AND lower(json_extract(d.payload_json, '$.speaker.email')) = ?2) AS drafts`, + ).bind(eventId, email.toLowerCase()).first<{ sent: number; drafts: number }>(); + return (row?.sent ?? 0) + (row?.drafts ?? 0); + } + async getSubmissionById(id: string): Promise { const row = await this.db .prepare( diff --git a/src/worker/repo/types.ts b/src/worker/repo/types.ts index c473ab1..8858809 100644 --- a/src/worker/repo/types.ts +++ b/src/worker/repo/types.ts @@ -79,6 +79,9 @@ export interface LecternRepo { saveCfpDraft(input: SaveCfpDraftInput): Promise<{ token: string; savedAt: string; draft: CfpDraftRequest }>; getCfpDraft(eventId: string, token: string): Promise<{ token: string; savedAt: string; draft: CfpDraftRequest } | null>; listSubmissions(eventId: string): Promise; + /** Proposals plus saved drafts already held by this email on an event — + * the organizer's capacity control counts both. */ + countSubmitterProposals(eventId: string, email: string): Promise; getSubmissionById(id: string): Promise; decideSubmission(input: DecideSubmissionInput): Promise; getOrganizerAgenda(eventId: string): Promise; diff --git a/src/worker/routes/api.ts b/src/worker/routes/api.ts index ee6503a..330a85e 100644 --- a/src/worker/routes/api.ts +++ b/src/worker/routes/api.ts @@ -84,6 +84,7 @@ import { draftReviewScores } from "../integrations/reviewScoring"; import { canEditSpeakerProposal, isCfpOpen, speakerProposalLockReason } from "../../shared/domain/cfp"; import { reviewResultsToCsv, submissionsToCsv } from "../../shared/domain/csv"; import { buildCalendarCollection, buildCalendarInvite } from "../../shared/domain/ics"; +import { canSubmitAgain, combinedLengthMessage, exceededLengthRules, submissionLimitMessage } from "../../shared/domain/formLimits"; import { missingRequiredFields, pruneAnswers } from "../../shared/domain/rules"; import { parseSpeakerCsv } from "../../shared/domain/speakerCsv"; import { buildStoreZip } from "../../shared/domain/zip"; @@ -1271,6 +1272,36 @@ api.post("/events/:slug/submissions", async (c) => { return errorResponse(422, "validation_error", "Unknown track for this event."); } + // Submission capacity and combined-length rules are enforced here as well as + // in the form, because the form can be bypassed and these caps are the + // organizer's, not a suggestion. + const submissionLimit = bundle.cfp.form.submissionLimit ?? null; + if (submissionLimit !== null) { + const used = await repo.countSubmitterProposals(bundle.event.id, data.speaker.email.trim().toLowerCase()); + if (!canSubmitAgain({ limit: submissionLimit, used })) { + return errorResponse( + 409, + "submission_limit_reached", + submissionLimitMessage({ limit: submissionLimit, used }) ?? "You have reached this call's proposal limit.", + ); + } + } + + const overLength = exceededLengthRules( + (bundle.cfp.lengthRules ?? []).map((rule) => ({ + id: rule.id, label: rule.label, fieldKeys: rule.fieldKeys, maxChars: rule.maxChars, + })), + { title: data.title, abstract: data.abstract, ...(data.answers ?? {}) }, + ); + if (overLength.length > 0) { + return errorResponse( + 422, + "validation_error", + overLength.map(combinedLengthMessage).join(" "), + overLength.map((usage) => ({ path: ["lengthRule", usage.rule.id], message: combinedLengthMessage(usage) })), + ); + } + const ctx = { format: data.format, answers: data.answers ?? {} }; const missing = missingRequiredFields(bundle.cfp.fields, bundle.cfp.rules, ctx); if (missing.length > 0) { diff --git a/usage/REPORT.md b/usage/REPORT.md index 88b9113..9ca82b5 100644 --- a/usage/REPORT.md +++ b/usage/REPORT.md @@ -1,15 +1,15 @@ # AI usage reimbursement audit -Generated 2026-08-14 16:22 UTC by `pnpm usage:report`. Do not edit by hand — regenerate instead. +Generated 2026-08-14 16:52 UTC by `pnpm usage:report`. Do not edit by hand — regenerate instead. -Ledger digest: `afa9a81cbc3fa60f340915da41c01999223199c375caaef3a486224da98dfeef` (212 entries). `pnpm usage:check` fails if this file no longer matches the ledger. -Receipt-allocation digest: `3f53e3abd6cf30b72354e88a7ad2f694e99c72773382c810d9c451c78641e1f0` (65 records). Raw receipts remain private. +Ledger digest: `41ba29e3ea17a775c99d0ca9e6e92eb3b870e8ffb40603fc9b402463560de31b` (213 entries). `pnpm usage:check` fails if this file no longer matches the ledger. +Receipt-allocation digest: `357c04391a6fc304703f70045c90e145fa4d5a05e7b9855ad47c85e6d4fcaaa1` (66 records). Raw receipts remain private. ## The three numbers, kept separate 1. **Provider-reported tokens** — counters copied from local provider session logs. -2. **API-equivalent estimate — $1811.64** — those tokens at pinned public list prices ([pricing.json](pricing.json)). A workload gauge, not a bill. -3. **Actual billed spend — $509.59 evidenced so far** — the number a reimbursement claim uses, backed by 4 primary billing records plus 61 zero-dollar coverage extensions. 1 usage entry remain uncovered by recorded evidence. +2. **API-equivalent estimate — $1836.83** — those tokens at pinned public list prices ([pricing.json](pricing.json)). A workload gauge, not a bill. +3. **Actual billed spend — $509.59 evidenced so far** — the number a reimbursement claim uses, backed by 4 primary billing records plus 62 zero-dollar coverage extensions. 1 usage entry remain uncovered by recorded evidence. The [brief](https://docs.google.com/document/d/1rBHJtiNKHv4i43tdf2Rm0sDEYuIcajhmAPoBKR_Az-A/) allows a valid submission up to **$500** in token-cost reimbursement, including qualifying Codex Pro / Claude Max subscription usage, subject to proof and organizer review. The claim will be the receipt amounts, capped at $500 — never the API-equivalent gauge. @@ -18,13 +18,13 @@ The [brief](https://docs.google.com/document/d/1rBHJtiNKHv4i43tdf2Rm0sDEYuIcajhm | Provider / model | Entries | Calls | Input | Cache reads | Cache writes | Output | API-equivalent USD | | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | | anthropic/claude-fable-5 | 71 | 1376 | 3,798 | 673,730,364 | 19,902,346 | 1,163,154 | $1129.97 | -| anthropic/claude-opus-5 | 22 | 618 | 845,470 | 361,996,856 | 11,500,834 | 577,578 | $314.67 | +| anthropic/claude-opus-5 | 23 | 650 | 845,534 | 392,099,218 | 12,461,010 | 598,935 | $339.86 | | openai/gpt-5.6-sol | 98 | — | 10,465,764 | 475,705,344 | 0 | 1,096,916 | $323.09 | | openai/gpt-5.5 | 1 | 6 | 5,393 | 516,096 | 0 | 137 | $0.29 | | anthropic/claude-sonnet-5 | 16 | 13 | 26,863 | 102,153,695 | 3,480,757 | 429,907 | $33.49 | | anthropic/claude-opus-4-8 | 1 | 17 | 34 | 8,979,232 | 535,436 | 7,291 | $10.03 | | anthropic/claude-haiku-4-5-20251001 | 3 | 2 | 21,600 | 276,442 | 23,992 | 4,843 | $0.10 | -| **Total** | **212** | | | | | | **$1811.64** | +| **Total** | **213** | | | | | | **$1836.83** | ## Evidence inventory @@ -244,6 +244,7 @@ One row per immutable ledger entry. The digest is the SHA-256 of the raw provide | 2026-08-14 | Codex engineering task | gpt-5.6-sol | engineering qa release | 2,575,487 | $1.42 | `34ba377fddb6…` (16536 lines) | `seed/seed` | | 2026-08-14 | Fable / Opus / Walt build session | claude-opus-5 | planning design engineering | 18,748,904 | $18.28 | `eaf0cc4de947…` (9347 lines) | `seed/seed` `usage/REP` `usage/led` | | 2026-08-14 | Fable / Opus / Walt build session | claude-opus-5 | planning design engineering | 946,440 | $0.49 | `75292638db2c…` (9349 lines) | `scripts/u` `seed/seed` `src/share` `usage/REP` `usage/led` `usage/rec` | +| 2026-08-14 | Fable / Opus / Walt build session | claude-opus-5 | planning design engineering | 31,083,959 | $25.19 | `7fd2ea5daeaa…` (9474 lines) | `migration` `src/share` `src/share` `src/share` `src/share` `src/worke` `src/worke` `src/worke` `src/worke` | ## Receipt allocations @@ -316,6 +317,7 @@ Billing evidence stays in `usage/private/`. The tracked allocation ledger stores | 2026-08-09–2026-09-09 | anthropic | Claude Max subscription — Aug 9–Sep 9, 2026 — coverage extension | coverage extension | $0.00 | 1 | `a429dcde5cd9…` (34,130 bytes) | | 2026-07-21–2026-08-21 | openai | ChatGPT Pro subscription — Jul 21–Aug 21, 2026 — coverage extension | coverage extension | $0.00 | 1 | `920a87c11f8c…` (38,220 bytes) | | 2026-08-09–2026-09-09 | anthropic | Claude Max subscription — Aug 9–Sep 9, 2026 — coverage extension | coverage extension | $0.00 | 1 | `a429dcde5cd9…` (34,130 bytes) | +| 2026-08-09–2026-09-09 | anthropic | Claude Max subscription — Aug 9–Sep 9, 2026 — coverage extension | coverage extension | $0.00 | 1 | `a429dcde5cd9…` (34,130 bytes) | ## How to audit this diff --git a/usage/ledger.jsonl b/usage/ledger.jsonl index d353965..033766a 100644 --- a/usage/ledger.jsonl +++ b/usage/ledger.jsonl @@ -210,3 +210,4 @@ {"schemaVersion":1,"id":"usage-20260814-gpt-5.6-sol-3f4745fdcf8a","recordedAt":"2026-08-14T16:20:54.077Z","period":{"start":"2026-08-14T15:16:12.457Z","end":"2026-08-14T15:19:51.102Z"},"actor":{"name":"Codex engineering task","surface":"Codex Desktop"},"provider":"openai","model":"gpt-5.6-sol","category":"engineering_qa_release","description":"Continuous AI-assisted SpeakerOps implementation, review, QA, release, and reimbursement work.","measurement":"provider_reported","calls":null,"tokens":{"uncachedInput":12530,"cacheRead":2560256,"cacheWrite":0,"cacheWrite5m":0,"cacheWrite1h":0,"output":2701,"reasoningOutput":873,"providerTotal":2575487},"cost":{"kind":"api_list_price_estimate","rateId":"openai-gpt-5.6-sol-2026-08-10","estimatedUsd":1.423808,"actualBilledUsd":null,"receiptStatus":"pending_subscription_receipt"},"source":{"kind":"codex_jsonl","sessionId":"019fe83d-d470-7330-ae79-9e6544f23247","sha256":"34ba377fddb65bfce0fab351dbd1bb1b79db54d081c9c2e4fe3daa7f2ec8023e","lineCount":16536,"rawEvidence":"retained_privately","cumulative":{"calls":null,"uncachedInput":10465764,"cacheRead":475705344,"cacheWrite":0,"cacheWrite5m":0,"cacheWrite1h":0,"output":1096916,"reasoningOutput":308006,"providerTotal":487268024}},"commits":[],"artifacts":["seed/seed.sql"],"notes":["Generated by scripts/usage-ledger.mjs; raw evidence remains private."]} {"schemaVersion":1,"id":"usage-20260814-claude-opus-5-9a60c82d8bfd","recordedAt":"2026-08-14T16:22:34.764Z","period":{"start":"2026-08-14T14:11:06.167Z","end":"2026-08-14T16:22:33.973Z"},"actor":{"name":"Fable / Opus / Walt build session","surface":"Claude Desktop"},"provider":"anthropic","model":"claude-opus-5","category":"planning_design_engineering","description":"Continuous AI-assisted SpeakerOps planning, design, implementation, deployment, and handoff work.","measurement":"provider_reported","calls":20,"tokens":{"uncachedInput":40,"cacheRead":17826180,"cacheWrite":0,"cacheWrite5m":0,"cacheWrite1h":913540,"output":9144,"reasoningOutput":0,"providerTotal":18748904},"cost":{"kind":"api_list_price_estimate","rateId":"anthropic-claude-opus-5-2026-08-10","estimatedUsd":18.27729,"actualBilledUsd":null,"receiptStatus":"pending_subscription_receipt"},"source":{"kind":"claude_jsonl","sessionId":"9b81bd7e-4c4d-43ac-a4d0-1e292b854b95","sha256":"eaf0cc4de9476e7495220abfdca0aac01fc272a084649fa7410dd2dcba1b5eac","lineCount":9347,"rawEvidence":"retained_privately","cumulative":{"calls":617,"uncachedInput":1553,"cacheRead":361051126,"cacheWrite":0,"cacheWrite5m":0,"cacheWrite1h":11500583,"output":484107,"reasoningOutput":0,"providerTotal":373037369}},"commits":[],"artifacts":["seed/seed.sql","usage/REPORT.md","usage/ledger.jsonl"],"notes":["Generated by scripts/usage-ledger.mjs; raw evidence remains private."]} {"schemaVersion":1,"id":"usage-20260814-claude-opus-5-a0cf445c3829","recordedAt":"2026-08-14T16:22:47.217Z","period":{"start":"2026-08-14T16:22:33.973Z","end":"2026-08-14T16:22:46.389Z"},"actor":{"name":"Fable / Opus / Walt build session","surface":"Claude Desktop"},"provider":"anthropic","model":"claude-opus-5","category":"planning_design_engineering","description":"Continuous AI-assisted SpeakerOps planning, design, implementation, deployment, and handoff work.","measurement":"provider_reported","calls":1,"tokens":{"uncachedInput":2,"cacheRead":945730,"cacheWrite":0,"cacheWrite5m":0,"cacheWrite1h":251,"output":457,"reasoningOutput":0,"providerTotal":946440},"cost":{"kind":"api_list_price_estimate","rateId":"anthropic-claude-opus-5-2026-08-10","estimatedUsd":0.48681,"actualBilledUsd":null,"receiptStatus":"pending_subscription_receipt"},"source":{"kind":"claude_jsonl","sessionId":"9b81bd7e-4c4d-43ac-a4d0-1e292b854b95","sha256":"75292638db2cca23646bd34487f181b20fecbb5b4d5d04c58bd7d0db8498533d","lineCount":9349,"rawEvidence":"retained_privately","cumulative":{"calls":618,"uncachedInput":1555,"cacheRead":361996856,"cacheWrite":0,"cacheWrite5m":0,"cacheWrite1h":11500834,"output":484564,"reasoningOutput":0,"providerTotal":373983809}},"commits":[],"artifacts":["scripts/usage-ledger.mjs","seed/seed.sql","src/shared/domain/usageLedger.test.ts","usage/REPORT.md","usage/ledger.jsonl","usage/receipts.jsonl"],"notes":["Generated by scripts/usage-ledger.mjs; raw evidence remains private."]} +{"schemaVersion":1,"id":"usage-20260814-claude-opus-5-9db5e5f0cfb0","recordedAt":"2026-08-14T16:52:24.911Z","period":{"start":"2026-08-14T16:22:46.389Z","end":"2026-08-14T16:52:23.979Z"},"actor":{"name":"Fable / Opus / Walt build session","surface":"Claude Desktop"},"provider":"anthropic","model":"claude-opus-5","category":"planning_design_engineering","description":"Continuous AI-assisted SpeakerOps planning, design, implementation, deployment, and handoff work.","measurement":"provider_reported","calls":32,"tokens":{"uncachedInput":64,"cacheRead":30102362,"cacheWrite":0,"cacheWrite5m":0,"cacheWrite1h":960176,"output":21357,"reasoningOutput":0,"providerTotal":31083959},"cost":{"kind":"api_list_price_estimate","rateId":"anthropic-claude-opus-5-2026-08-10","estimatedUsd":25.187186,"actualBilledUsd":null,"receiptStatus":"pending_subscription_receipt"},"source":{"kind":"claude_jsonl","sessionId":"9b81bd7e-4c4d-43ac-a4d0-1e292b854b95","sha256":"7fd2ea5daeaa7f0af1aa745b386c12ccc0ea32b6fa79e4d721cbe81f04d68137","lineCount":9474,"rawEvidence":"retained_privately","cumulative":{"calls":650,"uncachedInput":1619,"cacheRead":392099218,"cacheWrite":0,"cacheWrite5m":0,"cacheWrite1h":12461010,"output":505921,"reasoningOutput":0,"providerTotal":405067768}},"commits":[],"artifacts":["migrations/0014_form_settings.sql","src/shared/contracts/api.ts","src/shared/contracts/entities.ts","src/shared/domain/formLimits.test.ts","src/shared/domain/formLimits.ts","src/worker/repo/airtable/airtableRepo.ts","src/worker/repo/d1/d1Repo.ts","src/worker/repo/types.ts","src/worker/routes/api.ts"],"notes":["Generated by scripts/usage-ledger.mjs; raw evidence remains private."]} diff --git a/usage/receipts.jsonl b/usage/receipts.jsonl index 4631fe3..25093c3 100644 --- a/usage/receipts.jsonl +++ b/usage/receipts.jsonl @@ -63,3 +63,4 @@ {"schemaVersion":1,"id":"allocation-20260814-628b6e031111","recordedAt":"2026-08-14T16:22:36.275Z","provider":"anthropic","label":"Claude Max subscription — Aug 9–Sep 9, 2026 — coverage extension","period":{"start":"2026-08-09T00:00:00.000Z","end":"2026-09-09T23:59:59.000Z"},"amountUsd":0,"receiptStatus":"evidenced_allocation_extension","extendsReceiptId":"receipt-20260812-83e9aaa2f91f","source":{"kind":"existing_evidence_reference","sha256":"a429dcde5cd928aa61986db3c84de4b753867bc7f30f5bea5f338a4e69fa7b0a","bytes":34130,"rawEvidence":"retained_privately"},"coversEntryIds":["usage-20260814-claude-opus-5-9a60c82d8bfd"],"notes":["Automatically appended after usage sync; references existing private billing evidence and adds no billed amount."]} {"schemaVersion":1,"id":"allocation-20260814-9cbb3f1013c6","recordedAt":"2026-08-14T16:22:36.275Z","provider":"openai","label":"ChatGPT Pro subscription — Jul 21–Aug 21, 2026 — coverage extension","period":{"start":"2026-07-21T00:00:00.000Z","end":"2026-08-21T23:59:59.000Z"},"amountUsd":0,"receiptStatus":"evidenced_allocation_extension","extendsReceiptId":"receipt-20260812-4b6dd28cbb43","source":{"kind":"existing_evidence_reference","sha256":"920a87c11f8cae2dfa9310e7b5ebf82d33a0b4b495c6c36789bb5d49e53ba877","bytes":38220,"rawEvidence":"retained_privately"},"coversEntryIds":["usage-20260814-gpt-5.6-sol-3f4745fdcf8a"],"notes":["Automatically appended after usage sync; references existing private billing evidence and adds no billed amount."]} {"schemaVersion":1,"id":"allocation-20260814-257d0281e476","recordedAt":"2026-08-14T16:22:47.858Z","provider":"anthropic","label":"Claude Max subscription — Aug 9–Sep 9, 2026 — coverage extension","period":{"start":"2026-08-09T00:00:00.000Z","end":"2026-09-09T23:59:59.000Z"},"amountUsd":0,"receiptStatus":"evidenced_allocation_extension","extendsReceiptId":"receipt-20260812-83e9aaa2f91f","source":{"kind":"existing_evidence_reference","sha256":"a429dcde5cd928aa61986db3c84de4b753867bc7f30f5bea5f338a4e69fa7b0a","bytes":34130,"rawEvidence":"retained_privately"},"coversEntryIds":["usage-20260814-claude-opus-5-a0cf445c3829"],"notes":["Automatically appended after usage sync; references existing private billing evidence and adds no billed amount."]} +{"schemaVersion":1,"id":"allocation-20260814-9494f8538d5c","recordedAt":"2026-08-14T16:52:26.312Z","provider":"anthropic","label":"Claude Max subscription — Aug 9–Sep 9, 2026 — coverage extension","period":{"start":"2026-08-09T00:00:00.000Z","end":"2026-09-09T23:59:59.000Z"},"amountUsd":0,"receiptStatus":"evidenced_allocation_extension","extendsReceiptId":"receipt-20260812-83e9aaa2f91f","source":{"kind":"existing_evidence_reference","sha256":"a429dcde5cd928aa61986db3c84de4b753867bc7f30f5bea5f338a4e69fa7b0a","bytes":34130,"rawEvidence":"retained_privately"},"coversEntryIds":["usage-20260814-claude-opus-5-9db5e5f0cfb0"],"notes":["Automatically appended after usage sync; references existing private billing evidence and adds no billed amount."]} From 42ada1b03580eb95ee3e9198de138c247364de2d Mon Sep 17 00:00:00 2001 From: SteveMLC <121320326+SteveMLC@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:59:04 -0400 Subject: [PATCH 2/5] Warn draft holders before the call closes; copy the admins on new work Two controls in the product we are replacing had no answer here. Their close-date panel reads, verbatim: "Set a close date to enable draft reminder emails." The customer marked that whole panel "kinda impt". We already stored forms.closes_at and we already saved drafts, but nobody ever told a proposer their draft was about to be worthless. The six-hourly cron now runs a second sweep beside the task reminders: it finds unsubmitted drafts on a form closing within seven days and sends each one its own return link, through the same receipted email path every other message uses. cfp_drafts.reminded_at (migration 0015) holds it to one reminder per draft, however often it sweeps. Whether a draft has earned that reminder is decided by shouldRemindDraft in shared/domain/draftReminders.ts -- pure, clock injected, and tested against no close date, a close date already gone, a date outside the window, a draft already reminded, and the case that sends. Their Notifications step asks "What admins should be notified when a new submission is received?" The customer called that one "nice to have", so it is that and no more: a successful public submission copies each address on forms.notify_emails, naming the title, the reference code, and the submitter. A form with no addresses notifies nobody, and says nothing about it. forms.notify_emails arrives in 0014_form_settings.sql, which is not mine to write; this change only reads it. Co-Authored-By: Claude Opus 5 --- migrations/0015_cfp_draft_reminders.sql | 14 +++ src/shared/domain/draftReminders.test.ts | 48 ++++++++++ src/shared/domain/draftReminders.ts | 44 +++++++++ src/worker/index.ts | 10 +- src/worker/repo/airtable/airtableRepo.ts | 11 +++ src/worker/repo/d1/d1Repo.ts | 113 +++++++++++++++++++++++ src/worker/repo/types.ts | 31 +++++++ src/worker/routes/api.ts | 14 +++ 8 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 migrations/0015_cfp_draft_reminders.sql create mode 100644 src/shared/domain/draftReminders.test.ts create mode 100644 src/shared/domain/draftReminders.ts diff --git a/migrations/0015_cfp_draft_reminders.sql b/migrations/0015_cfp_draft_reminders.sql new file mode 100644 index 0000000..aaf3930 --- /dev/null +++ b/migrations/0015_cfp_draft_reminders.sql @@ -0,0 +1,14 @@ +-- Draft reminder emails, tied to the close date. +-- +-- The close-date panel in the product we are replacing reads, verbatim: "Set a +-- close date to enable draft reminder emails." We already store forms.closes_at +-- and we already keep saved drafts, but nothing ever told a proposer their +-- draft was about to expire. +-- +-- This column is the "we already told them" mark. It is written once, the first +-- time a reminder goes out, so an approaching close date can never nag the same +-- draft twice however often the sweep runs. +ALTER TABLE cfp_drafts ADD COLUMN reminded_at TEXT; + +-- The six-hourly sweep asks for un-reminded drafts and joins their form. +CREATE INDEX idx_cfp_drafts_reminded ON cfp_drafts(reminded_at, form_id); diff --git a/src/shared/domain/draftReminders.test.ts b/src/shared/domain/draftReminders.test.ts new file mode 100644 index 0000000..b276d8f --- /dev/null +++ b/src/shared/domain/draftReminders.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { DRAFT_REMINDER_WINDOW_MS, shouldRemindDraft } from "./draftReminders"; + +const NOW = "2026-08-14T12:00:00.000Z"; +const iso = (offsetMs: number) => new Date(Date.parse(NOW) + offsetMs).toISOString(); + +const candidate = { + email: "ada@example.com", + closesAt: iso(3 * 24 * 60 * 60 * 1000), + remindedAt: null, +}; + +describe("shouldRemindDraft", () => { + it("reminds an un-reminded draft whose close date is days away", () => { + expect(shouldRemindDraft(candidate, NOW)).toBe(true); + }); + + it("stays silent when the organizer set no close date", () => { + expect(shouldRemindDraft({ ...candidate, closesAt: null }, NOW)).toBe(false); + }); + + it("stays silent once the close date has passed", () => { + expect(shouldRemindDraft({ ...candidate, closesAt: iso(-60 * 1000) }, NOW)).toBe(false); + expect(shouldRemindDraft({ ...candidate, closesAt: NOW }, NOW)).toBe(false); + }); + + it("stays silent while the close date is further off than the window", () => { + expect(shouldRemindDraft({ ...candidate, closesAt: iso(DRAFT_REMINDER_WINDOW_MS + 1000) }, NOW)).toBe(false); + expect(shouldRemindDraft({ ...candidate, closesAt: iso(DRAFT_REMINDER_WINDOW_MS) }, NOW)).toBe(true); + }); + + it("never reminds the same draft twice", () => { + expect(shouldRemindDraft({ ...candidate, remindedAt: iso(-24 * 60 * 60 * 1000) }, NOW)).toBe(false); + }); + + it("needs somewhere to send it", () => { + expect(shouldRemindDraft({ ...candidate, email: null }, NOW)).toBe(false); + expect(shouldRemindDraft({ ...candidate, email: " " }, NOW)).toBe(false); + }); + + it("treats an unreadable close date as no close date", () => { + expect(shouldRemindDraft({ ...candidate, closesAt: "next Tuesday" }, NOW)).toBe(false); + }); + + it("refuses to guess at a broken clock", () => { + expect(() => shouldRemindDraft(candidate, "not a date")).toThrow(TypeError); + }); +}); diff --git a/src/shared/domain/draftReminders.ts b/src/shared/domain/draftReminders.ts new file mode 100644 index 0000000..44d3902 --- /dev/null +++ b/src/shared/domain/draftReminders.ts @@ -0,0 +1,44 @@ +/** + * Draft reminder policy, shared by the scheduled sweep that sends the mail and + * by its tests. Pure and clock-injected, like the rest of src/shared/domain: + * the sweep decides nothing on its own, it only supplies rows and sends what + * this function approves. + * + * The rule the customer's close-date panel promises — "Set a close date to + * enable draft reminder emails" — is exactly this: a close date turns the + * reminder on, and no close date turns it off. + */ + +/** How close the close date must be before a draft holder hears about it. */ +export const DRAFT_REMINDER_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +export interface DraftReminderCandidate { + /** Whatever email the draft carries so far. A draft may still have none. */ + email: string | null; + /** The form's close date, or null when the organizer set none. */ + closesAt: string | null; + /** When this draft was already reminded. Null means never. */ + remindedAt: string | null; +} + +/** + * True when this draft has earned exactly one reminder right now: + * somewhere to send it, a close date, that date still ahead, near enough to + * matter, and nobody has been told yet. + */ +export function shouldRemindDraft(candidate: DraftReminderCandidate, nowIso: string): boolean { + const now = Date.parse(nowIso); + if (Number.isNaN(now)) throw new TypeError(`Invalid now timestamp: ${JSON.stringify(nowIso)}`); + + // One reminder per draft, forever. A later close date does not buy a second. + if (candidate.remindedAt !== null) return false; + if (!candidate.email || !candidate.email.trim()) return false; + if (candidate.closesAt === null) return false; + + const closesAt = Date.parse(candidate.closesAt); + if (Number.isNaN(closesAt)) return false; + + // A closed call cannot be rescued, so a reminder would only annoy. + if (closesAt <= now) return false; + return closesAt - now <= DRAFT_REMINDER_WINDOW_MS; +} diff --git a/src/worker/index.ts b/src/worker/index.ts index 7ac4131..21d9e71 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -7,6 +7,7 @@ import { demoApi } from "./routes/demo"; import { demoPage } from "./routes/demoPage"; import { llms } from "./routes/llms"; import { createRepo } from "./repo/factory"; +import { DRAFT_REMINDER_WINDOW_MS } from "../shared/domain/draftReminders"; /** * One Worker serves everything: @@ -40,10 +41,17 @@ export default { scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext) { const now = new Date(); const dueBefore = new Date(now.getTime() + 48 * 60 * 60 * 1000); + const closesBefore = new Date(now.getTime() + DRAFT_REMINDER_WINDOW_MS); + const repo = createRepo(env); ctx.waitUntil( - createRepo(env) + repo .queueDueTaskReminders(now.toISOString(), dueBefore.toISOString()) .then((result) => console.log(`automatic task reminders queued: ${result.queued}`)), ); + ctx.waitUntil( + repo + .queueDraftCloseReminders(now.toISOString(), closesBefore.toISOString()) + .then((result) => console.log(`draft close reminders queued: ${result.queued}`)), + ); }, }; diff --git a/src/worker/repo/airtable/airtableRepo.ts b/src/worker/repo/airtable/airtableRepo.ts index d1bf274..0b2e148 100644 --- a/src/worker/repo/airtable/airtableRepo.ts +++ b/src/worker/repo/airtable/airtableRepo.ts @@ -51,6 +51,9 @@ import type { CreateSpeakerTaskInput, BulkTaskReminderInput, CreateAssetCommentInput, + NotifySubmissionAdminsInput, + NotifySubmissionAdminsResult, + QueueDraftCloseRemindersResult, } from "../types"; import { createEmailDelivery, type EmailDelivery } from "../../integrations/emailDelivery"; @@ -482,6 +485,14 @@ export class AirtableRepo implements LecternRepo { throw new AirtableNotWiredError("queueDueTaskReminders"); } + async queueDraftCloseReminders(_now: string, _closesBefore: string): Promise { + throw new AirtableNotWiredError("queueDraftCloseReminders"); + } + + async notifySubmissionAdmins(_input: NotifySubmissionAdminsInput): Promise { + throw new AirtableNotWiredError("notifySubmissionAdmins"); + } + async listMessages(_eventId: string): Promise { throw new AirtableNotWiredError("listMessages"); } diff --git a/src/worker/repo/d1/d1Repo.ts b/src/worker/repo/d1/d1Repo.ts index a814e88..81ffb62 100644 --- a/src/worker/repo/d1/d1Repo.ts +++ b/src/worker/repo/d1/d1Repo.ts @@ -73,9 +73,13 @@ import type { CreateSpeakerTaskInput, BulkTaskReminderInput, CreateAssetCommentInput, + NotifySubmissionAdminsInput, + NotifySubmissionAdminsResult, + QueueDraftCloseRemindersResult, } from "../types"; import { randomId } from "../../../shared/ids"; import { buildDirectSession, buildSessionFromSubmission } from "../../../shared/domain/acceptance"; +import { shouldRemindDraft } from "../../../shared/domain/draftReminders"; import { canApplyDecision, reviewerIdentity, statusForDecision } from "../../../shared/domain/decisions"; import { findScheduleConflicts } from "../../../shared/domain/schedule"; import { summarizeReviewScores } from "../../../shared/domain/reviews"; @@ -350,6 +354,24 @@ function parseJson(text: string | null, fallback: T): T { } } +/** + * forms.notify_emails holds a JSON array of admin addresses — the customer's + * "What admins should be notified when a new submission is received?" chips. + * Anything that is not a usable address is dropped and nobody hears about it: + * an unset, empty, or malformed list simply notifies no one. + */ +function parseNotifyEmails(raw: string | null): string[] { + const parsed = parseJson(raw, null); + if (!Array.isArray(parsed)) return []; + const addresses = new Set(); + for (const entry of parsed) { + if (typeof entry !== "string") continue; + const email = entry.trim().toLowerCase(); + if (email.includes("@")) addresses.add(email); + } + return [...addresses]; +} + function mapEvent(r: EventRow): Event { return { id: r.id, @@ -2674,6 +2696,97 @@ export class D1Repo implements LecternRepo { return { queued: taskIds.length, taskIds }; } + /** + * The other half of "Set a close date to enable draft reminder emails": a + * saved draft is worthless once the call closes, so anyone still holding one + * hears about it while there is still time to submit. SQL only narrows the + * field; shouldRemindDraft decides, and cfp_drafts.reminded_at is what makes + * a six-hourly sweep send exactly one reminder per draft. + */ + async queueDraftCloseReminders(now: string, closesBefore: string): Promise { + const rows = await this.db.prepare( + `SELECT d.token, d.event_id, d.payload_json, d.reminded_at, + f.closes_at, e.name AS event_name, e.slug AS event_slug + FROM cfp_drafts d + JOIN forms f ON f.id = d.form_id + JOIN events e ON e.id = d.event_id + WHERE d.reminded_at IS NULL AND f.closes_at IS NOT NULL + AND f.closes_at > ?1 AND f.closes_at <= ?2 + ORDER BY f.closes_at, d.token`, + ).bind(now, closesBefore).all<{ + token: string; event_id: string; payload_json: string; reminded_at: string | null; + closes_at: string; event_name: string; event_slug: string; + }>(); + const candidates = rows.results ?? []; + if (candidates.length === 0) return { queued: 0, tokens: [] }; + + const tokens: string[] = []; + for (const row of candidates) { + const parsed = CfpDraftRequest.safeParse(parseJson(row.payload_json, null)); + if (!parsed.success) continue; + const email = parsed.data.speaker?.email?.trim() ?? null; + if (!shouldRemindDraft({ email, closesAt: row.closes_at, remindedAt: row.reminded_at }, now)) continue; + if (!email) continue; // Already refused above; this keeps the type honest. + + const suffix = `${row.token}_${row.closes_at}`.replace(/[^a-zA-Z0-9_-]/g, "_"); + const closeDate = new Date(row.closes_at).toLocaleDateString("en-US", { + dateStyle: "long", + timeZone: "UTC", + }); + // The same return link the draft API hands the browser, so a proposer + // lands back on their own saved answers. + const resumeUrl = `/e/${encodeURIComponent(row.event_slug)}/cfp?draft=${encodeURIComponent(row.token)}`; + await this.simulateCommunication({ + messageId: `msg_draft_close_${suffix}`, + attemptId: `del_draft_close_${suffix}`, + eventId: row.event_id, + // A draft holder is nobody's speaker yet — there is no record to point at. + speakerId: null, + toEmail: email, + subject: `Your draft for ${row.event_name} is not submitted yet`, + bodyMd: `Hi ${parsed.data.speaker?.name?.trim() || "there"},\n\n“${parsed.data.title}” is still a draft for ${row.event_name}, and drafts are never reviewed. The call for speakers closes on ${closeDate}.\n\nFinish and submit it here: ${resumeUrl}`, + now, + }); + await this.db.prepare( + "UPDATE cfp_drafts SET reminded_at = ?1 WHERE token = ?2 AND reminded_at IS NULL", + ).bind(now, row.token).run(); + tokens.push(row.token); + } + return { queued: tokens.length, tokens }; + } + + /** + * "What admins should be notified when a new submission is received?" — one + * receipted message per configured address. Deterministic ids keyed on the + * submission, because the caller cannot know how many admins a form has. + */ + async notifySubmissionAdmins(input: NotifySubmissionAdminsInput): Promise { + const row = await this.db.prepare( + `SELECT f.notify_emails, e.name AS event_name + FROM forms f JOIN events e ON e.id = f.event_id + WHERE f.id = ?1 AND f.event_id = ?2`, + ).bind(input.formId, input.eventId).first<{ notify_emails: string | null; event_name: string }>(); + if (!row) return { notified: 0, recipientEmails: [] }; + + const recipients = parseNotifyEmails(row.notify_emails); + if (recipients.length === 0) return { notified: 0, recipientEmails: [] }; + + const code = input.referenceCode ?? input.submissionId; + for (const [index, toEmail] of recipients.entries()) { + await this.simulateCommunication({ + messageId: `msg_admin_new_${input.submissionId}_${index}`, + attemptId: `del_admin_new_${input.submissionId}_${index}`, + eventId: input.eventId, + speakerId: null, + toEmail, + subject: `New submission ${code}: ${input.title}`, + bodyMd: `${input.submitterName} submitted “${input.title}” to ${row.event_name}.\n\nReference: ${code}\nSubmitter: ${input.submitterName} <${input.submitterEmail}>\n\nOpen the submissions queue to review it.`, + now: input.now, + }); + } + return { notified: recipients.length, recipientEmails: recipients }; + } + async listMessages(eventId: string): Promise { const rows = await this.db.prepare( `SELECT m.id, m.to_email, m.subject, m.status, m.created_at, diff --git a/src/worker/repo/types.ts b/src/worker/repo/types.ts index c473ab1..0d2b6a5 100644 --- a/src/worker/repo/types.ts +++ b/src/worker/repo/types.ts @@ -110,6 +110,14 @@ export interface LecternRepo { createFormField(input: CreateFormFieldInput): Promise; simulateCommunication(input: SimulateCommunicationInput): Promise; queueDueTaskReminders(now: string, dueBefore: string): Promise; + /** + * Reminds anyone holding an unsubmitted draft that their form's close date is + * near. `closesBefore` is the far edge of the reminder window; the per-draft + * decision belongs to shouldRemindDraft in shared/domain/draftReminders. + */ + queueDraftCloseReminders(now: string, closesBefore: string): Promise; + /** Copies the form's configured admin addresses on a new submission. */ + notifySubmissionAdmins(input: NotifySubmissionAdminsInput): Promise; listMessages(eventId: string): Promise; createSpeakerAsset(input: CreateSpeakerAssetInput): Promise; getSpeakerAssetById(id: string): Promise; @@ -289,6 +297,29 @@ export interface QueueDueTaskRemindersResult { taskIds: string[]; } +export interface QueueDraftCloseRemindersResult { + queued: number; + /** Draft tokens reminded on this pass. */ + tokens: string[]; +} + +export interface NotifySubmissionAdminsInput { + eventId: string; + formId: string; + submissionId: string; + /** The human-readable code organizers say out loud, e.g. "SUB-12". */ + referenceCode: string | null; + title: string; + submitterName: string; + submitterEmail: string; + now: string; +} + +export interface NotifySubmissionAdminsResult { + notified: number; + recipientEmails: string[]; +} + export interface SaveEvaluationRoundInput { eventId: string; planId: string; diff --git a/src/worker/routes/api.ts b/src/worker/routes/api.ts index ee6503a..ef36f1e 100644 --- a/src/worker/routes/api.ts +++ b/src/worker/routes/api.ts @@ -1316,6 +1316,20 @@ api.post("/events/:slug/submissions", async (c) => { }); } + // "What admins should be notified when a new submission is received?" — the + // addresses the organizer listed on the form, each getting its own receipted + // message. A form with no addresses notifies nobody, and says nothing about it. + await repo.notifySubmissionAdmins({ + eventId: bundle.event.id, + formId: bundle.cfp.form.id, + submissionId: submission.id, + referenceCode: submission.referenceCode ?? null, + title: submission.title, + submitterName: primary?.name ?? "A submitter", + submitterEmail: primary?.email ?? "", + now, + }); + const body: CreateSubmissionResponse = { submission }; return c.json(body, 201); }); From 7609575bc9def8b2dedefa43a1d6b63ea7e18160 Mon Sep 17 00:00:00 2001 From: SteveMLC <121320326+SteveMLC@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:11:03 -0400 Subject: [PATCH 3/5] Let organizers order CFP questions, and lock the four the programme reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The customer's builder does two things ours did not. Its Form Questions panel carries a drag handle on every row, and its Title row carries a "Locked" badge: core questions can be edited but never deleted, because the programme depends on them. Both behaviours are visible in the screenshots we are replacing, so both are here. Ordering. form_fields.sort_order existed but was only ever set at insert, so the order a submitter read was the order the organizer happened to add fields in. PUT /api/events/:slug/cfp/fields/order now takes the whole ordered list of field ids — the house full-object contract, not a delta — and rewrites positions 0..n-1 through the repo boundary with an injected clock. The public CFP page already read fields ORDER BY sort_order, so it follows with no change. Move up / move down are the mechanism, not the fallback: they are what a keyboard reaches and what a screen reader announces, and their names carry the field label ("Move Speaking experience up"). Native HTML5 dragging is layered on top for a mouse. Moving a question to an end disables the button that was just pressed, so focus is handed to the control that is still enabled rather than dropped on the floor. Locking, and why this model. Title, abstract, track, and format are columns on submissions, not rows in form_fields — the builder never listed them, and turning them into rows would mean migrating four intrinsic columns to satisfy a badge. So the honest model is the cheap one: locked means "the key names something the schema depends on". CORE_CFP_FIELDS renders those four as badged rows that have no move and no remove control, and one pure function, isLockedCfpField, is the single source of truth. It also refuses a custom field keyed "title" or "format" at the API, which is what keeps the model true: form_fields can never hold a locked key, so a locked question can never end up reorderable or deletable by accident. Removal is new alongside it. A badge that says "cannot be deleted" means nothing when nothing can be deleted, so DELETE /cfp/fields/:fieldId removes a custom question and any conditional rule naming it, refuses a locked key, and asks for a second press in the UI because no delete here can be undone. Also gives the builder's own inputs the label association the rest of the console has; they were rendering as unnamed textboxes. Co-Authored-By: Claude Opus 5 --- src/shared/contracts/api.ts | 11 ++ src/shared/domain/formFields.test.ts | 111 ++++++++++++ src/shared/domain/formFields.ts | 106 +++++++++++ src/web/lib/api.ts | 5 + src/web/pages/admin/Settings.tsx | 217 ++++++++++++++++++++++- src/worker/repo/airtable/airtableRepo.ts | 4 + src/worker/repo/d1/d1Repo.ts | 32 ++++ src/worker/repo/types.ts | 20 +++ src/worker/routes/api.ts | 29 +++ 9 files changed, 528 insertions(+), 7 deletions(-) create mode 100644 src/shared/domain/formFields.test.ts create mode 100644 src/shared/domain/formFields.ts diff --git a/src/shared/contracts/api.ts b/src/shared/contracts/api.ts index 8f4a3ef..43975c6 100644 --- a/src/shared/contracts/api.ts +++ b/src/shared/contracts/api.ts @@ -121,6 +121,17 @@ export const CreateFormFieldRequest = z.object({ }); export type CreateFormFieldRequest = z.infer; +/** + * The whole ordered list of custom CFP field ids, not a delta: the organizer + * sends the order they can see, and the worker refuses anything that is not a + * permutation of the stored fields. Locked core questions carry no id and never + * appear here. + */ +export const ReorderFormFieldsRequest = z.object({ + fieldIds: z.array(z.string().trim().min(1).max(80)).max(60), +}); +export type ReorderFormFieldsRequest = z.infer; + /** Everything the public event + CFP pages need in one round trip. */ export const EventBundle = z.object({ event: Event, diff --git a/src/shared/domain/formFields.test.ts b/src/shared/domain/formFields.test.ts new file mode 100644 index 0000000..d1902ab --- /dev/null +++ b/src/shared/domain/formFields.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + CORE_CFP_FIELDS, + dropFieldOnto, + fieldOrderError, + isLockedCfpField, + moveFieldOrder, + moveFieldToIndex, +} from "./formFields"; + +describe("isLockedCfpField", () => { + it("locks the four questions the programme reads off the submission", () => { + for (const key of ["title", "abstract", "track", "format"]) { + expect(isLockedCfpField(key)).toBe(true); + } + }); + + it("leaves an organizer's own questions unlocked", () => { + for (const key of ["prior_speaking", "workshop_length", "travel_support", "title_slide"]) { + expect(isLockedCfpField(key)).toBe(false); + } + }); + + it("locks a key however it is typed, so a custom field cannot shadow a core one", () => { + expect(isLockedCfpField(" Title ")).toBe(true); + expect(isLockedCfpField("FORMAT")).toBe(true); + }); + + it("agrees with the core field list it is derived from", () => { + expect(CORE_CFP_FIELDS.map((field) => field.key)).toEqual(["title", "abstract", "track", "format"]); + for (const field of CORE_CFP_FIELDS) expect(isLockedCfpField(field.key)).toBe(true); + }); +}); + +describe("moveFieldOrder", () => { + const ids = ["ff_a", "ff_b", "ff_c"]; + + it("swaps a field with the one above or below it", () => { + expect(moveFieldOrder(ids, "ff_b", "up")).toEqual(["ff_b", "ff_a", "ff_c"]); + expect(moveFieldOrder(ids, "ff_b", "down")).toEqual(["ff_a", "ff_c", "ff_b"]); + }); + + it("holds the order at both ends", () => { + expect(moveFieldOrder(ids, "ff_a", "up")).toEqual(ids); + expect(moveFieldOrder(ids, "ff_c", "down")).toEqual(ids); + }); + + it("ignores an id that is not on the form", () => { + expect(moveFieldOrder(ids, "ff_gone", "up")).toEqual(ids); + }); + + it("never mutates the order it was given", () => { + const original = [...ids]; + moveFieldOrder(ids, "ff_b", "up"); + expect(ids).toEqual(original); + }); + + it("round-trips: down then up puts a field back", () => { + expect(moveFieldOrder(moveFieldOrder(ids, "ff_a", "down"), "ff_a", "up")).toEqual(ids); + }); +}); + +describe("moveFieldToIndex", () => { + const ids = ["ff_a", "ff_b", "ff_c", "ff_d"]; + + it("lifts a field out and puts it back at the index asked for", () => { + expect(moveFieldToIndex(ids, "ff_a", 3)).toEqual(["ff_b", "ff_c", "ff_d", "ff_a"]); + expect(moveFieldToIndex(ids, "ff_d", 0)).toEqual(["ff_d", "ff_a", "ff_b", "ff_c"]); + }); + + it("holds the order for an index off either end or an unknown field", () => { + expect(moveFieldToIndex(ids, "ff_a", -1)).toEqual(ids); + expect(moveFieldToIndex(ids, "ff_a", 4)).toEqual(ids); + expect(moveFieldToIndex(ids, "ff_gone", 1)).toEqual(ids); + }); +}); + +describe("dropFieldOnto", () => { + const ids = ["ff_a", "ff_b", "ff_c"]; + + it("gives the dragged field the target's place, dragging either way", () => { + expect(dropFieldOnto(ids, "ff_a", "ff_c")).toEqual(["ff_b", "ff_c", "ff_a"]); + expect(dropFieldOnto(ids, "ff_c", "ff_a")).toEqual(["ff_c", "ff_a", "ff_b"]); + }); + + it("holds the order when a field is dropped on itself or on a stranger", () => { + expect(dropFieldOnto(ids, "ff_b", "ff_b")).toEqual(ids); + expect(dropFieldOnto(ids, "ff_b", "ff_gone")).toEqual(ids); + }); +}); + +describe("fieldOrderError", () => { + const stored = ["ff_a", "ff_b", "ff_c"]; + + it("accepts any permutation of the stored fields", () => { + expect(fieldOrderError(stored, ["ff_c", "ff_a", "ff_b"])).toBeNull(); + expect(fieldOrderError([], [])).toBeNull(); + }); + + it("rejects a partial order, because the contract is the whole list", () => { + expect(fieldOrderError(stored, ["ff_a", "ff_b"])).toContain("Send the whole order."); + }); + + it("rejects a repeated field", () => { + expect(fieldOrderError(stored, ["ff_a", "ff_a", "ff_b"])).toContain("twice"); + }); + + it("names a field that is not on the form", () => { + expect(fieldOrderError(stored, ["ff_a", "ff_b", "ff_gone"])).toContain("ff_gone"); + }); +}); diff --git a/src/shared/domain/formFields.ts b/src/shared/domain/formFields.ts new file mode 100644 index 0000000..bf62438 --- /dev/null +++ b/src/shared/domain/formFields.ts @@ -0,0 +1,106 @@ +/** + * CFP question ordering and locking, shared by the organizer's form builder and + * the worker. + * + * A proposal is answered in two places. Four questions — title, abstract, + * track, format — are columns on `submissions`; the programme reads them + * directly to schedule, review, and publish a session, so they are locked: the + * builder shows them, but they can never be removed and never move. Everything + * else is a row in `form_fields` the organizer owns outright and can reorder, + * because the stored order is the order a submitter reads. + * + * Keeping the lock rule here means the builder, the create route, and the + * reorder route cannot disagree about which questions the programme depends on. + */ + +/** A locked question: the key it is addressable by, and what a submitter sees. */ +export interface CoreCfpField { + key: string; + label: string; + /** Mirrors the control the public CFP page renders for this question. */ + fieldType: "text" | "textarea" | "select"; + /** Why the programme cannot do without it, shown under the label. */ + reason: string; +} + +/** + * The four questions every proposal carries, in the order the public CFP page + * renders them. Stored as columns on `submissions`, not rows in `form_fields`. + */ +export const CORE_CFP_FIELDS: readonly CoreCfpField[] = [ + { key: "title", label: "Session title", fieldType: "text", reason: "Names the session everywhere it appears." }, + { key: "abstract", label: "Abstract", fieldType: "textarea", reason: "The text reviewers score and the programme publishes." }, + { key: "track", label: "Track", fieldType: "select", reason: "Routes the proposal to the right reviewers." }, + { key: "format", label: "Format", fieldType: "select", reason: "Sets session length and drives conditional questions." }, +]; + +const CORE_CFP_FIELD_KEYS = new Set(CORE_CFP_FIELDS.map((field) => field.key)); + +/** + * True when a question is one the programme depends on: locked in the builder, + * and refused as a custom field key so a custom question can never shadow one. + */ +export function isLockedCfpField(key: string): boolean { + return CORE_CFP_FIELD_KEYS.has(key.trim().toLowerCase()); +} + +/** + * One question lifted out of the order and put back at `toIndex`. Returns the + * order unchanged when the move is impossible — an unknown id, an index off + * either end — so no caller has to bounds-check before asking. + */ +export function moveFieldToIndex( + fieldIds: readonly string[], + fieldId: string, + toIndex: number, +): string[] { + const from = fieldIds.indexOf(fieldId); + if (from === -1 || toIndex < 0 || toIndex >= fieldIds.length) return [...fieldIds]; + const next = [...fieldIds]; + const [moved] = next.splice(from, 1); + if (moved === undefined) return [...fieldIds]; + next.splice(toIndex, 0, moved); + return next; +} + +/** What "move up" and "move down" do: one place, and nothing at the ends. */ +export function moveFieldOrder( + fieldIds: readonly string[], + fieldId: string, + direction: "up" | "down", +): string[] { + const from = fieldIds.indexOf(fieldId); + if (from === -1) return [...fieldIds]; + return moveFieldToIndex(fieldIds, fieldId, direction === "up" ? from - 1 : from + 1); +} + +/** What a drag does: the dragged question takes the target's place, and the + * questions it passed over close up behind it. */ +export function dropFieldOnto( + fieldIds: readonly string[], + fieldId: string, + targetFieldId: string, +): string[] { + return moveFieldToIndex(fieldIds, fieldId, fieldIds.indexOf(targetFieldId)); +} + +/** + * Why a submitted order cannot be applied, phrased for the organizer, or null + * when it is a clean permutation of the stored fields. The contract is the full + * ordered list, so a partial list is a bug rather than a partial update. + */ +export function fieldOrderError( + storedIds: readonly string[], + requestedIds: readonly string[], +): string | null { + if (new Set(requestedIds).size !== requestedIds.length) { + return "The submitted order lists the same question twice."; + } + if (requestedIds.length !== storedIds.length) { + return `The submitted order has ${requestedIds.length} question${requestedIds.length === 1 ? "" : "s"} but this form has ${storedIds.length}. Send the whole order.`; + } + const stored = new Set(storedIds); + const unknown = requestedIds.find((id) => !stored.has(id)); + if (unknown !== undefined) return `“${unknown}” is not a question on this form.`; + return null; +} diff --git a/src/web/lib/api.ts b/src/web/lib/api.ts index 54470ea..693b107 100644 --- a/src/web/lib/api.ts +++ b/src/web/lib/api.ts @@ -30,6 +30,7 @@ import { CreateTrackRequest, CreateRoomRequest, CreateFormFieldRequest, + ReorderFormFieldsRequest, FeedbackDraftRequest, FeedbackDraftResponse, ScheduleNoticeDraftRequest, @@ -169,6 +170,10 @@ export const apiClient = { createFormField: (slug: string, body: CreateFormFieldRequest) => request(EventBundle, `/api/events/${encodeURIComponent(slug)}/cfp/fields`, { method: "POST", body: JSON.stringify(body) }, { auth: true }), + reorderFormFields: (slug: string, body: ReorderFormFieldsRequest) => request(EventBundle, `/api/events/${encodeURIComponent(slug)}/cfp/fields/order`, { method: "PUT", body: JSON.stringify(body) }, { auth: true }), + + deleteFormField: (slug: string, fieldId: string) => request(EventBundle, `/api/events/${encodeURIComponent(slug)}/cfp/fields/${encodeURIComponent(fieldId)}`, { method: "DELETE" }, { auth: true }), + publicSchedule: (slug: string) => request(PublicScheduleResponse, `/api/public/events/${encodeURIComponent(slug)}/schedule`), diff --git a/src/web/pages/admin/Settings.tsx b/src/web/pages/admin/Settings.tsx index f2a04ba..f20d3ee 100644 --- a/src/web/pages/admin/Settings.tsx +++ b/src/web/pages/admin/Settings.tsx @@ -1,6 +1,7 @@ -import { useState } from "react"; -import type { EventBundle } from "../../../shared/contracts"; -import { Badge, Button, Card, ErrorBanner, Field, Input, PageHeader, Select, Spinner, Textarea } from "../../components/ui"; +import { useEffect, useRef, useState } from "react"; +import type { EventBundle, FormField } from "../../../shared/contracts"; +import { CORE_CFP_FIELDS, dropFieldOnto, moveFieldOrder } from "../../../shared/domain/formFields"; +import { Badge, Button, Card, ErrorBanner, Field, Input, PageHeader, Select, Spinner, Textarea, cn } from "../../components/ui"; import { ApiRequestError, apiClient } from "../../lib/api"; import { useAsync } from "../../lib/useAsync"; import { useAdminContext } from "./AdminLayout"; @@ -15,7 +16,7 @@ export function Settings() { if (loading) return ; if (error || !loaded) return ; const data = override ?? loaded; - return
{ setOverride(null); reload(); }}>Refresh} />{notice ?

{notice}

: null}
{ setOverride(next); setNotice("CFP settings saved. Speaker proposal editing now follows this window."); }} /> { setOverride(next); setNotice("Track added and immediately available on the public CFP."); }} /> { setOverride(next); setNotice("Custom field added. Client and API validation share the same rules."); }} /> setNotice(`${name} created. Reload the organizer console to select it.`)} />
; + return
{ setOverride(null); reload(); }}>Refresh} />{notice ?

{notice}

: null}
{ setOverride(next); setNotice("CFP settings saved. Speaker proposal editing now follows this window."); }} /> { setOverride(next); setNotice("Track added and immediately available on the public CFP."); }} /> { setOverride(next); setNotice(message); }} /> setNotice(`${name} created. Reload the organizer console to select it.`)} />
; } function CfpSettings({ data, onUpdated }: { data: EventBundle; onUpdated: (data: EventBundle) => void }) { @@ -30,10 +31,212 @@ function Tracks({ data, onUpdated }: { data: EventBundle; onUpdated: (data: Even return

Tracks & formats

{data.tracks.map((track) => {track.name})}

Formats are fixed, honest program types: Talk, Workshop, Panel, Lightning, and Keynote.

setName(e.target.value)} required />