feat(congregation): add the congregation organigram - #370
Merged
Conversation
Congregations maintain an "Organisation des services" sheet — the elders and ministerial servants, a service committee, and the services hanging under whichever committee member oversees each one. `Role` was flat, so none of it could be expressed. It already half-existed in the data: eight of the demo congregation's ten custom roles are boxes from that sheet. What was missing is the edge between them. Model - `Role.parentRoleId` — reporting structure only, never traversed by permission resolution. The chart's root is the `elder` roster, so inheriting downward would make every seated person an elder and inheriting upward would give every elder everything. Composite FK to [id, congregationId], so a role cannot be parented across congregations — enforced by Postgres rather than by a service check. - `UserRoleAssignment.kind` (leader | deputy | member) — one role per box, with the seat on the membership. Splitting each box into a unit plus a leader role doubled the node count and grew every unfiltered role picker with it. - Additive migration: four nullable columns plus two defaulted, nothing backfilled, `showInOrganigram` false everywhere. Page - `/congregation/roles/organigram`, read-only chart plus a node panel. - The chart never mutates; selecting a node opens its panel, so editing is scoped to what you clicked rather than re-selected in a detached form. - Selection lives in `?node=` so the panel survives a form post. - Searchable person picker; members without a login are shown disabled with the reason rather than hidden. - Descendants are absent from the "move under" list, so a cycle cannot be chosen rather than being refused after a reload. Invariants the database cannot hold — cycles, depth cap, identity rosters as roots — live in `role-tree.policy.ts` as pure functions.
Completes the feature: the chart survives an export/import round trip, is readable by the whole congregation from the display board, and can be started from nothing. Import/export - Export was blind to the tree columns and to `UserRoleAssignment.kind`, so a restore flattened the chart and demoted every responsable to a plain member. - `parentRoleId` in an archive is a *source* id, and target ids are only known once each row is inserted. Sorting the archive by depth — the intuitive fix — addresses ordering while the real problem is translation, so it would still write the wrong parent. Resolved in a second pass through the importer's id map, which is what that map exists for. - A parent missing from the archive leaves the role detached with a warning rather than throwing: the reader promotes an orphan to a root, so the branch stays visible and recoverable. Board document - Fourth `DynamicType`, in its own file — `dynamic-documents.server.ts` is already 428 lines against a 350 hard limit and only passes CI because it is grandfathered. - This is the surface members without `can-view-roles` actually read, so it is gated only by `can-view-board` and the section's visibility roles. - Content version tracks both the roles and their holders: a new elder joining the roster changes the document without any role row being touched. Deleting a role - `deleteRole` now refuses when other roles report to it, naming them, instead of failing on a raw foreign-key violation the admin cannot act on. Starting a chart - An empty organigram was a dead end: with no node there was no panel, and the panel was the only place a role could be added. There is now a root-level add, prominent in the empty state and available afterwards for a second root. Also: the E2E moves into app/tests/e2e so CI runs it, skipping gracefully when the environment has no chart to look at. Not done, deliberately: filtering organigram roles out of the eligibility pickers. That was planned when the model would have added ~30 structural nodes; under one-role-per-box the chart is built from roles that already existed, so the pickers gained nothing to filter — and «Sono» or «Estrade» are legitimately both an organigram node and a part-eligibility group.
`Role` is how the chart is stored; it is not what a congregation calls the thing it is looking at. The printed sheet this replaces is titled "Organisation des services", so the page uses that vocabulary throughout and the storage entity stops leaking into the interface. - « Ajouter un rôle ici » → « Rattacher un service » - « Aucun rôle dans l'organigramme » → « Aucun service dans l'organigramme » - « Le rôle et ses membres sont conservés » → « Le service et ses membres… » - « N rôles en dessous » → « N services rattachés » - The cycle message now reads « Un service ne peut pas être rattaché à lui-même ni à l'un des services qui en dépendent », which also explains the rule better than naming the constraint did. - Screen-reader labels follow: the seat control is « Fonction de X dans Y », the panel is « Service : X ». `deleteRole` keeps saying "rôle" for the thing being deleted — it is reached from the roles page — but now names what blocks it as services in the chart, which is what makes the message actionable. Identifiers, types and comments stay in English and keep using `role`: the model has not changed, only the words shown to a user.
Three gaps between "the plan is complete" and "the feature is done", plus a live 500 that the first of them surfaced. Nobody could find it - Nothing linked to /congregation/roles/organigram — not the sidebar, not the command palette, not the roles page. It was reachable only by typing the URL, and every gate passed because nothing tested discoverability. - «Organigramme» now replaces «Rôles» in the sidebar and the palette. The eligibility matrix becomes its second tab, «Groupes d'aptitude», named for what it is actually for: populations like commerces or micros that hold no place in the chart. The matrix toggle was broken (regression test added) - role-list.tsx submitted `member.id` into a field named `userId`, which reached `addUserToRole` and landed in `userRoleAssignment.create` where it must be a `UserAccount` id. Different sequences, so it failed on the foreign key and surfaced as a 500. It survived because this feature had no route tests. - The action now resolves the member's account, the field is named `memberId`, and a member with no login is shown disabled with the reason rather than failing on click. It could not create a service - The picker only adopted existing roles, so a congregation whose «Comité de service» did not exist yet had to leave for the roles page and come back — roughly fifteen times while building a first chart. - «Rattacher un service» now offers an existing service or a new name, in one submit, and focuses whatever it just created. A duplicate name is refused in French with the suggestion to attach the existing one instead: two services sharing an identity would split the same team's membership in half. Bands instead of cumulative indentation - The chart now groups children under «Sous la responsabilité de …» headers, as the printed sheet does, so depth costs no horizontal space. The old nesting lost ~15% of a 390px screen per level and was unreadable by depth 6. - The rules are derived from the tree, never stored: a unit with child units becomes a band, a unit whose only content is people becomes a row, and the roster header is suppressed because the masthead already names it. - A band's own node renders as its first row rather than as a bare heading — reducing «Coordinateur» to a header silently dropped Marc DUPONT.
…ditor Two surfaces, two jobs, so they should not read the same way. The board document is read and printed: bands — « Sous la responsabilité de Comité de service » — are what make the paper sheet scannable on one page, and depth costs no horizontal space there. The organigram page is where the structure gets rearranged, and nesting has to stay visible for that: you are choosing what sits under what, so the indentation is the information rather than decoration. It goes back to the nested tree. `toLayout` moves to app/shared/domain: display-board may not deep-import another feature's ui/ — `test:boundaries` enforces that — and the layout rules are pure tree derivations with no feature of their own.
The panel had four uppercase section labels stacked down a 22rem column, each competing with the node's own name — the only thing there that should carry weight. Sentence case, with a hairline rule above each section: the separation that the capitals were doing, without the emphasis. The seat control was the loudest thing on the page. A list of six people rendered as six full-strength bordered selects, when a seat is set once and read thereafter. It is borderless until you go near it, and still 36px tall — past the 24px WCAG 2.2 target minimum. The remove button is muted rather than hidden-until-hover. There is no hover on a touch screen, and a control that only exists on a pointer device is a control half the users never get.
Every congregation has one service committee of three elders — the coordinator, the secretary and the service overseer — and most services answer to one of them; the rest answer to the body of elders. That is universal, not a local arrangement, so it belongs in code rather than being retyped by each congregation. Until now those posts were ordinary custom roles. The demo congregation shows what that costs: French baked into `Role.name`, keys slugified from French (`responsable-pour-la-predication`), and only two of the three posts present — nothing noticed the secretary was missing, because nothing knew to look. Stable keys are the point. A default permission set can now ship for "the secretary", and seating a new coordinator can revoke the outgoing one's permissions, neither of which is expressible against a free-text role that a congregation may rename or delete. Stored like the identity rosters — isBuiltIn, name/description null, resolved per-locale by getRoleDisplayName — but appointed by hand like the system roles, so seats stay on UserRoleAssignment. Their place in the chart is fixed: the committee under the elders, the three posts inside it, neither re-parentable nor removable. New congregations get the whole structure at provisioning, so the first chart is no longer a blank page. Existing ones get the roles unplaced: they already have their own «Coordinateur» with people seated on it, and switching these on here would show them a duplicated, empty committee. They adopt the structure from the organigram page instead, where the mapping is proposed and confirmed rather than guessed — that flow is the next commit. The migration is additive, as it must be: the running image does not roll when migrations apply, and an unplaced role with no permissions is invisible to every existing query. Where a congregation already used one of these exact keys, its row is promoted rather than duplicated, and the name it typed is left alone — clearing it would read better and destroy something no one can get back.
The three posts are single-seat and elder-only. Both rules are checked before any write, so a refusal never leaves a post vacant, and the picker greys out non-elders rather than letting the admin choose and then be refused. Seating a new coordinator removes the outgoing one. That is not a side effect — it is the behaviour this feature was built for: the post carries the permissions, so handing it over hands those over too, without anyone having to remember to unseat first. The panel names whose fonction is about to end before you submit. `kind` is forced to leader on a post: one person holds it, so there is no membre/adjoint distinction to offer, and a control whose value the service overwrites is worse than no control at all. The committee and its posts also lose the move and remove controls, since the service refuses both. Fixes a bug this same change would otherwise have introduced: the tree reader derived `isRoster` from `isBuiltIn`, which was true of the two auto-synced rosters alone until the committee posts arrived. Left as it was, a post would have rendered as a reconciled list whose membership cannot be edited — the one thing those posts exist to let you do. It now reads the roster keys directly. The people section moves to its own file: the posts change nearly every line of it, and inlining that many branches put the panel over both the complexity budget and, at 394 of 400 lines, nearly over the size one.
Congregations that built a chart before the committee was structure already have their own «Comité de service» and «Coordinateur», with people seated on them and services reporting to them. The migration gave them the built-in posts but left them out of the chart precisely so this step could exist: a banner on the organigram, and a screen that proposes a mapping for them to confirm. The suggestion is made from the role key, which is already slugified, so «Responsable pour la prédication» is matched to the service overseer and a congregation with no secretary node is offered "start empty" rather than a guess. Longest hint first, so a bare «responsable» that is a different service entirely does not win over the specific one. On confirm, per mapped role: the holder moves onto the post, the services reporting to it are re-hung under the post, and the role leaves the chart without being deleted. Its permissions are **copied**, not moved — the old role may still gate part eligibility or be assigned outside the chart, and moving a permission off it would revoke access having nothing to do with the organigram. Where it held several people, the leader moves and the rest stay behind on a role that still exists, so nobody loses anything they had. Also fixes two things this exposed: The action used Conform, whose flattening does not turn bare repeated field names into arrays. It parsed to nothing and redirected as if it had succeeded — the adoption silently did not happen, which only showed up in the database. `getAll` instead, and a mismatched pair now throws rather than redirecting. The "attach a service" picker offered the appointed posts: they pass `canShowInOrganigram` but hold a fixed place, so the service refuses to attach them anywhere. Offering them was offering an error.
The per-choice body — copy permissions, move the leader, re-hang the children, unflag the old node — is one coherent operation on one role, and inlining it in the loop put adoptServiceCommittee over the complexity budget.
Confirming « Supprimer » closed the dialog and did nothing. No request, no
error, no change — which is why it read as "I can't remove roles" rather than as
a failure.
`AlertDialogAction` closes the dialog on click, unmounting the dialog's content
and, with it, the `<Form>` that lived inside it — before the browser reached the
button's default submit. Submitting programmatically makes the close irrelevant.
Nothing below the browser could have caught this: the action was never reached,
so no server-side test would have failed. The regression guard is therefore an
e2e one, and it asserts that a request is sent rather than that the role is
gone — whether the delete then succeeds or is refused depends on the data, but
sending nothing is always the bug.
Fixing that exposed a second bug it had been masking. `deleteRole` throws
ConflictError when organigram services still report to the role, and the action
caught only ForbiddenError, so that escaped as a 500 instead of « déplacez-les
avant de supprimer ce rôle ». Both refusals now surface as messages; a genuine
fault still throws, because reporting a dropped connection as "cannot be
deleted" would be a lie.
Also fixes an organigram e2e selector of mine that was wrong: `getByRole('link')`
matches every link on the page, so `.first()` clicked the sidebar's «Accueil» and
asserted against the home page.
It picked the first role on the page and deleted it. Every run destroyed real data in whatever database it was pointed at — three services disappeared from a development congregation before that was noticed. It now creates a uniquely named role, deletes that, and asserts it is gone, so the run leaves the database as it found it. A test for a destructive action has to supply its own subject; reaching for whatever happens to be there makes the test itself the most dangerous thing in the suite. Still fails when the dialog's submit is swallowed, which is what it is for.
A congregation's committee IS the coordinator, the secretary and the service overseer — that is what the word means, not a list that happens to contain them. So its membership is now derived: seating someone in one of the three posts joins them to the committee, and unseating removes them, the same way the elder roster is reconciled from Member flags rather than edited by hand. Real `UserRoleAssignment` rows rather than a union computed while rendering, because permissions resolve from that table: a committee role carrying a permission has to actually reach the three people holding its posts. The reconcile is idempotent and writes nothing when the membership already matches, so calling it after every seating neither churns rows nor fills the audit log with changes nobody made. Seating deliberately no longer returns early on an unchanged seat, which makes the operation self-healing — it repairs a committee whose membership drifted instead of requiring someone to guess which seat to poke. Adoption skips moving a holder onto the committee for the same reason: it would be reconciled straight back off, a change the admin would watch undo itself. The panel stops offering to edit a derived list. Its members show without a function select or a remove button, with a line saying where they come from — a form whose result the next reconcile would silently undo is worse than no form. Seating moves to its own file: the two questions are different — one arranges boxes, the other puts people in them — and organigram.server.ts was within three lines of its budget, so the next edit to it would have broken the build.
…three words Role.isSinglePerson marks a personal role — one titulaire, a handover on re-seating, adjoints allowed. The migration backfills the three committee posts, which were always exactly that; the committee itself stays a group. A second migration pins UserRoleAssignment.kind to leader/deputy/member with a CHECK constraint: every writer already keeps to those, so anything else reaching the table is a bug, and a loud insert failure is worth more than a seat that silently sorts to the bottom of its node.
…rite The route passed the form's postKey strings straight through, so a crafted request could carry a custom role's permissions onto any role at all — elder included — and a duplicate post could seat two people on a single-person post. The loader's already-adopted redirect also did not survive a stale tab: a re-posted adoption mapped a second time. Every choice is now resolved and refused before the first write, which is what lets the route turn an AppError into a flash message instead of a 500 without ever committing a half-placed committee.
…rough handover Seating generalises the posts' handover to every isSinglePerson role: a new titulaire sweeps only the previous leader seat, so adjoints survive — on the committee posts too, which used to lose every seat. The committee's membership now counts titulaires only, the elder rule applies to the titular seat and not to who helps them, and a 'membre' seat does not exist on a personal role. The read side carries the flag onto the node, and seatLabel — now shared between the chart and the board document — stops calling the holder of a one-person role its responsable: nobody is responsible *of* the coordinateur; the node name is the function and the person holds it. Also closes two request-level gaps the UI never exposed: seating someone into an auto-synced identity roster (a permanent grant no sync would ever clean up), and re-parenting a role that is not in the chart.
Three accounts on one role — leader, deputy, member — all resolve the role's permission and are all found by findAccountsWithPermission. The seat says who leads a service on the chart; anyone needing different access is modelled as a child role, never as a seat with different rights. If resolution ever becomes kind-aware, this breaks loudly.
An absent field in an older archive leaves the pre-seeded committee posts' flag untouched on import rather than stripping it.
The panel showed four workflows at equal weight — seating, moving, attaching, removing — and read as a wall of fields where a mistake felt easy. It is now two tiers: « Personnes » stands alone, and everything structural folds into one collapsed « Organiser l'arborescence » block, anchored by eyebrow labels instead of a stack of identical borders. The existing-or-new radio pair is gone: one attach form, and the action does whichever field the admin filled (a typed name wins). The tree gets one holder per line with legible seat labels, a band behind the rosters, breathing room, and a real « Vacant » badge where a personal role has no titulaire — the question the chart exists to answer. The help block opens itself while the chart is empty. The panel's loader assembly moves to a tested server helper, which also returns the route to its size budget. The sheet becomes a static header row above a scroll area, on every viewport: the node's name and the close button sit flush with the top and content cannot slide behind them — the sticky variant misaligned, and sticky never engages against the document inside the app shell anyway, which is also why the chart column (not the page) now scrolls beside an open panel on desktop. Seat controls grow to 44px on touch screens.
The « Rôle personnel » checkbox joins name and description on the role edit and creation forms — the chart arranges roles and seats people; the role pages define them, and the panel links there. Refusals from the flag (several titulaires still seated) land on the checkbox with their own message instead of colliding with the duplicate-name error.
A sheet's action buttons otherwise sit one thumb-width above five navigation targets, and a mis-tap leaves the page mid-edit. Hidden with the same zero-JS has() trick the layout uses for data-form-actions, keyed on data-bottom-sheet; the bar returns the moment the sheet closes.
…e organigram Three guards draw the line the two pages now stand on. addUserToRole refuses personal roles — a blind add would write a plain member seat onto a role that has only a titulaire and adjoints, skipping the handover. removeUserFromRole refuses to strip a leader or deputy seat — one stray uncheck while bulk-editing must not silently unseat a responsable. And the account-assignable filter excludes personal roles from both sides of the settings page's assignment diff, so a titulaire is never granted there and never stripped there either.
Columns are banded by the committee post whose branch of the organigram they belong to — the printed sheet's « sous la responsabilité du coordinateur » as a header row — with « autres services » and « hors organigramme » closing the table. A band folds to one narrow column through a URL-driven link, so a reader's working view survives every toggle and can be bookmarked. Only the chart's leaves become columns: the matrix edits plain members, members live on the teams, and upper levels carry seats this grid may not touch. Personal roles are out for the same reason. A role still holding plain members from before this model keeps its column until emptied — no membership ever becomes invisible — then disappears on its own. Any responsable or adjoint cell that does surface links to the chart instead of toggling. Cells say R/A/✓ from the seat kind, headers carry congregation-wide holder counts, every change flashes a named confirmation, and the page keeps its scroll through a toggle. Below md the table becomes member cards with tappable chips, bands preserved as section labels.
…nts only The « Membre » option leaves the seat form: plain members are the matrix's to add and remove in bulk, and this panel names who leads. The form offers Responsable/Adjoint (Titulaire/Adjoint on a personal role), member rows still show in the list but carry no controls — a stray click while reviewing a team cannot remove half of it — and a quiet hint under the form points to the groupes d'aptitude. The help block opens on the division of labour.
On the printed sheet every service ultimately answers to the collège des anciens; a service floating at the top answers to nobody. The policy now refuses a parentless service — on adding to the chart and on moving within it alike — and the page stops offering what the policy refuses: the « au sommet » option leaves the move select (a legacy parentless node gets a required placeholder instead, so its one gesture is picking a proper home), and the root-add form narrows to its one legitimate use, putting a roster back at the top. That form appears only while a roster is off the chart — a congregation that removed its elder list, or an archive from before the organigram — and renders nothing otherwise.
« Groupes d'aptitude » named the concept but not the place: the tab now says « Rôles », matching the title above it, and the panel hint and the help block point at the same word.
…oute The roster split pushed the route past its hard size budget — caught by the pre-push hook. The adoptable/rosters assembly joins the other pure page assembly in organigram-panel.server, where it is testable without a request.
The export writes UserRoleAssignment.kind — its own comment warns that dropping it on restore would quietly demote every responsable — and the import did exactly that, reading only userId and roleId so every seat came back a plain member. The round trip is now proven against the real database: a leader and a deputy keep their seats through the archive. Archives from before seats existed carry no kind and restore as members, which is what those rows were. The organigram parent pass also logs a missing role-id mapping the way it already logged a missing parent, instead of skipping in silence.
…ying nothing On a stale tab whose role had been deleted, the toggle chained three silences: the service no-opped, the success flash hinged on a post-write lookup that missed, and the page reloaded saying nothing — the click that 'did nothing'. The role is now resolved before the write: missing, the admin is told the role no longer exists; present, the named success flash always fires.
The picker extraction landed buildRolePickers between the JSDoc and the function it describes. Also fixes a typo in the adoption banner comment.
…y are added The board only shows documents whose visibility window has opened, and a freshly created dynamic document was born with a null visibleFrom — it flashed « ajouté » and then never appeared, until the admin guessed that the optional date field on the edit page was the reason. Creation now stamps visibleFrom with the current time: adding a document means showing it, and the edit page is where a different window gets chosen.
The organigram had fallen through every per-type branch: no icon on the picker card, the generic grey DocumentCard, no preview line. It now carries the Network icon, a teal card treatment beside the other three types, and a « N services » teaser — computed in the sibling organigram-document file, since dynamic-documents.server.ts is over its grandfathered size budget.
toLayout now knows the printed order: the two rosters as the masthead — the assistants no longer sink below the whole elder branch — then the committee as a bench of three nameplates, the page's one framed element, because the coordinator is not « sous la responsabilité » of the committee: he is part of it. Each post's branch follows in canonical order under an eyebrow-and-name section header, and the services the collège des anciens keeps for itself close the sheet. The lines speak the congregation's vocabulary: a leader is titled « Responsable » only when they are an elder, otherwise « Préposé » — from Member.isHelder, carried onto every holder — and « Adjoint » stays what it was. A service with teams is one line: titled leaders, then the service's deputies and each team's préposé folded together under ADJOINTS, then ÉQUIPES naming the teams on their own row. Plain members collapse to a count everywhere except the rosters, which ARE the list. Typography and rhythm follow the app's own identity — Fraunces headers, the teal accent on the titles, firmer roster rules, aligned bench baselines — and print tightens the gaps so the sheet holds to one A4.
The UI refresh capped page content at max-w-7xl, and the two viewers fake full-bleed with negative margins — which cancel padding but cannot escape a max-width. On any screen wider than the cap, the viewer became a centred column with gutters instead of filling the page. The wrapper now lifts the cap for pages that declare data-full-bleed, the same zero-JS has() trick it already uses for data-form-actions.
The section eyebrow contracts like the phrase it is — « du » Coordinateur, « des » for a plural, « de l’ » before a vowel — and the elder branch answers to the « Collège des anciens », never to a roster called « Anciens ». The service-overseer post is « Responsable de la prédication » in French, everywhere at once since built-in names resolve per locale, with the adoption hints taught the new slug. Hidden team members leave no count behind, and every service name carries the same weight: the bold on services-with-teams was the old container-heading style, orphaned once ÉQUIPES became a text line. groupLayout and responsibilityEyebrow move into organigram-layout.ts — pure, tested, and about to be shared with the printable version.
A Download button on the organigram document's viewer produces the sheet as an A4 PDF — same reading order, same vocabulary, same blocks as the screen, because OrganigramDocument consumes the same pure pipeline: toLayout, groupLayout, seatLabel, responsibilityEyebrow. Built on the app's react-pdf conventions (renderPdfResponse, Fira Sans, sanitized text), and the resource route is guarded exactly like the viewer — board permission plus section visibility, so a PDF URL can never show anyone a document the board itself would not.
The dynamic viewer's PDF download adopts the document viewer's button — outline, small, icon beside a visible label that collapses to the icon alone on a phone — instead of a bare ghost icon nobody could name.
…PDF route guards The committee-key contract test proves behaviourally that the layout's client-side key mirrors have not drifted from built-in-roles.server.
…y-tree download groupLayout can only send rows and headerless bands to the legacy pile, so the type now says so and both renderers drop their dead defensive branch. The download button hides while the organigram is empty — that PDF is a blank page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Congregations keep an Organisation des services sheet: the elders and ministerial servants, a
service committee, and the services hanging under whichever committee member oversees each one.
Rolewas flat, so none of it could be expressed.It already half-existed in the data — eight of the demo congregation's ten custom roles are boxes
from that sheet. What was missing is the edge between them.
Model
One role per box. The person who runs a box is a member of that box marked
leader, not aseparate child role. A prototype that split each box in two took the demo congregation from 21
roles to 50, and every role picker in the app grew with it, because all six run
role.findMany({ where: { congregationId } })with no filter. Of the 29 nodes that prototypecreated, zero carried a permission.
The tree grants nothing.
parentRoleIdis reporting structure and is never traversed bypermission resolution, in either direction. The chart's root is the
elderroster, so inheritingdownward would make every seated person an elder, and inheriting upward would give every elder
every permission in the congregation. A person's permissions come only from the roles they are
actually in.
Tenancy is enforced by Postgres. The FK is composite —
[parentRoleId, congregationId]→[id, congregationId]— so a role cannot be parented into another congregation.MATCH SIMPLEmeans a null parent skips the check, so roots need no special case.
Migration is additive: four nullable columns plus two defaulted, nothing backfilled,
showInOrganigramfalse everywhere. The chart stays empty until an admin builds one, and thecurrently-deployed image never selects these columns.
Page
/congregation/roles/organigram— a read-only chart plus a node panel.clicked instead of re-selected in a detached form.
?node=so the panel survives a form post.PersonDropdown); members without a login appear disabled with thereason rather than hidden.
being refused after a reload.
md, sticky column above it.Tests
Invariants the database cannot hold — cycles, a depth cap of 10, identity rosters as roots — are
pure functions in
role-tree.policy.ts. Tree assembly is separable from the queries so orphanpromotion and ordering are tested without a database.
The migration test asserts the applied schema rather than re-running the DDL inside a
transaction:
ALTER TABLE ADD COLUMNtakes ACCESS EXCLUSIVE onRoleand holds it untilrollback, and integration files run in parallel against one database — a previous migration test
in this directory caused random timeouts in unrelated suites that way. Idempotence is asserted by
reading the shipped SQL instead.
Not in this PR
kind; restoring an archive would flatten thechart and demote every leader. Next change.
deleteRolehas no child guard yet.