roster.ts
Language TypeScript5.4 KB158 lines
/*** Roster: user id -> name.** `GET /users` returns every active user in the company, and* `GET /users/team/{teamId}` returns team membership that `GET /teams` does not* carry. Harvesting from payloads is still the cheapest source, and it is what* runs. Every to-do carries `createdBy` next to* `createdByUserId`, and every company to-do carries `ownerFirstName` /* `ownerLastName` inline, which resolves the whole roster on that path for zero* extra requests. `note()` is how those get recorded.** Sources, cheapest first:* 1. SEED - pinned roster.json. Never expires, never overwritten. Keeps* someone with zero to-dos visible instead of silently absent:* the company sweep only returns people with open work.* 2. NOTED - free pairs off to-do payloads. No request at all.* 3. FETCH - GET /users/{id} for whoever is still unknown.** Merge, never replace. A refresh that drops people is worse than a stale name.*/import type { HttpClient } from './http.js'import { NinetyError } from './errors.js'import { RawUserSchema, normalizeUserName, UNASSIGNED, UNKNOWN_PERSON, type Person } from './types.js'export interface RosterEntry {id: stringname: string}export interface RosterOptions {/** Pinned entries from roster.json. Always win. */seed?: RosterEntry[]/** How long a fetched name stays fresh. Default 24h; people rarely rename. */ttlMs?: numbernow?: () => number}type Source = 'seed' | 'noted' | 'fetch'interface CacheEntry {name: stringfetchedAt: numbersource: Source}export class Roster {private readonly cache = new Map<string, CacheEntry>()private readonly inflight = new Map<string, Promise<void>>()private readonly failed = new Set<string>()private readonly ttlMs: numberprivate readonly now: () => numberconstructor(private readonly http: HttpClient,opts: RosterOptions = {},) {this.ttlMs = opts.ttlMs ?? 24 * 60 * 60 * 1000this.now = opts.now ?? (() => Date.now())for (const e of opts.seed ?? []) {this.cache.set(e.id, { name: e.name, fetchedAt: this.now(), source: 'seed' })}}/*** Record a free id -> name pair seen in a payload (e.g. createdByUserId +* createdBy on a to-do). Costs nothing and removes the id from the fetch list.* Never overrides a pinned seed entry.*/note(id: string, name: string): void {if (!id || !name) returnif (this.cache.get(id)?.source === 'seed') returnthis.cache.set(id, { name, fetchedAt: this.now(), source: 'noted' })this.failed.delete(id)}/** Synchronous lookup. Never returns a raw id as a name. */lookup(id: string | null | undefined): Person {if (!id) return UNASSIGNEDconst hit = this.cache.get(id)return hit ? { id, name: hit.name } : UNKNOWN_PERSON(id)}/** Fuzzy name -> entries. */find(query: string): RosterEntry[] {const q = query.trim().toLowerCase()if (!q) return []return [...this.cache].filter(([, e]) => e.name.toLowerCase().includes(q)).map(([id, e]) => ({ id, name: e.name })).sort((a, b) => a.name.localeCompare(b.name))}/** Ensure every id is resolved. Deduped; the HTTP token bucket paces it. */async resolveMany(ids: Iterable<string>): Promise<void> {const wanted = [...new Set([...ids].filter((id) => id && this.needsFetch(id)))]await Promise.all(wanted.map((id) => this.resolveOne(id)))}private needsFetch(id: string): boolean {if (this.failed.has(id)) return falseconst hit = this.cache.get(id)if (!hit) return true// Seeded and noted names came from a trusted payload; do not spend a request// re-confirming them.if (hit.source !== 'fetch') return falsereturn this.now() - hit.fetchedAt > this.ttlMs}private resolveOne(id: string): Promise<void> {const existing = this.inflight.get(id)if (existing) return existingconst p = (async () => {try {const raw = await this.http.get(`/users/${encodeURIComponent(id)}`)const parsed = RawUserSchema.safeParse(raw)const name = parsed.success ? normalizeUserName(parsed.data) : nullif (name) {if (this.cache.get(id)?.source !== 'seed') {this.cache.set(id, { name, fetchedAt: this.now(), source: 'fetch' })}} else {this.failed.add(id)}} catch (err) {// A deactivated or invisible user must not fail the whole sync. Auth// failures are different: those mean nothing will work, so re-throw.if (err instanceof NinetyError && (err.status === 401 || err.status === 403)) throw errthis.failed.add(id)} finally {this.inflight.delete(id)}})()this.inflight.set(id, p)return p}/** Ids that could not be resolved. Surface these; do not swallow them. */get unresolved(): string[] {return [...this.failed]}/** Where the names came from. Useful for a sync log line. */stats(): Record<Source, number> & { unresolved: number } {const out = { seed: 0, noted: 0, fetch: 0, unresolved: this.failed.size }for (const e of this.cache.values()) out[e.source]++return out}/** Everything known, for pinning back into roster.json. */toEntries(): RosterEntry[] {return [...this.cache].map(([id, e]) => ({ id, name: e.name })).sort((a, b) => a.name.localeCompare(b.name))}}