company.ts
Language TypeScript8.0 KB172 lines
#!/usr/bin/env tsx/*** Company sweep. Read-only.** The whole company's open to-dos from one admin token, via GET /todos/company.* This is the query the dashboard and the morning briefing run on.** pnpm company # hydrated (default): full 33-field records* pnpm company -- --thin # index only, one request, no hydration** A 403 here is a role problem, not a code problem: the endpoint is restricted* to company Owners and Admins. The error message says so.** What to look at in the output:** - assignments vs commitments. These are different numbers and the gap is* shared work. Never put "40 open to-dos" on a page without the other one.* - roster.seed. If it is 0 and owners < directory size, somebody with no open* work is invisible on the dashboard. Fill roster.json.* - sharedLinks.linkable. If this ever prints true, Ninety has started joining* shared copies and assignmentGroups() can stop guessing from titles.*/import { clientFromEnv, assignmentGroups } from '@meridian/ninety-client'import { resolveNinety, unmappedNinetyOwners } from '../config/meridian-people.js'import { loadRoster } from './lib.js'import { errorMessage } from '@meridian/shared'const tally = (fs: { message: string; status: number | null }[]) => {const m = new Map<string, number>()for (const f of fs) {const k = `${f.status ? f.status + ' ' : ''}${f.message}`.slice(0, 74)m.set(k, (m.get(k) ?? 0) + 1)}return m}const owners = (fs: { owner: string }[]) => [...new Set(fs.map((f) => f.owner))].join(', ')async function main(): Promise<void> {const thin = process.argv.includes('--thin')const seed = await loadRoster()const client = clientFromEnv(process.env, seed)console.log(seed?.length ? `Pinned roster: ${seed.length} from roster.json` : 'No roster.json, harvesting only')const started = Date.now()const snap = await client.companySnapshot({ detailed: !thin })const elapsed = ((Date.now() - started) / 1000).toFixed(1)console.log(`Company sweep (${thin ? 'index only' : 'hydrated'}, ${elapsed}s)`)console.log(`synced at ${snap.syncedAt}`)console.log()const { assignments, commitments, sharedCommitments, maxOwners } = snap.spreadconsole.log(`assignments ${assignments} <- rows returned`)console.log(`commitments ${commitments} <- distinct pieces of work`)console.log(`shared ${sharedCommitments} (max ${maxOwners} owners on one item)`)console.log(`recurrence dupes ${snap.recurrence.duplicates}`)const degraded = snap.hydrationFailures.filter((f) => f.degraded)const failed = snap.hydrationFailures.filter((f) => !f.degraded)if (failed.length) {console.log(`hydration FAILED ${failed.length} of ${snap.todos.length}, kept the thin index row`)for (const [k, n] of tally(failed)) console.log(` x${n} ${k}`)console.log(` owners: ${owners(failed)}`)if (failed.some((f) => f.status === 403)) {console.log(` 403: GET /todos/{id} does NOT inherit the company`)console.log(` endpoint's elevation.`)}if (failed.some((f) => f.status === 429)) {console.log(` 429: back off, lower NINETY_RPS.`)}}if (degraded.length) {// Not a failure. Every field the board reads is present; something the board// does not read was dropped. Worth seeing, not worth alarm.console.log(`hydration degraded ${degraded.length} of ${snap.todos.length}: hydrated, some unread fields dropped`)for (const [k, n] of tally(degraded)) console.log(` x${n} ${k}`)console.log(` owners: ${owners(degraded)}`)}if (!failed.length && !degraded.length) {console.log(`hydration all ${snap.todos.length} rows full-fidelity`)}console.log()// Per person. Every row counts here; deduping would erase co-owners.//// Names are shown roster-resolved, because Ninety's spelling and the roster's// routinely differ ("Samuel Whitaker" vs "Sam Whitaker") and a raw sweep that// prints Ninety's version cannot tell you whether the join works. This is the// cheapest place to catch an attribution break, so it checks here.const byOwner = new Map<string, { n: number; ninety: string; mapped: boolean; via: string | null }>()for (const t of snap.todos) {const { person, via } = resolveNinety(t.owner.id, t.owner.name)const key = person?.name ?? t.owner.nameconst prev = byOwner.get(key)byOwner.set(key, {n: (prev?.n ?? 0) + 1,ninety: t.owner.name,mapped: Boolean(person),via,})}console.log('OPEN BY OWNER'.padEnd(30) + 'COUNT')console.log('-'.repeat(52))for (const [name, o] of [...byOwner].sort((a, b) => b[1].n - a[1].n)) {const drift = o.mapped && o.ninety !== name ? ` (Ninety: ${o.ninety})` : ''const flag = o.mapped ? '' : ' <-- UNMAPPED, on no per-person view'console.log(name.padEnd(30) + String(o.n).padStart(5) + drift + flag)}console.log()// Every person has a ninetyId, so a name match means the id Ninety sent is// one this roster has never seen. The work still attributes correctly, which// is exactly why it would otherwise pass unnoticed until the name drifted too// and that person quietly emptied out. Surfaced rather than absorbed.const byNameOnly = [...byOwner].filter(([, o]) => o.via === 'name')if (byNameOnly.length) {console.log(`MATCHED BY NAME, NOT BY ID ${byNameOnly.length} owner(s).`)console.log(` Their to-dos are attributed correctly today. They will stop`)console.log(` being attributed at all the moment the spelling changes.`)for (const [name, o] of byNameOnly) {console.log(` - ${name} (Ninety sent "${o.ninety}")`)}console.log(` Fix: run \`pnpm roster\` and update ninetyId in`)console.log(` config/meridian-people.ts. Do not add an alias instead.`)console.log()}const unmapped = unmappedNinetyOwners(snap.todos.map((t) => ({ id: t.owner.id, name: t.owner.name })),)if (unmapped.length) {const lost = snap.todos.filter((t) => !personFromNinety(t.owner.id, t.owner.name)).lengthconsole.log(`ATTRIBUTION GAP ${lost} of ${snap.todos.length} to-dos belong to nobody on the roster.`)console.log(` They count in company totals and appear on NO per-person view,`)console.log(` which reads as those people having nothing.`)for (const u of unmapped) console.log(` - ${u.name} (${u.id})`)console.log(` Fix: add to MERIDIAN_PEOPLE with their ninetyId from \`pnpm roster\`,`)console.log(` or declare them in KNOWN_NON_ROSTER_NINETY if they are not staff.`)console.log()}const shared = assignmentGroups(snap.todos).filter((g) => g.shared)if (shared.length) {console.log('SHARED COMMITMENTS (grouped by title: heuristic, see assignmentGroups())')console.log('-'.repeat(78))for (const g of shared.sort((a, b) => b.owners.length - a.owners.length)) {console.log(` x${g.owners.length} ${g.title.slice(0, 56)}`)console.log(` ${g.owners.map((o) => o.name).join(', ')}`)}console.log()}console.log(`roster seed=${snap.roster.seed} noted=${snap.roster.noted} fetch=${snap.roster.fetch} unresolved=${snap.roster.unresolved}`)if (snap.unresolvedUsers.length) {console.log(` unresolved ids: ${snap.unresolvedUsers.join(', ')}`)}if (snap.sharedLinks) {const a = snap.sharedLinksconsole.log()console.log(`sharedLinks groups=${a.sharedGroups} seriesId=${a.sharesSeriesId} milestoneId=${a.sharesMilestoneId} linkable=${a.linkable}`)if (a.linkable) {console.log(' -> Ninety now links shared copies. Replace the title heuristic in')console.log(' assignmentGroups() with that field and delete sharedLinkAudit().')}}}main().catch((err) => {console.error(`\nCompany sweep failed: ${errorMessage(err)}`)process.exit(1)})