From 933ced8a2ec3894869ac915fecf38ff23f3544a5 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Sat, 29 Aug 2026 08:33:12 +0200 Subject: [PATCH 1/2] fix(refs): resolve logical group statuses --- docs/api-coverage.md | 4 +-- docs/configuration.md | 10 +++--- docs/handbuch/dynamic-groups.md | 8 ++--- docs/runbook-manual-surface.md | 20 +++++------ src/application/operations/adopt-group.ts | 1 + src/config/context.ts | 27 ++++----------- src/config/query-refs.ts | 2 +- src/resolve/refs.ts | 15 -------- src/resolve/resolver.ts | 24 +++++-------- src/resolve/reverse.ts | 23 ++++++++----- tests/adopt-group-command.test.ts | 26 ++++++++------ tests/context.test.ts | 18 +++++----- tests/query-refs.test.ts | 42 ++++++++++++++--------- tests/resolver.test.ts | 40 +++++++++------------ 14 files changed, 116 insertions(+), 144 deletions(-) diff --git a/docs/api-coverage.md b/docs/api-coverage.md index d85a98c..e2bae7b 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -24,7 +24,7 @@ Methods marked only if they actually exist on the matched path. "Update" = PUT o | 5 | `ct_group_role` | `/group/roles`, `/group/roles/{roleId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** (master-data roles). Per-group assignment lives separately at `/groups/{groupId}/roles` GET + `/groups/{groupId}/roles/{roleId}` PATCH. | | 6 | `ct_dynamic_group` | `/dynamicgroups` (GET), `/dynamicgroups/{groupId}/ruleset` (GET/PUT/DELETE), `/dynamicgroups/{groupId}/status` (GET/PUT), `/dynamicgroups/refresh` & `/dynamicgroups/{groupId}/refresh` (POST) | list ✅ / ruleset & status by-id ✅ | ⚠️ POST only triggers _refresh_, not create | ✅ PUT ruleset & status | ✅ ruleset DELETE | **Partial** — ruleset is fully updatable/deletable; the group entity itself is created via `/groups` (POST) then given a ruleset. No dedicated create/delete of the dynamic-group record. | | 7 | `ct_permission` | `/permissions/global` (GET), `/permissions/{domainType}` (GET), `/permissions/{domainType}/{domainId}` (GET/PUT/DELETE); `/permissions/internal/...` (GET) | list ✅ / by-id ✅ | — (no collection POST) | ✅ PUT sets permission | ✅ | **Writable** — assign/revoke via PUT/DELETE on `/{domainType}/{domainId}` | -| 8 | `ct_group_status` | none — see note | ❌ | ❌ | ❌ | ❌ | **No REST endpoint at all → manual, permanently (#67).** `/group/memberstatus` looks like a match but is a DIFFERENT dimension — **member** statuses (`{id: "active", name: "Active"}`, STRING ids, assignable per-membership), not group statuses (`groupStatusId`, numeric, e.g. 1 = active / 4 = archived). Live-verified 2026-07-10 on eqrm prod: `/groups/statuses` parses as `/groups/{groupId}` (400), `/group/statuses` and `/groupstatuses` both 404 — no read OR write endpoint for group statuses exists. (`/statuses` + `/statuses/{id}` DO offer full CRUD, but that is the person/community **Status** master data, tag `Status`, a third, unrelated dimension — do not conflate any of the three.) `groupStatusId` stays a plain numeric field on `ct_group`, authored directly — never resolved by name. | +| 8 | `ct_group_status` | `/person/masterdata` → `groupStatuses` | ✅ nested read-only catalog | ❌ | ❌ | ❌ | Technical `name` is the stable logical key; numeric IDs are host-local. Live-verified 2026-08-27. Distinct from `/group/memberstatus` (membership statuses) and `/statuses` (person/community statuses). Numeric `groupStatusId` remains an escape hatch. | | 9 | `ct_age_group` | `/group/agegroups`, `/group/agegroups/{ageGroupId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | | 10 | `ct_target_group` | `/group/targetgroups`, `/group/targetgroups/{targetGroupId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | | 11 | `ct_meeting_point` | — none — | ❌ | ❌ | ❌ | ❌ | **Not in API → fully manual.** Zero matches for `treffpunkt`/`meetingpoint`/`meeting point` anywhere in the spec. Closest neighbours are _meeting templates_ (`/group/meetingtemplates`, full CRUD) and _group meetings_ (`/groups/{groupId}/meetings`, CRUD) — different concepts; confirm with product whether "meeting point" was meant to be one of those. | @@ -71,7 +71,7 @@ No collection POST; state is set/removed through PUT/DELETE on the item path. Mo ### Read-only / not in API → keep manual for now — 2 resources -- `ct_group_status` — **no REST endpoint at all, read or write** (#67; corrected 2026-07-10 — a prior version of this table wrongly matched `GET /group/memberstatus`, which is actually **member** statuses, a different dimension with string ids). `groupStatusId` remains a plain numeric field. (Do not substitute `/statuses` either — that's person-status master data, a third dimension.) +- `ct_group_status` — read-only nested catalog at `GET /person/masterdata` → `groupStatuses` (#157). `/group/memberstatus` is a different membership-status dimension; `/statuses` is person-status master data. No group-status write endpoint is known. - `ct_meeting_point` — **no endpoint at all**; cannot be automated until CT ships one (or until "meeting point" is redefined onto meeting-templates/meetings, both of which are full CRUD). ### Version gate diff --git a/docs/configuration.md b/docs/configuration.md index 0358b0e..3f12b70 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -50,11 +50,11 @@ where it was used. Raw numeric ids remain a valid escape hatch everywhere; see [`examples/portable.config.ts`](https://github.com/eqrm/ct-cli/blob/main/examples/portable.config.ts) for a zero-numeric-id config. -**Exception: `groupStatusId` (a group's lifecycle status) is numeric-only, -always.** ChurchTools exposes no REST catalog to resolve a status by name -(`/group/memberstatus` is a different dimension, member statuses; #67), so a -`status:` declaration fails fast at eval time rather than resolving against the -wrong dimension. +For a group's lifecycle status, prefer the stable technical name, for example +`status: "active"`. ct resolves it through +`/person/masterdata.groupStatuses`. Numeric `groupStatusId` remains supported as +a backward-compatible escape hatch. This catalog is distinct from +`/group/memberstatus` (membership statuses) and `/statuses` (person statuses). ## Campus assignment diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index edff036..d86b441 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -305,7 +305,7 @@ At **capture** time (`ct adopt … --with-dynamic`) the state file and the ```text ! rulesets/jugend.json keeps 5 host-specific id(s) — NOT portable to another host: ctgroup.id: 1246 left numeric — not under management — `ct adopt group ` for each (then re-adopt) makes them portable - ctgroup.groupStatusId: 1, 2 left numeric — group statuses have no REST catalog (#67) — no logical form exists + ctgroup.groupStatusId: 99 left numeric — no managed group-status on this host carries these ids person.id: 5703, 4389 left numeric — person ids are NEVER portable — ct does not manage people, so this ruleset names DIFFERENT people on another host. Remove the clause or accept the divergence ``` @@ -345,9 +345,9 @@ reason it never checked: The capture-time reasons are distinct because the fixes are: an **unmanaged** target (adopt it), a **role unknown to `/group/roles`**, a role whose **group -type is unmanaged**, or a dimension with **no logical form at all** -(`ctgroup.groupStatusId` — group statuses have no REST catalog, #67; this one -needs no lookup, so the plan-time scan reports it too). +type is unmanaged**, or an **unknown group-status id**. Group statuses normally +resolve through `/person/masterdata.groupStatuses`; an id absent from that live +catalog stays numeric and is reported. **`--strict-rulesets`** turns the warning into a refusal: adopt writes nothing if the ruleset would still contain a host-specific id. Use it in a repo that has diff --git a/docs/runbook-manual-surface.md b/docs/runbook-manual-surface.md index f1e28cf..8414aac 100644 --- a/docs/runbook-manual-surface.md +++ b/docs/runbook-manual-surface.md @@ -40,16 +40,16 @@ in that instance's own config repo, in a runbook following this doc's structure. ## Not yet implemented — API supports it, `ct` doesn't drive it yet -| Item | What it is | Tracking issue | Manual workaround today | -| --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 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 | **Shipped (#20, #25).** Configs reference master data by name/key — `campus`/`groupType` on a group, `ref.campus(...)` in ruleset `var` values, `groupType: ""` for a `group_type_role` domain, and now `group: "", role: ""` for a `group_role` domain (#25) — and the per-host resolver maps each to that instance's id at plan time (managed resources ∪ live catalogs). A same-run campus resolves at apply time. Numeric ids still work as an escape hatch. **`status` (group status) is NOT part of this** (#67) — group statuses have no REST catalog, so `status:` fails fast at eval time; declare the numeric `groupStatusId` directly | [#20](https://github.com/eqrm/ct-cli/issues/20) (done), [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | None needed for the shipped surface. Write logical names; run `ct plan`. The `group_role` pairing-id resolution is verified live (row below) | -| 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 `group_role` domain by reference **(shipped, verified live)** | `ct.groupRole({ group, role })` now resolves the (group, role) pair to its pairing domainId at plan time (#25). **Confirmed live 2026-08-13 (CT 3.135.2):** it reads the group's role list (`GET /groups/{groupId}/roles`) and takes the matched role row's `id` as the pairing domainId. Two anchors on different group types: each row's `id` is a live `group_role` domainId carrying that role's grants, while its type-level `groupTypeRoleId` appears nowhere in the domainId set | [#25](https://github.com/eqrm/ct-cli/issues/25) (done, verified) | None needed. Works by reference for managed, already-created groups; numeric `id:` remains a supported escape hatch ([`docs/handbuch/permissions.md`](handbuch/permissions.md) "domainId semantics") | -| ~~Grant adoption~~ **(shipped)** | ~~existing rights structures must be hand-transcribed~~ — **`ct adopt grants ` ships this** (#25): it reads the live rows, applies the planner's normalization, and prints a paste-ready `ct.groupRole` / `ct.groupTypeRole` block (baseline/inherited excluded, denies noted-and-preserved, scope dataIds mapped back to managed-group keys). See [`docs/handbuch/permissions.md`](handbuch/permissions.md) "Adopting existing grants" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run `ct adopt grants group_role ` (or `group_type_role`), review the `WARNING`/`NOTE` comments, paste into config | -| ~~Permission catalog lifecycle~~ **(shipped)** | ~~`catalog.json` is a one-off HAR-trace snapshot with no staleness detection~~ — **shipped (#25):** `npm run regenerate:permission-catalog` rewrites it from a live instance (records the CT version in `$meta`), and `ct plan` now warns on a version mismatch or an unknown-authId live grant (which it leaves untouched, never revoking a right it cannot name). See [`docs/handbuch/permissions.md`](handbuch/permissions.md) "Catalog lifecycle & staleness" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run the command; heed the `ct plan` warnings | -| Field definitions & security levels (person + group custom fields) **(read-only, shipped #47/#48)** | The person master-data model, the security-level enumeration, and the data-field DEFINITIONS ("Datenfelder") for persons and groups — structural schema, not per-record values | [#47](https://github.com/eqrm/ct-cli/issues/47), [#48](https://github.com/eqrm/ct-cli/issues/48) (read shipped; write is an API gap — see note) | Read with `ct get person-masterdata` (model + security levels) and `ct get data-fields` (all field definitions, person + group, discriminated by `fieldCategory`). **Mutation stays manual:** field definitions have no REST write endpoint — only the legacy churchdb admin AJAX (`db_insertfields`/`db_updatefields`/`db_deletefields`) — so create/edit/delete them by hand in the master-data admin UI. Decision + evidence: [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md) | -| API re-audit for new CT releases | CT's OpenAPI spec is self-trimming (only shows endpoints your version has), so a new write endpoint (e.g. a member-status write, or — separately — a first-ever group-status list/write endpoint, #67) appears silently between CT upgrades | tracked by this issue ([#26](https://github.com/eqrm/ct-cli/issues/26)) | Procedure below (**Re-audit procedure for new CT releases**) | +| Item | What it is | Tracking issue | Manual workaround today | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 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 | **Shipped (#20, #25, #157).** Configs reference master data by name/key, including `campus`/`groupType`/`status` on a group and `ref.*` in rulesets. Group lifecycle statuses resolve through `/person/masterdata.groupStatuses`; numeric `groupStatusId` remains an escape hatch. This is distinct from `/group/memberstatus` and person `/statuses`. | [#20](https://github.com/eqrm/ct-cli/issues/20), [#25](https://github.com/eqrm/ct-cli/issues/25), [#157](https://github.com/eqrm/ct-cli/issues/157) | Write logical names; run `ct plan`. | +| 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 `group_role` domain by reference **(shipped, verified live)** | `ct.groupRole({ group, role })` now resolves the (group, role) pair to its pairing domainId at plan time (#25). **Confirmed live 2026-08-13 (CT 3.135.2):** it reads the group's role list (`GET /groups/{groupId}/roles`) and takes the matched role row's `id` as the pairing domainId. Two anchors on different group types: each row's `id` is a live `group_role` domainId carrying that role's grants, while its type-level `groupTypeRoleId` appears nowhere in the domainId set | [#25](https://github.com/eqrm/ct-cli/issues/25) (done, verified) | None needed. Works by reference for managed, already-created groups; numeric `id:` remains a supported escape hatch ([`docs/handbuch/permissions.md`](handbuch/permissions.md) "domainId semantics") | +| ~~Grant adoption~~ **(shipped)** | ~~existing rights structures must be hand-transcribed~~ — **`ct adopt grants ` ships this** (#25): it reads the live rows, applies the planner's normalization, and prints a paste-ready `ct.groupRole` / `ct.groupTypeRole` block (baseline/inherited excluded, denies noted-and-preserved, scope dataIds mapped back to managed-group keys). See [`docs/handbuch/permissions.md`](handbuch/permissions.md) "Adopting existing grants" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run `ct adopt grants group_role ` (or `group_type_role`), review the `WARNING`/`NOTE` comments, paste into config | +| ~~Permission catalog lifecycle~~ **(shipped)** | ~~`catalog.json` is a one-off HAR-trace snapshot with no staleness detection~~ — **shipped (#25):** `npm run regenerate:permission-catalog` rewrites it from a live instance (records the CT version in `$meta`), and `ct plan` now warns on a version mismatch or an unknown-authId live grant (which it leaves untouched, never revoking a right it cannot name). See [`docs/handbuch/permissions.md`](handbuch/permissions.md) "Catalog lifecycle & staleness" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run the command; heed the `ct plan` warnings | +| Field definitions & security levels (person + group custom fields) **(read-only, shipped #47/#48)** | The person master-data model, the security-level enumeration, and the data-field DEFINITIONS ("Datenfelder") for persons and groups — structural schema, not per-record values | [#47](https://github.com/eqrm/ct-cli/issues/47), [#48](https://github.com/eqrm/ct-cli/issues/48) (read shipped; write is an API gap — see note) | Read with `ct get person-masterdata` (model + security levels) and `ct get data-fields` (all field definitions, person + group, discriminated by `fieldCategory`). **Mutation stays manual:** field definitions have no REST write endpoint — only the legacy churchdb admin AJAX (`db_insertfields`/`db_updatefields`/`db_deletefields`) — so create/edit/delete them by hand in the master-data admin UI. Decision + evidence: [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md) | +| API re-audit for new CT releases | CT's OpenAPI spec is self-trimming (only shows endpoints your version has), so a new write endpoint (e.g. a member-status write, or — separately — a first-ever group-status list/write endpoint, #67) appears silently between CT upgrades | tracked by this issue ([#26](https://github.com/eqrm/ct-cli/issues/26)) | Procedure below (**Re-audit procedure for new CT releases**) | ## Out of tool scope — deliberate, not a gap diff --git a/src/application/operations/adopt-group.ts b/src/application/operations/adopt-group.ts index f7ccdd7..a376a4a 100644 --- a/src/application/operations/adopt-group.ts +++ b/src/application/operations/adopt-group.ts @@ -405,6 +405,7 @@ export async function runAdoptGroups( if (opts.withDynamic && opts.portableRulesets !== false) { portableCatalogMaps.campus = await reverse.idToKeyByKind("campus"); portableCatalogMaps["group-type"] = await reverse.idToKeyByKind("group-type"); + portableCatalogMaps["group-status"] = await reverse.idToKeyByKind("group-status"); groupTypeIdToKey = portableCatalogMaps["group-type"]; roleCatalog = await reverse.roleGroupTypeCatalog(); } diff --git a/src/config/context.ts b/src/config/context.ts index 3b1c3e9..6f6616c 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -24,7 +24,7 @@ import { collectRefs } from "../resolve/refs.js"; import type { DomainType } from "../permissions/grants.js"; import type { DesiredPermission, Grant, PreserveUnknown } from "../permissions/types.js"; import { KNOWN_SCOPE_FIELDS } from "../permissions/catalog.js"; -import { GROUP_STATUS_NO_CATALOG, isRef, ref, refKey, type Ref } from "../resolve/refs.js"; +import { isRef, ref, refKey, type Ref } from "../resolve/refs.js"; import { normalizeScopeEntry } from "../permissions/scope.js"; import { conventionalRulesetRef, isCallerAssignedId, knownFields } from "../resources/registry.js"; import { warn } from "../ui.js"; @@ -180,15 +180,11 @@ export interface PermissionInput { preserveUnknown?: PreserveUnknown; } -/** Logical id-field sugar for declarations: a named string field → a Ref-valued numeric id field. - * `status` (→ `groupStatusId`) is deliberately NOT here (#67): ChurchTools exposes no REST catalog - * for group statuses — `/group/memberstatus` is a different dimension (member statuses, string - * ids), verified live 2026-07-10. A declared `status:` field fails fast in {@link toDesired} instead - * of silently resolving against the wrong dimension. `ref.status`/`RefKind: "group-status"` remain - * in src/resolve/refs.ts so the sugar can return if CT ever ships a real group-status endpoint. */ +/** Logical id-field sugar for declarations: a named string field → a Ref-valued numeric id field. */ const ID_SUGAR: Record Ref }> = { campus: { idField: "campusId", make: ref.campus }, groupType: { idField: "groupTypeId", make: ref.groupType }, + status: { idField: "groupStatusId", make: ref.status }, }; /** The numeric id fields a declaration may carry — each accepts a number, `null`, or a {@link Ref}. */ @@ -366,7 +362,7 @@ export interface ConfigContext { * Grants on a PERSON status (`status` domain, #90) — they apply to every person carrying that * status, so this is the instance-wide lever. Addressed by `personStatus: ""` (resolved * against `/statuses`) or the numeric `id:` escape hatch. Note that person statuses are a different - * dimension from group statuses (`groupStatusId`), which have no catalog at all (#67). + * dimension from group statuses (`groupStatusId`), whose catalog is nested in `/person/masterdata`. */ status(input: PermissionInput): void; } @@ -535,17 +531,8 @@ function toDesired(type: string, input: ResourceInput, location?: string): Desir 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.`); } - // `status` (group status) has no REST catalog to resolve a name against — fail fast here rather - // than let it fall through to ID_SUGAR (which no longer carries a "status" entry, so it would - // otherwise silently be treated as an unrecognised field and just warn) or, worse, silently pick - // the wrong dimension (#67: `/group/memberstatus` is member statuses, string ids — a live-verified - // mismatch). Checked before the sugar loop so the message is specific, not the generic unknown-id - // fallback below. - if (fields.status !== undefined) { - throw new Error(`${type} "${key}": "status" cannot be resolved by name — ${GROUP_STATUS_NO_CATALOG}`); - } - // Logical id-field sugar (#20): a named string field (`campus`/`groupType`) sugars into - // a Ref-valued numeric id field (`campusId`/`groupTypeId`). The per-host resolver + // Logical id-field sugar (#20): a named string field (`campus`/`groupType`/`status`) sugars into + // a Ref-valued numeric id field (`campusId`/`groupTypeId`/`groupStatusId`). The per-host resolver // turns the Ref into a real id at plan time. Declaring BOTH forms (`campus` + `campusId`) is a // conflict — reject it rather than silently pick one. Numeric ids still pass straight through. for (const [logical, { idField, make }] of Object.entries(ID_SUGAR)) { @@ -569,8 +556,6 @@ function toDesired(type: string, input: ResourceInput, location?: string): Desir for (const idField of ID_FIELDS) { const value = fields[idField]; if (value === undefined || value === null || typeof value === "number" || isRef(value)) continue; - // `groupStatusId` has no logical sugar field (#67 — see ID_SUGAR above), so its hint omits the - // "use the X field" clause rather than pointing at a sugar that doesn't exist. const sugarName = Object.entries(ID_SUGAR).find(([, s]) => s.idField === idField)?.[0]; const hint = sugarName ? ` (use the "${sugarName}" field, or ref.*)` : ""; throw new Error( diff --git a/src/config/query-refs.ts b/src/config/query-refs.ts index 0703fd8..36940de 100644 --- a/src/config/query-refs.ts +++ b/src/config/query-refs.ts @@ -51,6 +51,7 @@ export const VAR_REF_KINDS: Readonly> = { "ctgroup.id": "group", "ctgroup.campusId": "campus", "ctgroup.groupTypeId": "group-type", + "ctgroup.groupStatusId": "group-status", "person.campusId": "campus", }; @@ -113,7 +114,6 @@ export interface PortablizeWarning { * is a literal, not an id, and reporting it would bury the real findings in noise. */ const UNPORTABLE_ENTITY_VARS: Readonly> = { - "ctgroup.groupStatusId": "group statuses have no REST catalog (#67) — no logical form exists", // #127. A ruleset that includes or excludes specific people by id is common — four of five // auto-group rulesets captured in one week did it — and it was the ONE entity var the audit never // mentioned, so the only way to find it was to read the captured JSON by hand. The absence of a diff --git a/src/resolve/refs.ts b/src/resolve/refs.ts index 98e062e..ffab1ed 100644 --- a/src/resolve/refs.ts +++ b/src/resolve/refs.ts @@ -32,21 +32,6 @@ export type RefKind = | "group-type-role" | "group-member-field"; -/** - * Shared explanation for why a group-status reference can never be resolved by name (#67): - * ChurchTools exposes no REST catalog for group statuses — `GET /group/memberstatus` is a - * different dimension (member statuses, string ids), live-verified 2026-07-10 on eqrm prod. - * Used verbatim by both guards that can see a group-status reference, so their messages can't - * drift apart: - * - the eval-time guard (src/config/context.ts) for a declared `status:` field, and - * - the plan-time guard (src/resolve/resolver.ts) for a `groupStatusId: ref.status(...)` value - * that bypassed the eval-time guard (the id-field escape hatch accepts any Ref) and reached - * the resolver directly. - */ -export const GROUP_STATUS_NO_CATALOG = - `group statuses have no REST catalog (GET /group/memberstatus is a different dimension: member ` + - `statuses, string ids — verified 2026-07-10). Declare a numeric "groupStatusId" instead (e.g. "groupStatusId: 1").`; - /** Simple key-addressed reference: campus / department / security level / group type / group status / person status / role definition / group. */ export interface SimpleRef { __ctRef: true; diff --git a/src/resolve/resolver.ts b/src/resolve/resolver.ts index 0c91814..4a7b1bc 100644 --- a/src/resolve/resolver.ts +++ b/src/resolve/resolver.ts @@ -41,7 +41,6 @@ import { import { collectRefs, deepMapRefs, - GROUP_STATUS_NO_CATALOG, isPendingRef, isRef, pendingRef, @@ -119,6 +118,7 @@ const CATALOG_PATH: Partial> = { // is why nothing in the resolve path may treat a falsy id as "missing". "comment-viewer": "/person/commentviewers", "group-type": "/group/grouptypes", + "group-status": "/person/masterdata", // PERSON statuses — the domain of a `status` permission declaration (#90). Unlike GROUP statuses // (see the note above), these DO have a flat REST catalog: `GET /statuses` returns // `[{id, name, shorty, …}]` — live-verified 2026-08-10 on eqrm prod. (`/person/masterdata` carries @@ -302,9 +302,14 @@ export class Resolver { let p = this.catalogs.get(kind); if (!p) { const path = CATALOG_PATH[kind]!; - const rows = this.client.getAll - ? this.client.getAll(path).then((page) => page.data) - : this.client.get(path); + const rows = + kind === "group-status" + ? this.client + .get<{ groupStatuses?: CatalogRecord[] }>(path) + .then((masterdata) => masterdata.groupStatuses ?? []) + : this.client.getAll + ? this.client.getAll(path).then((page) => page.data) + : this.client.get(path); p = rows.then((r) => (Array.isArray(r) ? r : [])); this.catalogs.set(kind, p); } @@ -608,17 +613,6 @@ export class Resolver { } private notFound(r: SimpleRef, site: string): Error { - // group-status (#67, reviewer follow-up): a `groupStatusId: ref.status(...)` value bypasses the - // eval-time guard in src/config/context.ts (the id-field escape hatch accepts any Ref) and lands - // here. The generic "declare/adopt it, fix the key" advice below is actively wrong for - // group-status — there is no such managed resource type and no catalog to adopt against — so - // give the same actionable message the eval-time guard uses instead (shared constant so the two - // sites can't drift). - if (r.kind === "group-status") { - return new Error( - `Cannot resolve ${refLabel(r)} referenced at ${site} on ${this.host}: ${GROUP_STATUS_NO_CATALOG}`, - ); - } const catalog = CATALOG_PATH[r.kind]; // A catalog-only kind has no managed resource type, so "Declare/adopt it" is advice the tool // cannot honour (#96's exact complaint about the old person-status message). The message says diff --git a/src/resolve/reverse.ts b/src/resolve/reverse.ts index c3c645e..f337299 100644 --- a/src/resolve/reverse.ts +++ b/src/resolve/reverse.ts @@ -2,10 +2,8 @@ * Reverse reference resolution for `ct adopt` (#52 item A): turn the numeric ChurchTools ids a * fetched resource carries (`campusId`, `groupTypeId`) into the logical sugar the DSL already * accepts (`campus`/`groupType`), so an adopted snippet is portable and reads like something a - * human would author — not a wall of instance-specific integers. `groupStatusId` is NOT reverse- - * sugared (#67): group statuses have no REST catalog to look a name up against (`/group/memberstatus` - * is a different dimension — member statuses, string ids — live-verified 2026-07-10), so adopt - * always emits it as a plain numeric field, same as any other unmapped id. + * human would author — not a wall of instance-specific integers. Group statuses are read from the + * nested `groupStatuses` catalog returned by `/person/masterdata` (#157). * * This is the mirror image of the forward {@link Resolver} (src/resolve/resolver.ts): it reads the * SAME master-data catalogs, matched here BY ID instead of by name, and emits `slug(name)` — exactly @@ -27,6 +25,7 @@ import { slug } from "../resources/registry.js"; const REVERSE_ID_FIELDS: Record = { campusId: { catalog: "/campuses", sugar: "campus" }, groupTypeId: { catalog: "/group/grouptypes", sugar: "groupType" }, + groupStatusId: { catalog: "/person/masterdata#groupStatuses", sugar: "status" }, }; /** @@ -38,6 +37,7 @@ const REVERSE_ID_FIELDS: Record = { const PORTABLE_CATALOG_PATHS: Partial> = { campus: "/campuses", "group-type": "/group/grouptypes", + "group-status": "/person/masterdata#groupStatuses", "role-def": "/group/roles", }; @@ -56,13 +56,13 @@ export interface RoleCatalogEntry { } export class ReverseResolver { - private readonly client: Pick; + private readonly client: Pick; /** id → logical key, per catalog path, fetched at most once. A failed fetch caches an empty map. */ private readonly catalogs = new Map>>(); /** groupTypeRoleId → {groupTypeId, name} from `/group/roles`, fetched at most once (#76). */ private roleCatalog?: Promise>; - constructor(client: Pick) { + constructor(client: Pick) { this.client = client; } @@ -74,9 +74,14 @@ export class ReverseResolver { // on eqrm prod `/group/roles` has 46 rows, so 36 roles had no id→key entry and every ruleset // `role.id` pointing at one was left as a host-specific number with a vague warning. The // forward Resolver was fixed for exactly this; the reverse side had the same bug. - p = this.client - .getAll(path) - .then(({ data: rows }) => { + const rows = + path === "/person/masterdata#groupStatuses" + ? this.client + .get<{ groupStatuses?: CatalogRecord[] }>("/person/masterdata") + .then((masterdata) => masterdata.groupStatuses ?? []) + : this.client.getAll(path).then(({ data }) => data); + p = rows + .then((rows) => { const map = new Map(); if (Array.isArray(rows)) { for (const row of rows) { diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts index cc6735f..4f81eb5 100644 --- a/tests/adopt-group-command.test.ts +++ b/tests/adopt-group-command.test.ts @@ -49,6 +49,10 @@ function makeClient(childrenResponse: ChildrenResponse = "array") { // dispatcher with other tests exercising member statuses, and to prove it's never fetched for a // plain group adopt (see the assertion in the --with-dynamic capture test below). const campuses = [{ id: 0, name: "Mainz" }]; + const groupStatuses = [ + { id: 1, name: "active", nameTranslated: "Aktiv" }, + { id: 2, name: "pending", nameTranslated: "Entwurf" }, + ]; const memberStatuses = [{ id: 1, name: "Aktiv" }]; // Global role catalog (/group/roles), each row carrying its `groupTypeId` — used to portablize a // `role.id` groupTypeRoleId into a (group-type, role-name) marker (#76). Role 7 is a "Leiter" on @@ -138,6 +142,7 @@ function makeClient(childrenResponse: ChildrenResponse = "array") { if (m) return memberFields[Number(m[1])] ?? []; if (path === "/group/grouptypes") return groupTypes; if (path === "/campuses") return campuses; + if (path === "/person/masterdata") return { groupStatuses }; if (path === "/group/roles") return roles; if (path === "/group/memberstatus") return memberStatuses; m = /^\/dynamicgroups\/(\d+)\/ruleset$/.exec(path); @@ -381,10 +386,8 @@ describe("ct adopt group --with-dynamic", () => { expect(block).toContain("dynamic: true,"); // groupType is reverse-sugared to its logical key against the mocked catalog... expect(block).toContain('groupType: "team",'); - // ...but groupStatusId is NOT (#67: no group-status catalog exists) — it stays numeric, with no - // TODO comment (a TODO only fires when a catalog exists but the id doesn't match anything in it). - expect(block).toContain("groupStatusId: 1,"); - expect(block).not.toContain("status:"); + expect(block).toContain('status: "active",'); + expect(block).not.toContain("groupStatusId: 1,"); expect(block).not.toContain("TODO"); // And the group-status "catalog" is never fetched at all — there is no such catalog to fetch. expect(client.get).not.toHaveBeenCalledWith("/group/memberstatus"); @@ -463,8 +466,10 @@ describe("ct adopt group --with-dynamic --portable-rulesets (#76 Stage 3)", () = expect(and[2]!.oneof![1]).toEqual([ { __ctRef: true, kind: "group-type-role", groupType: "team", role: "Leiter" }, ]); - // groupStatusId has no catalog (#67) → left numeric, untouched. - expect(and[3]!.oneof![1]).toEqual([1, 2]); + expect(and[3]!.oneof![1]).toEqual([ + { __ctRef: true, kind: "group-status", key: "active" }, + { __ctRef: true, kind: "group-status", key: "pending" }, + ]); }); it("names every dimension it left numeric, with the reason (#101)", async () => { @@ -485,7 +490,7 @@ describe("ct adopt group --with-dynamic --portable-rulesets (#76 Stage 3)", () = // Detail is id-free: formatPortablizeWarnings prints the ids once, ahead of it, so a detail // naming one id would be stamped across every id merged into the line. expect(warned).toMatch(/ctgroup\.id: 999 left numeric — not under management/); - expect(warned).toMatch(/ctgroup\.groupStatusId: 1, 2 left numeric — group statuses have no REST catalog/); + expect(warned).not.toMatch(/ctgroup\.groupStatusId/); }); it("is ON by default since #101: a plain --with-dynamic capture emits ref markers", async () => { @@ -548,15 +553,14 @@ describe("ct adopt group — idiomatic snippet round-trips to a no-op (#52 item } // The printed block is a `// group` header + one idiomatic multi-line `group({ ... });` snippet - // with campusId/groupTypeId reverse-sugared to campus/groupType keys; groupStatusId has no - // catalog to reverse-sugar against (#67), so it stays numeric — not a TODO, just plain data. + // with every catalog-backed id reverse-sugared to its logical key. const block = writes.join(""); const snippet = block.replace(/^\/\/ group\n/, "").trim(); expect(snippet.startsWith("group({")).toBe(true); expect(snippet).toContain('campus: "mainz"'); // id 0 reverse-resolved expect(snippet).toContain('groupType: "team"'); - expect(snippet).toContain("groupStatusId: 1"); - expect(snippet).not.toContain("status:"); // never the group-status sugar (#67) + expect(snippet).toContain('status: "active"'); + expect(snippet).not.toContain("groupStatusId: 1"); expect(snippet).not.toContain("TODO"); // everything resolved — a clean, hand-edit-free paste // Paste it VERBATIM into a config (only wrapping boilerplate + the `ct.` receiver added). diff --git a/tests/context.test.ts b/tests/context.test.ts index 0bea769..e6c0e62 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { createContext, evaluateConfig, type ConfigContext } from "../src/config/context.js"; import { isKnownType } from "../src/engine/graph.js"; import { RESOURCES } from "../src/resources/registry.js"; +import { ref } from "../src/resolve/refs.js"; describe("config context", () => { it("builds desired resources from DSL calls, separating key/parent from fields", () => { @@ -107,20 +108,17 @@ describe("config context", () => { expect(() => ct.group({ key: "h", name: "H", parents: [1] as never })).toThrow(/array of string/); }); - describe("group-status sugar removed (#67)", () => { - it("fails fast, with an actionable error, when `status` is declared", () => { - const { ct } = createContext(); - expect(() => ct.group({ key: "g", name: "G", status: "active" })).toThrow( - 'group "g": "status" cannot be resolved by name — group statuses have no REST catalog ' + - "(GET /group/memberstatus is a different dimension: member statuses, string ids — verified " + - '2026-07-10). Declare a numeric "groupStatusId" instead (e.g. "groupStatusId: 1").', - ); + describe("group-status sugar (#157)", () => { + it("sugars a technical status name into a logical reference", () => { + const { ct, resources } = createContext(); + ct.group({ key: "g", name: "G", status: "active" }); + expect(resources[0]?.fields.groupStatusId).toEqual(ref.status("active")); }); - it("still fails fast even when a numeric groupStatusId is also given", () => { + it("rejects declaring logical and numeric forms together", () => { const { ct } = createContext(); expect(() => ct.group({ key: "g", name: "G", status: "active", groupStatusId: 1 })).toThrow( - /"status" cannot be resolved by name/, + /either "status".*or "groupStatusId"/, ); }); diff --git a/tests/query-refs.test.ts b/tests/query-refs.test.ts index 2b965a9..7d5a857 100644 --- a/tests/query-refs.test.ts +++ b/tests/query-refs.test.ts @@ -17,7 +17,7 @@ import { } from "../src/config/query-refs.js"; import { q, churchQuery } from "../src/config/query.js"; import { normalizeRuleset } from "../src/engine/dynamic.js"; -import { deepMapRefs, refKey, type Ref, type RefKind } from "../src/resolve/refs.js"; +import { deepMapRefs, ref, refKey, type Ref, type RefKind } from "../src/resolve/refs.js"; const here = dirname(fileURLToPath(import.meta.url)); @@ -31,6 +31,7 @@ describe("VAR_REF_KINDS catalog (#76 Stage 1)", () => { "ctgroup.id": "group", "ctgroup.campusId": "campus", "ctgroup.groupTypeId": "group-type", + "ctgroup.groupStatusId": "group-status", "person.campusId": "campus", }); }); @@ -40,7 +41,7 @@ describe("VAR_REF_KINDS catalog (#76 Stage 1)", () => { // the (group-type, role-name) special case, not a lone name-based kind. groupStatusId has no REST // catalog (#67); isArchived/dateOfDeath are boolean/date literals. expect(VAR_REF_KINDS["role.id"]).toBeUndefined(); - expect(VAR_REF_KINDS["ctgroup.groupStatusId"]).toBeUndefined(); + expect(VAR_REF_KINDS["ctgroup.groupStatusId"]).toBe("group-status"); expect(VAR_REF_KINDS["person.isArchived"]).toBeUndefined(); expect(VAR_REF_KINDS["person.dateOfDeath"]).toBeUndefined(); }); @@ -93,16 +94,20 @@ describe("portablizeRuleset (#76 Stage 2)", () => { ]); }); - it("never REWRITES a catalog-less var (groupStatusId), but does report it left numeric (#101)", () => { + it("rewrites known groupStatusId values and preserves unknown ids with a warning (#157)", () => { const ruleset = { query: churchQuery(q.oneof("ctgroup.groupStatusId", [1, 2, 4])) }; const { ruleset: out, warnings } = portablizeRuleset(ruleset, { - idToKeyByKind: { group: new Map([[1, "nope"]]) }, + idToKeyByKind: { + "group-status": new Map([ + [1, "active"], + [2, "pending"], + ]), + }, }); const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; - expect(filter.oneof[1]).toEqual([1, 2, 4]); // never rewritten — no logical form exists - // …but silence here is what #101 was filed about: the ids ARE host-specific, so they are reported. - expect(warnings.map((w) => w.id)).toEqual([1, 2, 4]); - expect(new Set(warnings.map((w) => w.reason))).toEqual(new Set(["no-ref-kind"])); + expect(filter.oneof[1]).toEqual([ref.status("active"), ref.status("pending"), 4]); + expect(warnings.map((w) => w.id)).toEqual([4]); + expect(new Set(warnings.map((w) => w.reason))).toEqual(new Set(["unmanaged"])); }); it("does not mutate its input ruleset", () => { @@ -277,6 +282,11 @@ describe("portablizeRuleset (#76 Stage 2)", () => { [112, "bereich_kids"], [8, "team_kidsdienst"], ]), + "group-status": new Map([ + [1, "active"], + [2, "pending"], + [4, "finished"], + ]), }; const opts = { idToKeyByKind, roleCatalog, groupTypeIdToKey }; @@ -321,11 +331,14 @@ describe("portablizeRuleset (#76 Stage 2)", () => { id, ); } + for (const [id, key] of idToKeyByKind["group-status"]!) { + keyToId.set(refKey(ref.status(key)), id); + } const back = deepMapRefs(portable, (r: Ref) => keyToId.get(refKey(r))); expect(back).toEqual(normalized); }); - it("leaves the unmanaged group id (1246) and the groupStatusId lists numeric, and reports BOTH (#101)", () => { + it("leaves only the unmanaged group id numeric; known group statuses become refs (#157)", () => { const { ruleset: portable, warnings } = portablizeRuleset(normalized, opts); const json = JSON.stringify(portable); expect(json).toContain("1246"); // unmanaged group id survives numeric @@ -335,12 +348,8 @@ describe("portablizeRuleset (#76 Stage 2)", () => { reason: "unmanaged", detail: "not under management — `ct adopt group ` for each (then re-adopt) makes them portable", }); - // groupStatusId is never REWRITTEN (no catalog exists) but is still a host-specific id in a - // cross-host file, so #101 reports it rather than letting the capture look fully portable. - expect( - warnings.filter((w) => w.var === "ctgroup.groupStatusId").every((w) => w.reason === "no-ref-kind"), - ).toBe(true); - expect(warnings.some((w) => w.var === "ctgroup.groupStatusId")).toBe(true); + expect(json).toContain('"kind":"group-status"'); + expect(warnings.some((w) => w.var === "ctgroup.groupStatusId")).toBe(false); }); it("scanUnportablized reports the same ids from the ALREADY-PORTABLIZED file (#101 plan-time check)", () => { @@ -363,9 +372,8 @@ describe("portablizeRuleset (#76 Stage 2)", () => { expect(left.filter((w) => w.var === "ctgroup.id").every((w) => w.reason === "left-numeric")).toBe(true); expect(left.filter((w) => w.var === "role.id").every((w) => w.reason === "left-numeric")).toBe(true); expect(left.some((w) => /is not under management|no \/group\/roles row/.test(w.detail))).toBe(false); - // The catalog-less dimension keeps its own reason: that one IS derivable without any lookup. expect( - left.filter((w) => w.var === "ctgroup.groupStatusId").every((w) => w.reason === "no-ref-kind"), + left.filter((w) => w.var === "ctgroup.groupStatusId").every((w) => w.reason === "left-numeric"), ).toBe(true); }); }); diff --git a/tests/resolver.test.ts b/tests/resolver.test.ts index 16b3659..374326f 100644 --- a/tests/resolver.test.ts +++ b/tests/resolver.test.ts @@ -76,36 +76,28 @@ describe("Resolver.resolve", () => { await expect(r.resolve(ref.personStatus("5_core"), "site")).rejects.toThrow(/5_core/); }); - it("has no group-status catalog — a group-status ref is a hard error, never resolved against /group/memberstatus (#67)", async () => { - // /group/memberstatus IS mocked here (as a member-statuses catalog would be on a live host), to - // prove the resolver never even looks at it for a group-status ref — group statuses have no - // REST catalog to resolve against (a different, unrelated dimension from member statuses). - const client = fakeClient({ - "/group/memberstatus": [ - { id: 1, name: "Active" }, - { id: 2, name: "Candidate" }, - ], + it("resolves group statuses by technical name across hosts with different ids (#157)", async () => { + const clientA = fakeClient({ + "/person/masterdata": { groupStatuses: [{ id: 41, name: "active", nameTranslated: "Aktiv" }] }, }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); - await expect(r.resolve(ref.status("candidate"), "site")).rejects.toThrow( - /Cannot resolve group-status:candidate referenced at site on hostA/, - ); - expect(client.calls).toEqual({}); // /group/memberstatus never fetched for a group-status ref + const clientB = fakeClient({ + "/person/masterdata": { groupStatuses: [{ id: 7, name: "active", nameTranslated: "Aktiv" }] }, + }); + const resolverA = new Resolver({ client: clientA, state: emptyState("hostA"), desired: NO_DESIRED }); + const resolverB = new Resolver({ client: clientB, state: emptyState("hostB"), desired: NO_DESIRED }); + expect(await resolverA.resolve(ref.status("active"), "site")).toBe(41); + expect(await resolverB.resolve(ref.status("active"), "site")).toBe(7); + expect(clientA.calls).toEqual({ "/person/masterdata": 1 }); + expect(clientB.calls).toEqual({ "/person/masterdata": 1 }); }); - it("gives the same actionable no-catalog message as the eval-time guard, not the generic 'declare/adopt it' advice (#67 reviewer follow-up)", async () => { - // A `groupStatusId: ref.status(...)` value bypasses the eval-time guard in context.ts (the - // id-field escape hatch accepts any Ref) and reaches the resolver directly. The generic - // notFound() advice ("Declare/adopt it, fix the key/name, or use a numeric id") is wrong here — - // there is no group-status resource type and no catalog to adopt against — so this must be the - // SAME message context.ts's eval-time guard uses, not the generic one. - const client = fakeClient({}); + it("keeps numeric ids as the backward-compatible escape hatch and errors on unknown logical names", async () => { + const client = fakeClient({ "/person/masterdata": { groupStatuses: [{ id: 1, name: "active" }] } }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); await expect(r.resolve(ref.status("candidate"), 'group "g".groupStatusId')).rejects.toThrow( - 'Cannot resolve group-status:candidate referenced at group "g".groupStatusId on hostA: group statuses ' + - "have no REST catalog (GET /group/memberstatus is a different dimension: member statuses, string ids " + - '— verified 2026-07-10). Declare a numeric "groupStatusId" instead (e.g. "groupStatusId: 1").', + /no live group-status at \/person\/masterdata matches key "candidate"/, ); + expect(await r.resolveValue(99, "site")).toBe(99); }); it("returns a pending marker for a same-run-declared managed target (not yet in state)", async () => { From e11c3ebced7bc7280461cf9acc3576f7e5e560cb Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Sat, 29 Aug 2026 08:52:39 +0200 Subject: [PATCH 2/2] docs: refresh group-status catalog guidance --- docs/api-coverage.md | 2 +- docs/handbuch/blueprints.md | 11 +++++++---- docs/handbuch/dynamic-groups.md | 26 +++++++++++++------------ docs/handbuch/group-member-fields.md | 4 ++-- docs/handbuch/permissions.md | 13 +++++++------ docs/runbook-manual-surface.md | 2 +- examples/portable.config.ts | 8 ++++---- src/resolve/refs.ts | 5 ++--- src/resolve/resolver.ts | 29 +++++++++------------------- src/resolve/reverse.ts | 8 ++++---- 10 files changed, 51 insertions(+), 57 deletions(-) diff --git a/docs/api-coverage.md b/docs/api-coverage.md index e2bae7b..ac9073e 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -29,7 +29,7 @@ Methods marked only if they actually exist on the matched path. "Update" = PUT o | 10 | `ct_target_group` | `/group/targetgroups`, `/group/targetgroups/{targetGroupId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | | 11 | `ct_meeting_point` | — none — | ❌ | ❌ | ❌ | ❌ | **Not in API → fully manual.** Zero matches for `treffpunkt`/`meetingpoint`/`meeting point` anywhere in the spec. Closest neighbours are _meeting templates_ (`/group/meetingtemplates`, full CRUD) and _group meetings_ (`/groups/{groupId}/meetings`, CRUD) — different concepts; confirm with product whether "meeting point" was meant to be one of those. | | 12 | `ct_relationship_type` | `/person/relationshiptypes`, `/person/relationshiptypes/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | -| 13 | `ct_person_status` | `/statuses`, `/statuses/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD, live-verified 2026-08-13** (eqrm prod, CT 3.135.2, read from the instance OpenAPI spec): `/statuses` → GET, POST; `/statuses/{id}` → GET, PUT, DELETE. `POST` requires `name`, `shorty`, `isMember`; `PUT` requires ALL of `name`, `shorty`, `isMember`, `isSearchable`, `sortKey`, `securityLevelId` — uniquely strict among managed types (every other managed PUT declares no required fields), and since PUT is a full replace the registry manages all six rather than a subset. The person/community **Status** master data (tag `Status`): "0 - First", "3 - Group Active", …, the domain a `ct.status` permission grant hangs off. Adoptable since #96, which is what makes a config using that domain self-sufficient across hosts. Do NOT conflate with `ct_group_status` (row 8, no endpoint at all) or `/group/memberstatus` (member statuses, string ids). Master data — never a person record; the people guard is unaffected. | +| 13 | `ct_person_status` | `/statuses`, `/statuses/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD, live-verified 2026-08-13** (eqrm prod, CT 3.135.2, read from the instance OpenAPI spec): `/statuses` → GET, POST; `/statuses/{id}` → GET, PUT, DELETE. `POST` requires `name`, `shorty`, `isMember`; `PUT` requires ALL of `name`, `shorty`, `isMember`, `isSearchable`, `sortKey`, `securityLevelId` — uniquely strict among managed types (every other managed PUT declares no required fields), and since PUT is a full replace the registry manages all six rather than a subset. The person/community **Status** master data (tag `Status`): "0 - First", "3 - Group Active", …, the domain a `ct.status` permission grant hangs off. Adoptable since #96, which is what makes a config using that domain self-sufficient across hosts. Do NOT conflate with `ct_group_status` (row 8, read-only nested catalog) or `/group/memberstatus` (member statuses, string ids). Master data — never a person record; the people guard is unaffected. | | 14 | `ct_department` | `/departments` | list ✅ / by-id ❌ | ❌ | ❌ | ❌ | **No REST write path — a ref catalog here, not a managed resource.** Live-probed 2026-08-13 (eqrm prod, CT 3.135.2): `GET /departments` returns `[{id, name, nameTranslated, sortKey, shorty}]`; no `POST`/`PUT`/`DELETE` on `/departments` exists in the spec, and there is no `/departments/{id}` path at all. Bereiche are the `cdb_bereich` permission scope dimension (`churchdb:view alldata`), so `ct` resolves them BY NAME for a `scope: [{ department: "…" }]` reference (#98) and surfaces them via `ct get departments` — but `ct.department` declares one and `ct adopt department ` adopts one. **Managed since #108** — `ct` writes Bereiche through the legacy `POST /index.php?q=churchdb/ajax` `func=saveMasterData` interface the admin UI uses (create/update/delete all verified live on eqrm-dev 2026-08-14). It appears in no OpenAPI spec, which is why an OpenAPI-only audit could never have found it (#111). Note there is no `/departments/{id}` at all, so a single Bereich is read by filtering the collection. | | 15 | `ct_group_member_field` | `/groups/{groupId}/memberfields` (GET), `/groups/{groupId}/memberfields/group` (POST), `/groups/{groupId}/memberfields/group/{groupMemberFieldId}` (PATCH/PUT/DELETE) | list ✅ (per group) / by-id ❌ | ✅ | ✅ PATCH (PUT fallback) | ✅ | **Full CRUD, but GROUP-SCOPED — not a standalone resource (#135).** A member field belongs to exactly one group and is not globally reusable, so it has no collection path of its own and no entry in the resource registry: it is a _synthetic owned sub-resource_ of `ct_group`, declared inside `ct.group({ memberFields: [...] })`, exactly like hierarchy `parents` and the dynamic ruleset. Its portable identity is the managed group key plus a local field key (`ojbp_2026_27_praktikum_1::wahl`); a ChurchTools field id never reaches authored config or an adopted blueprint. Reads are narrowed to rows the `/memberfields/group` endpoints own — the same GET also returns fields sourced from person master data / group-type defaults, which are neither adoptable nor writable here. `apply` NEVER deletes one (a field dropped from config produces no desired diff key at all); removal is the explicit `ct destroy --member-field ::`. Update uses `PATCH` (partial, so unmanaged siblings survive) and falls back to `PUT` on a 405/501. Definitions only — never a per-person value; `assertNotPeople` still guards every path. Distinct from the group CUSTOM fields of #48/#60 (`/dbfields`, `fieldCategory.table == "cdb_gruppe"`), which describe the group RECORD rather than what its members are asked. | diff --git a/docs/handbuch/blueprints.md b/docs/handbuch/blueprints.md index b0aef90..df4877b 100644 --- a/docs/handbuch/blueprints.md +++ b/docs/handbuch/blueprints.md @@ -4,8 +4,8 @@ sources: - src/config/context.ts - src/engine/graph.ts - src/engine/hierarchy.ts -sources_hash: 6f4be8d3a93113ce -reviewed: 2026-08-28 +sources_hash: 15a942cea09195e1 +reviewed: 2026-08-29 --- # Blueprints (parametrized, reusable config) @@ -55,6 +55,9 @@ plan` renders it as `campusId = `. The same portability applies to the group type: `groupType: "ministry_team"` resolves against the live catalog per host, no hardcoded `groupTypeId`. +The group's lifecycle status works the same way: `status: "active"` resolves +through `/person/masterdata.groupStatuses`, whose numeric ids may differ by +host. `groupStatusId` remains the numeric escape hatch (#157). ```ts function kidsArea(ct: ConfigContext, campus: string): void { @@ -69,8 +72,8 @@ The **numeric escape hatch** stays available: pass `campusId: ` `groupTypeId: 2` to target one instance's id directly. `ct plan` diffs a campus assign/move/clear as a normal field update — see [`docs/group-field-decisions.md`](https://github.com/eqrm/ct-cli/blob/main/docs/group-field-decisions.md). Declaring both the -logical and the numeric form for one field (`campus` + `campusId`) is a conflict -and throws at eval time. +logical and the numeric form for one field (`campus` + `campusId`, `status` + +`groupStatusId`) is a conflict and throws at eval time. ## The loop-over-campuses pattern and `${campus}_`-prefixed keys diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index d86b441..ee983b5 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -6,8 +6,8 @@ sources: - src/engine/dynamic.ts - src/engine/synthetic.ts - src/application/operations/adopt-group.ts -sources_hash: e38b8c0f6032d5cc -reviewed: 2026-08-28 +sources_hash: f5d6029e65ce3936 +reviewed: 2026-08-29 --- # Auto-groups (dynamic groups) @@ -158,7 +158,8 @@ no-op — it does not re-`PUT` on every apply). Two equivalent ways to author it ``` Simple marker `kind`s carry a single `key` (the logical key / slug): - `campus`, `group`, `group-type`. A **role** (`role.id`) uses the compound + `campus`, `group`, `group-type`, `group-status`. A **role** (`role.id`) uses + the compound `group-type-role` marker instead — `{ "__ctRef": true, "kind": "group-type-role", "groupType": "", "role": "" }` — because a ruleset's `role.id` is a **groupTypeRoleId** (a role scoped to a @@ -210,14 +211,15 @@ position that maps to a **managed** logical key is rewritten to its `{ __ctRef } marker; every other id is left numeric. The `var → RefKind` catalog it keys off (`VAR_REF_KINDS`) is: -| ChurchQuery `var` | marker `kind` | source catalog / state | -| --------------------- | ----------------- | -------------------------------------------------- | -| `ctgroup.id` | `group` | managed state (no REST catalog) | -| `ctgroup.campusId` | `campus` | `/campuses` | -| `person.campusId` | `campus` | `/campuses` | -| `ctgroup.groupTypeId` | `group-type` | `/group/grouptypes` | -| `role.id` | `group-type-role` | `/group/roles` (by `groupTypeId` + name) | -| `role.id` | `role-def` | managed state — only when the pair collides (#125) | +| ChurchQuery `var` | marker `kind` | source catalog / state | +| ----------------------- | ----------------- | -------------------------------------------------- | +| `ctgroup.id` | `group` | managed state (no REST catalog) | +| `ctgroup.campusId` | `campus` | `/campuses` | +| `person.campusId` | `campus` | `/campuses` | +| `ctgroup.groupTypeId` | `group-type` | `/group/grouptypes` | +| `ctgroup.groupStatusId` | `group-status` | `/person/masterdata` → `groupStatuses` | +| `role.id` | `group-type-role` | `/group/roles` (by `groupTypeId` + name) | +| `role.id` | `role-def` | managed state — only when the pair collides (#125) | The same `group-type-role` rewrite also covers the **out-of-query** integer field `process.*.handleMembership.groupTypeRoleId` (the target role a @@ -305,7 +307,7 @@ At **capture** time (`ct adopt … --with-dynamic`) the state file and the ```text ! rulesets/jugend.json keeps 5 host-specific id(s) — NOT portable to another host: ctgroup.id: 1246 left numeric — not under management — `ct adopt group ` for each (then re-adopt) makes them portable - ctgroup.groupStatusId: 99 left numeric — no managed group-status on this host carries these ids + ctgroup.groupStatusId: 99 left numeric — no group-status catalog row on this host carries these ids person.id: 5703, 4389 left numeric — person ids are NEVER portable — ct does not manage people, so this ruleset names DIFFERENT people on another host. Remove the clause or accept the divergence ``` diff --git a/docs/handbuch/group-member-fields.md b/docs/handbuch/group-member-fields.md index 1da01b5..c976fb6 100644 --- a/docs/handbuch/group-member-fields.md +++ b/docs/handbuch/group-member-fields.md @@ -1,5 +1,5 @@ --- -sources_hash: c18b710bff24503d +sources_hash: 84cc15a0514f7d05 title: Group member fields sources: - src/engine/member-fields.ts @@ -7,7 +7,7 @@ sources: - src/config/context.ts - src/application/operations/adopt-group.ts - src/application/operations/destroy.ts -reviewed: 2026-08-28 +reviewed: 2026-08-29 --- # Group member fields (#135, #158) diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index 4b2c46a..9f037bc 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -7,8 +7,8 @@ sources: - src/resolve/resolver.ts - src/resolve/refs.ts - src/config/context.ts -sources_hash: c3a6740c4d4bc134 -reviewed: 2026-08-28 +sources_hash: 2c0e7cbf30d33880 +reviewed: 2026-08-29 --- # Permissions (`ct.groupRole` / `ct.groupTypeRole` / `ct.status`) @@ -236,10 +236,11 @@ The two DSL functions manage two different ChurchTools "domain types," and check, not a truthiness one. > **Person status ≠ group status.** `groupStatusId` (`ct.group`) is a - > different dimension with **no** REST catalog at all (#67) and must always be - > written as a number. Person statuses do have one (`GET /statuses`, flat - > array of `{id, name}` — live-verified 2026-08-10 on eqrm prod), so they - > resolve by name like campuses and group types. + > different dimension. Its read-only catalog is nested under + > `GET /person/masterdata` → `groupStatuses`, so `status: "active"` resolves + > by technical name (#157); numeric `groupStatusId` remains an escape hatch. + > Person statuses instead use `GET /statuses` (flat array of `{id, name}` — + > live-verified 2026-08-10 on eqrm prod). Since #96 the status itself is also **declarable**, via `ct.personStatus`: diff --git a/docs/runbook-manual-surface.md b/docs/runbook-manual-surface.md index 8414aac..703292c 100644 --- a/docs/runbook-manual-surface.md +++ b/docs/runbook-manual-surface.md @@ -49,7 +49,7 @@ in that instance's own config repo, in a runbook following this doc's structure. | ~~Grant adoption~~ **(shipped)** | ~~existing rights structures must be hand-transcribed~~ — **`ct adopt grants ` ships this** (#25): it reads the live rows, applies the planner's normalization, and prints a paste-ready `ct.groupRole` / `ct.groupTypeRole` block (baseline/inherited excluded, denies noted-and-preserved, scope dataIds mapped back to managed-group keys). See [`docs/handbuch/permissions.md`](handbuch/permissions.md) "Adopting existing grants" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run `ct adopt grants group_role ` (or `group_type_role`), review the `WARNING`/`NOTE` comments, paste into config | | ~~Permission catalog lifecycle~~ **(shipped)** | ~~`catalog.json` is a one-off HAR-trace snapshot with no staleness detection~~ — **shipped (#25):** `npm run regenerate:permission-catalog` rewrites it from a live instance (records the CT version in `$meta`), and `ct plan` now warns on a version mismatch or an unknown-authId live grant (which it leaves untouched, never revoking a right it cannot name). See [`docs/handbuch/permissions.md`](handbuch/permissions.md) "Catalog lifecycle & staleness" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run the command; heed the `ct plan` warnings | | Field definitions & security levels (person + group custom fields) **(read-only, shipped #47/#48)** | The person master-data model, the security-level enumeration, and the data-field DEFINITIONS ("Datenfelder") for persons and groups — structural schema, not per-record values | [#47](https://github.com/eqrm/ct-cli/issues/47), [#48](https://github.com/eqrm/ct-cli/issues/48) (read shipped; write is an API gap — see note) | Read with `ct get person-masterdata` (model + security levels) and `ct get data-fields` (all field definitions, person + group, discriminated by `fieldCategory`). **Mutation stays manual:** field definitions have no REST write endpoint — only the legacy churchdb admin AJAX (`db_insertfields`/`db_updatefields`/`db_deletefields`) — so create/edit/delete them by hand in the master-data admin UI. Decision + evidence: [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md) | -| API re-audit for new CT releases | CT's OpenAPI spec is self-trimming (only shows endpoints your version has), so a new write endpoint (e.g. a member-status write, or — separately — a first-ever group-status list/write endpoint, #67) appears silently between CT upgrades | tracked by this issue ([#26](https://github.com/eqrm/ct-cli/issues/26)) | Procedure below (**Re-audit procedure for new CT releases**) | +| API re-audit for new CT releases | CT's OpenAPI spec is self-trimming (only shows endpoints your version has), so a new write endpoint (e.g. a member-status or group-status write endpoint) or a dedicated group-status collection replacing the nested `/person/masterdata.groupStatuses` catalog (#157) appears silently between CT upgrades | tracked by this issue ([#26](https://github.com/eqrm/ct-cli/issues/26)) | Procedure below (**Re-audit procedure for new CT releases**) | ## Out of tool scope — deliberate, not a gap diff --git a/examples/portable.config.ts b/examples/portable.config.ts index 92553c3..a6ba0fc 100644 --- a/examples/portable.config.ts +++ b/examples/portable.config.ts @@ -5,14 +5,14 @@ * * References resolve from, in order: (1) resources managed by this tool (declared * here or already in state), (2) live master-data catalogs (group types, campuses, - * roles) matched by name. An unresolvable name fails the plan with a + * group statuses, roles) matched by name. An unresolvable name fails the plan with a * clear error naming the reference and where it was used — never a silent wrong id. * * The numeric escape hatch remains available everywhere (`groupTypeId: 2`, * `campusId: 3`, `q.eq("ctgroup.campusId", 4)`, `id: `) for the rare case - * where you deliberately target one instance's id — and is the ONLY way to set a - * group's `groupStatusId`: group statuses have no REST catalog to resolve a name - * against (#67), so unlike `groupType`/`campus` there is no `status:` sugar. + * where you deliberately target one instance's id. A group's lifecycle status can + * instead use `status: "active"`; it resolves through the nested group-status catalog + * in `/person/masterdata` (#157). */ import type { ConfigContext } from "../src/config/context.js"; import { q, churchQuery, ref } from "../src/config/context.js"; diff --git a/src/resolve/refs.ts b/src/resolve/refs.ts index ffab1ed..6ceae9f 100644 --- a/src/resolve/refs.ts +++ b/src/resolve/refs.ts @@ -196,9 +196,8 @@ export const ref = { }), /** * A PERSON status (`/statuses` — "0 - First", "3 - Group Active", …), the domain of a `status` - * permission declaration. Unrelated to {@link ref.status} (GROUP status, `groupStatusId`), which - * has no catalog at all (#67) — person statuses do, so this one resolves by name like any other - * master-data ref. + * permission declaration. Unrelated to {@link ref.status} (GROUP status, `groupStatusId`), whose + * separate catalog is nested under `/person/masterdata.groupStatuses` (#157). */ personStatus: (key: string): SimpleRef => ({ __ctRef: true, diff --git a/src/resolve/resolver.ts b/src/resolve/resolver.ts index 4a7b1bc..de83953 100644 --- a/src/resolve/resolver.ts +++ b/src/resolve/resolver.ts @@ -8,17 +8,12 @@ * resource tier applies — re-resolved at apply time, mirroring the permission * scope pattern in src/permissions/scope.ts). * 2. Live catalog master data, matched by `slug(name) === key` with an exact-name - * secondary: campus → /campuses, group-type → /group/grouptypes, role-def → /group/roles. + * secondary: campus → /campuses, group-type → /group/grouptypes, + * group-status → /person/masterdata.groupStatuses, role-def → /group/roles. * Each catalog is fetched at most once per run and cached by a `Map`, * so the resolver is safe to share across `buildPlan` and `buildPermissionPlan` running - * concurrently (both await the same in-flight promise). group-status ("group-status" / - * `ref.status`) has NO catalog here — ChurchTools exposes no REST list endpoint for group - * statuses at all (live-verified 2026-07-10 on eqrm prod; see the note by `CATALOG_PATH` - * below and #67). A declared `status:` field fails fast at eval time (src/config/context.ts) - * before it ever reaches this resolver — but a `groupStatusId: ref.status(...)` value skips - * that guard (the id-field escape hatch accepts any Ref) and lands on step 3 below, where - * `notFound` special-cases "group-status" to give the same actionable message instead of - * the generic "declare/adopt it" advice, which would be wrong (no such resource, no catalog). + * concurrently (both await the same in-flight promise). Group statuses are catalog-only: + * ChurchTools exposes them as a nested array rather than a dedicated collection (#157). * 3. Hard error naming the kind, key, referencing site, and host. * * Unknown / ambiguous references THROW (a config error — distinct from the @@ -55,7 +50,7 @@ import { type SimpleRef, } from "./refs.js"; -/** ref kind → managed resource type (state/desired). group-status has neither: no catalog and never managed (#67). */ +/** ref kind → managed resource type (state/desired). Group-status is catalog-only and never managed. */ const REF_KIND_TYPE: Partial> = { campus: "campus", "group-type": "group-type", @@ -87,16 +82,10 @@ const REF_KIND_TYPE: Partial> = { /** * ref kind → live catalog path. `group` has no catalog (managed-only); `group-role` is gated. * - * `group-status` is deliberately ABSENT (#67, disproving the prior assumption documented here): - * `GET /group/memberstatus` is NOT a group-status catalog — live-verified 2026-07-10 on eqrm prod, - * it returns MEMBER statuses (`{id: "active", name: "Active"}, {id: "requested", ...}`, STRING ids), - * a completely different dimension from `groupStatusId` (numeric — e.g. 1 = active, 4 = archived on - * that instance). Further probing found no REST list endpoint for group statuses at all - * (`/groups/statuses` parses as `/groups/{groupId}`, `/group/statuses` and `/groupstatuses` 404) — - * neither read nor write. So `status:` sugar fails fast at eval time instead (src/config/context.ts) - * rather than reaching this resolver and either resolving against the wrong dimension or landing - * here as an unconditional hard error. If CT ever ships a real group-status endpoint, add it back - * here and restore the `status` entry to `ID_SUGAR` in context.ts. + * Group statuses have no dedicated endpoint, but `GET /person/masterdata` contains them under + * `groupStatuses` (#157). That numeric-id/technical-name catalog is distinct from both + * `/group/memberstatus` (membership statuses with string ids) and `/statuses` (person statuses). + * The reader below special-cases the nested response shape. */ const CATALOG_PATH: Partial> = { campus: "/campuses", diff --git a/src/resolve/reverse.ts b/src/resolve/reverse.ts index f337299..269660e 100644 --- a/src/resolve/reverse.ts +++ b/src/resolve/reverse.ts @@ -150,10 +150,10 @@ export class ReverseResolver { /** * Reverse-sugar a managed-field bag for emission (#52 item A): each numeric id field with a catalog - * match becomes its logical `campus`/`groupType` key (dropping the numeric field); an id with NO - * match stays numeric and is named in `todos` so the emitter can flag it. Every other field — - * including `groupStatusId`, which has no `REVERSE_ID_FIELDS` entry (#67) — passes through - * untouched in its original position (and a `null` id — "no campus" — is omitted by the emitter). + * match becomes its logical `campus`/`groupType`/`status` key (dropping the numeric field); an id + * with NO match stays numeric and is named in `todos` so the emitter can flag it. Every other + * field passes through untouched in its original position (and a `null` id — "no campus" — is + * omitted by the emitter). */ async sugarFields( fields: Record,