Skip to content

uid: T-188 title: One profile per person β€” unify the RBAC user record onto the Individual, with input-once gates status: doing area: user-management created: 2026-07-27 updated: 2026-07-27 assignee: User Management owner: girafeev1 related: T-195, T-183, T-178, T-048, T-161, T-160


T-188 β€” One profile per person

Why

A person is described by two Firestore documents that both carry person-facts:

  • users/{uid} (aote-system) β€” the RBAC account. Besides access facts (role, badges, subsidiary access, status) it also holds jobTitle, organization, phoneNumber, whatsappNumber, contactNotes, preferredContactMethod, isExternal.
  • individuals/{id} (aote-system) β€” the canonical person in Contacts: basic.*, legal.*, system.* (incl. the userUid bridge).

That split has already produced real drift β€” two independent "job title" fields (users.jobTitle vs Individual.system.position), and only the latter prints on Payment Confirmations.

Owner, 2026-07-27 (verbatim): "I actually wanted the RBAC user profile to be combined with the 'Individual''s profile (if they're 2 different sets of Firestore doc), with gates limiting user to only be able to input their info without further editing it."

The full direction arrived as a written brief (owner, drafted with Records (Infrastructure) Β· session_018RDB37kCqfouHdygVXTAtD, 2026-07-27) β€” recorded in "The brief" below.

The brief (owner, 2026-07-27) β€” recorded verbatim in substance

  • Do not physically merge the two documents. users/{uid} becomes a pure access record (role, badges, subsidiary access, status + the identity an account needs: email, displayName as the login label, photo, telegram link). Every duplicated person-fact leaves it. individuals/{id} becomes the only home of person-facts; the Profile page reads/writes the Individual through self-scoped endpoints, and the account section stays read-only RBAC display.
  • Rationale for two docs: person and account have different lifecycles (contractors, students and directors exist with no login), and the auth layer reads users/{uid} on every request β€” a physical merge is a high-risk auth migration with no clarity gain.
  • Finish the repo's existing journey: lib/individuals/personalFields.ts already resolves person-facts as "Individual wins when present, else the legacy user doc" β€” the end state flips that to Individual only. The decisions index (2026-07-25) already records that the closing's job title comes from Individual.system.position and that the RBAC profile stays an access record.
  • Write seam: updateIndividualServer (section shallow-merge + individualβ†’payee write-through) and the self-scoped endpoint pattern from pages/api/profile/payment-details.ts (T-183). Standing rule: no adapters, no second dialects of how data is written to Firestore.
  • The gate β€” input-once, amend-by-bookkeeper. Users may fill a blank field on their own record but not change it once set; enforced server-side (compare stored vs incoming; reject changes to non-empty values with a message pointing at the bookkeeper) β€” a disabled input is not a gate. Proposed classes (owner asked for confirmation before building):
Class Fields User may
Payment/legal facts legal.legalName, legal.bankAccounts (add allowed; edit/delete/change-default β†’ bookkeeper), HKID if ever self-served Input once
Contact facts phone, whatsapp, preferred contact method, address Edit freely
Company assertions abbreviation, all of system.*, role, badges, subsidiary access Never self-service
  • Migration expectations β€” the owner's instructions on moving the data (backfill onto blank Individual fields first, a conflict list where both disagree, re-point every reader, then remove the duplicated fields under whole-document approval, and the suggested field-by-field phasing) are not part of T-188. At the owner's request (2026-07-30) the data migration is its own task, T-195, which carries those instructions in full. T-188 owns only the surfaces and the shape they read/write; it moves no existing data.
  • Ground rules: /api/profile/* stays self-scoped (no id parameters, ever); all person-fact writes through updateIndividualServer, bank lists through normaliseBankAccountsForWrite; preserve the ensureIndividualForUser onboarding guarantee and the single system.userUid bridge; build after T-183/PR #933 merges, not beside it, preserving two behaviours of its PaymentDetailsCard (silent when the record is complete; when the abbreviation is missing it says a bookkeeper must set it rather than offering a field) and the readiness shape of /api/profile/payment-details + the { email, displayName, position, paymentReadiness } shape of GET /api/profile/me; do not touch role/badge/subsidiary-access semantics, the director facet, or anything feeding WOPC signing.

Findings (verified in code 2026-07-27, before any change)

Onboarding β€” the abbreviation is system-generated, and the inviter is never asked. - The invite form (components/settings/SettingsApp.tsx β†’ InvitationsTab) collects email, role, primary subsidiary, subsidiary access, expiry β€” no name, no abbreviation. CreateInvitationInput (lib/rbac/types.ts) has no field to carry either. - At first sign-in (pages/api/auth/[...nextauth].ts:219 and :314) ensureIndividualForUser creates the Individual with abbreviation: '', and createIndividualServer (lib/individuals/crud.server.ts:53) substitutes id.slice(0, 8).toUpperCase() β€” an 8-character slice of the opaque doc id. T-183 found the same line from the payments side.

CORRECTION to an earlier report of mine (2026-07-27). I wrote that such a record would print ERL-WOPC/K3MZ8QP1-2026-001 on an issued document β€” taken from PR #933's description without verifying. It would not. lib/wopc.server.ts:279 shows the current reference format is ERL-WOPC/{YYYY}-{NNN} with no abbreviation (the abbreviation parameter is retained but explicitly unused, _abbreviation, since #664; parseReferenceNumber still accepts the legacy {ABBREV}- shape so old audit trails resolve). The abbreviation also does not print on the WOPC PDF β€” pdfRenderer.server.ts uses payeeAbbreviation only to resolve the cross-issuance closing director. The placeholder still matters, but for internal reasons: it is the payee doc key (payees/{abbr}), the directory's human key in Contacts, the cross-issuance input, and T-183's readiness predicate treats a placeholder as not payment-ready β€” which blocks reimbursements.

Profile page (components/profile/ProfileApp.tsx) β€” what is and isn't there. - Renders the user record via /api/auth/profile: identity, Directory & Contact Info, Name Preferences, Subsidiary Access, Effective Permissions, Account Details. - No Telegram surface, although the plumbing is complete: /api/profile/telegram/link + /status, the connect page at /auth/connect-telegram, and the admin per-user gate telegramLinkAllowed. Nothing in the app links to the connect page β€” the only mentions of the URL are two help strings inside the admin user editor. Users must be told the URL by hand. - No legal information (legal name, HKID, marital status, spouse, bank accounts) β€” those live on the Individual and are edited only in Contacts. - Bank details are in flight: T-183 / PR #933 (draft) adds PaymentDetailsCard + self-scoped /api/profile/payment-details. Merged β‰  deployed; and a large backlog is currently merged-but-undeployed. - No nickname field. basic.preferredName (one display string) and T-161's basic.preferredFirstName / preferredLastName exist but are Contacts-editable only. The Profile's editable name is users.displayName, which feeds nothing in the naming system. - Name Preferences is a fixed menu, not a composer: it renders standardCombinations(...) β€” a curated catalog of 6 greeting + 6 document combinations as pick-one chips. The token vocabulary, the grammar validator (title requires the last name; documents require a last name) and the API-side validation of arbitrary submitted combinations already exist (T-161), so an inline composer is a UI change over machinery already built for it.

Owner decisions, 2026-07-27 (answers to the findings)

  1. Attached brief β†’ recorded above.
  2. Asked whether the WOPC still includes an abbreviation β†’ answered by the correction above: it does not, in the reference number or on the PDF.
  3. "preferred name is good, no preferred last name is needed though. Nick name should be editable by the user and admin themselves"
  4. "Eons ago, there was a composer as a demo that was demonstrated to me… can you do a re-design to demonstrate to me one more time" β†’ preview gate applies before any build.
  5. "It's okay for the onboarding user to type in their own preferred name" β€” a deliberate, scoped exception to T-161's no-self-typed-name-parts rule.
  6. "The inviting user should have inputted basic info like the invitee's first name and last name of some sort already" β€” the invite collects the invitee's names (and therefore can propose the abbreviation).
  7. "I'd like the user to be able to input their own nickname on their own profile on the profile page which that nickname could be use as a token at certain situations"
  8. Legal information on the profile β†’ "read-only view on user's own profile after inputted to the system".
  9. Task-splitting/coordination β†’ "Up to you".

Scope (phased β€” preview-gated per the owner's standing rule)

  • P0 β€” preview. Re-design demonstration: the token composer, the unified Profile page, and the invite modal. No code until the owner approves it.
  • P1 β€” invite-time identity. Invitee first/last name + abbreviation on the invite form (live uniqueness check against individuals, proposal derived from the names), carried on the invitation doc, threaded through both ensureIndividualForUser call sites. Retires the id.slice(0,8) placeholder for every future user. Touches no T-183 file.
  • P2 β€” nickname + composer. basic.preferredName becomes self-editable (user and admin) and gains a token; the Name Preferences card becomes an inline composer over the token vocabulary.
  • P3 β€” profile surfaces. The surfaces only β€” write-once gate enforcement (the endpoint that compares stored vs incoming), the Profile restructure (Telegram card, read-only legal view, absorbing PaymentDetailsCard). Built after T-183 lands. No data migration lives here β€” the moving, grouping, backfilling and contracting of existing documents is entirely T-195 (owner's request, 2026-07-30). T-188 changes code and reads/writes the new shape; it never transforms a document that already exists.

Open questions for the owner (raised with the preview)

  • Retiring preferredLastName (decision 3) is a behaviour + stored-data change: it removes the document combinations Jake Ngai / Mr. Jake Ngai from the catalog, leaving documents on legal name only. That strengthens the T-160 grammar rule but is a real narrowing β€” and any stored preference referencing the token needs a decision (fall back, or migrate).
  • Nickname token scope: greeting-only, or documents too? A single nickname token can never satisfy "documents require a last name", so document use would need the rule relaxed.

P0 β€” preview published (2026-07-27)

https://claude.ai/code/artifact/b15ddd04-7353-47a8-853d-63b15227ad95 (label composer-v1)

Three plates: (1) the live name composer β€” slug tray, composition line, live impression, the grammar rules enforced inline as the owner stated them, context switch greeting/document, editable nickname feeding the render; (2) the regrouped Profile page β€” Account (read-only, incl. the directory key and a Telegram connect row), About you (freely editable, incl. nickname + the single job title), Legal & payment (read-only after input, HKID masked, add-another-account allowed); (3) the invitation form carrying first name, last name and a proposed directory key with a uniqueness state. Build starts only on the owner's approval, per the standing preview gate.

Two rulings requested with the preview: the nickname token's scope (greeting-only as drawn, or documents too), and whether an invited key is reserved at send time (recommended) or merely checked, given the record is created days later at first sign-in.

v2 β€” owner feedback on the preview (2026-07-27)

Owner (verbatim): "1. The nickname field should be included in the 'Directory & Contact Info' card, but now a casual inline edit field for Name Preferences. 2. No need for the composition field, just the parts and the 'impression' field is good. 3. Plate 2 and 3 is good 4. Just to clarify, Nickname and Preferred Name is the same thing. If there's already a Preferred Name writing on Firestore, keep it, while change the showing of 'Preferred Name' to 'Nickname' on the web app UI"

(Item 1 read as "but not a casual inline edit field for Name Preferences" β€” the contrast the sentence sets up, and consistent with item 2. Corrected in v2; flagged to the owner.)

Applied in composer-v2: - The nickname input left the Name Preferences card and now sits in Directory & Contact Info with the other freely-editable person-facts. Name Preferences composes only. - The composition strip is gone. Parts are toggles β€” click to set, click again to remove β€” with a small ordinal on each set part showing its position; the impression and the validity line are the only other elements. - Plates 2 and 3 approved as drawn (nickname row relocated into the Directory card).

DECISION β€” nickname IS the preferred name. basic.preferredName stays exactly as stored; only the UI label changes to "Nickname". No copy, no rename, no migration, and therefore no Firestore structure change for this part.

Consequence to settle at build time (P2): T-161 added basic.preferredFirstName / preferredLastName precisely because the single preferredName string could not feed a name token. With the owner's ruling, the token maps to preferredName and preferredLastName is retired β€” so any record already carrying the split fields needs a stated resolution (prefer preferredName when set; otherwise fold preferredFirstName into it) rather than two competing sources. That resolution is a data question and will be put to the owner on its own before P2 code.

P2 shipped β€” composer + self-service nickname (2026-07-27, f1c07146 / #937)

Owner: "I think we can proceed to write it to the web app" β†’ built the parts of the approved preview that need no new Firestore field. P1 (invitation) is held for the schema gate below.

  • Composer replaces the six-item fixed catalogue in Name Preferences: parts are toggles (click to set, click again to remove; ordinal shows print position), with the impression and the refusing rule inline. Rules come from the shared validateCombination, so the card refuses exactly what the API refuses.
  • preferredName token = the nickname, mapped to the existing basic.preferredName. No Firestore structure change β€” existing field, existing write seam.
  • Documents render from legal parts only: the preferred-first/last split leaves the catalogue and a nickname is refused on documents. Legacy split tokens still RENDER, so preferences already stored against them do not go dark (covered by a new test).
  • Self-service nickname on /api/profile/name-preferences (still self-scoped, still through updateIndividualServer); combinations are validated against the NEW nickname so a preference cannot be saved that stops rendering on save. Nickname row sits in Directory & Contact Info per the owner's note. Contacts relabels "Preferred name" β†’ "Nickname".
  • Verification: tsc 0 errors (pinned 5.9.3), vitest 715 passed (4 pre-existing workspace-billing failures, proven against a stashed baseline), eslint clean on touched files. A production next build could not be completed in-container (unrelated /v/[id] page fails for want of Firebase env).

⚠️ The verification gate was silently dead in this container

npx pulled TypeScript 6.0.2 because the container had no node_modules; TS 6 aborts before type-checking on the repo's deprecated moduleResolution: node10 / baseUrl, reporting two config errors and checking nothing. tsc --noEmit therefore "passed" while verifying nothing β€” and a deliberate const x: number = 'x' probe confirmed it. After npm install, the pinned 5.9.3 immediately found 8 real call sites this change broke. The repo config is fine; the container was not. Any agent claiming "heavy tsc clean" should confirm npx tsc --version matches the pinned 5.x first. Recorded here rather than fixed in tsconfig, because changing the repo's resolution settings is an infrastructure decision with repo-wide blast radius.

β›” P1 BLOCKED β€” awaiting the owner's Firestore approval

The invitation must carry the invitee's names and directory key from send to first sign-in, which means new fields on invitations/{id}. Per AGENTS.md that is a stop-and-ask gate, and a broad "proceed" is explicitly not schema approval. The whole-document current-vs-after was put to the owner on 2026-07-27; no code written until they answer.

Also still open from the preview: whether an invited key is reserved at send time (recommended) or merely checked.

Firestore indexes β€” created and reconciled (2026-07-27, 31a19bb2 / #944)

Owner: "Proceed and create the index please" β€” done, plus a real gap it uncovered.

Created in production (aote-system, via the Firestore Admin API using the environment service account, which holds datastore.indexes.create): users on status ASC + history.createdAt, in BOTH directions, mirroring the existing createdAt pair. Created ahead of the migration so the index is READY before any document carries the field β€” an index still building cannot serve a sort, and a sort on a field documents lack drops them silently.

The gap. Production holds 17 composite indexes on aote-system; the repo's firestore.aote-system.indexes.json declared 11. Four had been created ad hoc and never written back. Since the Firebase CLI treats that file as the intended state, an index deploy could have offered to DELETE them:

Undeclared index What depended on it
users (status, createdAt) Γ—2 every user list, the notification fan-out's "who is active?", System Emails routing's "All admins"
notifications (read, recipientUid, createdAt) the unread inbox query
ir56mSigningRequests (endingYear, payeeId, status, updatedAt) signing-request lookups

The users pair is the dangerous one: Firestore omits documents lacking the sort field instead of erroring, so losing it would have produced empty user lists and alerts reaching nobody, with no error anywhere. All six (four pre-existing + two new) are now declared; verified by diffing the parsed file against a live index listing β€” 17 declared, 17 in production, nothing declared that production lacks.

Standing note for future work: the declarative index file is now a faithful mirror. Anyone creating an index from a query-error link must write it back here, or the next deploy proposes to delete it.

2026-07-30 β€” field inventory approved; name-field naming convention decided

  • Design approved. Owner confirmed the four-document field inventory + purpose (the onboarding walkthrough that mapped invitations / users / individuals / auditLogs): "your design quoted as below is good." This approves WHAT fields exist and what each is for. The full grouped-map whole-document before/after (the access.* / history.* grouping etc.) is still its own pending schema gate.
  • Name-field naming convention (owner delegated the choice β€” "Depending on your choice, the other name relating fields should be of the same field naming style"). Decided:

A name with PARTS is a map named for its qualifier, with plain part fields (firstName / lastName / title) β€” no requalifying prefix. A name that is a single string stays a scalar.

So legal.legalName.firstName, NOT legal.legalName.legalFirstName β€” the parent already carries the qualifier, and re-prefixing the child is the reuse-a-word-at-two-levels anti-pattern from the 2026-07-27 AGENTS.md rule (the status.status smell). This is also the shape already stored (PayeeLegalName = { title, firstName, lastName }), so the legal name needs zero migration. - Applied across every name field: | Field | Today | Under the convention | Migration | |---|---|---|---| | English legal name | legal.legalName.{title, firstName, lastName} | same | none | | Chinese name | legal.chineseLastName + legal.chineseFirstName (flat) | legal.chineseName.{firstName, lastName} | yes β†’ T-195 | | Nickname β†’ preferred name | basic.preferredName (string) | basic.preferredName.{ firstName, lastName } (map) β€” superseded by the 2026-07-30 reversal below | yes β†’ T-195 | | preferred split | basic.preferredFirstName/LastName | folds INTO the preferredName map (per the reversal) | T-195 | - Deliberately NOT done: name fields are not gathered into one bucket β€” legal + Chinese names stay on legal (identity facts), the nickname on basic (everyday facts). Grouped by kind of fact, the axis the rest of the record uses. Flagged to the owner for override; recommended as-is.

2026-07-30 β€” further owner decisions (schema layout)

  • users/{uid} gets an access map. Owner: "have an 'access' map field please" β€” role, badges, roles, subsidiaryAccess, primarySubsidiary and status group under access.* rather than sitting flat. Will appear so in the pending whole-document before/after.
  • Nickname consolidation β†’ T-195 step 7. Owner: "migrate all preferredFirstName to preferredName and keep only preferredName." preferredLastName is dropped, preferredFirstName folds into preferredName. (Data migration; recorded on T-195.)
  • Terminology: "directory key" = the person's abbreviation (e.g. "JC"). Plain-English name for the existing field; will use abbreviation going forward to match storage.
  • Inviter names land in legal.legalName (decision #1, confirmed) β€” consistent with the naming convention above.
  • Reserve the abbreviation at invite time (decision #2). Owner: "reserve directory key please" β€” the key is claimed the moment the invite is sent, not merely checked, so nobody else can take it in the days before the invitee first signs in. Design consequence for P1: uniqueness must now consider THREE sources β€” existing individuals, outstanding pending invitations (two pending invites can't reserve the same key), and the claim must be released when the invitation is revoked or expires (the existing revoke/expire paths gain a release step). To be detailed in the invitation schema before/after.

2026-07-30 (later) β€” REVERSAL: the preferred name keeps first + last, as a map

Owner: "Okay, in that case, let's have preferred first name and last name then." This supersedes (a) the 2026-07-27 "nickname = a single field, no preferred last name" decision and (b) the 2026-07-30 "consolidate to a single preferredName" decision (T-195 step 7). The preferred name is structured again.

  • Shape β€” applying the naming convention the owner set (not the old flat fields). A preferred name with parts becomes a map named for its qualifier with plain part fields, exactly like legalName / chineseName: basic.preferredName.{ firstName, lastName } β€” NOT flat preferredFirstName / preferredLastName (which would break the 2026-07-27 no-flat-prefix rule). This is the owner's own convention applied to a first/last preferred name; flagged for correction if they'd rather the literal flat fields.
  • Migration consequence (T-195 step 7 rewritten): instead of consolidating away, the legacy flat preferredFirstName / preferredLastName migrate into the map (preferredName.firstName / .lastName); the current single-string preferredName (onboarding filler) seeds .firstName where the map is otherwise empty. The field's type changes string β†’ map β€” handled in the migration.
  • Tokens: the preferred first / last name return as greeting tokens (reading the map). The P2 composer shipped with a single preferredName token, so it needs a small follow-up build to expose the two parts again.
  • Documents β€” HARD RULE (owner, 2026-07-30): "preferred names are never allowed to signable documents. They should always stay greeting-only." This is now an invariant, not a default β€” the earlier "one-line override" affordance is withdrawn. The preferred parts render in greetings only ("Dear Jake Ngai," is fine); they can never appear on a signable/legal document (WOPC closing, Payment Confirmation, statutory form), which use the legal name exclusively. Already enforced structurally, not just by UI omission: validateCombination(..., 'document') refuses any combination containing preferredName (lib/naming/nameRendering.ts), so no code path β€” composer, API, or future producer β€” can leak a nickname onto signed paper. Rationale (my expert view, owner concurring): a nickname has no legal standing, doesn't tie to the HKID / company-register identity that makes a signatory bindable, and breaks the name-consistency chain across WOPC β†’ bank transfer β†’ IR56M that authoritative documents depend on. The only legitimate "known-as" construct on a legal instrument is an explicit, labelled alias ("Ngai Wang Chi (also known as Jake Ngai)") β€” which still leads with the legal name and is a separate future capability, never a bare-nickname substitution.

Source

Owner brief 2026-07-27 (usermanagementunificationbrief.md) + the Q&A above; naming convention 2026-07-30; preferred-name reversal 2026-07-30. Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW