own-list.ts
Language TypeScript6.4 KB156 lines
/*** The private list: what each person logs for themselves, and nobody else sees.** The board reads five systems and shows each person what those systems say* they owe; nothing on it could hold a thing that lives in none of them. This* list is where each person logs those, persistently, so they are not missed.** It sits in the My Day card, beside the orders, Bitrix tasks, Ninety* to-dos and tracker rows the sources already attribute to them: one card, one* place to look. It is not shared between people. Nobody can read* anybody else's list, and there is no count, tile or rollup anywhere on a* shared surface that is derived from one.** This file has no grant map, and that is not rule 13 being ignored (rule 15).** Rule 13 says an access list is not a default you can guess, and every* permission here defaults to false, because getting less than you should is a* bug report and getting more is an incident. Every word of that is about* permission over somebody else's things. INBOX_READERS guards colleagues'* mail; CHECKIN_READERS guards one person's daily "what I'm unsure about".** A private list has exactly one reader and it is its author. There is nobody* to over-grant to, so there is no grant to write down, and a hand-written* list here could only withhold a person's own list from them. A new hire* getting an empty private list the day they join the roster is the correct* default, not a widening.** What replaces the grant map is the scoping clause. Every read takes one name,* every write carries `AND person = ?`, and the name comes from the verified* identity rather than from anything a request can set. See* apps/worker/src/own-list.ts.** Runtime-neutral: no `node:` imports, because the Worker reads this (rule 7).*/import { MERIDIAN_PEOPLE } from './meridian-people.js'import type { Viewer } from '../apps/dashboard/src/viewer.js'/*** How long one line may be.** Shorter than a note's 2000 (NOTE_TEXT_CAP) on purpose: a note explains why an* order is where it is and wants room; a line on a list is a thing to do and* stays scannable. The number is repeated as a table CHECK in* apps/worker/migrations/0002_own_list.sql, and a test asserts the two agree:* a storage layer that accepts what the route refuses is a storage layer that* will eventually hold something nothing renders.*/export const OWN_TEXT_CAP = 500/*** How many open lines one person may hold.** Refuses over the cap, never trims (rule 5, and RECIPIENT_CAP's rule in rule* 12). An add past this writes nothing and says so. Trimming would silently* throw away the thing somebody just typed, which is the one outcome this* feature exists to prevent.** It is high enough that nobody will meet it in normal use and low enough that* a runaway script cannot fill the table.*/export const OWN_OPEN_CAP = 200/** How many completed lines the folded Done block shows. */export const OWN_DONE_SHOWN = 50/*** The priority domain, in the order it sorts.** Three values because a fourth turns the picker into a decision, and the ask* was "log things", not "triage things". The same three are a CHECK in the* migration; `ownPriorityOrder` below is the only place the ordering is* defined, so the domain and its order cannot drift apart.*/export const OWN_PRIORITIES = ['high', 'normal', 'low'] as constexport type OwnPriority = (typeof OWN_PRIORITIES)[number]/** What each priority is called on the page. */export const OWN_PRIORITY_LABEL: Record<OwnPriority, string> = {high: 'High',normal: 'Normal',low: 'Low',}export function isOwnPriority(raw: string): raw is OwnPriority {return (OWN_PRIORITIES as readonly string[]).includes(raw)}/** Sort rank. Lower sorts first. */export function ownPriorityOrder(p: OwnPriority): number {return OWN_PRIORITIES.indexOf(p)}/*** The name this viewer's list is filed under, or null if they have none.** The roster display name, not the address, and the migration's `person` column* comment has the full argument. The short version: most addresses are role* mailboxes, and keyed by address the next holder of `accounts@` would open* the board and read the previous holder's private list.** An authenticated address the roster does not know gets `COMPANY_VIEWER` with* its email carried but `person: null`: a shared mailbox rather than a person.* It returns null here, and it gets a sentence saying so rather than a list: a* private list on `help@` is a list everyone who signs into that mailbox can* read, which is the opposite of what this is.*/export function ownListPersonFor(viewer: Viewer): string | null {return viewer.person?.name ?? null}export const hasOwnList = (viewer: Viewer): boolean => ownListPersonFor(viewer) !== null// --- load-time invariants -----------------------------------------------------------//// Thinner than config/checkin.ts's, and the reason is worth stating: those// validate a hand-written map against the roster, and there is no map here to// get wrong. What is left is the handful of constants the storage layer also// encodes, checked so that a change to one that is not a change to the other// fails at import rather than on somebody's write.if (OWN_TEXT_CAP < 1 || OWN_OPEN_CAP < 1 || OWN_DONE_SHOWN < 1) {throw new Error('config/own-list.ts: caps must be at least 1')}if (new Set(OWN_PRIORITIES).size !== OWN_PRIORITIES.length) {throw new Error('config/own-list.ts: OWN_PRIORITIES must not repeat a value')}for (const p of OWN_PRIORITIES) {if (!OWN_PRIORITY_LABEL[p]) {throw new Error(`config/own-list.ts: no label for priority ${p}`)}}/*** Everyone on the roster can be filed under a distinct name.** `person` is the only scoping clause on this table, so two people sharing a* display name would share a private list. That is the single worst thing this* feature could do, and it is one comparison to rule out.*/{const names = MERIDIAN_PEOPLE.map((p) => p.name)const dupes = names.filter((n, i) => names.indexOf(n) !== i)if (dupes.length) {throw new Error(`config/meridian-people.ts: duplicate display names ${[...new Set(dupes)].join(', ')}: ` +'the private list is filed under this name and two people sharing one would share a list',)}const blank = names.filter((n) => !n.trim())if (blank.length) {throw new Error('config/meridian-people.ts: a person has no display name; the private list needs one')}}