Step 2 — Org/View Split + "Create View From Here" (Implementation Spec)
Hand-off-ready spec for Step 2 of the multi-chart plan (docs/admin/org-chart-multi-chart-design.md, issue #1549). This is the largest step — it introduces the data-model split that makes “many charts from one org” a live view, not a data copy.
Depends on nothing structurally, but is best sequenced after Step 1 (#1556) so the import path already produces clean orgs. All schema/line references verified against main as of 2026-07-13.
Goal
Let a user turn any node into a new chart as a live view over the same underlying people — the ChartHop/Workday “focus on this org” pattern. When the org’s data changes, every view updates. No duplication, no drift.
Key architectural decision: additive, not a rewrite
Today org_charts conflates the dataset (people + relationships + versions), the presentation (template/density/colors/branding), and the commercial unit (finalize triple + hosting). A full three-way split would require a risky backfill of every existing chart, share, hosted slug, and entitlement.
Instead: treat org_charts as the org (dataset + its canonical/default view), and add a lightweight org_chart_views table for additional views that reference it. This is exactly the “additive” path the design doc promised, and it means:
- Existing charts need zero backfill —
org_chartsand all its child tables keep working unchanged. Every current chart is an org with an implicit default view (whole tree, its existing template). - New named views are rows in
org_chart_views, referencing a parentorg_chartsand storing only{ root, filter, presentation }. - Rendering a view = load the parent org’s people, start traversal at the view’s root, apply its filter/depth, render with its template. Pure server-side derivation over the existing edges.
The full physical decomposition (moving commercial/hosting off org_charts) is not required for Step 2 and is explicitly deferred.
Scope
In scope
- New
org_chart_viewstable + RLS (migration). - Nullable
view_idonorg_chart_outputs,org_chart_shares,hosted_charts(default null = whole-org artifact — no backfill). - CRUD API for views (create/list/get/patch/delete), registered in both entry points.
- View scoping in the render/preview/generate path: given a
viewId, prune the org’s people tosubtree(rootPersonId, maxDepth)and apply thefilter, then render — flattened into one accessible tree. - Client: “Create view from here” action on any node; a Views list per org; view preview reusing existing templates with scoped data.
- Views inherit the parent org’s finalize/watermark state (defer per-view pricing to Phase 3).
Out of scope (later steps)
- Fork-to-independent-chart (copying a subtree into a new dataset) — that’s Step 3 and uses
createChartFromPeoplefrom Step 1. Step 2 views never copy data. - Transclusion / cross-org reference nodes — Step 4.
- Per-view Stripe pricing, seat tiers, workspaces — Phase 3 (competitive-analysis plan).
- Physically moving commercial/hosting columns off
org_charts— not needed under the additive model. - Versioning individual views — versioning stays at the org grain (
org_chart_versionsalready FKsorg_chart_id); views are disposable configs.
Current schema (verified — 056_org_chart_tables.sql)
Everything cascades off org_charts:
| Table | Key cols | FK to org_charts |
|---|---|---|
org_charts | id, user_id, name, presentation, finalize triple, branding | — (root) |
org_chart_people | id, org_chart_id, manager_id→people, department, custom fields | org_chart_id CASCADE |
org_chart_relationships | person_id, related_person_id, type | org_chart_id CASCADE |
org_chart_versions | version_number, snapshot | org_chart_id CASCADE |
org_chart_outputs | generated HTML, version_id | org_chart_id CASCADE |
org_chart_shares | output_id, template_id, access | org_chart_id CASCADE |
hosted_charts | public slug | org_chart_id |
extraction_quality_metrics | scores | org_chart_id CASCADE |
RLS: org_charts uses auth.uid() = user_id; children use EXISTS (SELECT 1 FROM org_charts WHERE id = org_chart_id AND user_id = auth.uid()). Presentation + finalize triple (finalized_at, paid_until, stripe_payment_intent_id) live on org_charts (see types.ts:28-56, migrations 117/118).
Migration (new file, next number after 208)
CREATE TABLE public.org_chart_views ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_chart_id UUID NOT NULL REFERENCES public.org_charts(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, -- denormalized owner for RLS name TEXT NOT NULL, root_person_id UUID REFERENCES public.org_chart_people(id) ON DELETE SET NULL, -- null = whole org from top max_depth INT, -- null = unlimited filter JSONB, -- e.g. { "departments": ["Eng"], "includeSecondary": true }; null = none template_id TEXT, -- presentation; null = inherit org's density TEXT, color_scheme TEXT, custom_palette JSONB, header_text TEXT, header_image_r2_key TEXT, footer_text TEXT, footer_image_r2_key TEXT, -- null = inherit org created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE INDEX idx_org_chart_views_org ON public.org_chart_views(org_chart_id);CREATE INDEX idx_org_chart_views_user ON public.org_chart_views(user_id);
ALTER TABLE public.org_chart_outputs ADD COLUMN view_id UUID REFERENCES public.org_chart_views(id) ON DELETE CASCADE;ALTER TABLE public.org_chart_shares ADD COLUMN view_id UUID REFERENCES public.org_chart_views(id) ON DELETE CASCADE;ALTER TABLE public.hosted_charts ADD COLUMN view_id UUID REFERENCES public.org_chart_views(id) ON DELETE CASCADE;
-- RLS: owner-only, mirroring the child-table patternALTER TABLE public.org_chart_views ENABLE ROW LEVEL SECURITY;CREATE POLICY org_chart_views_owner ON public.org_chart_views FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);- No backfill. Existing rows are untouched;
view_iddefaults null on the three artifact tables (= whole-org artifact, current behavior). root_person_idusesON DELETE SET NULL(matchesmanager_idsemantics) — deleting the root person degrades the view to whole-org rather than cascading it away. Surface that in the UI as a warning.- Non-additive migration → must wait for a Node
npm run rebuildto align with deployed code (per the node-rebuild rule). Ship the migration and the code that reads the new table together.
Types (packages/org-chart-shared/src/types.ts)
Add OrgChartView interface mirroring the columns (camelCase at the API boundary per house rules). Add a ViewFilter type ({ departments?: string[]; includeSecondary?: boolean }). Rebuild @org-chart/shared.
API (workers/api/src/routes/org-chart/)
New file views.ts (mount /api/orgcharts/:id/views in both index.ts and index-aws.ts):
POST /api/orgcharts/:id/views—{ name, rootPersonId?, maxDepth?, filter?, presentation? }→ create (“Create view from here”). ValidaterootPersonIdbelongs to:id.GET /api/orgcharts/:id/views— list views for the org.GET /api/orgcharts/:id/views/:viewId— get one.PATCH /api/orgcharts/:id/views/:viewId— update root/filter/presentation.DELETE /api/orgcharts/:id/views/:viewId— cascades its outputs/shares/hosting via FK.
View scoping helper (new workers/api/src/utils/view-scope.ts):
scopePeople(people, relationships, { rootPersonId, maxDepth, filter }) → { people, relationships }- Build the tree from
manager_id; ifrootPersonIdset, keep it + its descendants (respectingmaxDepth); else whole tree. - Apply
filter.departments(keep matching + ancestors needed to connect them, or flag disconnected — pick “keep ancestors” so the tree stays rooted). - Drop relationships whose endpoints fall outside the scoped set.
- Guarantee a single connected tree for the accessible renderer (the moat) — never emit orphan forests without a virtual root.
Render/preview/generate (generate.ts, org-chart-export.ts, preview): accept an optional viewId. When present, load the parent org’s people/relationships, run scopePeople, and render with the view’s presentation (falling back to the org’s where null). No viewId → unchanged whole-org behavior. Outputs/shares created for a view set view_id.
Watermark/finalize: a view inherits the parent org’s paid_until — isWithinPaidWindow(org) (types.ts:80) governs the view’s watermark. No new entitlement logic in Step 2; per-view pricing is Phase 3.
Client (apps/org-chart/src/app/)
- “Create view from here” action on each node in the editor/preview tree → dialog: name (default “
‘s org”), optional max depth, optional department filter, optional template (default inherit) → POST …/views→ navigate to the view preview (/preview?id=:orgId&view=:viewId). - Views list: a section on the chart/dashboard showing all views of an org (name, root person, last updated) with open/rename/delete. Distinguish the org (whole tree) from its views.
- Preview/editor read
viewquery param and request the scoped render. Editing people still edits the org (one source of truth) — make that explicit in the UI (“Editing the organization — changes appear in all views”). - Reuse existing template components with the scoped dataset. Every scoped render must pass
test:a11y(single ARIA tree).
Accessibility: the “Create view from here” control is keyboard-reachable on every node (not hover-only — ties into the radial keyboard work from Phase 2); dialog has a focus trap + restore; the views list is a real list/table; root-person-deleted degradation is announced, not silent.
Tests
- Migration/RLS: a user cannot see/modify another user’s views; deleting an org cascades its views; deleting a view cascades its outputs/shares/hosting; deleting the root person nulls
root_person_id. - Unit
view-scope.test.ts: subtree extraction;maxDepth; department filter keeps ancestors; relationships pruned to scope; always one connected tree (virtual root when needed); whole-org when no root. - Route
views.test.ts(route-test-coverage rule): CRUD;rootPersonIdmust belong to the org; scoped generate/preview returns the subtree; view output/share carriesview_id; watermark follows the parent org’s paid window. - A11y: scoped renders (subtree, filtered) pass axe; “Create view from here” + views list keyboard-operable.
npm run typecheck(via npm) +test:cigreen; coverage not decreased.
Acceptance criteria
- From any node, a user creates a named view; opening it shows that subtree, rendered live from the org’s current data.
- Editing a person in the org updates every view that includes them — no copies, no drift.
- Views can carry their own template/filter/depth; where unset they inherit the org’s.
- A paid org’s views render watermark-free within its paid window; an unpaid org’s views are watermarked.
- Deleting an org removes its views and their artifacts; deleting the root person degrades the view to whole-org with a visible warning (no silent data loss).
- Every scoped render is a single, keyboard-navigable ARIA tree passing axe.
- Existing charts, shares, and hosted slugs behave exactly as before (view_id null path); new routes work on both Node and Lambda.
Runtime & deploy
Node + Lambda; register views routes in both entry points. Non-additive migration → ship migration + code together and run npm run rebuild on 10.1.1.4 after merge (migration does not auto-apply on the Node runtime); Lambda/CF auto-deploy. New @org-chart/shared type export → rebuild the shared package; watch the workers/api Dockerfile workspace-deps rule. Smoke-test: create a view from a mid-tree node, edit a person, confirm the view reflects it.
Sequencing
One PR off feature/org-chart-org-view-split (branch from main). Unblocks Step 3 (fork-to-snapshot — the explicit copy path, reusing createChartFromPeople) and Step 4 (transclusion — reference nodes resolved through scopePeople into one tree). Pairs naturally with Phase 3 tiering, which decides per-view vs per-org billing on top of this model.