checkin.ts
Language TypeScript10.7 KB300 lines
/*** The end-of-day note's storage: D1, read and written from here and nowhere* else.** Why SQL (rule 14): everything else the board owns is a JSON blob in KV, read whole and written* whole, and that is right for what those things are: a sender list, a set of* read marks, a map of notes. This is a series: one row a day per person, read* back as a date range that has to include the days with nothing in them. KV* would mean read-modify-write over the whole history on every submission, and* KV is eventually consistent by up to about a minute, so two submissions a* minute apart can silently lose one. The* thing being lost is the only copy of something a person typed once.** What this file promises the page: Every function returns a state, never a* throw and never an empty list standing in for a failure: `unconfigured` when* there is no binding, `failed` with the store's own words when there is one* and it would not answer, `ok` otherwise. The page renders all three* differently. That is the prime invariant (zero, null and broken must never* look the same) applied to prose instead of to numbers.** The handler is a pure function with the route around it, for the same* reason inbox-send.ts is shaped that way: /checkin sits behind Cloudflare Access and* the test suite cannot mint an assertion for it, so the decision has to be* reachable without the route. `decideCheckin` is where every refusal lives and* it takes no I/O at all.*/import {checkinDay,checkinWindow,type CheckinNote,type CheckinOutcome,type CheckinRevision,type CheckinStore,} from '../../dashboard/src/checkin-view.js'import { CHECKIN_FIELD_CAP, CHECKIN_QUESTIONS } from '../../../config/checkin.js'import { MERIDIAN_TZ } from '@meridian/ninety-client'import { errorMessage } from '@meridian/shared'/** Just the binding, so the handlers can be called with a fake database. */export interface CheckinEnv {/*** Optional, and that is a decision rather than an oversight. A Worker* deployed before the D1 database exists must serve the whole board with the* note card saying "not set up", not throw, and not show an empty log that* reads as "she has never written one".*/CHECKINS?: D1Database}/** The three answers, as they arrive from the form. */export interface CheckinAnswers {did: stringunsure: stringneed: string}export interface CheckinInput extends CheckinAnswers {person: stringauthorEmail: string/** The day the form claimed. Compared with the server's own, never trusted. */claimedDay: string}// --- the decision -------------------------------------------------------------------export type CheckinDecision =| { write: true; day: string; answers: CheckinAnswers }| { write: false; outcome: Extract<CheckinOutcome, 'empty' | 'stale'>; day: string }/*** May this submission be written, and to which day?** The day comes from the clock, never from the form. A tab left open overnight* posts yesterday's day string, and writing it into today's row would overwrite* a note with text meant for a different day or, worse, fill in a day that* was genuinely missed with words about another one. So the two are compared* and a mismatch refuses: nothing is written, and the page says the page was* stale. Refusing costs a reload; guessing costs the record.** An all-blank note is refused, because a row of three empty strings renders* exactly like the day she never opened the form, the one distinction the* whole feature rests on. The database has the same CHECK, so this cannot be* routed around.*/export function decideCheckin(input: CheckinInput, now: Date): CheckinDecision {const day = checkinDay(now, MERIDIAN_TZ)const answers: CheckinAnswers = {did: input.did.trim().slice(0, CHECKIN_FIELD_CAP),unsure: input.unsure.trim().slice(0, CHECKIN_FIELD_CAP),need: input.need.trim().slice(0, CHECKIN_FIELD_CAP),}if (input.claimedDay && input.claimedDay !== day) return { write: false, outcome: 'stale', day }if (!answers.did && !answers.unsure && !answers.need) {return { write: false, outcome: 'empty', day }}return { write: true, day, answers }}// --- reading ------------------------------------------------------------------------interface NoteRow {person: stringday: stringdid: stringunsure: stringneed: stringsubmitted_at: stringupdated_at: stringrevisions: numberauthor_email: string}interface RevisionRow {person: stringday: stringrevision: numberdid: stringunsure: stringneed: stringwritten_at: string}const toNote = (r: NoteRow): CheckinNote => ({person: r.person,day: r.day,did: r.did,unsure: r.unsure,need: r.need,submittedAt: r.submitted_at,updatedAt: r.updated_at,revisions: r.revisions,authorEmail: r.author_email,})const toRevision = (r: RevisionRow): CheckinRevision => ({person: r.person,day: r.day,revision: r.revision,did: r.did,unsure: r.unsure,need: r.need,writtenAt: r.written_at,})/*** One person's notes for the window the card will render, plus the superseded* versions inside it.** The window comes from `checkinWindow`, the same function the card uses to* decide which days to list. Two independently-computed windows would drift,* and the failure would be silent and backwards: a day with a note rendering as* a day she missed.*/export async function readCheckins(env: CheckinEnv,person: string,now: Date = new Date(),): Promise<CheckinStore> {if (!env.CHECKINS) return { state: 'unconfigured' }const { from, limit } = checkinWindow(person, checkinDay(now, MERIDIAN_TZ))try {const [notes, earlier] = await env.CHECKINS.batch([env.CHECKINS.prepare(`SELECT person, day, did, unsure, need, submitted_at, updated_at, revisions, author_emailFROM checkinWHERE person = ?1 AND day >= ?2ORDER BY day DESCLIMIT ?3`,).bind(person, from, limit),// The superseded versions only. `revision < revisions` is not expressible// here without a join, so every revision in the window comes back and the// card drops the one that matches what is current (a handful of rows).env.CHECKINS.prepare(`SELECT person, day, revision, did, unsure, need, written_atFROM checkin_revisionWHERE person = ?1 AND day >= ?2ORDER BY day DESC, revision DESC`,).bind(person, from),])const noteRows = (notes?.results ?? []) as unknown as NoteRow[]return {state: 'ok',notes: noteRows.map(toNote),earlier: ((earlier?.results ?? []) as unknown as RevisionRow[]).map(toRevision),// Rule 5: what we have, plus the fact that there was more.truncated: noteRows.length >= limit,}} catch (err) {// The store's own words and no diagnosis (rule 1): "no such table:// checkin" is a fact; "you forgot the migration" is a guess the reader// can make themselves.return { state: 'failed', error: errorMessage(err ?? 'unknown error') }}}/** Every person this viewer may read, each with its own state. */export async function readCheckinsFor(env: CheckinEnv,people: string[],now: Date = new Date(),): Promise<Record<string, CheckinStore>> {const entries = await Promise.all(people.map(async (p) => [p, await readCheckins(env, p, now)] as const),)return Object.fromEntries(entries)}// --- writing ------------------------------------------------------------------------export type CheckinResult = { outcome: CheckinOutcome; day: string }/*** Store one submission. Creates, updates, or does nothing.** An identical resubmit writes nothing. A double-click, or a reload of a POST,* would otherwise add a second version of text that never changed, and the card* would tell a reader the note had been edited when it had not. Comparing the* three strings is cheaper than explaining that chip.** The two writes go together or not at all. `batch()` runs them in one* transaction, so there is no state where the current note has moved on and the* append-only log has not, which would make the log a record of some of the* versions, which is not a record.*/export async function saveCheckin(env: CheckinEnv,input: CheckinInput,now: Date = new Date(),): Promise<CheckinResult> {const decision = decideCheckin(input, now)if (!decision.write) return { outcome: decision.outcome, day: decision.day }const { day, answers } = decisionif (!env.CHECKINS) return { outcome: 'failed', day }const at = now.toISOString()try {const existing = await env.CHECKINS.prepare(`SELECT did, unsure, need, revisions FROM checkin WHERE person = ?1 AND day = ?2`,).bind(input.person, day).first<{ did: string; unsure: string; need: string; revisions: number }>()if (existing &&CHECKIN_QUESTIONS.every((q) => String(existing[q.id] ?? '') === answers[q.id])) {return { outcome: 'unchanged', day }}const revision = (existing?.revisions ?? 0) + 1await env.CHECKINS.batch([env.CHECKINS.prepare(`INSERT INTO checkin(person, author_email, day, day_tz, did, unsure, need, submitted_at, updated_at, revisions)VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8, 1)ON CONFLICT (person, day) DO UPDATE SETdid = excluded.did,unsure = excluded.unsure,need = excluded.need,updated_at = excluded.updated_at,author_email = excluded.author_email,revisions = checkin.revisions + 1`,// submitted_at is NOT touched on conflict: it is when she first wrote// one that day, and the reader's "written at 6:40 PM" means that.).bind(input.person,input.authorEmail,day,MERIDIAN_TZ,answers.did,answers.unsure,answers.need,at,),env.CHECKINS.prepare(`INSERT INTO checkin_revision(person, day, revision, did, unsure, need, author_email, written_at)VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`,).bind(input.person,day,revision,answers.did,answers.unsure,answers.need,input.authorEmail,at,),])return { outcome: existing ? 'updated' : 'saved', day }} catch {// The page must say the note was not saved, and it must say it loudly:// this is the one action here whose failure costs somebody the thing they// just typed. The store's message is not shown for a write: it lands on the// author's screen mid-task, where "D1_ERROR: near ..." is noise. What she// needs is "it did not save, keep your text".return { outcome: 'failed', day }}}