Skip to content

Commit 1799478

Browse files
authored
feat(flow): automatic actions become OpenRegister flows, and the dead surface goes (#1343)
* refactor(flow)!: move the node ids from procest.* to dossiq.* The app-id rename (#1329) rewrote 1765 files and did not reach this directory, because these fifteen nodes landed after it. So an OpenRegister node catalogue served `procest.action.sendEmail` under an app named Dossiq — the flow-node id is one more channel the rename inventory has to cover, alongside the ones info.xml already documents. BREAKING, deliberately and with the tradeoff named. A node id is a runtime lookup: an OR flow referencing `procest.sendEmail` now resolves to nothing, and a missing node is silent — no error, the step simply has no node. Registering both ids with the old one deprecated was the alternative and was declined: these nodes are days old, field usage is effectively nil, and a permanent alias buys compatibility nobody needs at the price of a catalogue that shows every action twice forever. The class names move with the ids (ProcestActionNode -> DossiqActionNode, and so on for all nineteen files) — a class called Procest* inside namespace OCA\Dossiq is the same half-finished rename read from the other side. BOTH ID SPACES STAY DISTINCT. `dossiq.*` is the live transition vocabulary the dispatcher fires; `dossiq.action.*` is the configured-action catalogue. Both still ship a sendEmail, so SideEffectDispatcherTest::testItResolvesTheLiveIdSpace keeps proving the dispatcher resolves the live space and not the catalogue — crossing them would run a different class with different config keys. Also renamed the personal-settings mount id (`procest-personal-settings` -> `dossiq-`): template and mount call are one pair, no stored data, nothing else reads it. LEFT ALONE, on purpose: `procest.tenant.*` / `procest.parafering.*` audit action keys and the `Open Registers/Procest` register folder are values already written into stored rows and on disk — info.xml calls them frozen and they are. Likewise the historical issue refs, archived @SPEC paths, MigrateUserPreferences' OLD_APP_ID, and `procestermijn`, which is a Dutch word and not the app. LIVE-VERIFIED: GET /api/flow/node-catalog returns 45 nodes, 15 dossiq.*, zero procest.*. Suite 2406 green, phpcs 0 errors, phpmd 0, phpstan [OK]. * feat(flow): automatic actions become OpenRegister flows, and the dead surface goes `automaticAction` objects have never executed. `SideEffectDispatcher` runs the SEPARATE `Service\Transitions` vocabulary keyed on an inline `type`; nothing has ever read these objects at run time. So `/settings/automatic-actions` was an admin surface over a capability with no runtime behind it — the same shape as the AVG page C1 retired. MIGRATION (`occ dossiq:actions:migrate-to-flows --user=<uid> [--dry-run]`). Each action becomes a flow whose single action node is the `dossiq.action.*` node this app already contributes, wrapped in a manual trigger and an end so OpenRegister will actually run it. That is what makes the configuration executable for the first time. IT IS A COMMAND, NOT A REPAIR STEP, AND THAT WAS FORCED. `FlowService` refuses to create a flow without a signed-in owner AND an active organisation — it throws rather than store an orphan that could never be seen, run or edited again. An upgrade runs as nobody, and `runAsSystem()` elevates RBAC while leaving both null, so an upgrade-time migration could only ever have thrown. `--user` is required and has no default: the flows inherit that identity permanently, so a guessed owner would be permanent too. THE FLOWS LAND ENABLED, which the user chose with the consequence stated: these are configured emails, webhooks and document generation that have never fired, and enabling them means they start. `--dry-run` exists for exactly that reason and the description says so. An action type NO node implements is SKIPPED, not wrapped. Writing a flow around an unresolvable node would rebuild the defect the previous commit just fixed in the VTH catalog — stored config naming a handler nothing answers to, reporting success and doing nothing. Idempotency comes from a `dossiq:automaticAction: <tenantId>:<slug>` marker stored on the flow itself, so a re-run rediscovers the link from the flow rather than from a ledger that can be lost; the marker is built from two schema-REQUIRED fields read WITHOUT defaults, because defaulting either would collapse several actions onto one marker and each migration would overwrite the last one's flow. RETIRED: both `/settings/automatic-actions` pages, and `ActionRegistry` + `ActionHandlerLocator` (556 lines, zero consumers — grepped across lib, src, tests and routes). The menu entry becomes a deeplink to `/apps/openregister/#/flows`, hash-routed, which is the trap C1 hit. The six handler classes STAY: the flow nodes are built on them, so they are now live code for the first time rather than dead code. TWO SCHEMA DESCRIPTIONS WERE LYING and are corrected in place rather than deleted, because the lie is the useful part of the history: `automaticAction` claimed its slug was "resolved at dispatch time by ActionRegistry", and `isPublished` claimed "only published actions are dispatched by SideEffectDispatcher". Neither was ever true. A reader who trusted them would conclude the objects worked. Also fixed: the Configuration menu link still pointed at `/settings/admin/procest` after the rename, so it 404'd. Tests (6): the create path asserts trigger+action+end and the decoded config; the skip path proves an unimplemented type writes nothing; the update path proves a re-run touches the existing flow instead of making a second; an unidentifiable action fails rather than sharing a marker; a dry run writes nothing; and one throwing action does not abort the rest. Suite 2412 green, vitest 343 green, phpcs 0 errors, phpmd 0, phpstan [OK], webpack builds. * refactor(besluitvorming)!: retire the agenda compiler — decidiq owns meetings (D1) decidiq owns agenda-building and meetings, and already surfaces them on a dossiq case through the `decidesk-decisions` integration leaf. The two pages here were the second implementation of the same thing. THE BLOCKER WAS REAL AND IS NOW GONE, VERIFIED AT RUNTIME. This retirement was held back because dossiq's leaf showed a "Besluitvorming unavailable" fallback until decidiq shipped its init-script wiring. That wiring is on decidiq `development` and deployed. Checked in the browser on a dossiq page rather than by reading decidiq's source: `OCA.OpenRegister.integrations.list()` returns 28 leaves including `decidesk-decisions` (label "Besluitvorming") carrying tab, widget, widgetCompact, widgetExpanded and widgetEntity surfaces. ⚠️ THE PR NUMBER IN THE BLOCKING TASK WAS WRONG. It named "decidesk PR #100", which in both decidesk and decidiq is "chore(spec): merge p2-minutes-and-decisions-other-t2", merged 2026-04-19 — an unrelated change. A reader checking that reference would have concluded the blocker still stood, or that it had cleared four months ago; neither is what happened. Verifying by CONTENT is the only check that catches a citation like that. REMOVED: `/besluitvorming/agenda` + `/besluitvorming/vergaderingen/:id` and their manifest fragment · AgendaCompilerView, VergaderingDetailView, AgendaItem · their registry entries · AgendaController + AgendaService + its test · the two `agenda#` routes · three API-client functions. The chain is followed all the way down deliberately. Leaving AgendaController behind would leave routes nothing can reach, which is the dead-endpoint shape this programme already retired once on the AVG page; leaving AgendaService would leave a service with no caller and a test that keeps it green. `generateAgenda` was worse than unused: it POSTed to `/agenda/generate`, and no route for that has ever been declared. Every call it made 404'd. Also removed: the `BesluitvormingGroup` menu entry, which had no route, no href and — since nothing in the menu declares a parent — no children either. The renderer drops it silently, so it was invisible rather than broken; it is still config asserting a group that does not exist. KEPT: BesluitPublicatiePanel and `publishBesluit` (DROP/LVBB publication is a real capability with a live route), and `mandaat#mandaatCheck`, whose route serves the server-side guard even though the client function had no caller. Noted, not fixed here: BesluitPublicatiePanel is registered but referenced by no manifest page, and `besluitvorming#activateTemplate` has no frontend caller. Suite 2406 green, vitest 343 green, phpstan [OK], webpack builds. * fix(routes)!: nine endpoints answered HTTP 400 for every request Nextcloud's Dispatcher binds a controller argument BY NAME — `$this->request->getParam($param, $default)` — never by position. Nine routes declared a placeholder whose name no parameter answered to, so the argument resolved to null, the non-nullable string typehint threw a TypeError, and the request came back 400 with an empty body. For any input. On every call. MEASURED AGAINST THE RUNNING INSTANCE, before and after: parafeerRoute#start 400 (empty) -> 500 {"error":"Starten van parafering mislukt"} parafeerRoute#completeStep 400 (empty) -> 500 {"error":"Stap kon niet worden voltooid"} parafeerRoute#skipStep 400 (empty) -> 400 {"error":"Geldig stapnummer is vereist"} parafeerRoute#addStep 400 (empty) -> 500 {"error":"Stap toevoegen mislukt"} voorstelBesluit#registerBesluit 400 (empty) -> 404 {"error":"Voorstel niet toegankelijk"} subsidie#finalizeVaststelling 400 (empty) -> 400 {"error":"Kon vaststelling niet vaststellen"} subsidie#signBeschikking 400 (empty) -> 400 {"error":"Kon beschikking niet ondertekenen"} subsidie#publishBeschikking 400 (empty) -> 400 {"error":"Beschikking niet gevonden"} caseRelation#destroy 400 (empty) -> 403 {"ok":false,"reason":"access_denied"} Every "after" is a DOMAIN answer about the deliberately bogus id I sent. That is the proof: the method body now runs. The before was the framework refusing to call it at all. Cause: the Dutch->English vocabulary sweep renamed the method parameters (`$voorstelId`->`$proposalId`, `$beschikkingId`->`$decisionId`, `$aardRelatie`->`$natureRelationship`) and left the URLs behind. The placeholder name is not decoration; it is the binding key. NOTHING CAUGHT THIS, AND THAT IS THE INTERESTING PART. The route exists. The controller exists. The method exists. gate-6 (route-reachability) verifies exactly those three facts and passes. Only the NAMES disagreed, and no check compared them. A caller saw a bare 400 and would reasonably read it as "my request was malformed". THE GUARD (tests/Unit/AppInfo/RoutePlaceholderBindingTest.php) reflects over every declared route and asserts that each required, non-nullable parameter of its target method is named by a placeholder in the URL. Written for the five I had found — it immediately found FOUR MORE I did not know about, all confirmed dead against the live instance before I touched them. That is the whole argument for writing the check rather than fixing the instances. ALSO FIXED, and only visible once the route worked: `BeschikkingService::publish()` called `find()` OUTSIDE its try block. OpenRegister's find() throws on a missing object rather than returning a non-array, so the throw escaped as an HTML 500 — and the `is_array()` guard below it could never fire, making 'Beschikking niet gevonden' a message no caller could ever receive. `sign()`, ten lines down, has always wrapped its call. Now they match, and that message is reachable. E2E (5 new tests, all passing against a live instance): a retirement is the one change nothing else in the suite can catch — every other spec asserts something renders, so deleting a page leaves them green by not running. tests/e2e/spec-coverage/retired-surfaces.spec.ts asserts BOTH halves: the old view is gone AND its replacement is reachable. Asserting only the first would pass just as happily on a build where the capability vanished. The first version of that spec failed for a reason that had nothing to do with what it tested — a retired route falls through to the app root, so its console-error assertion was grading the DASHBOARD's network traffic, which legitimately 404s against an instance whose register is not seeded. DOCS: docs/user-guide/admin/02-automatic-actions.md described a rule engine with triggers, conditions and a "Last run" column. The schema has no trigger field and no condition field, and nothing ever executed one. It documented a feature that did not exist. Rewritten around the migration command and the flow editor, with the correction stated rather than quietly dropped, and its five stale screenshots removed along with the capture block that produced them. Suite 2407 green, phpcs 0, phpmd 0, phpstan [OK]. * fix(bezwaar): advice requests were written under the name of the schema, not the property `bacAdviceRequest` declares `bezwaar` — a REQUIRED property — whose $ref points at the `objectionProceeding` schema. AdvisoryCommitteeService wrote the objection id under `objectionProceeding`: the name of the SCHEMA, not of the property. So every advice request ever created omits a required property and carries an undeclared one instead. WHAT A USER SAW. Nothing. BezwaarDetail's advice-request stats-blocks and its "Adviesaanvragen" object-list filter on `bezwaar: @objectId` (src/manifest.json:1852, 1871, 1906), so a bezwaar rendered NO advice requests however many it had. An empty list is also exactly what a bezwaar with no advice requests looks like, so there was never anything to notice. No error, no warning, no failing test — the schema, the manifest and the service each held a self-consistent view, and only the service's disagreed. FIXED IN THREE PLACES, because a rename that only moves the write leaves the reads pointing at nothing: - the write now sets `bezwaar` (AdvisoryCommitteeService:178) - both reads go through `objectionIdOf()`, which prefers `bezwaar` and falls back to the legacy key — AND LOGS A WARNING when it has to. A silent fallback would make the repair step look unnecessary: reads would keep working, the manifest filters would keep showing nothing, and the only symptom would stay invisible. Which is how this lasted. - `BackfillAdviceRequestObjection` copies the legacy key onto the declared one for rows already in the field. The backfill reads the legacy key WITHOUT a default. A row carrying neither key is skipped, not written with `bezwaar => ''` — that would satisfy the required property while pointing at nothing, producing a row that looks repaired and is still invisible to the filter that needed it. NOT changed: the `objectionProceeding` key inside the audit-trail payload (:199). That is a free-form label describing the referenced schema, which is accurate, and rewriting it would change the meaning of entries already written. Tests (5): the copy, a correct row left alone, an unrepairable row skipped rather than blanked, idempotency across a re-run, and a no-op when OpenRegister is absent — an upgrade must not fail because a projection could not complete. Found while scoping D2 (bezwaaradviescommissie -> decidiq governance-body); it is independent of that migration and worth fixing on its own. Suite 2412 green, phpcs 0, phpmd 0, phpstan [OK]. * refactor(parafering)!: retire the second sign-off API — the parafeeractie path is the live one dossiq had TWO parafering APIs. `/api/parafeer-actie` is the one the UI calls and the one that works: it records the action, advances the proposal, applies accordering effects and emits the audit event. `/api/parafeer-route/...` was a parallel implementation of the same flow that has never served a single request — its placeholder bound no argument, so every call answered 400 (fixed one commit ago, which is what made the choice visible rather than theoretical). REMOVED (2,627 lines): the four routes, ParafeerRouteController, ParafeerRouteService (663 lines), ParaferingStepActivator, VoorstelRouteMapper, ParaferingApprovalBridge (331 lines), parafeerRouteApi.js, AddStepDialog.vue and two test files that kept the dead code green. EVERY CAPABILITY IS ACCOUNTED FOR, not assumed: start -> the `besluitvormingActivate` transition action, which runs BesluitvormingParafeerService::activate(). Already live. complete-step -> parafeeractie `parafered` / `accorded`. Already live. skip-step -> parafeeractie `skipped`, which is in that endpoint's action enum. SkipStepDialog is RE-POINTED at it rather than deleted, so the "Stap overslaan" button works for the first time. add-step -> no live equivalent. Adding a step mid-route is not in the parafeeractie vocabulary, so the button goes with the API. Stated rather than quietly dropped: this is the one thing that stops being offered — and it never worked either. ADR-022's OpenRegister ApprovalChain delegation goes too, and its own code shows why it had to. `delegateToApprovalWorkflow()` returned early unless `proposal.approvalChainUuid` was set; that property is not declared on the `proposal` schema and was written in exactly one place — inside `ParafeerRouteService::startParafering()`, which was itself unreachable. So the bridge short-circuited on every call, the "legacy in-array path" its comments called a migration window was the only path there has ever been, and no approval event dossiq raised has ever reached OpenRegister. Removing it changes no behaviour; it removes a claim. Verified against the running instance, not just the suite: the retired routes now answer 405 (gone, rather than 400 = bound wrong), and a `skipped` action posted to /api/parafeer-actie reaches the service and comes back with a domain answer about the bogus id I sent it. Suite 2402 green, vitest 343 green, phpcs 0, phpmd 0, phpstan [OK], webpack builds. * fix(quality): the retirement orphaned a parameter, and two files needed formatting CI caught what my local run did not, in the one way that matters: it runs phpmd TWICE, and only the second pass uses `vendor/conduction/hydra-gates/quality-config/phpmd-unusedparams.xml`. I ran the first config alone and read its silence as clean. `ParafeerActieService::propagateDecision()` took `$proposal` solely to hand it to `delegateToApprovalWorkflow()`, which the previous commit removed along with the ApprovalChain bridge. With that gone the parameter had no reader — so the call site was still passing an array nobody looked at. Removed from the signature, the docblock and the call. Also: `prettier --check` covers `{js,ts,vue,css,scss}` and two of my files were unformatted. The three `src/manifest.d/*.json` warnings prettier also reports are pre-existing AND out of the check's scope — JSON is not in that glob — so they are neither caused nor fixed here. Suite 2402 green, both phpmd configs clean, phpstan [OK], `npm run format` clean. * fix(decidiq): the decision integration was dead — both event names moved and nothing said so D4 asked whether dossiq's consumption of decidiq is render-and-read only. It is: the leaf tab issues no write verb, dossiq writes nothing into decidiq's register, and every cross-app command travels as a typed event. The audit passed — and then found that the events themselves had stopped resolving. decidiq renamed its namespace from OCA\Decidesk to OCA\Decidiq WITH NO ALIAS. dossiq named only the old spelling, in two places, with two different failure modes: LISTENER (WorkflowListenerRegistrar) — guarded by class_exists, so it went false and simply stopped registering. Every concluded decision quietly stopped materialising a ZGW Besluit here. Nothing errored, and nothing could: a class_exists guard that goes false looks EXACTLY like the optional app not being installed, which is the case it was written for. DISPATCH (ContractDecisionDelegationService) — fails closed, so it threw "Decision service unavailable: decidesk is not installed" on an instance where it was installed. Every contract decision blocked, behind a message pointing at the wrong problem entirely. PROVEN LIVE before and after, not inferred: before: OCA\Decidesk\Event\DecisionConcludedEvent MISSING OCA\Decidesk\Event\DecisionRequestedEvent MISSING OCA\Decidiq\Event\DecisionConcludedEvent EXISTS OCA\Decidiq\Event\DecisionRequestedEvent EXISTS after: OCA\Decidiq\Event\DecisionConcludedEvent 1 listener (was 0) Both constants now LIST the spellings, newest first, and resolve to the first that exists. That is not politeness toward the old name — it is the only thing that keeps the integration working through an upgrade where the two apps move at different times, which is exactly the window this broke in. An app cannot move another app's class name; it can only follow it. So CrossAppEventNamesTest asserts the property worth holding: each constant is a LIST, names the current namespace, still names the old one, and prefers the current one. Pinning a single spelling fails the test now instead of silently disabling a feature later. NOT changed, deliberately: `sourceApp => 'procest'` and DecisionConcludedListener::SOURCE_APP. That value is this app's id AS THE OTHER APP KNOWS IT — decidiq echoes back what dossiq sends — so it moves only when both sides move together. Its own comment already says so. Suite 2406 green, phpcs 0, both phpmd configs 0, phpstan [OK], format clean. The phpstan ignore is scoped to the one resolver method: neither namespace is on this app's analysis path (that is the point of resolving them as strings), so class_exists can never be proven true and the method reads as returning only null. Same family as the ApprovalStepNotificationListener entries above it. * test(decidiq): stub BOTH event namespaces so the resilient lookup analyses as real CI's phpstan rejected the previous commit with an error my container never produced: `class_exists() with '\OCA\Decidesk\Event…'|'\OCA\Decidiq\Event…' will always evaluate to false` (function.impossibleType). Neither namespace was on the analysis path, so the analyser proved the lookup dead — reporting a fix for a real outage as dead code. The root cause is the same either way: only the OLD namespace was ever stubbed. This adds `tests/Stubs/Decidiq/Event/*` alongside the existing Decidesk pair and scans both in phpstan.neon, so both spellings resolve and the resolution reads as what it is. That is better than an ignore, which would have silenced the symptom while leaving the analyser blind to the contract. The earlier `resolveRequestEventClass() never returns string` ignore is REMOVED, not kept alongside: with both classes resolvable, class_exists can return true and the method demonstrably returns a string. Two errors, one cause. The bootstrap now loops the two namespaces × two events rather than repeating a four-line block, and stubs each only when the real class is absent — unchanged behaviour, one place to add the next spelling. THREE TESTS PINNED THE OLD LITERAL and started failing the moment both stubs loaded: they imported `OCA\Decidesk\Event\*` and asserted the dispatched object was an instance of it, while the resolver now prefers the current spelling. That is the same defect as the production code had, one layer down — a test that names a class the code no longer builds. Pointed at the current namespace, with a comment saying why and pointing at CrossAppEventNamesTest, which guards the ordering they depend on. ⚠️ Local phpstan passed on the previous commit and CI did not. The two disagree about which error fires, so `[OK] No errors` in the container is not proof the gate passes. The fix addresses the cause both report. Suite 2406 green, phpcs 0, both phpmd configs 0, phpstan [OK], format clean. * fix(ci): @SPEC on the new repair-step methods, and an e2e test that told the wrong story Two CI failures, both mine. GATE-16 (spec-coverage): `BackfillAdviceRequestObjection::run()` and `::getName()` are new PUBLIC methods and carried no `@spec`. The class had one; the gate is per-method. Added. E2E: `retired-surfaces.spec.ts` asserted the decision app registers its `decidesk-decisions` leaf, and returned `null` for TWO different things — the OpenRegister integration registry being absent, and the leaf being missing from a registry that is present. Every null was then reported as "OpenRegister integration registry not present". So the message was wrong in both directions. dossiq's CI does not install the decision app, so the registry is legitimately absent — an environment fact this PR cannot affect — and it was reported as a failure. And had the leaf actually gone missing with the registry present, that REAL finding would have been described as an environment problem and dismissed. A lookup failure must not wear the same words as a judgement. The probe now returns three distinguishable outcomes: registry absent -> test.skip() naming the reason. Not a defect here. registry present, leaf missing -> a real failure, and the message now LISTS what the registry actually holds instead of guessing. leaf present -> asserted as before. Suite 2406 green, phpcs 0, phpstan [OK], format clean.
1 parent 8550cd8 commit 1799478

97 files changed

Lines changed: 4753 additions & 6634 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

appinfo/info.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,15 @@ Vrij en open source onder de EUPL-1.2-licentie.
314314
install has no history to move.
315315
-->
316316
<step>OCA\Dossiq\Repair\MigrateAiOversightToHermiq</step>
317+
<!--
318+
bacAdviceRequest carried the objection id under the name of the
319+
SCHEMA it $refs (`objectionProceeding`) instead of the property
320+
the schema declares (`bezwaar`) — so a required property was
321+
never written, and BezwaarDetail's advice-request widgets, which
322+
filter on `bezwaar`, showed nothing at all. Idempotent (rows that
323+
already have `bezwaar` are skipped) and non-fatal.
324+
-->
325+
<step>OCA\Dossiq\Repair\BackfillAdviceRequestObjection</step>
317326
</post-migration>
318327
<!--
319328
FRESH INSTALL. Nextcloud does NOT run post-migration on a first install:
@@ -401,6 +410,7 @@ Vrij en open source onder de EUPL-1.2-licentie.
401410

402411
<commands>
403412
<command>OCA\Dossiq\Command\BackfillLegalHoldsCommand</command>
413+
<command>OCA\Dossiq\Command\MigrateAutomaticActionsToFlowsCommand</command>
404414
<command>OCA\Dossiq\Command\MigrateTenantsCommand</command>
405415
<command>OCA\Dossiq\Command\SeedBezwaarBeroepCommand</command>
406416
</commands>

appinfo/routes.php

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,13 @@
105105
['name' => 'subsidie#create', 'url' => '/api/subsidies', 'verb' => 'POST'],
106106
['name' => 'subsidie#createTussenrapportage', 'url' => '/api/subsidies/uitvoeringen/{uitvoeringId}/tussenrapportages', 'verb' => 'POST'],
107107
['name' => 'subsidie#approveTussenrapportage', 'url' => '/api/subsidies/tussenrapportages/{reportId}/beoordelen', 'verb' => 'POST'],
108-
['name' => 'subsidie#finalizeVaststelling', 'url' => '/api/subsidies/vaststellingen/{vaststellingId}/vast', 'verb' => 'POST'],
109-
['name' => 'subsidie#signBeschikking', 'url' => '/api/subsidies/beschikkingen/{beschikkingId}/sign', 'verb' => 'POST'],
110-
['name' => 'subsidie#publishBeschikking', 'url' => '/api/subsidies/beschikkingen/{beschikkingId}/publish', 'verb' => 'POST'],
108+
// The placeholder names bind BY NAME to the method parameters — see the
109+
// note on the parafeer-route block below. These three were
110+
// {vaststellingId}/{beschikkingId} against $determinationId/$decisionId
111+
// and answered HTTP 400 on every call.
112+
['name' => 'subsidie#finalizeVaststelling', 'url' => '/api/subsidies/vaststellingen/{determinationId}/vast', 'verb' => 'POST'],
113+
['name' => 'subsidie#signBeschikking', 'url' => '/api/subsidies/beschikkingen/{decisionId}/sign', 'verb' => 'POST'],
114+
['name' => 'subsidie#publishBeschikking', 'url' => '/api/subsidies/beschikkingen/{decisionId}/publish', 'verb' => 'POST'],
111115
['name' => 'subsidie#transition', 'url' => '/api/subsidies/{id}/transition', 'verb' => 'POST'],
112116
['name' => 'subsidie#createBeschikking', 'url' => '/api/subsidies/{id}/beschikking', 'verb' => 'POST'],
113117

@@ -258,15 +262,32 @@
258262
// ── Parafeerroute (B&W parafering engine) ───────────────────────
259263
// CRUD on parafeerroute objects is served by OpenRegister's auto-exposed
260264
// /api/objects/<register>/<schema> endpoints — only engine routes remain.
261-
['name' => 'parafeerRoute#start', 'url' => '/api/parafeer-route/voorstel/{voorstelId}/start', 'verb' => 'POST'],
262-
['name' => 'parafeerRoute#completeStep', 'url' => '/api/parafeer-route/voorstel/{voorstelId}/complete-step', 'verb' => 'POST'],
263-
['name' => 'parafeerRoute#skipStep', 'url' => '/api/parafeer-route/voorstel/{voorstelId}/skip-step', 'verb' => 'POST'],
264-
['name' => 'parafeerRoute#addStep', 'url' => '/api/parafeer-route/voorstel/{voorstelId}/add-step', 'verb' => 'POST'],
265+
// ⚠️ THE PLACEHOLDER NAME IS LOAD-BEARING. Nextcloud's Dispatcher binds a
266+
// controller argument by PARAMETER NAME (`$this->request->getParam($param)`),
267+
// not by position. These four were `{voorstelId}` while every target method
268+
// signs `string $proposalId` — so the argument resolved to null, the string
269+
// typehint threw a TypeError, and the Dispatcher answered HTTP 400. Measured
270+
// against the running instance before the fix: start, complete-step and
271+
// register-besluit all returned 400 with an empty body, on every call, for
272+
// any input.
273+
//
274+
// The Dutch→English vocabulary sweep renamed the method parameters and left
275+
// the URLs behind. Nothing caught it: the route exists, the method exists,
276+
// and gate-6 (route-reachability) checks that a route's target method is
277+
// present — which it is. Only the NAMES disagree.
278+
// The four parafeer-route engine routes were RETIRED. They were a second
279+
// implementation of a flow /api/parafeer-actie already runs, and they had
280+
// never served a request: the placeholder bound no argument, so every call
281+
// answered 400. `start` is covered by the `besluitvormingActivate`
282+
// transition action, `complete-step` and `skip-step` by parafeeractie's
283+
// own `parafered`/`accorded`/`skipped` vocabulary, and `add-step` has no
284+
// live equivalent — that button went with them.
265285

266286
// Voorstel → besluit registration delegates to a decidesk report-adoption
267287
// Decision (dossiq-delegate-remaining-decisions-to-decidesk, ADR-019).
268288
// The parafeerroute above is untouched; only the besluit decision moves.
269-
['name' => 'voorstelBesluit#registerBesluit', 'url' => '/api/voorstellen/{voorstelId}/register-besluit', 'verb' => 'POST'],
289+
// Same placeholder-name defect as the four routes above — see that note.
290+
['name' => 'voorstelBesluit#registerBesluit', 'url' => '/api/voorstellen/{proposalId}/register-besluit', 'verb' => 'POST'],
270291

271292
// NOTE: ParaferingController + ParaferingService were superseded scaffolding
272293
// that operated entirely in-memory (no persistence, client-supplied state).
@@ -324,7 +345,8 @@
324345
// Related-case linking — typed peer relations (relevanteAndereZaken).
325346
['name' => 'caseRelation#list', 'url' => '/api/cases/{caseId}/relations', 'verb' => 'GET'],
326347
['name' => 'caseRelation#create', 'url' => '/api/cases/{caseId}/relations', 'verb' => 'POST'],
327-
['name' => 'caseRelation#destroy', 'url' => '/api/cases/{caseId}/relations/{targetId}/{aardRelatie}', 'verb' => 'DELETE'],
348+
// {aardRelatie} bound nothing: the method signs $natureRelationship. HTTP 400.
349+
['name' => 'caseRelation#destroy', 'url' => '/api/cases/{caseId}/relations/{targetId}/{natureRelationship}', 'verb' => 'DELETE'],
328350
// Dashboard KPI aggregation endpoint.
329351
['name' => 'kpi#index', 'url' => '/api/dashboard/kpis', 'verb' => 'GET'],
330352

@@ -577,8 +599,11 @@
577599

578600
// ── Besluitvorming workflow ──────────────────────────────────────
579601
['name' => 'besluitvorming#activateTemplate', 'url' => '/api/besluitvorming/templates/{slug}/activate', 'verb' => 'POST'],
580-
['name' => 'agenda#addToAgenda', 'url' => '/api/besluitvorming/cases/{id}/agenda', 'verb' => 'POST'],
581-
['name' => 'agenda#updateAgendaItem', 'url' => '/api/besluitvorming/cases/{id}/agenda', 'verb' => 'PUT'],
602+
// The two `agenda#` routes were removed with the agenda compiler: decidiq
603+
// owns agenda-building and meetings, and it surfaces them here through the
604+
// `decidesk-decisions` integration leaf. They served only the compiler view
605+
// and became unreachable the moment it went, which is the dead-endpoint
606+
// shape this programme already retired once on the AVG page.
582607
['name' => 'publication#publish', 'url' => '/api/besluitvorming/cases/{id}/publish', 'verb' => 'POST'],
583608
['name' => 'mandaat#mandaatCheck', 'url' => '/api/besluitvorming/cases/{id}/mandaat-check', 'verb' => 'GET'],
584609

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Lines changed: 58 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,93 @@
11
---
22
sidebar_position: 2
33
title: Set up automatic actions
4-
description: "Create rules that fire on case events: create tasks, send notifications, transition status, kick off integrations."
4+
description: "Automatic actions are OpenRegister flows. Migrate the old records, then build and run actions in the flow editor."
55
---
66

77
# Set up automatic actions
88

9-
Automatische acties (automatic actions) are the rule engine that turns case events into tasks, notifications, and follow-up transitions. A rule fires on a *trigger* (a status change, a deadline crossing, a document upload), evaluates a *condition* (always, or only when a field matches), and runs an *action* (create a task, send an email, transition the case, post to an integration).
9+
Automatic actions are **OpenRegister flows**. Dossiq contributes the action nodes — send an email, notify a role, call a webhook, create a document, merge a template, schedule a reminder — and OpenRegister's flow engine runs them.
10+
11+
:::warning What changed, and why this page was wrong
12+
13+
Dossiq used to have its own **Automatische acties** settings page. It has been retired.
14+
15+
An earlier version of this page described a rule engine with *triggers*, *conditions* and a *Last run* column. **None of that existed.** The stored record had no trigger field and no condition field, and nothing in the application ever executed one — the code that fires actions on a status change reads a different, separate definition that lives on the case type. A rule created through that page was saved and then never ran.
16+
17+
If you configured actions there, nothing was lost: the records are still stored, and the migration below turns each into a flow that does run.
18+
:::
1019

1120
## Goal
1221

13-
By the end you will have created an automatic action that fires on a specific status transition and creates a task assigned to a role on the case.
22+
By the end you will have migrated any existing automatic actions to flows, and know where to build new ones.
1423

1524
## Prerequisites
1625

17-
- Administrator role on the Nextcloud instance.
18-
- At least one case type configured (see [Configure case types and workflows](./01-configure-case-types.md)).
19-
- A clear idea of which trigger / condition / action you want to chain.
26+
- Administrator role on the Nextcloud instance, and shell access for the migration command.
27+
- OpenRegister installed (Dossiq requires it).
28+
- The user you migrate as must belong to an organisation — a flow takes its owner and organisation from that user, permanently.
29+
30+
## Migrate existing automatic actions
2031

21-
## Steps
32+
Run a dry run first. It writes nothing and shows exactly what would be created:
2233

23-
1. From the Dossiq navigation, click **Automatische acties** (in the configuration block). The list opens with the standard Cards/Table toggle and an **Add Item** button.
34+
```bash
35+
occ dossiq:actions:migrate-to-flows --user=<uid> --dry-run
36+
```
2437

25-
![Automatic actions list](/screenshots/tutorials/admin/02-automatic-actions-01.png)
38+
```
39+
dossiq:actions:migrate-to-flows (dry run — nothing was written)
40+
total = 1
41+
created = 1
42+
updated = 0
43+
skipped = 0
44+
failed = 0
45+
[created] dossiq:automaticAction:<tenant>:<slug> — dry run — no write
46+
```
2647

27-
2. Click **Add Item**. The rule dialog opens with four sections: *Algemeen* (name, description, active toggle), *Trigger*, *Condition*, *Action*.
48+
Then run it for real:
2849

29-
![New automatic action dialog](/screenshots/tutorials/admin/02-automatic-actions-02.png)
50+
```bash
51+
occ dossiq:actions:migrate-to-flows --user=<uid>
52+
```
3053

31-
3. Set the **Trigger**. Pick *Status change* and select the case-type plus the source and target statuses. Other triggers include *Deadline approaching*, *Document uploaded*, *Case created*, *Case closed*.
54+
The command is safe to re-run: the second run reports `updated` rather than creating a duplicate.
3255

33-
![Configuring the trigger](/screenshots/tutorials/admin/02-automatic-actions-03.png)
56+
:::caution The migrated flows are enabled
57+
These actions have never fired before. Migrating them makes them runnable, so review each one in the flow editor before triggering it — particularly anything that sends email to an address outside your organisation. Each migrated flow uses a **manual** trigger, so it runs only when someone runs it; it will not start firing on its own.
58+
:::
3459

35-
4. Set the **Condition** (optional). Leave blank for "always fire" or add a field match (e.g. *Confidentiality = Public*) so the rule only fires on matching cases. Conditions support AND/OR groups.
60+
Reading the summary:
3661

37-
![Conditions](/screenshots/tutorials/admin/02-automatic-actions-04.png)
62+
| Outcome | Meaning |
63+
|---|---|
64+
| `created` | A new flow was made for this action. |
65+
| `updated` | The flow this command made earlier was refreshed. |
66+
| `skipped` | No node implements that action's type, so no flow was written. A flow around a node that does not exist would report success and do nothing. |
67+
| `failed` | The record is missing its tenant or slug and cannot be identified. Fix the record and re-run. |
3868

39-
5. Set the **Action**. Pick *Create task*, fill the task title, description, assignee role, and due-date offset. Save the rule with **Save**. Confirm the rule is *Active* in the list: only active rules fire.
69+
## Build and run actions
4070

41-
![Action and save](/screenshots/tutorials/admin/02-automatic-actions-05.png)
71+
1. From the Dossiq navigation, open **Automatische acties** in the configuration block. It takes you to OpenRegister's **Flows** page.
72+
2. Use **New flow**, or open a migrated flow to review it.
73+
3. A runnable flow needs an entry and an exit: a trigger node, your action node(s), and an end node, wired with edges. The migration builds exactly that shape.
74+
4. Dossiq's action nodes appear in the node catalogue under `dossiq.action.*`.
4275

4376
## Verification
4477

45-
You have set up the action correctly when: triggering the configured status transition on a test case produces the new task in the case's **Tasks** sidebar tab, the task is assigned to the configured role, and the rule's *Last run* column in the **Automatische acties** list updates to the most recent fire time.
78+
You have it working when the flow appears on the **Flows** page with app `dossiq`, and running it produces the effect you configured — the email arrives, the webhook is called, the document is generated.
4679

4780
## Common issues
4881

4982
| Symptom | Fix |
5083
|---|---|
51-
| Rule is configured but never fires | The rule's *Active* toggle is off, or the trigger case-type does not match the case-type you tested on. |
52-
| Task is created but unassigned | The configured assignee role is not present on the case; add it to the case's Participants or update the rule to use a role that exists. |
53-
| Action runs but no notification arrives | The notification action uses Nextcloud's mail; SMTP must be configured at the server level (the dev environment intentionally disables outgoing mail: see [PROJECT MEMORY notes on mail](../../#mail-notifications)). |
54-
| Condition does not match what you expect | Open the rule's *Last run* details: the recorded field values clarify why the condition skipped. |
84+
| `occ dossiq:actions:migrate-to-flows` says `--user is required` | It has no default on purpose: the created flows inherit that user's identity and organisation permanently. Pass a real uid. |
85+
| The command reports `OpenRegister exposes no FlowService on this instance` | OpenRegister is missing or too old. Flows live in OpenRegister; Dossiq only contributes nodes. |
86+
| An action was `skipped` | Its `type` is not one Dossiq implements a node for. The six are `sendEmail`, `notifyRole`, `callWebhook`, `createDocument`, `mergeTemplate` and `scheduleReminder`. |
87+
| A flow saves but will not run | Check it has both a trigger node and an end node. OpenRegister reports a flow with neither as not runnable. |
88+
| Actions attached to a status transition are not on this page | Those are a different mechanism: they live on the case type's workflow, not here. See [Configure case types and workflows](./01-configure-case-types.md). |
5589

5690
## Reference
5791

58-
- [Configure case types and workflows](./01-configure-case-types.md): defines the triggers (statuses, deadlines).
59-
- [Case management](../../Features/case-management.md): the underlying event stream rules listen to.
92+
- [Configure case types and workflows](./01-configure-case-types.md): status-transition actions, which are configured on the case type.
93+
- [Case management](../../Features/case-management.md): the case lifecycle these actions hang off.

lib/AppInfo/Registrar/ListenerRegistrar.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public function register(IRegistrationContext $context): void {
6060
(new BezwaarListenerRegistrar())->register(context: $context);
6161
(new WorkflowListenerRegistrar())->register(context: $context);
6262

63-
// ADR-065: OpenRegister owns the flow engine; procest contributes the six
63+
// ADR-065: OpenRegister owns the flow engine; dossiq contributes the six
6464
// things a case can DO, because every one of OpenRegister's own nineteen
6565
// nodes is control-flow or data and none of them acts outward.
6666
//
@@ -71,7 +71,7 @@ public function register(IRegistrationContext $context): void {
7171
if (class_exists(\OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent::class) === true) {
7272
$context->registerEventListener(
7373
\OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent::class,
74-
\OCA\Dossiq\Flow\ProcestFlowNodeListener::class
74+
\OCA\Dossiq\Flow\DossiqFlowNodeListener::class
7575
);
7676
}
7777
}//end register()

lib/AppInfo/Registrar/WorkflowListenerRegistrar.php

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,22 @@
4141
* @spec openspec/changes/termijnbewaking-dwangsom-engine-02-termijn-binding-lifecycle/tasks.md
4242
*/
4343
class WorkflowListenerRegistrar {
44+
/**
45+
* Every spelling of the decision-concluded event, newest first.
46+
*
47+
* A cross-app event class name is a RUNTIME lookup: this app cannot move it,
48+
* only follow it. When the other app renamed its namespace without an alias,
49+
* naming one spelling meant the listener silently stopped registering — so
50+
* both are listed, and the listener attaches to whichever exists. The old
51+
* entry can be dropped once no supported install still ships it.
52+
*
53+
* @var array<int, string>
54+
*/
55+
private const DECISION_CONCLUDED_EVENTS = [
56+
'OCA\Decidiq\Event\DecisionConcludedEvent',
57+
'OCA\Decidesk\Event\DecisionConcludedEvent',
58+
];
59+
4460
/**
4561
* Register the termijn and decision listeners.
4662
*
@@ -94,16 +110,26 @@ private function registerTermListeners(IRegistrationContext $context): void {
94110
* @spec openspec/changes/dossiq-delegation-via-events/specs/contract-decision-delegation/spec.md#requirement-req-pdcd-003-the-zgw-besluit-is-materialised-from-the-decisionconcludedevent
95111
*/
96112
private function registerDecisionListeners(IRegistrationContext $context): void {
97-
if (class_exists('\\OCA\\Decidesk\\Event\\DecisionConcludedEvent') === false) {
98-
return;
99-
}
100-
101-
// FQN string (not ::class) so there is no hard compile-time dependency
102-
// on the optional decidesk app — mirrors the OpenRegister approval-event
113+
// BOTH spellings, and the old one is not optional politeness — it is the
114+
// only thing that keeps this integration working during an upgrade where
115+
// the two apps move at different times.
116+
//
117+
// The app renamed its namespace from OCA\Decidesk to OCA\Decidiq with no
118+
// compatibility alias. This guard named only the OLD class, so from the
119+
// moment that landed it returned false, the listener was never
120+
// registered, and every concluded decision stopped materialising a ZGW
121+
// Besluit here. Nothing errored: a class_exists() guard that goes false
122+
// looks exactly like the optional app not being installed.
123+
//
124+
// FQN strings (not ::class) so there is no hard compile-time dependency
125+
// on the optional app — mirrors the OpenRegister approval-event
103126
// registration in BezwaarListenerRegistrar.
104-
$context->registerEventListener(
105-
event: 'OCA\Decidesk\Event\DecisionConcludedEvent',
106-
listener: DecisionConcludedListener::class
107-
);
127+
foreach (self::DECISION_CONCLUDED_EVENTS as $event) {
128+
if (class_exists('\\' . $event) === false) {
129+
continue;
130+
}
131+
132+
$context->registerEventListener(event: $event, listener: DecisionConcludedListener::class);
133+
}
108134
}//end registerDecisionListeners()
109135
}//end class

0 commit comments

Comments
 (0)