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

fathom-people.ts

Language TypeScript16.0 KB383 lines

config/fathom-people.ts

  1. /**
  2. * Fathom assignee label -> roster person.
  3. *
  4. * A third naming convention, different again from Plinth and Bitrix. As
  5. * observed:
  6. *
  7. * Sam Whitaker - Meridian Display
  8. * Ines Carvalho | Meridian Display and Ines C Meridian Display
  9. * Theo | MeridianDisplay
  10. * Rafael Montero
  11. * Marco
  12. * Hazel O
  13. * Nora L and Nora Lindqvist @ Meridian Display
  14. *
  15. * Same person, up to two spellings, three separators, and no surname on two of
  16. * them. Mapping is explicit for that reason.
  17. *
  18. * The important one: "Meridian Display" is not a person.
  19. *
  20. * Fathom assigns to a bare "Meridian Display" when the transcript never named an
  21. * owner. That is an unowned commitment: somebody said the company would do a
  22. * thing and nobody's name attached to it.
  23. *
  24. * Note that "Office MeridianDisplay" and "Orders Meridian Display" are not this. They
  25. * look like shared inboxes but each belongs to one person: Theo and
  26. * Rafael respectively. Only a bare "Meridian Display" is genuinely unowned.
  27. *
  28. * Those are the ones most likely to be missed, which is the entire reason for
  29. * wiring Fathom in. They are counted separately and surfaced on their own,
  30. * never silently distributed to anyone.
  31. */
  32. import { personByName, personFromEmail, type Person } from './meridian-people.js'
  33. /**
  34. * Fathom label -> roster display name, checked against the team's own mapping.
  35. *
  36. * Three labels look like shared inboxes and are in fact one person's account:
  37. *
  38. * 'office meridiandisplay' -> Theo Bassett
  39. * 'orders meridian display' -> Rafael Montero
  40. * 'studio@meridiandisplay.example' -> Marco Ibáñez
  41. *
  42. * Read as unowned, a mailbox-shaped label silently becomes "nobody's problem"
  43. * instead of landing on a person, and that compounds every week it goes
  44. * unnoticed.
  45. *
  46. * The pattern is consistent once you see it: the mailbox is the person.
  47. * studio@ is Marco, orderdesk@ is Rafael, frontdesk@ is Theo, hello@ is Sam.
  48. * The same logic explains the Plinth labels: "Marco-Acct7-Desk",
  49. * "Rafael-Prod-Desk".
  50. */
  51. export const FATHOM_NAME_MAP: Record<string, string> = {
  52. // Sam
  53. 'sam whitaker - meridian display': 'Sam Whitaker',
  54. 'sam whitaker': 'Sam Whitaker',
  55. 'hello@meridiandisplay.example': 'Sam Whitaker',
  56. // Hazel: former staff, and her personal spellings stay mapped on purpose.
  57. // Meetings she attended are in Fathom forever, and a recording that resolved
  58. // to her yesterday must not resolve to nobody today. She is no longer on the
  59. // roster, so these now attribute to a name the board shows no row for, which
  60. // is the honest outcome: her old meetings were hers.
  61. 'hazel o': 'Hazel Okafor',
  62. 'hazel okafor': 'Hazel Okafor',
  63. 'hazel.o@meridiandisplay.example': 'Hazel Okafor',
  64. // Rosa. The mailbox changed hands and this mapping moved with it.
  65. // A meeting joined from accounts@ before Rosa started was Hazel at that desk
  66. // and now attributes to Rosa. Nothing in the Fathom record distinguishes
  67. // them (the address is all there is), so this is a known and stated
  68. // imprecision on historical meetings, not a claim that she was in the room.
  69. 'accounts@meridiandisplay.example': 'Rosa Galván',
  70. 'rosa galván': 'Rosa Galván',
  71. 'rosa galvan': 'Rosa Galván',
  72. 'rosa': 'Rosa Galván',
  73. // Marco
  74. marco: 'Marco Ibáñez',
  75. 'marco ibáñez': 'Marco Ibáñez',
  76. // Arrives with a null email, so the label is the only route. The
  77. // leading-token fallback splits on | - @ and this string has none of them,
  78. // so without an explicit entry it resolves to `unknown`.
  79. 'marco ibáñez meridian display': 'Marco Ibáñez',
  80. 'studio@meridiandisplay.example': 'Marco Ibáñez',
  81. // Rafael
  82. 'rafael montero': 'Rafael Montero',
  83. rafael: 'Rafael Montero',
  84. 'orders meridiandisplay': 'Rafael Montero',
  85. 'orders meridian display': 'Rafael Montero',
  86. 'orderdesk@meridiandisplay.example': 'Rafael Montero',
  87. // Ines
  88. 'ines carvalho | meridian display': 'Ines Carvalho',
  89. 'ines c meridian display': 'Ines Carvalho',
  90. 'ines carvalho': 'Ines Carvalho',
  91. 'projects@meridiandisplay.example': 'Ines Carvalho',
  92. // Theo. theo@ is a second address: the roster knows frontdesk@, and he
  93. // also appears as an invitee under theo@.
  94. 'theo | meridiandisplay': 'Theo Bassett',
  95. theo: 'Theo Bassett',
  96. 'office meridiandisplay': 'Theo Bassett',
  97. 'frontdesk@meridiandisplay.example': 'Theo Bassett',
  98. 'theo@meridiandisplay.example': 'Theo Bassett',
  99. // Nora. nora@ is a second address the roster does not carry (it has
  100. // nora.l@), and some of her action items arrive from it. Without this entry
  101. // they resolve to "external" and reach nobody's view.
  102. 'nora l': 'Nora Lindqvist',
  103. 'nora lindqvist': 'Nora Lindqvist',
  104. 'nora lindqvist @ meridian display': 'Nora Lindqvist',
  105. 'nora.l@meridiandisplay.example': 'Nora Lindqvist',
  106. 'nora@meridiandisplay.example': 'Nora Lindqvist',
  107. }
  108. /**
  109. * Labels that mean "nobody was named". Not people, not a fallback bucket to
  110. * quietly assign to whoever ran the meeting.
  111. */
  112. export const UNOWNED_LABELS = new Set(['meridian display', 'meridiandisplay'])
  113. /**
  114. * People outside the roster who legitimately hold action items: Northgate staff
  115. * and vendors. Tracked so they are not mistaken for unowned work, but
  116. * they do not appear on the bandwidth board.
  117. */
  118. export const EXTERNAL_LABELS = new Set([
  119. 'priya nair',
  120. 'neha rao',
  121. 'vikram shah',
  122. 'rohan mehta',
  123. 'dana whitfield',
  124. ])
  125. export type Attribution =
  126. | { kind: 'roster'; person: Person }
  127. | { kind: 'unowned' }
  128. | { kind: 'external'; label: string }
  129. | { kind: 'unknown'; label: string }
  130. export function attributeFathomAssignee(label: string | null | undefined): Attribution {
  131. const raw = (label ?? '').trim()
  132. if (!raw) return { kind: 'unowned' }
  133. const key = raw.toLowerCase()
  134. if (UNOWNED_LABELS.has(key)) return { kind: 'unowned' }
  135. if (EXTERNAL_LABELS.has(key)) return { kind: 'external', label: raw }
  136. const mapped = FATHOM_NAME_MAP[key]
  137. if (mapped) {
  138. const p = personByName(mapped)
  139. if (p) return { kind: 'roster', person: p }
  140. }
  141. // Last resort: match on the leading token, which catches a new spelling of an
  142. // existing name without silently inventing a person.
  143. const leading = raw.split(/\s*[|\-@]/)[0]?.trim()
  144. if (leading) {
  145. const p = personByName(leading) ?? personByName(FATHOM_NAME_MAP[leading.toLowerCase()] ?? '')
  146. if (p) return { kind: 'roster', person: p }
  147. }
  148. return { kind: 'unknown', label: raw }
  149. }
  150. /**
  151. * A Fathom person as the live API actually sends one: a display name, an email,
  152. * and both of them nullable.
  153. *
  154. * `Assignee` and `Invitee` in the OpenAPI spec differ only in fields we do not
  155. * read, so one shape serves both.
  156. */
  157. export interface FathomRef {
  158. name: string | null
  159. email: string | null
  160. }
  161. /**
  162. * Attribute an assignee or a participant. Email first, label second.
  163. *
  164. * Display-name joins are this repo's most repeated failure, and
  165. * FATHOM_NAME_MAP exists because the same people arrive under up to two
  166. * spellings and three separators. An email is a key; a display name is a
  167. * guess.
  168. *
  169. * Email also avoids guessing on labels that cannot be resolved honestly: an
  170. * address the roster maps to one person can arrive with somebody else's
  171. * display name, and matching on the name would mean deciding which of them it
  172. * is, which is not a decision this file gets to make.
  173. *
  174. * But the label path is load-bearing, not a nicety: a real share of assignees
  175. * arrive with `email: null`.
  176. *
  177. * Order:
  178. * 1. Fathom's own alias table, which carries addresses the roster does not
  179. * (nora@ alongside nora.l@, theo@ alongside frontdesk@).
  180. * 2. The roster's own email index.
  181. * 3. The label, via attributeFathomAssignee.
  182. */
  183. export function attributeFathomRef(
  184. ref: FathomRef | null | undefined,
  185. /**
  186. * The meeting's invitee list, when the caller has it.
  187. *
  188. * An external person can hold an action item, and assignees
  189. * carry no `is_external` flag of their own, only invitees do. Without this the
  190. * only way to recognise them is a hand-maintained EXTERNAL_LABELS list, and
  191. * every client contact who is ever assigned something would surface as an
  192. * unmapped label until somebody added them by hand. That is the lag every
  193. * hand-kept table has, on a list that would grow forever.
  194. *
  195. * So we ask Fathom instead: if this person sat in the same meeting flagged
  196. * `is_external`, they are external. The source's own semantics beat our table.
  197. */
  198. invitees?: readonly (FathomRef & { is_external?: boolean })[] | null,
  199. ): Attribution {
  200. const email = (ref?.email ?? '').trim().toLowerCase()
  201. if (email) {
  202. const aliased = FATHOM_NAME_MAP[email]
  203. if (aliased) {
  204. const p = personByName(aliased)
  205. if (p) return { kind: 'roster', person: p }
  206. }
  207. const direct = personFromEmail(email)
  208. if (direct) return { kind: 'roster', person: direct }
  209. // A role mailbox whose items are routed to a person. Checked last so a real
  210. // person's own address is never overridden by a routing entry.
  211. const routedTo = ROUTED_ADDRESSES[email]
  212. if (routedTo) {
  213. const p = personByName(routedTo)
  214. if (p) return { kind: 'roster', person: p }
  215. }
  216. }
  217. // No email, or an address nobody claims. Fall through to the label, which also
  218. // handles the unowned and external cases.
  219. const byLabel = attributeFathomAssignee(ref?.name)
  220. if (byLabel.kind !== 'unknown') return byLabel
  221. // Last chance before `unknown`: did Fathom itself call this person external in
  222. // the meeting they were assigned in? Matched on email when there is one,
  223. // otherwise on the display name.
  224. const name = (ref?.name ?? '').trim().toLowerCase()
  225. for (const invitee of invitees ?? []) {
  226. if (invitee.is_external !== true) continue
  227. const iEmail = (invitee.email ?? '').trim().toLowerCase()
  228. const iName = (invitee.name ?? '').trim().toLowerCase()
  229. if ((email && iEmail === email) || (name && iName === name)) {
  230. return { kind: 'external', label: (ref?.name ?? '').trim() || iEmail }
  231. }
  232. }
  233. // An unresolvable address is more useful in the message than a bare name.
  234. return { kind: 'unknown', label: (ref?.name ?? '').trim() || email || '(no name or email)' }
  235. }
  236. /**
  237. * True when Fathom named nobody at all.
  238. *
  239. * Two shapes, and they must not be told apart on the page: the familiar bare
  240. * "Meridian Display" and a fully null `{name: null, email: null}`. Either way
  241. * somebody said the company would do a thing and no name attached to it: the
  242. * items most likely to be missed, which is the reason for wiring Fathom in.
  243. */
  244. export function isUnownedRef(ref: FathomRef | null | undefined): boolean {
  245. if (!ref) return true
  246. const name = (ref.name ?? '').trim()
  247. const email = (ref.email ?? '').trim()
  248. if (!name && !email) return true
  249. return !email && UNOWNED_LABELS.has(name.toLowerCase())
  250. }
  251. /**
  252. * Internal addresses that are deliberately not people, and so never viewers.
  253. *
  254. * director@meridiandisplay.example looks like it might be Nora's, but it has
  255. * the shape of a role alias, not a personal second address: it appears only
  256. * on invitations that also carry a Nora address, never alone, owns no action
  257. * items, and its display name is the role-shaped "Director MeridianDisplay".
  258. *
  259. * Mapping it to Nora would buy nothing and cost something. She already
  260. * resolves as a participant in those meetings through her own address, so no
  261. * visibility is gained, while a role alias mapped to a person grants whoever
  262. * that alias covers her access, silently, the day it covers somebody else.
  263. * That is a permission widening with nobody editing the access list, which
  264. * access control must never allow. "Probably" is not an observation (rule 1).
  265. *
  266. * Listed here rather than left in `unmatched` so it reads as a known non-person
  267. * instead of an open question. If it is ever confirmed to be one person's
  268. * address, move it to FATHOM_NAME_MAP: that is a one-line change and a
  269. * deliberate one.
  270. */
  271. export const KNOWN_NON_PERSON_ADDRESSES = new Set(['director@meridiandisplay.example'])
  272. /**
  273. * Role mailboxes whose action items are routed to a person.
  274. *
  275. * exec@meridiandisplay.example is the executive-assistant mailbox. It has no owner
  276. * at present, and its items land on Sam's list until better routing rules
  277. * exist. Flagged on purpose: this is a holding decision, not a fact about the
  278. * org, and it should not survive unexamined once those rules exist.
  279. *
  280. * Routing an item is not being in the room. An address here makes its action
  281. * items somebody's to-do. It does not make that somebody a participant of the
  282. * meetings the mailbox attended. participantsOf() treats these addresses like
  283. * KNOWN_NON_PERSON_ADDRESSES: acknowledged, never a viewer. So routing exec@ to
  284. * Sam widens what lands on his list and nothing about what he can see. If the
  285. * visibility is wanted too, that is a separate, deliberate line in
  286. * FATHOM_NAME_MAP, not a side effect of this one.
  287. */
  288. export const ROUTED_ADDRESSES: Record<string, string> = {
  289. 'exec@meridiandisplay.example': 'Sam Whitaker',
  290. }
  291. /** Who was in the room, split by whether the board knows them. */
  292. export interface Participants {
  293. /** Roster people. These are the viewers allowed to see the meeting. */
  294. roster: Person[]
  295. /** Recognised outsiders: clients, Northgate. Named, never counted as load. */
  296. external: string[]
  297. /**
  298. * Internal-looking addresses matching nobody on the roster. Surfaced, never
  299. * dropped: guessing who one is would be exactly the wrong move, and
  300. * dropping it silently would hide a person who can see a meeting the board
  301. * thinks has no such viewer. This list is meant to be empty; anything in it is
  302. * a question for a human.
  303. */
  304. unmatched: string[]
  305. /**
  306. * Addresses in KNOWN_NON_PERSON_ADDRESSES. Not viewers, and not a problem
  307. * either. Kept apart from `unmatched` so that list keeps meaning "somebody
  308. * needs to look at this".
  309. */
  310. acknowledged: string[]
  311. }
  312. /**
  313. * Resolve a meeting's invitee list to the people who may see it.
  314. *
  315. * Fails closed. A participant the board cannot resolve does not become a viewer;
  316. * they land in `unmatched` and are reported. The direction that matters: getting
  317. * less than you should is a bug report, getting more is an incident.
  318. *
  319. * `is_external` comes from Fathom rather than from a domain list here, because
  320. * the source's own semantics beat a hand-maintained table: Northgate alone
  321. * arrives under two domains, northgate.example and northgate-studio.example.
  322. */
  323. export function participantsOf(
  324. invitees: readonly (FathomRef & { is_external?: boolean })[] | null | undefined,
  325. ): Participants {
  326. const roster: Person[] = []
  327. const external: string[] = []
  328. const unmatched: string[] = []
  329. const acknowledged: string[] = []
  330. for (const invitee of invitees ?? []) {
  331. const label = (invitee.name ?? '').trim() || (invitee.email ?? '').trim() || '(unnamed)'
  332. const addr = (invitee.email ?? '').trim().toLowerCase()
  333. // Not people, or routed mailboxes: acknowledged, never viewers. A routed
  334. // address must be caught here, before attributeFathomRef() would resolve it
  335. // to the person its items go to. Otherwise routing exec@'s tasks to Sam
  336. // would quietly make Sam a participant of every meeting exec@ attended.
  337. if (addr && (KNOWN_NON_PERSON_ADDRESSES.has(addr) || addr in ROUTED_ADDRESSES)) {
  338. if (!acknowledged.includes(label)) acknowledged.push(label)
  339. continue
  340. }
  341. if (invitee.is_external === true) {
  342. if (!external.includes(label)) external.push(label)
  343. continue
  344. }
  345. const attributed = attributeFathomRef(invitee, invitees)
  346. if (attributed.kind === 'roster') {
  347. if (!roster.some((p) => p.name === attributed.person.name)) roster.push(attributed.person)
  348. } else if (attributed.kind === 'external') {
  349. if (!external.includes(attributed.label)) external.push(attributed.label)
  350. } else if (!unmatched.includes(label)) {
  351. unmatched.push(label)
  352. }
  353. }
  354. return { roster, external, unmatched, acknowledged }
  355. }
  356. /**
  357. * Meeting commitments have no reliable completion state, so an all-time count
  358. * only ever grows and stops meaning anything. Two weeks is what a person can
  359. * still act on; older than that has either been done or genuinely dropped, and
  360. * either way it is a different conversation.
  361. */
  362. export const COMMITMENT_WINDOW_DAYS = 14