Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,18 +112,29 @@ default-exports a function receiving the DSL:
```ts
export default (ct) => {
ct.campus({ key: "mainz", name: "Mainz", shorty: "MZ" });
ct.group({ key: "mainz_area", name: "Mainz · Bereiche", groupTypeId: 2 });
// Reference master data BY NAME, not by hardcoded id: `groupType: "…"` resolves to the
// per-host group-type id at plan time, so this config is portable across instances (#20).
ct.group({ key: "mainz_area", name: "Mainz · Bereiche", groupType: "ministry_team" });
// Hierarchy is opt-in and multi-parent: `parents` are managed group keys, each declared
// in this config. Omit it to leave a group's hierarchy unmanaged; edges to unmanaged
// groups stay invisible. (`parent:` is unrelated — an ordering hint only, not hierarchy.)
ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", groupTypeId: 2, parents: ["mainz_area"] });
// 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"] });
ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", groupType: "ministry_team", parents: ["mainz_area"] });
// Assign a group to a campus BY KEY: `campus: "mainz"` links to the campus above even though
// it is created in the same apply (its id is filled in at apply time). The numeric escape
// hatch still works — `campusId: 3` (or `campusId: null` to clear) targets an existing id.
ct.group({ key: "mainz_kids", name: "Mainz · Kids", groupType: "ministry_team", campus: "mainz", parents: ["mainz_kids_lead"] });
};
```

**Portable references (#20):** logical fields (`campus`/`groupType`/`status` on a
group, `groupType` on a permission) and the inline `ref.*` helper compile to id-free
sentinels a per-host resolver maps to real ChurchTools ids at plan time — sourced from
resources this tool manages, then live master-data catalogs matched by name. So one
config file plans and applies unchanged against different instances (ids differ per
host). An unresolvable name fails the plan with a clear error naming the reference and
where it was used. Raw numeric ids remain a valid escape hatch everywhere; see
[`examples/portable.config.ts`](examples/portable.config.ts) for a zero-numeric-id config.

`campusId` is a managed group field: `ct plan` shows a campus assign/move/clear
as a normal field update, and `ct adopt group <id>` captures it. Which group
fields are managed vs. deliberately left to the CT UI is recorded in
Expand Down Expand Up @@ -176,7 +187,7 @@ runnable example.
```ts
ct.groupTypeRole({
key: "leiter_tpl",
id: 1, // the domainId — the group type's own id for group_type_role
groupType: "kids", // domain BY NAME — resolved to the group-type domainId per host (#20)
grants: [
"churchgroup:view", // unscoped
{ right: "churchgroup:view group", scope: ["kids_area"] }, // scoped to a managed group
Expand Down
37 changes: 25 additions & 12 deletions docs/blueprints.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,31 @@ 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: <existing campus id>`
(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.
Link a group to a campus **by key** — `campus: "mainz"` (or, when the campus key
is a loop variable, `campus`) — and the per-host resolver fills in the id (#20).
When the blueprint *creates* the campus in the same apply, its id is unknown at
eval time, so the resolver marks the link **pending** and writes the
freshly-created id at apply time (tier ordering creates the campus first). `ct
plan` renders it as `campusId = <campus:mainz (created this apply)>`.

The same portability applies to the group type: `groupType: "ministry_team"`
resolves against the live catalog per host, no hardcoded `groupTypeId`.

```ts
function kidsArea(ct: ConfigContext, campus: string): void {
const lead = `${campus}_kids_lead`;
// group type BY NAME, campus BY KEY — both resolved per host (#20).
ct.group({ key: lead, name: `${campus} · Kids Leitung`, groupType: "ministry_team", campus, parents: [] });
}
```

The **numeric escape hatch** stays available: pass `campusId: <existing id>`
(CT stores it at `information.campusId`; `campusId: null` clears it) or
`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`](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.

## The loop-over-campuses pattern and `${campus}_`-prefixed keys

Expand Down
15 changes: 10 additions & 5 deletions docs/dynamic-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,13 @@ sibling of `query`, not an argument to `churchQuery(...)`.
| `q.oneof(varName, values)` | `{ oneof: [{ var: varName }, values] }` |
| `q.isnull(varName)` | `{ isnull: [{ var: varName }] }` |

`var` values are **raw ChurchTools ids** (e.g. `ctgroup.campusId`) — resolve
any key → id lookup at config-build time, before calling `q.eq`/`q.oneof`,
and pass the number.
`var` values may be **logical references** or **raw ids**. Prefer a reference so
the ruleset is portable across hosts (#20): `q.eq("ctgroup.campusId",
ref.campus("mainz"))` — the per-host resolver fills in that instance's campus id
at plan time (and, for a campus created in the same run, at apply time). `ref` is
re-exported from `src/config/context.js` alongside `q`/`churchQuery`. The numeric
escape hatch still works — pass a plain number to target one instance's id
directly. References resolve deep inside the ruleset, so any `var` value works.

`churchQuery(filter, opts?)` wraps a JSONLogic filter tree in the same
envelope shape ChurchTools itself returns:
Expand All @@ -131,15 +135,16 @@ covers `primaryEntityAlias` / `responseFields` / `groupBy`, for a query keyed
on something other than `person.id`.

```ts
import { q, churchQuery } from "../src/config/context.js";
import { q, churchQuery, ref } from "../src/config/context.js";

const ruleset = {
description: "Alle aktiven Personen in Mainz",
shorty: "Autom. Mitgliedschaft Alle Mainz",
importance: 0,
personIdFieldName: "person.id",
process: {},
query: churchQuery(q.and(q.eq("ctgroup.campusId", mainzCampusId), q.eq("person.isArchived", false))),
// campus BY NAME — resolved to the per-host id at plan time (numeric ids still work too).
query: churchQuery(q.and(q.eq("ctgroup.campusId", ref.campus("mainz")), q.eq("person.isArchived", false))),
};
```

Expand Down
46 changes: 31 additions & 15 deletions docs/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ workflow used for structural resources (issue #13).
```ts
export default (ct) => {
ct.groupTypeRole({
key: "leiter_tpl", // logical key (unique across the whole config)
id: 8, // the domainId — see "domainId semantics" below
key: "leiter_tpl", // logical key (unique across the whole config)
groupType: "ministry_team", // domain BY NAME — resolved to the domainId per host (#20)
grants: [
"churchgroup:view group", // unscoped
{ right: "churchgroup:view group", scope: ["kids_area"] }, // scoped
Expand All @@ -19,20 +19,27 @@ export default (ct) => {

ct.groupRole({
key: "kids_lead_grant",
id: 2882, // the internal (group, role) domainId — see below
id: 2882, // the internal (group, role) domainId — see below (group_role has no ref yet)
// "edit group memberships of group" is a scoped right, so it takes a `scope: [...]`.
grants: [{ right: "churchgroup:edit group memberships of group", scope: ["kids_area"] }],
});
};
```

Both take the same shape, `{ key, id, grants }`:
Both take `{ key, <domain>, grants }`, where `<domain>` is either a logical
reference or a numeric `id`:

- **`key`** — the logical key, unique across the whole config (shared
namespace with every other resource type).
- **`id`** — the explicit **domainId** of the permission domain object. This
tool does not look it up for you; you supply it directly (see
"domainId semantics" below).
- **domain** — the permission domain object. Declare it **by reference** (the
portable form, #20) or **by numeric `id`** (the escape hatch):
- `ct.groupTypeRole` — `groupType: "<name>"` resolves against the live
group-type catalog per host, or `id: <domainId>` targets one directly.
- `ct.groupRole` — **`id: <domainId>` only for now.** The logical
`group: "<key>", role: "<name>"` form is accepted by the DSL but the
resolver rejects it at plan time (the (group, role) pairing id has no
confirmed API source — see "domainId semantics" and #25). Declaring both a
logical form and a numeric `id` is a conflict and throws.
- **`grants`** — an array of `Grant`s, each either:
- a bare string, `"module:right"` — an **unscoped** grant, or
- an object `{ right: "module:right", scope: string[] }` — a **scoped**
Expand Down Expand Up @@ -67,16 +74,25 @@ evaluation only checks a grant's *shape*: `module:right` string or
The two DSL functions manage two different ChurchTools "domain types," and
`id` means something different for each:

- **`group_type_role`** (`ct.groupTypeRole`) — `id` is the **group type's own
id** (the same id you'd pass as `groupTypeId` on `ct.group`). It scopes the
grant to "every role holder of this group type."
- **`group_role`** (`ct.groupRole`) — `id` is the **internal
- **`group_type_role`** (`ct.groupTypeRole`) — the domain is the **group type's
own id** (the same id you'd pass as `groupTypeId` on `ct.group`). It scopes the
grant to "every role holder of this group type." Declare it portably as
`groupType: "<name>"` (resolved per host, #20) or directly as `id: <domainId>`.
- **`group_role`** (`ct.groupRole`) — the domain is the **internal
(group, role) pairing's own id** — a ChurchTools-internal id for one
specific group's specific role, *not* the group's id and *not* the role's
id. There is no lookup helper for this in the CLI; find it via the
ChurchTools permission editor / an existing `GET /permissions/group_role`
response for a group+role you already have, and hardcode it in the config
like any other domainId.
id. **This is `id: <domainId>` only.** The logical `group` + `role` form is
reserved (and accepted by the DSL) but **not yet resolvable**: the pairing id
has no confirmed API source, so the resolver throws a clear "pass a numeric id
(see #25)" error at plan time. Find the id via the ChurchTools permission
editor / an existing `GET /permissions/group_role` response for a group+role
you already have, and hardcode it like any other domainId.

Resolution runs in `buildPermissionPlan` (`src/permissions/plan.ts`): a numeric
`id` passes straight through; a `groupType` reference resolves against the live
catalog. After resolution, two declarations that resolve to the **same**
`(domainType, domainId)` are rejected (they would otherwise diff against each
other's grants forever) — even if one used a name and the other a raw id.

## Scope resolution

Expand Down
Loading
Loading