fathom-people.ts
Language TypeScript16.0 KB383 lines
/*** Fathom assignee label -> roster person.** A third naming convention, different again from Plinth and Bitrix. As* observed:** Sam Whitaker - Meridian Display* Ines Carvalho | Meridian Display and Ines C Meridian Display* Theo | MeridianDisplay* Rafael Montero* Marco* Hazel O* Nora L and Nora Lindqvist @ Meridian Display** Same person, up to two spellings, three separators, and no surname on two of* them. Mapping is explicit for that reason.** The important one: "Meridian Display" is not a person.** Fathom assigns to a bare "Meridian Display" when the transcript never named an* owner. That is an unowned commitment: somebody said the company would do a* thing and nobody's name attached to it.** Note that "Office MeridianDisplay" and "Orders Meridian Display" are not this. They* look like shared inboxes but each belongs to one person: Theo and* Rafael respectively. Only a bare "Meridian Display" is genuinely unowned.** Those are the ones most likely to be missed, which is the entire reason for* wiring Fathom in. They are counted separately and surfaced on their own,* never silently distributed to anyone.*/import { personByName, personFromEmail, type Person } from './meridian-people.js'/*** Fathom label -> roster display name, checked against the team's own mapping.** Three labels look like shared inboxes and are in fact one person's account:** 'office meridiandisplay' -> Theo Bassett* 'orders meridian display' -> Rafael Montero* 'studio@meridiandisplay.example' -> Marco Ibáñez** Read as unowned, a mailbox-shaped label silently becomes "nobody's problem"* instead of landing on a person, and that compounds every week it goes* unnoticed.** The pattern is consistent once you see it: the mailbox is the person.* studio@ is Marco, orderdesk@ is Rafael, frontdesk@ is Theo, hello@ is Sam.* The same logic explains the Plinth labels: "Marco-Acct7-Desk",* "Rafael-Prod-Desk".*/export const FATHOM_NAME_MAP: Record<string, string> = {// Sam'sam whitaker - meridian display': 'Sam Whitaker','sam whitaker': 'Sam Whitaker','hello@meridiandisplay.example': 'Sam Whitaker',// Hazel: former staff, and her personal spellings stay mapped on purpose.// Meetings she attended are in Fathom forever, and a recording that resolved// to her yesterday must not resolve to nobody today. She is no longer on the// roster, so these now attribute to a name the board shows no row for, which// is the honest outcome: her old meetings were hers.'hazel o': 'Hazel Okafor','hazel okafor': 'Hazel Okafor','hazel.o@meridiandisplay.example': 'Hazel Okafor',// Rosa. The mailbox changed hands and this mapping moved with it.// A meeting joined from accounts@ before Rosa started was Hazel at that desk// and now attributes to Rosa. Nothing in the Fathom record distinguishes// them (the address is all there is), so this is a known and stated// imprecision on historical meetings, not a claim that she was in the room.'accounts@meridiandisplay.example': 'Rosa Galván','rosa galván': 'Rosa Galván','rosa galvan': 'Rosa Galván','rosa': 'Rosa Galván',// Marcomarco: 'Marco Ibáñez','marco ibáñez': 'Marco Ibáñez',// Arrives with a null email, so the label is the only route. The// leading-token fallback splits on | - @ and this string has none of them,// so without an explicit entry it resolves to `unknown`.'marco ibáñez meridian display': 'Marco Ibáñez','studio@meridiandisplay.example': 'Marco Ibáñez',// Rafael'rafael montero': 'Rafael Montero',rafael: 'Rafael Montero','orders meridiandisplay': 'Rafael Montero','orders meridian display': 'Rafael Montero','orderdesk@meridiandisplay.example': 'Rafael Montero',// Ines'ines carvalho | meridian display': 'Ines Carvalho','ines c meridian display': 'Ines Carvalho','ines carvalho': 'Ines Carvalho','projects@meridiandisplay.example': 'Ines Carvalho',// Theo. theo@ is a second address: the roster knows frontdesk@, and he// also appears as an invitee under theo@.'theo | meridiandisplay': 'Theo Bassett',theo: 'Theo Bassett','office meridiandisplay': 'Theo Bassett','frontdesk@meridiandisplay.example': 'Theo Bassett','theo@meridiandisplay.example': 'Theo Bassett',// Nora. nora@ is a second address the roster does not carry (it has// nora.l@), and some of her action items arrive from it. Without this entry// they resolve to "external" and reach nobody's view.'nora l': 'Nora Lindqvist','nora lindqvist': 'Nora Lindqvist','nora lindqvist @ meridian display': 'Nora Lindqvist','nora.l@meridiandisplay.example': 'Nora Lindqvist','nora@meridiandisplay.example': 'Nora Lindqvist',}/*** Labels that mean "nobody was named". Not people, not a fallback bucket to* quietly assign to whoever ran the meeting.*/export const UNOWNED_LABELS = new Set(['meridian display', 'meridiandisplay'])/*** People outside the roster who legitimately hold action items: Northgate staff* and vendors. Tracked so they are not mistaken for unowned work, but* they do not appear on the bandwidth board.*/export const EXTERNAL_LABELS = new Set(['priya nair','neha rao','vikram shah','rohan mehta','dana whitfield',])export type Attribution =| { kind: 'roster'; person: Person }| { kind: 'unowned' }| { kind: 'external'; label: string }| { kind: 'unknown'; label: string }export function attributeFathomAssignee(label: string | null | undefined): Attribution {const raw = (label ?? '').trim()if (!raw) return { kind: 'unowned' }const key = raw.toLowerCase()if (UNOWNED_LABELS.has(key)) return { kind: 'unowned' }if (EXTERNAL_LABELS.has(key)) return { kind: 'external', label: raw }const mapped = FATHOM_NAME_MAP[key]if (mapped) {const p = personByName(mapped)if (p) return { kind: 'roster', person: p }}// Last resort: match on the leading token, which catches a new spelling of an// existing name without silently inventing a person.const leading = raw.split(/\s*[|\-@]/)[0]?.trim()if (leading) {const p = personByName(leading) ?? personByName(FATHOM_NAME_MAP[leading.toLowerCase()] ?? '')if (p) return { kind: 'roster', person: p }}return { kind: 'unknown', label: raw }}/*** A Fathom person as the live API actually sends one: a display name, an email,* and both of them nullable.** `Assignee` and `Invitee` in the OpenAPI spec differ only in fields we do not* read, so one shape serves both.*/export interface FathomRef {name: string | nullemail: string | null}/*** Attribute an assignee or a participant. Email first, label second.** Display-name joins are this repo's most repeated failure, and* FATHOM_NAME_MAP exists because the same people arrive under up to two* spellings and three separators. An email is a key; a display name is a* guess.** Email also avoids guessing on labels that cannot be resolved honestly: an* address the roster maps to one person can arrive with somebody else's* display name, and matching on the name would mean deciding which of them it* is, which is not a decision this file gets to make.** But the label path is load-bearing, not a nicety: a real share of assignees* arrive with `email: null`.** Order:* 1. Fathom's own alias table, which carries addresses the roster does not* (nora@ alongside nora.l@, theo@ alongside frontdesk@).* 2. The roster's own email index.* 3. The label, via attributeFathomAssignee.*/export function attributeFathomRef(ref: FathomRef | null | undefined,/*** The meeting's invitee list, when the caller has it.** An external person can hold an action item, and assignees* carry no `is_external` flag of their own, only invitees do. Without this the* only way to recognise them is a hand-maintained EXTERNAL_LABELS list, and* every client contact who is ever assigned something would surface as an* unmapped label until somebody added them by hand. That is the lag every* hand-kept table has, on a list that would grow forever.** So we ask Fathom instead: if this person sat in the same meeting flagged* `is_external`, they are external. The source's own semantics beat our table.*/invitees?: readonly (FathomRef & { is_external?: boolean })[] | null,): Attribution {const email = (ref?.email ?? '').trim().toLowerCase()if (email) {const aliased = FATHOM_NAME_MAP[email]if (aliased) {const p = personByName(aliased)if (p) return { kind: 'roster', person: p }}const direct = personFromEmail(email)if (direct) return { kind: 'roster', person: direct }// A role mailbox whose items are routed to a person. Checked last so a real// person's own address is never overridden by a routing entry.const routedTo = ROUTED_ADDRESSES[email]if (routedTo) {const p = personByName(routedTo)if (p) return { kind: 'roster', person: p }}}// No email, or an address nobody claims. Fall through to the label, which also// handles the unowned and external cases.const byLabel = attributeFathomAssignee(ref?.name)if (byLabel.kind !== 'unknown') return byLabel// Last chance before `unknown`: did Fathom itself call this person external in// the meeting they were assigned in? Matched on email when there is one,// otherwise on the display name.const name = (ref?.name ?? '').trim().toLowerCase()for (const invitee of invitees ?? []) {if (invitee.is_external !== true) continueconst iEmail = (invitee.email ?? '').trim().toLowerCase()const iName = (invitee.name ?? '').trim().toLowerCase()if ((email && iEmail === email) || (name && iName === name)) {return { kind: 'external', label: (ref?.name ?? '').trim() || iEmail }}}// An unresolvable address is more useful in the message than a bare name.return { kind: 'unknown', label: (ref?.name ?? '').trim() || email || '(no name or email)' }}/*** True when Fathom named nobody at all.** Two shapes, and they must not be told apart on the page: the familiar bare* "Meridian Display" and a fully null `{name: null, email: null}`. Either way* somebody said the company would do a thing and no name attached to it: the* items most likely to be missed, which is the reason for wiring Fathom in.*/export function isUnownedRef(ref: FathomRef | null | undefined): boolean {if (!ref) return trueconst name = (ref.name ?? '').trim()const email = (ref.email ?? '').trim()if (!name && !email) return truereturn !email && UNOWNED_LABELS.has(name.toLowerCase())}/*** Internal addresses that are deliberately not people, and so never viewers.** director@meridiandisplay.example looks like it might be Nora's, but it has* the shape of a role alias, not a personal second address: it appears only* on invitations that also carry a Nora address, never alone, owns no action* items, and its display name is the role-shaped "Director MeridianDisplay".** Mapping it to Nora would buy nothing and cost something. She already* resolves as a participant in those meetings through her own address, so no* visibility is gained, while a role alias mapped to a person grants whoever* that alias covers her access, silently, the day it covers somebody else.* That is a permission widening with nobody editing the access list, which* access control must never allow. "Probably" is not an observation (rule 1).** Listed here rather than left in `unmatched` so it reads as a known non-person* instead of an open question. If it is ever confirmed to be one person's* address, move it to FATHOM_NAME_MAP: that is a one-line change and a* deliberate one.*/export const KNOWN_NON_PERSON_ADDRESSES = new Set(['director@meridiandisplay.example'])/*** Role mailboxes whose action items are routed to a person.** exec@meridiandisplay.example is the executive-assistant mailbox. It has no owner* at present, and its items land on Sam's list until better routing rules* exist. Flagged on purpose: this is a holding decision, not a fact about the* org, and it should not survive unexamined once those rules exist.** Routing an item is not being in the room. An address here makes its action* items somebody's to-do. It does not make that somebody a participant of the* meetings the mailbox attended. participantsOf() treats these addresses like* KNOWN_NON_PERSON_ADDRESSES: acknowledged, never a viewer. So routing exec@ to* Sam widens what lands on his list and nothing about what he can see. If the* visibility is wanted too, that is a separate, deliberate line in* FATHOM_NAME_MAP, not a side effect of this one.*/export const ROUTED_ADDRESSES: Record<string, string> = {'exec@meridiandisplay.example': 'Sam Whitaker',}/** Who was in the room, split by whether the board knows them. */export interface Participants {/** Roster people. These are the viewers allowed to see the meeting. */roster: Person[]/** Recognised outsiders: clients, Northgate. Named, never counted as load. */external: string[]/*** Internal-looking addresses matching nobody on the roster. Surfaced, never* dropped: guessing who one is would be exactly the wrong move, and* dropping it silently would hide a person who can see a meeting the board* thinks has no such viewer. This list is meant to be empty; anything in it is* a question for a human.*/unmatched: string[]/*** Addresses in KNOWN_NON_PERSON_ADDRESSES. Not viewers, and not a problem* either. Kept apart from `unmatched` so that list keeps meaning "somebody* needs to look at this".*/acknowledged: string[]}/*** Resolve a meeting's invitee list to the people who may see it.** Fails closed. A participant the board cannot resolve does not become a viewer;* they land in `unmatched` and are reported. The direction that matters: getting* less than you should is a bug report, getting more is an incident.** `is_external` comes from Fathom rather than from a domain list here, because* the source's own semantics beat a hand-maintained table: Northgate alone* arrives under two domains, northgate.example and northgate-studio.example.*/export function participantsOf(invitees: readonly (FathomRef & { is_external?: boolean })[] | null | undefined,): Participants {const roster: Person[] = []const external: string[] = []const unmatched: string[] = []const acknowledged: string[] = []for (const invitee of invitees ?? []) {const label = (invitee.name ?? '').trim() || (invitee.email ?? '').trim() || '(unnamed)'const addr = (invitee.email ?? '').trim().toLowerCase()// Not people, or routed mailboxes: acknowledged, never viewers. A routed// address must be caught here, before attributeFathomRef() would resolve it// to the person its items go to. Otherwise routing exec@'s tasks to Sam// would quietly make Sam a participant of every meeting exec@ attended.if (addr && (KNOWN_NON_PERSON_ADDRESSES.has(addr) || addr in ROUTED_ADDRESSES)) {if (!acknowledged.includes(label)) acknowledged.push(label)continue}if (invitee.is_external === true) {if (!external.includes(label)) external.push(label)continue}const attributed = attributeFathomRef(invitee, invitees)if (attributed.kind === 'roster') {if (!roster.some((p) => p.name === attributed.person.name)) roster.push(attributed.person)} else if (attributed.kind === 'external') {if (!external.includes(attributed.label)) external.push(attributed.label)} else if (!unmatched.includes(label)) {unmatched.push(label)}}return { roster, external, unmatched, acknowledged }}/*** Meeting commitments have no reliable completion state, so an all-time count* only ever grows and stops meaning anything. Two weeks is what a person can* still act on; older than that has either been done or genuinely dropped, and* either way it is a different conversation.*/export const COMMITMENT_WINDOW_DAYS = 14