diff --git a/README.md b/README.md index dceab93..79a98d9 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,11 @@ default-exports a function receiving the DSL: ```ts export default (ct) => { ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); - ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", parent: "mainz_area" }); + ct.group({ key: "mainz_area", name: "Mainz · Bereiche", groupTypeId: 2 }); + // Hierarchy is opt-in and multi-parent: `parents` are managed group keys, each declared + // in this config. Omit it to leave a group's hierarchy unmanaged; edges to unmanaged + // groups stay invisible. (`parent:` is unrelated — an ordering hint only, not hierarchy.) + ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", groupTypeId: 2, parents: ["mainz_area"] }); }; ``` diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 45ef94f..1fb3e1f 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -7,6 +7,7 @@ import { loadState, resolveStatePath } from "../state/state.js"; import { loadConfig, resolveConfigPath } from "../config/load.js"; import { computePlan } from "../engine/plan.js"; import { renderPlan } from "../engine/render.js"; +import { parentIdsByGroupId, applyHierarchy, type HierarchyEntry } from "../engine/hierarchy.js"; import { mapConcurrent } from "../util/concurrency.js"; import { info, warn, out } from "../ui.js"; @@ -63,7 +64,26 @@ export function planCommand(): Command { } }); - const plan = computePlan(desired, state, actual, { unresolved }); + // Group hierarchy: one bulk call, folded into each opted-in group's `parents` set-field. + let parentIds = new Map(); + let hierarchyOk = true; + const hasManagedGroups = Object.values(state.resources).some((m) => m.type === "group"); + if (hasManagedGroups) { + try { + const raw = await client.get("/groups/hierarchies"); + parentIds = parentIdsByGroupId(Array.isArray(raw) ? raw : []); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + fetchErrors.push(`group hierarchies: ${message}`); + warn(`Failed to fetch group hierarchies: ${message}`); + hierarchyOk = false; + } + } + // On a hierarchy-fetch failure, leave `parents` undiffed rather than folding an empty map + // (which would fabricate spurious "add all parents" changes). The plan is flagged INCOMPLETE below. + const desiredWithHierarchy = hierarchyOk ? applyHierarchy(desired, state, actual, parentIds) : desired; + + const plan = computePlan(desiredWithHierarchy, state, actual, { unresolved }); if (opts.json) { out(plan); } else { diff --git a/src/config/context.ts b/src/config/context.ts index ace9e59..57fd1c6 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -4,7 +4,8 @@ * * export default (ct: ConfigContext) => { * ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); - * ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", parent: "mainz_area" }); + * ct.group({ key: "mainz_area", name: "Mainz · Bereiche", groupTypeId: 2 }); + * ct.group({ key: "mainz_kids", name: "Mainz · Kids", groupTypeId: 2, parents: ["mainz_area"] }); * }; * * The context is injected (no global state), so blueprints are just functions @@ -14,7 +15,14 @@ import type { DesiredResource } from "../engine/types.js"; export interface ResourceInput { key: string; + /** Ordering hint: apply this resource after `parent`. A dependency edge only — NOT managed hierarchy. */ parent?: string; + /** + * Managed parent groups (group→group hierarchy). Opt-in: omit to leave a group's hierarchy + * unmanaged; `[]` means "managed with no parents". Each key must reference a group declared + * in the same config. + */ + parents?: string[]; dependsOn?: string[]; [field: string]: unknown; } @@ -31,15 +39,50 @@ export interface ConfigContext { export type ConfigModule = (ct: ConfigContext) => void | Promise; function toDesired(type: string, input: ResourceInput): DesiredResource { - const { key, parent, dependsOn = [], ...fields } = input; + const { key, parent, parents, dependsOn = [], ...fields } = input; if (!key || typeof key !== "string") { throw new Error(`${type} declaration is missing a string "key".`); } - const edges = [...dependsOn]; - if (parent) { - edges.push(parent); + // A nullish/empty `parent` is "no parent", not an opt-in to managed-empty hierarchy. + if (parent != null && typeof parent !== "string") { + throw new Error(`${type} "${key}": "parent" must be a string key.`); + } + if (parents !== undefined && (!Array.isArray(parents) || parents.some((p) => typeof p !== "string"))) { + throw new Error(`${type} "${key}": "parents" must be an array of string group keys.`); + } + // `parent` is an ordering hint only — a dependency edge, never a diffed/managed field + // (its pre-hierarchy meaning; a `parent` may point at a campus). Group hierarchy is + // managed opt-in via `parents`: `undefined` → unmanaged, `[]` → managed with no parents. + const parentKey = typeof parent === "string" && parent !== "" ? parent : undefined; + const parentKeys = parents !== undefined ? [...new Set(parents)] : undefined; + const edges = [...new Set([...dependsOn, ...(parentKey ? [parentKey] : []), ...(parentKeys ?? [])])]; + return { type, key, fields, parent: parentKey, parents: parentKeys, dependsOn: edges }; +} + +/** + * Every managed hierarchy parent must reference a group declared in the same config. + * A parent that resolves to nothing (typo, unmanaged group) or to a non-group would + * diff forever against the managed-only actual side, so reject it up front rather than + * emit a plan that can never converge. + */ +function validateReferences(resources: DesiredResource[]): void { + const byKey = new Map(resources.map((r) => [r.key, r])); + for (const r of resources) { + for (const parentKey of r.parents ?? []) { + const target = byKey.get(parentKey); + if (!target) { + throw new Error( + `Group "${r.key}" declares hierarchy parent "${parentKey}", which is not declared in this config. ` + + `Managed parents must reference a group by its key (omit unmanaged parents entirely).`, + ); + } + if (target.type !== "group") { + throw new Error( + `Group "${r.key}" declares hierarchy parent "${parentKey}", but "${parentKey}" is a ${target.type}, not a group.`, + ); + } + } } - return { type, key, fields, parent, dependsOn: edges }; } export function createContext(): { ct: ConfigContext; resources: DesiredResource[] } { @@ -72,5 +115,6 @@ export function createContext(): { ct: ConfigContext; resources: DesiredResource export async function evaluateConfig(mod: ConfigModule): Promise { const { ct, resources } = createContext(); await mod(ct); + validateReferences(resources); return resources; } diff --git a/src/engine/hierarchy.ts b/src/engine/hierarchy.ts new file mode 100644 index 0000000..68c60fa --- /dev/null +++ b/src/engine/hierarchy.ts @@ -0,0 +1,97 @@ +/** + * Group hierarchy (parent/child) is a many-to-many DAG in ChurchTools: a group + * can have several parents. `GET /groups/hierarchies` returns, per group, the + * ids of its parents and children. + * + * We surface hierarchy in the plan as an opt-in `parents` set-field on a group, + * resolved to logical keys and **restricted to managed groups** — an edge to an + * unmanaged group is invisible (managed-guard), never diffed or proposed for + * removal. + */ + +import type { State } from "../state/state.js"; +import type { DesiredResource } from "./types.js"; + +export interface HierarchyEntry { + groupId: number; + parents?: number[]; + children?: number[]; +} + +/** Map each group id to its parent group ids. */ +export function parentIdsByGroupId(entries: HierarchyEntry[]): Map { + const map = new Map(); + for (const entry of entries) { + if (typeof entry.groupId === "number") { + map.set(entry.groupId, Array.isArray(entry.parents) ? entry.parents : []); + } + } + return map; +} + +/** + * The managed parent keys for a group: its parent ids that are themselves under + * management, mapped to their logical keys and sorted (stable for set diffing). + */ +export function managedParentKeys(parentIds: number[], groupIdToKey: Map): string[] { + const keys: string[] = []; + for (const id of parentIds) { + const key = groupIdToKey.get(id); + if (key !== undefined) { + keys.push(key); + } + } + return keys.sort(); +} + +/** + * Fold group hierarchy into the plan inputs as a `parents` set-field. + * + * Only groups that opted into hierarchy (`parents !== undefined` on the desired + * side) are touched: their `actual` record gets a `parents` set of managed + * parent keys (from `parentIdsByGroup`), and their desired gets a sorted + * `parents` field so `computePlan` diffs it generically. Groups that did not opt + * in are left untouched on both sides — their hierarchy is not managed. + * + * Two caveats `parents` inherits from being a synthetic field: + * - It is a *pseudo-field* of logical keys, NOT a real ChurchTools group column. + * Apply must route it to the per-edge endpoint (`PUT`/`DELETE + * /groups/{id}/parents/{parentId}`) — never PATCH it onto the group object. + * - The adopt snapshot (`managed.fields`) never carries `parents`, so a + * third-party re-parent surfaces as an ordinary `update`, not under drift. + */ +export function applyHierarchy( + desired: DesiredResource[], + state: State, + actual: Map>, + parentIdsByGroup: Map, +): DesiredResource[] { + const groupIdToKey = new Map(); + for (const managed of Object.values(state.resources)) { + if (managed.type === "group") { + groupIdToKey.set(managed.id, managed.key); + } + } + + // Only groups whose desired opted into hierarchy are managed — mirror the desired-side guard + // below so we never write a `parents` field onto the actual of an opted-out group. + const optedIn = new Set( + desired.filter((d) => d.type === "group" && d.parents !== undefined).map((d) => d.key), + ); + + for (const managed of Object.values(state.resources)) { + if (managed.type !== "group" || !optedIn.has(managed.key)) { + continue; + } + const a = actual.get(managed.key); + if (a) { + a.parents = managedParentKeys(parentIdsByGroup.get(managed.id) ?? [], groupIdToKey); + } + } + + return desired.map((d) => + d.type === "group" && d.parents !== undefined + ? { ...d, fields: { ...d.fields, parents: [...d.parents].sort() } } + : d, + ); +} diff --git a/src/engine/types.ts b/src/engine/types.ts index 66ac111..cfc1fe6 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -8,9 +8,11 @@ export interface DesiredResource { key: string; /** Managed fields — same shape as the Phase 2 registry snapshot, so they diff like-for-like against actual. */ fields: Record; - /** Parent logical key (group hierarchy). Also contributes a dependency edge. */ + /** Ordering hint: a dependency edge only (may point at a campus). NOT managed hierarchy — see `parents`. */ parent?: string; - /** Logical keys this resource must be applied after (includes `parent`). */ + /** Managed parent group keys (hierarchy). `undefined` = hierarchy not managed for this group; `[]` = should have no managed parents. */ + parents?: string[]; + /** Logical keys this resource must be applied after (includes `parent`/`parents`). */ dependsOn: string[]; } diff --git a/tests/context.test.ts b/tests/context.test.ts index fd3375b..bf5ab10 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -30,6 +30,64 @@ describe("config context", () => { expect(resources[0]?.dependsOn).toEqual(["type_x", "lead"]); }); + it("captures multiple parents (hierarchy is a DAG) as an opt-in set, not in fields", () => { + const { ct, resources } = createContext(); + ct.group({ key: "team", name: "Team", parents: ["area_a", "area_b"] }); + expect(resources[0]?.parents).toEqual(["area_a", "area_b"]); + expect(resources[0]?.dependsOn).toEqual(["area_a", "area_b"]); + expect(resources[0]?.fields).not.toHaveProperty("parents"); + }); + + it("treats parents as opt-in: omitted → undefined, explicit [] → managed-empty", () => { + const { ct, resources } = createContext(); + ct.group({ key: "no_hierarchy", name: "N" }); + ct.group({ key: "empty_hierarchy", name: "E", parents: [] }); + expect(resources[0]?.parents).toBeUndefined(); + expect(resources[1]?.parents).toEqual([]); + }); + + it("de-duplicates the `parents` set", () => { + const { ct, resources } = createContext(); + ct.group({ key: "team", name: "T", parents: ["lead", "other", "lead"] }); + expect(resources[0]?.parents).toEqual(["lead", "other"]); + }); + + it("keeps `parent` out of managed hierarchy: it is an ordering edge only, never in `parents`", () => { + const { ct, resources } = createContext(); + ct.group({ key: "team", name: "T", parent: "lead", parents: ["other"] }); + expect(resources[0]?.parent).toBe("lead"); + expect(resources[0]?.parents).toEqual(["other"]); // `lead` is NOT folded into managed parents + expect(resources[0]?.dependsOn).toEqual(["lead", "other"]); // both still contribute ordering edges + }); + + it("treats a falsy `parent` as absent, not as opt-in to managed-empty hierarchy", () => { + const { ct, resources } = createContext(); + ct.group({ key: "g", name: "G", parent: "" }); + expect(resources[0]?.parents).toBeUndefined(); // no hierarchy management + expect(resources[0]?.dependsOn).toEqual([]); + }); + + it("rejects a non-array / non-string `parents`", () => { + const { ct } = createContext(); + expect(() => ct.group({ key: "g", name: "G", parents: "area" as never })).toThrow(/array of string/); + expect(() => ct.group({ key: "h", name: "H", parents: [1] as never })).toThrow(/array of string/); + }); + + it("rejects a hierarchy parent that is not a declared group", async () => { + await expect( + evaluateConfig((ct) => { + ct.campus({ key: "mz", name: "Mainz" }); + ct.group({ key: "kids", name: "Kids", parents: ["mz"] }); // mz is a campus + }), + ).rejects.toThrow(/is a campus, not a group/); + + await expect( + evaluateConfig((ct) => { + ct.group({ key: "kids", name: "Kids", parents: ["ghost"] }); // never declared + }), + ).rejects.toThrow(/not declared in this config/); + }); + it("rejects a duplicate logical key", () => { const { ct } = createContext(); ct.campus({ key: "mainz", name: "A" }); diff --git a/tests/hierarchy.test.ts b/tests/hierarchy.test.ts new file mode 100644 index 0000000..d488889 --- /dev/null +++ b/tests/hierarchy.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; +import { parentIdsByGroupId, managedParentKeys, applyHierarchy } from "../src/engine/hierarchy.js"; +import type { DesiredResource } from "../src/engine/types.js"; +import type { State, ManagedResource } from "../src/state/state.js"; + +function group(key: string, id: number): ManagedResource { + return { type: "group", id, key, fields: { name: key }, adoptedAt: "t", updatedAt: "t" }; +} + +describe("parentIdsByGroupId", () => { + it("maps group ids to their parent id lists", () => { + const map = parentIdsByGroupId([ + { groupId: 1311, parents: [8, 119, 1175], children: [] }, + { groupId: 8, children: [1311] }, + ]); + expect(map.get(1311)).toEqual([8, 119, 1175]); + expect(map.get(8)).toEqual([]); // no parents key → empty + }); +}); + +describe("managedParentKeys", () => { + it("keeps only managed parents, mapped to keys and sorted", () => { + const idToKey = new Map([ + [8, "team_kids"], + [119, "not_relevant"], + ]); + // 1175 is unmanaged → dropped; result sorted + expect(managedParentKeys([1175, 119, 8], idToKey)).toEqual(["not_relevant", "team_kids"]); + }); +}); + +describe("applyHierarchy", () => { + const state: State = { + version: 1, + host: "h", + resources: { child: group("child", 1311), parent: group("parent", 8) }, + }; + + it("injects actual managed parents and augments opted-in desired groups", () => { + const actual = new Map>([ + ["child", { name: "child" }], + ["parent", { name: "parent" }], + ]); + const parentIds = new Map([ + [1311, [8, 119, 1175]], // only 8 is managed + [8, []], + ]); + const desired: DesiredResource[] = [ + { type: "group", key: "child", fields: { name: "child" }, parents: ["parent"], dependsOn: ["parent"] }, + { type: "group", key: "parent", fields: { name: "parent" }, dependsOn: [] }, // no opt-in + ]; + + const out = applyHierarchy(desired, state, actual, parentIds); + + expect(actual.get("child")?.parents).toEqual(["parent"]); // 119/1175 unmanaged → dropped + // "parent" did not opt into hierarchy → its actual is left untouched (no injected pseudo-field). + expect(actual.get("parent")).not.toHaveProperty("parents"); + expect(out[0]?.fields.parents).toEqual(["parent"]); + expect(out[1]?.fields.parents).toBeUndefined(); // group that did not opt in is untouched + }); + + it("injects an empty actual.parents for an opted-in group whose managed parents are all gone", () => { + // A group that opts in (parents: []) but still has a managed parent in CT must see the removal. + const actual = new Map>([ + ["child", { name: "child" }], + ["parent", { name: "parent" }], + ]); + const parentIds = new Map([[1311, [8]]]); // CT still links child -> parent(8, managed) + const desired: DesiredResource[] = [ + { type: "group", key: "child", fields: { name: "child" }, parents: [], dependsOn: [] }, // managed-empty + { type: "group", key: "parent", fields: { name: "parent" }, dependsOn: [] }, + ]; + + const out = applyHierarchy(desired, state, actual, parentIds); + + expect(actual.get("child")?.parents).toEqual(["parent"]); // real managed parent surfaced... + expect(out[0]?.fields.parents).toEqual([]); // ...against a desired of none → diff proposes removal + }); + + it("leaves non-group resources untouched", () => { + const campusState: State = { + version: 1, + host: "h", + resources: { mz: { type: "campus", id: 0, key: "mz", fields: {}, adoptedAt: "t", updatedAt: "t" } }, + }; + const actual = new Map>([["mz", { name: "Mainz" }]]); + applyHierarchy([], campusState, actual, new Map()); + expect(actual.get("mz")).not.toHaveProperty("parents"); + }); +});