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

own-list.ts

Language TypeScript14.8 KB390 lines

apps/worker/src/own-list.ts

  1. /**
  2. * The private list's storage: the decision, the SQL, and the states a read can
  3. * be in.
  4. *
  5. * The decision is a pure function and the route is around it, the same shape as
  6. * decideCheckin and for the same reason: `/own` sits behind Cloudflare Access
  7. * and the test suite cannot mint an assertion for it, so every refusal has to
  8. * be reachable without the route.
  9. *
  10. * Every statement here carries `person`, and it is never taken from a request
  11. * (rule 15). There is no grant list guarding this table (see
  12. * config/own-list.ts for why rule 13 does not apply to a store with one
  13. * reader), so the scoping clause is the access control. A row id in a form post is a handle, not an
  14. * authorisation: an id belonging to somebody else matches zero rows and comes
  15. * back `notfound`, which is also exactly what a deleted row looks like, so the
  16. * answer reveals nothing either way.
  17. *
  18. * The read takes one name, not a list. Deliberately not shaped like
  19. * readCheckinsFor(env, people[]); there is no plural entry point, so a future
  20. * "let someone see everyone's" has to add a function rather than pass a longer
  21. * array, and that function would have to be reviewed.
  22. */
  23. import { MERIDIAN_TZ } from '@meridian/ninety-client'
  24. import {
  25. OWN_DONE_SHOWN,
  26. OWN_OPEN_CAP,
  27. OWN_TEXT_CAP,
  28. isOwnPriority,
  29. type OwnPriority,
  30. } from '../../../config/own-list.js'
  31. import { WORK_KEY_RE } from '../../dashboard/src/work-key.js'
  32. import type { OwnItem, OwnOutcome, OwnStore } from '../../dashboard/src/own-list-view.js'
  33. import { errorMessage } from '@meridian/shared'
  34. /**
  35. * Just the binding, so the handlers can be called with a fake database.
  36. *
  37. * Optional, and that is a decision rather than an oversight, the same one
  38. * CheckinEnv makes. A Worker deployed before the migration is applied must
  39. * serve the whole board with this card saying the store is not set up, not
  40. * throw, and above all not show an empty list that reads as "you have never
  41. * written anything".
  42. *
  43. * It is the check-in binding. One D1 database holds both tables; the name is
  44. * wider than its first table, and 0002_own_list.sql records why it has not
  45. * been renamed.
  46. */
  47. export interface OwnListEnv {
  48. CHECKINS?: D1Database
  49. }
  50. /** Everything the route hands in, unparsed except for the person. */
  51. export interface OwnInput {
  52. /** From the verified identity, via ownListPersonFor. Never from the form. */
  53. person: string
  54. authorEmail: string
  55. action: string
  56. id: string
  57. /** The `updated_at` the form was rendered with. '' for an add. */
  58. seen: string
  59. text: string
  60. due: string
  61. priority: string
  62. link: string
  63. }
  64. export interface OwnFields {
  65. text: string
  66. due: string | null
  67. priority: OwnPriority
  68. linkKey: string | null
  69. }
  70. export type OwnDecision =
  71. | { write: 'add'; fields: OwnFields }
  72. | { write: 'edit'; id: number; seen: string; fields: OwnFields }
  73. | { write: 'done' | 'reopen'; id: number; seen: string }
  74. | { write: false; outcome: OwnOutcome }
  75. interface ItemRow {
  76. id: number
  77. text: string
  78. due: string | null
  79. priority: string
  80. link_key: string | null
  81. created_at: string
  82. updated_at: string
  83. edits: number
  84. done_at: string | null
  85. }
  86. const toItem = (r: ItemRow): OwnItem => ({
  87. id: r.id,
  88. text: r.text,
  89. due: r.due,
  90. // The column is CHECK-constrained to the same three values, so anything else
  91. // could not have been written. Narrowed rather than asserted so a row written
  92. // by hand against a loosened schema renders as normal instead of as nothing.
  93. priority: isOwnPriority(r.priority) ? (r.priority as OwnPriority) : 'normal',
  94. linkKey: r.link_key,
  95. createdAt: r.created_at,
  96. updatedAt: r.updated_at,
  97. edits: r.edits,
  98. doneAt: r.done_at,
  99. })
  100. /** A real calendar date, not merely a well-shaped string. */
  101. function validDay(raw: string): boolean {
  102. if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return false
  103. const d = new Date(`${raw}T00:00:00Z`)
  104. // 2026-02-31 parses and rolls over to March; comparing back catches it.
  105. return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === raw
  106. }
  107. /**
  108. * Normalize what somebody typed into the link box, without guessing.
  109. *
  110. * A bare order number gets the prefix it obviously wants, because typing
  111. * `MD2639582` and being refused would be a papercut with no upside. Anything
  112. * that still does not match the shape is refused rather than coerced: the
  113. * difference between convenience and inference is that convenience cannot be
  114. * wrong about what you meant.
  115. */
  116. export function normalizeLink(raw: string): string | null | 'bad' {
  117. const v = raw.trim()
  118. if (!v) return null
  119. const prefixed = /^(order|task):/i.test(v) ? v : /^\d+$/.test(v) ? `task:${v}` : `order:${v}`
  120. const key = prefixed.replace(/^(ORDER|TASK|Order|Task):/, (m) => m.toLowerCase())
  121. return WORK_KEY_RE.test(key) ? key : 'bad'
  122. }
  123. /**
  124. * Decide what a submission means. No I/O at all.
  125. *
  126. * Every refusal has its own outcome and nothing is defaulted (rule 2). An
  127. * unknown priority is refused rather than quietly becoming `normal`: the form
  128. * always sends one, so an absent one means the post was not the form, and a
  129. * default here would stand in for knowledge nobody has.
  130. *
  131. * A past due date is allowed. You can log something that is already late, and
  132. * refusing it would lose the text for the sake of tidiness.
  133. */
  134. export function decideOwn(input: OwnInput): OwnDecision {
  135. const action = input.action.trim()
  136. if (action !== 'add' && action !== 'edit' && action !== 'done' && action !== 'reopen') {
  137. return { write: false, outcome: 'badaction' }
  138. }
  139. if (action === 'done' || action === 'reopen' || action === 'edit') {
  140. const id = Number(input.id.trim())
  141. if (!Number.isInteger(id) || id <= 0) return { write: false, outcome: 'badid' }
  142. const seen = input.seen.trim()
  143. if (!seen) return { write: false, outcome: 'badid' }
  144. if (action !== 'edit') return { write: action, id, seen }
  145. const fields = fieldsFrom(input)
  146. return 'outcome' in fields ? { write: false, outcome: fields.outcome } : { write: 'edit', id, seen, fields }
  147. }
  148. const fields = fieldsFrom(input)
  149. return 'outcome' in fields ? { write: false, outcome: fields.outcome } : { write: 'add', fields }
  150. }
  151. function fieldsFrom(input: OwnInput): OwnFields | { outcome: OwnOutcome } {
  152. const text = input.text.trim()
  153. if (!text) return { outcome: 'empty' }
  154. // Refused, not truncated. Silently storing the first 500 characters of what
  155. // somebody typed is the failure mode rule 5 names from the other direction:
  156. // the page would show a sentence that stops mid-word and nothing would say
  157. // why. Refusing costs a retry with the text still in the box.
  158. if (text.length > OWN_TEXT_CAP) return { outcome: 'toolong' }
  159. const dueRaw = input.due.trim()
  160. if (dueRaw && !validDay(dueRaw)) return { outcome: 'baddate' }
  161. const priority = input.priority.trim()
  162. if (!isOwnPriority(priority)) return { outcome: 'badpriority' }
  163. const link = normalizeLink(input.link)
  164. if (link === 'bad') return { outcome: 'badlink' }
  165. return { text, due: dueRaw || null, priority, linkKey: link }
  166. }
  167. // --- reading ------------------------------------------------------------------------
  168. const OPEN_SQL = `SELECT id, text, due, priority, link_key, created_at, updated_at, edits, done_at
  169. FROM own_item
  170. WHERE person = ?1 AND done_at IS NULL
  171. ORDER BY (due IS NULL) ASC, due ASC, id ASC
  172. LIMIT ?2`
  173. const DONE_SQL = `SELECT id, text, due, priority, link_key, created_at, updated_at, edits, done_at
  174. FROM own_item
  175. WHERE person = ?1 AND done_at IS NOT NULL
  176. ORDER BY done_at DESC
  177. LIMIT ?2`
  178. const DONE_COUNT_SQL = `SELECT count(*) AS n FROM own_item WHERE person = ?1 AND done_at IS NOT NULL`
  179. /**
  180. * One person's list.
  181. *
  182. * `(due IS NULL) ASC` because SQLite sorts NULLs first and "no date" has to
  183. * sort last. The card sorts again with sortOpen() (the same order, expressed
  184. * once in each language) because the grouping and the ordering must come from
  185. * one definition, and the card is the thing a reader is actually looking at.
  186. *
  187. * Both LIMITs are the cap plus one, the same trick as the check-in read: `truncated` can then
  188. * only fire if something bypassed the add cap, and a short list says it is
  189. * short rather than passing itself off as the whole thing (rule 5).
  190. */
  191. export async function readOwnList(
  192. env: OwnListEnv,
  193. person: string,
  194. ): Promise<OwnStore> {
  195. if (!env.CHECKINS) return { state: 'unconfigured' }
  196. try {
  197. const [open, done, doneCount] = await env.CHECKINS.batch([
  198. env.CHECKINS.prepare(OPEN_SQL).bind(person, OWN_OPEN_CAP + 1),
  199. env.CHECKINS.prepare(DONE_SQL).bind(person, OWN_DONE_SHOWN),
  200. env.CHECKINS.prepare(DONE_COUNT_SQL).bind(person),
  201. ])
  202. const openRows = (open?.results ?? []) as unknown as ItemRow[]
  203. const doneRows = (done?.results ?? []) as unknown as ItemRow[]
  204. const total = Number(((doneCount?.results ?? []) as { n?: number }[])[0]?.n ?? doneRows.length)
  205. return {
  206. state: 'ok',
  207. person,
  208. open: openRows.slice(0, OWN_OPEN_CAP).map(toItem),
  209. done: doneRows.map(toItem),
  210. truncated: openRows.length > OWN_OPEN_CAP,
  211. // Counted rather than inferred from the page size: "50 shown of 63" is a
  212. // fact the card states, and inferring it from a full page would say
  213. // "more" when there were exactly fifty.
  214. doneHidden: Math.max(0, total - doneRows.length),
  215. }
  216. } catch (err) {
  217. // The store's own words and no diagnosis (rule 1): "no such table:
  218. // own_item" is a fact; "you forgot the migration" is a guess the reader
  219. // can make themselves.
  220. return { state: 'failed', error: errorMessage(err ?? 'unknown error') }
  221. }
  222. }
  223. // --- writing ------------------------------------------------------------------------
  224. export interface OwnResult {
  225. outcome: OwnOutcome
  226. }
  227. /**
  228. * Apply one submission.
  229. *
  230. * The stale token. Every action on an existing line carries the `updated_at`
  231. * the form was rendered with, and the statement includes `AND updated_at = ?`.
  232. * This is decideCheckin's claimedDay moved from a day to a row: a tab left open
  233. * while the line changed somewhere else acts on a version the person is no
  234. * longer looking at. Refusing costs a reload; guessing costs the record.
  235. *
  236. * When nothing matched, one follow-up SELECT tells the two cases apart (no row
  237. * at all is `notfound`, a row with a different token is `stale`), and that
  238. * second query only ever runs on the failure path.
  239. */
  240. export async function saveOwn(
  241. env: OwnListEnv,
  242. input: OwnInput,
  243. now: Date = new Date(),
  244. ): Promise<OwnResult> {
  245. const decision = decideOwn(input)
  246. if (!decision.write) return { outcome: decision.outcome }
  247. // An unconfigured store collapses into `failed` on a write, unlike on a read:
  248. // what somebody mid-task needs told is "it did not save", not which of the
  249. // two reasons it did not.
  250. if (!env.CHECKINS) return { outcome: 'failed' }
  251. const db = env.CHECKINS
  252. const at = now.toISOString()
  253. try {
  254. if (decision.write === 'add') {
  255. const { n } = (await db
  256. .prepare(`SELECT count(*) AS n FROM own_item WHERE person = ?1 AND done_at IS NULL`)
  257. .bind(input.person)
  258. .first<{ n: number }>()) ?? { n: 0 }
  259. // Refuses over the cap instead of trimming (rule 5, and RECIPIENT_CAP's
  260. // rule in rule 12). Trimming would throw away the thing just typed, which
  261. // is the single outcome this feature exists to prevent.
  262. if (n >= OWN_OPEN_CAP) return { outcome: 'full' }
  263. const f = decision.fields
  264. await db
  265. .prepare(
  266. `INSERT INTO own_item
  267. (person, author_email, text, due, priority, link_key,
  268. created_at, updated_at, edits, done_at, tz)
  269. VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, 0, NULL, ?8)`,
  270. )
  271. .bind(input.person, input.authorEmail, f.text, f.due, f.priority, f.linkKey, at, MERIDIAN_TZ)
  272. .run()
  273. return { outcome: 'added' }
  274. }
  275. if (decision.write === 'edit') {
  276. const f = decision.fields
  277. const current = await db
  278. .prepare(
  279. `SELECT text, due, priority, link_key FROM own_item WHERE id = ?1 AND person = ?2`,
  280. )
  281. .bind(decision.id, input.person)
  282. .first<{ text: string; due: string | null; priority: string; link_key: string | null }>()
  283. // An unchanged edit writes nothing, so `edits` cannot tick for a no-op
  284. // and the row cannot come back saying "edited" when it was not, the same
  285. // call saveCheckin makes by comparing before it writes.
  286. if (
  287. current &&
  288. current.text === f.text &&
  289. current.due === f.due &&
  290. current.priority === f.priority &&
  291. current.link_key === f.linkKey
  292. ) {
  293. return { outcome: 'unchanged' }
  294. }
  295. const res = await db
  296. .prepare(
  297. `UPDATE own_item
  298. SET text = ?3, due = ?4, priority = ?5, link_key = ?6,
  299. updated_at = ?7, edits = edits + 1
  300. WHERE id = ?1 AND person = ?2 AND updated_at = ?8`,
  301. )
  302. .bind(decision.id, input.person, f.text, f.due, f.priority, f.linkKey, at, decision.seen)
  303. .run()
  304. return { outcome: changed(res) ? 'edited' : await whyNot(db, decision.id, input.person, decision.seen) }
  305. }
  306. const done = decision.write === 'done'
  307. const res = await db
  308. .prepare(
  309. done
  310. ? `UPDATE own_item SET done_at = ?3, updated_at = ?3
  311. WHERE id = ?1 AND person = ?2 AND done_at IS NULL AND updated_at = ?4`
  312. : `UPDATE own_item SET done_at = NULL, updated_at = ?3
  313. WHERE id = ?1 AND person = ?2 AND done_at IS NOT NULL AND updated_at = ?4`,
  314. )
  315. .bind(decision.id, input.person, at, decision.seen)
  316. .run()
  317. return {
  318. outcome: changed(res)
  319. ? done
  320. ? 'completed'
  321. : 'reopened'
  322. : await whyNot(db, decision.id, input.person, decision.seen),
  323. }
  324. } catch {
  325. // The page must say it was not saved, loudly. The store's own message is
  326. // deliberately not shown for a write: it lands on somebody's screen
  327. // mid-task, where "D1_ERROR: near ..." is noise. What they need is "it did
  328. // not save, keep your text".
  329. return { outcome: 'failed' }
  330. }
  331. }
  332. /** D1 reports rows touched in `meta.changes`; a stand-in may not. */
  333. function changed(res: unknown): boolean {
  334. const n = (res as { meta?: { changes?: number } } | null)?.meta?.changes
  335. return typeof n === 'number' ? n > 0 : false
  336. }
  337. /**
  338. * Why an update matched nothing: the row is gone, or it moved on.
  339. *
  340. * `notfound` is also what somebody else's row looks like, and the wording is
  341. * correct for both without saying which: `person` is in the WHERE of both this
  342. * query and the update it is explaining, so a guessed id belonging to a
  343. * colleague is indistinguishable from one that was never there.
  344. */
  345. async function whyNot(
  346. db: D1Database,
  347. id: number,
  348. person: string,
  349. seen: string,
  350. ): Promise<OwnOutcome> {
  351. const row = await db
  352. .prepare(`SELECT updated_at FROM own_item WHERE id = ?1 AND person = ?2`)
  353. .bind(id, person)
  354. .first<{ updated_at: string }>()
  355. if (!row) return 'notfound'
  356. return row.updated_at === seen ? 'notfound' : 'stale'
  357. }