meridian-people.ts
Language TypeScript22.7 KB555 lines
/*** The Meridian roster: the dashboard's primary axis.** The board is a bird's-eye view of per-person workload: who is loaded, who* is not, at a glance. It is organized around the people below rather than* around queues; queues are the supporting detail.** No single system covers everyone:** Plinth production owner Marco, Rafael (designers)* Plinth account owner Sam, Rosa (CS reps)* Bitrix leads effectively one CS rep only: the assignee field* is a routing default, so it is not used for workload* Bitrix tasks whoever is set as RESPONSIBLE_ID** So some people have no Plinth assignment at all. If the page rendered them* at zero next to a designer at nine, a reader would take that as spare* capacity and route work to them: the precise failure this board exists to* prevent, arrived at faster and with more authority because a screen said it.** Partial coverage is normal, not an error. Nobody is covered by every source and plenty of fields come back null. That is* expected and the page should not treat it as a fault: no red banners, no* warnings. It is a quiet annotation on the row.** What matters is only this: a person with no covering source must render as* "not tracked", never as a zero. An empty cell and an untracked one look* identical to someone scanning quickly, and only one of them means free* capacity. That single distinction is what keeps this view honest.*/export type Source =| 'plinth-designer'| 'plinth-client-success'| 'bitrix-tasks'| 'ninety-todos'| 'content-tracker'export interface Person {/** Display name. Also the key the Plinth account labels map onto. */name: stringrole: string/** Plinth account labels, exactly as they appear in the export. */plinthAccounts: string[]/*** Ninety user id: the only reliable join. Filled for everyone on the roster;* keep it that way.** Ninety returns `ownerFirstName` / `ownerLastName` inline on every company* to-do, spelled as someone typed them there ("Samuel Whitaker", "Marco* Ibanez" against a roster reading "Sam Whitaker", "Marco Ibáñez"). A* display-name join silently matches nobody for those people, and their* briefings show zero to-dos as though they were idle.** Names are typed by humans in another system and will keep drifting. Ids do* not. Get a new one from `pnpm roster`, or directly:* curl -H "Authorization: Bearer $NINETY_TOKEN" \* https://api.public.ninety.io/v1/users** Optional in the type only because a new hire is added here before anyone has* run `pnpm roster` to get their id. `pnpm company` reports anyone resolving by* name instead of by id, so an unfilled one does not stay unnoticed.*/ninetyId?: string/*** Spellings seen in other systems. Empty for everyone, deliberately: every* `ninetyId` is pinned, and aliases would rot into a second, staler source of* truth.** The mechanism stays, because deleting it would mean a re-created Ninety* account (new id, same person) silently dropping off the board, which is* worse than a loose match. But a match made this way is now reported rather* than absorbed: see `resolveNinety()`.*/aliases?: string[]/*** Google Workspace address, as Cloudflare Access asserts it.** The join that decides what somebody sees. Access authenticates against* Workspace and passes the address in a signed JWT; the Worker maps it back to* this roster to pick which briefing to serve. It must be the Workspace* address, which is not derivable from the display name: several people sign* in with a role mailbox such as `hello@meridiandisplay.example`.** An address here that nobody signs in with is harmless. An address that* belongs to somebody else would show them another person's briefing, so this* field is never guessed.** Absent means that person cannot be identified and falls back to the* company-only view, same as anyone not on the roster.*/email?: string/*** May read the Leadership Team to-dos.** Granted per person, by name, and never inherited with a row (see Rosa's).* The designers are deliberately not on it. This is a viewing right and does* not track who holds Leadership work: a person can hold none and still see* it.*/seesLeadership?: boolean/*** May read the executive briefing. Exactly one person, checked at load.** A hard requirement (the executive briefing has one reader), so not a soft* toggle. The exec brief is omitted from every other viewer's page at render* time rather than hidden in the markup, because hidden markup is not hidden:* anyone can open view-source.*/seesExec?: boolean/*** Bitrix user id, for tasks. Read from `user.get` and pinned for everyone on* the roster; see `personFromBitrix()` for why this is an id match and not a* name one.** Optional in the type only so a new hire can be added here before anyone runs* the lookup. An unfilled one is reported rather than absorbed.*/bitrixUserId?: number/*** Which sources can say anything about this person's load.* Empty means the dashboard genuinely cannot see their work.*/coveredBy: Source[]/** Shown on the page when coverage is partial or absent. */coverageNote?: string}export const MERIDIAN_PEOPLE: Person[] = [{name: 'Sam Whitaker',role: 'Client Success',plinthAccounts: ['Sam-Accounts-Desk'],ninetyId: '3e894807d73d5a39b03fb411',bitrixUserId: 1924,email: 'hello@meridiandisplay.example',seesLeadership: true,coveredBy: ['plinth-client-success', 'bitrix-tasks', 'ninety-todos', 'content-tracker'],},{/*** Rosa holds the Client Success role and mailbox that Hazel held, so she* took over that row rather than being added beside it. Only the fields* that describe the role transfer:** plinthAccounts transfers: Plinth still holds the literal string* "Hazel (Desk 2)" and that value now means Rosa, so* orders assigned before the change show as hers. That* is the stated meaning of the reused label.** ninetyId does not: an id is a person's account, and reusing* bitrixUserId one would attribute somebody else's work to Rosa.* Both below are her own. The Bitrix id cannot be* verified from this repo, which holds no credential;* a credentialled `pnpm dashboard` confirms it by* attributing her tasks, or Data health names her as* uncovered.** seesLeadership is absent by decision, not by default: reading the* Leadership Team's to-dos is set per person by name and was not granted.* The inbox is granted separately in config/inbox.ts. Getting less than* you should is a bug report; getting more is an incident.*/name: 'Rosa Galván',role: 'Client Success',plinthAccounts: ['Hazel (Desk 2)'],ninetyId: '40a05e495e934c8a2eb4c690',bitrixUserId: 11776,email: 'accounts@meridiandisplay.example',// Each source is listed only with its id. An id without the source it// unlocks, or the source without the id, is a known shape of bug:// collected, attributed, and then dropped before it reaches the page.// content-tracker is absent because she owns no tracker rows.coveredBy: ['plinth-client-success', 'ninety-todos', 'bitrix-tasks'],coverageNote:'New to the role. Her Plinth orders arrive through the unchanged "Hazel (Desk 2)" ' +'assignment, so that count includes orders assigned before she got here. That is the ' +'inherited queue, not a day-one workload. Her Ninety and Bitrix ids are her own accounts, so ' +'those two count only her own work.',},{name: 'Marco Ibáñez',role: 'Designer',plinthAccounts: ['Marco-Acct7-Desk'],ninetyId: '8466ef547c9f9a6ee92fe8a0',bitrixUserId: 7108,email: 'studio@meridiandisplay.example',coveredBy: ['plinth-designer', 'bitrix-tasks', 'ninety-todos', 'content-tracker'],},{name: 'Rafael Montero',role: 'Designer',plinthAccounts: ['Rafael-Prod-Desk'],ninetyId: '05b7014d82e5b5ed5e68b309',bitrixUserId: 9256,email: 'orderdesk@meridiandisplay.example',coveredBy: ['plinth-designer', 'bitrix-tasks', 'ninety-todos', 'content-tracker'],},{name: 'Ines Carvalho',role: 'Marketing',plinthAccounts: [],ninetyId: '4e77bc9877e856992de26b64',bitrixUserId: 7404,email: 'projects@meridiandisplay.example',seesLeadership: true,coveredBy: ['bitrix-tasks', 'ninety-todos', 'content-tracker'],coverageNote:'No Plinth assignment. Ines is never a production owner or account owner, so her ' +'order work is invisible here. Only Bitrix tasks are counted, which is a partial view.',},{name: 'Theo Bassett',role: 'Operations',plinthAccounts: [],ninetyId: '6d8c1631dc0c3eda4c26b839',bitrixUserId: 1698,email: 'frontdesk@meridiandisplay.example',seesLeadership: true,coveredBy: ['bitrix-tasks', 'ninety-todos', 'content-tracker'],coverageNote:'No Plinth assignment. Only Bitrix tasks are counted, which is a partial view.',},{name: 'Nora Lindqvist',role: 'Owner / General Manager',plinthAccounts: [],ninetyId: '8326a8602530a97b5afd91ae',bitrixUserId: 1,email: 'nora.l@meridiandisplay.example',seesLeadership: true,seesExec: true,coveredBy: ['bitrix-tasks', 'ninety-todos', 'content-tracker'],coverageNote:'No Plinth assignment. Ninety commitments and Bitrix tasks only, which is a partial view.',},]/*** People in the Ninety directory who are deliberately not on this roster.** Ninety's user list is not the Meridian team: it also holds former staff and* outside collaborators. Without this, every sweep reports them as "unmapped"* and someone eventually chases a coverage gap that does not exist.** Listing them is not the same as hiding them. An unmapped owner is a question* to answer once; an entry here is that answer, written down.*/export const KNOWN_NON_ROSTER_NINETY = ['Grant Holloway', // former business coach, no longer with Meridian/*** Former client success; Rosa Galván took over the role and the mailbox.* Listed here so the sweep stops reporting her as an unmapped owner: the* answer to that question, written down.** This does not say that her open to-dos went anywhere. Anything still* assigned to her Ninety account is now held by nobody on this roster and* appears in no per-person view. If that matters, the fix is in Ninety:* reassign them, not here.*/'Hazel Okafor',]/*** The board's primary audience is also a row.** The person managing everyone's bandwidth can hold more open commitments than* anyone, and leaving them out would resolve that work to nobody: on no* per-person view, while still counting in every company total. A capacity* view that omits its largest single holder is not a capacity view.* And her own leadership brief should include her own load, or it is asking her* to reason about everyone's bandwidth except her own.*/export const AUDIENCE = 'Nora'const byAccount = new Map<string, Person>()for (const p of MERIDIAN_PEOPLE) for (const a of p.plinthAccounts) byAccount.set(a.toLowerCase(), p)const byName = new Map(MERIDIAN_PEOPLE.map((p) => [p.name.toLowerCase(), p]))/** Canonical name and every known alias, lowercased. */const byAnyName = new Map<string, Person>()for (const p of MERIDIAN_PEOPLE) {byAnyName.set(p.name.toLowerCase(), p)for (const a of p.aliases ?? []) byAnyName.set(a.toLowerCase(), p)}const byEmail = new Map<string, Person>()for (const p of MERIDIAN_PEOPLE) if (p.email) byEmail.set(p.email.trim().toLowerCase(), p)/*** Google Workspace address -> roster person. Null rather than a guess.** Null is the safe return and callers must treat it that way: an address this* roster does not know gets the company-only view, never someone else's. There* is deliberately no fuzzy match, no domain-prefix rule, no fallback to display* name. Most of the addresses are role mailboxes (hello@, orderdesk@, accounts@,* frontdesk@, projects@), so any rule inferred from names would be wrong for* almost everyone, and wrong here means showing one person another's briefing.*/export function personFromEmail(email: string | null | undefined): Person | null {const key = (email ?? '').trim().toLowerCase()return key ? (byEmail.get(key) ?? null) : null}/*** Load-time invariants. These are access-control facts, so a typo must fail* the build rather than quietly widening who can read what. A duplicated* address would hand two people the same identity; a second `seesExec` would* break the exec brief's single-reader rule.*/const emailCount = MERIDIAN_PEOPLE.filter((p) => p.email).lengthif (byEmail.size !== emailCount) {throw new Error(`config/meridian-people.ts: ${emailCount} people carry an email but only ${byEmail.size} are ` +`distinct. Two people share an address, which would give them the same identity.`,)}const execHolders = MERIDIAN_PEOPLE.filter((p) => p.seesExec).map((p) => p.name)if (execHolders.length !== 1) {throw new Error(`config/meridian-people.ts: exactly one person may have seesExec; found ${execHolders.length}` +(execHolders.length ? ` (${execHolders.join(', ')})` : '') +`. The executive briefing is for one reader by decision.`,)}for (const p of MERIDIAN_PEOPLE) {if (p.seesExec && !p.seesLeadership) {throw new Error(`config/meridian-people.ts: ${p.name} sees the exec briefing but not Leadership to-dos. ` +`The exec view summarises them, so that combination leaks by summary.`,)}}const byNinetyId = new Map<string, Person>()for (const p of MERIDIAN_PEOPLE) if (p.ninetyId) byNinetyId.set(p.ninetyId, p)/*** How a Ninety owner was matched to a roster person.** `via: 'name'` is news, not a detail. Every person carries a `ninetyId`, so a* name match means the id Ninety sent is one this roster has never seen: a* re-created account, or an id that changed underneath us. The* work still attributes correctly, which is exactly why it would otherwise go* unnoticed until the name drifted too and the person quietly emptied out.** A fallback standing in for knowledge has to be surfaced every run, not* asserted and forgotten (rule 2).*/export interface NinetyMatch {person: Person | nullvia: 'id' | 'name' | null}export function resolveNinety(ownerId: string | null | undefined,ownerName: string | null | undefined,): NinetyMatch {if (ownerId) {const byId = byNinetyId.get(ownerId)if (byId) return { person: byId, via: 'id' }}if (ownerName?.trim()) {const byNameMatch = byAnyName.get(ownerName.trim().toLowerCase())if (byNameMatch) return { person: byNameMatch, via: 'name' }}return { person: null, via: null }}/*** Ninety user -> roster person. Id first, name second, and null rather than a* guess.** Null is the important return. Absorbed into an empty list, an unmatched* owner renders as having no work, indistinguishable from being idle. Callers* must surface a null as "unmapped". See `unmappedNinetyOwners()`.** Use `resolveNinety()` instead when you can report how the match was made.*/export function personFromNinety(ownerId: string | null | undefined,ownerName: string | null | undefined,): Person | null {return resolveNinety(ownerId, ownerName).person}/*** Ninety owners that match nobody on the roster. Non-empty is a problem to fix,* not a curiosity: every to-do behind one of these names is missing from the* board, and missing looks exactly like finished.*/export function unmappedNinetyOwners(owners: { id: string; name: string }[],): { id: string; name: string }[] {const known = new Set(KNOWN_NON_ROSTER_NINETY.map((n) => n.toLowerCase()))const seen = new Map<string, { id: string; name: string }>()for (const o of owners) {if (known.has(o.name.trim().toLowerCase())) continueif (!personFromNinety(o.id, o.name)) seen.set(o.id || o.name, o)}return [...seen.values()]}/** Plinth account label -> roster person. */export function personFromPlinthAccount(label: string | null | undefined): Person | null {if (!label?.trim()) return nullreturn byAccount.get(label.trim().toLowerCase()) ?? null}export function personByName(name: string | null | undefined): Person | null {if (!name?.trim()) return nullreturn byName.get(name.trim().toLowerCase()) ?? null}export const SOURCE_LABELS: Record<Source, string> = {'content-tracker': 'Content Creation tracker','plinth-designer': 'Plinth design assignments','plinth-client-success': 'Plinth client-success assignments','bitrix-tasks': 'Bitrix tasks','ninety-todos': 'Ninety to-dos',}const byBitrixId = new Map<string, Person>()for (const p of MERIDIAN_PEOPLE) if (p.bitrixUserId) byBitrixId.set(String(p.bitrixUserId), p)/*** Bitrix `responsibleId` -> roster person. Id only. No name fallback, on purpose.** `Task.responsibleId` is Bitrix's numeric user id, stringified, so it must be* matched against `bitrixUserId`. Passing it to `personByName()` looks up a* display name and never matches, which renders every person at "+ 0 tasks".** No name fallback, unlike `resolveNinety()`. Bitrix does not send a name on* a task at all (only the id), so there is nothing to fall back to. And the* directory holds 64 accounts against a roster of 7, including "Meeting Room",* "Front Desk" and "Backup-Paige Lorenz". Fuzzy matching into that is a way to* attribute someone else's work to a colleague.** Null is the common case here, and it is not an error. Most of the accounts* are deliberately out of scope, so a null means "not one of the* seven", not "attribution failed". Callers count them via* `offRosterBitrixIds()` so they are stated rather than silently absorbed, and* must not raise them as coverage gaps.*/export function personFromBitrix(responsibleId: string | null | undefined): Person | null {if (!responsibleId?.trim()) return nullreturn byBitrixId.get(responsibleId.trim()) ?? null}/*** Task-holding Bitrix ids that are nobody on the roster, with their task counts.** Not the same thing as `unmappedNinetyOwners()`, and the difference is the* whole point. An unmapped Ninety owner is a bug: Ninety is company-scoped and* every owner in it should be on the roster. An off-roster Bitrix id is expected:* the directory is the whole company's history, 40 of 64 accounts are already* marked inactive by Bitrix, and the dashboard covers seven people.** So this exists to be reported quietly on Data health, not to be chased. What it* guards against is the other failure: a roster person's id going stale and their* tasks quietly joining this bucket, which would look exactly like them having* nothing to do.*/export function offRosterBitrixIds(responsibleIds: (string | null | undefined)[],): { id: string; tasks: number }[] {const counts = new Map<string, number>()for (const id of responsibleIds) {const key = id?.trim()if (!key || personFromBitrix(key)) continuecounts.set(key, (counts.get(key) ?? 0) + 1)}return [...counts.entries()].map(([id, tasks]) => ({ id, tasks })).sort((a, b) => b.tasks - a.tasks || Number(a.id) - Number(b.id))}/*** The Content Creation tracker spells owners as first names: "Rafael",* "Marco", "Nora & Sam". It is a spreadsheet a human types into, so there is no* id to match on and there never will be.** First-name matching is only safe because it is checked. The load-time* assertion below refuses to start if two roster people ever share a first name,* which is the exact day this join would begin attributing one person's work to* another and never say so. A second "Sam" or "Marco" is a hiring decision, not a* code change, so the failure has to come from the roster rather than from* review.** Scoped deliberately: this is not a general `personByFirstName()`. Every other* source on this board carries an id, and none of them should be tempted into* matching this way.*/const byFirstName = new Map<string, Person>()for (const p of MERIDIAN_PEOPLE) {const first = p.name.trim().split(/\s+/)[0]!.toLowerCase()const clash = byFirstName.get(first)if (clash) {throw new Error(`config/meridian-people.ts: ${clash.name} and ${p.name} share the first name ` +`"${first}". The Content Creation tracker names owners by first name only, ` +`so this would silently attribute one person's work to the other. Give the ` +`tracker a way to tell them apart before adding the second one.`,)}byFirstName.set(first, p)}/*** Tracker Owner cell -> roster person. Null for anyone not on the roster.** Null must be surfaced, never absorbed: a row behind an unmapped owner is* missing from every per-person view, and missing looks exactly like finished.* See `unmappedTrackerOwners()`.*/export function personFromTrackerOwner(owner: string | null | undefined): Person | null {const key = (owner ?? '').trim().toLowerCase()if (!key) return nullreturn byFirstName.get(key) ?? byName.get(key) ?? null}/** Tracker owner spellings that match nobody on the roster. */export function unmappedTrackerOwners(owners: string[]): string[] {const seen = new Set<string>()for (const o of owners) {const key = o.trim()if (key && !personFromTrackerOwner(key)) seen.add(key)}return [...seen].sort()}/** Roster people with no Bitrix id pinned. Non-empty means tasks go unattributed. */export function unpinnedBitrixPeople(): string[] {return MERIDIAN_PEOPLE.filter((p) => p.coveredBy.includes('bitrix-tasks') && !p.bitrixUserId).map((p) => p.name,)}/*** Roster people Bitrix cannot see at all: no id, and no claim to be covered.** The sibling of `unpinnedBitrixPeople`, and a different fact. That one reports* a person the board says Bitrix covers who has no id: a bug, because their* tasks silently fail to attribute. This one reports a person nothing claims to* cover (a new hire, typically): not a bug, but not nothing either. Their task* count is blank, and this is how the page says why.** Takes its list as an argument so both states can be tested against a* synthetic roster rather than against whoever is mid-onboarding today.*/export function uncoveredByBitrix(people: Person[] = MERIDIAN_PEOPLE): string[] {return people.filter((p) => !p.bitrixUserId && !p.coveredBy.includes('bitrix-tasks')).map((p) => p.name)}