From 20e35bb425e174291a416f94dc5919b2b29921ee Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 24 Aug 2026 07:35:07 +0200 Subject: [PATCH 01/10] refactor(flow)!: move the node ids from procest.* to dossiq.* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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]. --- lib/AppInfo/Registrar/ListenerRegistrar.php | 4 +- ...estActionNode.php => DossiqActionNode.php} | 6 +- ...hookNode.php => DossiqCallWebhookNode.php} | 4 +- ...tNode.php => DossiqCreateDocumentNode.php} | 4 +- ...lowNodeBase.php => DossiqFlowNodeBase.php} | 22 ++--- ...istener.php => DossiqFlowNodeListener.php} | 46 +++++------ ...teNode.php => DossiqMergeTemplateNode.php} | 4 +- ...yRoleNode.php => DossiqNotifyRoleNode.php} | 4 +- ...ode.php => DossiqScheduleReminderNode.php} | 4 +- ...dEmailNode.php => DossiqSendEmailNode.php} | 4 +- ...itionNode.php => DossiqTransitionNode.php} | 4 +- ...=> DossiqTxBesluitvormingActivateNode.php} | 6 +- ... => DossiqTxBesluitvormingPublishNode.php} | 6 +- ...Node.php => DossiqTxCreateSubCaseNode.php} | 6 +- ...askNode.php => DossiqTxCreateTaskNode.php} | 6 +- ...e.php => DossiqTxEvaluateDecisionNode.php} | 6 +- ...xNotifyNode.php => DossiqTxNotifyNode.php} | 6 +- ...mailNode.php => DossiqTxSendEmailNode.php} | 6 +- ...FieldNode.php => DossiqTxSetFieldNode.php} | 6 +- ...ebhookNode.php => DossiqTxWebhookNode.php} | 6 +- .../Transitions/SideEffectDispatcher.php | 8 +- src/personalSettings.js | 2 +- templates/settings/personal.php | 2 +- tests/Stubs/Flow/IFlowNode.php | 4 +- tests/Stubs/Flow/RegisterFlowNodesEvent.php | 4 +- ...nNodeTest.php => DossiqActionNodeTest.php} | 16 ++-- ...est.php => DossiqFlowNodeListenerTest.php} | 80 +++++++++---------- .../Transitions/SideEffectDispatcherTest.php | 10 +-- 28 files changed, 143 insertions(+), 143 deletions(-) rename lib/Flow/{ProcestActionNode.php => DossiqActionNode.php} (84%) rename lib/Flow/{ProcestCallWebhookNode.php => DossiqCallWebhookNode.php} (95%) rename lib/Flow/{ProcestCreateDocumentNode.php => DossiqCreateDocumentNode.php} (95%) rename lib/Flow/{ProcestFlowNodeBase.php => DossiqFlowNodeBase.php} (90%) rename lib/Flow/{ProcestFlowNodeListener.php => DossiqFlowNodeListener.php} (78%) rename lib/Flow/{ProcestMergeTemplateNode.php => DossiqMergeTemplateNode.php} (95%) rename lib/Flow/{ProcestNotifyRoleNode.php => DossiqNotifyRoleNode.php} (95%) rename lib/Flow/{ProcestScheduleReminderNode.php => DossiqScheduleReminderNode.php} (95%) rename lib/Flow/{ProcestSendEmailNode.php => DossiqSendEmailNode.php} (95%) rename lib/Flow/{ProcestTransitionNode.php => DossiqTransitionNode.php} (85%) rename lib/Flow/{ProcestTxBesluitvormingActivateNode.php => DossiqTxBesluitvormingActivateNode.php} (93%) rename lib/Flow/{ProcestTxBesluitvormingPublishNode.php => DossiqTxBesluitvormingPublishNode.php} (93%) rename lib/Flow/{ProcestTxCreateSubCaseNode.php => DossiqTxCreateSubCaseNode.php} (93%) rename lib/Flow/{ProcestTxCreateTaskNode.php => DossiqTxCreateTaskNode.php} (94%) rename lib/Flow/{ProcestTxEvaluateDecisionNode.php => DossiqTxEvaluateDecisionNode.php} (93%) rename lib/Flow/{ProcestTxNotifyNode.php => DossiqTxNotifyNode.php} (94%) rename lib/Flow/{ProcestTxSendEmailNode.php => DossiqTxSendEmailNode.php} (94%) rename lib/Flow/{ProcestTxSetFieldNode.php => DossiqTxSetFieldNode.php} (94%) rename lib/Flow/{ProcestTxWebhookNode.php => DossiqTxWebhookNode.php} (94%) rename tests/Unit/Flow/{ProcestActionNodeTest.php => DossiqActionNodeTest.php} (93%) rename tests/Unit/Flow/{ProcestFlowNodeListenerTest.php => DossiqFlowNodeListenerTest.php} (63%) diff --git a/lib/AppInfo/Registrar/ListenerRegistrar.php b/lib/AppInfo/Registrar/ListenerRegistrar.php index dfb9aa507..ce1db70dd 100644 --- a/lib/AppInfo/Registrar/ListenerRegistrar.php +++ b/lib/AppInfo/Registrar/ListenerRegistrar.php @@ -60,7 +60,7 @@ public function register(IRegistrationContext $context): void { (new BezwaarListenerRegistrar())->register(context: $context); (new WorkflowListenerRegistrar())->register(context: $context); - // ADR-065: OpenRegister owns the flow engine; procest contributes the six + // ADR-065: OpenRegister owns the flow engine; dossiq contributes the six // things a case can DO, because every one of OpenRegister's own nineteen // nodes is control-flow or data and none of them acts outward. // @@ -71,7 +71,7 @@ public function register(IRegistrationContext $context): void { if (class_exists(\OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent::class) === true) { $context->registerEventListener( \OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent::class, - \OCA\Dossiq\Flow\ProcestFlowNodeListener::class + \OCA\Dossiq\Flow\DossiqFlowNodeListener::class ); } }//end register() diff --git a/lib/Flow/ProcestActionNode.php b/lib/Flow/DossiqActionNode.php similarity index 84% rename from lib/Flow/ProcestActionNode.php rename to lib/Flow/DossiqActionNode.php index ea1eb9dc7..c4cc28a6a 100644 --- a/lib/Flow/ProcestActionNode.php +++ b/lib/Flow/DossiqActionNode.php @@ -20,13 +20,13 @@ * Base for the CONFIGURED-ACTION catalogue nodes. * * `lib/Service/Actions/` — the six reusable actions administered as - * `automaticAction` objects. They take the `procest.action.*` id space, because + * `automaticAction` objects. They take the `dossiq.action.*` id space, because * the live transition vocabulary owns the plain names and both systems ship a * `sendEmail`. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -abstract class ProcestActionNode extends ProcestFlowNodeBase { +abstract class DossiqActionNode extends DossiqFlowNodeBase { /** @@ -40,7 +40,7 @@ abstract class ProcestActionNode extends ProcestFlowNodeBase { * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ protected function nodeId(): string { - return 'procest.action.' . $this->handler()->type(); + return 'dossiq.action.' . $this->handler()->type(); }//end nodeId() diff --git a/lib/Flow/ProcestCallWebhookNode.php b/lib/Flow/DossiqCallWebhookNode.php similarity index 95% rename from lib/Flow/ProcestCallWebhookNode.php rename to lib/Flow/DossiqCallWebhookNode.php index d70da6c10..d0bf7675e 100644 --- a/lib/Flow/ProcestCallWebhookNode.php +++ b/lib/Flow/DossiqCallWebhookNode.php @@ -26,12 +26,12 @@ * Flow node for the `callWebhook` action. * * A thin wrapper: CallWebhookHandler keeps the logic, this presents it to - * OpenRegister's engine. See ProcestActionNode for why these are contributed + * OpenRegister's engine. See DossiqActionNode for why these are contributed * nodes rather than a mapping onto OpenRegister's own. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestCallWebhookNode extends ProcestActionNode { +class DossiqCallWebhookNode extends DossiqActionNode { /** diff --git a/lib/Flow/ProcestCreateDocumentNode.php b/lib/Flow/DossiqCreateDocumentNode.php similarity index 95% rename from lib/Flow/ProcestCreateDocumentNode.php rename to lib/Flow/DossiqCreateDocumentNode.php index 7e9428c61..d794995d3 100644 --- a/lib/Flow/ProcestCreateDocumentNode.php +++ b/lib/Flow/DossiqCreateDocumentNode.php @@ -26,12 +26,12 @@ * Flow node for the `createDocument` action. * * A thin wrapper: CreateDocumentHandler keeps the logic, this presents it to - * OpenRegister's engine. See ProcestActionNode for why these are contributed + * OpenRegister's engine. See DossiqActionNode for why these are contributed * nodes rather than a mapping onto OpenRegister's own. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestCreateDocumentNode extends ProcestActionNode { +class DossiqCreateDocumentNode extends DossiqActionNode { /** diff --git a/lib/Flow/ProcestFlowNodeBase.php b/lib/Flow/DossiqFlowNodeBase.php similarity index 90% rename from lib/Flow/ProcestFlowNodeBase.php rename to lib/Flow/DossiqFlowNodeBase.php index 540446025..bd4d73659 100644 --- a/lib/Flow/ProcestFlowNodeBase.php +++ b/lib/Flow/DossiqFlowNodeBase.php @@ -25,11 +25,11 @@ use UnexpectedValueException; /** - * Presents one procest action handler to OpenRegister's flow engine. + * Presents one dossiq action handler to OpenRegister's flow engine. * - * Two families extend this: ProcestActionNode for the configured-action - * catalogue (`procest.action.*`) and ProcestTransitionNode for the live - * transition vocabulary (`procest.*`). They are siblings rather than one base + * Two families extend this: DossiqActionNode for the configured-action + * catalogue (`dossiq.action.*`) and DossiqTransitionNode for the live + * transition vocabulary (`dossiq.*`). They are siblings rather than one base * with fifteen children because they ARE two systems — and phpmd flagged the * single hierarchy at exactly the point where that stopped being expressible. * @@ -37,20 +37,20 @@ * and every one of them is control-flow or data — await-signal, batch, filter, * iterate, map, merge, object-read, object-write, route, set-fields, sub-flow, * switch, the three triggers, wait. Not one does anything outward-facing. All - * six procest actions DO: they send mail, call a webhook, render a document, + * six dossiq actions DO: they send mail, call a webhook, render a document, * notify a role. Mapping them onto existing nodes would mean inventing - * behaviour OpenRegister deliberately does not own, so procest contributes them + * behaviour OpenRegister deliberately does not own, so dossiq contributes them * instead — which is what FlowNodeRegistry is built for ("apps present nodes * through OpenRegister"), and what hermiq already does with its agent nodes. * * THE HANDLERS KEEP THEIR LOGIC. This is a wrapper, not a port: each subclass * hands its existing ActionHandlerInterface the same `(actionConfig, case, * transitionContext)` it always got. What changes is who calls it — the flow - * engine rather than procest's private registry. + * engine rather than dossiq's private registry. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -abstract class ProcestFlowNodeBase implements IFlowNode { +abstract class DossiqFlowNodeBase implements IFlowNode { /** @@ -72,7 +72,7 @@ public function __construct( /** * The handler this node runs. * - * A UNION, because procest carries two action systems with two interfaces + * A UNION, because dossiq carries two action systems with two interfaces * of the same name in different namespaces. They declare an identical * `handle(array, array, array): ActionResult` and their ActionResults have * an identical shape (succeeded / error / data), so one node body serves @@ -96,8 +96,8 @@ abstract protected function requiredConfigKeys(): array; * * Stated by the subclass rather than derived, because the two action * systems both ship a `sendEmail` and their ids would collide. The LIVE - * transition vocabulary takes the plain `procest.` names; the - * configured-action catalogue takes `procest.action.`. + * transition vocabulary takes the plain `dossiq.` names; the + * configured-action catalogue takes `dossiq.action.`. * * @return string The namespaced node id. */ diff --git a/lib/Flow/ProcestFlowNodeListener.php b/lib/Flow/DossiqFlowNodeListener.php similarity index 78% rename from lib/Flow/ProcestFlowNodeListener.php rename to lib/Flow/DossiqFlowNodeListener.php index 8100cd53b..a79443e6f 100644 --- a/lib/Flow/ProcestFlowNodeListener.php +++ b/lib/Flow/DossiqFlowNodeListener.php @@ -25,16 +25,16 @@ use Throwable; /** - * Presents procest's case actions to OpenRegister's flow engine. + * Presents dossiq's case actions to OpenRegister's flow engine. * * ADR-065: OpenRegister owns the flow engine and no leaf app grows a second - * one. procest does not keep one — it CONTRIBUTES what its cases can do, which + * one. dossiq does not keep one — it CONTRIBUTES what its cases can do, which * is what FlowNodeRegistry is built for and what hermiq already does. * - * TWO VOCABULARIES, DELIBERATELY DISTINCT IDS. procest carries two action + * TWO VOCABULARIES, DELIBERATELY DISTINCT IDS. dossiq carries two action * systems and both ship a `sendEmail`. The LIVE transition vocabulary — what * SideEffectDispatcher fires on every status change — takes the plain - * `procest.*` ids; the configured-action catalogue takes `procest.action.*`. + * `dossiq.*` ids; the configured-action catalogue takes `dossiq.action.*`. * Without that split one handler would silently shadow the other and a flow * builder picking "Send email" would get whichever registered last. * @@ -53,10 +53,10 @@ * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestFlowNodeListener implements IEventListener { +class DossiqFlowNodeListener implements IEventListener { /** - * The nodes procest contributes, in catalogue order. + * The nodes dossiq contributes, in catalogue order. * * The live transition vocabulary first — it is the one that runs. * @@ -72,22 +72,22 @@ class ProcestFlowNodeListener implements IEventListener { */ private const NODES = [ // Live: fired by SideEffectDispatcher on every status change. - ProcestTxSendEmailNode::class, - ProcestTxCreateTaskNode::class, - ProcestTxCreateSubCaseNode::class, - ProcestTxWebhookNode::class, - ProcestTxSetFieldNode::class, - ProcestTxNotifyNode::class, - ProcestTxBesluitvormingActivateNode::class, - ProcestTxBesluitvormingPublishNode::class, - ProcestTxEvaluateDecisionNode::class, + DossiqTxSendEmailNode::class, + DossiqTxCreateTaskNode::class, + DossiqTxCreateSubCaseNode::class, + DossiqTxWebhookNode::class, + DossiqTxSetFieldNode::class, + DossiqTxNotifyNode::class, + DossiqTxBesluitvormingActivateNode::class, + DossiqTxBesluitvormingPublishNode::class, + DossiqTxEvaluateDecisionNode::class, // The configured-action catalogue. - ProcestSendEmailNode::class, - ProcestNotifyRoleNode::class, - ProcestCallWebhookNode::class, - ProcestCreateDocumentNode::class, - ProcestMergeTemplateNode::class, - ProcestScheduleReminderNode::class, + DossiqSendEmailNode::class, + DossiqNotifyRoleNode::class, + DossiqCallWebhookNode::class, + DossiqCreateDocumentNode::class, + DossiqMergeTemplateNode::class, + DossiqScheduleReminderNode::class, ]; @@ -108,7 +108,7 @@ public function __construct( /** - * Register procest's nodes on the catalogue. + * Register dossiq's nodes on the catalogue. * * A node that cannot be constructed is logged and SKIPPED rather than * aborting the loop: one unresolvable dependency must not cost the other @@ -132,7 +132,7 @@ public function handle(Event $event): void { $node = $this->container->get($class); } catch (Throwable $e) { $this->logger->warning( - 'ProcestFlowNodeListener: could not construct a flow node; it will not be offered', + 'DossiqFlowNodeListener: could not construct a flow node; it will not be offered', ['node' => $class, 'error' => $e->getMessage()], ); continue; diff --git a/lib/Flow/ProcestMergeTemplateNode.php b/lib/Flow/DossiqMergeTemplateNode.php similarity index 95% rename from lib/Flow/ProcestMergeTemplateNode.php rename to lib/Flow/DossiqMergeTemplateNode.php index 5261f3d6f..dd4f2bf5e 100644 --- a/lib/Flow/ProcestMergeTemplateNode.php +++ b/lib/Flow/DossiqMergeTemplateNode.php @@ -26,12 +26,12 @@ * Flow node for the `mergeTemplate` action. * * A thin wrapper: MergeTemplateHandler keeps the logic, this presents it to - * OpenRegister's engine. See ProcestActionNode for why these are contributed + * OpenRegister's engine. See DossiqActionNode for why these are contributed * nodes rather than a mapping onto OpenRegister's own. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestMergeTemplateNode extends ProcestActionNode { +class DossiqMergeTemplateNode extends DossiqActionNode { /** diff --git a/lib/Flow/ProcestNotifyRoleNode.php b/lib/Flow/DossiqNotifyRoleNode.php similarity index 95% rename from lib/Flow/ProcestNotifyRoleNode.php rename to lib/Flow/DossiqNotifyRoleNode.php index 0f94badd7..c1a8fb58c 100644 --- a/lib/Flow/ProcestNotifyRoleNode.php +++ b/lib/Flow/DossiqNotifyRoleNode.php @@ -26,12 +26,12 @@ * Flow node for the `notifyRole` action. * * A thin wrapper: NotifyRoleHandler keeps the logic, this presents it to - * OpenRegister's engine. See ProcestActionNode for why these are contributed + * OpenRegister's engine. See DossiqActionNode for why these are contributed * nodes rather than a mapping onto OpenRegister's own. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestNotifyRoleNode extends ProcestActionNode { +class DossiqNotifyRoleNode extends DossiqActionNode { /** diff --git a/lib/Flow/ProcestScheduleReminderNode.php b/lib/Flow/DossiqScheduleReminderNode.php similarity index 95% rename from lib/Flow/ProcestScheduleReminderNode.php rename to lib/Flow/DossiqScheduleReminderNode.php index ad26fe0f8..da3f245ba 100644 --- a/lib/Flow/ProcestScheduleReminderNode.php +++ b/lib/Flow/DossiqScheduleReminderNode.php @@ -26,12 +26,12 @@ * Flow node for the `scheduleReminder` action. * * A thin wrapper: ScheduleReminderHandler keeps the logic, this presents it to - * OpenRegister's engine. See ProcestActionNode for why these are contributed + * OpenRegister's engine. See DossiqActionNode for why these are contributed * nodes rather than a mapping onto OpenRegister's own. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestScheduleReminderNode extends ProcestActionNode { +class DossiqScheduleReminderNode extends DossiqActionNode { /** diff --git a/lib/Flow/ProcestSendEmailNode.php b/lib/Flow/DossiqSendEmailNode.php similarity index 95% rename from lib/Flow/ProcestSendEmailNode.php rename to lib/Flow/DossiqSendEmailNode.php index d32dde05b..19b76d987 100644 --- a/lib/Flow/ProcestSendEmailNode.php +++ b/lib/Flow/DossiqSendEmailNode.php @@ -26,12 +26,12 @@ * Flow node for the `sendEmail` action. * * A thin wrapper: SendEmailHandler keeps the logic, this presents it to - * OpenRegister's engine. See ProcestActionNode for why these are contributed + * OpenRegister's engine. See DossiqActionNode for why these are contributed * nodes rather than a mapping onto OpenRegister's own. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestSendEmailNode extends ProcestActionNode { +class DossiqSendEmailNode extends DossiqActionNode { /** diff --git a/lib/Flow/ProcestTransitionNode.php b/lib/Flow/DossiqTransitionNode.php similarity index 85% rename from lib/Flow/ProcestTransitionNode.php rename to lib/Flow/DossiqTransitionNode.php index b7695078f..e5a47e421 100644 --- a/lib/Flow/ProcestTransitionNode.php +++ b/lib/Flow/DossiqTransitionNode.php @@ -20,7 +20,7 @@ * Base for the LIVE transition-action nodes. * * `lib/Service/Transitions/` — the nine actions SideEffectDispatcher fires on - * every case status change. They take the plain `procest.*` id space because + * every case status change. They take the plain `dossiq.*` id space because * they are the vocabulary that actually runs. * * The subclass states its own id: these handlers carry no `type()` of their @@ -29,6 +29,6 @@ * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -abstract class ProcestTransitionNode extends ProcestFlowNodeBase { +abstract class DossiqTransitionNode extends DossiqFlowNodeBase { }//end class diff --git a/lib/Flow/ProcestTxBesluitvormingActivateNode.php b/lib/Flow/DossiqTxBesluitvormingActivateNode.php similarity index 93% rename from lib/Flow/ProcestTxBesluitvormingActivateNode.php rename to lib/Flow/DossiqTxBesluitvormingActivateNode.php index b27622272..709b64254 100644 --- a/lib/Flow/ProcestTxBesluitvormingActivateNode.php +++ b/lib/Flow/DossiqTxBesluitvormingActivateNode.php @@ -27,12 +27,12 @@ * * A thin wrapper: BesluitvormingActivateHandler keeps the logic. This is the vocabulary * SideEffectDispatcher actually fires on every status change, which is why it - * takes the plain `procest.besluitvormingActivate` id rather than the `procest.action.*` + * takes the plain `dossiq.besluitvormingActivate` id rather than the `dossiq.action.*` * prefix the configured-action catalogue uses. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestTxBesluitvormingActivateNode extends ProcestTransitionNode { +class DossiqTxBesluitvormingActivateNode extends DossiqTransitionNode { /** @@ -71,7 +71,7 @@ protected function handler(): CatalogueActionHandler|TransitionActionHandler { * @return string The namespaced node id. */ protected function nodeId(): string { - return 'procest.besluitvormingActivate'; + return 'dossiq.besluitvormingActivate'; }//end nodeId() diff --git a/lib/Flow/ProcestTxBesluitvormingPublishNode.php b/lib/Flow/DossiqTxBesluitvormingPublishNode.php similarity index 93% rename from lib/Flow/ProcestTxBesluitvormingPublishNode.php rename to lib/Flow/DossiqTxBesluitvormingPublishNode.php index e52ee7a84..4538e7e73 100644 --- a/lib/Flow/ProcestTxBesluitvormingPublishNode.php +++ b/lib/Flow/DossiqTxBesluitvormingPublishNode.php @@ -27,12 +27,12 @@ * * A thin wrapper: BesluitvormingPublishHandler keeps the logic. This is the vocabulary * SideEffectDispatcher actually fires on every status change, which is why it - * takes the plain `procest.besluitvormingPublish` id rather than the `procest.action.*` + * takes the plain `dossiq.besluitvormingPublish` id rather than the `dossiq.action.*` * prefix the configured-action catalogue uses. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestTxBesluitvormingPublishNode extends ProcestTransitionNode { +class DossiqTxBesluitvormingPublishNode extends DossiqTransitionNode { /** @@ -71,7 +71,7 @@ protected function handler(): CatalogueActionHandler|TransitionActionHandler { * @return string The namespaced node id. */ protected function nodeId(): string { - return 'procest.besluitvormingPublish'; + return 'dossiq.besluitvormingPublish'; }//end nodeId() diff --git a/lib/Flow/ProcestTxCreateSubCaseNode.php b/lib/Flow/DossiqTxCreateSubCaseNode.php similarity index 93% rename from lib/Flow/ProcestTxCreateSubCaseNode.php rename to lib/Flow/DossiqTxCreateSubCaseNode.php index 2491ddd2c..b020ce842 100644 --- a/lib/Flow/ProcestTxCreateSubCaseNode.php +++ b/lib/Flow/DossiqTxCreateSubCaseNode.php @@ -27,12 +27,12 @@ * * A thin wrapper: CreateSubCaseHandler keeps the logic. This is the vocabulary * SideEffectDispatcher actually fires on every status change, which is why it - * takes the plain `procest.createSubCase` id rather than the `procest.action.*` + * takes the plain `dossiq.createSubCase` id rather than the `dossiq.action.*` * prefix the configured-action catalogue uses. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestTxCreateSubCaseNode extends ProcestTransitionNode { +class DossiqTxCreateSubCaseNode extends DossiqTransitionNode { /** @@ -71,7 +71,7 @@ protected function handler(): CatalogueActionHandler|TransitionActionHandler { * @return string The namespaced node id. */ protected function nodeId(): string { - return 'procest.createSubCase'; + return 'dossiq.createSubCase'; }//end nodeId() diff --git a/lib/Flow/ProcestTxCreateTaskNode.php b/lib/Flow/DossiqTxCreateTaskNode.php similarity index 94% rename from lib/Flow/ProcestTxCreateTaskNode.php rename to lib/Flow/DossiqTxCreateTaskNode.php index d70b62c57..e90d56f59 100644 --- a/lib/Flow/ProcestTxCreateTaskNode.php +++ b/lib/Flow/DossiqTxCreateTaskNode.php @@ -27,12 +27,12 @@ * * A thin wrapper: CreateTaskHandler keeps the logic. This is the vocabulary * SideEffectDispatcher actually fires on every status change, which is why it - * takes the plain `procest.createTask` id rather than the `procest.action.*` + * takes the plain `dossiq.createTask` id rather than the `dossiq.action.*` * prefix the configured-action catalogue uses. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestTxCreateTaskNode extends ProcestTransitionNode { +class DossiqTxCreateTaskNode extends DossiqTransitionNode { /** @@ -71,7 +71,7 @@ protected function handler(): CatalogueActionHandler|TransitionActionHandler { * @return string The namespaced node id. */ protected function nodeId(): string { - return 'procest.createTask'; + return 'dossiq.createTask'; }//end nodeId() diff --git a/lib/Flow/ProcestTxEvaluateDecisionNode.php b/lib/Flow/DossiqTxEvaluateDecisionNode.php similarity index 93% rename from lib/Flow/ProcestTxEvaluateDecisionNode.php rename to lib/Flow/DossiqTxEvaluateDecisionNode.php index da729127f..ccbac97a5 100644 --- a/lib/Flow/ProcestTxEvaluateDecisionNode.php +++ b/lib/Flow/DossiqTxEvaluateDecisionNode.php @@ -27,12 +27,12 @@ * * A thin wrapper: EvaluateDecisionHandler keeps the logic. This is the vocabulary * SideEffectDispatcher actually fires on every status change, which is why it - * takes the plain `procest.evaluateDecision` id rather than the `procest.action.*` + * takes the plain `dossiq.evaluateDecision` id rather than the `dossiq.action.*` * prefix the configured-action catalogue uses. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestTxEvaluateDecisionNode extends ProcestTransitionNode { +class DossiqTxEvaluateDecisionNode extends DossiqTransitionNode { /** @@ -71,7 +71,7 @@ protected function handler(): CatalogueActionHandler|TransitionActionHandler { * @return string The namespaced node id. */ protected function nodeId(): string { - return 'procest.evaluateDecision'; + return 'dossiq.evaluateDecision'; }//end nodeId() diff --git a/lib/Flow/ProcestTxNotifyNode.php b/lib/Flow/DossiqTxNotifyNode.php similarity index 94% rename from lib/Flow/ProcestTxNotifyNode.php rename to lib/Flow/DossiqTxNotifyNode.php index 4de0edbeb..caccea423 100644 --- a/lib/Flow/ProcestTxNotifyNode.php +++ b/lib/Flow/DossiqTxNotifyNode.php @@ -27,12 +27,12 @@ * * A thin wrapper: NotifyHandler keeps the logic. This is the vocabulary * SideEffectDispatcher actually fires on every status change, which is why it - * takes the plain `procest.notify` id rather than the `procest.action.*` + * takes the plain `dossiq.notify` id rather than the `dossiq.action.*` * prefix the configured-action catalogue uses. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestTxNotifyNode extends ProcestTransitionNode { +class DossiqTxNotifyNode extends DossiqTransitionNode { /** @@ -71,7 +71,7 @@ protected function handler(): CatalogueActionHandler|TransitionActionHandler { * @return string The namespaced node id. */ protected function nodeId(): string { - return 'procest.notify'; + return 'dossiq.notify'; }//end nodeId() diff --git a/lib/Flow/ProcestTxSendEmailNode.php b/lib/Flow/DossiqTxSendEmailNode.php similarity index 94% rename from lib/Flow/ProcestTxSendEmailNode.php rename to lib/Flow/DossiqTxSendEmailNode.php index 64bcfabf5..6892e425c 100644 --- a/lib/Flow/ProcestTxSendEmailNode.php +++ b/lib/Flow/DossiqTxSendEmailNode.php @@ -27,12 +27,12 @@ * * A thin wrapper: SendEmailHandler keeps the logic. This is the vocabulary * SideEffectDispatcher actually fires on every status change, which is why it - * takes the plain `procest.sendEmail` id rather than the `procest.action.*` + * takes the plain `dossiq.sendEmail` id rather than the `dossiq.action.*` * prefix the configured-action catalogue uses. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestTxSendEmailNode extends ProcestTransitionNode { +class DossiqTxSendEmailNode extends DossiqTransitionNode { /** @@ -71,7 +71,7 @@ protected function handler(): CatalogueActionHandler|TransitionActionHandler { * @return string The namespaced node id. */ protected function nodeId(): string { - return 'procest.sendEmail'; + return 'dossiq.sendEmail'; }//end nodeId() diff --git a/lib/Flow/ProcestTxSetFieldNode.php b/lib/Flow/DossiqTxSetFieldNode.php similarity index 94% rename from lib/Flow/ProcestTxSetFieldNode.php rename to lib/Flow/DossiqTxSetFieldNode.php index 5387d2858..527a6f5df 100644 --- a/lib/Flow/ProcestTxSetFieldNode.php +++ b/lib/Flow/DossiqTxSetFieldNode.php @@ -27,12 +27,12 @@ * * A thin wrapper: SetFieldHandler keeps the logic. This is the vocabulary * SideEffectDispatcher actually fires on every status change, which is why it - * takes the plain `procest.setField` id rather than the `procest.action.*` + * takes the plain `dossiq.setField` id rather than the `dossiq.action.*` * prefix the configured-action catalogue uses. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestTxSetFieldNode extends ProcestTransitionNode { +class DossiqTxSetFieldNode extends DossiqTransitionNode { /** @@ -71,7 +71,7 @@ protected function handler(): CatalogueActionHandler|TransitionActionHandler { * @return string The namespaced node id. */ protected function nodeId(): string { - return 'procest.setField'; + return 'dossiq.setField'; }//end nodeId() diff --git a/lib/Flow/ProcestTxWebhookNode.php b/lib/Flow/DossiqTxWebhookNode.php similarity index 94% rename from lib/Flow/ProcestTxWebhookNode.php rename to lib/Flow/DossiqTxWebhookNode.php index 692ee756e..0850e5bcd 100644 --- a/lib/Flow/ProcestTxWebhookNode.php +++ b/lib/Flow/DossiqTxWebhookNode.php @@ -27,12 +27,12 @@ * * A thin wrapper: WebhookHandler keeps the logic. This is the vocabulary * SideEffectDispatcher actually fires on every status change, which is why it - * takes the plain `procest.webhook` id rather than the `procest.action.*` + * takes the plain `dossiq.webhook` id rather than the `dossiq.action.*` * prefix the configured-action catalogue uses. * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestTxWebhookNode extends ProcestTransitionNode { +class DossiqTxWebhookNode extends DossiqTransitionNode { /** @@ -71,7 +71,7 @@ protected function handler(): CatalogueActionHandler|TransitionActionHandler { * @return string The namespaced node id. */ protected function nodeId(): string { - return 'procest.webhook'; + return 'dossiq.webhook'; }//end nodeId() diff --git a/lib/Service/Transitions/SideEffectDispatcher.php b/lib/Service/Transitions/SideEffectDispatcher.php index 34e8765dc..8a3570eb1 100644 --- a/lib/Service/Transitions/SideEffectDispatcher.php +++ b/lib/Service/Transitions/SideEffectDispatcher.php @@ -75,7 +75,7 @@ public function dispatch(array $actions, array $case, array $transitionContext): } // ADR-065: OpenRegister owns the engine, so a transition side effect - // runs the SAME node a flow would — procest contributes those nodes + // runs the SAME node a flow would — dossiq contributes those nodes // (lib/Flow) and this is the second caller of identical code, not a // parallel implementation. The local-handler path is the fallback for // an instance without OpenRegister, so a transition never silently @@ -109,7 +109,7 @@ public function dispatch(array $actions, array $case, array $transitionContext): /** * OpenRegister's node catalogue, or null when OpenRegister is absent. * - * Resolved lazily and BY NAME: procest declares no hard dependency on + * Resolved lazily and BY NAME: dossiq declares no hard dependency on * OpenRegister, and an instance without it must still fire its side effects. * * @return FlowNodeRegistry|null The catalogue, or null. @@ -165,7 +165,7 @@ private function viaNode( array $transitionContext, ): array { try { - $node = $nodes->get(type: 'procest.' . $type); + $node = $nodes->get(type: 'dossiq.' . $type); } catch (Throwable $e) { $this->logger->warning( 'SideEffectDispatcher: unknown action type', @@ -192,7 +192,7 @@ private function viaNode( }//end viaNode() /** - * Run one action through procest's own handler registry. + * Run one action through dossiq's own handler registry. * * The fallback for an instance without OpenRegister. * diff --git a/src/personalSettings.js b/src/personalSettings.js index ae110d461..2beeae69b 100644 --- a/src/personalSettings.js +++ b/src/personalSettings.js @@ -17,4 +17,4 @@ const app = createApp(SubstitutionSettings) app.use(pinia) app.config.globalProperties.t = t app.config.globalProperties.n = n -app.mount('#procest-personal-settings') +app.mount('#dossiq-personal-settings') diff --git a/templates/settings/personal.php b/templates/settings/personal.php index 66c683631..1c68b8f28 100644 --- a/templates/settings/personal.php +++ b/templates/settings/personal.php @@ -9,4 +9,4 @@ Util::addScript($appId, $appId . '-shared-nc-vue'); Util::addScript($appId, $appId . '-personal-settings'); ?> -
+
diff --git a/tests/Stubs/Flow/IFlowNode.php b/tests/Stubs/Flow/IFlowNode.php index 818b825b4..523eaebe3 100644 --- a/tests/Stubs/Flow/IFlowNode.php +++ b/tests/Stubs/Flow/IFlowNode.php @@ -6,10 +6,10 @@ * * Test stub for OpenRegister's published flow-node contract. * - * procest's nodes implement this interface, so without it the classes cannot be + * dossiq's nodes implement this interface, so without it the classes cannot be * loaded in a unit test on an instance where OpenRegister is absent. Mirrors * openregister lib/Service/Flow/IFlowNode.php — if that contract changes, this - * stub is where procest finds out. + * stub is where dossiq finds out. * * @category Test * @package OCA\OpenRegister\Service\Flow diff --git a/tests/Stubs/Flow/RegisterFlowNodesEvent.php b/tests/Stubs/Flow/RegisterFlowNodesEvent.php index 1d48b561d..0c01a5a71 100644 --- a/tests/Stubs/Flow/RegisterFlowNodesEvent.php +++ b/tests/Stubs/Flow/RegisterFlowNodesEvent.php @@ -6,12 +6,12 @@ * * Test stub for OpenRegister's node-registration collect event. * - * procest's ProcestFlowNodeListener is typed against it, so without this stub + * dossiq's DossiqFlowNodeListener is typed against it, so without this stub * the listener cannot be loaded — and phpstan cannot resolve the * `IEventListener` generic bound either. * * DELIBERATELY NOT A FAITHFUL COPY OF THE CONSTRUCTOR. The real event takes - * OpenRegister's FlowNodeRegistry and hands each node straight to it; procest + * OpenRegister's FlowNodeRegistry and hands each node straight to it; dossiq * has no such class and does not need one to prove it registers the right six. * This stub collects them instead, which is exactly what a consumer-side test * needs to assert. diff --git a/tests/Unit/Flow/ProcestActionNodeTest.php b/tests/Unit/Flow/DossiqActionNodeTest.php similarity index 93% rename from tests/Unit/Flow/ProcestActionNodeTest.php rename to tests/Unit/Flow/DossiqActionNodeTest.php index 88ca9b8c3..3ee3a88ba 100644 --- a/tests/Unit/Flow/ProcestActionNodeTest.php +++ b/tests/Unit/Flow/DossiqActionNodeTest.php @@ -16,7 +16,7 @@ namespace OCA\Dossiq\Tests\Unit\Flow; -use OCA\Dossiq\Flow\ProcestSendEmailNode; +use OCA\Dossiq\Flow\DossiqSendEmailNode; use OCA\Dossiq\Service\Actions\ActionResult; use OCA\Dossiq\Service\Actions\SendEmailHandler; use OCP\IL10N; @@ -32,7 +32,7 @@ * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestActionNodeTest extends TestCase { +class DossiqActionNodeTest extends TestCase { /** * @var SendEmailHandler&\PHPUnit\Framework\MockObject\MockObject @@ -40,9 +40,9 @@ class ProcestActionNodeTest extends TestCase { private $handler; /** - * @var ProcestSendEmailNode + * @var DossiqSendEmailNode */ - private ProcestSendEmailNode $node; + private DossiqSendEmailNode $node; /** @@ -62,9 +62,9 @@ static function (string $text, array $params=[]): string { } ); $urls = $this->createMock(IURLGenerator::class); - $urls->method('imagePath')->willReturn('/apps/procest/img/app-dark.svg'); + $urls->method('imagePath')->willReturn('/apps/dossiq/img/app-dark.svg'); - $this->node = new ProcestSendEmailNode($this->handler, $l10n, $urls); + $this->node = new DossiqSendEmailNode($this->handler, $l10n, $urls); }//end setUp() @@ -95,11 +95,11 @@ private function config(): array { * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ public function testIdIsDerivedFromTheHandlerType(): void { - // `procest.action.*`, not `procest.*`: the LIVE transition vocabulary + // `dossiq.action.*`, not `dossiq.*`: the LIVE transition vocabulary // owns the plain names and both systems ship a sendEmail. An id // collision here would have one handler silently shadow the other in // the catalogue. - $this->assertSame('procest.action.sendEmail', $this->node->getId()); + $this->assertSame('dossiq.action.sendEmail', $this->node->getId()); }//end testIdIsDerivedFromTheHandlerType() diff --git a/tests/Unit/Flow/ProcestFlowNodeListenerTest.php b/tests/Unit/Flow/DossiqFlowNodeListenerTest.php similarity index 63% rename from tests/Unit/Flow/ProcestFlowNodeListenerTest.php rename to tests/Unit/Flow/DossiqFlowNodeListenerTest.php index 12149f955..e2dec307a 100644 --- a/tests/Unit/Flow/ProcestFlowNodeListenerTest.php +++ b/tests/Unit/Flow/DossiqFlowNodeListenerTest.php @@ -18,22 +18,22 @@ use OCA\OpenRegister\Service\Flow\IFlowNode; use OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent; -use OCA\Dossiq\Flow\ProcestCallWebhookNode; -use OCA\Dossiq\Flow\ProcestCreateDocumentNode; -use OCA\Dossiq\Flow\ProcestFlowNodeListener; -use OCA\Dossiq\Flow\ProcestMergeTemplateNode; -use OCA\Dossiq\Flow\ProcestNotifyRoleNode; -use OCA\Dossiq\Flow\ProcestScheduleReminderNode; -use OCA\Dossiq\Flow\ProcestSendEmailNode; -use OCA\Dossiq\Flow\ProcestTxSendEmailNode; -use OCA\Dossiq\Flow\ProcestTxCreateTaskNode; -use OCA\Dossiq\Flow\ProcestTxCreateSubCaseNode; -use OCA\Dossiq\Flow\ProcestTxWebhookNode; -use OCA\Dossiq\Flow\ProcestTxSetFieldNode; -use OCA\Dossiq\Flow\ProcestTxNotifyNode; -use OCA\Dossiq\Flow\ProcestTxBesluitvormingActivateNode; -use OCA\Dossiq\Flow\ProcestTxBesluitvormingPublishNode; -use OCA\Dossiq\Flow\ProcestTxEvaluateDecisionNode; +use OCA\Dossiq\Flow\DossiqCallWebhookNode; +use OCA\Dossiq\Flow\DossiqCreateDocumentNode; +use OCA\Dossiq\Flow\DossiqFlowNodeListener; +use OCA\Dossiq\Flow\DossiqMergeTemplateNode; +use OCA\Dossiq\Flow\DossiqNotifyRoleNode; +use OCA\Dossiq\Flow\DossiqScheduleReminderNode; +use OCA\Dossiq\Flow\DossiqSendEmailNode; +use OCA\Dossiq\Flow\DossiqTxSendEmailNode; +use OCA\Dossiq\Flow\DossiqTxCreateTaskNode; +use OCA\Dossiq\Flow\DossiqTxCreateSubCaseNode; +use OCA\Dossiq\Flow\DossiqTxWebhookNode; +use OCA\Dossiq\Flow\DossiqTxSetFieldNode; +use OCA\Dossiq\Flow\DossiqTxNotifyNode; +use OCA\Dossiq\Flow\DossiqTxBesluitvormingActivateNode; +use OCA\Dossiq\Flow\DossiqTxBesluitvormingPublishNode; +use OCA\Dossiq\Flow\DossiqTxEvaluateDecisionNode; use OCP\EventDispatcher\Event; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -41,7 +41,7 @@ use RuntimeException; /** - * Proves procest actually contributes all six case actions. + * Proves dossiq actually contributes all six case actions. * * A node class that exists but is never registered is invisible to the flow * editor — and looks identical to one that works, right up until somebody tries @@ -49,7 +49,7 @@ * * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md */ -class ProcestFlowNodeListenerTest extends TestCase { +class DossiqFlowNodeListenerTest extends TestCase { /** * The id each node class reports, in the order the listener registers them. @@ -57,21 +57,21 @@ class ProcestFlowNodeListenerTest extends TestCase { * @var array */ private const EXPECTED_IDS = [ - ProcestTxSendEmailNode::class => 'procest.sendEmail', - ProcestTxCreateTaskNode::class => 'procest.createTask', - ProcestTxCreateSubCaseNode::class => 'procest.createSubCase', - ProcestTxWebhookNode::class => 'procest.webhook', - ProcestTxSetFieldNode::class => 'procest.setField', - ProcestTxNotifyNode::class => 'procest.notify', - ProcestTxBesluitvormingActivateNode::class => 'procest.besluitvormingActivate', - ProcestTxBesluitvormingPublishNode::class => 'procest.besluitvormingPublish', - ProcestTxEvaluateDecisionNode::class => 'procest.evaluateDecision', - ProcestSendEmailNode::class => 'procest.action.sendEmail', - ProcestNotifyRoleNode::class => 'procest.action.notifyRole', - ProcestCallWebhookNode::class => 'procest.action.callWebhook', - ProcestCreateDocumentNode::class => 'procest.action.createDocument', - ProcestMergeTemplateNode::class => 'procest.action.mergeTemplate', - ProcestScheduleReminderNode::class => 'procest.action.scheduleReminder', + DossiqTxSendEmailNode::class => 'dossiq.sendEmail', + DossiqTxCreateTaskNode::class => 'dossiq.createTask', + DossiqTxCreateSubCaseNode::class => 'dossiq.createSubCase', + DossiqTxWebhookNode::class => 'dossiq.webhook', + DossiqTxSetFieldNode::class => 'dossiq.setField', + DossiqTxNotifyNode::class => 'dossiq.notify', + DossiqTxBesluitvormingActivateNode::class => 'dossiq.besluitvormingActivate', + DossiqTxBesluitvormingPublishNode::class => 'dossiq.besluitvormingPublish', + DossiqTxEvaluateDecisionNode::class => 'dossiq.evaluateDecision', + DossiqSendEmailNode::class => 'dossiq.action.sendEmail', + DossiqNotifyRoleNode::class => 'dossiq.action.notifyRole', + DossiqCallWebhookNode::class => 'dossiq.action.callWebhook', + DossiqCreateDocumentNode::class => 'dossiq.action.createDocument', + DossiqMergeTemplateNode::class => 'dossiq.action.mergeTemplate', + DossiqScheduleReminderNode::class => 'dossiq.action.scheduleReminder', ]; @@ -87,9 +87,9 @@ class ProcestFlowNodeListenerTest extends TestCase { * * @param string[] $failing Class names the container should refuse to build. * - * @return ProcestFlowNodeListener The listener under test. + * @return DossiqFlowNodeListener The listener under test. */ - private function listener(array $failing=[]): ProcestFlowNodeListener { + private function listener(array $failing=[]): DossiqFlowNodeListener { $container = $this->createMock(ContainerInterface::class); $container->method('get')->willReturnCallback( function (string $class) use ($failing): IFlowNode { @@ -103,7 +103,7 @@ function (string $class) use ($failing): IFlowNode { } ); - return new ProcestFlowNodeListener($container, $this->createMock(LoggerInterface::class)); + return new DossiqFlowNodeListener($container, $this->createMock(LoggerInterface::class)); }//end listener() @@ -163,7 +163,7 @@ public function testUnrelatedEventIsIgnored(): void { */ public function testOneUnbuildableNodeDoesNotCostTheRest(): void { $event = new RegisterFlowNodesEvent(); - $this->listener(failing: [ProcestTxSetFieldNode::class])->handle($event); + $this->listener(failing: [DossiqTxSetFieldNode::class])->handle($event); $ids = array_map( static fn ($node): string => $node->getId(), @@ -171,9 +171,9 @@ public function testOneUnbuildableNodeDoesNotCostTheRest(): void { ); $this->assertCount(14, $ids); - $this->assertNotContains('procest.setField', $ids); - $this->assertContains('procest.sendEmail', $ids); - $this->assertContains('procest.action.sendEmail', $ids); + $this->assertNotContains('dossiq.setField', $ids); + $this->assertContains('dossiq.sendEmail', $ids); + $this->assertContains('dossiq.action.sendEmail', $ids); }//end testOneUnbuildableNodeDoesNotCostTheRest() diff --git a/tests/Unit/Service/Transitions/SideEffectDispatcherTest.php b/tests/Unit/Service/Transitions/SideEffectDispatcherTest.php index c703de35f..912f0714b 100644 --- a/tests/Unit/Service/Transitions/SideEffectDispatcherTest.php +++ b/tests/Unit/Service/Transitions/SideEffectDispatcherTest.php @@ -95,7 +95,7 @@ private function dispatcher(?FlowNodeRegistry $nodes, ?ActionHandlerRegistry $le */ public function testActionsRunThroughTheSharedNode(): void { $registry = new FlowNodeRegistry(); - $registry->register($this->node('procest.sendEmail')); + $registry->register($this->node('dossiq.sendEmail')); $results = $this->dispatcher($registry)->dispatch( [['type' => 'sendEmail']], @@ -111,7 +111,7 @@ public function testActionsRunThroughTheSharedNode(): void { /** * The dispatcher resolves the LIVE id space, not the catalogue's. * - * Both action systems ship a sendEmail. Resolving `procest.action.sendEmail` + * Both action systems ship a sendEmail. Resolving `dossiq.action.sendEmail` * here would run the configured-action handler for a transition — a * different class with different config keys. * @@ -121,7 +121,7 @@ public function testActionsRunThroughTheSharedNode(): void { */ public function testItResolvesTheLiveIdSpace(): void { $registry = new FlowNodeRegistry(); - $registry->register($this->node('procest.action.sendEmail')); + $registry->register($this->node('dossiq.action.sendEmail')); $results = $this->dispatcher($registry)->dispatch([['type' => 'sendEmail']], [], []); @@ -145,8 +145,8 @@ public function testItResolvesTheLiveIdSpace(): void { */ public function testAFailedActionDoesNotAbortTheRest(): void { $registry = new FlowNodeRegistry(); - $registry->register($this->node('procest.sendEmail', new RuntimeException('smtp down'))); - $registry->register($this->node('procest.createTask')); + $registry->register($this->node('dossiq.sendEmail', new RuntimeException('smtp down'))); + $registry->register($this->node('dossiq.createTask')); $results = $this->dispatcher($registry)->dispatch( [['type' => 'sendEmail'], ['type' => 'createTask']], From dccac4a6b0b5f85c0d26c36ebdf53e1ece1df793 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 24 Aug 2026 09:38:00 +0200 Subject: [PATCH 02/10] feat(flow): automatic actions become OpenRegister flows, and the dead surface goes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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= [--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: :` 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. --- appinfo/info.xml | 1 + .../MigrateAutomaticActionsToFlowsCommand.php | 156 ++++++ lib/Service/Actions/ActionHandlerLocator.php | 152 ------ lib/Service/Actions/ActionRegistry.php | 404 --------------- .../Actions/AutomaticActionFlowMigrator.php | 483 ++++++++++++++++++ lib/Settings/dossiq_register.json | 4 +- src/manifest.json | 98 +--- .../AutomaticActionFlowMigratorTest.php | 444 ++++++++++++++++ 8 files changed, 1089 insertions(+), 653 deletions(-) create mode 100644 lib/Command/MigrateAutomaticActionsToFlowsCommand.php delete mode 100644 lib/Service/Actions/ActionHandlerLocator.php delete mode 100644 lib/Service/Actions/ActionRegistry.php create mode 100644 lib/Service/Actions/AutomaticActionFlowMigrator.php create mode 100644 tests/Unit/Service/Actions/AutomaticActionFlowMigratorTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index cdf1dc4e5..745587f94 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -354,6 +354,7 @@ Vrij en open source onder de EUPL-1.2-licentie. OCA\Dossiq\Command\BackfillLegalHoldsCommand + OCA\Dossiq\Command\MigrateAutomaticActionsToFlowsCommand OCA\Dossiq\Command\MigrateTenantsCommand OCA\Dossiq\Command\SeedBezwaarBeroepCommand diff --git a/lib/Command/MigrateAutomaticActionsToFlowsCommand.php b/lib/Command/MigrateAutomaticActionsToFlowsCommand.php new file mode 100644 index 000000000..02672fee5 --- /dev/null +++ b/lib/Command/MigrateAutomaticActionsToFlowsCommand.php @@ -0,0 +1,156 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/page-topology-cleanup/tasks.md + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Command; + +use OCA\Dossiq\Service\Actions\AutomaticActionFlowMigrator; +use OCP\IUserManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Migrate Dossiq automatic actions to OpenRegister flows. + * + * @spec openspec/changes/page-topology-cleanup/tasks.md + */ +class MigrateAutomaticActionsToFlowsCommand extends Command { + /** + * Wire the command against the migrator. + * + * @param AutomaticActionFlowMigrator $migrator The migrator. + * @param IUserManager $userManager Resolves the acting user. + */ + public function __construct( + private readonly AutomaticActionFlowMigrator $migrator, + private readonly IUserManager $userManager, + ) { + parent::__construct(); + }//end __construct() + + /** + * Define the command name, description and options. + * + * `--user` is REQUIRED and has no default. The created flows inherit that + * user's identity and organisation permanently, so guessing an owner would + * hand every migrated flow to whoever the guess landed on. + * + * @return void + * + * @spec openspec/changes/page-topology-cleanup/tasks.md + */ + protected function configure(): void { + $this->setName(name: 'dossiq:actions:migrate-to-flows') + ->setDescription( + 'Migrate Dossiq automatic actions to OpenRegister flows (idempotent). ' + . 'The flows are created ENABLED, so actions that never fired before will start firing — ' + . 'use --dry-run first.' + ) + ->addOption( + name: 'user', + mode: InputOption::VALUE_REQUIRED, + description: 'UID the created flows belong to; also supplies the active organisation.' + ) + ->addOption( + name: 'dry-run', + mode: InputOption::VALUE_NONE, + description: 'Report what would be created or updated, and write nothing.' + ); + }//end configure() + + /** + * Run the migration and report per-action outcomes. + * + * @param InputInterface $input Console input. + * @param OutputInterface $output Console output. + * + * @return int Symfony command exit code. + * + * @spec openspec/changes/page-topology-cleanup/tasks.md + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $uid = (string)($input->getOption('user') ?? ''); + if ($uid === '') { + $output->writeln('--user is required: a flow needs an owner and an organisation.'); + return Command::INVALID; + } + + $user = $this->userManager->get($uid); + if ($user === null) { + $output->writeln('No such user: ' . $uid . ''); + return Command::INVALID; + } + + $dryRun = (bool)$input->getOption('dry-run'); + $summary = $this->migrator->migrate(user: $user, dryRun: $dryRun); + + return $this->report(summary: $summary, dryRun: $dryRun, output: $output); + }//end execute() + + /** + * Print the summary, and decide the exit code from it. + * + * A failed row exits non-zero. Reporting success over a partial migration is + * how a caller ends up believing data moved that did not. + * + * @param array $summary The migrator's summary. + * @param bool $dryRun Whether this was a dry run. + * @param OutputInterface $output Console output. + * + * @return int Symfony command exit code. + */ + private function report(array $summary, bool $dryRun, OutputInterface $output): int { + if (isset($summary['note']) === true && $summary['note'] !== '') { + $output->writeln('' . (string)$summary['note'] . ''); + return Command::SUCCESS; + } + + $prefix = 'dossiq:actions:migrate-to-flows'; + if ($dryRun === true) { + $prefix .= ' (dry run — nothing was written)'; + } + + $output->writeln('' . $prefix . ''); + foreach (['total', 'created', 'updated', 'skipped', 'failed'] as $key) { + $output->writeln(' ' . str_pad($key, 8) . ' = ' . (string)$summary[$key]); + } + + foreach ($summary['rows'] as $row) { + $output->writeln(' [' . $row['outcome'] . '] ' . $row['marker'] . ' — ' . $row['detail']); + } + + if ($summary['failed'] > 0) { + return Command::FAILURE; + } + + return Command::SUCCESS; + }//end report() +}//end class diff --git a/lib/Service/Actions/ActionHandlerLocator.php b/lib/Service/Actions/ActionHandlerLocator.php deleted file mode 100644 index ffa64793a..000000000 --- a/lib/Service/Actions/ActionHandlerLocator.php +++ /dev/null @@ -1,152 +0,0 @@ - - * @copyright 2026 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://conduction.nl - * - * SPDX-License-Identifier: EUPL-1.2 - * SPDX-FileCopyrightText: 2026 Conduction B.V. - * - * @spec openspec/changes/retrofit-2026-05-24-case-management/tasks.md - */ - -declare(strict_types=1); - -namespace OCA\Dossiq\Service\Actions; - -use OCA\Dossiq\AppInfo\Application; -use Psr\Container\ContainerInterface; -use Psr\Log\LoggerInterface; - -/** - * Resolves automatic-action handlers by their `type` slug. - * - * @spec openspec/changes/retrofit-2026-05-24-case-management/tasks.md - */ -class ActionHandlerLocator { - - /** - * In-memory handler index keyed by handler `type` slug. - * - * Populated lazily from the DI container the first time a handler is - * requested, so the container stays lean until a transition actually - * dispatches a side effect. - * - * @var array|null - */ - private ?array $handlerIndex = null; - - /** - * Constructor for ActionHandlerLocator. - * - * @param ContainerInterface $container DI container — used to lazily resolve the handler implementations. - * @param LoggerInterface $logger PSR-3 logger for handler-resolution failures. - * - * @return void - */ - public function __construct( - private readonly ContainerInterface $container, - private readonly LoggerInterface $logger, - ) { - }//end __construct() - - /** - * Lookup a registered handler by its `type` slug. - * - * @param string $type Handler `type` slug (matches ActionHandlerInterface::type()). - * - * @return ActionHandlerInterface|null Null when no handler is registered for the slug. - * - * @spec openspec/changes/retrofit-2026-05-24-case-management/tasks.md - */ - public function get(string $type): ?ActionHandlerInterface { - if ($this->handlerIndex === null) { - $this->handlerIndex = $this->buildIndex(); - } - - return ($this->handlerIndex[$type] ?? null); - }//end get() - - /** - * Resolve every known handler class and index it by its `type` slug. - * - * Each handler class is registered as a regular DI service and referenced by - * FQCN; they are resolved lazily so the container can stay lean. - * - * @return array The handler index, keyed by type slug. - * - * @spec openspec/changes/retrofit-2026-05-24-case-management/tasks.md - */ - private function buildIndex(): array { - $index = []; - $candidates = [ - SendEmailHandler::class, - CreateDocumentHandler::class, - NotifyRoleHandler::class, - CallWebhookHandler::class, - MergeTemplateHandler::class, - ScheduleReminderHandler::class, - ]; - - foreach ($candidates as $fqcn) { - $handler = $this->resolve(fqcn: $fqcn); - if ($handler !== null) { - $index[$handler->type()] = $handler; - } - } - - return $index; - }//end buildIndex() - - /** - * Resolve one handler out of the container, tolerating a broken handler. - * - * @param string $fqcn Fully-qualified handler class name. - * - * @return ActionHandlerInterface|null The handler, or null when it cannot be built or is not a handler. - * - * @spec openspec/changes/retrofit-2026-05-24-case-management/tasks.md - */ - private function resolve(string $fqcn): ?ActionHandlerInterface { - try { - $handler = $this->container->get($fqcn); - } catch (\Throwable $e) { - $this->logger->error( - 'ActionRegistry: failed to resolve handler', - [ - 'app' => Application::APP_ID, - 'fqcn' => $fqcn, - 'exception' => $e->getMessage(), - ] - ); - return null; - } - - if ($handler instanceof ActionHandlerInterface) { - return $handler; - } - - return null; - }//end resolve() -}//end class diff --git a/lib/Service/Actions/ActionRegistry.php b/lib/Service/Actions/ActionRegistry.php deleted file mode 100644 index 2c93e8e84..000000000 --- a/lib/Service/Actions/ActionRegistry.php +++ /dev/null @@ -1,404 +0,0 @@ - - * @copyright 2024 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * SPDX-License-Identifier: EUPL-1.2 - * SPDX-FileCopyrightText: 2024 Conduction B.V. - * - * @version GIT: - * - * @link https://conduction.nl - * - * @spec openspec/specs/automatic-actions/spec.md - */ - -declare(strict_types=1); - -namespace OCA\Dossiq\Service\Actions; - -use OCA\Dossiq\AppInfo\Application; -use OCP\IAppConfig; -use Psr\Container\ContainerInterface; -use Psr\Log\LoggerInterface; - -/** - * Resolves `automaticAction` references for SideEffectDispatcher. - * - * Resolution rules (REQ-AA-2, REQ-AA-8): - * - Only published (`isPublished: true`) actions are returned. - * - Cross-tenant lookups return null AND log an error including both the - * requested tenantId and the action's owning tenantId. - * - Unknown slugs return null and are logged at error level. - * - A per-request in-memory cache avoids re-querying OpenRegister for the - * same `(tenantId, slug)` pair within a single transition dispatch. - * - * Action storage lives in OpenRegister under the dossiq register. CRUD is - * delegated entirely to the OpenRegister manifest renderer - * (`/settings/automatic-actions`) — this class is read-only. - */ -class ActionRegistry { - - /** - * In-process cache keyed by "{tenantId}::{slug}". - * - * Values are either the resolved action array or the sentinel `false` - * for known-miss (so we don't repeatedly log the same unknown slug - * during a single transition). - * - * @var array - */ - private array $cache = []; - - /** - * Constructor for ActionRegistry. - * - * @param ContainerInterface $container DI container — used - * to lazily resolve - * OpenRegister's - * ObjectService and to - * discover handler - * implementations. - * @param IAppConfig $appConfig Dossiq app config — - * provides the `register` and - * `automatic_action_schema` - * keys. - * @param LoggerInterface $logger PSR-3 logger for error logging on - * unknown slugs, cross-tenant - * attempts, and resolution - * failures. - * @param ActionHandlerLocator $handlerLocator Owns the handler table and - * resolves handlers by `type` slug. - * - * @return void - */ - public function __construct( - private readonly ContainerInterface $container, - private readonly IAppConfig $appConfig, - private readonly LoggerInterface $logger, - private readonly ActionHandlerLocator $handlerLocator, - ) { - }//end __construct() - - /** - * Resolve a published action by tenant + slug. - * - * @param string $tenantId The tenant the resolution is being attempted in - * (derived from the case being transitioned). - * @param string $slug Tenant-unique action slug. - * - * @return array|null The full `automaticAction` array (with decoded - * `config`), or null on miss, unpublished, or - * cross-tenant attempt. - * - * @spec openspec/changes/retrofit-2026-05-24-case-management/tasks.md - */ - public function resolve(string $tenantId, string $slug): ?array { - $cacheKey = $tenantId . '::' . $slug; - if (array_key_exists($cacheKey, $this->cache) === true) { - $cached = $this->cache[$cacheKey]; - if ($cached === false) { - return null; - } - - return $cached; - } - - try { - $action = $this->findAction(slug: $slug); - } catch (\Throwable $e) { - $this->logger->error( - 'ActionRegistry: failed to load automaticAction', - [ - 'app' => Application::APP_ID, - 'slug' => $slug, - 'tenantId' => $tenantId, - 'exception' => $e->getMessage(), - ] - ); - $this->cache[$cacheKey] = false; - return null; - } - - if ($action === null) { - $this->logger->error( - 'ActionRegistry: unknown action slug', - [ - 'app' => Application::APP_ID, - 'slug' => $slug, - 'tenantId' => $tenantId, - 'reason' => 'not_found', - ] - ); - $this->cache[$cacheKey] = false; - return null; - } - - $ownerTenant = (string)($action['tenantId'] ?? ''); - if ($ownerTenant !== '' && $ownerTenant !== $tenantId) { - $this->logger->error( - 'ActionRegistry: cross-tenant resolution rejected', - [ - 'app' => Application::APP_ID, - 'slug' => $slug, - 'requestedTenant' => $tenantId, - 'ownerTenant' => $ownerTenant, - 'reason' => 'cross_tenant', - ] - ); - $this->cache[$cacheKey] = false; - return null; - } - - if (($action['isPublished'] ?? false) !== true) { - $this->logger->error( - 'ActionRegistry: action is not published', - [ - 'app' => Application::APP_ID, - 'slug' => $slug, - 'tenantId' => $tenantId, - 'reason' => 'unpublished', - ] - ); - $this->cache[$cacheKey] = false; - return null; - } - - $action['config'] = $this->normaliseConfig(action: $action); - - $this->cache[$cacheKey] = $action; - return $action; - }//end resolve() - - /** - * Normalise a stored `config` value to a decoded array. - * - * OpenRegister stores the config as a JSON string; the dispatcher expects - * a decoded array. Already-decoded configs are passed through for forward - * compatibility, and anything unreadable degrades to an empty array. - * - * @param array $action The stored action carrying the raw `config` value. - * - * @return array The decoded config, or an empty array. - */ - private function normaliseConfig(array $action): array { - $config = ($action['config'] ?? null); - if (is_string($config) === true && $config !== '') { - $decoded = json_decode($config, true); - if (is_array($decoded) === true) { - return $decoded; - } - } - - if (is_array($config) === false) { - return []; - } - - return $config; - }//end normaliseConfig() - - /** - * List all actions for a tenant (used by admin UI and dry-run preview). - * - * @param string $tenantId The current tenant. - * @param string|null $typeFilter Optional `type` filter (e.g. only - * `sendEmail`). - * - * @return array Tenant-owned actions; published flag is left - * on each entry so the UI can render badges. - * - * @spec openspec/changes/retrofit-2026-05-24-case-management/tasks.md - */ - public function listForTenant(string $tenantId, ?string $typeFilter = null): array { - try { - $all = $this->fetchAll(); - } catch (\Throwable $e) { - $this->logger->error( - 'ActionRegistry: failed to list actions for tenant', - [ - 'app' => Application::APP_ID, - 'tenantId' => $tenantId, - 'exception' => $e->getMessage(), - ] - ); - return []; - } - - $out = []; - foreach ($all as $action) { - if ((string)($action['tenantId'] ?? '') !== $tenantId) { - continue; - } - - if ($typeFilter !== null && (string)($action['type'] ?? '') !== $typeFilter) { - continue; - } - - $out[] = $action; - } - - return $out; - }//end listForTenant() - - /** - * Lookup a registered handler by its `type` slug. - * - * Used by SideEffectDispatcher and by any dry-run pathway that needs to - * invoke a handler outside the normal transition flow. - * - * @param string $type Handler `type` slug (matches - * ActionHandlerInterface::type()). - * - * @return ActionHandlerInterface|null Null when no handler is registered. - * - * @spec openspec/changes/retrofit-2026-05-24-case-management/tasks.md - */ - public function getHandler(string $type): ?ActionHandlerInterface { - return $this->handlerLocator->get(type: $type); - }//end getHandler() - - /** - * Find a single action by slug via the OpenRegister object service. - * - * @param string $slug Action slug. - * - * @return array|null - */ - private function findAction(string $slug): ?array { - $objectService = $this->getObjectService(); - if ($objectService === null) { - return null; - } - - $register = $this->appConfig->getValueString( - Application::APP_ID, - 'register', - '' - ); - $schema = $this->appConfig->getValueString( - Application::APP_ID, - 'automatic_action_schema', - '' - ); - - if ($register === '' || $schema === '') { - return null; - } - - // ObjectService::findAll() takes a single $config array — the previous - // named-argument form (register:/schema:/filters:/limit:) threw - // "Unknown named parameter $register". Register/schema are read from - // inside `filters`; limit is a top-level config key. Slug uniqueness is - // enforced per-tenant at write time, so a slug match is exact here. - $results = $objectService->findAll( - [ - 'filters' => [ - 'register' => $register, - 'schema' => $schema, - 'slug' => $slug, - ], - 'limit' => 1, - ] - ); - - if (is_array($results) === false || $results === []) { - return null; - } - - $first = $results[0]; - if (is_object($first) === true && method_exists($first, 'jsonSerialize') === true) { - $first = $first->jsonSerialize(); - } - - return (array)$first; - }//end findAction() - - /** - * Fetch all automaticAction objects across tenants (filtered downstream). - * - * @return array - */ - private function fetchAll(): array { - $objectService = $this->getObjectService(); - if ($objectService === null) { - return []; - } - - $register = $this->appConfig->getValueString( - Application::APP_ID, - 'register', - '' - ); - $schema = $this->appConfig->getValueString( - Application::APP_ID, - 'automatic_action_schema', - '' - ); - - if ($register === '' || $schema === '') { - return []; - } - - // ObjectService::findAll() takes a single $config array — see the note in - // findAction(); register/schema are read from inside `filters`. - $results = $objectService->findAll( - [ - 'filters' => [ - 'register' => $register, - 'schema' => $schema, - ], - ] - ); - - if (is_array($results) === false) { - return []; - } - - $out = []; - foreach ($results as $entry) { - if (is_object($entry) === true && method_exists($entry, 'jsonSerialize') === true) { - $entry = $entry->jsonSerialize(); - } - - $out[] = (array)$entry; - } - - return $out; - }//end fetchAll() - - /** - * Resolve the OpenRegister ObjectService lazily. - * - * @return object|null - * - * @psalm-suppress MixedReturnStatement - * @psalm-suppress MixedInferredReturnType - */ - private function getObjectService(): ?object { - try { - return $this->container->get('OCA\OpenRegister\Service\ObjectService'); - } catch (\Throwable $e) { - $this->logger->error( - 'ActionRegistry: OpenRegister ObjectService unavailable', - [ - 'app' => Application::APP_ID, - 'exception' => $e->getMessage(), - ] - ); - return null; - } - }//end getObjectService() -}//end class diff --git a/lib/Service/Actions/AutomaticActionFlowMigrator.php b/lib/Service/Actions/AutomaticActionFlowMigrator.php new file mode 100644 index 000000000..0fef49328 --- /dev/null +++ b/lib/Service/Actions/AutomaticActionFlowMigrator.php @@ -0,0 +1,483 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Service\Actions; + +use OCA\Dossiq\AppInfo\Application; +use OCA\Dossiq\Service\SettingsService; +use OCP\IAppConfig; +use OCP\IUser; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Turns stored automaticAction objects into runnable OpenRegister flows. + * + * @spec openspec/changes/page-topology-cleanup/tasks.md + */ +class AutomaticActionFlowMigrator { + /** + * The flow-node id space Dossiq's configured-action catalogue registers under. + * + * Deliberately NOT `dossiq.*`, which is the live transition vocabulary the + * SideEffectDispatcher fires. Both spaces ship a `sendEmail` implemented by + * different classes with different config keys, so crossing them would run + * the wrong handler against the right config. + */ + private const NODE_PREFIX = 'dossiq.action.'; + + /** + * Prefix of the `notes` marker that ties a flow back to its source action. + * + * Idempotency hangs on this. It is stored on the flow rather than kept in a + * side table so a re-run rediscovers the link from the flow itself, the same + * property that let the Codeberg migration be rebuilt after its state was + * lost. + */ + private const MARKER_PREFIX = 'dossiq:automaticAction:'; + + /** + * How many flows to page through when rebuilding the marker map. + */ + private const FLOW_PAGE = 100; + + /** + * Constructor. + * + * @param SettingsService $settingsService Bridge to OpenRegister. + * @param ContainerInterface $container Service container, for by-name resolution. + * @param IAppConfig $appConfig App configuration. + * @param LoggerInterface $logger Logger. + */ + public function __construct( + private readonly SettingsService $settingsService, + private readonly ContainerInterface $container, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Migrate every automaticAction to a flow, acting as the given user. + * + * @param IUser $user The user the created flows belong to. + * @param bool $dryRun Report what would happen without writing. + * + * @return array The summary: total, created, updated, skipped, failed, rows. + * + * @spec openspec/changes/page-topology-cleanup/tasks.md + */ + public function migrate(IUser $user, bool $dryRun): array { + $objectService = $this->settingsService->getObjectService(); + if ($objectService === null) { + return $this->emptySummary(note: 'OpenRegister is not available.'); + } + + $flowService = $this->flowService(); + if ($flowService === null) { + return $this->emptySummary(note: 'OpenRegister exposes no FlowService on this instance.'); + } + + return $objectService->runAs( + $user, + fn (): array => $this->migrateAll(flowService: $flowService, dryRun: $dryRun) + ); + }//end migrate() + + /** + * Resolve OpenRegister's FlowService by name, or null when absent. + * + * By name and not by type-hint: Dossiq must install and boot on an instance + * without OpenRegister, where the class does not exist to hint against. + * + * @return object|null The FlowService, or null. + */ + private function flowService(): ?object { + try { + return $this->container->get('OCA\OpenRegister\Service\Flow\FlowService'); + } catch (Throwable $e) { + $this->logger->debug( + 'Dossiq: FlowService could not be resolved', + ['app' => Application::APP_ID, 'exception' => $e->getMessage()] + ); + return null; + } + }//end flowService() + + /** + * A summary describing a run that could not start. + * + * @param string $note Why nothing happened. + * + * @return array The summary. + */ + private function emptySummary(string $note): array { + return [ + 'total' => 0, + 'created' => 0, + 'updated' => 0, + 'skipped' => 0, + 'failed' => 0, + 'rows' => [], + 'note' => $note, + ]; + }//end emptySummary() + + /** + * Walk every action and project it, inside the acting user's context. + * + * @param object $flowService OpenRegister's FlowService. + * @param bool $dryRun Report only. + * + * @return array The summary. + */ + private function migrateAll(object $flowService, bool $dryRun): array { + $actions = $this->fetchActions(); + $existing = $this->existingByMarker(flowService: $flowService); + + $summary = $this->emptySummary(note: ''); + $summary['total'] = count($actions); + unset($summary['note']); + + foreach ($actions as $action) { + $row = $this->migrateOne( + action: $action, + existing: $existing, + flowService: $flowService, + dryRun: $dryRun, + ); + $summary[$row['outcome']] = ($summary[$row['outcome']] + 1); + $summary['rows'][] = $row; + } + + return $summary; + }//end migrateAll() + + /** + * Project one action, returning the row that describes what happened. + * + * @param array $action The stored automaticAction. + * @param array $existing Marker → flow uuid. + * @param object $flowService OpenRegister's FlowService. + * @param bool $dryRun Report only. + * + * @return array{outcome: string, marker: string, detail: string} The outcome row. + */ + private function migrateOne(array $action, array $existing, object $flowService, bool $dryRun): array { + $marker = $this->marker(action: $action); + if ($marker === null) { + return [ + 'outcome' => 'failed', + 'marker' => '(unidentifiable)', + 'detail' => 'the action carries no tenantId/slug pair, which the schema requires', + ]; + } + + $type = (string)($action['type'] ?? ''); + $nodeType = self::NODE_PREFIX . $type; + if ($this->nodeExists(nodeType: $nodeType) === false) { + return [ + 'outcome' => 'skipped', + 'marker' => $marker, + 'detail' => 'no node implements "' . $type . '"; a flow around it would never run', + ]; + } + + $uuid = ($existing[$marker] ?? null); + if ($dryRun === true) { + return [ + 'outcome' => $this->outcomeFor(uuid: $uuid), + 'marker' => $marker, + 'detail' => 'dry run — no write', + ]; + } + + return $this->writeFlow( + flowService: $flowService, + document: $this->flowDocument(action: $action, marker: $marker, nodeType: $nodeType), + marker: $marker, + uuid: $uuid, + ); + }//end migrateOne() + + /** + * Write (or rewrite) the flow, converting a throw into a failed row. + * + * One unusable action must never abort the rest of the migration. + * + * @param object $flowService OpenRegister's FlowService. + * @param array $document The flow document. + * @param string $marker The provenance marker. + * @param string|null $uuid The existing flow uuid, or null to create. + * + * @return array{outcome: string, marker: string, detail: string} The outcome row. + */ + private function writeFlow(object $flowService, array $document, string $marker, ?string $uuid): array { + try { + $flow = $flowService->save($document, $uuid); + } catch (Throwable $e) { + $this->logger->error( + 'Dossiq: could not migrate an automaticAction to a flow', + ['app' => Application::APP_ID, 'marker' => $marker, 'exception' => $e->getMessage()] + ); + return ['outcome' => 'failed', 'marker' => $marker, 'detail' => $e->getMessage()]; + } + + return [ + 'outcome' => $this->outcomeFor(uuid: $uuid), + 'marker' => $marker, + 'detail' => 'flow ' . (string)$flow->getUuid(), + ]; + }//end writeFlow() + + /** + * Whether writing against this uuid counts as a create or an update. + * + * A method rather than a ternary at both call sites: phpcs.xml forbids + * inline IF, and the summary keys are the two outcomes' names, so getting + * this wrong would mis-tally the run rather than fail it. + * + * @param string|null $uuid The existing flow uuid, or null. + * + * @return string Either `created` or `updated`. + */ + private function outcomeFor(?string $uuid): string { + if ($uuid === null) { + return 'created'; + } + + return 'updated'; + }//end outcomeFor() + + /** + * Whether OpenRegister's node registry knows this node id. + * + * Checked BEFORE writing, because a flow whose action node does not resolve + * is the exact failure this programme already paid for once: `spawnCase` sat + * in shipped data naming a handler nothing implemented, and every transition + * that ran it reported success while doing nothing. + * + * @param string $nodeType The fully-qualified node id. + * + * @return bool True when a node answers to it. + */ + private function nodeExists(string $nodeType): bool { + try { + $registry = $this->container->get('OCA\OpenRegister\Service\Flow\FlowNodeRegistry'); + $registry->get($nodeType); + } catch (Throwable $e) { + $this->logger->debug( + 'Dossiq: no flow node answers to this id', + ['app' => Application::APP_ID, 'node' => $nodeType, 'exception' => $e->getMessage()] + ); + return false; + } + + return true; + }//end nodeExists() + + /** + * The flow document one action becomes. + * + * Three nodes, because a flow OpenRegister will run needs an entry and an + * exit: a manual trigger, the action itself, and an end. `enabled` is true — + * the stored configuration said what it wanted and had never been honoured. + * + * @param array $action The stored automaticAction. + * @param string $marker The provenance marker. + * @param string $nodeType The action node id. + * + * @return array The flow document. + */ + private function flowDocument(array $action, string $marker, string $nodeType): array { + return [ + 'name' => (string)($action['title'] ?? $action['slug']), + 'description' => $this->description(action: $action), + 'app' => Application::APP_ID, + 'enabled' => true, + 'trigger' => 'manual', + 'notes' => $marker, + 'nodes' => [ + ['id' => 'trigger', 'type' => 'openregister.trigger-manual'], + ['id' => 'action', 'type' => $nodeType, 'config' => $this->config(action: $action)], + ['id' => 'end', 'type' => 'openregister.end'], + ], + 'edges' => [ + ['id' => 'trigger-action', 'from' => ['trigger'], 'to' => ['action']], + ['id' => 'action-end', 'from' => ['action'], 'to' => ['end']], + ], + ]; + }//end flowDocument() + + /** + * The flow's description, carrying the provenance a reader needs. + * + * @param array $action The stored automaticAction. + * + * @return string The description. + */ + private function description(array $action): string { + $own = (string)($action['description'] ?? ''); + $provenance = 'Migrated from the Dossiq automatic action "' . (string)($action['slug'] ?? '') . '".'; + if ($own === '') { + return $provenance; + } + + return $own . ' — ' . $provenance; + }//end description() + + /** + * Decode the action's handler config. + * + * Stored as a JSON string by the schema. A value that does not decode to an + * array yields an empty config rather than a fatal: the flow is still worth + * creating so an admin can see and repair it in the flow editor. + * + * @param array $action The stored automaticAction. + * + * @return array The handler config. + */ + private function config(array $action): array { + $raw = ($action['config'] ?? ''); + if (is_array($raw) === true) { + return $raw; + } + + $decoded = json_decode((string)$raw, true); + if (is_array($decoded) === false) { + return []; + } + + return $decoded; + }//end config() + + /** + * The provenance marker for one action, or null when it cannot be identified. + * + * Built from `tenantId` + `slug`, both of which the schema marks required and + * which are together tenant-unique. Read WITHOUT a default: a missing one + * means the row is malformed, and defaulting would silently collapse several + * actions onto a single marker — one flow overwriting the next. + * + * @param array $action The stored automaticAction. + * + * @return string|null The marker, or null. + */ + private function marker(array $action): ?string { + if (isset($action['tenantId'], $action['slug']) === false) { + return null; + } + + $tenantId = (string)$action['tenantId']; + $slug = (string)$action['slug']; + if ($tenantId === '' || $slug === '') { + return null; + } + + return self::MARKER_PREFIX . $tenantId . ':' . $slug; + }//end marker() + + /** + * Map the markers of already-migrated flows to their uuids. + * + * Paged: an instance with more than one page of Dossiq flows would otherwise + * look empty past the first, and every action beyond it would be created a + * second time on the next run. + * + * @param object $flowService OpenRegister's FlowService. + * + * @return array Marker → flow uuid. + */ + private function existingByMarker(object $flowService): array { + $map = []; + $offset = 0; + + while (true) { + $page = $flowService->findAll(Application::APP_ID, null, null, self::FLOW_PAGE, $offset); + if (is_array($page) === false || $page === []) { + return $map; + } + + foreach ($page as $flow) { + $notes = (string)($flow->getNotes() ?? ''); + if (str_starts_with($notes, self::MARKER_PREFIX) === true) { + $map[$notes] = (string)$flow->getUuid(); + } + } + + if (count($page) < self::FLOW_PAGE) { + return $map; + } + + $offset += self::FLOW_PAGE; + } + }//end existingByMarker() + + /** + * Read every stored automaticAction object. + * + * @return array> The actions. + */ + private function fetchActions(): array { + $objectService = $this->settingsService->getObjectService(); + if ($objectService === null) { + return []; + } + + $register = $this->appConfig->getValueString(Application::APP_ID, 'register', ''); + $schema = $this->appConfig->getValueString(Application::APP_ID, 'automatic_action_schema', ''); + if ($register === '' || $schema === '') { + return []; + } + + $results = $objectService->findAll(['filters' => ['register' => $register, 'schema' => $schema]]); + if (is_array($results) === false) { + return []; + } + + $out = []; + foreach ($results as $entry) { + if (is_object($entry) === true && method_exists($entry, 'jsonSerialize') === true) { + $entry = $entry->jsonSerialize(); + } + + $out[] = (array)$entry; + } + + return $out; + }//end fetchActions() +}//end class diff --git a/lib/Settings/dossiq_register.json b/lib/Settings/dossiq_register.json index 2e23a40ea..ba4b00664 100644 --- a/lib/Settings/dossiq_register.json +++ b/lib/Settings/dossiq_register.json @@ -3237,7 +3237,7 @@ "version": "1.0.0", "x-schema-org": "schema:Action", "title": "Automatic Action", - "description": "Declarative automatic action attached to a status transition. The slug is referenced from transitions[].automaticActions[].ref and resolved at dispatch time by ActionRegistry. Six built-in handler types are supported; per-tenant scoped; unpublished actions are not resolvable.", + "description": "Declarative automatic action. HISTORICAL: this description used to say the slug was referenced from transitions[].automaticActions[].ref and resolved at dispatch time — it never was. SideEffectDispatcher runs a separate vocabulary keyed on an inline `type`, and nothing has ever read these objects at run time. They are migrated to OpenRegister flows by `occ dossiq:actions:migrate-to-flows`, where each becomes a flow around the matching dossiq.action.* node; the flow is what executes. Kept for the migration and for reading historical configuration.", "type": "object", "required": [ "slug", @@ -3297,7 +3297,7 @@ "isPublished": { "type": "boolean", "default": false, - "description": "Only published actions are dispatched by SideEffectDispatcher", + "description": "Legacy publish flag. It never gated anything: SideEffectDispatcher does not read these objects at all. The migration to OpenRegister flows ignores it — enable or disable the resulting flow instead.", "title": "Is Published" }, "active": { diff --git a/src/manifest.json b/src/manifest.json index 8484c1980..8a9a036bf 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -283,10 +283,10 @@ "order": 97 }, { - "id": "AutomaticActionsMenu", + "id": "FlowsLink", "label": "Automatic actions", "icon": "Sitemap", - "route": "AutomaticActions", + "href": "/apps/openregister/#/flows", "section": "settings", "order": 96 }, @@ -342,7 +342,7 @@ "id": "AdminSettingsLink", "label": "Configuration", "icon": "CogOutline", - "href": "/settings/admin/procest", + "href": "/settings/admin/dossiq", "section": "settings", "order": 99 }, @@ -3418,98 +3418,6 @@ } } }, - { - "id": "AutomaticActions", - "route": "/settings/automatic-actions", - "type": "index", - "title": "Automatische acties", - "config": { - "register": "dossiq", - "schema": "automaticAction", - "columns": [ - "slug", - "type", - "title", - "isPublished", - "active" - ], - "showViewAction": false, - "actions": [ - "create", - "edit", - "delete", - { - "id": "view", - "label": "View", - "icon": "EyeOutline", - "handler": "navigate", - "route": "AutomaticActionDetail", - "type": "handler" - } - ], - "sidebar": { - "enabled": true, - "showMetadata": true - }, - "documentationUrl": "https://procest.conduction.nl" - } - }, - { - "id": "AutomaticActionDetail", - "route": "/settings/automatic-actions/:id", - "type": "detail", - "title": "Automatische actie", - "config": { - "register": "dossiq", - "schema": "automaticAction", - "_note": "CONFIG/TECHNICAL archetype (workflow automation handler definition — a rule consumed by the workflow engine). No comms, no child schemas, no status enum (isPublished/active are booleans). The single Data widget carries identity + the handler-specific config[] payload (slug, type, title, description, tenantId, version, isPublished, active, config). No object-lists, no content leaves, no lifecycleActions. Audit history (publish/activation changes) is a sidebar tab.", - "documentationUrl": "https://procest.conduction.nl", - "widgets": [ - { - "id": "automaticaction-data", - "type": "data", - "title": "Automatische actie", - "icon": "Cog", - "content": { - "columns": 2 - } - } - ], - "layout": [ - { - "id": "1", - "widgetId": "automaticaction-data", - "gridX": 0, - "gridY": 0, - "gridWidth": 12, - "gridHeight": 5, - "showTitle": false - } - ], - "sidebar": { - "enabled": true, - "showMetadata": true, - "tabs": [ - { - "id": "audit", - "label": "History", - "icon": "History", - "widgets": [ - { - "type": "audit" - } - ] - }, - { - "id": "version-history", - "label": "Version history", - "icon": "FileCompare", - "component": "VersionHistoryLeafTab" - } - ] - } - } - }, { "id": "PublicAppointment", "route": "/public/appointments/:id", diff --git a/tests/Unit/Service/Actions/AutomaticActionFlowMigratorTest.php b/tests/Unit/Service/Actions/AutomaticActionFlowMigratorTest.php new file mode 100644 index 000000000..b0e0638e2 --- /dev/null +++ b/tests/Unit/Service/Actions/AutomaticActionFlowMigratorTest.php @@ -0,0 +1,444 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\Dossiq\Tests\Unit\Service\Actions + * @author Conduction B.V. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 + * @link https://github.com/ConductionNL/dossiq + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Tests\Unit\Service\Actions; + +use OCA\Dossiq\Service\Actions\AutomaticActionFlowMigrator; +use OCA\Dossiq\Service\SettingsService; +use OCA\OpenRegister\Service\Flow\FlowNodeRegistry; +use OCA\OpenRegister\Service\Flow\IFlowNode; +use OCP\IAppConfig; +use OCP\IUser; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Covers the automaticAction → flow projection. + * + * The migration turns configuration that has never executed into something that + * fires, so every branch that decides WHETHER to write is asserted here: an + * unimplemented action type must be skipped rather than wrapped in a flow that + * cannot run, and a re-run must update the flow it made last time instead of + * creating a second one. + */ +class AutomaticActionFlowMigratorTest extends TestCase { + /** + * A minimal stand-in for OpenRegister's FlowService. + * + * @return object The fake, exposing saves[] and a settable page. + */ + private function flowServiceFake(): object { + return new class { + /** + * @var array, uuid: string|null}> + */ + public array $saves = []; + + /** + * @var array + */ + public array $page = []; + + /** + * Record a save and hand back a flow-like object. + * + * @param array $document The flow document. + * @param string|null $uuid The flow being updated. + * + * @return object The stored flow. + */ + public function save(array $document, ?string $uuid = null): object { + $this->saves[] = ['document' => $document, 'uuid' => $uuid]; + + return new class($uuid ?? 'new-flow-uuid') { + /** + * @param string $uuid The uuid. + */ + public function __construct(private string $uuid) { + } + + /** + * @return string The uuid. + */ + public function getUuid(): string { + return $this->uuid; + } + }; + } + + /** + * Return the first page of flows, then nothing. + * + * @param string|null $app The owning app. + * @param string|null $applicationSlug Unused. + * @param bool|null $enabled Unused. + * @param int $limit Page size. + * @param int $offset Page offset. + * + * @return array The page. + */ + public function findAll( + ?string $app = null, + ?string $applicationSlug = null, + ?bool $enabled = null, + int $limit = 100, + int $offset = 0, + ): array { + if ($offset > 0) { + return []; + } + + return $this->page; + } + }; + } + + /** + * A flow-like row carrying a provenance marker. + * + * @param string $notes The notes field. + * @param string $uuid The flow uuid. + * + * @return object The row. + */ + private function flowRow(string $notes, string $uuid): object { + return new class($notes, $uuid) { + /** + * @param string $notes The notes. + * @param string $uuid The uuid. + */ + public function __construct(private string $notes, private string $uuid) { + } + + /** + * @return string The notes. + */ + public function getNotes(): string { + return $this->notes; + } + + /** + * @return string The uuid. + */ + public function getUuid(): string { + return $this->uuid; + } + }; + } + + /** + * An ObjectService stand-in returning the given actions. + * + * @param array> $actions The stored actions. + * + * @return object The fake. + */ + private function objectServiceFake(array $actions): object { + return new class($actions) { + /** + * @param array> $actions The actions. + */ + public function __construct(private array $actions) { + } + + /** + * Run the callable straight through. + * + * @param IUser $user The acting user. + * @param callable $operation The operation. + * + * @return mixed The result. + */ + public function runAs(IUser $user, callable $operation) { + return $operation(); + } + + /** + * Return the configured actions. + * + * @param array $config The query config. + * + * @return array> The actions. + */ + public function findAll(array $config): array { + return $this->actions; + } + }; + } + + /** + * Build the migrator with the given fakes. + * + * @param array> $actions Stored actions. + * @param object $flowService The flow-service fake. + * @param array $nodeIds Node ids the registry knows. + * + * @return AutomaticActionFlowMigrator The migrator. + */ + private function migrator(array $actions, object $flowService, array $nodeIds): AutomaticActionFlowMigrator { + $registry = new FlowNodeRegistry(); + foreach ($nodeIds as $id) { + $node = $this->createMock(IFlowNode::class); + $node->method('getId')->willReturn($id); + $registry->register($node); + } + + $settings = $this->createMock(SettingsService::class); + $settings->method('getObjectService')->willReturn($this->objectServiceFake($actions)); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + static function (string $id) use ($flowService, $registry) { + if ($id === 'OCA\OpenRegister\Service\Flow\FlowService') { + return $flowService; + } + + return $registry; + } + ); + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturnCallback( + static fn (string $app, string $key, string $default = ''): string => ($key === 'register') ? '17' : '115' + ); + + return new AutomaticActionFlowMigrator( + $settings, + $container, + $appConfig, + $this->createMock(LoggerInterface::class), + ); + } + + /** + * One well-formed action becomes one enabled, runnable flow. + * + * @return void + */ + public function testItCreatesAnEnabledFlowPerAction(): void { + $flowService = $this->flowServiceFake(); + $migrator = $this->migrator( + [ + [ + 'tenantId' => 'tenant-a', + 'slug' => 'send-decision-email', + 'title' => 'Send decision email', + 'type' => 'sendEmail', + 'config' => '{"subject":"Uw besluit"}', + ], + ], + $flowService, + ['dossiq.action.sendEmail'], + ); + + $summary = $migrator->migrate(user: $this->createMock(IUser::class), dryRun: false); + + $this->assertSame(1, $summary['total']); + $this->assertSame(1, $summary['created']); + $this->assertCount(1, $flowService->saves); + + $document = $flowService->saves[0]['document']; + $this->assertNull($flowService->saves[0]['uuid'], 'A first migration must CREATE, not update.'); + $this->assertTrue($document['enabled']); + $this->assertSame('manual', $document['trigger']); + $this->assertSame( + ['openregister.trigger-manual', 'dossiq.action.sendEmail', 'openregister.end'], + array_column($document['nodes'], 'type'), + 'A flow OpenRegister will run needs an entry and an exit around the action.' + ); + $this->assertSame(['subject' => 'Uw besluit'], $document['nodes'][1]['config']); + $this->assertSame('dossiq:automaticAction:tenant-a:send-decision-email', $document['notes']); + } + + /** + * An action type no node implements is SKIPPED, never wrapped in a flow. + * + * Writing it would rebuild the exact defect this programme already fixed in + * the VTH catalog: a stored step naming a handler nothing answers to, which + * reports success and does nothing. + * + * @return void + */ + public function testItSkipsAnActionTypeNoNodeImplements(): void { + $flowService = $this->flowServiceFake(); + $migrator = $this->migrator( + [['tenantId' => 't', 'slug' => 'carrier-pigeon', 'title' => 'Pigeon', 'type' => 'sendCarrierPigeon']], + $flowService, + ['dossiq.action.sendEmail'], + ); + + $summary = $migrator->migrate(user: $this->createMock(IUser::class), dryRun: false); + + $this->assertSame(1, $summary['skipped']); + $this->assertSame(0, $summary['created']); + $this->assertSame([], $flowService->saves); + } + + /** + * A re-run updates the flow it created, rather than making a second one. + * + * @return void + */ + public function testItUpdatesTheFlowItAlreadyCreated(): void { + $flowService = $this->flowServiceFake(); + $flowService->page = [ + $this->flowRow('dossiq:automaticAction:tenant-a:send-decision-email', 'existing-uuid'), + ]; + + $migrator = $this->migrator( + [ + [ + 'tenantId' => 'tenant-a', + 'slug' => 'send-decision-email', + 'title' => 'Send decision email', + 'type' => 'sendEmail', + 'config' => '{}', + ], + ], + $flowService, + ['dossiq.action.sendEmail'], + ); + + $summary = $migrator->migrate(user: $this->createMock(IUser::class), dryRun: false); + + $this->assertSame(1, $summary['updated']); + $this->assertSame(0, $summary['created']); + $this->assertSame('existing-uuid', $flowService->saves[0]['uuid']); + } + + /** + * An action missing tenantId or slug FAILS rather than sharing a marker. + * + * Defaulting the missing half would collapse several actions onto one + * marker, and each migration would overwrite the previous one's flow. + * + * @return void + */ + public function testItFailsAnActionItCannotIdentify(): void { + $flowService = $this->flowServiceFake(); + $migrator = $this->migrator( + [['slug' => 'no-tenant', 'title' => 'Orphan', 'type' => 'sendEmail']], + $flowService, + ['dossiq.action.sendEmail'], + ); + + $summary = $migrator->migrate(user: $this->createMock(IUser::class), dryRun: false); + + $this->assertSame(1, $summary['failed']); + $this->assertSame([], $flowService->saves); + } + + /** + * A dry run reports the same outcomes and writes nothing. + * + * @return void + */ + public function testADryRunWritesNothing(): void { + $flowService = $this->flowServiceFake(); + $migrator = $this->migrator( + [['tenantId' => 't', 'slug' => 's', 'title' => 'T', 'type' => 'sendEmail', 'config' => '{}']], + $flowService, + ['dossiq.action.sendEmail'], + ); + + $summary = $migrator->migrate(user: $this->createMock(IUser::class), dryRun: true); + + $this->assertSame(1, $summary['created']); + $this->assertSame([], $flowService->saves, 'A dry run that writes is not a dry run.'); + } + + /** + * A save that throws is reported as failed and does not abort the rest. + * + * @return void + */ + public function testOneFailingActionDoesNotAbortTheRest(): void { + $flowService = new class($this->flowServiceFake()) { + /** + * @var array, uuid: string|null}> + */ + public array $saves = []; + + /** + * @param object $inner Unused; keeps the shape symmetric. + */ + public function __construct(private object $inner) { + } + + /** + * Throw for the first action, succeed for the second. + * + * @param array $document The flow document. + * @param string|null $uuid The flow being updated. + * + * @return object The stored flow. + */ + public function save(array $document, ?string $uuid = null): object { + if ($document['name'] === 'Boom') { + throw new \RuntimeException('storage exploded'); + } + + $this->saves[] = ['document' => $document, 'uuid' => $uuid]; + + return new class { + /** + * @return string The uuid. + */ + public function getUuid(): string { + return 'ok-uuid'; + } + }; + } + + /** + * No pre-existing flows. + * + * @param string|null $app The owning app. + * @param string|null $applicationSlug Unused. + * @param bool|null $enabled Unused. + * @param int $limit Page size. + * @param int $offset Page offset. + * + * @return array The page. + */ + public function findAll( + ?string $app = null, + ?string $applicationSlug = null, + ?bool $enabled = null, + int $limit = 100, + int $offset = 0, + ): array { + return []; + } + }; + + $migrator = $this->migrator( + [ + ['tenantId' => 't', 'slug' => 'boom', 'title' => 'Boom', 'type' => 'sendEmail', 'config' => '{}'], + ['tenantId' => 't', 'slug' => 'fine', 'title' => 'Fine', 'type' => 'sendEmail', 'config' => '{}'], + ], + $flowService, + ['dossiq.action.sendEmail'], + ); + + $summary = $migrator->migrate(user: $this->createMock(IUser::class), dryRun: false); + + $this->assertSame(1, $summary['failed']); + $this->assertSame(1, $summary['created']); + $this->assertCount(1, $flowService->saves); + } +} From 3ea68afde7715035f05b6034ee6bd6fb3544d6c2 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 24 Aug 2026 09:52:56 +0200 Subject: [PATCH 03/10] =?UTF-8?q?refactor(besluitvorming)!:=20retire=20the?= =?UTF-8?q?=20agenda=20compiler=20=E2=80=94=20decidiq=20owns=20meetings=20?= =?UTF-8?q?(D1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- appinfo/routes.php | 7 +- lib/Controller/AgendaController.php | 162 ---------- lib/Service/AgendaService.php | 227 -------------- .../tasks.md | 4 +- src/components/besluitvorming/AgendaItem.vue | 95 ------ src/manifest.d/50-besluitvorming.json | 30 -- src/manifest.json | 6 - src/registry.js | 15 +- src/services/besluitvormingApi.js | 70 +---- .../besluitvorming/AgendaCompilerView.vue | 279 ------------------ .../besluitvorming/VergaderingDetailView.vue | 247 ---------------- tests/Unit/Service/AgendaServiceTest.php | 216 -------------- 12 files changed, 21 insertions(+), 1337 deletions(-) delete mode 100644 lib/Controller/AgendaController.php delete mode 100644 lib/Service/AgendaService.php delete mode 100644 src/components/besluitvorming/AgendaItem.vue delete mode 100644 src/manifest.d/50-besluitvorming.json delete mode 100644 src/views/besluitvorming/AgendaCompilerView.vue delete mode 100644 src/views/besluitvorming/VergaderingDetailView.vue delete mode 100644 tests/Unit/Service/AgendaServiceTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 7f2e5f364..d845b8070 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -577,8 +577,11 @@ // ── Besluitvorming workflow ────────────────────────────────────── ['name' => 'besluitvorming#activateTemplate', 'url' => '/api/besluitvorming/templates/{slug}/activate', 'verb' => 'POST'], - ['name' => 'agenda#addToAgenda', 'url' => '/api/besluitvorming/cases/{id}/agenda', 'verb' => 'POST'], - ['name' => 'agenda#updateAgendaItem', 'url' => '/api/besluitvorming/cases/{id}/agenda', 'verb' => 'PUT'], + // The two `agenda#` routes were removed with the agenda compiler: decidiq + // owns agenda-building and meetings, and it surfaces them here through the + // `decidesk-decisions` integration leaf. They served only the compiler view + // and became unreachable the moment it went, which is the dead-endpoint + // shape this programme already retired once on the AVG page. ['name' => 'publication#publish', 'url' => '/api/besluitvorming/cases/{id}/publish', 'verb' => 'POST'], ['name' => 'mandaat#mandaatCheck', 'url' => '/api/besluitvorming/cases/{id}/mandaat-check', 'verb' => 'GET'], diff --git a/lib/Controller/AgendaController.php b/lib/Controller/AgendaController.php deleted file mode 100644 index bf1bb4dfb..000000000 --- a/lib/Controller/AgendaController.php +++ /dev/null @@ -1,162 +0,0 @@ - - * @copyright 2026 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * SPDX-License-Identifier: EUPL-1.2 - * SPDX-FileCopyrightText: 2026 Conduction B.V. - * - * @link https://conduction.nl - * - * @spec openspec/changes/besluitvorming-workflow/tasks.md#task-4 - */ - -declare(strict_types=1); - -namespace OCA\Dossiq\Controller; - -use OCA\Dossiq\AppInfo\Application; -use OCA\Dossiq\Service\AgendaService; -use OCP\AppFramework\Controller; -use OCP\AppFramework\Http; -use OCP\AppFramework\Http\Attribute\NoAdminRequired; -use OCP\AppFramework\Http\JSONResponse; -use OCP\IRequest; -use OCP\IUserSession; -use Psr\Log\LoggerInterface; -use Throwable; - -/** - * Controller exposing besluitvorming agenda endpoints. - * - * @psalm-suppress UnusedClass - */ -class AgendaController extends Controller { - /** - * Constructor. - * - * @param IRequest $request The request. - * @param AgendaService $agendaService Agenda-item service. - * @param IUserSession $userSession User session for guard. - * @param LoggerInterface $logger Logger. - */ - public function __construct( - IRequest $request, - private readonly AgendaService $agendaService, - private readonly IUserSession $userSession, - private readonly LoggerInterface $logger, - ) { - parent::__construct(appName: Application::APP_ID, request: $request); - }//end __construct() - - /** - * Add a new agenda item to a case. - * - * @param string $id The case id. - * - * @return JSONResponse The updated agenda items list. - * - * @psalm-suppress PossiblyUnusedMethod - * - * @spec openspec/changes/besluitvorming-workflow/tasks.md#task-4 - */ - #[NoAdminRequired] - public function addToAgenda(string $id): JSONResponse { - $unauthorized = $this->requireAuthenticated(); - if ($unauthorized !== null) { - return $unauthorized; - } - - $payload = $this->bodyParams(); - - try { - $result = $this->agendaService->addToAgenda(caseId: $id, item: $payload); - } catch (Throwable $e) { - $this->logger->error( - 'AgendaController::addToAgenda failed: ' . $e->getMessage(), - ['app' => Application::APP_ID, 'caseId' => $id] - ); - return new JSONResponse( - ['error' => $e->getMessage()], - Http::STATUS_BAD_REQUEST - ); - } - - return new JSONResponse($result, Http::STATUS_CREATED); - }//end addToAgenda() - - /** - * Update an existing agenda item on a case. - * - * @param string $id The case id. - * - * @return JSONResponse The updated agenda items list. - * - * @psalm-suppress PossiblyUnusedMethod - * - * @spec openspec/changes/besluitvorming-workflow/tasks.md#task-4 - */ - #[NoAdminRequired] - public function updateAgendaItem(string $id): JSONResponse { - $unauthorized = $this->requireAuthenticated(); - if ($unauthorized !== null) { - return $unauthorized; - } - - $payload = $this->bodyParams(); - - try { - $result = $this->agendaService->updateAgendaItem(caseId: $id, patch: $payload); - } catch (Throwable $e) { - $this->logger->error( - 'AgendaController::updateAgendaItem failed: ' . $e->getMessage(), - ['app' => Application::APP_ID, 'caseId' => $id] - ); - return new JSONResponse( - ['error' => $e->getMessage()], - Http::STATUS_BAD_REQUEST - ); - } - - return new JSONResponse($result, Http::STATUS_OK); - }//end updateAgendaItem() - - /** - * Read JSON / form body params, excluding routing params. - * - * @return array The body params. - */ - private function bodyParams(): array { - $params = $this->request->getParams(); - unset($params['id'], $params['_route']); - return $params; - }//end bodyParams() - - /** - * Require an authenticated user; return a response otherwise. - * - * @return JSONResponse|null Null when authorised, a response when blocked. - */ - private function requireAuthenticated(): ?JSONResponse { - if ($this->userSession->getUser() === null) { - return new JSONResponse( - ['error' => 'Authenticatie vereist'], - Http::STATUS_BAD_REQUEST - ); - } - - return null; - }//end requireAuthenticated() -}//end class diff --git a/lib/Service/AgendaService.php b/lib/Service/AgendaService.php deleted file mode 100644 index b804c36dd..000000000 --- a/lib/Service/AgendaService.php +++ /dev/null @@ -1,227 +0,0 @@ - - * @copyright 2026 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * SPDX-License-Identifier: EUPL-1.2 - * SPDX-FileCopyrightText: 2026 Conduction B.V. - * - * @link https://conduction.nl - * - * @spec openspec/changes/besluitvorming-workflow/tasks.md#task-4 - */ - -declare(strict_types=1); - -namespace OCA\Dossiq\Service; - -use InvalidArgumentException; -use OCA\Dossiq\AppInfo\Application; -use Psr\Log\LoggerInterface; -use RuntimeException; -use Throwable; - -/** - * Service for besluitvorming agenda item management. - */ -class AgendaService { - /** - * Constructor. - * - * @param SettingsService $settingsService Settings service (resolves OR). - * @param LoggerInterface $logger Logger. - */ - public function __construct( - private readonly SettingsService $settingsService, - private readonly LoggerInterface $logger, - ) { - }//end __construct() - - /** - * Add an agenda item to a case. - * - * @param string $caseId The case id. - * @param array $item The agenda-item payload: { meetingDate, agendaPoint?, - * discussionStatus?, notes? }. - * - * @return array The updated case agenda item list. - * - * @throws \RuntimeException When OR is unavailable. - * - * @spec openspec/changes/besluitvorming-workflow/tasks.md#task-4 - */ - public function addToAgenda(string $caseId, array $item): array { - $case = $this->loadCase(caseId: $caseId); - - $items = $this->extractItems(case: $case); - - $item['createdAt'] = $item['createdAt'] ?? date(format: 'c'); - $item['itemId'] = $item['itemId'] ?? uniqid(prefix: 'agenda_', more_entropy: true); - $items[] = $item; - - return $this->persistItems(case: $case, items: $items); - }//end addToAgenda() - - /** - * Update an agenda item by itemId on a case. - * - * @param string $caseId The case id. - * @param array $patch The patch payload: must include itemId; fields to merge. - * - * @return array The updated case agenda item list. - * - * @spec openspec/changes/besluitvorming-workflow/tasks.md#task-4 - */ - public function updateAgendaItem(string $caseId, array $patch): array { - $case = $this->loadCase(caseId: $caseId); - - $itemId = (string)($patch['itemId'] ?? ''); - if ($itemId === '') { - throw new InvalidArgumentException('itemId is required'); - } - - $items = $this->extractItems(case: $case); - $found = false; - foreach ($items as $i => $existing) { - if ((string)($existing['itemId'] ?? '') === $itemId) { - $items[$i] = array_merge($existing, $patch, ['itemId' => $itemId]); - $found = true; - break; - } - } - - if ($found === false) { - throw new RuntimeException('Agenda item not found: ' . $itemId); - } - - return $this->persistItems(case: $case, items: $items); - }//end updateAgendaItem() - - /** - * Load a case object (raw array). - * - * @param string $caseId The case id. - * - * @return array The case object. - * - * @throws \RuntimeException When OR is unavailable or case not found. - */ - private function loadCase(string $caseId): array { - $objectService = $this->settingsService->getObjectService(); - if ($objectService === null) { - throw new RuntimeException('OpenRegister is not available'); - } - - $register = $this->settingsService->getConfigValue('register'); - $schema = $this->settingsService->getConfigValue('case_schema'); - - try { - $obj = $objectService->find( - id: $caseId, - register: $register, - schema: $schema - ); - } catch (Throwable $e) { - $this->logger->error( - 'AgendaService::loadCase failed', - ['app' => Application::APP_ID, 'caseId' => $caseId, 'error' => $e->getMessage()] - ); - throw new RuntimeException('Case not found: ' . $caseId); - } - - if ($obj === null) { - throw new RuntimeException('Case not found: ' . $caseId); - } - - // The OR object may return either a hydrated DTO or array; normalise to array. - if (is_object($obj) === true && method_exists($obj, 'jsonSerialize') === true) { - return $obj->jsonSerialize(); - } - - if (is_array($obj) === true) { - return $obj; - } - - return (array)$obj; - }//end loadCase() - - /** - * Extract the existing agenda items list from a case array. - * - * @param array $case The case object. - * - * @return array> The agenda items list. - */ - private function extractItems(array $case): array { - $items = $case['agendaItems'] ?? []; - if (is_string($items) === true) { - $decoded = json_decode((string)$items, associative: true); - $items = []; - if (is_array($decoded) === true) { - $items = $decoded; - } - } - - if (is_array($items) === false) { - return []; - } - - $clean = []; - foreach ($items as $item) { - if (is_array($item) === true) { - $clean[] = $item; - } - } - - return $clean; - }//end extractItems() - - /** - * Persist an updated items list to the case. - * - * @param array $case The original case object. - * @param array> $items The updated items list. - * - * @return array { caseId, agendaItems }. - */ - private function persistItems(array $case, array $items): array { - $objectService = $this->settingsService->getObjectService(); - if ($objectService === null) { - throw new RuntimeException('OpenRegister is not available'); - } - - $register = $this->settingsService->getConfigValue('register'); - $schema = $this->settingsService->getConfigValue('case_schema'); - - $case['agendaItems'] = $items; - $caseId = (string)($case['id'] ?? ($case['@self']['id'] ?? '')); - - $objectService->saveObject( - object: $case, - register: $register, - schema: $schema, - ); - - return [ - 'caseId' => $caseId, - 'agendaItems' => $items, - ]; - }//end persistItems() -}//end class diff --git a/openspec/changes/consume-decidesk-besluitvorming-leaf/tasks.md b/openspec/changes/consume-decidesk-besluitvorming-leaf/tasks.md index 8a4342de7..b2c9845e2 100644 --- a/openspec/changes/consume-decidesk-besluitvorming-leaf/tasks.md +++ b/openspec/changes/consume-decidesk-besluitvorming-leaf/tasks.md @@ -22,4 +22,6 @@ - [x] 4.1 Build dossiq against published nc-vue beta.138 (USE_LOCAL_LIB=false); lint clean (0 errors); `appinfo/info.xml` at 0.2.20; chown js to 1000:1000; `occ upgrade` (no-op — DB already 0.2.20). Built `js/dossiq-main.js` serves with version cache-bust and contains `decidesk-decisions` + `besluitvorming-leaf-tab`. - [x] 4.2 Verify (server-side — browser MCP unavailable this session): manifest `CaseDetail.config.sidebarTabs` carries `besluitvorming → BesluitvormingLeafTab` (order 35); `menu-layout.json#removals` contains `BesluitvormingGroup/Voorstellen/Advice/BesluitvormingAgenda` with their stale relocations dropped; all former page routes (`/voorstellen`, `/voorstellen/:id`, `/advice`, `/advice/:id`, `/besluitvorming/agenda`, `/besluitvorming/vergaderingen/:id`) remain registered (ADR-044). dossiq app page returns HTTP 200. - [x] 4.3 decidesk write-path: fixed schema-96 `decisionType` `format:uuid` mis-import on this dev instance; decision `POST /api/objects/18/96` now returns 201 (was 422). See proposal caveat #2. -- [ ] 4.4 BLOCKED (decidesk-side, not dossiq): the real decidesk leaf does not render at runtime until decidesk PR #100 (`d4940267`, the `Util::addInitScript('decidesk','decidesk-integration-init')` wiring) is merged to decidesk `development` and deployed. Until then dossiq's leaf shows the graceful "Besluitvorming unavailable" fallback. See proposal caveat #1. +- [x] 4.4 UNBLOCKED and verified at runtime 2026-08-24. The wiring is on decidiq `development` and deployed here — `lib/AppInfo/Application.php` calls `Util::addInitScript(self::APP_ID, 'decidiq-integration-init')` and `js/decidiq-integration-init.js` is built. Verified in the browser ON A DOSSIQ PAGE, not by reading code: `OCA.OpenRegister.integrations.list()` returns 28 leaves including `decidesk-decisions` (label "Besluitvorming") with `tab`, `widget`, `widgetCompact`, `widgetExpanded` and `widgetEntity` surfaces. The fallback is no longer what a user sees. + ⚠️ The PR reference above was wrong: `#100` in both `decidesk` and `decidiq` is "chore(spec): merge p2-minutes-and-decisions-other-t2", merged 2026-04-19 — a different change entirely. The wiring was confirmed BY CONTENT instead, which is the only check that would have caught this. + 🔑 The leaf id stays `decidesk-decisions` on the OLD app name. It is a cross-app runtime lookup, so it can only move when the registration on decidiq's side moves. diff --git a/src/components/besluitvorming/AgendaItem.vue b/src/components/besluitvorming/AgendaItem.vue deleted file mode 100644 index 3e6e3e133..000000000 --- a/src/components/besluitvorming/AgendaItem.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - diff --git a/src/manifest.d/50-besluitvorming.json b/src/manifest.d/50-besluitvorming.json deleted file mode 100644 index 9676861ea..000000000 --- a/src/manifest.d/50-besluitvorming.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "pages": [ - { - "id": "AgendaCompiler", - "route": "/besluitvorming/agenda", - "type": "custom", - "title": "Agenda", - "_note": "Bespoke agenda compiler — assembles a meeting agenda from selected proposals with drag-ordering and section grouping; the compile-and-reorder workflow is not expressible as a standard index/detail page.", - "component": "AgendaCompilerView" - }, - { - "id": "VergaderingDetail", - "route": "/besluitvorming/vergaderingen/:id", - "type": "custom", - "title": "Vergadering", - "_note": "Bespoke meeting (vergadering) detail — combines agenda, attendees, voting and minutes into one working surface the standard detail type cannot express.", - "component": "VergaderingDetailView" - } - ], - "menu": [ - { - "id": "BesluitvormingAgenda", - "label": "Agenda", - "icon": "CalendarTextOutline", - "route": "AgendaCompiler", - "order": 72, - "requiresRole": ["Behandelaar", "Agendabeheerder", "Griffier"] - } - ] -} diff --git a/src/manifest.json b/src/manifest.json index 8a9a036bf..8f2b358c1 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -206,12 +206,6 @@ "route": "CaseMap", "order": 60 }, - { - "id": "BesluitvormingGroup", - "label": "Decision-making", - "icon": "Gavel", - "order": 70 - }, { "id": "Voorstellen", "label": "Proposals", diff --git a/src/registry.js b/src/registry.js index 8822ca8ea..dbb6009ad 100644 --- a/src/registry.js +++ b/src/registry.js @@ -44,8 +44,6 @@ import CaseDocumentsTab from './components/tabs/CaseDocumentsTab.vue' // Detail-tab components (used as `component:` in sidebarTabs[]) import CaseTasksTab from './components/tabs/CaseTasksTab.vue' import SubstitutionAdminView from './views/admin/SubstitutionAdmin.vue' -import AgendaCompilerView from './views/besluitvorming/AgendaCompilerView.vue' -import VergaderingDetailView from './views/besluitvorming/VergaderingDetailView.vue' // VTH-specific case detail panels import AdviceRequestPanel from './views/cases/components/AdviceRequestPanel.vue' import AdviesPanel from './views/cases/components/AdviesPanel.vue' @@ -230,16 +228,9 @@ const registry = { }, // --- Besluitvorming workflow views. --- - AgendaCompilerView: { - kind: 'page', - component: AgendaCompilerView, - _note: 'Agenda compiler: available vs agenda panels, hamerstuk/bespreekstuk toggle (besluitvorming-workflow).', - }, - VergaderingDetailView: { - kind: 'page', - component: VergaderingDetailView, - _note: 'Decision recording per geagendeerd case: stemuitslag, attending members, aanhouden flow.', - }, + // The agenda compiler and the vergadering detail view were retired: decidiq + // owns agenda-building and meetings, and surfaces them on a case through the + // `decidesk-decisions` integration leaf rather than through pages here. BesluitPublicatiePanel: { kind: 'page', component: BesluitPublicatiePanel, diff --git a/src/services/besluitvormingApi.js b/src/services/besluitvormingApi.js index 21626adab..3b127bf93 100644 --- a/src/services/besluitvormingApi.js +++ b/src/services/besluitvormingApi.js @@ -3,10 +3,18 @@ /** * Besluitvorming API service. * - * Wraps the dossiq /api/besluitvorming endpoints (agenda compilation, - * DROP/LVBB publication, mandaat validation). All HTTP traffic uses + * Wraps the dossiq /api/besluitvorming endpoints. All HTTP traffic uses * @nextcloud/axios for CSRF + auth interop. Never use raw fetch(). * + * WHAT LEFT AND WHY. `addToAgenda`, `confirmAgenda` and `generateAgenda` went + * with the agenda compiler: decidiq owns agenda-building and meetings, and + * surfaces them on a case through the `decidesk-decisions` integration leaf. + * `mandaatCheck` went too — it had no caller at all, while its route stays live + * for the guard that uses it server-side. + * + * `generateAgenda` was worse than unused: it POSTed to `/agenda/generate`, for + * which no route has ever been declared. It could only ever have 404'd. + * * @spec openspec/specs/besluitvorming-workflow/spec.md */ import axios from '@nextcloud/axios' @@ -14,49 +22,6 @@ import { generateUrl } from '@nextcloud/router' const base = (path) => generateUrl('/apps/dossiq/api/besluitvorming' + path) -/** - * Add a case to an agenda with a classification and order. - * - * @param {string} caseId The case UUID. - * @param {string} behandeling 'hamerstuk' | 'bespreekstuk'. - * @param {number} order The agenda order position. - * @return {Promise} The updated agenda item. - */ -export async function addToAgenda(caseId, behandeling, order) { - const response = await axios.post(base('/cases/' + caseId + '/agenda'), { - behandeling, - order, - }) - return response.data -} - -/** - * Confirm an agenda for a list of cases on a meeting date. - * - * @param {string} vergaderingId The vergadering case UUID. - * @param {Array} caseIds The ordered case UUIDs. - * @param {string} meetingDate ISO yyyy-mm-dd meeting date. - * @return {Promise} The confirmation summary. - */ -export async function confirmAgenda(vergaderingId, caseIds, meetingDate) { - const response = await axios.put(base('/cases/' + vergaderingId + '/agenda'), { - caseIds, - meetingDate, - }) - return response.data -} - -/** - * Generate the agenda document (hamerstukken first). - * - * @param {Array} caseIds The case UUIDs on the agenda. - * @return {Promise} The ordered items and optional document id. - */ -export async function generateAgenda(caseIds) { - const response = await axios.post(base('/agenda/generate'), { caseIds }) - return response.data -} - /** * Trigger (retry) DROP/LVBB publication for a case. * @@ -67,18 +32,3 @@ export async function publishBesluit(caseId) { const response = await axios.post(base('/cases/' + caseId + '/publish'), {}) return response.data } - -/** - * Validate the signing official's mandate for a case. - * - * @param {string} caseId The case UUID. - * @param {string} [signingUserId] Optional signing user UID. - * @return {Promise} The validation result. - */ -export async function mandaatCheck(caseId, signingUserId) { - const params = signingUserId ? { signingUserId } : {} - const response = await axios.get(base('/cases/' + caseId + '/mandaat-check'), { - params, - }) - return response.data -} diff --git a/src/views/besluitvorming/AgendaCompilerView.vue b/src/views/besluitvorming/AgendaCompilerView.vue deleted file mode 100644 index ed2b62c48..000000000 --- a/src/views/besluitvorming/AgendaCompilerView.vue +++ /dev/null @@ -1,279 +0,0 @@ - - - - - - diff --git a/src/views/besluitvorming/VergaderingDetailView.vue b/src/views/besluitvorming/VergaderingDetailView.vue deleted file mode 100644 index e166d097a..000000000 --- a/src/views/besluitvorming/VergaderingDetailView.vue +++ /dev/null @@ -1,247 +0,0 @@ - -