diff --git a/l10n/.schema-l10n-baseline.json b/l10n/.schema-l10n-baseline.json index b01d4fbd3..d880fe418 100644 --- a/l10n/.schema-l10n-baseline.json +++ b/l10n/.schema-l10n-baseline.json @@ -1,3 +1,3 @@ { - "uncovered": 1634 + "uncovered": 1632 } diff --git a/l10n/en.js b/l10n/en.js index feb315f5f..86879498e 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1738,7 +1738,18 @@ OC.L10N.register( "No meetings": "No meetings", "Previous month": "Previous month", "Table": "Table", - "Today": "Today" + "Today": "Today", + "Source app": "Source app", + "External reference": "External reference", + "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.": "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.", + "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).": "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).", + "consultationType=tender: public tender reference/notice number.": "consultationType=tender: public tender reference/notice number.", + "Association or VvE": "Association or VvE", + "Company board": "Company board", + "Every schema, generated values": "Every schema, generated values", + "Municipality": "Municipality", + "None, I will set this up myself": "None, I will set this up myself", + "Works council": "Works council" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 0dbd12c4f..ec7a986ff 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1737,7 +1737,18 @@ "No meetings": "No meetings", "Previous month": "Previous month", "Table": "Table", - "Today": "Today" + "Today": "Today", + "Source app": "Source app", + "External reference": "External reference", + "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.": "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.", + "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).": "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).", + "consultationType=tender: public tender reference/notice number.": "consultationType=tender: public tender reference/notice number.", + "Association or VvE": "Association or VvE", + "Company board": "Company board", + "Every schema, generated values": "Every schema, generated values", + "Municipality": "Municipality", + "None, I will set this up myself": "None, I will set this up myself", + "Works council": "Works council" }, "pluralForm": "nplurals=2; plural=(n != 1);" } diff --git a/l10n/nl.js b/l10n/nl.js index 39bb1714e..eb803c05e 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -2038,10 +2038,21 @@ OC.L10N.register( "{n} open action items": "{n} openstaande actiepunten", "{responded} of {invited} responded": "{responded} van {invited} gereageerd", "{states} states, {transitions} transitions": "{states} statussen, {transitions} overgangen", + "Source app": "Bronapplicatie", + "External reference": "Externe referentie", "Which kind of organisation is this for?": "Voor wat voor organisatie is dit?", "Pick the organisation this app runs for and you get a worked example of it: bodies, meetings, decisions and templates that fit. Installing the app plants nothing on its own, so this is the only step that adds data. Choose \"None\" on a production install.": "Kies de organisatie waarvoor deze app draait en je krijgt een uitgewerkt voorbeeld: organen, vergaderingen, besluiten en sjablonen die daarbij passen. De app zet zelf niets klaar, dus dit is de enige stap die gegevens toevoegt. Kies \"Geen\" op een productieomgeving.", "Load the example data": "Laad de voorbeeldgegevens", - "Loads the set you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt de set die je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen." + "Loads the set you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt de set die je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.", + "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.": "consultationType=tender: de winnende partij, vastgelegd zodra het gunningsbesluit is genomen (status=awarded). Decidiq beheert alleen publiceren, beheren en gunnen; het opstellen gebeurt in procest en het inschrijven in pipelinq.", + "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).": "schema.org-annotatie die hoort bij het ORI-type (ChooseAction / Event / CreativeWork).", + "consultationType=tender: public tender reference/notice number.": "consultationType=tender: het openbare aanbestedings- of publicatienummer.", + "Association or VvE": "Vereniging of VvE", + "Company board": "Raad van bestuur", + "Every schema, generated values": "Elk schema, gegenereerde waarden", + "Municipality": "Gemeente", + "None, I will set this up myself": "Geen, ik richt dit zelf in", + "Works council": "Ondernemingsraad" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 235bc38bf..c28f11d4d 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -2037,10 +2037,21 @@ "{n} open action items": "{n} openstaande actiepunten", "{responded} of {invited} responded": "{responded} van {invited} gereageerd", "{states} states, {transitions} transitions": "{states} statussen, {transitions} overgangen", + "Source app": "Bronapplicatie", + "External reference": "Externe referentie", "Which kind of organisation is this for?": "Voor wat voor organisatie is dit?", "Pick the organisation this app runs for and you get a worked example of it: bodies, meetings, decisions and templates that fit. Installing the app plants nothing on its own, so this is the only step that adds data. Choose \"None\" on a production install.": "Kies de organisatie waarvoor deze app draait en je krijgt een uitgewerkt voorbeeld: organen, vergaderingen, besluiten en sjablonen die daarbij passen. De app zet zelf niets klaar, dus dit is de enige stap die gegevens toevoegt. Kies \"Geen\" op een productieomgeving.", "Load the example data": "Laad de voorbeeldgegevens", - "Loads the set you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt de set die je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen." + "Loads the set you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt de set die je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.", + "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.": "consultationType=tender: de winnende partij, vastgelegd zodra het gunningsbesluit is genomen (status=awarded). Decidiq beheert alleen publiceren, beheren en gunnen; het opstellen gebeurt in procest en het inschrijven in pipelinq.", + "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).": "schema.org-annotatie die hoort bij het ORI-type (ChooseAction / Event / CreativeWork).", + "consultationType=tender: public tender reference/notice number.": "consultationType=tender: het openbare aanbestedings- of publicatienummer.", + "Association or VvE": "Vereniging of VvE", + "Company board": "Raad van bestuur", + "Every schema, generated values": "Elk schema, gegenereerde waarden", + "Municipality": "Gemeente", + "None, I will set this up myself": "Geen, ik richt dit zelf in", + "Works council": "Ondernemingsraad" }, "plurals": null, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 155b2288b..167739a51 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -24,6 +24,7 @@ namespace OCA\Decidiq\AppInfo; use OCA\Decidiq\AppInfo\Registrar\AppHostRegistrar; +use OCA\Decidiq\AppInfo\Registrar\CrossAppEventRegistrar; use OCA\Decidiq\AppInfo\Registrar\DomainServiceRegistrar; use OCA\Decidiq\AppInfo\Registrar\IntegrationLeafRegistrar; use OCA\Decidiq\AppInfo\Registrar\ObjectListenerRegistrar; @@ -46,7 +47,8 @@ * than accumulating here: * * - {@see AppHostRegistrar} AppHost boilerplate adoption (ADR-040 / ADR-022). - * - {@see DomainServiceRegistrar} decision events, MCP tools, eIDAS, translation. + * - {@see CrossAppEventRegistrar} inbound cross-app command listeners (ADR-041). + * - {@see DomainServiceRegistrar} MCP tools, eIDAS, translation. * - {@see PlatformIntegrationRegistrar} search, object-write guards, dashboard widget. * - {@see IntegrationLeafRegistrar} server-side half of the OR integration leaves (ADR-066). * - {@see ObjectListenerRegistrar} boot()-time filtered object-lifecycle subscriptions. @@ -140,6 +142,7 @@ public function register(IRegistrationContext $context): void { // them. // @spec openspec/changes/migrate-comments-to-talk-leaf/tasks.md#task-2.1. // @spec openspec/specs/user-settings/spec.md + (new CrossAppEventRegistrar())->register(context: $context); (new DomainServiceRegistrar())->register(context: $context); // Board portal Phase 2 services (audit log, conflict of interest, diff --git a/lib/AppInfo/Registrar/CrossAppEventRegistrar.php b/lib/AppInfo/Registrar/CrossAppEventRegistrar.php new file mode 100644 index 000000000..7ca3e3ba9 --- /dev/null +++ b/lib/AppInfo/Registrar/CrossAppEventRegistrar.php @@ -0,0 +1,81 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Decidiq\AppInfo\Registrar; + +use OCA\Decidiq\Event\DecisionRequestedEvent; +use OCA\Decidiq\Event\GovernanceBodyRequestedEvent; +use OCA\Decidiq\Listener\DecisionRequestedListener; +use OCA\Decidiq\Listener\GovernanceBodyRequestedListener; +use OCP\AppFramework\Bootstrap\IRegistrationContext; + +/** + * Registers every inbound cross-app command listener. + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ +class CrossAppEventRegistrar { + + /** + * The command contract, as event class => listener class. + * + * A map rather than four call sites, so adding a command is one line and + * the whole inbound surface reads as one list. + * + * @var array + */ + private const COMMANDS = [ + // Raise a governance Decision for a consumer's object, and conclude it + // back through DecisionConcludedEvent. + DecisionRequestedEvent::class => DecisionRequestedListener::class, + + // Hold a governance body — a committee, a board — with its roster. + GovernanceBodyRequestedEvent::class => GovernanceBodyRequestedListener::class, + ]; + + /** + * Register every command listener. + * + * @param IRegistrationContext $context The registration context. + * + * @return void + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function register(IRegistrationContext $context): void { + foreach (self::COMMANDS as $event => $listener) { + $context->registerEventListener(event: $event, listener: $listener); + } + + }//end register() + +}//end class diff --git a/lib/AppInfo/Registrar/DomainServiceRegistrar.php b/lib/AppInfo/Registrar/DomainServiceRegistrar.php index 94c346b97..8553f57af 100644 --- a/lib/AppInfo/Registrar/DomainServiceRegistrar.php +++ b/lib/AppInfo/Registrar/DomainServiceRegistrar.php @@ -32,8 +32,6 @@ namespace OCA\Decidiq\AppInfo\Registrar; -use OCA\Decidiq\Event\DecisionRequestedEvent; -use OCA\Decidiq\Listener\DecisionRequestedListener; use OCA\Decidiq\Mcp\DecidiqToolProvider; use OCA\Decidiq\Service\EIDASSignatureService; use OCA\Decidiq\Service\IEIDASSignatureService; @@ -73,35 +71,12 @@ class DomainServiceRegistrar { * @spec openspec/specs/mcp-tools/spec.md */ public function register(IRegistrationContext $context): void { - $this->registerDecisionEvents(context: $context); $this->registerMcpToolProvider(context: $context); $this->registerEidasBindings(context: $context); $this->registerTranslationAdapter(context: $context); }//end register() - /** - * The event contract for delegated decisions. - * - * Consumer apps dispatch DecisionRequestedEvent (handled here -> - * createDecision) and listen for DecisionConcludedEvent (emitted from - * DecisionLifecycleService). In-process replacement for the broken - * IntegrationService::getLeaf path. - * - * @param IRegistrationContext $context The registration context - * - * @return void - * - * @spec openspec/changes/decidesk-decision-events/specs/decidesk-decision-events/spec.md - */ - private function registerDecisionEvents(IRegistrationContext $context): void { - $context->registerEventListener( - event: DecisionRequestedEvent::class, - listener: DecisionRequestedListener::class - ); - - }//end registerDecisionEvents() - /** * Register DecidiqToolProvider as the MCP tool provider for the AI Chat Companion. * diff --git a/lib/Event/GovernanceBodyCreatedEvent.php b/lib/Event/GovernanceBodyCreatedEvent.php new file mode 100644 index 000000000..d6e910b3e --- /dev/null +++ b/lib/Event/GovernanceBodyCreatedEvent.php @@ -0,0 +1,121 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Decidiq\Event; + +use OCP\EventDispatcher\Event; + +/** + * Conclusion event for a cross-app governance-body command. + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ +class GovernanceBodyCreatedEvent extends Event { + + /** + * Construct the conclusion event. + * + * @param string $governanceBodyId The id Decidiq created or matched + * @param string $sourceApp App id of the producer the command came from + * @param string $externalReference The producer's own id for the originating record + * @param boolean $created True when this command minted the body, false when one matched + * @param string $correlationId Correlation id echoed from the request + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function __construct( + private readonly string $governanceBodyId, + private readonly string $sourceApp, + private readonly string $externalReference, + private readonly bool $created, + private readonly string $correlationId = '', + ) { + parent::__construct(); + + }//end __construct() + + /** + * Get the resolved GovernanceBody id. + * + * @return string The id + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getGovernanceBodyId(): string { + return $this->governanceBodyId; + + }//end getGovernanceBodyId() + + /** + * Get the producing app id. + * + * @return string The app id + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getSourceApp(): string { + return $this->sourceApp; + + }//end getSourceApp() + + /** + * Get the producer's own reference. + * + * @return string The external reference + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getExternalReference(): string { + return $this->externalReference; + + }//end getExternalReference() + + /** + * Whether the body was newly created rather than matched. + * + * @return boolean The created flag + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function isCreated(): bool { + return $this->created; + + }//end isCreated() + + /** + * Get the correlation id echoed from the request. + * + * @return string The correlation id + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getCorrelationId(): string { + return $this->correlationId; + + }//end getCorrelationId() + +}//end class diff --git a/lib/Event/GovernanceBodyRequestedEvent.php b/lib/Event/GovernanceBodyRequestedEvent.php new file mode 100644 index 000000000..f4ea9ad31 --- /dev/null +++ b/lib/Event/GovernanceBodyRequestedEvent.php @@ -0,0 +1,306 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Decidiq\Event; + +use OCP\EventDispatcher\Event; + +/** + * Cross-app command event: a consumer app asks Decidiq to raise a GovernanceBody. + * + * All request fields are immutable (constructor-injected getters). Nextcloud + * typed dispatch is synchronous, so the result slots (governanceBodyId, created, + * handled) are written by Decidiq's listener and read by the producer right + * after dispatch — the same request/response-over-the-bus pattern + * DecisionRequestedEvent uses. + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ +class GovernanceBodyRequestedEvent extends Event { + + /** + * The id of the GovernanceBody Decidiq created or matched (result slot). + * + * @var string|null + */ + private ?string $governanceBodyId = null; + + /** + * Whether the body was newly created (false when an existing one matched). + * + * @var boolean + */ + private bool $created = false; + + /** + * Whether Decidiq's listener handled this command (result slot). + * + * @var boolean + */ + private bool $handled = false; + + /** + * Construct the command event. + * + * @param string $sourceApp App id of the producer (e.g. dossiq) + * @param string $externalReference The producer's own id for the originating record + * @param string $name Body name + * @param string $bodyType GovernanceBody bodyType (e.g. advisory-body) + * @param string $domain Governance domain preset + * @param boolean $active Whether the body may be assigned new work. Stated, never defaulted + * @param array $attributes Further body fields (quorum, jurisdiction, statutoryBasis, termStart, termEnd, parentBody) + * @param array> $members Roster entries of {uid, role, external, label} + * @param string $actorId Nextcloud UID on whose behalf the command runs + * @param string $correlationId Correlation id echoed on GovernanceBodyCreatedEvent + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) This parameter list is a + * PUBLISHED CROSS-APP CONTRACT, not an internal signature. A consumer app + * constructs the event POSITIONALLY through a class-string so it stays + * installable without Decidiq — the same constraint DecisionRequestedEvent + * documents. Collapsing these into an array would move the contract from the + * signature into an undocumented key set. + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function __construct( + private readonly string $sourceApp, + private readonly string $externalReference, + private readonly string $name, + private readonly string $bodyType, + private readonly string $domain, + private readonly bool $active, + private readonly array $attributes = [], + private readonly array $members = [], + private readonly string $actorId = '', + private readonly string $correlationId = '', + ) { + parent::__construct(); + + }//end __construct() + + /** + * Get the producing app id. + * + * @return string The app id + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getSourceApp(): string { + return $this->sourceApp; + + }//end getSourceApp() + + /** + * Get the producer's own reference for the originating record. + * + * @return string The external reference + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getExternalReference(): string { + return $this->externalReference; + + }//end getExternalReference() + + /** + * Get the body name. + * + * @return string The name + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getName(): string { + return $this->name; + + }//end getName() + + /** + * Get the body type. + * + * @return string The bodyType + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getBodyType(): string { + return $this->bodyType; + + }//end getBodyType() + + /** + * Get the governance domain. + * + * @return string The domain + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getDomain(): string { + return $this->domain; + + }//end getDomain() + + /** + * Whether the body may be assigned new work. + * + * @return boolean The active flag + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function isActive(): bool { + return $this->active; + + }//end isActive() + + /** + * Get the further body fields. + * + * @return array The attribute map + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getAttributes(): array { + return $this->attributes; + + }//end getAttributes() + + /** + * Get the roster entries. + * + * @return array> The members + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getMembers(): array { + return $this->members; + + }//end getMembers() + + /** + * Get the acting Nextcloud UID. + * + * @return string The actor id + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getActorId(): string { + return $this->actorId; + + }//end getActorId() + + /** + * Get the correlation id. + * + * @return string The correlation id + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getCorrelationId(): string { + return $this->correlationId; + + }//end getCorrelationId() + + /** + * Get the resolved GovernanceBody id. + * + * @return string The id, or an empty string when unhandled + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getGovernanceBodyId(): string { + return ($this->governanceBodyId ?? ''); + + }//end getGovernanceBodyId() + + /** + * Record the resolved GovernanceBody id (result slot). + * + * @param string $governanceBodyId The id Decidiq created or matched + * + * @return void + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function setGovernanceBodyId(string $governanceBodyId): void { + $this->governanceBodyId = $governanceBodyId; + + }//end setGovernanceBodyId() + + /** + * Whether the body was newly created rather than matched. + * + * @return boolean The created flag + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function isCreated(): bool { + return $this->created; + + }//end isCreated() + + /** + * Record whether the body was newly created (result slot). + * + * @param boolean $created True when this command minted the body + * + * @return void + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function setCreated(bool $created): void { + $this->created = $created; + + }//end setCreated() + + /** + * Whether Decidiq handled the command. + * + * @return boolean The handled flag + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function isHandled(): bool { + return $this->handled; + + }//end isHandled() + + /** + * Record that Decidiq handled the command (result slot). + * + * @param boolean $handled True when the command was applied + * + * @return void + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function setHandled(bool $handled): void { + $this->handled = $handled; + + }//end setHandled() + +}//end class diff --git a/lib/Listener/GovernanceBodyRequestedListener.php b/lib/Listener/GovernanceBodyRequestedListener.php new file mode 100644 index 000000000..911ee672b --- /dev/null +++ b/lib/Listener/GovernanceBodyRequestedListener.php @@ -0,0 +1,131 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Decidiq\Listener; + +use OCA\Decidiq\Event\GovernanceBodyCreatedEvent; +use OCA\Decidiq\Event\GovernanceBodyRequestedEvent; +use OCA\Decidiq\Service\GovernanceBodyCommandService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Handles GovernanceBodyRequestedEvent by delegating to the command service. + * + * @implements IEventListener + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ +class GovernanceBodyRequestedListener implements IEventListener { + + /** + * Constructor. + * + * @param GovernanceBodyCommandService $commandService The idempotent upsert engine. + * @param IEventDispatcher $dispatcher Dispatcher for the conclusion event. + * @param LoggerInterface $logger Logger. + */ + public function __construct( + private readonly GovernanceBodyCommandService $commandService, + private readonly IEventDispatcher $dispatcher, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Handle a GovernanceBodyRequestedEvent. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function handle(Event $event): void { + if ($event instanceof GovernanceBodyRequestedEvent === false) { + return; + } + + try { + $result = $this->commandService->upsert( + sourceApp: $event->getSourceApp(), + externalReference: $event->getExternalReference(), + body: ($event->getAttributes() + [ + 'name' => $event->getName(), + 'bodyType' => $event->getBodyType(), + 'domain' => $event->getDomain(), + 'active' => $event->isActive(), + ]), + members: $event->getMembers(), + ); + } catch (Throwable $e) { + // The producer sees isHandled() === false and decides what to do. + // Nothing is rethrown: an exception out of handle() aborts the whole + // dispatch, so an unrelated listener on the same event would stop + // running because this one failed. + $this->logger->error( + 'Decidiq: GovernanceBodyRequestedEvent not handled', + [ + 'sourceApp' => $event->getSourceApp(), + 'externalReference' => $event->getExternalReference(), + 'exception' => $e, + ] + ); + return; + }//end try + + $event->setGovernanceBodyId($result['id']); + $event->setCreated($result['created']); + $event->setHandled(true); + + $this->logger->info( + 'Decidiq: handled GovernanceBodyRequestedEvent', + [ + 'sourceApp' => $event->getSourceApp(), + 'externalReference' => $event->getExternalReference(), + 'governanceBodyId' => $result['id'], + 'created' => $result['created'], + ] + ); + + $this->dispatcher->dispatchTyped( + new GovernanceBodyCreatedEvent( + governanceBodyId: $result['id'], + sourceApp: $event->getSourceApp(), + externalReference: $event->getExternalReference(), + created: $result['created'], + correlationId: $event->getCorrelationId(), + ) + ); + + }//end handle() + +}//end class diff --git a/lib/Service/ApprovalRouteService.php b/lib/Service/ApprovalRouteService.php index 632f9d4f2..9f088abd9 100644 --- a/lib/Service/ApprovalRouteService.php +++ b/lib/Service/ApprovalRouteService.php @@ -66,10 +66,10 @@ class ApprovalRouteService { /** * Constructor. * - * @param ApprovalRouteStore $store Reads and writes the objects a route is made of. + * @param RegisterObjectStore $store Reads and writes the objects a route is made of. */ public function __construct( - private readonly ApprovalRouteStore $store, + private readonly RegisterObjectStore $store, ) { }//end __construct() diff --git a/lib/Service/GovernanceBodyCommandService.php b/lib/Service/GovernanceBodyCommandService.php new file mode 100644 index 000000000..c1195f81e --- /dev/null +++ b/lib/Service/GovernanceBodyCommandService.php @@ -0,0 +1,415 @@ + + * @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/governance-body-events/specs/governance-body-events/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Decidiq\Service; + +use RuntimeException; + +/** + * Idempotent upsert of a GovernanceBody plus its Person/Membership roster. + * + * Every write in here is preceded by a resolve. That is the whole design: a + * migration in the producing app is re-runnable by construction, and a partial + * run is completed by the next one rather than duplicated by it. + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ +class GovernanceBodyCommandService { + + /** + * Schema slug of the body. + */ + private const SCHEMA_BODY = 'governance-body'; + + /** + * Schema slug of a natural person. + */ + private const SCHEMA_PERSON = 'person'; + + /** + * Schema slug of a person's seat on a body. + */ + private const SCHEMA_MEMBERSHIP = 'membership'; + + /** + * Body fields a command may set beyond the five it states explicitly. + * + * A closed list, not a passthrough: an open merge would let a producing app + * write `sourceApp` or `externalReference` itself and break the key this + * service resolves on. + * + * @var list + */ + private const ALLOWED_ATTRIBUTES = [ + 'quorum', + 'quorumRule', + 'jurisdiction', + 'statutoryBasis', + 'votingDefault', + 'termStart', + 'termEnd', + 'parentBody', + 'workflowTemplate', + ]; + + /** + * Membership roles this seam accepts, matching Membership.role. + * + * @var list + */ + private const ALLOWED_ROLES = [ + 'chair', + 'vice-chair', + 'secretary', + 'treasurer', + 'member', + 'observer', + ]; + + /** + * Constructor. + * + * @param RegisterObjectStore $store Reads and writes decidiq's register objects. + */ + public function __construct( + private readonly RegisterObjectStore $store, + ) { + }//end __construct() + + /** + * Raise or update a governance body and its roster. + * + * @param string $sourceApp App id of the producer. + * @param string $externalReference The producer's own id for the originating record. + * @param array $body Body fields; MUST carry name, bodyType, domain and active. + * @param array> $members Roster entries of {uid, role, external, label}. + * + * @return array{id: string, created: bool} The resolved id and whether it was minted here. + * + * @throws RuntimeException When the command is incomplete or a write fails. + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function upsert( + string $sourceApp, + string $externalReference, + array $body, + array $members = [], + ): array { + if ($sourceApp === '' || $externalReference === '') { + throw new RuntimeException( + 'A governance-body command needs both sourceApp and externalReference: they are the key a re-run resolves on' + ); + } + + $payload = $this->buildBodyPayload( + sourceApp: $sourceApp, + externalReference: $externalReference, + body: $body, + ); + + $existing = $this->findBody(sourceApp: $sourceApp, externalReference: $externalReference); + $created = ($existing === null); + + // The body is saved and its id read BEFORE the first membership write. + // A membership written against an unsaved body points at nothing, and a + // crash between the two then leaves orphans instead of a body the next + // run can complete (REQ-GBE-004). + $stored = $this->store->save( + schema: self::SCHEMA_BODY, + object: $payload, + uuid: $this->idOf(row: ($existing ?? [])), + ); + + $bodyId = $this->idOf(row: $stored); + if ($bodyId === null) { + throw new RuntimeException('OpenRegister returned a governance body with no id'); + } + + $this->syncRoster(bodyId: $bodyId, members: $members); + + return [ + 'id' => $bodyId, + 'created' => $created, + ]; + + }//end upsert() + + /** + * Build the body object to store. + * + * @param string $sourceApp App id of the producer. + * @param string $externalReference The producer's own reference. + * @param array $body The commanded body fields. + * + * @return array The payload. + * + * @throws RuntimeException When a required field is missing. + */ + private function buildBodyPayload(string $sourceApp, string $externalReference, array $body): array { + foreach (['name', 'bodyType', 'domain'] as $required) { + if (($body[$required] ?? '') === '') { + throw new RuntimeException('A governance-body command needs a ' . $required); + } + } + + // `active` decides whether the body may be assigned new work, and the + // consuming app throws on it. Defaulting an absent value to true would + // route objections to a disbanded committee and nothing would error, so + // an omitted `active` is refused instead (REQ-GBE-005). + if (array_key_exists('active', $body) === false || is_bool($body['active']) === false) { + throw new RuntimeException( + 'A governance-body command must state `active` as a boolean: it is never defaulted' + ); + } + + $payload = [ + 'name' => (string)$body['name'], + 'bodyType' => (string)$body['bodyType'], + 'domain' => (string)$body['domain'], + 'active' => $body['active'], + 'sourceApp' => $sourceApp, + 'externalReference' => $externalReference, + ]; + + foreach (self::ALLOWED_ATTRIBUTES as $key) { + if (array_key_exists($key, $body) === true && $body[$key] !== null && $body[$key] !== '') { + $payload[$key] = $body[$key]; + } + } + + return $payload; + + }//end buildBodyPayload() + + /** + * Resolve an existing body by its provenance pair. + * + * @param string $sourceApp App id of the producer. + * @param string $externalReference The producer's own reference. + * + * @return array|null The row, or null when none matches. + */ + private function findBody(string $sourceApp, string $externalReference): ?array { + $rows = $this->store->findAll( + schema: self::SCHEMA_BODY, + filters: [ + 'sourceApp' => $sourceApp, + 'externalReference' => $externalReference, + ], + ); + + // The filter is re-checked in PHP rather than trusted. A filter key the + // store does not recognise is dropped rather than refused, and a dropped + // filter returns EVERY body — which would make this method match the + // first unrelated row and silently overwrite it. + foreach ($rows as $row) { + $matchesApp = ((string)($row['sourceApp'] ?? '') === $sourceApp); + $matchesRef = ((string)($row['externalReference'] ?? '') === $externalReference); + if ($matchesApp === true && $matchesRef === true) { + return $row; + } + } + + return null; + + }//end findBody() + + /** + * Create or update one seat per roster entry. + * + * @param string $bodyId The stored body's id. + * @param array> $members The roster. + * + * @return void + * + * @throws RuntimeException When an entry is unusable. + */ + private function syncRoster(string $bodyId, array $members): void { + foreach ($members as $member) { + $uid = trim((string)($member['uid'] ?? '')); + if ($uid === '') { + throw new RuntimeException('A roster entry needs a uid'); + } + + $role = (string)($member['role'] ?? 'member'); + if (in_array($role, self::ALLOWED_ROLES, true) === false) { + throw new RuntimeException('Unknown membership role: ' . $role); + } + + $personId = $this->resolvePerson(uid: $uid, member: $member); + $this->upsertMembership( + bodyId: $bodyId, + personId: $personId, + role: $role, + member: $member, + ); + } + + }//end syncRoster() + + /** + * Find the Person for a Nextcloud uid, creating one only when absent. + * + * @param string $uid The Nextcloud user id. + * @param array $member The roster entry. + * + * @return string The person's id. + * + * @throws RuntimeException When the person cannot be stored. + */ + private function resolvePerson(string $uid, array $member): string { + $rows = $this->store->findAll( + schema: self::SCHEMA_PERSON, + filters: ['nextcloudUserId' => $uid], + ); + + foreach ($rows as $row) { + if ((string)($row['nextcloudUserId'] ?? '') !== $uid) { + continue; + } + + $id = $this->idOf(row: $row); + if ($id !== null) { + return $id; + } + } + + $name = trim((string)($member['name'] ?? '')); + if ($name === '') { + $name = $uid; + } + + $stored = $this->store->save( + schema: self::SCHEMA_PERSON, + object: [ + 'name' => $name, + 'nextcloudUserId' => $uid, + ], + ); + + $id = $this->idOf(row: $stored); + if ($id === null) { + throw new RuntimeException('OpenRegister returned a person with no id'); + } + + return $id; + + }//end resolvePerson() + + /** + * Create or update the one seat this person holds on this body. + * + * @param string $bodyId The body's id. + * @param string $personId The person's id. + * @param string $role The membership role. + * @param array $member The roster entry. + * + * @return void + */ + private function upsertMembership(string $bodyId, string $personId, string $role, array $member): void { + $rows = $this->store->findAll( + schema: self::SCHEMA_MEMBERSHIP, + filters: [ + 'governanceBody' => $bodyId, + 'person' => $personId, + ], + ); + + $existingId = null; + foreach ($rows as $row) { + $sameBody = ($this->refOf(value: ($row['governanceBody'] ?? null)) === $bodyId); + $samePerson = ($this->refOf(value: ($row['person'] ?? null)) === $personId); + if ($sameBody === true && $samePerson === true) { + $existingId = $this->idOf(row: $row); + break; + } + } + + $object = [ + 'governanceBody' => $bodyId, + 'person' => $personId, + 'role' => $role, + // Awb 7:13(2): the secretary sits from outside the administrative + // organ. The producer states it; an absent value reads as false + // because "not declared external" is the ordinary case. + 'external' => (bool)($member['external'] ?? false), + ]; + + $label = trim((string)($member['label'] ?? '')); + if ($label !== '') { + $object['label'] = $label; + } + + $this->store->save( + schema: self::SCHEMA_MEMBERSHIP, + object: $object, + uuid: $existingId, + ); + + }//end upsertMembership() + + /** + * Read an object's id out of either shape OpenRegister returns. + * + * @param array $row The row. + * + * @return string|null The id, or null when absent. + */ + private function idOf(array $row): ?string { + $id = (string)($row['id'] ?? ($row['@self']['id'] ?? '')); + if ($id === '') { + return null; + } + + return $id; + + }//end idOf() + + /** + * Reduce a relation value to the id it points at. + * + * OpenRegister returns a relation as a bare uuid string, or as the expanded + * object when the read inlined it. Comparing the raw value would miss the + * expanded form and mint a second membership on every re-run. + * + * @param mixed $value The relation value. + * + * @return string The id, or an empty string. + */ + private function refOf(mixed $value): string { + if (is_array($value) === true) { + return (string)($value['id'] ?? ($value['@self']['id'] ?? '')); + } + + return (string)$value; + + }//end refOf() + +}//end class diff --git a/lib/Service/ApprovalRouteStore.php b/lib/Service/RegisterObjectStore.php similarity index 84% rename from lib/Service/ApprovalRouteStore.php rename to lib/Service/RegisterObjectStore.php index bd3edddff..446c35658 100644 --- a/lib/Service/ApprovalRouteStore.php +++ b/lib/Service/RegisterObjectStore.php @@ -1,12 +1,16 @@ getUser()` is null, which is exactly the state a background + migration runs in. + +The pattern already exists in this app and is the one being copied: +`DecisionRequestedEvent` → `DecisionRequestedListener` → +`DecisionIntegrationService::createDecision()`, with the resolved id written +back onto the event instance and `DecisionConcludedEvent` carrying the +correlation home. + +## Affected Projects + +- [x] Project: `decidiq` — this change. Two events, a command service, a + listener, an idempotency key on `GovernanceBody`. +- [ ] Project: `dossiq` — the consumer. `migrate-committees-to-decidiq` + unblocks on this and is not part of it. + +## Scope + +### In Scope + +1. **`GovernanceBody` gains `sourceApp` and `externalReference`**, additively, + in a register.d fragment. This is the idempotency key and the audit trail of + where a body came from. `Decision` already carries exactly this pair for + exactly this reason; a governance body raised by another app needs it for + the same one. +2. **`GovernanceBodyRequestedEvent`** — the inbound command. Body fields plus a + `members[]` roster of `{uid, role, external}`, a `sourceApp`, an + `externalReference`, a `correlationId`, and an `actorId`. Result slots for + `governanceBodyId`, `created` and `handled`, written by the listener and read + by the producer right after dispatch. +3. **`GovernanceBodyCommandService`** — the engine: + - `upsert()` resolves an existing body by `(sourceApp, externalReference)` + BEFORE writing anything, so a re-run updates rather than mints a second. + - The roster fans out to `Person` + `Membership`. A `Person` is resolved by + `nextcloudUserId` (which `67-model-debt-cleanup` already added) and created + only when absent. + - A `Membership` is resolved by `(person, governanceBody)` so a re-run + updates the role rather than adding a second seat. + - **The body is written before the memberships**, so a crash between them + leaves a body with a partial roster that the next run completes, rather + than orphan memberships pointing at nothing. +4. **`GovernanceBodyRequestedListener`** — maps the event onto the service, + writes the result slots, dispatches `GovernanceBodyCreatedEvent`, and never + lets an exception escape into the dispatcher. +5. **Registration** in `DomainServiceRegistrar`, beside `registerDecisionEvents`. + +### Out of Scope + +- **Any dossiq change.** The migration, the read path and the retirement of + dossiq's local schema are dossiq's. +- **A UI.** Bodies raised this way are ordinary `GovernanceBody` rows and appear + in the existing list and detail pages with no new surface. +- **Deleting a body.** The seam creates and updates; retirement is `active: + false` through the normal surface. + +## Risks + +- 🔴 **A fan-out migration is not idempotent by default.** This is the risk the + consuming proposal names, and it is answered here rather than there: + resolution by `(sourceApp, externalReference)` for the body and by + `nextcloudUserId` for the person are what make a re-run safe. Both are + asserted by tests that call the seam TWICE and count rows, because a test + that calls it once cannot tell an idempotent write from a duplicating one. +- 🔴 **`active` is load-bearing on the consumer side.** dossiq's + `AdvisoryCommitteeService` throws "Committee is archived" on it. The event + carries it explicitly and the service never defaults it silently: an absent + `active` is an error, not a `true`. +- ⚠️ **A `Person` matched by `nextcloudUserId` may be a different human with a + recycled uid.** Accepted: NC uids are not reissued in practice, and the + alternative (matching on name) is worse. +- ⚠️ **The listener runs synchronously inside the producer's request.** A slow + fan-out is the producer's latency. Bounded by the roster size, which for an + Awb 7:13 committee is single digits. + +## Status + +Ready. No dependency outside this repo. diff --git a/openspec/changes/governance-body-events/specs/governance-body-events/spec.md b/openspec/changes/governance-body-events/specs/governance-body-events/spec.md new file mode 100644 index 000000000..05c028e5b --- /dev/null +++ b/openspec/changes/governance-body-events/specs/governance-body-events/spec.md @@ -0,0 +1,117 @@ +# governance-body-events Specification + +## Purpose + +An in-process command seam that lets another installed fleet app ask decidiq to +raise a `GovernanceBody` with its roster, and read back the id decidiq gave it. +Per ADR-041 a cross-app command travels as a typed event, not as REST. + +## Requirements + +### Requirement: REQ-GBE-001 A governance body carries where it came from + +`GovernanceBody` SHALL carry `sourceApp` and `externalReference`. Together they +identify the originating record in the producing app, and they are the key the +seam resolves on so a repeated command updates one body rather than minting a +second. + +#### Scenario: The pair is additive + +- **GIVEN** the register fragment +- **WHEN** the register imports +- **THEN** `GovernanceBody` has `sourceApp` and `externalReference` +- **AND** its `required` list is unchanged, so every stored body stays valid +- **AND** a body created through the existing UI leaves both empty + +### Requirement: REQ-GBE-002 The seam is a typed event, dispatched and answered in process + +Decidiq SHALL register a listener for `GovernanceBodyRequestedEvent`. The +listener SHALL write the resolved id, the created flag and the handled flag onto +the dispatched instance, so a producer reads the result immediately after +dispatch. + +#### Scenario: A request raises a body and answers with its id + +- **GIVEN** an installed decidiq and no body for `(dossiq, cmte-1)` +- **WHEN** a producer dispatches `GovernanceBodyRequestedEvent` for it +- **THEN** a `GovernanceBody` exists with the mapped fields +- **AND** the event reports `handled = true`, `created = true`, and a non-empty + `governanceBodyId` + +#### Scenario: A failure leaves the event unhandled and throws nothing + +- **GIVEN** OpenRegister rejects the write +- **WHEN** the event is dispatched +- **THEN** `handled` is false and `governanceBodyId` is empty +- **AND** no exception escapes the dispatcher, so an unrelated listener still runs + +### Requirement: REQ-GBE-003 A repeated command updates, it does not duplicate + +The service SHALL resolve an existing body by `(sourceApp, externalReference)` +before writing. A second command carrying the same pair SHALL update that body +and SHALL NOT create another. The same rule applies to each roster member: a +`Person` resolves by `nextcloudUserId` and a `Membership` by its +`(person, governanceBody)` pair. + +#### Scenario: Dispatching the same command twice creates one body + +- **GIVEN** a command for `(dossiq, cmte-1)` that has already been handled +- **WHEN** the identical command is dispatched again +- **THEN** exactly one `GovernanceBody` exists for that pair +- **AND** the event reports `created = false` with the same id as the first run + +#### Scenario: Dispatching the same command twice creates one seat per member + +- **GIVEN** a command whose roster names `alice` as chair, already handled +- **WHEN** the identical command is dispatched again +- **THEN** exactly one `Person` exists with `nextcloudUserId = alice` +- **AND** exactly one `Membership` links that person to that body + +#### Scenario: A changed role updates the seat rather than adding one + +- **GIVEN** a handled command naming `alice` as `member` +- **WHEN** a command with the same `externalReference` names `alice` as `chair` +- **THEN** her single membership reads `chair` + +### Requirement: REQ-GBE-004 The body is written before its roster + +The service SHALL persist the `GovernanceBody` and obtain its id BEFORE writing +any `Membership`. A membership SHALL never be written against an unsaved body. + +#### Scenario: A crash mid-fan-out leaves a completable body + +- **GIVEN** a roster of three where the second membership write fails +- **WHEN** the command is dispatched +- **THEN** the body exists with the first membership +- **AND** re-dispatching the command completes the roster without duplicating + the first + +### Requirement: REQ-GBE-005 `active` is never defaulted silently + +`active` decides whether a body may be assigned new work, and the consuming app +throws on it. The service SHALL require the command to state it, and SHALL +refuse a command that omits it rather than assuming `true`. + +#### Scenario: An omitted active is refused + +- **WHEN** a command is dispatched with no `active` +- **THEN** the seam refuses it, no body is written, and `handled` is false + +#### Scenario: An archived committee stays archived across a re-run + +- **GIVEN** a handled command that set `active = false` +- **WHEN** the identical command is dispatched again +- **THEN** the body still reads `active = false` + +### Requirement: REQ-GBE-006 The producer learns the outcome by correlation + +After a successful command the listener SHALL dispatch +`GovernanceBodyCreatedEvent` carrying the `correlationId` from the request, the +resulting `governanceBodyId`, and whether the body was created or matched. + +#### Scenario: The conclusion echoes the correlation + +- **GIVEN** a command carrying `correlationId = abc` +- **WHEN** it is handled +- **THEN** a `GovernanceBodyCreatedEvent` is dispatched with `correlationId = + abc` and the same id the request reports diff --git a/openspec/changes/governance-body-events/tasks.md b/openspec/changes/governance-body-events/tasks.md new file mode 100644 index 000000000..6a1311848 --- /dev/null +++ b/openspec/changes/governance-body-events/tasks.md @@ -0,0 +1,43 @@ +# Tasks: governance-body-events + +## Implementation Tasks + +### Task 1: `sourceApp` + `externalReference` on GovernanceBody +- **spec_ref**: `openspec/changes/governance-body-events/specs/governance-body-events/spec.md#requirement-req-gbe-001-a-governance-body-carries-where-it-came-from` +- **files**: `lib/Settings/register.d/71-governance-body-events.json` +- **acceptance_criteria**: + - GIVEN the fragment WHEN the register imports THEN `GovernanceBody` has both properties and no other schema is touched + - GIVEN `GovernanceBody.required` WHEN compared to before THEN it is unchanged +- [x] Implement +- [x] Test + +### Task 2: The two events +- **spec_ref**: `openspec/changes/governance-body-events/specs/governance-body-events/spec.md#requirement-req-gbe-002-the-seam-is-a-typed-event-dispatched-and-answered-in-process` +- **files**: `lib/Event/GovernanceBodyRequestedEvent.php`, `lib/Event/GovernanceBodyCreatedEvent.php` +- **acceptance_criteria**: + - GIVEN the request event WHEN constructed positionally THEN every field reads back, because a consumer app builds it through a class-string and cannot use named arguments safely + - GIVEN the result slots WHEN unset THEN `isHandled()` is false and `getGovernanceBodyId()` is empty +- [x] Implement +- [x] Test + +### Task 3: GovernanceBodyCommandService — idempotent upsert + roster fan-out +- **spec_ref**: `.../spec.md#requirement-req-gbe-003-a-repeated-command-updates-it-does-not-duplicate` (+ REQ-GBE-004/REQ-GBE-005) +- **files**: `lib/Service/GovernanceBodyCommandService.php` +- **acceptance_criteria**: + - GIVEN a command WHEN dispatched twice THEN one body, one Person per uid, one Membership per person — asserted by COUNTING rows after the second call, not by inspecting the first + - GIVEN a member whose role changes WHEN re-dispatched THEN the existing membership is updated, not supplemented + - GIVEN a roster WHEN written THEN the body is saved and its id read BEFORE the first membership write + - GIVEN a command with no `active` WHEN dispatched THEN it is refused and nothing is written + - GIVEN `active = false` WHEN re-dispatched identically THEN the body stays false +- [x] Implement +- [x] Test + +### Task 4: Listener + registration +- **spec_ref**: `.../spec.md#requirement-req-gbe-006-the-producer-learns-the-outcome-by-correlation` +- **files**: `lib/Listener/GovernanceBodyRequestedListener.php`, `lib/AppInfo/Registrar/DomainServiceRegistrar.php` +- **acceptance_criteria**: + - GIVEN a handled command WHEN it completes THEN `GovernanceBodyCreatedEvent` carries the request's correlationId and the same id + - GIVEN the service throws WHEN the event is dispatched THEN `handled` is false and NO exception escapes handle() + - GIVEN an event of another type WHEN passed to handle() THEN it returns without touching anything +- [x] Implement +- [x] Test diff --git a/tests/Unit/Listener/GovernanceBodyRequestedListenerTest.php b/tests/Unit/Listener/GovernanceBodyRequestedListenerTest.php new file mode 100644 index 000000000..741b2db0a --- /dev/null +++ b/tests/Unit/Listener/GovernanceBodyRequestedListenerTest.php @@ -0,0 +1,205 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\Decidiq\Tests\Unit\Listener + * @author Conduction B.V. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 + * @link https://github.com/ConductionNL/decidiq + */ + +declare(strict_types=1); + +namespace OCA\Decidiq\Tests\Unit\Listener; + +use OCA\Decidiq\Event\GovernanceBodyCreatedEvent; +use OCA\Decidiq\Event\GovernanceBodyRequestedEvent; +use OCA\Decidiq\Listener\GovernanceBodyRequestedListener; +use OCA\Decidiq\Service\GovernanceBodyCommandService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Covers the governance-body command listener. + * + * The failure test is the load-bearing one. An exception out of handle() aborts + * the whole typed dispatch, so a listener that rethrows takes down every other + * listener on the same event — and the producer, which reads its answer off the + * event instance, would never reach the line where it checks isHandled(). + */ +class GovernanceBodyRequestedListenerTest extends TestCase { + + /** + * A request event. + * + * @return GovernanceBodyRequestedEvent The event. + */ + private function event(): GovernanceBodyRequestedEvent { + return new GovernanceBodyRequestedEvent( + 'dossiq', + 'cmte-1', + 'Bezwaarcommissie sociaal domein', + 'advisory-body', + 'social_domain', + true, + ['statutoryBasis' => 'Awb 7:13'], + [['uid' => 'alice', 'role' => 'chair']], + 'admin', + 'corr-1', + ); + + }//end event() + + /** + * A handled command writes the result slots and announces the conclusion. + * + * @return void + */ + public function testHandledCommandWritesResultSlotsAndAnnounces(): void { + $service = $this->createMock(GovernanceBodyCommandService::class); + $service->method('upsert')->willReturn(['id' => 'gb-9', 'created' => true]); + + $announced = []; + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->method('dispatchTyped')->willReturnCallback( + static function (Event $e) use (&$announced): void { + $announced[] = $e; + } + ); + + $event = $this->event(); + $listener = new GovernanceBodyRequestedListener( + $service, + $dispatcher, + $this->createMock(LoggerInterface::class), + ); + $listener->handle($event); + + $this->assertTrue($event->isHandled()); + $this->assertTrue($event->isCreated()); + $this->assertSame('gb-9', $event->getGovernanceBodyId()); + + $this->assertCount(1, $announced); + $conclusion = $announced[0]; + $this->assertInstanceOf(GovernanceBodyCreatedEvent::class, $conclusion); + $this->assertSame('corr-1', $conclusion->getCorrelationId()); + $this->assertSame('gb-9', $conclusion->getGovernanceBodyId()); + $this->assertSame('dossiq', $conclusion->getSourceApp()); + $this->assertSame('cmte-1', $conclusion->getExternalReference()); + $this->assertTrue($conclusion->isCreated()); + + }//end testHandledCommandWritesResultSlotsAndAnnounces() + + /** + * A failing command leaves the event unhandled and throws nothing. + * + * @return void + */ + public function testFailureLeavesEventUnhandledAndThrowsNothing(): void { + $service = $this->createMock(GovernanceBodyCommandService::class); + $service->method('upsert')->willThrowException(new RuntimeException('register down')); + + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->expects($this->never())->method('dispatchTyped'); + + $event = $this->event(); + $listener = new GovernanceBodyRequestedListener( + $service, + $dispatcher, + $this->createMock(LoggerInterface::class), + ); + + $listener->handle($event); + + $this->assertFalse($event->isHandled()); + $this->assertSame('', $event->getGovernanceBodyId()); + $this->assertFalse($event->isCreated()); + + }//end testFailureLeavesEventUnhandledAndThrowsNothing() + + /** + * A matched body reports created = false. + * + * @return void + */ + public function testMatchedBodyReportsNotCreated(): void { + $service = $this->createMock(GovernanceBodyCommandService::class); + $service->method('upsert')->willReturn(['id' => 'gb-9', 'created' => false]); + + $event = $this->event(); + $listener = new GovernanceBodyRequestedListener( + $service, + $this->createMock(IEventDispatcher::class), + $this->createMock(LoggerInterface::class), + ); + $listener->handle($event); + + $this->assertTrue($event->isHandled()); + $this->assertFalse($event->isCreated()); + + }//end testMatchedBodyReportsNotCreated() + + /** + * An unrelated event passes through untouched. + * + * @return void + */ + public function testUnrelatedEventIsIgnored(): void { + $service = $this->createMock(GovernanceBodyCommandService::class); + $service->expects($this->never())->method('upsert'); + + $listener = new GovernanceBodyRequestedListener( + $service, + $this->createMock(IEventDispatcher::class), + $this->createMock(LoggerInterface::class), + ); + + $listener->handle(new class extends Event { + }); + + $this->addToAssertionCount(1); + + }//end testUnrelatedEventIsIgnored() + + /** + * The four stated fields reach the service alongside the attribute bag. + * + * @return void + */ + public function testStatedFieldsReachTheService(): void { + $seen = []; + $service = $this->createMock(GovernanceBodyCommandService::class); + $service->method('upsert')->willReturnCallback( + static function (string $app, string $ref, array $body, array $members) use (&$seen): array { + $seen = ['app' => $app, 'ref' => $ref, 'body' => $body, 'members' => $members]; + + return ['id' => 'gb-9', 'created' => true]; + } + ); + + $listener = new GovernanceBodyRequestedListener( + $service, + $this->createMock(IEventDispatcher::class), + $this->createMock(LoggerInterface::class), + ); + $listener->handle($this->event()); + + $this->assertSame('dossiq', $seen['app']); + $this->assertSame('cmte-1', $seen['ref']); + $this->assertSame('Bezwaarcommissie sociaal domein', $seen['body']['name']); + $this->assertSame('advisory-body', $seen['body']['bodyType']); + $this->assertSame('social_domain', $seen['body']['domain']); + $this->assertTrue($seen['body']['active']); + $this->assertSame('Awb 7:13', $seen['body']['statutoryBasis']); + $this->assertSame([['uid' => 'alice', 'role' => 'chair']], $seen['members']); + + }//end testStatedFieldsReachTheService() + +}//end class diff --git a/tests/Unit/Service/ApprovalRouteServiceTest.php b/tests/Unit/Service/ApprovalRouteServiceTest.php index a0364f9ec..1a56d55cf 100644 --- a/tests/Unit/Service/ApprovalRouteServiceTest.php +++ b/tests/Unit/Service/ApprovalRouteServiceTest.php @@ -17,7 +17,7 @@ namespace OCA\Decidiq\Tests\Unit\Service; use OCA\Decidiq\Service\ApprovalRouteService; -use OCA\Decidiq\Service\ApprovalRouteStore; +use OCA\Decidiq\Service\RegisterObjectStore; use OCA\OpenRegister\Contract\ObjectEntityInterface; use OCA\OpenRegister\Contract\ObjectServiceInterface; use PHPUnit\Framework\TestCase; @@ -162,7 +162,7 @@ function ( static fn (array $config = []): array => $state->findAll($config) ); - return new ApprovalRouteService(new ApprovalRouteStore($facade)); + return new ApprovalRouteService(new RegisterObjectStore($facade)); } /** diff --git a/tests/Unit/Service/GovernanceBodyCommandServiceTest.php b/tests/Unit/Service/GovernanceBodyCommandServiceTest.php new file mode 100644 index 000000000..363a13b08 --- /dev/null +++ b/tests/Unit/Service/GovernanceBodyCommandServiceTest.php @@ -0,0 +1,453 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\Decidiq\Tests\Unit\Service + * @author Conduction B.V. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 + * @link https://github.com/ConductionNL/decidiq + */ + +declare(strict_types=1); + +namespace OCA\Decidiq\Tests\Unit\Service; + +use OCA\Decidiq\Service\GovernanceBodyCommandService; +use OCA\Decidiq\Service\RegisterObjectStore; +use OCA\OpenRegister\Contract\ObjectEntityInterface; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +/** + * Covers the cross-app governance-body command engine. + * + * Every idempotency assertion here calls the seam TWICE and then COUNTS rows. + * A test that calls it once cannot tell an idempotent write from a duplicating + * one — it sees a body either way — which is exactly how a fan-out migration + * ships looking correct and mints a second Person per member on the re-run. + */ +class GovernanceBodyCommandServiceTest extends TestCase { + + /** + * In-memory OpenRegister stand-in. + * + * @var object + */ + private object $objectService; + + /** + * Build the fake register. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->objectService = new class { + /** + * Stored rows, keyed by schema then uuid. + * + * @var array>> + */ + public array $rows = ['governance-body' => [], 'person' => [], 'membership' => []]; + + /** + * Schemas whose next write must throw, keyed by schema slug. + * + * @var array + */ + public array $failWriteAt = []; + + /** + * Writes seen per schema. + * + * @var array + */ + public array $writes = []; + + /** + * Uuid counter. + * + * @var int + */ + private int $counter = 0; + + /** + * Create or patch a row. + * + * @param array $object The object or patch. + * @param string $register The register. + * @param string $schema The schema. + * @param string|null $uuid The uuid. + * + * @return array The stored row. + */ + public function saveObject(array $object, string $register, string $schema, ?string $uuid = null): array { + $this->writes[$schema] = (($this->writes[$schema] ?? 0) + 1); + if (($this->failWriteAt[$schema] ?? null) === $this->writes[$schema]) { + throw new RuntimeException('simulated write failure on ' . $schema); + } + + if ($uuid === null) { + $this->counter++; + $uuid = $schema . '-' . $this->counter; + $this->rows[$schema][$uuid] = ($object + ['id' => $uuid]); + + return $this->rows[$schema][$uuid]; + } + + $this->rows[$schema][$uuid] = (array_merge(($this->rows[$schema][$uuid] ?? []), $object) + ['id' => $uuid]); + + return $this->rows[$schema][$uuid]; + } + + /** + * Filter the stored rows. + * + * @param array $config The query config. + * + * @return array> The rows. + */ + public function findAll(array $config): array { + $filters = $config['filters']; + $schema = $filters['schema']; + unset($filters['register'], $filters['schema']); + + $out = []; + foreach (($this->rows[$schema] ?? []) as $row) { + $matches = true; + foreach ($filters as $key => $value) { + if (($row[$key] ?? null) !== $value) { + $matches = false; + break; + } + } + + if ($matches === true) { + $out[] = $row; + } + } + + return $out; + } + }; + + }//end setUp() + + /** + * The service under test. + * + * @return GovernanceBodyCommandService The service. + */ + private function service(): GovernanceBodyCommandService { + $state = $this->objectService; + $facade = $this->createMock(ObjectServiceInterface::class); + $facade->method('saveObject')->willReturnCallback( + function ( + array $object, + ?array $extend = [], + string|int|null $register = null, + string|int|null $schema = null, + ?string $uuid = null, + ) use ($state): ObjectEntityInterface { + $row = $state->saveObject($object, (string)$register, (string)$schema, $uuid); + + $entity = $this->createMock(ObjectEntityInterface::class); + $entity->method('jsonSerialize')->willReturn($row); + + return $entity; + } + ); + $facade->method('findAll')->willReturnCallback( + static fn (array $config = []): array => $state->findAll($config) + ); + + return new GovernanceBodyCommandService(new RegisterObjectStore($facade)); + + }//end service() + + /** + * A committee command body. + * + * @param boolean $active The active flag. + * + * @return array The body fields. + */ + private function body(bool $active = true): array { + return [ + 'name' => 'Bezwaarcommissie sociaal domein', + 'bodyType' => 'advisory-body', + 'domain' => 'social_domain', + 'active' => $active, + 'quorum' => 3, + 'statutoryBasis' => 'Awb 7:13', + ]; + + }//end body() + + /** + * A three-seat roster. + * + * @param string $aliceRole Alice's role. + * + * @return array> The roster. + */ + private function roster(string $aliceRole = 'chair'): array { + return [ + ['uid' => 'alice', 'role' => $aliceRole], + ['uid' => 'bob', 'role' => 'member'], + ['uid' => 'carol', 'role' => 'secretary', 'external' => true], + ]; + + }//end roster() + + /** + * Count stored rows of one schema. + * + * @param string $schema The schema slug. + * + * @return int The count. + */ + private function countRows(string $schema): int { + return count($this->objectService->rows[$schema]); + + }//end countRows() + + /** + * REQ-GBE-002: a command raises a body and reports its id. + * + * @return void + */ + public function testCommandRaisesBodyAndReportsId(): void { + $result = $this->service()->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + + $this->assertTrue($result['created']); + $this->assertNotSame('', $result['id']); + $this->assertSame(1, $this->countRows('governance-body')); + + $stored = $this->objectService->rows['governance-body'][$result['id']]; + $this->assertSame('dossiq', $stored['sourceApp']); + $this->assertSame('cmte-1', $stored['externalReference']); + $this->assertSame('Awb 7:13', $stored['statutoryBasis']); + $this->assertSame(3, $stored['quorum']); + + }//end testCommandRaisesBodyAndReportsId() + + /** + * REQ-GBE-003: a second identical command creates one body, not two. + * + * @return void + */ + public function testSecondIdenticalCommandCreatesOneBody(): void { + $service = $this->service(); + $first = $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + $second = $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + + $this->assertSame(1, $this->countRows('governance-body')); + $this->assertSame($first['id'], $second['id']); + $this->assertTrue($first['created']); + $this->assertFalse($second['created']); + + }//end testSecondIdenticalCommandCreatesOneBody() + + /** + * REQ-GBE-003: the roster fan-out is idempotent too. + * + * @return void + */ + public function testSecondIdenticalCommandCreatesOneSeatPerMember(): void { + $service = $this->service(); + $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + + $this->assertSame(3, $this->countRows('person')); + $this->assertSame(3, $this->countRows('membership')); + + }//end testSecondIdenticalCommandCreatesOneSeatPerMember() + + /** + * REQ-GBE-003: a changed role updates the seat rather than adding one. + * + * @return void + */ + public function testChangedRoleUpdatesTheSeat(): void { + $service = $this->service(); + $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster('member')); + $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster('chair')); + + $this->assertSame(3, $this->countRows('membership')); + + $alice = null; + foreach ($this->objectService->rows['person'] as $row) { + if ($row['nextcloudUserId'] === 'alice') { + $alice = $row['id']; + } + } + + $roles = []; + foreach ($this->objectService->rows['membership'] as $row) { + if ($row['person'] === $alice) { + $roles[] = $row['role']; + } + } + + $this->assertSame(['chair'], $roles); + + }//end testChangedRoleUpdatesTheSeat() + + /** + * REQ-GBE-003: a different externalReference is a different body. + * + * @return void + */ + public function testDifferentReferenceIsADifferentBody(): void { + $service = $this->service(); + $service->upsert('dossiq', 'cmte-1', $this->body(), []); + $service->upsert('dossiq', 'cmte-2', $this->body(), []); + + $this->assertSame(2, $this->countRows('governance-body')); + + }//end testDifferentReferenceIsADifferentBody() + + /** + * REQ-GBE-004: a crash mid-fan-out leaves a body the next run completes. + * + * @return void + */ + public function testCrashMidRosterLeavesACompletableBody(): void { + // Writes to `membership`: 1 = alice, 2 = bob. Fail on bob. + $this->objectService->failWriteAt['membership'] = 2; + + $service = $this->service(); + try { + $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + $this->fail('the simulated write failure should have propagated'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('simulated write failure', $e->getMessage()); + } + + $this->assertSame(1, $this->countRows('governance-body'), 'the body is written before the roster'); + $this->assertSame(1, $this->countRows('membership')); + + $this->objectService->failWriteAt = []; + $result = $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + + $this->assertFalse($result['created'], 'the re-run matches the half-written body'); + $this->assertSame(1, $this->countRows('governance-body')); + $this->assertSame(3, $this->countRows('membership')); + $this->assertSame(3, $this->countRows('person')); + + }//end testCrashMidRosterLeavesACompletableBody() + + /** + * REQ-GBE-005: an omitted `active` is refused, not defaulted. + * + * @return void + */ + public function testOmittedActiveIsRefused(): void { + $body = $this->body(); + unset($body['active']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/never defaulted/'); + + try { + $this->service()->upsert('dossiq', 'cmte-1', $body, $this->roster()); + } finally { + $this->assertSame(0, $this->countRows('governance-body'), 'nothing is written on a refusal'); + } + + }//end testOmittedActiveIsRefused() + + /** + * REQ-GBE-005: an archived committee stays archived across a re-run. + * + * @return void + */ + public function testArchivedBodyStaysArchivedAcrossRerun(): void { + $service = $this->service(); + $first = $service->upsert('dossiq', 'cmte-1', $this->body(active: false), []); + $service->upsert('dossiq', 'cmte-1', $this->body(active: false), []); + + $this->assertFalse($this->objectService->rows['governance-body'][$first['id']]['active']); + + }//end testArchivedBodyStaysArchivedAcrossRerun() + + /** + * A producer cannot overwrite the provenance pair through the attribute bag. + * + * @return void + */ + public function testProducerCannotOverwriteTheProvenancePair(): void { + $body = ($this->body() + ['sourceApp' => 'evil', 'externalReference' => 'other']); + $result = $this->service()->upsert('dossiq', 'cmte-1', $body, []); + + $stored = $this->objectService->rows['governance-body'][$result['id']]; + $this->assertSame('dossiq', $stored['sourceApp']); + $this->assertSame('cmte-1', $stored['externalReference']); + + }//end testProducerCannotOverwriteTheProvenancePair() + + /** + * An unknown membership role is refused rather than stored. + * + * @return void + */ + public function testUnknownRoleIsRefused(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/Unknown membership role/'); + + $this->service()->upsert( + 'dossiq', + 'cmte-1', + $this->body(), + [['uid' => 'alice', 'role' => 'grand-vizier']] + ); + + }//end testUnknownRoleIsRefused() + + /** + * A command without its provenance pair is refused. + * + * @return void + */ + public function testMissingProvenanceIsRefused(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/sourceApp and externalReference/'); + + $this->service()->upsert('', 'cmte-1', $this->body(), []); + + }//end testMissingProvenanceIsRefused() + + /** + * Awb 7:13(2): the secretary's external flag survives the fan-out. + * + * @return void + */ + public function testExternalFlagIsCarriedOntoTheMembership(): void { + $this->service()->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + + $carol = null; + foreach ($this->objectService->rows['person'] as $row) { + if ($row['nextcloudUserId'] === 'carol') { + $carol = $row['id']; + } + } + + $external = null; + foreach ($this->objectService->rows['membership'] as $row) { + if ($row['person'] === $carol) { + $external = $row['external']; + } + } + + $this->assertTrue($external); + + }//end testExternalFlagIsCarriedOntoTheMembership() + +}//end class