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

todos.ts

Language TypeScript13.9 KB355 lines

packages/ninety-client/src/resources/todos.ts

  1. /**
  2. * To-Dos. The only resource on the dashboard's critical path.
  3. *
  4. * Wire facts, confirmed against the live API:
  5. * - POST /todos/query returns a bare array
  6. * - `page` is 1-based (see the note on toWire below)
  7. * - `sort` takes a camelCase field name, e.g. "dueDate"; do not lowercase it
  8. * - `order` is lowercase "asc" | "desc"
  9. * - confirmed filters: teamId, isPersonal, completed, archived, searchText, title
  10. *
  11. * `userId` is not a filter on this endpoint: the OpenAPI document's
  12. * GetTodosQueryDTO has no such property. It
  13. * is applied client-side and always will be.
  14. *
  15. * For company-wide reads use company() / companyDetailed(), which hit
  16. * GET /todos/company and do not have this endpoint's per-user scope. Read the
  17. * comment on company() before adding any query parameter to it.
  18. */
  19. import type { HttpClient } from './../http.js'
  20. import { ZodError } from 'zod'
  21. import { NinetyError } from './../errors.js'
  22. import type { Roster } from './../roster.js'
  23. import {
  24. collect,
  25. fromArray,
  26. fromCursor,
  27. iterateAll,
  28. iterateCursor,
  29. type Page,
  30. type PageRequest,
  31. } from './../paginate.js'
  32. import {
  33. RawCompanyTodoSchema,
  34. RawTodoSchema,
  35. companyOwnerName,
  36. companyTodoToRaw,
  37. normalizeTodo,
  38. type NormalizeOptions,
  39. type RawCompanyTodo,
  40. type RawTodo,
  41. type Todo,
  42. } from './../types.js'
  43. /**
  44. * A row that could not be hydrated to the full record.
  45. *
  46. * It keeps its thin version rather than vanishing (a missing to-do is a worse
  47. * lie than a partial one), but the reason matters. A 429 means back off; a 403
  48. * means `GET /todos/{id}` does not inherit the elevation that
  49. * `GET /todos/company` has, which would be an architectural finding rather than
  50. * a transient error.
  51. */
  52. export interface HydrationFailure {
  53. id: string
  54. owner: string
  55. status: number | null
  56. message: string
  57. /**
  58. * True when the record was hydrated with some fields dropped; false when it
  59. * could not be hydrated at all and kept its thin index version.
  60. *
  61. * These are different severities and must not be reported as one number. A
  62. * degraded row has every field the board reads; a failed row is missing
  63. * seriesId, milestoneId and date churn. Calling both "failed" overstates the
  64. * problem, which is its own kind of inaccuracy.
  65. */
  66. degraded: boolean
  67. }
  68. /**
  69. * Turn a thrown error into one readable line.
  70. *
  71. * Zod stringifies to a multi-line JSON array, so the raw message's first line
  72. * is "[" and says nothing at all. A schema failure needs the field path: that is the difference between "Ninety added a field" and "this
  73. * record genuinely differs", and only one of those is our problem.
  74. */
  75. function describeFailure(err: unknown): string {
  76. if (err instanceof ZodError) {
  77. const issues = err.issues.slice(0, 3).map((i) => {
  78. const path = i.path.join('.') || '(root)'
  79. const got = 'received' in i ? ` got ${String(i.received)}` : ''
  80. return `${path}: ${i.message}${got}`
  81. })
  82. return `schema: ${issues.join(' | ')}${err.issues.length > 3 ? ` (+${err.issues.length - 3} more)` : ''}`
  83. }
  84. if (err instanceof Error) return err.message.split('\n')[0]!
  85. return String(err)
  86. }
  87. const ENDPOINT = '/todos/query'
  88. const COMPANY_ENDPOINT = '/todos/company'
  89. const MAX_PAGE_SIZE = 100
  90. const COMPANY_PAGE_SIZE = 100
  91. const DEFAULT_SORT = 'dueDate'
  92. export interface TodoQuery extends PageRequest {
  93. teamId?: string
  94. /** Not a server-side filter. Applied client-side by list(). */
  95. userId?: string
  96. /** Ninety default appears to be all; pass false to exclude personal to-dos. */
  97. isPersonal?: boolean
  98. /** Defaults to false: open items only, which is what a dashboard wants. */
  99. completed?: boolean
  100. /** Defaults to false. */
  101. archived?: boolean
  102. searchText?: string
  103. /** Exact title match. */
  104. title?: string
  105. /** ISO date. Applied client-side; no confirmed server-side equivalent. */
  106. dueBefore?: string
  107. }
  108. export class TodosResource {
  109. constructor(
  110. private readonly http: HttpClient,
  111. private readonly roster: Roster,
  112. /** Timezone and clock for date normalization. See dates.ts. */
  113. private readonly normalizeOpts: NormalizeOptions = {},
  114. ) {}
  115. /** One page, normalized and hydrated. */
  116. async query(q: TodoQuery = {}): Promise<Page<Todo>> {
  117. const page = q.page ?? 0
  118. const pageSize = Math.min(q.pageSize ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE)
  119. const ctx = { endpoint: `POST ${ENDPOINT}`, pageIndex: page }
  120. const raw = await this.http.post(ENDPOINT, { body: toWire(q, page, pageSize), pageIndex: page })
  121. const rawPage = fromArray<unknown>(raw, { page, pageSize }, ctx)
  122. const parsed = rawPage.items.map((item) => RawTodoSchema.parse(item))
  123. return { ...rawPage, items: await this.hydrate(parsed) }
  124. }
  125. /**
  126. * Every page. This is what the dashboard sync calls.
  127. *
  128. * Note it does not collapse recurring series; that is a presentation choice,
  129. * so it stays with the caller. See collapseSeries() in types.ts, and read the
  130. * comment there before counting anything.
  131. */
  132. async list(q: TodoQuery = {}): Promise<Todo[]> {
  133. const pageSize = Math.min(q.pageSize ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE)
  134. let all = await collect(
  135. iterateAll<Todo>(
  136. (req) => this.query({ ...q, page: req.page, pageSize: req.pageSize }),
  137. { page: q.page ?? 0, pageSize },
  138. ),
  139. )
  140. // Client-side because these filters are not confirmed server-side.
  141. if (q.userId) all = all.filter((t) => t.owner.id === q.userId)
  142. if (q.dueBefore) {
  143. const cutoff = Date.parse(q.dueBefore)
  144. all = all.filter((t) => t.dueDate !== null && Date.parse(t.dueDate) < cutoff)
  145. }
  146. // Ninety returns soft-deleted rows; never show them.
  147. return all.filter((t) => !t.deleted)
  148. }
  149. async get(id: string): Promise<Todo> {
  150. const raw = await this.http.get(`/todos/${encodeURIComponent(id)}`)
  151. const [one] = await this.hydrate([RawTodoSchema.parse(raw)])
  152. if (!one) throw new Error(`Todo ${id} normalized to nothing`)
  153. return one
  154. }
  155. /**
  156. * Like get(), but a schema mismatch downgrades the record rather than losing it.
  157. *
  158. * Strictness is right for the primary list (a wrong shape there means every
  159. * number is suspect), but hydration is enrichment. Refusing an enrichment
  160. * because one field drifted throws away the 30 fields that parsed fine, and
  161. * the row with them (rule 4).
  162. *
  163. * So: keep whatever validates, report what did not, and let the caller decide.
  164. * The reason still surfaces in `hydrationFailures`, so drift is visible rather
  165. * than absorbed. The point is to stop it being *costly*, not to stop noticing.
  166. */
  167. async getLenient(id: string): Promise<{ todo: Todo; issue: string | null }> {
  168. const raw = await this.http.get(`/todos/${encodeURIComponent(id)}`)
  169. const parsed = RawTodoSchema.safeParse(raw)
  170. if (parsed.success) {
  171. const [one] = await this.hydrate([parsed.data])
  172. if (!one) throw new Error(`Todo ${id} normalized to nothing`)
  173. return { todo: one, issue: null }
  174. }
  175. // Drop only the fields that failed, keep the rest, re-validate.
  176. const bad = new Set(parsed.error.issues.map((i) => String(i.path[0])).filter(Boolean))
  177. const pruned = Object.fromEntries(
  178. Object.entries(raw as Record<string, unknown>).filter(([k]) => !bad.has(k)),
  179. )
  180. const retry = RawTodoSchema.safeParse(pruned)
  181. if (!retry.success) throw parsed.error
  182. const [one] = await this.hydrate([retry.data])
  183. if (!one) throw new Error(`Todo ${id} normalized to nothing`)
  184. return { todo: one, issue: describeFailure(parsed.error) }
  185. }
  186. /**
  187. * Every open to-do in the company, from one token. The dashboard's whole
  188. * reason for existing.
  189. *
  190. * Requires Owner or Admin in Ninety; other roles get 403, which surfaces as
  191. * NinetyForbiddenError with a message that says what to ask for. One
  192. * company-scoped token rather than one per person means a single expired
  193. * token cannot silently erase one person from the dashboard.
  194. *
  195. * On sending no filters. Every boolean query param on this endpoint is either
  196. * useless or actively wrong, measured:
  197. *
  198. * (no filters) -> 31 rows, nextToken null <- correct
  199. * completed=false -> 0 rows
  200. * completed=true -> 0 rows
  201. * completed=false&archived=false -> 100 rows, nextToken set
  202. *
  203. * `completed=false` returns nothing because the field does not exist on an
  204. * open to-do (absent on every row), so equality against false
  205. * matches nothing. `completed=true` returns nothing because Meridian archives on
  206. * completion at the weekly review, and archived rows are excluded
  207. * by default. The last line is the genuinely broken one: passing explicit
  208. * `false` widens the result past the unfiltered default, which no correct
  209. * filter does.
  210. *
  211. * So: send nothing. The endpoint's defaults are exactly what a dashboard
  212. * wants, and every parameter that could change them is a trap. If you need
  213. * completed or archived history, that is a separate sweep with `archived` and
  214. * a date range, not a tweak to this call.
  215. */
  216. async company(opts: { maxPages?: number } = {}): Promise<Todo[]> {
  217. const rows = await collect(
  218. iterateCursor<RawCompanyTodo>(
  219. async (nextToken) => {
  220. const query: Record<string, string | number | undefined> = { limit: COMPANY_PAGE_SIZE }
  221. if (nextToken) query.nextToken = nextToken
  222. const raw = await this.http.get(COMPANY_ENDPOINT, { query })
  223. const page = fromCursor<unknown>(raw, { endpoint: `GET ${COMPANY_ENDPOINT}` })
  224. return { ...page, items: page.items.map((i) => RawCompanyTodoSchema.parse(i)) }
  225. },
  226. opts,
  227. ),
  228. )
  229. // Names ride along on every row, so the roster is fully resolved for free,
  230. // with no GET /users/{id} traffic at all on this path.
  231. for (const r of rows) {
  232. const name = companyOwnerName(r)
  233. if (r.userId && name) this.roster.note(r.userId, name)
  234. }
  235. return rows.map((r) =>
  236. normalizeTodo(companyTodoToRaw(r), (id) => this.roster.lookup(id), this.normalizeOpts),
  237. )
  238. }
  239. /**
  240. * company(), then hydrate each row to the full 33-field record.
  241. *
  242. * The index payload has no seriesId, milestoneId, completedDate,
  243. * originalDueDate or dueDateChanged, so anything that collapses recurrence,
  244. * links a to-do to its rock, or measures due-date churn needs this instead.
  245. *
  246. * Cost is one request per row: 31 rows is about a second and a half at the default
  247. * 20 rps, paced by the token bucket. Cheap enough to run per sync, too
  248. * expensive to run per page load, so cache it and show `syncedAt`.
  249. *
  250. * A row that fails to hydrate keeps its thin version rather than vanishing.
  251. * A missing to-do is a worse lie than a partial one, and `hydrationFailures`
  252. * on the snapshot is how you find out it happened.
  253. */
  254. async companyDetailed(
  255. opts: { maxPages?: number } = {},
  256. ): Promise<{ todos: Todo[]; hydrationFailures: HydrationFailure[] }> {
  257. const thin = await this.company(opts)
  258. const failures: HydrationFailure[] = []
  259. const todos = await Promise.all(
  260. thin.map(async (t) => {
  261. try {
  262. const { todo, issue } = await this.getLenient(t.id)
  263. // Degraded, not lost: the record hydrated with some fields dropped.
  264. // Still reported, because silent drift becomes permanent drift.
  265. if (issue) {
  266. failures.push({ id: t.id, owner: t.owner.name, status: null, message: issue, degraded: true })
  267. }
  268. return todo
  269. } catch (err) {
  270. // Record WHY, not just which. A bare list of ids cannot distinguish
  271. // "rate limited, retry" from "the schema rejected this record", and
  272. // those need opposite responses.
  273. failures.push({
  274. id: t.id,
  275. owner: t.owner.name,
  276. status: err instanceof NinetyError ? (err.status ?? null) : null,
  277. message: describeFailure(err),
  278. degraded: false,
  279. })
  280. return t
  281. }
  282. }),
  283. )
  284. return { todos, hydrationFailures: failures }
  285. }
  286. /**
  287. * Resolve owner and creator ids to names before returning. Keeps ObjectIds out
  288. * of the dashboard and out of the model's context.
  289. *
  290. * The cheap part: every to-do carries `createdBy` as a display name next to
  291. * `createdByUserId`. Recording those pairs costs nothing and means most of the
  292. * roster is already known before a single GET /users/{id} goes out.
  293. */
  294. private async hydrate(raws: RawTodo[]): Promise<Todo[]> {
  295. for (const r of raws) {
  296. if (r.createdByUserId && r.createdBy) this.roster.note(r.createdByUserId, r.createdBy)
  297. }
  298. const ids = raws.flatMap((r) => [r.userId, r.createdByUserId]).filter((v): v is string => !!v)
  299. await this.roster.resolveMany(ids)
  300. return raws.map((r) => normalizeTodo(r, (id) => this.roster.lookup(id), this.normalizeOpts))
  301. }
  302. }
  303. /**
  304. * The one place page translation and wire-field naming happens.
  305. *
  306. * On the 1-based page. A live query with an exact-title filter matching two
  307. * to-dos, sent with `page: 1` and `pageSize: 25`, returned both rows. If the
  308. * parameter were 0-based, `page: 1` would be the second page and would have come
  309. * back empty. So `page: 1` is the first page. Callers here are 0-based; the +1
  310. * lives on this line and nowhere else.
  311. *
  312. * On sort casing. `sort` takes a camelCase field name ("dueDate").
  313. * Lowercasing it, as a third-party SDK's docs suggest, silently mis-sorts
  314. * every list.
  315. */
  316. export function toWire(q: TodoQuery, page: number, pageSize: number): Record<string, unknown> {
  317. const body: Record<string, unknown> = {
  318. page: page + 1,
  319. pageSize,
  320. sort: q.sortBy ?? DEFAULT_SORT,
  321. order: q.sortDir ?? 'asc',
  322. // Open items unless asked otherwise. A dashboard asking for "to-dos" means
  323. // open ones, and defaulting the other way makes every count wrong.
  324. completed: q.completed ?? false,
  325. archived: q.archived ?? false,
  326. }
  327. if (q.teamId) body.teamId = q.teamId
  328. if (q.isPersonal !== undefined) body.isPersonal = q.isPersonal
  329. if (q.searchText) body.searchText = q.searchText
  330. if (q.title) body.title = q.title
  331. return body
  332. }