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

own-list.ts

Language TypeScript6.4 KB156 lines

config/own-list.ts

  1. /**
  2. * The private list: what each person logs for themselves, and nobody else sees.
  3. *
  4. * The board reads five systems and shows each person what those systems say
  5. * they owe; nothing on it could hold a thing that lives in none of them. This
  6. * list is where each person logs those, persistently, so they are not missed.
  7. *
  8. * It sits in the My Day card, beside the orders, Bitrix tasks, Ninety
  9. * to-dos and tracker rows the sources already attribute to them: one card, one
  10. * place to look. It is not shared between people. Nobody can read
  11. * anybody else's list, and there is no count, tile or rollup anywhere on a
  12. * shared surface that is derived from one.
  13. *
  14. * This file has no grant map, and that is not rule 13 being ignored (rule 15).
  15. *
  16. * Rule 13 says an access list is not a default you can guess, and every
  17. * permission here defaults to false, because getting less than you should is a
  18. * bug report and getting more is an incident. Every word of that is about
  19. * permission over somebody else's things. INBOX_READERS guards colleagues'
  20. * mail; CHECKIN_READERS guards one person's daily "what I'm unsure about".
  21. *
  22. * A private list has exactly one reader and it is its author. There is nobody
  23. * to over-grant to, so there is no grant to write down, and a hand-written
  24. * list here could only withhold a person's own list from them. A new hire
  25. * getting an empty private list the day they join the roster is the correct
  26. * default, not a widening.
  27. *
  28. * What replaces the grant map is the scoping clause. Every read takes one name,
  29. * every write carries `AND person = ?`, and the name comes from the verified
  30. * identity rather than from anything a request can set. See
  31. * apps/worker/src/own-list.ts.
  32. *
  33. * Runtime-neutral: no `node:` imports, because the Worker reads this (rule 7).
  34. */
  35. import { MERIDIAN_PEOPLE } from './meridian-people.js'
  36. import type { Viewer } from '../apps/dashboard/src/viewer.js'
  37. /**
  38. * How long one line may be.
  39. *
  40. * Shorter than a note's 2000 (NOTE_TEXT_CAP) on purpose: a note explains why an
  41. * order is where it is and wants room; a line on a list is a thing to do and
  42. * stays scannable. The number is repeated as a table CHECK in
  43. * apps/worker/migrations/0002_own_list.sql, and a test asserts the two agree:
  44. * a storage layer that accepts what the route refuses is a storage layer that
  45. * will eventually hold something nothing renders.
  46. */
  47. export const OWN_TEXT_CAP = 500
  48. /**
  49. * How many open lines one person may hold.
  50. *
  51. * Refuses over the cap, never trims (rule 5, and RECIPIENT_CAP's rule in rule
  52. * 12). An add past this writes nothing and says so. Trimming would silently
  53. * throw away the thing somebody just typed, which is the one outcome this
  54. * feature exists to prevent.
  55. *
  56. * It is high enough that nobody will meet it in normal use and low enough that
  57. * a runaway script cannot fill the table.
  58. */
  59. export const OWN_OPEN_CAP = 200
  60. /** How many completed lines the folded Done block shows. */
  61. export const OWN_DONE_SHOWN = 50
  62. /**
  63. * The priority domain, in the order it sorts.
  64. *
  65. * Three values because a fourth turns the picker into a decision, and the ask
  66. * was "log things", not "triage things". The same three are a CHECK in the
  67. * migration; `ownPriorityOrder` below is the only place the ordering is
  68. * defined, so the domain and its order cannot drift apart.
  69. */
  70. export const OWN_PRIORITIES = ['high', 'normal', 'low'] as const
  71. export type OwnPriority = (typeof OWN_PRIORITIES)[number]
  72. /** What each priority is called on the page. */
  73. export const OWN_PRIORITY_LABEL: Record<OwnPriority, string> = {
  74. high: 'High',
  75. normal: 'Normal',
  76. low: 'Low',
  77. }
  78. export function isOwnPriority(raw: string): raw is OwnPriority {
  79. return (OWN_PRIORITIES as readonly string[]).includes(raw)
  80. }
  81. /** Sort rank. Lower sorts first. */
  82. export function ownPriorityOrder(p: OwnPriority): number {
  83. return OWN_PRIORITIES.indexOf(p)
  84. }
  85. /**
  86. * The name this viewer's list is filed under, or null if they have none.
  87. *
  88. * The roster display name, not the address, and the migration's `person` column
  89. * comment has the full argument. The short version: most addresses are role
  90. * mailboxes, and keyed by address the next holder of `accounts@` would open
  91. * the board and read the previous holder's private list.
  92. *
  93. * An authenticated address the roster does not know gets `COMPANY_VIEWER` with
  94. * its email carried but `person: null`: a shared mailbox rather than a person.
  95. * It returns null here, and it gets a sentence saying so rather than a list: a
  96. * private list on `help@` is a list everyone who signs into that mailbox can
  97. * read, which is the opposite of what this is.
  98. */
  99. export function ownListPersonFor(viewer: Viewer): string | null {
  100. return viewer.person?.name ?? null
  101. }
  102. export const hasOwnList = (viewer: Viewer): boolean => ownListPersonFor(viewer) !== null
  103. // --- load-time invariants -----------------------------------------------------------
  104. //
  105. // Thinner than config/checkin.ts's, and the reason is worth stating: those
  106. // validate a hand-written map against the roster, and there is no map here to
  107. // get wrong. What is left is the handful of constants the storage layer also
  108. // encodes, checked so that a change to one that is not a change to the other
  109. // fails at import rather than on somebody's write.
  110. if (OWN_TEXT_CAP < 1 || OWN_OPEN_CAP < 1 || OWN_DONE_SHOWN < 1) {
  111. throw new Error('config/own-list.ts: caps must be at least 1')
  112. }
  113. if (new Set(OWN_PRIORITIES).size !== OWN_PRIORITIES.length) {
  114. throw new Error('config/own-list.ts: OWN_PRIORITIES must not repeat a value')
  115. }
  116. for (const p of OWN_PRIORITIES) {
  117. if (!OWN_PRIORITY_LABEL[p]) {
  118. throw new Error(`config/own-list.ts: no label for priority ${p}`)
  119. }
  120. }
  121. /**
  122. * Everyone on the roster can be filed under a distinct name.
  123. *
  124. * `person` is the only scoping clause on this table, so two people sharing a
  125. * display name would share a private list. That is the single worst thing this
  126. * feature could do, and it is one comparison to rule out.
  127. */
  128. {
  129. const names = MERIDIAN_PEOPLE.map((p) => p.name)
  130. const dupes = names.filter((n, i) => names.indexOf(n) !== i)
  131. if (dupes.length) {
  132. throw new Error(
  133. `config/meridian-people.ts: duplicate display names ${[...new Set(dupes)].join(', ')}: ` +
  134. 'the private list is filed under this name and two people sharing one would share a list',
  135. )
  136. }
  137. const blank = names.filter((n) => !n.trim())
  138. if (blank.length) {
  139. throw new Error('config/meridian-people.ts: a person has no display name; the private list needs one')
  140. }
  141. }