Step 3 — Fork to Independent Chart (Snapshot) — Implementation Spec
Hand-off-ready spec for Step 3 of the multi-chart plan (docs/admin/org-chart-multi-chart-design.md, issue #1549). Small step. Depends on Step 2 (#1558, scopePeople) and Step 1 (#1556, createChartFromPeople) — do not start until both merge.
Goal
Provide the explicit, rare “Fork to independent chart” action: copy a subtree (or a whole org / a view) into a new, independent dataset for what-if / scenario / archival use. Unlike a Step 2 view, a fork is a point-in-time copy that deliberately drifts — it does not stay in sync with the source.
The whole point of Step 2 was to make views the default so people don’t fork-and-drift by accident. Step 3 exists so the legitimate copy use-case has a clear, clearly-labeled home.
Scope
In scope
POST /api/orgcharts/:id/forkandPOST /api/orgcharts/:id/views/:viewId/fork— materialize a scoped subtree into a brand-neworg_chartsdataset.- Deep-copy people + relationships with regenerated IDs and remapped
manager_id/relationship endpoints. - Provenance columns (
forked_from_org_chart_id,forked_at) — additive, nullable, no backfill. - Client “Fork to independent chart” action, explicitly labeled as a drifting copy (contrasted with “Create view”).
Out of scope
- Live views (Step 2), transclusion (Step 4), per-view/-chart pricing (Phase 3).
- Copying version history — a fork starts a fresh version lineage (its own
org_chart_versions).
How it differs from a Step 2 view
| View (Step 2) | Fork (Step 3) | |
|---|---|---|
| Data | Derived over the org’s edges | New independent org_charts + copied people |
| Sync | Live — org changes propagate | Frozen — drifts by design |
| Commercial | Inherits parent org’s paid_until | New unpaid unit — watermarked until finalized separately |
| Use | Departmental charts, focus views | What-if, scenario, archival snapshot |
| Default? | Yes | No — deliberate action |
Migration (additive, no backfill)
ALTER TABLE public.org_charts ADD COLUMN forked_from_org_chart_id UUID REFERENCES public.org_charts(id) ON DELETE SET NULL, ADD COLUMN forked_at TIMESTAMPTZ;CREATE INDEX idx_org_charts_forked_from ON public.org_charts(forked_from_org_chart_id);ON DELETE SET NULL so deleting the source doesn’t cascade the fork (a fork is fully independent). Additive → still ship with code + npm run rebuild.
API (workers/api/src/routes/org-chart/)
Add to views.ts (or a small fork.ts); mount is already covered by /api/orgcharts in both entry points.
POST /api/orgcharts/:id/fork—{ name, rootPersonId?, maxDepth?, filter? }POST /api/orgcharts/:id/views/:viewId/fork—{ name? }(uses the view’s root/filter/depth/presentation)
Flow:
- Load the source org’s people + relationships; run
scopePeople(Step 2) with the given root/filter/depth. - Regenerate IDs: build
oldId → newIdmap for every person; rewritemanager_idand relationshipperson_id/related_person_idthrough the map. Managers whose target falls outside the scope become roots (or null) — same rulescopePeoplealready applies. - Copy custom fields verbatim; copy person
photoUrl/linkUrlstrings as-is (external refs). - Copy R2 branding objects (header/footer images) to the new chart’s R2 keys so deleting the source can’t break the fork — do not reference-share R2 keys across datasets.
- Write via
createChartFromPeople(Step 1) → neworg_charts(new id,forked_from_org_chart_id = :id,forked_at = now(), presentation copied from source or view, finalize triple null → unpaid/watermarked). - Return
{ orgChart }.
No entitlement inheritance: a fork is a new dataset the user may host separately; it starts unpaid/watermarked. Creating it is free (same as creating any chart); it counts against chart quota once tiers exist (Phase 3).
Client (apps/org-chart/src/app/)
- “Fork to independent chart” action on a node and on a view. Confirmation dialog that states plainly: “This creates a separate copy. It will NOT stay in sync with the original — future edits to either won’t affect the other.” (Explicit contrast with “Create view from here.”)
- On success → navigate to the new chart’s editor.
- Where both actions appear, order/word them so the live view is the obvious default and fork reads as the deliberate exception.
- If the source is later shown, surface provenance (“Forked from X on date”) using
forked_from_org_chart_id.
Accessibility: dialog focus trap + restore; action keyboard-reachable on every node; announcements not color-only.
Tests
- Unit: ID regeneration + manager/relationship remap correctness; out-of-scope managers become roots; custom fields preserved.
- Route
fork.test.ts(route-test-coverage rule): fork whole org, fork subtree, fork a view; new chart is independent (edit source → fork unchanged, and vice versa); fork is watermarked (finalize triple null);forked_from/forked_atrecorded; R2 branding objects copied not shared. - A11y: fork dialog + action.
npm run typecheck(via npm) +test:cigreen; coverage not decreased.
Acceptance criteria
- From a node or a view, a user forks to a new independent chart; it opens in the editor with the scoped people.
- Editing the source after forking does not change the fork (and vice versa) — verified drift.
- The fork is a new unpaid, watermarked commercial unit; it does not inherit the source’s paid window.
- Person IDs are regenerated; manager lines and secondary relationships remap correctly within the fork.
- Branding images are copied, so deleting the source never breaks the fork.
- Provenance is recorded and shown; the UI makes clear a fork drifts (vs a live view).
- Works on both Node and Lambda; existing charts unaffected.
Runtime & deploy
Node + Lambda; additive migration ships with code → npm run rebuild on 10.1.1.4 after merge; Lambda/CF auto-deploy. One PR off feature/org-chart-fork-snapshot (branch from main after Steps 1–2 merge).