mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

roster.ts

Language TypeScript4.9 KB124 lines

scripts/roster.ts

  1. #!/usr/bin/env tsx
  2. /**
  3. * Print the Ninety user directory as paste-ready config. Read-only.
  4. *
  5. * pnpm roster # human-readable table + both config blocks
  6. * pnpm roster -- --json # write roster.json to stdout, nothing else
  7. *
  8. * A script and not a one-liner, because Ninety is not consistent about its id
  9. * field: `GET /todos/company` returns `id` while `GET /todos/{id}` returns
  10. * `_id`. A one-liner that hard-codes either key breaks on the endpoint that uses the
  11. * other, and the failure is a bare KeyError that says nothing useful. This
  12. * checks every plausible key and tells you which one it found.
  13. *
  14. * What to do with the output; two things, both printed ready to paste:
  15. *
  16. * 1. `ninetyId` values for config/meridian-people.ts. Names are typed by humans in
  17. * another system and drift ("Samuel Whitaker" for "Sam Whitaker"); ids do not.
  18. * Every current person is pinned this way, which is why the display-name
  19. * aliases are empty. Run this for a new hire, or when
  20. * `pnpm company` reports someone matched by name rather than by id.
  21. * 2. roster.json, which pins people so anyone with no open work still renders
  22. * as "covered, nothing open" rather than vanishing.
  23. *
  24. * The directory is not the team. It holds former staff and outside
  25. * collaborators. Anyone
  26. * not on staff belongs in KNOWN_NON_ROSTER_NINETY, not in roster.json.
  27. */
  28. import { clientFromEnv } from '@meridian/ninety-client'
  29. import { personFromNinety, KNOWN_NON_ROSTER_NINETY } from '../config/meridian-people.js'
  30. import { errorMessage } from '@meridian/shared'
  31. interface RawUser {
  32. [k: string]: unknown
  33. }
  34. /** Ninety uses `_id` on some endpoints and `id` on others. Take whichever exists. */
  35. function idOf(u: RawUser): string | null {
  36. for (const key of ['_id', 'id', 'userId']) {
  37. const v = u[key]
  38. if (typeof v === 'string' && v.length) return v
  39. }
  40. return null
  41. }
  42. function nameOf(u: RawUser): string {
  43. const s = (k: string) => (typeof u[k] === 'string' ? (u[k] as string).trim() : '')
  44. const joined = [s('firstName'), s('lastName')].filter(Boolean).join(' ')
  45. return joined || s('fullName') || s('name') || s('email') || '(unnamed)'
  46. }
  47. async function main(): Promise<void> {
  48. const client = clientFromEnv(process.env)
  49. const raw = await client.http.get<unknown>('/users')
  50. // Bare array on some endpoints, enveloped on others. Accept both rather than
  51. // guessing.
  52. const list: RawUser[] = Array.isArray(raw)
  53. ? (raw as RawUser[])
  54. : ((raw as { data?: RawUser[]; items?: RawUser[] }).data ??
  55. (raw as { items?: RawUser[] }).items ??
  56. [])
  57. if (!list.length) {
  58. console.error('GET /users returned no users. Top-level keys:',
  59. raw && typeof raw === 'object' ? Object.keys(raw as object).join(', ') : typeof raw)
  60. process.exit(1)
  61. }
  62. const users = list.map((u) => {
  63. const id = idOf(u)
  64. const name = nameOf(u)
  65. const known = KNOWN_NON_ROSTER_NINETY.some((n) => n.toLowerCase() === name.toLowerCase())
  66. return { id, name, known, person: personFromNinety(id, name) }
  67. })
  68. if (process.argv.includes('--json')) {
  69. console.log(JSON.stringify({
  70. entries: users.filter((u) => u.person && u.id).map((u) => ({ id: u.id, name: u.person!.name })),
  71. }, null, 2))
  72. return
  73. }
  74. const idKey = ['_id', 'id', 'userId'].find((k) => typeof list[0]![k] === 'string') ?? '(none)'
  75. console.log(`Ninety directory: ${users.length} users (id field: ${idKey})\n`)
  76. console.log('NAME'.padEnd(24) + 'ID'.padEnd(28) + 'STATUS')
  77. console.log('-'.repeat(76))
  78. for (const u of users) {
  79. const status = u.known
  80. ? 'declared non-roster'
  81. : u.person
  82. ? `roster: ${u.person.name}`
  83. : 'NOT ON ROSTER, decide'
  84. console.log(u.name.padEnd(24) + (u.id ?? '(no id)').padEnd(28) + status)
  85. }
  86. const undecided = users.filter((u) => !u.person && !u.known)
  87. if (undecided.length) {
  88. console.log(`\n${undecided.length} user(s) are neither on the roster nor declared non-roster.`)
  89. console.log('Their to-dos count in company totals but appear on no per-person view.')
  90. console.log('Add them to MERIDIAN_PEOPLE, or to KNOWN_NON_ROSTER_NINETY if they are not staff.')
  91. }
  92. console.log('\n\n--- paste into config/meridian-people.ts, one per person ---\n')
  93. for (const u of users) {
  94. if (!u.person || !u.id) continue
  95. console.log(` // ${u.person.name}`)
  96. console.log(` ninetyId: '${u.id}',`)
  97. }
  98. console.log('\nEvery person needs one. An unpinned person resolves by display name')
  99. console.log('until that name is retyped, and then resolves to nobody, which on the')
  100. console.log('board is indistinguishable from having nothing to do.')
  101. console.log('\n\n--- write to roster.json ---\n')
  102. console.log(JSON.stringify({
  103. entries: users.filter((u) => u.person && u.id).map((u) => ({ id: u.id, name: u.person!.name })),
  104. }, null, 2))
  105. console.log('\n pnpm roster -- --json > roster.json')
  106. }
  107. main().catch((err) => {
  108. console.error(`\nroster failed: ${errorMessage(err)}`)
  109. process.exit(1)
  110. })