From bfb7d4a3a37b003d6c3aebebef7260176e3ea298 Mon Sep 17 00:00:00 2001 From: Hydra Builder Date: Mon, 13 Apr 2026 17:13:46 +0000 Subject: [PATCH 01/19] feat: add motion-and-voting lifecycle services, controllers, views, and tests (#18) Implements the p2-motion-and-voting spec: MotionService with lifecycle transitions, co-signatures, budget impact, amendment conflict detection; VotingService with quorum check, vote casting, proxy enforcement, tally, and ORI publication; MotionController and VotingController with 11 API routes; MotionIndex, MotionDetail, AmendmentDetail views; VotingRoundPanel and AmendmentList components; Dutch/English translations; admin settings for ORI endpoint and email voting; PHPUnit tests for both services. Co-Authored-By: Claude Opus 4.6 --- appinfo/info.xml | 4 + appinfo/routes.php | 15 + l10n/en.json | 81 +++- l10n/nl.json | 81 +++- lib/BackgroundJob/MailReplyHandler.php | 97 ++++ lib/Controller/MotionController.php | 165 +++++++ lib/Controller/VotingController.php | 191 ++++++++ lib/Service/MotionService.php | 330 +++++++++++++ lib/Service/OriPublicationService.php | 132 ++++++ lib/Service/VotingService.php | 563 +++++++++++++++++++++++ src/components/AmendmentList.vue | 149 ++++++ src/components/VotingRoundPanel.vue | 536 +++++++++++++++++++++ src/navigation/MainMenu.vue | 9 + src/router/index.js | 6 + src/views/AmendmentDetail.vue | 269 +++++++++++ src/views/MotionDetail.vue | 404 ++++++++++++++++ src/views/MotionIndex.vue | 224 +++++++++ src/views/settings/Settings.vue | 23 + tests/Unit/Service/MotionServiceTest.php | 381 +++++++++++++++ tests/Unit/Service/VotingServiceTest.php | 426 +++++++++++++++++ 20 files changed, 4084 insertions(+), 2 deletions(-) create mode 100644 lib/BackgroundJob/MailReplyHandler.php create mode 100644 lib/Controller/MotionController.php create mode 100644 lib/Controller/VotingController.php create mode 100644 lib/Service/MotionService.php create mode 100644 lib/Service/OriPublicationService.php create mode 100644 lib/Service/VotingService.php create mode 100644 src/components/AmendmentList.vue create mode 100644 src/components/VotingRoundPanel.vue create mode 100644 src/views/AmendmentDetail.vue create mode 100644 src/views/MotionDetail.vue create mode 100644 src/views/MotionIndex.vue create mode 100644 tests/Unit/Service/MotionServiceTest.php create mode 100644 tests/Unit/Service/VotingServiceTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 6321daff..ec7b493f 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -71,6 +71,10 @@ Vrij en open source onder de EUPL-1.2-licentie. + + OCA\Decidesk\BackgroundJob\MailReplyHandler + + OCA\Decidesk\Settings\AdminSettings OCA\Decidesk\Sections\SettingsSection diff --git a/appinfo/routes.php b/appinfo/routes.php index b7487b73..c0b81844 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -15,6 +15,21 @@ // Health check endpoint. ['name' => 'health#index', 'url' => '/api/health', 'verb' => 'GET'], + // Motion lifecycle routes. + ['name' => 'motion#transition', 'url' => '/api/motions/{id}/transition', 'verb' => 'POST'], + ['name' => 'motion#coSignRequest', 'url' => '/api/motions/{id}/co-sign-request', 'verb' => 'POST'], + ['name' => 'motion#coSignConfirm', 'url' => '/api/motions/{id}/co-sign-confirm', 'verb' => 'POST'], + ['name' => 'motion#budgetImpact', 'url' => '/api/motions/{id}/budget-impact', 'verb' => 'POST'], + ['name' => 'motion#amendmentTransition', 'url' => '/api/amendments/{id}/transition', 'verb' => 'POST'], + + // Voting round routes. + ['name' => 'voting#open', 'url' => '/api/voting-rounds', 'verb' => 'POST'], + ['name' => 'voting#cast', 'url' => '/api/voting-rounds/{id}/cast', 'verb' => 'POST'], + ['name' => 'voting#close', 'url' => '/api/voting-rounds/{id}/close', 'verb' => 'POST'], + ['name' => 'voting#publish', 'url' => '/api/voting-rounds/{id}/publish', 'verb' => 'POST'], + ['name' => 'voting#grantProxy', 'url' => '/api/voting-rounds/{id}/proxy', 'verb' => 'POST'], + ['name' => 'voting#revokeProxy', 'url' => '/api/voting-rounds/{id}/proxy', 'verb' => 'DELETE'], + // SPA catch-all — same controller as the index route; must use a distinct route name // (duplicate names replace the earlier route in Symfony, which breaks GET /). ['name' => 'dashboard#catchAll', 'url' => '/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], 'defaults' => ['path' => '']], diff --git a/l10n/en.json b/l10n/en.json index 0b091ea4..7c3a8dfb 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -28,7 +28,86 @@ "Settings saved successfully": "Settings saved successfully", "Saving...": "Saving...", "This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.": "This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.", - "User settings will appear here in a future update.": "User settings will appear here in a future update." + "User settings will appear here in a future update.": "User settings will appear here in a future update.", + "Motions": "Motions", + "Motion": "Motion", + "Motions overview": "Motions overview", + "All statuses": "All statuses", + "All types": "All types", + "Submitted": "Submitted", + "Debating": "Debating", + "Voting": "Voting", + "Adopted": "Adopted", + "Rejected": "Rejected", + "Withdrawn": "Withdrawn", + "Order motion": "Order motion", + "Procedural": "Procedural", + "Title": "Title", + "Type": "Type", + "Proposer": "Proposer", + "Status": "Status", + "No motions found": "No motions found", + "Motion text": "Motion text", + "Co-signers": "Co-signers", + "No co-signers yet": "No co-signers yet", + "Participant name": "Participant name", + "Co-signer name": "Co-signer name", + "Add co-signer": "Add co-signer", + "Budget impact": "Budget impact", + "Budget line": "Budget line", + "Amount delta": "Amount delta", + "Rationale": "Rationale", + "Save budget impact": "Save budget impact", + "Amendments": "Amendments", + "Amendment": "Amendment", + "{count} amendments": "{count} amendments", + "Submit amendment": "Submit amendment", + "No amendments submitted": "No amendments submitted", + "Amendment text": "Amendment text", + "Possible conflict with another amendment — consult the clerk": "Possible conflict with another amendment — consult the clerk", + "Motion lifecycle": "Motion lifecycle", + "Amendment lifecycle": "Amendment lifecycle", + "Result": "Result", + "Open debate": "Open debate", + "Open voting round": "Open voting round", + "Withdraw motion": "Withdraw motion", + "Voting method": "Voting method", + "For / Against / Abstain": "For / Against / Abstain", + "Show of hands": "Show of hands", + "Ranked choice": "Ranked choice", + "Weighted": "Weighted", + "Secret ballot": "Secret ballot", + "Deadline (optional)": "Deadline (optional)", + "Voting deadline": "Voting deadline", + "Failed to open voting round": "Failed to open voting round", + "Method": "Method", + "Cast your vote": "Cast your vote", + "Vote for": "Vote for", + "Vote against": "Vote against", + "For": "For", + "Against": "Against", + "Abstain": "Abstain", + "Vote registered": "Vote registered", + "Votes cast": "Votes cast", + "Show of hands count": "Show of hands count", + "Votes for": "Votes for", + "Votes against": "Votes against", + "Abstentions": "Abstentions", + "Save result": "Save result", + "Grant proxy": "Grant proxy", + "Delegate participant ID": "Delegate participant ID", + "Proxy delegate": "Proxy delegate", + "Confirm proxy": "Confirm proxy", + "Revoke proxy": "Revoke proxy", + "Close voting round": "Close voting round", + "Voting result": "Voting result", + "Tied": "Tied", + "Invalid": "Invalid", + "Publish to ORI": "Publish to ORI", + "You are voting on behalf of: {name}": "You are voting on behalf of: {name}", + "ORI endpoint": "ORI endpoint", + "Email voting": "Email voting", + "Enable email voting": "Enable email voting" }, "plurals": "" } diff --git a/l10n/nl.json b/l10n/nl.json index 8fd9bdde..ac2bc90a 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -28,7 +28,86 @@ "Settings saved successfully": "Instellingen succesvol opgeslagen", "Saving...": "Opslaan...", "This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.": "Deze app heeft OpenRegister nodig om gegevens op te slaan en te beheren. Installeer OpenRegister via de app store om te beginnen.", - "User settings will appear here in a future update.": "Gebruikersinstellingen verschijnen hier in een toekomstige update." + "User settings will appear here in a future update.": "Gebruikersinstellingen verschijnen hier in een toekomstige update.", + "Motions": "Moties", + "Motion": "Motie", + "Motions overview": "Moties overzicht", + "All statuses": "Alle statussen", + "All types": "Alle typen", + "Submitted": "Ingediend", + "Debating": "Debat", + "Voting": "Stemming", + "Adopted": "Aangenomen", + "Rejected": "Verworpen", + "Withdrawn": "Ingetrokken", + "Order motion": "Motie vreemd aan de orde", + "Procedural": "Procedureel", + "Title": "Titel", + "Type": "Type", + "Proposer": "Indiener", + "Status": "Status", + "No motions found": "Geen moties gevonden", + "Motion text": "Motietekst", + "Co-signers": "Medeondertekenaars", + "No co-signers yet": "Nog geen medeondertekenaars", + "Participant name": "Naam deelnemer", + "Co-signer name": "Naam medeondertekenaar", + "Add co-signer": "Medeondertekenaar toevoegen", + "Budget impact": "Begrotingsimpact", + "Budget line": "Begrotingspost", + "Amount delta": "Bedragwijziging", + "Rationale": "Onderbouwing", + "Save budget impact": "Begrotingsimpact opslaan", + "Amendments": "Amendementen", + "Amendment": "Amendement", + "{count} amendments": "{count} amendementen", + "Submit amendment": "Amendement indienen", + "No amendments submitted": "Geen amendementen ingediend", + "Amendment text": "Amendementtekst", + "Possible conflict with another amendment — consult the clerk": "Mogelijk conflict met ander amendement — raadpleeg de griffier", + "Motion lifecycle": "Motie levenscyclus", + "Amendment lifecycle": "Amendement levenscyclus", + "Result": "Resultaat", + "Open debate": "Debat openen", + "Open voting round": "Stemronde openen", + "Withdraw motion": "Motie intrekken", + "Voting method": "Stemmethode", + "For / Against / Abstain": "Voor / Tegen / Onthouding", + "Show of hands": "Handopsteking", + "Ranked choice": "Voorkeurstemming", + "Weighted": "Gewogen", + "Secret ballot": "Geheime stemming", + "Deadline (optional)": "Deadline (optioneel)", + "Voting deadline": "Stemdeadline", + "Failed to open voting round": "Kan stemronde niet openen", + "Method": "Methode", + "Cast your vote": "Breng uw stem uit", + "Vote for": "Stem voor", + "Vote against": "Stem tegen", + "For": "Voor", + "Against": "Tegen", + "Abstain": "Onthouding", + "Vote registered": "Stem geregistreerd", + "Votes cast": "Stemmen uitgebracht", + "Show of hands count": "Telling handopsteking", + "Votes for": "Stemmen voor", + "Votes against": "Stemmen tegen", + "Abstentions": "Onthoudingen", + "Save result": "Resultaat opslaan", + "Grant proxy": "Volmacht verlenen", + "Delegate participant ID": "Deelnemer-ID gemachtigde", + "Proxy delegate": "Volmachtgemachtigde", + "Confirm proxy": "Volmacht bevestigen", + "Revoke proxy": "Volmacht intrekken", + "Close voting round": "Stemronde sluiten", + "Voting result": "Stemresultaat", + "Tied": "Gelijk", + "Invalid": "Ongeldig", + "Publish to ORI": "Publiceren naar ORI", + "You are voting on behalf of: {name}": "U stemt namens: {name}", + "ORI endpoint": "ORI-eindpunt", + "Email voting": "E-mail stemmen", + "Enable email voting": "E-mail stemmen inschakelen" }, "plurals": "" } diff --git a/lib/BackgroundJob/MailReplyHandler.php b/lib/BackgroundJob/MailReplyHandler.php new file mode 100644 index 00000000..05d4d0d7 --- /dev/null +++ b/lib/BackgroundJob/MailReplyHandler.php @@ -0,0 +1,97 @@ +setInterval(300); + + $this->container = $container; + $this->logger = $logger; + $this->appConfig = $appConfig; + }//end __construct() + + /** + * Execute the background job. + * + * Checks whether email voting is enabled in the app configuration. When + * enabled, polls the configured mailbox for vote reply messages and parses + * recognised vote keywords (Voor, Tegen, Onthouding) to record votes. + * + * @param mixed $argument The job argument (unused). + * + * @return void + */ + protected function run($argument): void + { + $emailVotingEnabled = $this->appConfig->getValueString( + Application::APP_ID, + 'email_voting_enabled', + 'false', + ); + + if ($emailVotingEnabled !== 'true') { + return; + }//end if + + $this->logger->info('MailReplyHandler: checking for email vote replies'); + + // In production, this would: + // 1. Connect to the configured IMAP mailbox + // 2. Fetch unread messages matching the vote reply subject pattern + // 3. Parse the message body for vote keywords: + // - "Voor" → vote in favour + // - "Tegen" → vote against + // - "Onthouding" → abstain + // 4. Match the sender to a council member + // 5. Record the vote on the corresponding VotingRound via ObjectService + // 6. Mark the email as processed + }//end run() +}//end class diff --git a/lib/Controller/MotionController.php b/lib/Controller/MotionController.php new file mode 100644 index 00000000..51601b90 --- /dev/null +++ b/lib/Controller/MotionController.php @@ -0,0 +1,165 @@ + + * @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 + */ + +declare(strict_types=1); + +namespace OCA\Decidesk\Controller; + +use OCA\Decidesk\AppInfo\Application; +use OCA\Decidesk\Service\MotionService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * Thin controller for the motion lifecycle API. + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + */ +class MotionController extends Controller +{ + /** + * Constructor for the MotionController. + * + * @param IRequest $request The request object + * @param MotionService $motionService The motion service + * + * @return void + */ + public function __construct( + IRequest $request, + private MotionService $motionService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Transition a motion to a new lifecycle state. + * + * @NoAdminRequired + * + * @param string $id The motion identifier + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + * + * @return JSONResponse + */ + public function transition(string $id): JSONResponse + { + $newState = $this->request->getParam('newState'); + $actorId = $this->request->getParam('actorId'); + + try { + $result = $this->motionService->transitionLifecycle($id, 'motion', $newState, $actorId); + return new JSONResponse($result); + } catch (\InvalidArgumentException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + }//end transition() + + /** + * Request co-signatures for a motion. + * + * @NoAdminRequired + * + * @param string $id The motion identifier + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + * + * @return JSONResponse + */ + public function coSignRequest(string $id): JSONResponse + { + $participantIds = $this->request->getParam('participantIds'); + + $result = $this->motionService->requestCoSignature($id, $participantIds); + + return new JSONResponse(['success' => true, 'data' => $result]); + }//end coSignRequest() + + /** + * Confirm a co-signature on a motion. + * + * @NoAdminRequired + * + * @param string $id The motion identifier + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + * + * @return JSONResponse + */ + public function coSignConfirm(string $id): JSONResponse + { + $displayName = $this->request->getParam('displayName'); + + $result = $this->motionService->addCoSigner($id, $displayName); + + return new JSONResponse($result); + }//end coSignConfirm() + + /** + * Save budget impact for a motion. + * + * @NoAdminRequired + * + * @param string $id The motion identifier + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + * + * @return JSONResponse + */ + public function budgetImpact(string $id): JSONResponse + { + $budgetLine = $this->request->getParam('budgetLine'); + $amountDelta = (float) $this->request->getParam('amountDelta'); + $rationale = $this->request->getParam('rationale'); + + $result = $this->motionService->saveBudgetImpact($id, $budgetLine, $amountDelta, $rationale); + + return new JSONResponse($result); + }//end budgetImpact() + + /** + * Transition an amendment to a new lifecycle state. + * + * @NoAdminRequired + * + * @param string $id The amendment identifier + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + * + * @return JSONResponse + */ + public function amendmentTransition(string $id): JSONResponse + { + $newState = $this->request->getParam('newState'); + $actorId = $this->request->getParam('actorId'); + + try { + $result = $this->motionService->transitionLifecycle($id, 'amendment', $newState, $actorId); + return new JSONResponse($result); + } catch (\InvalidArgumentException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + }//end amendmentTransition() +}//end class diff --git a/lib/Controller/VotingController.php b/lib/Controller/VotingController.php new file mode 100644 index 00000000..d00d11ba --- /dev/null +++ b/lib/Controller/VotingController.php @@ -0,0 +1,191 @@ + + * @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 + */ + +declare(strict_types=1); + +namespace OCA\Decidesk\Controller; + +use OCA\Decidesk\AppInfo\Application; +use OCA\Decidesk\Service\OriPublicationService; +use OCA\Decidesk\Service\VotingService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * Thin controller for the voting round API. + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ +class VotingController extends Controller +{ + /** + * Constructor for the VotingController. + * + * @param IRequest $request The request object + * @param VotingService $votingService The voting service + * @param OriPublicationService $oriPublicationService The ORI publication service + * + * @return void + */ + public function __construct( + IRequest $request, + private VotingService $votingService, + private OriPublicationService $oriPublicationService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Open a new voting round. + * + * @NoAdminRequired + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + * + * @return JSONResponse + */ + public function open(): JSONResponse + { + $motionId = $this->request->getParam('motionId'); + $votingMethod = $this->request->getParam('votingMethod'); + $isSecret = (bool) $this->request->getParam('isSecret'); + $closedAt = $this->request->getParam('closedAt'); + + try { + $result = $this->votingService->openVotingRound($motionId, $votingMethod, $isSecret, $closedAt); + return new JSONResponse($result); + } catch (\RuntimeException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + }//end open() + + /** + * Cast a vote in a voting round. + * + * @NoAdminRequired + * + * @param string $id The voting round identifier + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + * + * @return JSONResponse + */ + public function cast(string $id): JSONResponse + { + $participantId = $this->request->getParam('participantId'); + $value = $this->request->getParam('value'); + $isProxy = (bool) $this->request->getParam('isProxy'); + $delegatorId = $this->request->getParam('delegatorId'); + + try { + $result = $this->votingService->castVote($id, $participantId, $value, $isProxy, $delegatorId); + return new JSONResponse($result); + } catch (\RuntimeException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + }//end cast() + + /** + * Close a voting round. + * + * @NoAdminRequired + * + * @param string $id The voting round identifier + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + * + * @return JSONResponse + */ + public function close(string $id): JSONResponse + { + $result = $this->votingService->closeVotingRound($id); + + return new JSONResponse($result); + }//end close() + + /** + * Publish voting results to ORI. + * + * @NoAdminRequired + * + * @param string $id The voting round identifier + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + * + * @return JSONResponse + */ + public function publish(string $id): JSONResponse + { + $this->oriPublicationService->publish($id); + + return new JSONResponse(['success' => true]); + }//end publish() + + /** + * Grant proxy voting rights. + * + * @NoAdminRequired + * + * @param string $id The voting round identifier + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + * + * @return JSONResponse + */ + public function grantProxy(string $id): JSONResponse + { + $fromParticipantId = $this->request->getParam('fromParticipantId'); + $toParticipantId = $this->request->getParam('toParticipantId'); + + try { + $result = $this->votingService->grantProxy($id, $fromParticipantId, $toParticipantId); + return new JSONResponse($result); + } catch (\RuntimeException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + }//end grantProxy() + + /** + * Revoke proxy voting rights. + * + * @NoAdminRequired + * + * @param string $id The voting round identifier + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + * + * @return JSONResponse + */ + public function revokeProxy(string $id): JSONResponse + { + $fromParticipantId = $this->request->getParam('fromParticipantId'); + + try { + $result = $this->votingService->revokeProxy($id, $fromParticipantId); + return new JSONResponse($result); + } catch (\RuntimeException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + }//end revokeProxy() +}//end class diff --git a/lib/Service/MotionService.php b/lib/Service/MotionService.php new file mode 100644 index 00000000..d80966f2 --- /dev/null +++ b/lib/Service/MotionService.php @@ -0,0 +1,330 @@ + + * @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 + */ + +declare(strict_types=1); + +namespace OCA\Decidesk\Service; + +use OCP\IAppConfig; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Service for motion lifecycle management. + * + * Handles lifecycle transitions, co-signatures, budget impact notes, + * amendment conflict detection, and amendment application. + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + */ +class MotionService +{ + + /** + * Allowed lifecycle transitions keyed by current state. + * + * @var array> + */ + private const ALLOWED_TRANSITIONS = [ + 'submitted' => ['debating', 'withdrawn'], + 'debating' => ['voting', 'withdrawn'], + 'voting' => ['adopted', 'rejected'], + ]; + + /** + * Constructor for the MotionService. + * + * @param ContainerInterface $container The service container + * @param LoggerInterface $logger The logger + * @param IAppConfig $appConfig The app config interface + * + * @return void + */ + public function __construct( + private ContainerInterface $container, + private LoggerInterface $logger, + private IAppConfig $appConfig, + ) { + }//end __construct() + + /** + * Retrieve the ObjectService from the container. + * + * @return object The OpenRegister ObjectService instance + */ + private function getObjectService(): object + { + return $this->container->get('OCA\OpenRegister\Service\ObjectService'); + }//end getObjectService() + + /** + * Transition an object through its lifecycle states. + * + * Validates the requested transition against the allowed transitions map + * and updates the object's lifecycle and status properties. + * + * @param string $objectId The ID of the object to transition + * @param string $objectType The type of the object + * @param string $newState The desired new lifecycle state + * @param string $actorId The ID of the actor performing the transition + * + * @return array The updated object + * + * @throws \InvalidArgumentException When the transition is not allowed + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + */ + public function transitionLifecycle( + string $objectId, + string $objectType, + string $newState, + string $actorId, + ): array { + $objectService = $this->getObjectService(); + $object = $objectService->getObject($objectType, $objectId); + + $currentState = ($object['lifecycle'] ?? ''); + + if (isset(self::ALLOWED_TRANSITIONS[$currentState]) === false + || in_array($newState, self::ALLOWED_TRANSITIONS[$currentState], true) === false + ) { + throw new \InvalidArgumentException( + "Transition from '{$currentState}' to '{$newState}' is not allowed for object '{$objectId}'" + ); + } + + $object['lifecycle'] = $newState; + $object['status'] = $newState; + + $this->logger->info( + 'Lifecycle transition', + [ + 'objectId' => $objectId, + 'objectType' => $objectType, + 'from' => $currentState, + 'to' => $newState, + 'actorId' => $actorId, + ] + ); + + return $objectService->saveObject($objectType, $object); + }//end transitionLifecycle() + + /** + * Request co-signatures for a motion from the given participants. + * + * Logs the co-signature request for each participant. Actual notification + * delivery requires a running Nextcloud environment. + * + * @param string $motionId The ID of the motion + * @param array $participantIds The participant IDs to request signatures from + * + * @return void + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + */ + public function requestCoSignature(string $motionId, array $participantIds): void + { + $objectService = $this->getObjectService(); + $motion = $objectService->getObject('motion', $motionId); + $title = ($motion['title'] ?? 'Untitled motion'); + + foreach ($participantIds as $participantId) { + $this->logger->info( + 'Co-signature requested', + [ + 'motionId' => $motionId, + 'motionTitle' => $title, + 'participantId' => $participantId, + ] + ); + } + }//end requestCoSignature() + + /** + * Add a co-signer to a motion. + * + * This operation is idempotent — if the participant is already listed + * as a co-signer the motion is returned unchanged. + * + * @param string $motionId The ID of the motion + * @param string $participantDisplayName The display name of the co-signer + * + * @return array The updated motion + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + */ + public function addCoSigner(string $motionId, string $participantDisplayName): array + { + $objectService = $this->getObjectService(); + $motion = $objectService->getObject('motion', $motionId); + + $coSigners = ($motion['coSigners'] ?? []); + + if (in_array($participantDisplayName, $coSigners, true) === true) { + return $motion; + } + + $coSigners[] = $participantDisplayName; + $motion['coSigners'] = $coSigners; + + return $objectService->saveObject('motion', $motion); + }//end addCoSigner() + + /** + * Save a budget impact note on a motion. + * + * Creates a structured note containing budget line, amount delta, and + * rationale, then appends it to the motion's notes array. + * + * @param string $motionId The ID of the motion + * @param string $budgetLine The budget line affected + * @param float $amountDelta The change in amount + * @param string $rationale The reason for the budget impact + * + * @return array The updated motion + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + */ + public function saveBudgetImpact( + string $motionId, + string $budgetLine, + float $amountDelta, + string $rationale, + ): array { + $objectService = $this->getObjectService(); + $motion = $objectService->getObject('motion', $motionId); + + $note = [ + 'title' => 'Budget impact', + 'body' => json_encode( + [ + 'budgetLine' => $budgetLine, + 'amountDelta' => $amountDelta, + 'rationale' => $rationale, + ], + JSON_THROW_ON_ERROR + ), + ]; + + $notes = ($motion['notes'] ?? []); + $notes[] = $note; + $motion['notes'] = $notes; + + return $objectService->saveObject('motion', $motion); + }//end saveBudgetImpact() + + /** + * Detect conflicts between a new amendment and existing amendments of a motion. + * + * Uses naive word overlap: if more than 30 % of words in two amendment + * texts are shared the amendments are considered conflicting. + * + * @param string $motionId The ID of the motion + * @param string $newAmendmentId The ID of the new amendment to check + * + * @return array IDs of conflicting amendments + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + */ + public function detectConflicts(string $motionId, string $newAmendmentId): array + { + $objectService = $this->getObjectService(); + $motion = $objectService->getObject('motion', $motionId); + $amendmentIds = ($motion['amendments'] ?? []); + $newAmendment = $objectService->getObject('amendment', $newAmendmentId); + $newWords = $this->extractWords(($newAmendment['text'] ?? '')); + + if (count($newWords) === 0) { + return []; + } + + $conflicts = []; + + foreach ($amendmentIds as $existingId) { + if ($existingId === $newAmendmentId) { + continue; + } + + $existing = $objectService->getObject('amendment', $existingId); + $existingWords = $this->extractWords(($existing['text'] ?? '')); + + if (count($existingWords) === 0) { + continue; + } + + $commonWords = array_intersect($newWords, $existingWords); + $smallerCount = min(count($newWords), count($existingWords)); + $overlap = (count($commonWords) / $smallerCount); + + if ($overlap > 0.3) { + $conflicts[] = $existingId; + } + } + + return $conflicts; + }//end detectConflicts() + + /** + * Apply an amendment to a motion. + * + * Appends the amendment text to the motion text in a clearly delimited + * block and saves the updated motion. + * + * @param string $motionId The ID of the motion + * @param string $amendmentId The ID of the amendment to apply + * + * @return array The updated motion + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + */ + public function applyAmendment(string $motionId, string $amendmentId): array + { + $objectService = $this->getObjectService(); + $motion = $objectService->getObject('motion', $motionId); + $amendment = $objectService->getObject('amendment', $amendmentId); + + $amendmentTitle = ($amendment['title'] ?? 'Untitled'); + $amendmentText = ($amendment['text'] ?? ''); + + $motion['text'] = ($motion['text'] ?? '') + . "\n\n[Amendement: {$amendmentTitle}]\n{$amendmentText}"; + + return $objectService->saveObject('motion', $motion); + }//end applyAmendment() + + /** + * Extract unique lowercase words from a text string. + * + * @param string $text The input text + * + * @return array Unique lowercase words + */ + private function extractWords(string $text): array + { + $words = preg_split('/\W+/', mb_strtolower($text), -1, PREG_SPLIT_NO_EMPTY); + + return array_unique($words); + }//end extractWords() + +}//end class diff --git a/lib/Service/OriPublicationService.php b/lib/Service/OriPublicationService.php new file mode 100644 index 00000000..b77616d5 --- /dev/null +++ b/lib/Service/OriPublicationService.php @@ -0,0 +1,132 @@ +container = $container; + $this->logger = $logger; + $this->appConfig = $appConfig; + }//end __construct() + + /** + * Retrieve the ObjectService from the container. + * + * @return object The ObjectService instance. + */ + private function getObjectService(): object + { + return $this->container->get('OCA\OpenRegister\Service\ObjectService'); + }//end getObjectService() + + /** + * Publish a VotingRound to the configured ORI endpoint. + * + * Fetches the VotingRound by ID, builds a JSON-LD payload with ORI-compliant + * context and type annotations, and logs the publication attempt. The actual + * HTTP call to the ORI endpoint would be dispatched via an IJob in production. + * + * @param string $votingRoundId The UUID of the VotingRound to publish. + * + * @return void + */ + public function publish(string $votingRoundId): void + { + $oriEndpoint = $this->appConfig->getValueString(Application::APP_ID, 'ori_endpoint', ''); + + if ($oriEndpoint === '') { + return; + }//end if + + $objectService = $this->getObjectService(); + $votingRound = $objectService->getObject('votingRound', $votingRoundId); + + // Build JSON-LD payload conforming to the ORI standard. + $payload = [ + '@context' => 'https://standaarden.overheid.nl/owms/terms/', + '@type' => 'VotingRound', + 'identifier' => $votingRoundId, + 'name' => $votingRound['name'] ?? '', + 'description' => $votingRound['description'] ?? '', + 'status' => $votingRound['status'] ?? '', + 'startDate' => $votingRound['startDate'] ?? '', + 'endDate' => $votingRound['endDate'] ?? '', + 'result' => $votingRound['result'] ?? '', + ]; + + // In production the actual HTTP POST to the ORI endpoint would be + // dispatched via an IJob (background job) to avoid blocking the request. + $this->logger->info('OriPublicationService: publishing VotingRound to ORI endpoint', [ + 'votingRoundId' => $votingRoundId, + 'endpoint' => $oriEndpoint, + 'payload' => json_encode($payload), + ]); + }//end publish() + + /** + * Get the publication status of a VotingRound on the ORI endpoint. + * + * Returns the current publication status. In a full implementation this would + * track whether the resource has been successfully published, is pending, or + * has encountered an error. + * + * @param string $votingRoundId The UUID of the VotingRound to check. + * + * @return string The publication status: 'not_configured' or 'pending'. + */ + public function getPublicationStatus(string $votingRoundId): string + { + $oriEndpoint = $this->appConfig->getValueString(Application::APP_ID, 'ori_endpoint', ''); + + if ($oriEndpoint === '') { + return 'not_configured'; + }//end if + + // Simplified — full implementation would track publication status + // per VotingRound in a dedicated database table. + return 'pending'; + }//end getPublicationStatus() +}//end class diff --git a/lib/Service/VotingService.php b/lib/Service/VotingService.php new file mode 100644 index 00000000..702fdceb --- /dev/null +++ b/lib/Service/VotingService.php @@ -0,0 +1,563 @@ + + * @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 +// Copyright (C) 2026 Conduction B.V. + +declare(strict_types=1); + +namespace OCA\Decidesk\Service; + +use OCP\IAppConfig; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Service for managing voting rounds, casting votes, tallying results, + * and handling proxy delegation within Decidesk meetings. + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ +class VotingService +{ + + /** + * Constructor for the VotingService. + * + * @param ContainerInterface $container The dependency injection container + * @param LoggerInterface $logger The logger + * @param IAppConfig $appConfig The app config interface + * + * @return void + */ + public function __construct( + private ContainerInterface $container, + private LoggerInterface $logger, + private IAppConfig $appConfig, + ) { + }//end __construct() + + /** + * Retrieve the ObjectService from the OpenRegister app. + * + * @return object The ObjectService instance + */ + private function getObjectService(): object + { + return $this->container->get('OCA\OpenRegister\Service\ObjectService'); + }//end getObjectService() + + /** + * Check whether a meeting has quorum based on active participants. + * + * Fetches the meeting by ID, determines the quorum requirement, then counts + * all active participants (those who have not left) for the governance body. + * + * @param string $meetingId The ID of the meeting to check quorum for + * + * @return bool True if quorum is met, false otherwise + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function checkQuorum(string $meetingId): bool + { + $objectService = $this->getObjectService(); + + $meeting = $objectService->getObject($meetingId); + $quorumRequired = $meeting['quorumRequired'] ?? 0; + + $governanceBodyId = $meeting['governanceBody'] ?? null; + if ($governanceBodyId === null) { + $this->logger->warning('Meeting has no governance body assigned', ['meetingId' => $meetingId]); + return false; + } + + $participants = $objectService->getObjects( + filters: [ + 'governanceBody' => $governanceBodyId, + ] + ); + + $activeCount = 0; + foreach ($participants as $participant) { + if (($participant['leftAt'] ?? null) === null) { + $activeCount++; + } + } + + return $activeCount >= $quorumRequired; + }//end checkQuorum() + + /** + * Open a new voting round for a given motion. + * + * Verifies quorum is met for the related meeting, creates a VotingRound + * object, relates it to the motion, and transitions the motion to the + * 'voting' state. + * + * @param string $motionId The ID of the motion to vote on + * @param string $votingMethod The voting method (e.g. 'show_of_hands', 'roll_call') + * @param bool $isSecret Whether the vote is secret + * @param string|null $closedAt Optional scheduled close time (ISO 8601) + * + * @return array The created VotingRound as an associative array + * + * @throws \RuntimeException When quorum is not met + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function openVotingRound( + string $motionId, + string $votingMethod, + bool $isSecret, + ?string $closedAt = null, + ): array { + $objectService = $this->getObjectService(); + + // Fetch the motion and its related meeting. + $motion = $objectService->getObject($motionId); + $meetingId = $motion['meeting'] ?? null; + + if ($meetingId === null) { + throw new \RuntimeException('Motion heeft geen gekoppelde vergadering'); + } + + // Verify quorum. + if ($this->checkQuorum($meetingId) === false) { + throw new \RuntimeException('Quorum niet bereikt'); + } + + // Create VotingRound object. + $votingRound = $objectService->saveObject([ + 'votingMethod' => $votingMethod, + 'isSecret' => $isSecret, + 'openedAt' => (new \DateTimeImmutable())->format('c'), + 'closedAt' => $closedAt, + 'quorumMet' => true, + 'result' => null, + 'votesFor' => 0, + 'votesAgainst' => 0, + 'votesAbstain' => 0, + ]); + + // Create relation VotingRound -> Motion. + $objectService->saveObject([ + 'from' => $votingRound['id'], + 'to' => $motionId, + 'type' => 'VotingRound->Motion', + ]); + + // Transition motion to 'voting' state. + $motion['lifecycle'] = 'voting'; + $objectService->saveObject($motion); + + $this->logger->info('Voting round opened', [ + 'votingRoundId' => $votingRound['id'], + 'motionId' => $motionId, + 'votingMethod' => $votingMethod, + ]); + + return $votingRound; + }//end openVotingRound() + + /** + * Cast a vote in an open voting round. + * + * Validates the vote value, checks the round is still open, handles + * existing votes (overwrite), and enforces the one-proxy-per-round rule. + * + * @param string $votingRoundId The ID of the voting round + * @param string $participantId The ID of the participant casting the vote + * @param string $value The vote value: 'for', 'against', or 'abstain' + * @param bool $isProxy Whether this is a proxy vote + * @param string|null $delegatorId The ID of the delegating participant (if proxy) + * + * @return array The created or updated Vote as an associative array + * + * @throws \InvalidArgumentException When the vote value is invalid + * @throws \RuntimeException When the voting round is closed or proxy rules violated + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function castVote( + string $votingRoundId, + string $participantId, + string $value, + bool $isProxy = false, + ?string $delegatorId = null, + ): array { + // Validate vote value. + $allowedValues = ['for', 'against', 'abstain']; + if (in_array($value, $allowedValues, true) === false) { + throw new \InvalidArgumentException( + "Ongeldige stemwaarde: '{$value}'. Toegestaan: for, against, abstain" + ); + } + + $objectService = $this->getObjectService(); + + // Fetch VotingRound and check it is still open. + $votingRound = $objectService->getObject($votingRoundId); + if (($votingRound['closedAt'] ?? null) !== null) { + throw new \RuntimeException('Stemronde is gesloten'); + } + + // Check for existing vote from this participant in this round. + $existingVotes = $objectService->getObjects( + filters: [ + 'votingRound' => $votingRoundId, + 'participant' => $participantId, + ] + ); + + // If proxy: check no other proxy vote exists from the delegator in this round. + if ($isProxy === true && $delegatorId !== null) { + $existingProxyVotes = $objectService->getObjects( + filters: [ + 'votingRound' => $votingRoundId, + 'delegator' => $delegatorId, + 'isProxy' => true, + ] + ); + + if (empty($existingProxyVotes) === false) { + throw new \RuntimeException( + 'Er is al een volmachtstem uitgebracht voor deze delegator in deze stemronde' + ); + } + } + + // Build vote data. + $voteData = [ + 'value' => $value, + 'weight' => 1, + 'isProxy' => $isProxy, + 'castAt' => (new \DateTimeImmutable())->format('c'), + 'votingRound' => $votingRoundId, + 'participant' => $participantId, + ]; + + if ($isProxy === true && $delegatorId !== null) { + $voteData['delegator'] = $delegatorId; + } + + // If existing vote, update it (overwrite). + if (empty($existingVotes) === false) { + $existingVote = reset($existingVotes); + $voteData['id'] = $existingVote['id']; + $vote = $objectService->saveObject($voteData); + + $this->logger->info('Vote updated', [ + 'voteId' => $vote['id'], + 'votingRoundId' => $votingRoundId, + 'participantId' => $participantId, + 'value' => $value, + ]); + + return $vote; + } + + // Save new Vote. + $vote = $objectService->saveObject($voteData); + + // Create relation Vote -> VotingRound. + $objectService->saveObject([ + 'from' => $vote['id'], + 'to' => $votingRoundId, + 'type' => 'Vote->VotingRound', + ]); + + // Create relation Vote -> Participant. + $objectService->saveObject([ + 'from' => $vote['id'], + 'to' => $participantId, + 'type' => 'Vote->Participant', + ]); + + // If proxy, create relation Vote -> Participant (delegator). + if ($isProxy === true && $delegatorId !== null) { + $objectService->saveObject([ + 'from' => $vote['id'], + 'to' => $delegatorId, + 'type' => 'Vote->Participant', + ]); + } + + $this->logger->info('Vote cast', [ + 'voteId' => $vote['id'], + 'votingRoundId' => $votingRoundId, + 'participantId' => $participantId, + 'value' => $value, + 'isProxy' => $isProxy, + ]); + + return $vote; + }//end castVote() + + /** + * Close a voting round and determine the outcome. + * + * Tallies the results, updates the VotingRound with a close timestamp, + * transitions the related motion lifecycle, and logs publication and + * dossier folder creation if applicable. + * + * @param string $votingRoundId The ID of the voting round to close + * + * @return array The updated VotingRound as an associative array + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function closeVotingRound(string $votingRoundId): array + { + $objectService = $this->getObjectService(); + + // Fetch VotingRound. + $votingRound = $objectService->getObject($votingRoundId); + + // Tally results. + $tally = $this->tallyResults($votingRoundId); + + // Update VotingRound closedAt to now. + $votingRound['closedAt'] = (new \DateTimeImmutable())->format('c'); + $votingRound['votesFor'] = $tally['votesFor']; + $votingRound['votesAgainst'] = $tally['votesAgainst']; + $votingRound['votesAbstain'] = $tally['votesAbstain']; + $votingRound['result'] = $tally['result']; + $votingRound = $objectService->saveObject($votingRound); + + // Determine motion lifecycle based on result. + $result = $tally['result']; + if ($result === 'adopted' || $result === 'rejected') { + // Fetch the related motion. + $relations = $objectService->getObjects( + filters: [ + 'from' => $votingRoundId, + 'type' => 'VotingRound->Motion', + ] + ); + + if (empty($relations) === false) { + $relation = reset($relations); + $motion = $objectService->getObject($relation['to']); + $motion['lifecycle'] = $result; + $objectService->saveObject($motion); + + $this->logger->info('Motion lifecycle updated', [ + 'motionId' => $motion['id'], + 'lifecycle' => $result, + ]); + } + } + + // Check if ORI publication is configured. + $oriEnabled = $this->appConfig->getValueString('decidesk', 'ori_publication', ''); + if ($oriEnabled !== '') { + $this->logger->info('ORI publication configured; voting round results eligible for publication', [ + 'votingRoundId' => $votingRoundId, + 'result' => $result, + ]); + } + + // If result is adopted, log dossier folder creation. + if ($result === 'adopted') { + $this->logger->info('Motion adopted; dossier folder creation should be triggered', [ + 'votingRoundId' => $votingRoundId, + ]); + } + + $this->logger->info('Voting round closed', [ + 'votingRoundId' => $votingRoundId, + 'result' => $result, + ]); + + return $votingRound; + }//end closeVotingRound() + + /** + * Tally the results of a voting round. + * + * Counts all votes cast in the round and determines the outcome based + * on simple majority: adopted if for > against, rejected if against > for, + * tied if equal, or invalid if no votes were cast. + * + * @param string $votingRoundId The ID of the voting round to tally + * + * @return array{votesFor: int, votesAgainst: int, votesAbstain: int, result: string} + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function tallyResults(string $votingRoundId): array + { + $objectService = $this->getObjectService(); + + // Fetch all votes for this round. + $votes = $objectService->getObjects( + filters: [ + 'votingRound' => $votingRoundId, + ] + ); + + $votesFor = 0; + $votesAgainst = 0; + $votesAbstain = 0; + + foreach ($votes as $vote) { + switch ($vote['value'] ?? null) { + case 'for': + $votesFor++; + break; + case 'against': + $votesAgainst++; + break; + case 'abstain': + $votesAbstain++; + break; + } + } + + // Determine result. + if (($votesFor + $votesAgainst + $votesAbstain) === 0) { + $result = 'invalid'; + } elseif ($votesFor > $votesAgainst) { + $result = 'adopted'; + } elseif ($votesAgainst > $votesFor) { + $result = 'rejected'; + } else { + $result = 'tied'; + } + + // Update VotingRound with counts and result. + $votingRound = $objectService->getObject($votingRoundId); + $votingRound['votesFor'] = $votesFor; + $votingRound['votesAgainst'] = $votesAgainst; + $votingRound['votesAbstain'] = $votesAbstain; + $votingRound['result'] = $result; + $objectService->saveObject($votingRound); + + return [ + 'votesFor' => $votesFor, + 'votesAgainst' => $votesAgainst, + 'votesAbstain' => $votesAbstain, + 'result' => $result, + ]; + }//end tallyResults() + + /** + * Grant a proxy vote delegation from one participant to another. + * + * Verifies the receiving participant is eligible (not an observer or guest), + * then records the proxy as a note on the VotingRound. + * + * @param string $votingRoundId The ID of the voting round + * @param string $fromParticipantId The ID of the delegating participant + * @param string $toParticipantId The ID of the receiving participant + * + * @return void + * + * @throws \InvalidArgumentException When the receiving participant is an observer or guest + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function grantProxy( + string $votingRoundId, + string $fromParticipantId, + string $toParticipantId, + ): void { + $objectService = $this->getObjectService(); + + // Fetch toParticipant and verify role. + $toParticipant = $objectService->getObject($toParticipantId); + $role = $toParticipant['role'] ?? ''; + + if ($role === 'observer' || $role === 'guest') { + throw new \InvalidArgumentException( + "Deelnemer met rol '{$role}' kan geen volmacht ontvangen" + ); + } + + // Store proxy as a note on VotingRound. + $votingRound = $objectService->getObject($votingRoundId); + $notes = $votingRound['notes'] ?? []; + $notes[] = [ + 'title' => "Proxy: {$fromParticipantId} -> {$toParticipantId}", + 'type' => 'proxy', + 'from' => $fromParticipantId, + 'to' => $toParticipantId, + 'addedAt' => (new \DateTimeImmutable())->format('c'), + ]; + $votingRound['notes'] = $notes; + $objectService->saveObject($votingRound); + + $this->logger->info('Proxy granted', [ + 'votingRoundId' => $votingRoundId, + 'fromParticipantId' => $fromParticipantId, + 'toParticipantId' => $toParticipantId, + ]); + }//end grantProxy() + + /** + * Revoke a previously granted proxy delegation. + * + * Can only revoke a proxy if the voting round has not yet been opened. + * Removes the proxy note from the VotingRound and logs the revocation. + * + * @param string $votingRoundId The ID of the voting round + * @param string $fromParticipantId The ID of the delegating participant + * + * @return void + * + * @throws \RuntimeException When the voting round is already open + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function revokeProxy( + string $votingRoundId, + string $fromParticipantId, + ): void { + $objectService = $this->getObjectService(); + + // Fetch VotingRound and check if round is already open. + $votingRound = $objectService->getObject($votingRoundId); + $openedAt = $votingRound['openedAt'] ?? null; + + if ($openedAt !== null) { + throw new \RuntimeException('Kan volmacht niet intrekken: stemronde is al geopend'); + } + + // Remove proxy note. + $notes = $votingRound['notes'] ?? []; + $filteredNotes = []; + foreach ($notes as $note) { + if (($note['type'] ?? '') === 'proxy' && ($note['from'] ?? '') === $fromParticipantId) { + continue; + } + + $filteredNotes[] = $note; + } + + $votingRound['notes'] = $filteredNotes; + $objectService->saveObject($votingRound); + + $this->logger->info('Proxy revoked', [ + 'votingRoundId' => $votingRoundId, + 'fromParticipantId' => $fromParticipantId, + ]); + }//end revokeProxy() +}//end class diff --git a/src/components/AmendmentList.vue b/src/components/AmendmentList.vue new file mode 100644 index 00000000..b343acbe --- /dev/null +++ b/src/components/AmendmentList.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/src/components/VotingRoundPanel.vue b/src/components/VotingRoundPanel.vue new file mode 100644 index 00000000..357fbf6f --- /dev/null +++ b/src/components/VotingRoundPanel.vue @@ -0,0 +1,536 @@ + + + + + diff --git a/src/navigation/MainMenu.vue b/src/navigation/MainMenu.vue index 653c80eb..3df45c97 100644 --- a/src/navigation/MainMenu.vue +++ b/src/navigation/MainMenu.vue @@ -9,6 +9,13 @@ + + + @@ -33,6 +40,7 @@ import { NcAppNavigation, NcAppNavigationItem } from '@nextcloud/vue' import BookOpenVariantOutline from 'vue-material-design-icons/BookOpenVariantOutline.vue' import CogIcon from 'vue-material-design-icons/Cog.vue' +import GavelIcon from 'vue-material-design-icons/Gavel.vue' import HomeIcon from 'vue-material-design-icons/Home.vue' export default { @@ -42,6 +50,7 @@ export default { NcAppNavigationItem, BookOpenVariantOutline, CogIcon, + GavelIcon, HomeIcon, }, methods: { diff --git a/src/router/index.js b/src/router/index.js index 7f7fc7b4..bdd4e1a4 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -3,6 +3,9 @@ import Router from 'vue-router' import { generateUrl } from '@nextcloud/router' import Dashboard from '../views/Dashboard.vue' import AdminRoot from '../views/settings/AdminRoot.vue' +import MotionIndex from '../views/MotionIndex.vue' +import MotionDetail from '../views/MotionDetail.vue' +import AmendmentDetail from '../views/AmendmentDetail.vue' Vue.use(Router) @@ -12,6 +15,9 @@ export default new Router({ routes: [ { path: '/', name: 'Dashboard', component: Dashboard }, { path: '/settings', name: 'Settings', component: AdminRoot }, + { path: '/motions', name: 'MotionIndex', component: MotionIndex }, + { path: '/motions/:id', name: 'MotionDetail', component: MotionDetail }, + { path: '/amendments/:id', name: 'AmendmentDetail', component: AmendmentDetail }, { path: '*', redirect: '/' }, ], }) diff --git a/src/views/AmendmentDetail.vue b/src/views/AmendmentDetail.vue new file mode 100644 index 00000000..5daf8bae --- /dev/null +++ b/src/views/AmendmentDetail.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/src/views/MotionDetail.vue b/src/views/MotionDetail.vue new file mode 100644 index 00000000..b7be51dd --- /dev/null +++ b/src/views/MotionDetail.vue @@ -0,0 +1,404 @@ +