roster.ts
Language TypeScript4.9 KB124 lines
#!/usr/bin/env tsx/*** Print the Ninety user directory as paste-ready config. Read-only.** pnpm roster # human-readable table + both config blocks* pnpm roster -- --json # write roster.json to stdout, nothing else** A script and not a one-liner, because Ninety is not consistent about its id* field: `GET /todos/company` returns `id` while `GET /todos/{id}` returns* `_id`. A one-liner that hard-codes either key breaks on the endpoint that uses the* other, and the failure is a bare KeyError that says nothing useful. This* checks every plausible key and tells you which one it found.** What to do with the output; two things, both printed ready to paste:** 1. `ninetyId` values for config/meridian-people.ts. Names are typed by humans in* another system and drift ("Samuel Whitaker" for "Sam Whitaker"); ids do not.* Every current person is pinned this way, which is why the display-name* aliases are empty. Run this for a new hire, or when* `pnpm company` reports someone matched by name rather than by id.* 2. roster.json, which pins people so anyone with no open work still renders* as "covered, nothing open" rather than vanishing.** The directory is not the team. It holds former staff and outside* collaborators. Anyone* not on staff belongs in KNOWN_NON_ROSTER_NINETY, not in roster.json.*/import { clientFromEnv } from '@meridian/ninety-client'import { personFromNinety, KNOWN_NON_ROSTER_NINETY } from '../config/meridian-people.js'import { errorMessage } from '@meridian/shared'interface RawUser {[k: string]: unknown}/** Ninety uses `_id` on some endpoints and `id` on others. Take whichever exists. */function idOf(u: RawUser): string | null {for (const key of ['_id', 'id', 'userId']) {const v = u[key]if (typeof v === 'string' && v.length) return v}return null}function nameOf(u: RawUser): string {const s = (k: string) => (typeof u[k] === 'string' ? (u[k] as string).trim() : '')const joined = [s('firstName'), s('lastName')].filter(Boolean).join(' ')return joined || s('fullName') || s('name') || s('email') || '(unnamed)'}async function main(): Promise<void> {const client = clientFromEnv(process.env)const raw = await client.http.get<unknown>('/users')// Bare array on some endpoints, enveloped on others. Accept both rather than// guessing.const list: RawUser[] = Array.isArray(raw)? (raw as RawUser[]): ((raw as { data?: RawUser[]; items?: RawUser[] }).data ??(raw as { items?: RawUser[] }).items ??[])if (!list.length) {console.error('GET /users returned no users. Top-level keys:',raw && typeof raw === 'object' ? Object.keys(raw as object).join(', ') : typeof raw)process.exit(1)}const users = list.map((u) => {const id = idOf(u)const name = nameOf(u)const known = KNOWN_NON_ROSTER_NINETY.some((n) => n.toLowerCase() === name.toLowerCase())return { id, name, known, person: personFromNinety(id, name) }})if (process.argv.includes('--json')) {console.log(JSON.stringify({entries: users.filter((u) => u.person && u.id).map((u) => ({ id: u.id, name: u.person!.name })),}, null, 2))return}const idKey = ['_id', 'id', 'userId'].find((k) => typeof list[0]![k] === 'string') ?? '(none)'console.log(`Ninety directory: ${users.length} users (id field: ${idKey})\n`)console.log('NAME'.padEnd(24) + 'ID'.padEnd(28) + 'STATUS')console.log('-'.repeat(76))for (const u of users) {const status = u.known? 'declared non-roster': u.person? `roster: ${u.person.name}`: 'NOT ON ROSTER, decide'console.log(u.name.padEnd(24) + (u.id ?? '(no id)').padEnd(28) + status)}const undecided = users.filter((u) => !u.person && !u.known)if (undecided.length) {console.log(`\n${undecided.length} user(s) are neither on the roster nor declared non-roster.`)console.log('Their to-dos count in company totals but appear on no per-person view.')console.log('Add them to MERIDIAN_PEOPLE, or to KNOWN_NON_ROSTER_NINETY if they are not staff.')}console.log('\n\n--- paste into config/meridian-people.ts, one per person ---\n')for (const u of users) {if (!u.person || !u.id) continueconsole.log(` // ${u.person.name}`)console.log(` ninetyId: '${u.id}',`)}console.log('\nEvery person needs one. An unpinned person resolves by display name')console.log('until that name is retyped, and then resolves to nobody, which on the')console.log('board is indistinguishable from having nothing to do.')console.log('\n\n--- write to roster.json ---\n')console.log(JSON.stringify({entries: users.filter((u) => u.person && u.id).map((u) => ({ id: u.id, name: u.person!.name })),}, null, 2))console.log('\n pnpm roster -- --json > roster.json')}main().catch((err) => {console.error(`\nroster failed: ${errorMessage(err)}`)process.exit(1)})