diff --git a/src/actions/catalog.actions.ts b/src/actions/catalog.actions.ts new file mode 100644 index 0000000..98bf4bd --- /dev/null +++ b/src/actions/catalog.actions.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineAction } from '@objectstack/spec'; + +import { + CATALOG_APPLY_ACTION, + CATALOG_SYNC_ACTION, +} from './catalog.handlers.js'; + +/** + * Catalog instantiation — the onboarding path. + * + * Customers arrive with their catalog already written, usually as a + * spreadsheet. Taking a position has to mean "apply the list", not "hand-type + * 26 duties", or the rollout dies in week one. + * + * ── Why these are OBJECT-LESS and headless ──────────────────────────────── + * Neither action operates on a record: `duly_catalog_apply` reads a whole + * position's worth of `duly_catalog_item` rows and writes `duly_duty` rows for + * several people at once. That makes it a GLOBAL action — no `objectName` — and + * an object-less action in protocol 17 has no UI home to declare. + * `global_nav` was removed from `ACTION_LOCATIONS` in @objectstack/spec 17 + * (#6888, ADR-0049 enforce-or-remove): the console's ⌘K palette reads no action + * metadata, so the location never rendered. Every surviving location + * (`list_toolbar`, `list_item`, `record_*`) is bound to an object. + * + * So `locations: []` is the honest declaration the spec itself prescribes for + * this case — it keeps the param contract, the capability gate and the audit + * trail, and the action is invoked over the platform action route + * (`POST /api/v1/actions/global/duly_catalog_apply`) or MCP rather than from a + * button. Declaring a location a renderer does not serve would be the + * ADR-0078 declares-renders-does-nothing shape. + * + * ── Why `type: 'script'` with a `target` and no `body` ──────────────────── + * The cadence maths and the idempotency probe are real code with real tests, + * not a sandboxed L1/L2 snippet. `target` names the handler registered from + * `src/actions/register-handlers.ts`; a `script` action with neither `body` + * nor `target` is rejected at author time precisely because it would otherwise + * render, be clickable, and 404 at call time. + */ + +/** + * `duly_catalog_apply` — instantiate a position's catalog onto people. + * + * `position_code` is free text on purpose: a customer can load their catalog on + * day one, before positions are modelled in the platform, so this deliberately + * does NOT pick from `sys_position` and does NOT require a `sys_user_position` + * row to exist for the selected people. + */ +export const CatalogApplyAction = defineAction({ + name: CATALOG_APPLY_ACTION, + label: 'Apply role catalog', + // Dialog copy, not the confirm prompt: an action that collects params and + // also sets `confirmText` shows two dialogs for one decision (#7278). The + // question is asked here, and the user's own Confirm sends it. + description: + 'Create the duties this position owes for each person selected. Runs again safely — anyone who already has a duty from a catalog item is skipped, not duplicated.', + icon: 'user-plus', + type: 'script', + target: CATALOG_APPLY_ACTION, + locations: [], + variant: 'primary', + params: [ + { + name: 'position_code', + label: 'Position', + type: 'text', + required: true, + placeholder: 'plant_compliance_officer', + helpText: + 'Matches duly_catalog_item.position_code exactly. Free text — the position does not have to be modelled in the platform yet.', + }, + { + name: 'users', + label: 'People', + type: 'user', + multiple: true, + required: true, + helpText: 'Each person gets their own copy of every active duty in this position\'s catalog.', + }, + ], +}); + +/** + * `duly_catalog_sync` — replay catalog cadence edits onto instantiated duties. + * + * Cadence only (`frequency`, `due_anchor`, `due_offset_days`, `lead_days`, + * `grace_days`). `owner`, `status`, `timezone` and the `effective_*` window are + * local decisions the catalog has no business overwriting, and a retired + * catalog item is REPORTED rather than acted on — deleting someone's duties + * because a template was deactivated is a decision for a human. + * + * `position_code` is optional here and narrows the sweep. Sync rewrites + * authored cadence, so being able to run it for one position instead of the + * whole org is the difference between a correction and an incident. + */ +export const CatalogSyncAction = defineAction({ + name: CATALOG_SYNC_ACTION, + label: 'Sync duties from catalog', + description: + 'Replay cadence changes from the role catalog onto the duties created from it. Owner, status, timezone and the effective window are left alone; duties from a deactivated catalog item are reported, never deleted.', + icon: 'refresh-cw', + type: 'script', + target: CATALOG_SYNC_ACTION, + locations: [], + params: [ + { + name: 'position_code', + label: 'Position', + type: 'text', + required: false, + placeholder: 'plant_compliance_officer', + helpText: 'Limit the sync to one position. Leave blank to sync every catalog-sourced duty.', + }, + ], +}); diff --git a/src/actions/catalog.handlers.ts b/src/actions/catalog.handlers.ts new file mode 100644 index 0000000..74e296b --- /dev/null +++ b/src/actions/catalog.handlers.ts @@ -0,0 +1,456 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { ActionEngineFacade, ActionHandler } from '@objectstack/spec/ui'; + +import type { HandlerRegistrationContext } from './register-handlers.js'; + +/** + * Runtime handlers for the catalog-instantiation actions. + * + * Action METADATA (`catalog.actions.ts`) declares the button and the param + * contract; THIS is the code that runs. The two are joined only by name, at + * registration time — an action whose handler is not registered renders, is + * clickable, and fails at call time with `Action '' on object 'global' + * not found`. `pnpm validate` cannot see that, so the wiring is asserted in + * `test/catalog-instantiate.test.ts` instead. + */ + +/** Action names. Exported so the metadata, the wiring and the tests agree by construction. */ +export const CATALOG_APPLY_ACTION = 'duly_catalog_apply'; +export const CATALOG_SYNC_ACTION = 'duly_catalog_sync'; + +/** + * The engine object key an object-less action registers under. + * + * `'global'` is the CANONICAL object-less key, not a wildcard: `executeAction` + * is an exact-string `Map` lookup on `:`, so a handler filed + * under anything else is unreachable no matter how the action is declared. + */ +export const GLOBAL_ACTION_OBJECT = 'global'; + +/** + * The cadence fields the catalog owns, and the complete list of what `sync` + * is allowed to write. + * + * Everything absent from this tuple is a LOCAL decision: `owner` (who actually + * holds the duty), `status` (paused because the person is on leave), + * `timezone`, and the `effective_*` window. A catalog edit must not silently + * un-pause a duty or move it to someone else, so `sync` builds its patch from + * this tuple alone rather than diffing whole records. + */ +export const CADENCE_FIELDS = [ + 'frequency', + 'due_anchor', + 'due_offset_days', + 'lead_days', + 'grace_days', +] as const; + +export type CadenceField = (typeof CADENCE_FIELDS)[number]; + +/** + * Duty timezone for a newly instantiated duty. + * + * The mapping this implements is "the user's zone if resolvable, else the org + * default, else UTC". Measured against @objectstack/spec 17.2.0 and + * @objectstack/platform-objects 17.2.0, the first two rungs do not exist for an + * action handler to read, so this resolves to the third: + * + * 1. THE USER'S ZONE — there is no such field. `sys_user` declares + * `name, email, email_verified, two_factor_enabled, role, banned, + * ban_reason, ban_expires, failed_login_count, locked_until, + * password_changed_at, phone_number, phone_number_verified, + * must_change_password, mfa_required_at, last_login_at, last_login_ip, + * ai_access, image, manager_id, primary_business_unit_id, source, id, + * created_at, updated_at` — no timezone, no locale. (`sys_user_preference` + * is an open key/value bag whose documented examples are theme and locale; + * inventing a `timezone` key there would be an application-level + * convention no platform surface writes.) + * 2. THE ORG DEFAULT — real, but not reachable from here. + * `ExecutionContext.timezone` is the resolved tenant zone (localization + * settings: platform default → global → tenant, ADR-0053 Phase 2), and + * `buildSession()` does not propagate it: an action handler's `ctx.session` + * carries `userId`, `organizationId`, `positions`, `roles` and nothing else. + * + * So this returns `'UTC'` — deliberately as a named function with the ladder + * written down, not as an inline literal, so that when the platform grows a + * user zone or carries the tenant zone into the action context there is exactly + * one place to add the rung. It is NOT a speculative read of an undeclared key: + * a `ctx.user.timezone ?? ...` chain here would be a tolerant consumer standing + * in for a producer that does not exist, which is how a wrong zone would ship + * silently instead of being fixed where it belongs. + * + * `'UTC'` is also `duly_duty.timezone`'s own declared `defaultValue`, and the + * test pins the two together so they cannot drift into two answers. + */ +export const DEFAULT_DUTY_TIMEZONE = 'UTC'; + +export function resolveDutyTimezone(): string { + return DEFAULT_DUTY_TIMEZONE; +} + +// ── Shapes ────────────────────────────────────────────────────────────────── + +export interface CatalogApplyParams extends Record { + position_code?: unknown; + users?: unknown; +} + +export interface CatalogSyncParams extends Record { + position_code?: unknown; +} + +/** One (catalog item × person) decision, so a run is legible row by row. */ +export interface CatalogApplyEntry { + catalog_item: string; + catalog_item_name: string; + owner: string; + outcome: 'created' | 'skipped'; + /** Id of the duty created by THIS run. Absent for a skip. */ + duty?: string; +} + +export interface CatalogApplyResult { + action: typeof CATALOG_APPLY_ACTION; + position_code: string; + catalog_items: number; + users: number; + created: number; + skipped: number; + entries: CatalogApplyEntry[]; +} + +/** A single cadence field's before/after. Sync is destructive, so both sides are recorded. */ +export interface CadenceChange { + from: unknown; + to: unknown; +} + +export interface CatalogSyncChange { + duty: string; + owner: string; + catalog_item: string; + catalog_item_name: string; + fields: Partial>; +} + +/** A duty whose catalog item has been deactivated. Reported, never deleted. */ +export interface CatalogSyncRetired { + duty: string; + owner: string; + catalog_item: string; + catalog_item_name: string; +} + +export interface CatalogSyncResult { + action: typeof CATALOG_SYNC_ACTION; + position_code: string | null; + scanned: number; + updated: number; + unchanged: number; + changes: CatalogSyncChange[]; + retired: CatalogSyncRetired[]; +} + +// ── Reading params ────────────────────────────────────────────────────────── +// +// The dispatcher validates params against the declared contract before the +// handler runs (ADR-0104 D2), so these guards are for the programmatic caller +// that bypasses it — a job, a test, another handler. They fail loudly rather +// than instantiating a catalog for nobody, which is the failure that would +// otherwise look like a successful run reporting zero. + +function requireText(value: unknown, param: string): string { + const text = typeof value === 'string' ? value.trim() : ''; + if (!text) throw new Error(`Action param "${param}" is required and must be a non-empty string.`); + return text; +} + +function optionalText(value: unknown): string | null { + const text = typeof value === 'string' ? value.trim() : ''; + return text ? text : null; +} + +function requireUserIds(value: unknown, param: string): string[] { + const raw = Array.isArray(value) ? value : [value]; + const ids: string[] = []; + for (const entry of raw) { + const id = typeof entry === 'string' ? entry.trim() : ''; + if (id && !ids.includes(id)) ids.push(id); + } + if (ids.length === 0) { + throw new Error(`Action param "${param}" is required and must name at least one user.`); + } + return ids; +} + +function recordId(row: Record): string { + const id = row.id; + if (typeof id !== 'string' || !id) { + throw new Error('Encountered a record with no id — cannot build an idempotency key from it.'); + } + return id; +} + +function text(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +/** + * `(catalog_item, owner)` — the pair the issue makes `apply` idempotent on. + * + * The parts are joined with a NUL, which cannot occur in a record id. Plain + * concatenation would collide: ('ab','c') and ('a','bc') produce the same + * string, so two different pairs would share one key and the second duty + * would be silently skipped as already taken. + * + * Written as the \u0000 ESCAPE, never as a raw byte. A literal NUL in the + * source makes git treat the whole file as binary — no diff, no blame, no + * review, for the life of the file — is invisible in an editor, and is dropped + * silently by most tooling on copy-paste, which would degrade this back to + * plain concatenation and reintroduce the collision with no error. + */ +export function pairKey(catalogItem: unknown, owner: unknown): string { + return `${text(catalogItem)}\u0000${text(owner)}`; +} + +// ── Business unit ─────────────────────────────────────────────────────────── + +/** + * The duty's rollup anchor: the business unit of the person's position + * assignment. + * + * `sys_user_position.business_unit_id` is a real, nullable lookup to + * `sys_business_unit` — "[ADR-0090 Addendum] Assignment-level BU anchor: where + * this position assignment applies … Null = unanchored". The object lives in + * `@objectstack/plugin-security`, not `@objectstack/platform-objects`. + * + * Returns `undefined` when the person has no position row or no anchored one, + * and the duty is then created WITHOUT a business unit. That is deliberate: + * `position_code` is free text so a customer can load their catalog before + * positions are modelled, which means "no `sys_user_position` row" is a normal + * day-one state and must not fail the apply. + * + * (`sys_user.primary_business_unit_id` is a second, user-level anchor that + * exists on the platform. It is NOT read here — the issue names the + * assignment-level anchor, and adding a fallback rung is a product decision, + * reported rather than taken.) + */ +async function resolveBusinessUnit( + engine: ActionEngineFacade, + userId: string, +): Promise { + const rows = await engine.find('sys_user_position', { where: { user_id: userId } }); + for (const row of rows) { + // A person can hold several positions; take the first anchored one. + // Unanchored rows (`null`) are legacy/tenant-wide and carry no depth. + const anchor = row?.business_unit_id; + if (typeof anchor === 'string' && anchor) return anchor; + } + return undefined; +} + +// ── duly_catalog_apply ────────────────────────────────────────────────────── + +export const applyCatalogHandler: ActionHandler = async (ctx) => { + const engine = ctx.engine; + const positionCode = requireText(ctx.params?.position_code, 'position_code'); + const users = requireUserIds(ctx.params?.users, 'users'); + + // Only ACTIVE items instantiate. A deactivated template is one the org has + // stopped asking for; handing it to a new hire on their first day is the + // opposite of what deactivating it meant. + const items = await engine.find('duly_catalog_item', { + where: { position_code: positionCode, active: true }, + }); + const activeItems = items.filter((item) => item?.active !== false); + + // One probe for the whole run, not one per (item, user). The pair set is + // what makes a second apply create nothing. + const existing = await engine.find('duly_duty', { where: { owner: { $in: users } } }); + const taken = new Set(); + for (const duty of existing) { + // Any duty already pointing at this catalog item for this person counts — + // the issue's rule is the `(catalog_item, owner)` pair, unqualified by + // source or status. A second row for the same pair is the duplicate the + // rule exists to prevent, whatever wrote the first one. + if (!users.includes(text(duty?.owner))) continue; + const item = duty?.catalog_item; + if (typeof item === 'string' && item) taken.add(pairKey(item, duty?.owner)); + } + + // Resolved once per person, not once per (item, person): a 26-item catalog + // for 3 people is 3 lookups here, not 78. + const businessUnits = new Map(); + for (const userId of users) { + businessUnits.set(userId, await resolveBusinessUnit(engine, userId)); + } + + const timezone = resolveDutyTimezone(); + const entries: CatalogApplyEntry[] = []; + let created = 0; + let skipped = 0; + + for (const item of activeItems) { + const itemId = recordId(item); + const itemName = text(item.name); + + for (const owner of users) { + if (taken.has(pairKey(itemId, owner))) { + skipped += 1; + entries.push({ catalog_item: itemId, catalog_item_name: itemName, owner, outcome: 'skipped' }); + continue; + } + + const businessUnit = businessUnits.get(owner); + const duty: Record = { + // ── from the catalog item ── + name: item.name, + description: item.description, + form: item.form, + frequency: item.frequency, + due_anchor: item.due_anchor, + due_offset_days: item.due_offset_days, + lead_days: item.lead_days, + grace_days: item.grace_days, + // ── from the person ── + owner, + ...(businessUnit ? { business_unit: businessUnit } : {}), + timezone, + // ── provenance and lifecycle ── + // `source: 'catalog'` is the CALIBER field: it is what makes on-time + // rates over these duties mean something, and it is the flag `sync` + // dispatches on. `catalog_item` is what makes the edit replayable. + source: 'catalog', + catalog_item: itemId, + status: 'active', + }; + + const inserted = await engine.insert('duly_duty', duty); + created += 1; + // Same-run guard: two identical items in one catalog cannot both land on + // the same person just because the pre-probe ran before either existed. + taken.add(pairKey(itemId, owner)); + entries.push({ + catalog_item: itemId, + catalog_item_name: itemName, + owner, + outcome: 'created', + duty: inserted?.id, + }); + } + } + + const result: CatalogApplyResult = { + action: CATALOG_APPLY_ACTION, + position_code: positionCode, + catalog_items: activeItems.length, + users: users.length, + created, + skipped, + entries, + }; + return result; +}; + +// ── duly_catalog_sync ─────────────────────────────────────────────────────── + +export const syncCatalogHandler: ActionHandler = async (ctx) => { + const engine = ctx.engine; + const positionCode = optionalText(ctx.params?.position_code); + + // Deliberately NOT filtered on `active`: a deactivated item is exactly what + // the retired report is made of, so it has to come back from this read. + const items = await engine.find( + 'duly_catalog_item', + positionCode ? { where: { position_code: positionCode } } : {}, + ); + const byId = new Map>(); + for (const item of items) { + if (positionCode && text(item?.position_code) !== positionCode) continue; + byId.set(recordId(item), item); + } + + const duties = await engine.find('duly_duty', { where: { source: 'catalog' } }); + + const changes: CatalogSyncChange[] = []; + const retired: CatalogSyncRetired[] = []; + let scanned = 0; + let updated = 0; + let unchanged = 0; + + for (const duty of duties) { + // The `source` guard is re-applied in code, not left to the query. + // "A duty whose source is 'self' is never touched by sync, even if its + // catalog_item is somehow set" is a product invariant, and an invariant + // that lives only in a `where` clause is one lenient driver away from + // being untrue. A self-declared duty is the owner's own record-keeping; + // the catalog does not get to rewrite it. + if (text(duty?.source) !== 'catalog') continue; + + const itemId = text(duty?.catalog_item); + if (!itemId) continue; + + const item = byId.get(itemId); + // Not in scope for this sweep (another position, or an item that no longer + // exists). Narrowing by `position_code` must not report the rest of the org + // as retired. + if (!item) continue; + + scanned += 1; + const dutyId = recordId(duty); + const owner = text(duty?.owner); + const itemName = text(item.name); + + if (item.active === false) { + // Report and move on. Deleting someone's duties because a template was + // deactivated is a decision for a human, not a side effect of a sync. + retired.push({ duty: dutyId, owner, catalog_item: itemId, catalog_item_name: itemName }); + continue; + } + + const patch: Record = {}; + const fields: Partial> = {}; + for (const field of CADENCE_FIELDS) { + const next = item[field]; + const current = duty?.[field]; + if (Object.is(current ?? null, next ?? null)) continue; + patch[field] = next; + fields[field] = { from: current, to: next }; + } + + if (Object.keys(patch).length === 0) { + unchanged += 1; + continue; + } + + await engine.update('duly_duty', dutyId, patch); + updated += 1; + changes.push({ duty: dutyId, owner, catalog_item: itemId, catalog_item_name: itemName, fields }); + } + + const result: CatalogSyncResult = { + action: CATALOG_SYNC_ACTION, + position_code: positionCode, + scanned, + updated, + unchanged, + changes, + retired, + }; + return result; +}; + +// ── Wiring ────────────────────────────────────────────────────────────────── + +/** + * Register both catalog handlers on the engine. + * + * Called from `registerDulyActionHandlers` in `register-handlers.ts`, which + * `objectstack.config.ts` invokes from `onEnable`. Both register under + * {@link GLOBAL_ACTION_OBJECT} because both actions are object-less. + */ +export function registerCatalogActionHandlers(ql: HandlerRegistrationContext): void { + ql.registerAction(GLOBAL_ACTION_OBJECT, CATALOG_APPLY_ACTION, applyCatalogHandler); + ql.registerAction(GLOBAL_ACTION_OBJECT, CATALOG_SYNC_ACTION, syncCatalogHandler); +} diff --git a/src/actions/index.ts b/src/actions/index.ts index b560efe..f36c0af 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -13,4 +13,8 @@ // makes `name` optional and fails the assignment. A named array is `never[]` // while empty and infers correctly the moment something is pushed into it. -export const dulyActions = []; +import { CatalogApplyAction, CatalogSyncAction } from './catalog.actions.js'; + +export { CatalogApplyAction, CatalogSyncAction }; + +export const dulyActions = [CatalogApplyAction, CatalogSyncAction]; diff --git a/src/actions/register-handlers.ts b/src/actions/register-handlers.ts index c5ec2f7..21ace8f 100644 --- a/src/actions/register-handlers.ts +++ b/src/actions/register-handlers.ts @@ -1,5 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { registerCatalogActionHandlers } from './catalog.handlers.js'; + /** * Action handler registration. * @@ -16,7 +18,8 @@ export interface HandlerRegistrationContext { registerAction: (...args: unknown[]) => void; } -export function registerDulyActionHandlers(_ql: HandlerRegistrationContext): void { +export function registerDulyActionHandlers(ql: HandlerRegistrationContext): void { // Register handlers here, one call per action: // registerTaskActionHandlers(ql); + registerCatalogActionHandlers(ql); } diff --git a/test/catalog-instantiate.test.ts b/test/catalog-instantiate.test.ts new file mode 100644 index 0000000..950d721 --- /dev/null +++ b/test/catalog-instantiate.test.ts @@ -0,0 +1,614 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { ActionEngineFacade, ActionHandlerContext } from '@objectstack/spec/ui'; + +import { Duty } from '../src/objects/index.js'; +import { dulyActions } from '../src/actions/index.js'; +import { registerDulyActionHandlers } from '../src/actions/register-handlers.js'; +import type { HandlerRegistrationContext } from '../src/actions/register-handlers.js'; +import { + CADENCE_FIELDS, + CATALOG_APPLY_ACTION, + CATALOG_SYNC_ACTION, + DEFAULT_DUTY_TIMEZONE, + GLOBAL_ACTION_OBJECT, + applyCatalogHandler, + pairKey, + resolveDutyTimezone, + syncCatalogHandler, +} from '../src/actions/catalog.handlers.js'; +import type { CatalogApplyResult, CatalogSyncResult } from '../src/actions/catalog.handlers.js'; + +// ─── A fake engine ────────────────────────────────────────────────────────── +// +// `ActionEngineFacade` is four methods, so the handlers can be driven directly +// without a kernel. The fake HONOURS `where` (equality plus `$in`) rather than +// returning everything: a fake that ignored filters would make every test pass +// for the wrong reason, and would hide a handler that forgot to narrow its +// read. The one test that needs an unfiltered read builds its own lenient +// engine, deliberately — see "the source guard is in the code". + +interface Row extends Record { + id: string; +} + +function matches(row: Row, where: Record | undefined): boolean { + if (!where) return true; + for (const [field, expected] of Object.entries(where)) { + const actual = row[field]; + if (expected !== null && typeof expected === 'object' && '$in' in (expected as object)) { + const set = (expected as { $in: unknown[] }).$in; + if (!Array.isArray(set) || !set.includes(actual)) return false; + continue; + } + if (actual !== expected) return false; + } + return true; +} + +class FakeEngine implements ActionEngineFacade { + readonly tables = new Map(); + readonly inserts: Array<{ object: string; data: Record }> = []; + readonly updates: Array<{ object: string; id: string; data: Record }> = []; + readonly deletes: Array<{ object: string; id: string }> = []; + private seq = 0; + + seed(object: string, rows: Array>): void { + const table = this.tables.get(object) ?? []; + for (const row of rows) table.push({ ...row, id: String(row.id ?? `${object}_${++this.seq}`) }); + this.tables.set(object, table); + } + + async insert(object: string, data: Record): Promise<{ id: string }> { + const id = `${object}_${++this.seq}`; + const table = this.tables.get(object) ?? []; + table.push({ ...data, id }); + this.tables.set(object, table); + this.inserts.push({ object, data }); + return { id }; + } + + async update(object: string, id: string, data: Record): Promise { + const row = (this.tables.get(object) ?? []).find((r) => r.id === id); + if (!row) throw new Error(`update: no ${object} row ${id}`); + Object.assign(row, data); + this.updates.push({ object, id, data }); + } + + async delete(object: string, id: string): Promise { + this.deletes.push({ object, id }); + } + + async find(object: string, query: Record): Promise>> { + const where = query?.where as Record | undefined; + return (this.tables.get(object) ?? []).filter((row) => matches(row, where)).map((row) => ({ ...row })); + } + + rows(object: string): Row[] { + return this.tables.get(object) ?? []; + } +} + +function contextFor(engine: ActionEngineFacade, params: Record): ActionHandlerContext { + return { + record: {}, + params, + user: { id: 'admin_1', organizationId: 'org_1' }, + session: { userId: 'admin_1', organizationId: 'org_1' }, + engine, + }; +} + +/** A 26-item catalog, the size the issue uses as the adoption bar. */ +function seedCatalog(engine: FakeEngine, positionCode: string, count = 26, idPrefix = 'item'): void { + engine.seed( + 'duly_catalog_item', + Array.from({ length: count }, (_, i) => ({ + id: `${idPrefix}_${i + 1}`, + name: `Duty ${i + 1}`, + description: `What done means for duty ${i + 1}`, + position_code: positionCode, + form: 'recurring', + frequency: 'monthly', + due_anchor: 'period_start', + due_offset_days: 5, + lead_days: 7, + grace_days: 0, + active: true, + })), + ); +} + +const THREE_USERS = ['user_a', 'user_b', 'user_c']; + +describe('duly_catalog_apply', () => { + let engine: FakeEngine; + + beforeEach(() => { + engine = new FakeEngine(); + seedCatalog(engine, 'plant_compliance_officer'); + }); + + it('applying a 26-item catalog to 3 users creates 78 duties, all catalog-sourced', async () => { + const result = (await applyCatalogHandler( + contextFor(engine, { position_code: 'plant_compliance_officer', users: THREE_USERS }), + )) as CatalogApplyResult; + + expect(result.created).toBe(78); + expect(result.skipped).toBe(0); + expect(result.catalog_items).toBe(26); + expect(result.users).toBe(3); + + const duties = engine.rows('duly_duty'); + expect(duties).toHaveLength(78); + for (const duty of duties) { + expect(duty.source).toBe('catalog'); + expect(typeof duty.catalog_item).toBe('string'); + expect(duty.catalog_item).toBeTruthy(); + expect(duty.status).toBe('active'); + } + + // Every (item, owner) pair exactly once — 78 rows could still be 26 + // duplicates of three. + const pairs = new Set(duties.map((d) => `${String(d.catalog_item)} ${String(d.owner)}`)); + expect(pairs.size).toBe(78); + }); + + it('copies the catalog item\'s content and cadence onto the duty', async () => { + await applyCatalogHandler( + contextFor(engine, { position_code: 'plant_compliance_officer', users: ['user_a'] }), + ); + + const duty = engine.rows('duly_duty').find((d) => d.catalog_item === 'item_1'); + expect(duty).toBeDefined(); + expect(duty).toMatchObject({ + name: 'Duty 1', + description: 'What done means for duty 1', + form: 'recurring', + frequency: 'monthly', + due_anchor: 'period_start', + due_offset_days: 5, + lead_days: 7, + grace_days: 0, + owner: 'user_a', + source: 'catalog', + status: 'active', + }); + }); + + it('applying the same input again creates 0 and reports 78 skipped', async () => { + const first = (await applyCatalogHandler( + contextFor(engine, { position_code: 'plant_compliance_officer', users: THREE_USERS }), + )) as CatalogApplyResult; + expect(first.created).toBe(78); + + const insertsAfterFirst = engine.inserts.length; + + const second = (await applyCatalogHandler( + contextFor(engine, { position_code: 'plant_compliance_officer', users: THREE_USERS }), + )) as CatalogApplyResult; + + expect(second.created).toBe(0); + expect(second.skipped).toBe(78); + // Counting the report is not enough — assert nothing was written. + expect(engine.inserts.length).toBe(insertsAfterFirst); + expect(engine.rows('duly_duty')).toHaveLength(78); + expect(second.entries.every((e) => e.outcome === 'skipped')).toBe(true); + }); + + it('adding a person to an already-applied position creates only their duties', async () => { + await applyCatalogHandler( + contextFor(engine, { position_code: 'plant_compliance_officer', users: ['user_a', 'user_b'] }), + ); + + const result = (await applyCatalogHandler( + contextFor(engine, { position_code: 'plant_compliance_officer', users: THREE_USERS }), + )) as CatalogApplyResult; + + expect(result.created).toBe(26); + expect(result.skipped).toBe(52); + expect(result.entries.filter((e) => e.outcome === 'created').every((e) => e.owner === 'user_c')).toBe(true); + }); + + it('instantiates only ACTIVE items, and only for the requested position', async () => { + engine.seed('duly_catalog_item', [ + { id: 'item_off', name: 'Retired duty', position_code: 'plant_compliance_officer', form: 'recurring', active: false }, + { id: 'item_other', name: 'Someone else\'s duty', position_code: 'shift_supervisor', form: 'recurring', active: true }, + ]); + + const result = (await applyCatalogHandler( + contextFor(engine, { position_code: 'plant_compliance_officer', users: ['user_a'] }), + )) as CatalogApplyResult; + + expect(result.created).toBe(26); + const items = engine.rows('duly_duty').map((d) => d.catalog_item); + expect(items).not.toContain('item_off'); + expect(items).not.toContain('item_other'); + }); + + it('anchors business_unit on sys_user_position.business_unit_id', async () => { + engine.seed('sys_user_position', [ + { id: 'up_1', user_id: 'user_a', position: 'plant_compliance_officer', business_unit_id: 'bu_north' }, + ]); + + await applyCatalogHandler( + contextFor(engine, { position_code: 'plant_compliance_officer', users: ['user_a'] }), + ); + + for (const duty of engine.rows('duly_duty')) expect(duty.business_unit).toBe('bu_north'); + }); + + it('does NOT require a sys_user_position row — day one, before positions are modelled', async () => { + const result = (await applyCatalogHandler( + contextFor(engine, { position_code: 'plant_compliance_officer', users: ['user_a'] }), + )) as CatalogApplyResult; + + expect(result.created).toBe(26); + // Absent, not null: an unanchored duty must not claim a business unit. + for (const duty of engine.rows('duly_duty')) expect(duty.business_unit).toBeUndefined(); + }); + + it('ignores an unanchored position row rather than writing a null business unit', async () => { + engine.seed('sys_user_position', [ + { id: 'up_1', user_id: 'user_a', position: 'plant_compliance_officer', business_unit_id: null }, + ]); + + await applyCatalogHandler( + contextFor(engine, { position_code: 'plant_compliance_officer', users: ['user_a'] }), + ); + + for (const duty of engine.rows('duly_duty')) expect(duty.business_unit).toBeUndefined(); + }); + + it('refuses a blank position_code or an empty user list instead of reporting a no-op run', async () => { + await expect( + applyCatalogHandler(contextFor(engine, { position_code: ' ', users: THREE_USERS })), + ).rejects.toThrow(/position_code/); + + await expect( + applyCatalogHandler(contextFor(engine, { position_code: 'plant_compliance_officer', users: [] })), + ).rejects.toThrow(/users/); + + expect(engine.inserts).toHaveLength(0); + }); + + it('de-duplicates a repeated user id in one call', async () => { + const result = (await applyCatalogHandler( + contextFor(engine, { position_code: 'plant_compliance_officer', users: ['user_a', 'user_a'] }), + )) as CatalogApplyResult; + + expect(result.users).toBe(1); + expect(result.created).toBe(26); + }); +}); + +describe('duly_catalog_sync', () => { + let engine: FakeEngine; + + async function applyThenEdit(edit: Record): Promise { + await applyCatalogHandler( + contextFor(engine, { position_code: 'plant_compliance_officer', users: THREE_USERS }), + ); + const item = engine.rows('duly_catalog_item').find((i) => i.id === 'item_1'); + Object.assign(item as Row, edit); + } + + beforeEach(() => { + engine = new FakeEngine(); + seedCatalog(engine, 'plant_compliance_officer'); + }); + + it('replays an edited due_offset_days onto every derived duty', async () => { + await applyThenEdit({ due_offset_days: 12 }); + + const result = (await syncCatalogHandler(contextFor(engine, {}))) as CatalogSyncResult; + + expect(result.updated).toBe(3); + expect(result.unchanged).toBe(75); + expect(result.scanned).toBe(78); + + const derived = engine.rows('duly_duty').filter((d) => d.catalog_item === 'item_1'); + expect(derived).toHaveLength(3); + for (const duty of derived) expect(duty.due_offset_days).toBe(12); + }); + + it('leaves owner, status, timezone and the effective window untouched', async () => { + await applyThenEdit({ due_offset_days: 12, frequency: 'quarterly' }); + + // Local decisions made after instantiation: a paused duty, a moved window. + for (const duty of engine.rows('duly_duty')) { + if (duty.catalog_item !== 'item_1') continue; + duty.status = 'paused'; + duty.timezone = 'Europe/Berlin'; + duty.effective_from = '2026-01-01'; + duty.effective_to = '2026-12-31'; + } + + await syncCatalogHandler(contextFor(engine, {})); + + for (const duty of engine.rows('duly_duty')) { + if (duty.catalog_item !== 'item_1') continue; + expect(duty.due_offset_days).toBe(12); + expect(duty.frequency).toBe('quarterly'); + // untouched + expect(duty.status).toBe('paused'); + expect(duty.timezone).toBe('Europe/Berlin'); + expect(duty.effective_from).toBe('2026-01-01'); + expect(duty.effective_to).toBe('2026-12-31'); + expect(THREE_USERS).toContain(duty.owner); + } + + // The patch itself must never name a non-cadence key — asserting the + // written record is not enough, since a patch could rewrite a field to the + // value it already had and look untouched. + for (const update of engine.updates) { + expect(Object.keys(update.data).sort()).toEqual( + Object.keys(update.data).filter((k) => (CADENCE_FIELDS as readonly string[]).includes(k)).sort(), + ); + } + }); + + it('writes every cadence field the catalog owns, and only those', async () => { + await applyThenEdit({ + frequency: 'weekly', + due_anchor: 'period_end', + due_offset_days: -3, + lead_days: 2, + grace_days: 4, + }); + + const result = (await syncCatalogHandler(contextFor(engine, {}))) as CatalogSyncResult; + + expect(result.updated).toBe(3); + for (const change of result.changes) { + expect(Object.keys(change.fields).sort()).toEqual([...CADENCE_FIELDS].sort()); + expect(change.fields.due_offset_days).toEqual({ from: 5, to: -3 }); + } + for (const duty of engine.rows('duly_duty')) { + if (duty.catalog_item !== 'item_1') continue; + expect(duty).toMatchObject({ + frequency: 'weekly', + due_anchor: 'period_end', + due_offset_days: -3, + lead_days: 2, + grace_days: 4, + }); + } + }); + + it('reports each change with a legible from/to — sync is destructive to authored cadence', async () => { + await applyThenEdit({ due_offset_days: 12 }); + + const result = (await syncCatalogHandler(contextFor(engine, {}))) as CatalogSyncResult; + + expect(result.changes).toHaveLength(3); + for (const change of result.changes) { + expect(change.catalog_item).toBe('item_1'); + expect(change.catalog_item_name).toBe('Duty 1'); + expect(THREE_USERS).toContain(change.owner); + expect(change.fields.due_offset_days).toEqual({ from: 5, to: 12 }); + } + }); + + it('a self-declared duty is never touched, even with catalog_item set', async () => { + await applyThenEdit({ due_offset_days: 12 }); + + engine.seed('duly_duty', [ + { + id: 'duty_self', + name: 'My own note to self', + owner: 'user_a', + source: 'self', + catalog_item: 'item_1', + due_offset_days: 0, + frequency: 'daily', + status: 'active', + }, + ]); + + const result = (await syncCatalogHandler(contextFor(engine, {}))) as CatalogSyncResult; + + expect(result.updated).toBe(3); + expect(engine.updates.some((u) => u.id === 'duty_self')).toBe(false); + const self = engine.rows('duly_duty').find((d) => d.id === 'duty_self'); + expect(self?.due_offset_days).toBe(0); + expect(self?.frequency).toBe('daily'); + }); + + it('the source guard is in the code, not only in the query filter', async () => { + // A driver that ignores `where` must not be able to make a self-declared + // duty catalog-writable. The product invariant lives in the handler. + await applyThenEdit({ due_offset_days: 12 }); + engine.seed('duly_duty', [ + { id: 'duty_self', owner: 'user_a', source: 'self', catalog_item: 'item_1', due_offset_days: 0 }, + ]); + + const lenient: ActionEngineFacade = { + insert: (o, d) => engine.insert(o, d), + update: (o, i, d) => engine.update(o, i, d), + delete: (o, i) => engine.delete(o, i), + // Deliberately filter-blind: returns every row whatever the query says. + find: async (o) => engine.rows(o).map((r) => ({ ...r })), + }; + + const result = (await syncCatalogHandler(contextFor(lenient, {}))) as CatalogSyncResult; + + expect(engine.updates.some((u) => u.id === 'duty_self')).toBe(false); + expect(result.changes.some((c) => c.duty === 'duty_self')).toBe(false); + }); + + it('deactivating a catalog item and syncing reports it and changes nothing', async () => { + await applyThenEdit({ active: false, due_offset_days: 12 }); + + const before = engine.rows('duly_duty').map((d) => ({ ...d })); + const result = (await syncCatalogHandler(contextFor(engine, {}))) as CatalogSyncResult; + + expect(result.retired).toHaveLength(3); + expect(result.updated).toBe(0); + for (const entry of result.retired) { + expect(entry.catalog_item).toBe('item_1'); + expect(entry.catalog_item_name).toBe('Duty 1'); + expect(THREE_USERS).toContain(entry.owner); + } + + // Reported, never deleted, never edited. + expect(engine.deletes).toHaveLength(0); + expect(engine.updates).toHaveLength(0); + expect(engine.rows('duly_duty')).toEqual(before); + }); + + it('narrowing by position_code leaves other positions alone', async () => { + await applyThenEdit({ due_offset_days: 12 }); + + seedCatalog(engine, 'shift_supervisor', 2, 'sup'); + const other = engine.rows('duly_catalog_item').filter((i) => i.position_code === 'shift_supervisor'); + await applyCatalogHandler(contextFor(engine, { position_code: 'shift_supervisor', users: ['user_d'] })); + Object.assign(other[0] as Row, { due_offset_days: 99 }); + + const result = (await syncCatalogHandler( + contextFor(engine, { position_code: 'plant_compliance_officer' }), + )) as CatalogSyncResult; + + expect(result.position_code).toBe('plant_compliance_officer'); + expect(result.updated).toBe(3); + // The other position was neither updated nor reported as retired. + const otherDuty = engine.rows('duly_duty').find((d) => d.catalog_item === other[0].id); + expect(otherDuty?.due_offset_days).toBe(5); + expect(result.retired).toHaveLength(0); + expect(result.changes.every((c) => c.catalog_item === 'item_1')).toBe(true); + }); + + it('is idempotent — a second sync with no catalog edit writes nothing', async () => { + await applyThenEdit({ due_offset_days: 12 }); + await syncCatalogHandler(contextFor(engine, {})); + const writesAfterFirst = engine.updates.length; + + const second = (await syncCatalogHandler(contextFor(engine, {}))) as CatalogSyncResult; + + expect(second.updated).toBe(0); + expect(second.unchanged).toBe(78); + expect(engine.updates.length).toBe(writesAfterFirst); + }); +}); + +describe('pairKey — the idempotency key', () => { + // The separator is deliberate and the reason is not obvious, so it is pinned + // rather than left to be "simplified" away. + + it('does not collide across a shifted boundary', () => { + // Plain concatenation makes these the same string, which would make apply + // skip a duty it has never created. + expect(pairKey('ab', 'c')).not.toBe(pairKey('a', 'bc')); + expect(pairKey('item', '1_user')).not.toBe(pairKey('item_1', 'user')); + }); + + it('is stable and distinguishes each component', () => { + expect(pairKey('item_1', 'user_a')).toBe(pairKey('item_1', 'user_a')); + expect(pairKey('item_1', 'user_a')).not.toBe(pairKey('item_1', 'user_b')); + expect(pairKey('item_1', 'user_a')).not.toBe(pairKey('item_2', 'user_a')); + }); + + it('separates with an escaped NUL, which no record id can contain', () => { + // Asserted via the escape, never a raw byte in this file either. + expect(pairKey('a', 'b')).toBe('a' + '\u0000' + 'b'); + expect(pairKey('a', 'b')).toHaveLength(3); + }); +}); + +describe('handler wiring', () => { + // The failure mode with no author-time gate: an action whose handler is not + // registered renders, is clickable, and 404s at call time. `pnpm validate` + // checks the declaration and knows nothing about the registry, so the + // declaration↔handler bijection is asserted here. + + function registered(): Array<{ object: string; action: string; handler: unknown }> { + const calls: Array<{ object: string; action: string; handler: unknown }> = []; + const ql: HandlerRegistrationContext = { + registerAction: (...args: unknown[]) => { + calls.push({ object: String(args[0]), action: String(args[1]), handler: args[2] }); + }, + }; + registerDulyActionHandlers(ql); + return calls; + } + + it('every declared action has a registered handler', () => { + const calls = registered(); + const declared = dulyActions.map((a) => a.name).sort(); + const wired = calls.map((c) => c.action).sort(); + + expect(declared).toEqual([CATALOG_APPLY_ACTION, CATALOG_SYNC_ACTION].sort()); + expect(wired).toEqual(declared); + }); + + it('object-less actions register under the canonical "global" key', () => { + // `executeAction` is an exact-string Map lookup on `:` — a + // handler filed under any other key is unreachable, however the action is + // declared. + for (const call of registered()) { + expect(call.object).toBe(GLOBAL_ACTION_OBJECT); + expect(typeof call.handler).toBe('function'); + } + }); + + it('the declared actions are object-less and headless, matching that key', () => { + for (const action of dulyActions) { + expect(action.objectName).toBeUndefined(); + // `global_nav` was retired in protocol 17 and every surviving location is + // object-bound, so `locations: []` is the only honest declaration here. + expect(action.locations).toEqual([]); + } + }); + + it('each script action names a target, so it cannot 404 for want of a binding', () => { + for (const action of dulyActions) { + expect(action.type).toBe('script'); + expect(action.target).toBe(action.name); + } + }); + + it('duly_catalog_apply declares the position_code + multi-user input the flow needs', () => { + const apply = dulyActions.find((a) => a.name === CATALOG_APPLY_ACTION); + const params = apply?.params ?? []; + + const position = params.find((p) => p.name === 'position_code'); + expect(position?.type).toBe('text'); + expect(position?.required).toBe(true); + + const users = params.find((p) => p.name === 'users'); + expect(users?.type).toBe('user'); + expect(users?.multiple).toBe(true); + expect(users?.required).toBe(true); + }); + + it('duly_catalog_sync scopes by an OPTIONAL position_code', () => { + const sync = dulyActions.find((a) => a.name === CATALOG_SYNC_ACTION); + const position = (sync?.params ?? []).find((p) => p.name === 'position_code'); + expect(position?.type).toBe('text'); + expect(position?.required).toBe(false); + }); +}); + +describe('duty timezone resolution', () => { + it('falls back to UTC — sys_user carries no zone and the org default is not in the action context', () => { + expect(resolveDutyTimezone()).toBe(DEFAULT_DUTY_TIMEZONE); + expect(DEFAULT_DUTY_TIMEZONE).toBe('UTC'); + }); + + it('agrees with duly_duty.timezone\'s own declared default', () => { + // Two answers to one question is the drift this pins: if the field default + // moves, the instantiated duties must move with it. + expect(Duty.fields.timezone.defaultValue).toBe(DEFAULT_DUTY_TIMEZONE); + }); + + it('stamps the resolved zone on every instantiated duty', async () => { + const engine = new FakeEngine(); + seedCatalog(engine, 'plant_compliance_officer', 2); + await applyCatalogHandler( + contextFor(engine, { position_code: 'plant_compliance_officer', users: ['user_a'] }), + ); + for (const duty of engine.rows('duly_duty')) expect(duty.timezone).toBe(DEFAULT_DUTY_TIMEZONE); + }); +});