mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

checkin.ts

Language TypeScript10.7 KB300 lines

apps/worker/src/checkin.ts

  1. /**
  2. * The end-of-day note's storage: D1, read and written from here and nowhere
  3. * else.
  4. *
  5. * Why SQL (rule 14): everything else the board owns is a JSON blob in KV, read whole and written
  6. * whole, and that is right for what those things are: a sender list, a set of
  7. * read marks, a map of notes. This is a series: one row a day per person, read
  8. * back as a date range that has to include the days with nothing in them. KV
  9. * would mean read-modify-write over the whole history on every submission, and
  10. * KV is eventually consistent by up to about a minute, so two submissions a
  11. * minute apart can silently lose one. The
  12. * thing being lost is the only copy of something a person typed once.
  13. *
  14. * What this file promises the page: Every function returns a state, never a
  15. * throw and never an empty list standing in for a failure: `unconfigured` when
  16. * there is no binding, `failed` with the store's own words when there is one
  17. * and it would not answer, `ok` otherwise. The page renders all three
  18. * differently. That is the prime invariant (zero, null and broken must never
  19. * look the same) applied to prose instead of to numbers.
  20. *
  21. * The handler is a pure function with the route around it, for the same
  22. * reason inbox-send.ts is shaped that way: /checkin sits behind Cloudflare Access and
  23. * the test suite cannot mint an assertion for it, so the decision has to be
  24. * reachable without the route. `decideCheckin` is where every refusal lives and
  25. * it takes no I/O at all.
  26. */
  27. import {
  28. checkinDay,
  29. checkinWindow,
  30. type CheckinNote,
  31. type CheckinOutcome,
  32. type CheckinRevision,
  33. type CheckinStore,
  34. } from '../../dashboard/src/checkin-view.js'
  35. import { CHECKIN_FIELD_CAP, CHECKIN_QUESTIONS } from '../../../config/checkin.js'
  36. import { MERIDIAN_TZ } from '@meridian/ninety-client'
  37. import { errorMessage } from '@meridian/shared'
  38. /** Just the binding, so the handlers can be called with a fake database. */
  39. export interface CheckinEnv {
  40. /**
  41. * Optional, and that is a decision rather than an oversight. A Worker
  42. * deployed before the D1 database exists must serve the whole board with the
  43. * note card saying "not set up", not throw, and not show an empty log that
  44. * reads as "she has never written one".
  45. */
  46. CHECKINS?: D1Database
  47. }
  48. /** The three answers, as they arrive from the form. */
  49. export interface CheckinAnswers {
  50. did: string
  51. unsure: string
  52. need: string
  53. }
  54. export interface CheckinInput extends CheckinAnswers {
  55. person: string
  56. authorEmail: string
  57. /** The day the form claimed. Compared with the server's own, never trusted. */
  58. claimedDay: string
  59. }
  60. // --- the decision -------------------------------------------------------------------
  61. export type CheckinDecision =
  62. | { write: true; day: string; answers: CheckinAnswers }
  63. | { write: false; outcome: Extract<CheckinOutcome, 'empty' | 'stale'>; day: string }
  64. /**
  65. * May this submission be written, and to which day?
  66. *
  67. * The day comes from the clock, never from the form. A tab left open overnight
  68. * posts yesterday's day string, and writing it into today's row would overwrite
  69. * a note with text meant for a different day or, worse, fill in a day that
  70. * was genuinely missed with words about another one. So the two are compared
  71. * and a mismatch refuses: nothing is written, and the page says the page was
  72. * stale. Refusing costs a reload; guessing costs the record.
  73. *
  74. * An all-blank note is refused, because a row of three empty strings renders
  75. * exactly like the day she never opened the form, the one distinction the
  76. * whole feature rests on. The database has the same CHECK, so this cannot be
  77. * routed around.
  78. */
  79. export function decideCheckin(input: CheckinInput, now: Date): CheckinDecision {
  80. const day = checkinDay(now, MERIDIAN_TZ)
  81. const answers: CheckinAnswers = {
  82. did: input.did.trim().slice(0, CHECKIN_FIELD_CAP),
  83. unsure: input.unsure.trim().slice(0, CHECKIN_FIELD_CAP),
  84. need: input.need.trim().slice(0, CHECKIN_FIELD_CAP),
  85. }
  86. if (input.claimedDay && input.claimedDay !== day) return { write: false, outcome: 'stale', day }
  87. if (!answers.did && !answers.unsure && !answers.need) {
  88. return { write: false, outcome: 'empty', day }
  89. }
  90. return { write: true, day, answers }
  91. }
  92. // --- reading ------------------------------------------------------------------------
  93. interface NoteRow {
  94. person: string
  95. day: string
  96. did: string
  97. unsure: string
  98. need: string
  99. submitted_at: string
  100. updated_at: string
  101. revisions: number
  102. author_email: string
  103. }
  104. interface RevisionRow {
  105. person: string
  106. day: string
  107. revision: number
  108. did: string
  109. unsure: string
  110. need: string
  111. written_at: string
  112. }
  113. const toNote = (r: NoteRow): CheckinNote => ({
  114. person: r.person,
  115. day: r.day,
  116. did: r.did,
  117. unsure: r.unsure,
  118. need: r.need,
  119. submittedAt: r.submitted_at,
  120. updatedAt: r.updated_at,
  121. revisions: r.revisions,
  122. authorEmail: r.author_email,
  123. })
  124. const toRevision = (r: RevisionRow): CheckinRevision => ({
  125. person: r.person,
  126. day: r.day,
  127. revision: r.revision,
  128. did: r.did,
  129. unsure: r.unsure,
  130. need: r.need,
  131. writtenAt: r.written_at,
  132. })
  133. /**
  134. * One person's notes for the window the card will render, plus the superseded
  135. * versions inside it.
  136. *
  137. * The window comes from `checkinWindow`, the same function the card uses to
  138. * decide which days to list. Two independently-computed windows would drift,
  139. * and the failure would be silent and backwards: a day with a note rendering as
  140. * a day she missed.
  141. */
  142. export async function readCheckins(
  143. env: CheckinEnv,
  144. person: string,
  145. now: Date = new Date(),
  146. ): Promise<CheckinStore> {
  147. if (!env.CHECKINS) return { state: 'unconfigured' }
  148. const { from, limit } = checkinWindow(person, checkinDay(now, MERIDIAN_TZ))
  149. try {
  150. const [notes, earlier] = await env.CHECKINS.batch([
  151. env.CHECKINS.prepare(
  152. `SELECT person, day, did, unsure, need, submitted_at, updated_at, revisions, author_email
  153. FROM checkin
  154. WHERE person = ?1 AND day >= ?2
  155. ORDER BY day DESC
  156. LIMIT ?3`,
  157. ).bind(person, from, limit),
  158. // The superseded versions only. `revision < revisions` is not expressible
  159. // here without a join, so every revision in the window comes back and the
  160. // card drops the one that matches what is current (a handful of rows).
  161. env.CHECKINS.prepare(
  162. `SELECT person, day, revision, did, unsure, need, written_at
  163. FROM checkin_revision
  164. WHERE person = ?1 AND day >= ?2
  165. ORDER BY day DESC, revision DESC`,
  166. ).bind(person, from),
  167. ])
  168. const noteRows = (notes?.results ?? []) as unknown as NoteRow[]
  169. return {
  170. state: 'ok',
  171. notes: noteRows.map(toNote),
  172. earlier: ((earlier?.results ?? []) as unknown as RevisionRow[]).map(toRevision),
  173. // Rule 5: what we have, plus the fact that there was more.
  174. truncated: noteRows.length >= limit,
  175. }
  176. } catch (err) {
  177. // The store's own words and no diagnosis (rule 1): "no such table:
  178. // checkin" is a fact; "you forgot the migration" is a guess the reader
  179. // can make themselves.
  180. return { state: 'failed', error: errorMessage(err ?? 'unknown error') }
  181. }
  182. }
  183. /** Every person this viewer may read, each with its own state. */
  184. export async function readCheckinsFor(
  185. env: CheckinEnv,
  186. people: string[],
  187. now: Date = new Date(),
  188. ): Promise<Record<string, CheckinStore>> {
  189. const entries = await Promise.all(
  190. people.map(async (p) => [p, await readCheckins(env, p, now)] as const),
  191. )
  192. return Object.fromEntries(entries)
  193. }
  194. // --- writing ------------------------------------------------------------------------
  195. export type CheckinResult = { outcome: CheckinOutcome; day: string }
  196. /**
  197. * Store one submission. Creates, updates, or does nothing.
  198. *
  199. * An identical resubmit writes nothing. A double-click, or a reload of a POST,
  200. * would otherwise add a second version of text that never changed, and the card
  201. * would tell a reader the note had been edited when it had not. Comparing the
  202. * three strings is cheaper than explaining that chip.
  203. *
  204. * The two writes go together or not at all. `batch()` runs them in one
  205. * transaction, so there is no state where the current note has moved on and the
  206. * append-only log has not, which would make the log a record of some of the
  207. * versions, which is not a record.
  208. */
  209. export async function saveCheckin(
  210. env: CheckinEnv,
  211. input: CheckinInput,
  212. now: Date = new Date(),
  213. ): Promise<CheckinResult> {
  214. const decision = decideCheckin(input, now)
  215. if (!decision.write) return { outcome: decision.outcome, day: decision.day }
  216. const { day, answers } = decision
  217. if (!env.CHECKINS) return { outcome: 'failed', day }
  218. const at = now.toISOString()
  219. try {
  220. const existing = await env.CHECKINS.prepare(
  221. `SELECT did, unsure, need, revisions FROM checkin WHERE person = ?1 AND day = ?2`,
  222. )
  223. .bind(input.person, day)
  224. .first<{ did: string; unsure: string; need: string; revisions: number }>()
  225. if (
  226. existing &&
  227. CHECKIN_QUESTIONS.every((q) => String(existing[q.id] ?? '') === answers[q.id])
  228. ) {
  229. return { outcome: 'unchanged', day }
  230. }
  231. const revision = (existing?.revisions ?? 0) + 1
  232. await env.CHECKINS.batch([
  233. env.CHECKINS.prepare(
  234. `INSERT INTO checkin
  235. (person, author_email, day, day_tz, did, unsure, need, submitted_at, updated_at, revisions)
  236. VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8, 1)
  237. ON CONFLICT (person, day) DO UPDATE SET
  238. did = excluded.did,
  239. unsure = excluded.unsure,
  240. need = excluded.need,
  241. updated_at = excluded.updated_at,
  242. author_email = excluded.author_email,
  243. revisions = checkin.revisions + 1`,
  244. // submitted_at is NOT touched on conflict: it is when she first wrote
  245. // one that day, and the reader's "written at 6:40 PM" means that.
  246. ).bind(
  247. input.person,
  248. input.authorEmail,
  249. day,
  250. MERIDIAN_TZ,
  251. answers.did,
  252. answers.unsure,
  253. answers.need,
  254. at,
  255. ),
  256. env.CHECKINS.prepare(
  257. `INSERT INTO checkin_revision
  258. (person, day, revision, did, unsure, need, author_email, written_at)
  259. VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`,
  260. ).bind(
  261. input.person,
  262. day,
  263. revision,
  264. answers.did,
  265. answers.unsure,
  266. answers.need,
  267. input.authorEmail,
  268. at,
  269. ),
  270. ])
  271. return { outcome: existing ? 'updated' : 'saved', day }
  272. } catch {
  273. // The page must say the note was not saved, and it must say it loudly:
  274. // this is the one action here whose failure costs somebody the thing they
  275. // just typed. The store's message is not shown for a write: it lands on the
  276. // author's screen mid-task, where "D1_ERROR: near ..." is noise. What she
  277. // needs is "it did not save, keep your text".
  278. return { outcome: 'failed', day }
  279. }
  280. }