todos.ts
Language TypeScript13.9 KB355 lines
/*** To-Dos. The only resource on the dashboard's critical path.** Wire facts, confirmed against the live API:* - POST /todos/query returns a bare array* - `page` is 1-based (see the note on toWire below)* - `sort` takes a camelCase field name, e.g. "dueDate"; do not lowercase it* - `order` is lowercase "asc" | "desc"* - confirmed filters: teamId, isPersonal, completed, archived, searchText, title** `userId` is not a filter on this endpoint: the OpenAPI document's* GetTodosQueryDTO has no such property. It* is applied client-side and always will be.** For company-wide reads use company() / companyDetailed(), which hit* GET /todos/company and do not have this endpoint's per-user scope. Read the* comment on company() before adding any query parameter to it.*/import type { HttpClient } from './../http.js'import { ZodError } from 'zod'import { NinetyError } from './../errors.js'import type { Roster } from './../roster.js'import {collect,fromArray,fromCursor,iterateAll,iterateCursor,type Page,type PageRequest,} from './../paginate.js'import {RawCompanyTodoSchema,RawTodoSchema,companyOwnerName,companyTodoToRaw,normalizeTodo,type NormalizeOptions,type RawCompanyTodo,type RawTodo,type Todo,} from './../types.js'/*** A row that could not be hydrated to the full record.** It keeps its thin version rather than vanishing (a missing to-do is a worse* lie than a partial one), but the reason matters. A 429 means back off; a 403* means `GET /todos/{id}` does not inherit the elevation that* `GET /todos/company` has, which would be an architectural finding rather than* a transient error.*/export interface HydrationFailure {id: stringowner: stringstatus: number | nullmessage: string/*** True when the record was hydrated with some fields dropped; false when it* could not be hydrated at all and kept its thin index version.** These are different severities and must not be reported as one number. A* degraded row has every field the board reads; a failed row is missing* seriesId, milestoneId and date churn. Calling both "failed" overstates the* problem, which is its own kind of inaccuracy.*/degraded: boolean}/*** Turn a thrown error into one readable line.** Zod stringifies to a multi-line JSON array, so the raw message's first line* is "[" and says nothing at all. A schema failure needs the field path: that is the difference between "Ninety added a field" and "this* record genuinely differs", and only one of those is our problem.*/function describeFailure(err: unknown): string {if (err instanceof ZodError) {const issues = err.issues.slice(0, 3).map((i) => {const path = i.path.join('.') || '(root)'const got = 'received' in i ? ` got ${String(i.received)}` : ''return `${path}: ${i.message}${got}`})return `schema: ${issues.join(' | ')}${err.issues.length > 3 ? ` (+${err.issues.length - 3} more)` : ''}`}if (err instanceof Error) return err.message.split('\n')[0]!return String(err)}const ENDPOINT = '/todos/query'const COMPANY_ENDPOINT = '/todos/company'const MAX_PAGE_SIZE = 100const COMPANY_PAGE_SIZE = 100const DEFAULT_SORT = 'dueDate'export interface TodoQuery extends PageRequest {teamId?: string/** Not a server-side filter. Applied client-side by list(). */userId?: string/** Ninety default appears to be all; pass false to exclude personal to-dos. */isPersonal?: boolean/** Defaults to false: open items only, which is what a dashboard wants. */completed?: boolean/** Defaults to false. */archived?: booleansearchText?: string/** Exact title match. */title?: string/** ISO date. Applied client-side; no confirmed server-side equivalent. */dueBefore?: string}export class TodosResource {constructor(private readonly http: HttpClient,private readonly roster: Roster,/** Timezone and clock for date normalization. See dates.ts. */private readonly normalizeOpts: NormalizeOptions = {},) {}/** One page, normalized and hydrated. */async query(q: TodoQuery = {}): Promise<Page<Todo>> {const page = q.page ?? 0const pageSize = Math.min(q.pageSize ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE)const ctx = { endpoint: `POST ${ENDPOINT}`, pageIndex: page }const raw = await this.http.post(ENDPOINT, { body: toWire(q, page, pageSize), pageIndex: page })const rawPage = fromArray<unknown>(raw, { page, pageSize }, ctx)const parsed = rawPage.items.map((item) => RawTodoSchema.parse(item))return { ...rawPage, items: await this.hydrate(parsed) }}/*** Every page. This is what the dashboard sync calls.** Note it does not collapse recurring series; that is a presentation choice,* so it stays with the caller. See collapseSeries() in types.ts, and read the* comment there before counting anything.*/async list(q: TodoQuery = {}): Promise<Todo[]> {const pageSize = Math.min(q.pageSize ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE)let all = await collect(iterateAll<Todo>((req) => this.query({ ...q, page: req.page, pageSize: req.pageSize }),{ page: q.page ?? 0, pageSize },),)// Client-side because these filters are not confirmed server-side.if (q.userId) all = all.filter((t) => t.owner.id === q.userId)if (q.dueBefore) {const cutoff = Date.parse(q.dueBefore)all = all.filter((t) => t.dueDate !== null && Date.parse(t.dueDate) < cutoff)}// Ninety returns soft-deleted rows; never show them.return all.filter((t) => !t.deleted)}async get(id: string): Promise<Todo> {const raw = await this.http.get(`/todos/${encodeURIComponent(id)}`)const [one] = await this.hydrate([RawTodoSchema.parse(raw)])if (!one) throw new Error(`Todo ${id} normalized to nothing`)return one}/*** Like get(), but a schema mismatch downgrades the record rather than losing it.** Strictness is right for the primary list (a wrong shape there means every* number is suspect), but hydration is enrichment. Refusing an enrichment* because one field drifted throws away the 30 fields that parsed fine, and* the row with them (rule 4).** So: keep whatever validates, report what did not, and let the caller decide.* The reason still surfaces in `hydrationFailures`, so drift is visible rather* than absorbed. The point is to stop it being *costly*, not to stop noticing.*/async getLenient(id: string): Promise<{ todo: Todo; issue: string | null }> {const raw = await this.http.get(`/todos/${encodeURIComponent(id)}`)const parsed = RawTodoSchema.safeParse(raw)if (parsed.success) {const [one] = await this.hydrate([parsed.data])if (!one) throw new Error(`Todo ${id} normalized to nothing`)return { todo: one, issue: null }}// Drop only the fields that failed, keep the rest, re-validate.const bad = new Set(parsed.error.issues.map((i) => String(i.path[0])).filter(Boolean))const pruned = Object.fromEntries(Object.entries(raw as Record<string, unknown>).filter(([k]) => !bad.has(k)),)const retry = RawTodoSchema.safeParse(pruned)if (!retry.success) throw parsed.errorconst [one] = await this.hydrate([retry.data])if (!one) throw new Error(`Todo ${id} normalized to nothing`)return { todo: one, issue: describeFailure(parsed.error) }}/*** Every open to-do in the company, from one token. The dashboard's whole* reason for existing.** Requires Owner or Admin in Ninety; other roles get 403, which surfaces as* NinetyForbiddenError with a message that says what to ask for. One* company-scoped token rather than one per person means a single expired* token cannot silently erase one person from the dashboard.** On sending no filters. Every boolean query param on this endpoint is either* useless or actively wrong, measured:** (no filters) -> 31 rows, nextToken null <- correct* completed=false -> 0 rows* completed=true -> 0 rows* completed=false&archived=false -> 100 rows, nextToken set** `completed=false` returns nothing because the field does not exist on an* open to-do (absent on every row), so equality against false* matches nothing. `completed=true` returns nothing because Meridian archives on* completion at the weekly review, and archived rows are excluded* by default. The last line is the genuinely broken one: passing explicit* `false` widens the result past the unfiltered default, which no correct* filter does.** So: send nothing. The endpoint's defaults are exactly what a dashboard* wants, and every parameter that could change them is a trap. If you need* completed or archived history, that is a separate sweep with `archived` and* a date range, not a tweak to this call.*/async company(opts: { maxPages?: number } = {}): Promise<Todo[]> {const rows = await collect(iterateCursor<RawCompanyTodo>(async (nextToken) => {const query: Record<string, string | number | undefined> = { limit: COMPANY_PAGE_SIZE }if (nextToken) query.nextToken = nextTokenconst raw = await this.http.get(COMPANY_ENDPOINT, { query })const page = fromCursor<unknown>(raw, { endpoint: `GET ${COMPANY_ENDPOINT}` })return { ...page, items: page.items.map((i) => RawCompanyTodoSchema.parse(i)) }},opts,),)// Names ride along on every row, so the roster is fully resolved for free,// with no GET /users/{id} traffic at all on this path.for (const r of rows) {const name = companyOwnerName(r)if (r.userId && name) this.roster.note(r.userId, name)}return rows.map((r) =>normalizeTodo(companyTodoToRaw(r), (id) => this.roster.lookup(id), this.normalizeOpts),)}/*** company(), then hydrate each row to the full 33-field record.** The index payload has no seriesId, milestoneId, completedDate,* originalDueDate or dueDateChanged, so anything that collapses recurrence,* links a to-do to its rock, or measures due-date churn needs this instead.** Cost is one request per row: 31 rows is about a second and a half at the default* 20 rps, paced by the token bucket. Cheap enough to run per sync, too* expensive to run per page load, so cache it and show `syncedAt`.** A row that fails to hydrate keeps its thin version rather than vanishing.* A missing to-do is a worse lie than a partial one, and `hydrationFailures`* on the snapshot is how you find out it happened.*/async companyDetailed(opts: { maxPages?: number } = {},): Promise<{ todos: Todo[]; hydrationFailures: HydrationFailure[] }> {const thin = await this.company(opts)const failures: HydrationFailure[] = []const todos = await Promise.all(thin.map(async (t) => {try {const { todo, issue } = await this.getLenient(t.id)// Degraded, not lost: the record hydrated with some fields dropped.// Still reported, because silent drift becomes permanent drift.if (issue) {failures.push({ id: t.id, owner: t.owner.name, status: null, message: issue, degraded: true })}return todo} catch (err) {// Record WHY, not just which. A bare list of ids cannot distinguish// "rate limited, retry" from "the schema rejected this record", and// those need opposite responses.failures.push({id: t.id,owner: t.owner.name,status: err instanceof NinetyError ? (err.status ?? null) : null,message: describeFailure(err),degraded: false,})return t}}),)return { todos, hydrationFailures: failures }}/*** Resolve owner and creator ids to names before returning. Keeps ObjectIds out* of the dashboard and out of the model's context.** The cheap part: every to-do carries `createdBy` as a display name next to* `createdByUserId`. Recording those pairs costs nothing and means most of the* roster is already known before a single GET /users/{id} goes out.*/private async hydrate(raws: RawTodo[]): Promise<Todo[]> {for (const r of raws) {if (r.createdByUserId && r.createdBy) this.roster.note(r.createdByUserId, r.createdBy)}const ids = raws.flatMap((r) => [r.userId, r.createdByUserId]).filter((v): v is string => !!v)await this.roster.resolveMany(ids)return raws.map((r) => normalizeTodo(r, (id) => this.roster.lookup(id), this.normalizeOpts))}}/*** The one place page translation and wire-field naming happens.** On the 1-based page. A live query with an exact-title filter matching two* to-dos, sent with `page: 1` and `pageSize: 25`, returned both rows. If the* parameter were 0-based, `page: 1` would be the second page and would have come* back empty. So `page: 1` is the first page. Callers here are 0-based; the +1* lives on this line and nowhere else.** On sort casing. `sort` takes a camelCase field name ("dueDate").* Lowercasing it, as a third-party SDK's docs suggest, silently mis-sorts* every list.*/export function toWire(q: TodoQuery, page: number, pageSize: number): Record<string, unknown> {const body: Record<string, unknown> = {page: page + 1,pageSize,sort: q.sortBy ?? DEFAULT_SORT,order: q.sortDir ?? 'asc',// Open items unless asked otherwise. A dashboard asking for "to-dos" means// open ones, and defaulting the other way makes every count wrong.completed: q.completed ?? false,archived: q.archived ?? false,}if (q.teamId) body.teamId = q.teamIdif (q.isPersonal !== undefined) body.isPersonal = q.isPersonalif (q.searchText) body.searchText = q.searchTextif (q.title) body.title = q.titlereturn body}