Skip to content

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

  1. POST /api/orgcharts/:id/fork and POST /api/orgcharts/:id/views/:viewId/fork — materialize a scoped subtree into a brand-new org_charts dataset.
  2. Deep-copy people + relationships with regenerated IDs and remapped manager_id/relationship endpoints.
  3. Provenance columns (forked_from_org_chart_id, forked_at) — additive, nullable, no backfill.
  4. 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)
DataDerived over the org’s edgesNew independent org_charts + copied people
SyncLive — org changes propagateFrozen — drifts by design
CommercialInherits parent org’s paid_untilNew unpaid unit — watermarked until finalized separately
UseDepartmental charts, focus viewsWhat-if, scenario, archival snapshot
Default?YesNo — 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:

  1. Load the source org’s people + relationships; run scopePeople (Step 2) with the given root/filter/depth.
  2. Regenerate IDs: build oldId → newId map for every person; rewrite manager_id and relationship person_id/related_person_id through the map. Managers whose target falls outside the scope become roots (or null) — same rule scopePeople already applies.
  3. Copy custom fields verbatim; copy person photoUrl/linkUrl strings as-is (external refs).
  4. 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.
  5. Write via createChartFromPeople (Step 1) → new org_charts (new id, forked_from_org_chart_id = :id, forked_at = now(), presentation copied from source or view, finalize triple null → unpaid/watermarked).
  6. 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_at recorded; R2 branding objects copied not shared.
  • A11y: fork dialog + action.
  • npm run typecheck (via npm) + test:ci green; coverage not decreased.

Acceptance criteria

  1. From a node or a view, a user forks to a new independent chart; it opens in the editor with the scoped people.
  2. Editing the source after forking does not change the fork (and vice versa) — verified drift.
  3. The fork is a new unpaid, watermarked commercial unit; it does not inherit the source’s paid window.
  4. Person IDs are regenerated; manager lines and secondary relationships remap correctly within the fork.
  5. Branding images are copied, so deleting the source never breaks the fork.
  6. Provenance is recorded and shown; the UI makes clear a fork drifts (vs a live view).
  7. 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).