From c4197f222a06ae9c5d829579b6ad438d9e5290d9 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 9 Jul 2026 08:31:37 +0200 Subject: [PATCH 1/4] feat(groups): manage campus assignment via numeric campusId (#21) Add campusId to the group's managedFields, read from information.campusId (with top-level fallback) and normalised to null when unset, mirroring how groupTypeId/groupStatusId are wired. The executor already writes it the field-agnostic way (top-level campusId on PATCH). The DSL passes a numeric campusId through as a plain field and now rejects a logical `campus: "key"` reference (deferred to #20) and a non-numeric campusId. Document why an additive managed field needs no state migration (unlike shortName->shorty). --- src/config/context.ts | 13 +++++++++++++ src/resources/registry.ts | 7 +++++++ src/state/state.ts | 8 ++++++++ 3 files changed, 28 insertions(+) diff --git a/src/config/context.ts b/src/config/context.ts index e3d1e77..8f97754 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -75,6 +75,19 @@ function toDesired(type: string, input: ResourceInput): DesiredResource { 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.`); } + // Campus assignment is a numeric escape hatch only (mirrors `groupTypeId`): `campusId: `. + // A logical `campus: "mainz"` reference is #20's resolver, not built yet — reject it up front + // rather than let an un-diffable `campus` field slip into the bag and drift against the + // managed-only actual forever. Also pin `campusId`'s type so a stray string fails at eval time. + if (fields.campus !== undefined) { + throw new Error( + `${type} "${key}": logical campus references ("campus") are not supported yet — ` + + `use a numeric "campusId" (the existing CT campus id). Logical references land with #20.`, + ); + } + if (fields.campusId !== undefined && fields.campusId !== null && typeof fields.campusId !== "number") { + throw new Error(`${type} "${key}": "campusId" must be a number (the CT campus id) or null to clear.`); + } // `dynamic` is a synthetic field for auto-groups, handled separately from the plain diffed // field bag. Opt-in: `undefined` means "not a dynamic group" (mirrors `parents`). let dynamicSpec: DynamicSpec | undefined; diff --git a/src/resources/registry.ts b/src/resources/registry.ts index 9d74374..48dd25f 100644 --- a/src/resources/registry.ts +++ b/src/resources/registry.ts @@ -77,10 +77,17 @@ export const RESOURCES: Record = { updateMethod: "PATCH", tier: 1, deriveKey: (r) => slug(str(r, "name")), + // Campus lives on the live group at `information.campusId` (same nesting as groupTypeId / + // groupStatusId), and PATCH accepts it as a top-level `campusId` — so it is read via + // `fromInformation` and written the same field-agnostic way the executor writes every field. + // Numeric escape hatch only: a *logical* `campus: "key"` reference is #20's resolver, not this. + // Normalise an unset campus to `null` (never `undefined`) so the actual side is deterministic — + // an assign/change/clear all diff against a concrete `null`, and campus id `0` (Mainz) survives. managedFields: (r) => ({ name: r.name, groupTypeId: fromInformation(r, "groupTypeId"), groupStatusId: fromInformation(r, "groupStatusId"), + campusId: fromInformation(r, "campusId") ?? null, }), }), "group-type": define({ diff --git a/src/state/state.ts b/src/state/state.ts index c202ea0..f0c7847 100644 --- a/src/state/state.ts +++ b/src/state/state.ts @@ -111,6 +111,14 @@ function validateState(parsed: unknown, path: string): State { * create-required `shorty`. Rename the key on load — only when `shorty` is absent, * so a post-rename snapshot is never clobbered. The next apply re-writes the real * value; this just clears the phantom drift so the diff can converge. + * + * Contrast with an *additive* managed field (e.g. group `campusId`, #21): a snapshot from + * before the field was managed simply lacks the key. That produces NO phantom drift and needs + * no migration here, because the diff is desired-driven (`diffFields` only walks the config's + * fields) and drift is snapshot-driven (`driftFields` only walks the old snapshot's keys) — a + * key absent from both sides is never surfaced. The write body comes from the fetched actual + * (#27), so an unrelated update never omits or reverts the new field, and the post-write snapshot + * self-heals to include it. Only a *renamed/removed* key can drift forever; an added one cannot. */ function migrateState(state: State): State { for (const resource of Object.values(state.resources)) { From 145ca5125e2814142519ae7b2b7bb2b02396c786 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 9 Jul 2026 08:31:37 +0200 Subject: [PATCH 2/4] test(groups): cover campusId diff, PATCH body, adopt, and no phantom drift (#21) - registry: managedFields snapshots campusId (nested/top-level), normalises unset to null, preserves campus id 0. - context: numeric campusId passes through; logical campus + non-numeric rejected. - plan: assign/change/clear campus as normal field updates; no-op when matching; no spurious diff for a pre-#21 snapshot lacking campusId. - execute: campus assignment PATCHes a top-level { campusId }. - adopt: a campus-assigned group captures campusId from information. --- tests/adopt.test.ts | 19 +++++++++++-- tests/context.test.ts | 16 +++++++++++ tests/execute.test.ts | 31 ++++++++++++++++++++++ tests/plan.test.ts | 60 ++++++++++++++++++++++++++++++++++++++++++ tests/registry.test.ts | 26 +++++++++++++++--- 5 files changed, 147 insertions(+), 5 deletions(-) diff --git a/tests/adopt.test.ts b/tests/adopt.test.ts index 222e7dc..14ba3bf 100644 --- a/tests/adopt.test.ts +++ b/tests/adopt.test.ts @@ -3,8 +3,8 @@ import { readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -const fakeCampus = { id: 0, name: "Mainz", shorty: "MZ" }; -const getMock = vi.fn(async () => fakeCampus); +const fakeCampus: Record = { id: 0, name: "Mainz", shorty: "MZ" }; +const getMock = vi.fn(async (): Promise> => fakeCampus); vi.mock("../src/api/session.js", () => ({ authedSession: vi.fn(async () => ({ client: { get: getMock }, me: { id: 1 } })), @@ -48,6 +48,21 @@ describe("ct adopt", () => { }); }); + it("captures a campus-assigned group's campusId from information (#21)", async () => { + getMock.mockResolvedValueOnce({ + id: 12, + name: "Kids Team", + information: { groupTypeId: 2, groupStatusId: 1, campusId: 4 }, + }); + await runAdopt(["group", "12", "--state", statePath]); + const state = await loadState(statePath, HOST); + expect(state.resources.kids_team).toMatchObject({ + type: "group", + id: 12, + fields: { name: "Kids Team", groupTypeId: 2, groupStatusId: 1, campusId: 4 }, + }); + }); + it("is idempotent — re-adopting keeps a single entry", async () => { await runAdopt(["campus", "0", "--state", statePath]); await runAdopt(["campus", "0", "--state", statePath]); diff --git a/tests/context.test.ts b/tests/context.test.ts index 6edb105..434aa1d 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -68,6 +68,22 @@ describe("config context", () => { expect(resources[0]?.dependsOn).toEqual([]); }); + it("passes a numeric campusId through as a plain field (the #21 escape hatch)", () => { + const { ct, resources } = createContext(); + ct.group({ key: "team", name: "Team", groupTypeId: 2, campusId: 4 }); + expect(resources[0]?.fields).toEqual({ name: "Team", groupTypeId: 2, campusId: 4 }); + // null is allowed (clears the assignment). + const { ct: ct2, resources: r2 } = createContext(); + ct2.group({ key: "team", name: "Team", campusId: null }); + expect(r2[0]?.fields).toEqual({ name: "Team", campusId: null }); + }); + + it("rejects a logical `campus` reference (deferred to #20) and a non-numeric campusId", () => { + const { ct } = createContext(); + expect(() => ct.group({ key: "g", name: "G", campus: "mainz" })).toThrow(/not supported yet/); + expect(() => ct.group({ key: "h", name: "H", campusId: "4" as never })).toThrow(/must be a number/); + }); + 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/); diff --git a/tests/execute.test.ts b/tests/execute.test.ts index 21babff..4dd0c61 100644 --- a/tests/execute.test.ts +++ b/tests/execute.test.ts @@ -143,6 +143,37 @@ describe("executePlan", () => { expect(state.resources.team!.fields).toEqual({ name: "Team A", groupTypeId: 2, groupStatusId: 1 }); }); + it("assigns a campus by PATCHing a top-level campusId (#21)", async () => { + const state = emptyState("h"); + state.resources.team = { + type: "group", + id: 9, + key: "team", + fields: { name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: null }, + adoptedAt: "t", + updatedAt: "t", + }; + const { client, calls } = recorder(); + const plan: Plan = { + items: [ + { + type: "group", + key: "team", + id: 9, + action: "update", + changes: [{ field: "campusId", from: null, to: 4 }], + actual: { name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: null }, + }, + ], + }; + const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + expect(result.updated).toEqual(["team"]); + // CT reads campus at information.campusId but accepts a top-level campusId on PATCH — mirroring + // how groupTypeId/groupStatusId are written. PATCH carries only the changed field. + expect(calls[0]).toEqual({ method: "PATCH", path: "/groups/9", body: { campusId: 4 } }); + expect(state.resources.team!.fields).toEqual({ name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: 4 }); + }); + it("does NOT revert a field that drifted in CT when a sibling field is updated (#27)", async () => { // Campus adopted with { name, shorty }; an admin edited `shorty` in the CT UI after adoption, // so state carries the adopt-time "MZ" while the fetched actual is "MZX". The user changed `name`. diff --git a/tests/plan.test.ts b/tests/plan.test.ts index 8209340..301434b 100644 --- a/tests/plan.test.ts +++ b/tests/plan.test.ts @@ -176,3 +176,63 @@ describe("computePlan", () => { expect(plan.items.map((i) => i.key)).toEqual(["g", "c"]); }); }); + +// A group's campus (`information.campusId`, managed as top-level `campusId`) is a plain diffed +// field — assign, change, and clear are all ordinary updates. The actual side is normalised to a +// concrete `null` when unset (see registry), so each transition diffs against a real value. +describe("group campus assignment (#21)", () => { + const g = (key: string, fields: Record): DesiredResource => + desired(key, fields, { type: "group" }); + const gState = (fields: Record): State => + stateOf(managedT("group", "team", 9, { name: "Team", groupTypeId: 2, groupStatusId: 1, ...fields })); + + it("assigns a campus to a previously unassigned group", () => { + const plan = computePlan( + [g("team", { name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: 4 })], + gState({ campusId: null }), + actualOf({ team: { name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: null } }), + ); + expect(plan.items[0]).toMatchObject({ action: "update", key: "team" }); + expect(plan.items[0]?.changes).toEqual([{ field: "campusId", from: null, to: 4 }]); + }); + + it("plans a campus move as a normal field update", () => { + const plan = computePlan( + [g("team", { name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: 7 })], + gState({ campusId: 4 }), + actualOf({ team: { name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: 4 } }), + ); + expect(plan.items[0]?.changes).toEqual([{ field: "campusId", from: 4, to: 7 }]); + }); + + it("clears a campus assignment (campusId: null)", () => { + const plan = computePlan( + [g("team", { name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: null })], + gState({ campusId: 4 }), + actualOf({ team: { name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: 4 } }), + ); + expect(plan.items[0]?.changes).toEqual([{ field: "campusId", from: 4, to: null }]); + }); + + it("is a no-op when desired campus matches actual", () => { + const plan = computePlan( + [g("team", { name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: 4 })], + gState({ campusId: 4 }), + actualOf({ team: { name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: 4 } }), + ); + expect(plan.items[0]?.action).toBe("no-op"); + }); + + it("does not drift on a pre-#21 state snapshot that lacks campusId (config omits it too)", () => { + // A group adopted before campusId was managed: its state snapshot has no campusId key, and the + // pre-#21 config declares none either. The fetched actual now carries campusId (managed). This + // must stay a no-op — no phantom drift, no spurious update — proving no state migration is needed. + const plan = computePlan( + [g("team", { name: "Team", groupTypeId: 2, groupStatusId: 1 })], + stateOf(managedT("group", "team", 9, { name: "Team", groupTypeId: 2, groupStatusId: 1 })), + actualOf({ team: { name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: 4 } }), + ); + expect(plan.items[0]?.action).toBe("no-op"); + expect(plan.items[0]?.changes).toEqual([]); + }); +}); diff --git a/tests/registry.test.ts b/tests/registry.test.ts index af8a06f..528578d 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -38,14 +38,34 @@ describe("resourceType", () => { it("snapshots group ids whether they are nested under information or top-level", () => { expect( - RESOURCES.group?.managedFields({ name: "Team", information: { groupTypeId: 2, groupStatusId: 1 } }), - ).toEqual({ name: "Team", groupTypeId: 2, groupStatusId: 1 }); - expect(RESOURCES.group?.managedFields({ name: "Team", groupTypeId: 2, groupStatusId: 1 })).toEqual({ + RESOURCES.group?.managedFields({ + name: "Team", + information: { groupTypeId: 2, groupStatusId: 1, campusId: 4 }, + }), + ).toEqual({ name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: 4 }); + expect( + RESOURCES.group?.managedFields({ name: "Team", groupTypeId: 2, groupStatusId: 1, campusId: 4 }), + ).toEqual({ name: "Team", groupTypeId: 2, groupStatusId: 1, + campusId: 4, }); }); + + it("normalises an unset group campus to null (never undefined) and preserves campus id 0", () => { + // No campus anywhere → null, so the actual side is a concrete value that assign/clear can diff against. + // (groupStatusId, un-normalised, stays undefined here — toEqual ignores it; only campusId is coalesced.) + expect(RESOURCES.group?.managedFields({ name: "Team", information: { groupTypeId: 2 } })).toEqual({ + name: "Team", + groupTypeId: 2, + campusId: null, + }); + // Mainz is campus id 0 — must survive the null-coalescing, not collapse to null. + expect( + RESOURCES.group?.managedFields({ name: "Team", information: { groupTypeId: 2, campusId: 0 } })?.campusId, + ).toBe(0); + }); }); describe("configSnippet", () => { From 8e9ae98cc40e674d8d0620abe50a549030d7fbfe Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 9 Jul 2026 08:31:37 +0200 Subject: [PATCH 3/4] docs(groups): field decision table + campus-assignment guidance (#21) - New docs/group-field-decisions.md: managed/opt-in/out-of-scope triage of group fields with rationale, the information.campusId GET/PATCH shape, and the no-migration reasoning. - runbook: move group<->campus assignment out of 'not yet implemented' (managed now); same-run campus link deferred to #20; decision-table row marked shipped; add campusId to the #20 hardcoded-ids list. - README + blueprints.md + campus-blueprint example: show numeric campusId and note the same-run reference is #20's job. --- README.md | 9 ++++ docs/blueprints.md | 15 +++++++ docs/group-field-decisions.md | 69 +++++++++++++++++++++++++++++ docs/runbook-manual-surface.md | 12 ++--- examples/campus-blueprint.config.ts | 9 +++- 5 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 docs/group-field-decisions.md diff --git a/README.md b/README.md index 9e90594..180f4e5 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,18 @@ export default (ct) => { // 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"] }); + // Assign a group to a campus by its numeric id (CT stores it at `information.campusId`). + // `campusId: null` clears the assignment. A *logical* `campus: "mainz"` reference — resolving a + // same-run campus by key — is deferred to #20; use the existing campus's numeric id for now. + ct.group({ key: "mainz_kids", name: "Mainz · Kids", groupTypeId: 2, campusId: 3, parents: ["mainz_kids_lead"] }); }; ``` +`campusId` is a managed group field: `ct plan` shows a campus assign/move/clear +as a normal field update, and `ct adopt group ` captures it. Which group +fields are managed vs. deliberately left to the CT UI is recorded in +[`docs/group-field-decisions.md`](docs/group-field-decisions.md). + Machine-readable output goes to **stdout** (pipe/`jq` it); human status lines go to **stderr**. diff --git a/docs/blueprints.md b/docs/blueprints.md index f13f596..a87e85d 100644 --- a/docs/blueprints.md +++ b/docs/blueprints.md @@ -34,6 +34,21 @@ default export declares two independent Kids-area structures — one per campus — using the same code. That's the whole mechanism: no templating language, no generated files, just a function called twice. +### Assigning the blueprint's groups to their campus + +A group is linked to a campus with a numeric `campusId: ` +(CT stores it at `information.campusId`; `campusId: null` clears it). `ct plan` +diffs a campus assign/move/clear as a normal field update — see +[`docs/group-field-decisions.md`](group-field-decisions.md). + +The catch for a per-campus blueprint: the blueprint usually *creates* the campus +in the same apply, and a numeric id doesn't exist until after that create. So +linking a group to a **same-run** campus by key (`campus: "mainz"`) needs the +logical-reference resolver and is deferred to +[#20](https://github.com/eqrm/ct-cli/issues/20). Until then, assign to an +**existing** campus by hardcoding its numeric id, or apply the campuses first and +fill in the ids on a second pass. + ## The loop-over-campuses pattern and `${campus}_`-prefixed keys Because every declared resource needs a config-wide-unique `key` diff --git a/docs/group-field-decisions.md b/docs/group-field-decisions.md new file mode 100644 index 0000000..5d1bc89 --- /dev/null +++ b/docs/group-field-decisions.md @@ -0,0 +1,69 @@ +# Group field decisions (#21) + +`ct-cli` manages the rights-bearing *structure* of a group, not everything the +ChurchTools group object carries. This table records the deliberate decision for +each field seen on the live group / group `PATCH` body: **managed** (diffed and +applied like `name`), **opt-in synthetic** (its own DSL block + endpoint, not the +plain field bag), or **out of scope** (left to the CT admin UI, never touched). + +Adding a *managed* field is a real commitment — it needs a registry entry, a diff +test, an adopt round-trip, and a note on state-snapshot migration. So this issue +promotes exactly one new field (`campusId`) and triages the rest rather than +silently widening `managedFields`. + +## Where the fields live + +CT nests a group's structural ids under an `information` object on the live GET +body (`information.groupTypeId`, `information.groupStatusId`, +`information.campusId`), but accepts them as **top-level** keys on `PATCH`. The +registry mirrors this: it reads via `fromInformation(...)` (nested, with a +top-level fallback) and writes the field-agnostic way the executor writes every +field — a plain top-level key. `campusId` is wired the same deliberate way as +`groupTypeId`. + +## Decision table + +| Field | Decision | Rationale | +|---|---|---| +| `name` | **managed** | Core identity; already managed. | +| `groupTypeId` | **managed** | Determines the group's kind and role template; already managed. | +| `groupStatusId` | **managed** | Lifecycle status; already managed. | +| **`campusId`** | **managed (new, #21)** | The campus link is the tool's core "instantiate this area per campus" requirement. Numeric escape hatch only — an existing CT campus id (or `null` to clear). A *logical* `campus: "key"` reference (resolving a same-run campus by key) is **deferred to [#20](https://github.com/eqrm/ct-cli/issues/20)**; the DSL rejects a `campus` field with a pointer to #20 so it can't slip through as an un-diffable phantom. | +| `parents` (hierarchy) | **opt-in synthetic** | Group→group hierarchy is reconciled through its own endpoint, not the group body — see `src/engine/synthetic.ts`. Opt-in via `parents: [...]`. | +| `dynamic` (auto-group ruleset) | **opt-in synthetic** | Ruleset + status live behind a dedicated endpoint (#14); opt-in via the `dynamic` block. See `docs/dynamic-groups.md`. | +| `visibility` | **out of scope** | Not rights-bearing structure; instance-/policy-specific and easily changed in the UI. No demand in #21 to manage it. Promote later only with its own registry entry + tests. | +| `note` | **out of scope** | Free-text annotation, not structure. Managing it would fight human edits in the UI for no structural benefit. | +| `autoAccept` / open-for-members settings | **out of scope** | Membership-request policy — adjacent to *who is in a group*, which the tool never manages (`assertNotPeople`, `src/engine/guard.ts`). Left to the UI. | +| chat status | **out of scope** | Chat/messaging toggle, outside the structural mandate (README: "campuses, structural groups, hierarchies, group types/roles, permission & auto-groups"). | +| sort key | **out of scope** | Presentation ordering, not structure. (Note: `sortKey` *is* managed on the master-data types `age-group`/`target-group`, where ordering is the resource's point; on a group it is cosmetic.) | + +## State-snapshot migration + +`campusId` is an **additive** managed field, so a group adopted before this issue +has a state snapshot that simply lacks the key. That produces **no phantom drift +and needs no migration**, because: + +- the diff is **desired-driven** — `diffFields` only walks the config's fields, so + a config that doesn't declare `campusId` never invents a change for it; +- drift is **snapshot-driven** — `driftFields` only walks the *old* snapshot's + keys, so a key absent there is never surfaced; +- the write body comes from the **fetched actual** ([#27](https://github.com/eqrm/ct-cli/issues/27)), + so an unrelated field update never omits or reverts `campusId`, and the + post-write snapshot self-heals to include it. + +Contrast the `shortName → shorty` case (#17): a *renamed* key lingers in the old +snapshot and drifts forever, so it needs an explicit `migrateState` rename. An +*added* key cannot. See the comment on `migrateState` in `src/state/state.ts`. + +**Re-adopt guidance:** to capture the campus of an already-managed group into +state immediately (rather than waiting for the next apply to self-heal the +snapshot), re-run `ct adopt group ` — `upsert` refreshes the snapshot in +place. This is optional; it does not change plan output. + +## Assigning a group to a same-run campus + +With numeric ids only, a group can be assigned to an **existing** campus by its +id today. Assigning a group to a campus **created in the same `ct apply`** is not +possible yet: the new campus's id is unknowable at config-eval time. That link +(`campus: "mainz_key"`) is the logical-reference resolver's job and lands with +[#20](https://github.com/eqrm/ct-cli/issues/20). diff --git a/docs/runbook-manual-surface.md b/docs/runbook-manual-surface.md index f4466b0..586a190 100644 --- a/docs/runbook-manual-surface.md +++ b/docs/runbook-manual-surface.md @@ -33,9 +33,9 @@ this doc's structure. | Item | What it is | Tracking issue | Manual workaround today | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Group ↔ campus assignment | `campusId` on a group (`information.campusId` in the live object) — not in `managedFields`, not in the DSL | [#21](https://github.com/eqrm/ct-cli/issues/21) | Set the campus by hand in the group's CT admin page after `ct apply` creates it; re-running `plan` will not detect drift because the field isn't diffed yet | -| Group/group-type field decision table | Fields seen live but not yet triaged (managed / opt-in synthetic / explicitly out of scope): visibility, note, `autoAccept`/open-for-members, chat status, sort key | [#21](https://github.com/eqrm/ct-cli/issues/21) | Set by hand; treat any such field as unmanaged until #21's decision table ships (do not assume `ct` will preserve or diff it) | -| Portable/logical references | Config still hardcodes numeric CT ids (`groupTypeId`, `groupStatusId`, permission `domainId`, dynamic-group ruleset `var` values like `q.eq("ctgroup.campusId", 4)`) instead of resolving keys/names per host | [#20](https://github.com/eqrm/ct-cli/issues/20) | Hand-resolve each id per target host (`ct get group-types`, `ct get campuses`, etc.) and hardcode it in config; a config authored against one instance will not plan correctly against another until this lands | +| Group ↔ campus assignment (same-run) | Assigning a group to a campus **created in the same `ct apply`** — needs the new campus's id at eval time, which is unknowable without the logical-reference resolver. Assignment to an **existing** campus by numeric `campusId` **is managed now** (#21) | [#20](https://github.com/eqrm/ct-cli/issues/20) | Apply the campus first, look up its id (`ct get campuses`), then set `campusId: ` on the group and apply again; or assign by hand in the CT admin page. `plan` diffs the numeric `campusId` as a normal field update | +| Group/group-type field decision table | Fields deliberately left unmanaged (decided out of scope): visibility, note, `autoAccept`/open-for-members, chat status, sort key. The triage **shipped** as a committed decision table ([`docs/group-field-decisions.md`](group-field-decisions.md)) | [#21](https://github.com/eqrm/ct-cli/issues/21) (decided) | Set by hand; these fields are intentionally not diffed — `ct` will neither preserve nor revert them. Promote one later only with its own registry entry + tests | +| Portable/logical references | Config still hardcodes numeric CT ids (`groupTypeId`, `groupStatusId`, `campusId`, permission `domainId`, dynamic-group ruleset `var` values like `q.eq("ctgroup.campusId", 4)`) instead of resolving keys/names per host | [#20](https://github.com/eqrm/ct-cli/issues/20) | Hand-resolve each id per target host (`ct get group-types`, `ct get campuses`, etc.) and hardcode it in config; a config authored against one instance will not plan correctly against another until this lands | | Environments (dev → prod promotion) | Named `(host, token, state file)` profiles and a `--env` flag; today one config + one state file = one host | [#22](https://github.com/eqrm/ct-cli/issues/22) | Point `CT_HOST`/state file manually at each target and re-run; keep dev and prod state files apart yourself, and be careful — nothing stops you from applying a dev-shaped config against prod today | | Permission `domainId` by reference | `ct.groupRole`/`ct.groupTypeRole` require the numeric `domainId` supplied by hand — for `group_role` this is CT's internal (group, role) _pairing_ id, with **no CLI lookup helper** | [#25](https://github.com/eqrm/ct-cli/issues/25) | Find the pairing id via the CT permission editor, or an existing `GET /permissions/group_role` response for a group+role you already have, and hardcode it ([`docs/permissions.md`](permissions.md) "domainId semantics") | | Grant adoption | No `ct adopt grants ` — existing rights structures on a live instance must be hand-transcribed into `grants: [...]` config blocks | [#25](https://github.com/eqrm/ct-cli/issues/25) | `ct get raw /permissions/group_role/` (or `group_type_role`), read off the non-inherited, non-baseline rows (`isInherited: false`, `meta.modifiedPid !== -1`), and hand-author the equivalent `grants:` array | @@ -101,8 +101,10 @@ in where they'd otherwise be silently skipped: 1. `ct apply` the structural config (campuses, group types, age/target groups, groups, hierarchy, dynamic groups, permission grants). -2. **Group ↔ campus assignment** — set by hand per group (#21, not yet - diffed). +2. **Group ↔ campus assignment** — declare `campusId: ` + on the group in config; `ct` diffs and applies it like any field (#21). + Only assignment to a campus created in the *same* apply is still manual + (#20) — apply the campus first, then set its numeric id. 3. **Member statuses** — confirm the expected set exists via `ct get raw /group/memberstatus`; create any missing ones by hand in the CT admin UI. diff --git a/examples/campus-blueprint.config.ts b/examples/campus-blueprint.config.ts index c3cf75f..33d68d5 100644 --- a/examples/campus-blueprint.config.ts +++ b/examples/campus-blueprint.config.ts @@ -8,7 +8,14 @@ import { q, churchQuery } from "../src/config/context.js"; const CAMPUSES = ["mainz", "berlin"] as const; -/** One campus's Kids area: a lead group with three ministry teams under it, plus a dynamic "all members" group. */ +/** + * One campus's Kids area: a lead group with three ministry teams under it, plus a dynamic "all members" group. + * + * Campus assignment note (#21): a group is assigned to a campus with a numeric `campusId: `. + * These campuses are created in this same apply, so their ids are unknowable at eval time — linking a group + * to a *same-run* campus by key (`campus: "mainz"`) is the logical-reference resolver's job and lands with #20. + * To assign to an existing campus today, pass its numeric id, e.g. `campusId: 3`. + */ function kidsArea(ct: ConfigContext, campus: string): void { const lead = `${campus}_kids_lead`; ct.group({ key: lead, name: `${campus} · Kids Leitung`, groupTypeId: 2, parents: [] }); From d7e4ca2471041731931f39a4f2025ca3546791c3 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 9 Jul 2026 08:42:09 +0200 Subject: [PATCH 4/4] fix(adopt): omit null-valued fields from config snippets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emitting campusId: null would actively manage 'no campus' — a later UI-assigned campus would be planned back to null. Omission leaves the field unmanaged, matching the adopt philosophy. Flagged by the PR #43 review. --- src/resources/registry.ts | 5 ++++- tests/registry.test.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/resources/registry.ts b/src/resources/registry.ts index 48dd25f..7be581f 100644 --- a/src/resources/registry.ts +++ b/src/resources/registry.ts @@ -161,8 +161,11 @@ export function configSnippet(type: string, key: string, fields: Record): string { + // null-valued fields are omitted, not emitted: pasting `campusId: null` would actively + // MANAGE "no campus" (planning a later UI-assigned campus back to null), whereas omission + // leaves the field unmanaged — the safer default for a freshly adopted resource. const parts = Object.entries(obj) - .filter(([, v]) => v !== undefined) + .filter(([, v]) => v !== undefined && v !== null) .map(([k, v]) => `${isIdentifier(k) ? k : JSON.stringify(k)}: ${JSON.stringify(v)}`); return `{ ${parts.join(", ")} }`; } diff --git a/tests/registry.test.ts b/tests/registry.test.ts index 528578d..8bcd671 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -156,3 +156,11 @@ describe("write specs", () => { ).toEqual({ name: "Mitglied", nameTranslated: "Mitglied", groupTypeId: 2 }); }); }); + +describe("configSnippet null omission", () => { + it("omits null-valued fields — a campus-less group adopts without managing 'no campus'", () => { + expect(configSnippet("group", "team", { name: "Team", groupTypeId: 2, campusId: null })).toBe( + 'group({ key: "team", name: "Team", groupTypeId: 2 });', + ); + }); +});