Skip to content

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 backfillorg_charts and 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 parent org_charts and 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

  1. New org_chart_views table + RLS (migration).
  2. Nullable view_id on org_chart_outputs, org_chart_shares, hosted_charts (default null = whole-org artifact — no backfill).
  3. CRUD API for views (create/list/get/patch/delete), registered in both entry points.
  4. View scoping in the render/preview/generate path: given a viewId, prune the org’s people to subtree(rootPersonId, maxDepth) and apply the filter, then render — flattened into one accessible tree.
  5. Client: “Create view from here” action on any node; a Views list per org; view preview reusing existing templates with scoped data.
  6. 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 createChartFromPeople from 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_versions already FKs org_chart_id); views are disposable configs.

Current schema (verified — 056_org_chart_tables.sql)

Everything cascades off org_charts:

TableKey colsFK to org_charts
org_chartsid, user_id, name, presentation, finalize triple, branding— (root)
org_chart_peopleid, org_chart_id, manager_id→people, department, custom fieldsorg_chart_id CASCADE
org_chart_relationshipsperson_id, related_person_id, typeorg_chart_id CASCADE
org_chart_versionsversion_number, snapshotorg_chart_id CASCADE
org_chart_outputsgenerated HTML, version_idorg_chart_id CASCADE
org_chart_sharesoutput_id, template_id, accessorg_chart_id CASCADE
hosted_chartspublic slugorg_chart_id
extraction_quality_metricsscoresorg_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 pattern
ALTER 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_id defaults null on the three artifact tables (= whole-org artifact, current behavior).
  • root_person_id uses ON DELETE SET NULL (matches manager_id semantics) — 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 rebuild to 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”). Validate rootPersonId belongs 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; if rootPersonId set, keep it + its descendants (respecting maxDepth); 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_untilisWithinPaidWindow(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 view query 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; rootPersonId must belong to the org; scoped generate/preview returns the subtree; view output/share carries view_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:ci green; coverage not decreased.

Acceptance criteria

  1. From any node, a user creates a named view; opening it shows that subtree, rendered live from the org’s current data.
  2. Editing a person in the org updates every view that includes them — no copies, no drift.
  3. Views can carry their own template/filter/depth; where unset they inherit the org’s.
  4. A paid org’s views render watermark-free within its paid window; an unpaid org’s views are watermarked.
  5. 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).
  6. Every scoped render is a single, keyboard-navigable ARIA tree passing axe.
  7. 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.