client.ts
Language TypeScript6.8 KB175 lines
/*** NinetyClient: the thing everything else imports.** Read-only by design: write methods are deliberately absent. The board has no* write-back to Ninety (rule 12).*/import { HttpClient, type HttpOptions } from './http.js'import { Roster, type RosterEntry, type RosterOptions } from './roster.js'import { TodosResource, type HydrationFailure } from './resources/todos.js'import { TeamsResource } from './resources/teams.js'import { assignmentSpread, seriesInflation, sharedLinkAudit, type Todo } from './types.js'import { MERIDIAN_TZ } from './dates.js'export interface NinetyClientOptions extends HttpOptions {/** Pinned roster entries, normally loaded from roster.json. */roster?: RosterEntry[]rosterOptions?: Omit<RosterOptions, 'seed'>/*** Timezone for date display and grouping. Defaults to America/Chicago.** This is not cosmetic. Ninety serializes due dates as end-of-day local time* in UTC, so getting this wrong shifts every date by a day. See dates.ts.*/tz?: string}export interface SyncSnapshot {todos: Todo[]/** When the sync ran. Put this on the page; polling means never live. */syncedAt: stringroster: ReturnType<Roster['stats']>/** How many rows recurrence is contributing. Log it; it explains count drift. */recurrence: ReturnType<typeof seriesInflation>/** Ids that stayed "unknown user". Non-empty is a problem worth seeing. */unresolvedUsers: string[]}export interface CompanySnapshot {todos: Todo[]/** When the sync ran. Put this on the page; polling means never live. */syncedAt: string/** False means the thin index shape: no seriesId, milestoneId or date churn. */detailed: boolean/*** Assignments vs commitments. Show both. On live data a real share of rows are* one commitment shared between two or three people, so a headline counting* rows overstates the work and hides the fact that some of it is jointly* owned.*/spread: ReturnType<typeof assignmentSpread>/** How many rows recurrence contributed. Log it; it explains count drift. */recurrence: ReturnType<typeof seriesInflation>/** null when detailed:false; the audit needs fields the index does not carry. */sharedLinks: ReturnType<typeof sharedLinkAudit> | null/** Rows that stayed on their thin version, with the reason. See HydrationFailure. */hydrationFailures: HydrationFailure[]roster: ReturnType<Roster['stats']>unresolvedUsers: string[]}export class NinetyClient {readonly http: HttpClientreadonly roster: Rosterreadonly todos: TodosResourcereadonly teams: TeamsResourcereadonly tz: stringconstructor(opts: NinetyClientOptions) {this.http = new HttpClient(opts)this.roster = new Roster(this.http, { ...opts.rosterOptions, seed: opts.roster })this.tz = opts.tz ?? MERIDIAN_TZthis.todos = new TodosResource(this.http, this.roster, { tz: this.tz })this.teams = new TeamsResource(this.http)}/*** One open-to-do sweep with everything the dashboard needs to render and to* explain itself.** Deliberately does not collapse recurring series; that is a presentation* decision. It does report how many duplicate rows recurrence produced, so a* count that looks wrong can be explained instead of argued about.*/async snapshot(opts: { teamId?: string; now?: () => Date } = {}): Promise<SyncSnapshot> {const now = opts.now ?? (() => new Date())const query = opts.teamId ? { teamId: opts.teamId } : {}const todos = await this.todos.list(query)return {todos,syncedAt: now().toISOString(),roster: this.roster.stats(),recurrence: seriesInflation(todos),unresolvedUsers: this.roster.unresolved,}}/*** The company-wide sweep. This is what the dashboard and the morning briefing* actually run on.** One token, every person, via GET /todos/company. Requires Owner or Admin in* Ninety. A 403 here means the role, not the code, and NinetyForbiddenError* says so.** `detailed` defaults to true because the thin index row carries no seriesId,* milestoneId or dueDateChanged, and without those you cannot collapse* recurrence, link a to-do to its rock, or spot a date that has moved four* times. Pass false only when you want a fast headcount and nothing else.** Like snapshot(), this deliberately does not collapse or dedupe. Both are* presentation decisions and both have a correct answer that depends on the* panel: per-person views keep every row, company counts dedupe. What it does* instead is hand you the numbers to explain either one.*/async companySnapshot(opts: { detailed?: boolean; now?: () => Date; maxPages?: number } = {},): Promise<CompanySnapshot> {const now = opts.now ?? (() => new Date())const detailed = opts.detailed ?? trueconst iterOpts = opts.maxPages === undefined ? {} : { maxPages: opts.maxPages }const { todos, hydrationFailures } = detailed? await this.todos.companyDetailed(iterOpts): { todos: await this.todos.company(iterOpts), hydrationFailures: [] as HydrationFailure[] }return {todos,syncedAt: now().toISOString(),detailed,spread: assignmentSpread(todos),recurrence: seriesInflation(todos),sharedLinks: detailed ? sharedLinkAudit(todos) : null,hydrationFailures,roster: this.roster.stats(),unresolvedUsers: this.roster.unresolved,}}}/*** Where a missing credential actually comes from.** Naming the shell rather than the code is the point. There is no .env reader in* this repo, so "NINETY_TOKEN is not set" almost always means this shell never* sourced the profile (a new terminal, or one an editor spawned). The same* wording is in preflight() in apps/dashboard/src/main.ts.*/const SHELL_HINT ='Credentials come from the shell profile; this repo has no .env reader. ' +'See credentials.example.sh, then open a NEW terminal; an existing one keeps ' +'the old environment. Check what the shell has: env | grep NINETY'/** Build a client from environment variables. */export function clientFromEnv(env: Record<string, string | undefined>,roster?: RosterEntry[],): NinetyClient {const token = env.NINETY_TOKENif (!token) {// Points at the shell profile, never at a credentials file: nothing reads// one, and the message must not send people to create the thing the// credentials policy forbids.throw new Error('NINETY_TOKEN is not set. It must be a Personal Access Token belonging to a ' +'company Owner or Admin. ' + SHELL_HINT,)}const opts: NinetyClientOptions = { token }if (env.NINETY_BASE_URL) opts.baseUrl = env.NINETY_BASE_URLif (env.NINETY_RPS) opts.rps = Number(env.NINETY_RPS)if (env.NINETY_DEBUG === 'true') opts.debug = trueif (roster) opts.roster = rosterreturn new NinetyClient(opts)}