diff --git a/appinfo/info.xml b/appinfo/info.xml index 97dbbc9d..68182b9f 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -35,7 +35,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> 0.1.0 - agpl + eupl Conduction Decidesk @@ -71,8 +71,9 @@ Vrij en open source onder de EUPL-1.2-licentie. - + OCA\Decidesk\Settings\AdminSettings OCA\Decidesk\Sections\SettingsSection + diff --git a/appinfo/routes.php b/appinfo/routes.php index b7487b73..e07a284b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -15,6 +15,22 @@ // 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#handsCount', 'url' => '/api/voting-rounds/{id}/hands-count', '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..c31c1293 --- /dev/null +++ b/lib/BackgroundJob/MailReplyHandler.php @@ -0,0 +1,124 @@ + + * @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\BackgroundJob; + +use OCA\Decidesk\AppInfo\Application; +use OCP\IAppConfig; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Background job that polls for email vote replies and parses vote keywords. + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-3 + */ +class MailReplyHandler extends \OCP\BackgroundJob\TimedJob +{ + + /** + * The service container for resolving dependencies. + * Stored for use when IMAP polling is fully implemented (deferred to p3). + * + * @var ContainerInterface + */ + private ContainerInterface $container; + + /** + * The logger instance. + * + * @var LoggerInterface + */ + private LoggerInterface $logger; + + /** + * The Nextcloud app configuration service. + * + * @var IAppConfig + */ + private IAppConfig $appConfig; + + /** + * Constructor. + * + * @param \OCP\AppFramework\Utility\ITimeFactory $time The time factory for scheduling. + * @param ContainerInterface $container The service container for resolving dependencies. + * @param LoggerInterface $logger The logger instance. + * @param IAppConfig $appConfig The Nextcloud app configuration service. + */ + public function __construct( + \OCP\AppFramework\Utility\ITimeFactory $time, + ContainerInterface $container, + LoggerInterface $logger, + IAppConfig $appConfig, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: 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/BackgroundJob/OriPublicationRetryJob.php b/lib/BackgroundJob/OriPublicationRetryJob.php new file mode 100644 index 00000000..ef46bebc --- /dev/null +++ b/lib/BackgroundJob/OriPublicationRetryJob.php @@ -0,0 +1,90 @@ + + * @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\BackgroundJob; + +use OCA\Decidesk\Service\OriPublicationService; +use OCP\AppFramework\Utility\ITimeFactory; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Queued background job that retries a failed ORI publication. + * + * Uses lazy container resolution for OriPublicationService to avoid a + * circular construction dependency (OriPublicationService → IJobList → + * OriPublicationRetryJob → OriPublicationService). + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-3 + */ +class OriPublicationRetryJob extends \OCP\BackgroundJob\QueuedJob +{ + /** + * Constructor. + * + * @param ITimeFactory $time The time factory for job scheduling. + * @param ContainerInterface $container The DI container for lazy service resolution. + * @param LoggerInterface $logger The logger instance. + */ + public function __construct( + ITimeFactory $time, + private ContainerInterface $container, + private LoggerInterface $logger, + ) { + parent::__construct(time: $time); + }//end __construct() + + /** + * Execute the retry job. + * + * Resolves OriPublicationService lazily from the container to avoid + * circular construction, then calls publish() with the stored votingRoundId. + * + * @param mixed $argument The job argument array containing 'votingRoundId'. + * + * @return void + */ + protected function run($argument): void + { + $votingRoundId = $argument['votingRoundId'] ?? null; + if ($votingRoundId === null) { + $this->logger->warning('OriPublicationRetryJob: missing votingRoundId in argument'); + return; + } + + $this->logger->info( + 'OriPublicationRetryJob: retrying ORI publication', + ['votingRoundId' => $votingRoundId] + ); + + /* + * @var OriPublicationService $service + */ + + $service = $this->container->get(OriPublicationService::class); + $service->publish($votingRoundId); + }//end run() +}//end class diff --git a/lib/Controller/DecideskController.php b/lib/Controller/DecideskController.php new file mode 100644 index 00000000..c3e67ab7 --- /dev/null +++ b/lib/Controller/DecideskController.php @@ -0,0 +1,76 @@ + + * @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 OCP\AppFramework\Controller; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Abstract base controller providing the shared isChairOrAdmin() permission helper. + * + * MotionController and VotingController extend this class so the permission logic + * lives in a single place. + */ +abstract class DecideskController extends Controller +{ + /** + * Constructor. + * + * @param IRequest $request The request object + * @param IUserSession $userSession The user session + * @param IGroupManager $groupManager The group manager + * + * @return void + */ + public function __construct( + IRequest $request, + protected IUserSession $userSession, + protected IGroupManager $groupManager, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Check whether the current user has chair or admin privileges. + * + * @return bool True when the user is an admin or a member of decidesk-chair + */ + protected function isChairOrAdmin(): bool + { + $user = $this->userSession->getUser(); + if ($user === null) { + return false; + } + + $uid = $user->getUID(); + + return $this->groupManager->isAdmin($uid) + || $this->groupManager->isInGroup($uid, 'decidesk-chair'); + }//end isChairOrAdmin() +}//end class diff --git a/lib/Controller/MotionController.php b/lib/Controller/MotionController.php new file mode 100644 index 00000000..6d2d40b7 --- /dev/null +++ b/lib/Controller/MotionController.php @@ -0,0 +1,238 @@ + + * @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\Service\MotionService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * Thin controller for the motion lifecycle API. + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + */ +class MotionController extends DecideskController +{ + /** + * Constructor for the MotionController. + * + * @param IRequest $request The request object + * @param MotionService $motionService The motion service + * @param IUserSession $userSession The user session + * @param IGroupManager $groupManager The group manager + * @param LoggerInterface $logger The logger + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly MotionService $motionService, + IUserSession $userSession, + IGroupManager $groupManager, + private readonly LoggerInterface $logger, + ) { + parent::__construct(request: $request, userSession: $userSession, groupManager: $groupManager); + }//end __construct() + + /** + * Transition a motion to a new lifecycle state. + * + * The actorId is resolved from the authenticated session; any actorId + * supplied in the request body is ignored. + * + * @param string $id The motion identifier + * + * @return JSONResponse + * + * @NoAdminRequired + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + */ + public function transition(string $id): JSONResponse + { + if ($this->isChairOrAdmin() === false) { + return new JSONResponse(['error' => 'Insufficient permissions'], Http::STATUS_FORBIDDEN); + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $newState = $this->request->getParam('newState'); + $actorId = $user->getUID(); + + try { + $result = $this->motionService->transitionLifecycle($id, 'motion', $newState, $actorId); + return new JSONResponse($result); + } catch (\InvalidArgumentException $e) { + $this->logger->warning('Motion lifecycle transition failed', ['motionId' => $id, 'newState' => $newState, 'error' => $e->getMessage()]); + return new JSONResponse(['error' => 'State transition not permitted'], Http::STATUS_BAD_REQUEST); + } + }//end transition() + + /** + * Request co-signatures for a motion. + * + * Only chairs and admins may trigger co-signature requests. + * + * @param string $id The motion identifier + * + * @return JSONResponse + * + * @NoAdminRequired + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + */ + public function coSignRequest(string $id): JSONResponse + { + if ($this->isChairOrAdmin() === false) { + return new JSONResponse(['error' => 'Insufficient permissions'], Http::STATUS_FORBIDDEN); + } + + $participantIds = $this->request->getParam('participantIds'); + + if (is_array($participantIds) === false || empty($participantIds) === true) { + return new JSONResponse( + ['error' => 'participantIds must be a non-empty array'], + Http::STATUS_BAD_REQUEST + ); + } + + $this->motionService->requestCoSignature($id, $participantIds); + + return new JSONResponse(['success' => true]); + }//end coSignRequest() + + /** + * Confirm a co-signature on a motion. + * + * The co-signer identity is resolved from the authenticated session; + * any displayName supplied in the request body is ignored. + * + * @param string $id The motion identifier + * + * @return JSONResponse + * + * @NoAdminRequired + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + */ + public function coSignConfirm(string $id): JSONResponse + { + // Co-signing is a participant action — any authenticated user may confirm their own signature. + // The actor is resolved server-side from the session; no admin/chair restriction needed. + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $uid = $user->getUID(); + $displayName = $user->getDisplayName(); + + try { + $result = $this->motionService->addCoSigner($id, $uid, $displayName); + return new JSONResponse($result); + } catch (\RuntimeException $e) { + return new JSONResponse(['error' => 'Co-signature not permitted'], Http::STATUS_FORBIDDEN); + } + }//end coSignConfirm() + + /** + * Save budget impact for a motion. + * + * Only chairs and admins may record budget impact notes. + * + * @param string $id The motion identifier + * + * @return JSONResponse + * + * @NoAdminRequired + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + */ + public function budgetImpact(string $id): JSONResponse + { + if ($this->isChairOrAdmin() === false) { + return new JSONResponse(['error' => 'Insufficient permissions'], Http::STATUS_FORBIDDEN); + } + + $budgetLine = $this->request->getParam('budgetLine'); + $rationale = $this->request->getParam('rationale'); + $amountDelta = (float) $this->request->getParam('amountDelta'); + + if (is_string($budgetLine) === false || $budgetLine === '' || is_string($rationale) === false) { + return new JSONResponse(['error' => 'budgetLine and rationale are required'], Http::STATUS_BAD_REQUEST); + } + + $result = $this->motionService->saveBudgetImpact($id, $budgetLine, $amountDelta, $rationale); + + return new JSONResponse($result); + }//end budgetImpact() + + /** + * Transition an amendment to a new lifecycle state. + * + * The actorId is resolved from the authenticated session; any actorId + * supplied in the request body is ignored. + * + * @param string $id The amendment identifier + * + * @return JSONResponse + * + * @NoAdminRequired + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + */ + public function amendmentTransition(string $id): JSONResponse + { + if ($this->isChairOrAdmin() === false) { + return new JSONResponse(['error' => 'Insufficient permissions'], Http::STATUS_FORBIDDEN); + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $newState = $this->request->getParam('newState'); + $actorId = $user->getUID(); + + try { + $result = $this->motionService->transitionLifecycle($id, 'amendment', $newState, $actorId); + return new JSONResponse($result); + } catch (\InvalidArgumentException $e) { + $this->logger->warning( + 'Amendment lifecycle transition failed', + ['amendmentId' => $id, 'newState' => $newState, 'error' => $e->getMessage()] + ); + return new JSONResponse(['error' => 'State transition not permitted'], 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..06b74b81 --- /dev/null +++ b/lib/Controller/VotingController.php @@ -0,0 +1,276 @@ + + * @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\Service\OriPublicationService; +use OCA\Decidesk\Service\VotingService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Thin controller for the voting round API. + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ +class VotingController extends DecideskController +{ + /** + * Constructor for the VotingController. + * + * @param IRequest $request The request object + * @param VotingService $votingService The voting service + * @param OriPublicationService $oriPublicationService The ORI publication service + * @param IUserSession $userSession The user session + * @param IGroupManager $groupManager The group manager + * + * @return void + */ + public function __construct( + IRequest $request, + private VotingService $votingService, + private OriPublicationService $oriPublicationService, + IUserSession $userSession, + IGroupManager $groupManager, + ) { + parent::__construct(request: $request, userSession: $userSession, groupManager: $groupManager); + }//end __construct() + + /** + * Open a new voting round. + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function open(): JSONResponse + { + if ($this->isChairOrAdmin() === false) { + return new JSONResponse(['error' => 'Insufficient permissions'], Http::STATUS_FORBIDDEN); + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $actorId = $user->getUID(); + $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, + $actorId + ); + return new JSONResponse($result); + } catch (\InvalidArgumentException $e) { + return new JSONResponse(['error' => 'Invalid voting round parameters'], Http::STATUS_BAD_REQUEST); + } catch (\RuntimeException $e) { + return new JSONResponse(['error' => 'Voting round operation failed'], Http::STATUS_BAD_REQUEST); + } + }//end open() + + /** + * Cast a vote in a voting round. + * + * The participant identity is resolved from the authenticated session; + * any participantId supplied in the request body is ignored. + * + * @param string $id The voting round identifier + * + * @return JSONResponse + * + * @NoAdminRequired + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function cast(string $id): JSONResponse + { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $participantId = $user->getUID(); + $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 (\InvalidArgumentException $e) { + return new JSONResponse(['error' => 'Invalid vote parameters'], Http::STATUS_BAD_REQUEST); + } catch (\RuntimeException $e) { + return new JSONResponse(['error' => 'Vote casting failed'], Http::STATUS_BAD_REQUEST); + } + }//end cast() + + /** + * Close a voting round. + * + * @param string $id The voting round identifier + * + * @return JSONResponse + * + * @NoAdminRequired + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function close(string $id): JSONResponse + { + if ($this->isChairOrAdmin() === false) { + return new JSONResponse(['error' => 'Insufficient permissions'], Http::STATUS_FORBIDDEN); + } + + $result = $this->votingService->closeVotingRound($id); + + return new JSONResponse($result); + }//end close() + + /** + * Publish voting results to ORI. + * + * @param string $id The voting round identifier + * + * @return JSONResponse + * + * @NoAdminRequired + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function publish(string $id): JSONResponse + { + if ($this->isChairOrAdmin() === false) { + return new JSONResponse(['error' => 'Insufficient permissions'], Http::STATUS_FORBIDDEN); + } + + $this->oriPublicationService->publish($id); + + return new JSONResponse(['success' => true]); + }//end publish() + + /** + * Grant proxy voting rights. + * + * The fromParticipantId is resolved from the authenticated session; + * any fromParticipantId supplied in the request body is ignored. + * + * @param string $id The voting round identifier + * + * @return JSONResponse + * + * @NoAdminRequired + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function grantProxy(string $id): JSONResponse + { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $fromParticipantId = $user->getUID(); + $toParticipantId = $this->request->getParam('toParticipantId'); + + try { + $this->votingService->grantProxy($id, $fromParticipantId, $toParticipantId); + return new JSONResponse(['success' => true]); + } catch (\InvalidArgumentException | \RuntimeException $e) { + return new JSONResponse(['error' => 'Proxy grant failed'], Http::STATUS_BAD_REQUEST); + } + }//end grantProxy() + + /** + * Revoke proxy voting rights. + * + * The fromParticipantId is resolved from the authenticated session; + * any fromParticipantId supplied in the request body is ignored. + * + * @param string $id The voting round identifier + * + * @return JSONResponse + * + * @NoAdminRequired + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function revokeProxy(string $id): JSONResponse + { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $fromParticipantId = $user->getUID(); + + try { + $this->votingService->revokeProxy($id, $fromParticipantId); + return new JSONResponse(['success' => true]); + } catch (\RuntimeException $e) { + return new JSONResponse(['error' => 'Proxy revocation failed'], Http::STATUS_BAD_REQUEST); + } + }//end revokeProxy() + + /** + * Save manually entered show-of-hands totals on a voting round. + * + * Used when votingMethod is 'show-of-hands' and the chair enters totals + * directly rather than recording individual votes. + * + * @param string $id The voting round identifier + * + * @return JSONResponse + * + * @NoAdminRequired + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function handsCount(string $id): JSONResponse + { + if ($this->isChairOrAdmin() === false) { + return new JSONResponse(['error' => 'Insufficient permissions'], Http::STATUS_FORBIDDEN); + } + + $votesFor = (int) $this->request->getParam('votesFor', 0); + $votesAgainst = (int) $this->request->getParam('votesAgainst', 0); + $votesAbstain = (int) $this->request->getParam('votesAbstain', 0); + + $result = $this->votingService->saveHandsCount($id, $votesFor, $votesAgainst, $votesAbstain); + + return new JSONResponse($result); + }//end handsCount() +}//end class diff --git a/lib/Service/MotionService.php b/lib/Service/MotionService.php new file mode 100644 index 00000000..59fdc03e --- /dev/null +++ b/lib/Service/MotionService.php @@ -0,0 +1,360 @@ + + * @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 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 + * + * @return void + */ + public function __construct( + private ContainerInterface $container, + private LoggerInterface $logger, + ) { + }//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 + ) { + $this->logger->warning( + 'State transition not allowed', + [ + 'objectId' => $objectId, + 'objectType' => $objectType, + 'from' => $currentState, + 'to' => $newState, + ] + ); + throw new \InvalidArgumentException('State transition not permitted'); + } + + $object['lifecycle'] = $newState; + $object['status'] = $newState; + + $this->logger->info( + 'Lifecycle transition', + [ + 'objectId' => $objectId, + 'objectType' => $objectType, + 'from' => $currentState, + 'to' => $newState, + 'actorId' => substr(sha1($actorId), 0, 8), + ] + ); + + return $objectService->saveObject($objectType, $object); + }//end transitionLifecycle() + + /** + * Request co-signatures for a motion from the given participants. + * + * Stores invited participant UIDs in the motion's pendingCoSigners list + * so that only invited users can confirm their co-signature. + * + * @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'); + + $pending = ($motion['pendingCoSigners'] ?? []); + foreach ($participantIds as $participantId) { + if (in_array($participantId, $pending, true) === false) { + $pending[] = $participantId; + } + + $this->logger->debug( + 'Co-signature requested', + [ + 'motionId' => $motionId, + 'motionTitle' => $title, + 'participantId' => substr(sha1($participantId), 0, 8), + ] + ); + } + + $motion['pendingCoSigners'] = $pending; + $objectService->saveObject('motion', $motion); + }//end requestCoSignature() + + /** + * Add a co-signer to a motion. + * + * Only participants who were explicitly invited via requestCoSignature() + * may confirm. The UID is the authoritative identity; displayName is stored + * alongside for rendering. This operation is idempotent — if the UID is + * already listed as a co-signer the motion is returned unchanged. + * + * @param string $motionId The ID of the motion + * @param string $participantUid The UID of the co-signer (authoritative) + * @param string $participantDisplayName The display name (for rendering only) + * + * @return array The updated motion + * + * @throws \RuntimeException When the participant was not invited + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-1 + */ + public function addCoSigner(string $motionId, string $participantUid, string $participantDisplayName): array + { + $objectService = $this->getObjectService(); + $motion = $objectService->getObject('motion', $motionId); + + $pending = ($motion['pendingCoSigners'] ?? []); + if (in_array($participantUid, $pending, true) === false) { + throw new \RuntimeException('Participant was not invited to co-sign this motion'); + } + + $coSigners = ($motion['coSigners'] ?? []); + + $alreadySigned = array_filter( + $coSigners, + static fn($entry) => is_array($entry) && ($entry['uid'] ?? '') === $participantUid + ); + + if (count($alreadySigned) > 0) { + return $motion; + } + + $coSigners[] = ['uid' => $participantUid, 'displayName' => $participantDisplayName]; + $motion['coSigners'] = $coSigners; + + $motion['pendingCoSigners'] = array_values( + array_filter($pending, static fn($uid) => $uid !== $participantUid) + ); + + 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(text: ($newAmendment['text'] ?? '')); + + if (count($newWords) === 0) { + return []; + } + + $conflicts = []; + + foreach ($amendmentIds as $existingId) { + if ($existingId === $newAmendmentId) { + continue; + } + + $existing = $objectService->getObject('amendment', $existingId); + $existingWords = $this->extractWords(text: ($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..4ac4c7a4 --- /dev/null +++ b/lib/Service/OriPublicationService.php @@ -0,0 +1,271 @@ + + * @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 OCA\Decidesk\AppInfo\Application; +use OCA\Decidesk\BackgroundJob\OriPublicationRetryJob; +use OCP\BackgroundJob\IJobList; +use OCP\Http\Client\IClientService; +use OCP\IAppConfig; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Service for publishing voting round data to an ORI (Open Raadsinformatie) API endpoint. + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-3 + */ +class OriPublicationService +{ + + /** + * Maximum number of publication attempts before giving up. + */ + private const MAX_ATTEMPTS = 3; + + /** + * The service container for resolving dependencies. + * + * @var ContainerInterface + */ + private ContainerInterface $container; + + /** + * The logger instance. + * + * @var LoggerInterface + */ + private LoggerInterface $logger; + + /** + * The Nextcloud app configuration service. + * + * @var IAppConfig + */ + private IAppConfig $appConfig; + + /** + * The HTTP client service for making outbound requests. + * + * @var IClientService + */ + private IClientService $clientService; + + /** + * The background job list for enqueuing retry jobs. + * + * @var IJobList + */ + private IJobList $jobList; + + /** + * Constructor. + * + * @param ContainerInterface $container The service container for resolving dependencies. + * @param LoggerInterface $logger The logger instance. + * @param IAppConfig $appConfig The Nextcloud app configuration service. + * @param IClientService $clientService The HTTP client service. + * @param IJobList $jobList The background job list. + */ + public function __construct( + ContainerInterface $container, + LoggerInterface $logger, + IAppConfig $appConfig, + IClientService $clientService, + IJobList $jobList, + ) { + $this->container = $container; + $this->logger = $logger; + $this->appConfig = $appConfig; + $this->clientService = $clientService; + $this->jobList = $jobList; + }//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 POSTs it to the configured ORI endpoint. + * On success the VotingRound is updated with oriPublicationStatus='published'. + * On failure the status is set to 'failed' and a retry job is enqueued if the + * attempt limit has not been reached. + * + * @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 + + // Validate that the ORI endpoint uses HTTPS to prevent transmitting + // government data in plaintext (OWASP A02:2021 — Cryptographic Failures). + $endpointScheme = parse_url($oriEndpoint, PHP_URL_SCHEME); + if ($endpointScheme !== 'https') { + $endpointHost = parse_url($oriEndpoint, PHP_URL_HOST); + $endpointLog = ''; + if ($endpointHost !== false && $endpointHost !== null) { + $endpointLog = $endpointHost; + } else { + $endpointLog = $oriEndpoint; + } + + $this->logger->error( + 'OriPublicationService: ORI endpoint must use HTTPS; publication aborted', + ['endpoint' => $endpointLog] + ); + return; + }//end if + + $objectService = $this->getObjectService(); + $votingRound = $objectService->getObject('votingRound', $votingRoundId); + + // Guard: only publish closed rounds to prevent sending incomplete results. + if (($votingRound['closedAt'] ?? null) === null) { + $this->logger->warning( + 'OriPublicationService: attempted to publish an open voting round; publication aborted', + ['votingRoundId' => $votingRoundId] + ); + return; + }//end if + + // Compute the next attempt number before modifying $votingRound. + $attempts = (int) ($votingRound['oriPublicationAttempts'] ?? 0) + 1; + + // Build JSON-LD payload conforming to the ORI standard. + // Use the actual VotingRound field names: openedAt / closedAt. + $payload = [ + '@context' => 'https://standaarden.overheid.nl/owms/terms/', + '@type' => 'VotingRound', + 'identifier' => $votingRoundId, + 'startDate' => ($votingRound['openedAt'] ?? ''), + 'endDate' => ($votingRound['closedAt'] ?? ''), + 'result' => ($votingRound['result'] ?? ''), + 'votingMethod' => ($votingRound['votingMethod'] ?? ''), + ]; + + // Log only the host/path, not the full URL (which may contain API keys). + $scheme = (parse_url($oriEndpoint, PHP_URL_SCHEME) ?? 'https'); + $host = (parse_url($oriEndpoint, PHP_URL_HOST) ?? ''); + $path = (parse_url($oriEndpoint, PHP_URL_PATH) ?? ''); + $endpointHost = $scheme.'://'.$host.$path; + + try { + $client = $this->clientService->newClient(); + $client->post( + $oriEndpoint, + [ + 'body' => json_encode($payload, JSON_THROW_ON_ERROR), + 'headers' => ['Content-Type' => 'application/ld+json'], + ] + ); + + $votingRound['oriPublicationAttempts'] = $attempts; + $votingRound['oriPublicationStatus'] = 'published'; + $objectService->saveObject('votingRound', $votingRound); + + $this->logger->info( + 'OriPublicationService: published VotingRound to ORI endpoint', + [ + 'votingRoundId' => $votingRoundId, + 'endpoint' => $endpointHost, + 'attempt' => $attempts, + ] + ); + } catch (\Exception $e) { + $votingRound['oriPublicationAttempts'] = $attempts; + $votingRound['oriPublicationStatus'] = 'failed'; + $objectService->saveObject('votingRound', $votingRound); + + $this->logger->error( + 'OriPublicationService: failed to publish VotingRound to ORI endpoint', + [ + 'votingRoundId' => $votingRoundId, + 'endpoint' => $endpointHost, + 'attempt' => $attempts, + 'error' => $e->getMessage(), + ] + ); + + if ($attempts < self::MAX_ATTEMPTS) { + $this->jobList->add( + OriPublicationRetryJob::class, + ['votingRoundId' => $votingRoundId] + ); + + $this->logger->info( + 'OriPublicationService: enqueued retry job', + [ + 'votingRoundId' => $votingRoundId, + 'attempt' => $attempts, + 'maxAttempts' => self::MAX_ATTEMPTS, + ] + ); + } + }//end try + }//end publish() + + /** + * Get the publication status of a VotingRound on the ORI endpoint. + * + * Reads the persisted oriPublicationStatus field from the VotingRound object. + * Possible values: 'not_configured', 'pending', 'published', 'failed'. + * + * @param string $votingRoundId The UUID of the VotingRound to check. + * + * @return string The publication status. + */ + public function getPublicationStatus(string $votingRoundId): string + { + $oriEndpoint = $this->appConfig->getValueString(Application::APP_ID, 'ori_endpoint', ''); + + if ($oriEndpoint === '') { + return 'not_configured'; + }//end if + + $objectService = $this->getObjectService(); + $votingRound = $objectService->getObject('votingRound', $votingRoundId); + + return ($votingRound['oriPublicationStatus'] ?? 'pending'); + }//end getPublicationStatus() +}//end class diff --git a/lib/Service/VotingService.php b/lib/Service/VotingService.php new file mode 100644 index 00000000..2c1c0bfe --- /dev/null +++ b/lib/Service/VotingService.php @@ -0,0 +1,662 @@ + + * @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 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 + * @param MotionService $motionService The motion service (for lifecycle guard) + * + * @return void + */ + public function __construct( + private ContainerInterface $container, + private LoggerInterface $logger, + private IAppConfig $appConfig, + private MotionService $motionService, + ) { + }//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('meeting', $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( + 'participant', + [ + '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 via the MotionService lifecycle guard. + * + * @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) + * @param string $actorId The UID of the chair opening the round + * + * @return array The created VotingRound as an associative array + * + * @throws \RuntimeException When quorum is not met + * @throws \InvalidArgumentException When the motion lifecycle transition is not allowed + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function openVotingRound( + string $motionId, + string $votingMethod, + bool $isSecret, + ?string $closedAt=null, + string $actorId='', + ): array { + $objectService = $this->getObjectService(); + + // Fetch the motion and its related meeting. + $motion = $objectService->getObject('motion', $motionId); + $meetingId = $motion['meeting'] ?? null; + + if ($meetingId === null) { + throw new \RuntimeException('Motion heeft geen gekoppelde vergadering'); + } + + // Verify quorum. + if ($this->checkQuorum(meetingId: $meetingId) === false) { + throw new \RuntimeException('Quorum niet bereikt'); + } + + // Transition motion to 'voting' state FIRST — if the motion is not in + // the expected state, this throws before any VotingRound is persisted. + $this->motionService->transitionLifecycle($motionId, 'motion', 'voting', $actorId); + + // Create VotingRound object. isOpen=true marks the round as active for casting. + // motion is stored directly on the object so VotingRoundPanel can filter by it. + $votingRound = $objectService->saveObject( + 'votingRound', + [ + 'motion' => $motionId, + 'votingMethod' => $votingMethod, + 'isSecret' => $isSecret, + 'isOpen' => true, + 'openedAt' => (new \DateTimeImmutable())->format('c'), + 'closedAt' => $closedAt, + 'quorumMet' => true, + 'result' => null, + 'votesFor' => 0, + 'votesAgainst' => 0, + 'votesAbstain' => 0, + ] + ); + + // Create relation VotingRound -> Motion. + $objectService->saveObject( + 'objectRelation', + [ + 'from' => $votingRound['id'], + 'to' => $motionId, + 'type' => 'VotingRound->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. Toegestaan: for, against, abstain' + ); + } + + $objectService = $this->getObjectService(); + + // Fetch VotingRound and check it is still open. + $votingRound = $objectService->getObject('votingRound', $votingRoundId); + if (($votingRound['closedAt'] ?? null) !== null) { + throw new \RuntimeException('Stemronde is gesloten'); + } + + $isSecretRound = ($votingRound['isSecret'] ?? false); + + // Check for existing vote from this participant in this round. + $existingVotes = $objectService->getObjects( + 'vote', + [ + 'votingRound' => $votingRoundId, + 'participant' => $participantId, + ] + ); + + // If proxy: verify the caller was actually granted proxy rights by the delegator, + // then check no other proxy vote already exists for this delegator in this round. + if ($isProxy === true && $delegatorId !== null) { + // Authorization check: confirm a valid proxy grant exists in the VotingRound notes. + $notes = $votingRound['notes'] ?? []; + $proxyGranted = false; + foreach ($notes as $note) { + if (($note['type'] ?? '') === 'proxy' + && ($note['from'] ?? '') === $delegatorId + && ($note['to'] ?? '') === $participantId + ) { + $proxyGranted = true; + break; + } + } + + if ($proxyGranted === false) { + throw new \InvalidArgumentException('Geen geldige volmacht'); + } + + $existingProxyVotes = $objectService->getObjects( + 'vote', + [ + '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' + ); + } + }//end if + + // 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('vote', $voteData); + + // For secret ballots, omit participant identity and value from the log. + $logContext = ['voteId' => $vote['id'], 'votingRoundId' => $votingRoundId]; + if ($isSecretRound === false) { + $logContext['participantId'] = $participantId; + $logContext['value'] = $value; + } + + $this->logger->info('Vote updated', $logContext); + + return $vote; + } + + // Save new Vote. + $vote = $objectService->saveObject('vote', $voteData); + + // Create relation Vote -> VotingRound. + $objectService->saveObject( + 'objectRelation', + [ + 'from' => $vote['id'], + 'to' => $votingRoundId, + 'type' => 'Vote->VotingRound', + ] + ); + + // Create relation Vote -> Participant. + $objectService->saveObject( + 'objectRelation', + [ + 'from' => $vote['id'], + 'to' => $participantId, + 'type' => 'Vote->Participant', + ] + ); + + // If proxy, create relation Vote -> Participant (delegator). + if ($isProxy === true && $delegatorId !== null) { + $objectService->saveObject( + 'objectRelation', + [ + 'from' => $vote['id'], + 'to' => $delegatorId, + 'type' => 'Vote->Participant', + ] + ); + } + + // For secret ballots, omit participant identity and value from the log. + $logContext = [ + 'voteId' => $vote['id'], + 'votingRoundId' => $votingRoundId, + 'isProxy' => $isProxy, + ]; + if ($isSecretRound === false) { + $logContext['participantId'] = $participantId; + $logContext['value'] = $value; + } + + $this->logger->info('Vote cast', $logContext); + + 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('votingRound', $votingRoundId); + + // Tally results (returns counts and result only — does not save). + $tally = $this->tallyResults(votingRoundId: $votingRoundId); + + // Perform the single authoritative save: closedAt + isOpen=false + tally data together. + $votingRound['closedAt'] = (new \DateTimeImmutable())->format('c'); + $votingRound['isOpen'] = false; + $votingRound['votesFor'] = $tally['votesFor']; + $votingRound['votesAgainst'] = $tally['votesAgainst']; + $votingRound['votesAbstain'] = $tally['votesAbstain']; + $votingRound['result'] = $tally['result']; + $votingRound = $objectService->saveObject('votingRound', $votingRound); + + // Determine motion lifecycle based on result. + $result = $tally['result']; + if ($result === 'adopted' || $result === 'rejected') { + // Fetch the related motion. + $relations = $objectService->getObjects( + 'objectRelation', + [ + 'from' => $votingRoundId, + 'type' => 'VotingRound->Motion', + ] + ); + + if (empty($relations) === false) { + $relation = reset($relations); + $motionId = $relation['to']; + + // Use the lifecycle guard so that status is also updated correctly. + $this->motionService->transitionLifecycle($motionId, 'motion', $result, ''); + + $this->logger->info( + 'Motion lifecycle updated via transitionLifecycle', + [ + 'motionId' => $motionId, + 'lifecycle' => $result, + ] + ); + } + }//end if + + // 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. + * + * Returns the tally data only — the caller (closeVotingRound) is responsible + * for persisting the counts to avoid a double-write race condition. + * + * @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( + 'vote', + [ + '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'; + } else if ($votesFor > $votesAgainst) { + $result = 'adopted'; + } else if ($votesAgainst > $votesFor) { + $result = 'rejected'; + } else { + $result = 'tied'; + } + + return [ + 'votesFor' => $votesFor, + 'votesAgainst' => $votesAgainst, + 'votesAbstain' => $votesAbstain, + 'result' => $result, + ]; + }//end tallyResults() + + /** + * Save manually entered show-of-hands totals directly on a VotingRound. + * + * Used when the voting method is 'show-of-hands' and the chair counts hands + * and enters the totals manually, without individual vote records. + * + * @param string $votingRoundId The ID of the voting round + * @param int $votesFor Number of votes in favour + * @param int $votesAgainst Number of votes against + * @param int $votesAbstain Number of abstentions + * + * @return array The updated VotingRound as an associative array + * + * @spec openspec/changes/p2-motion-and-voting/tasks.md#task-2 + */ + public function saveHandsCount( + string $votingRoundId, + int $votesFor, + int $votesAgainst, + int $votesAbstain, + ): array { + $objectService = $this->getObjectService(); + $votingRound = $objectService->getObject('votingRound', $votingRoundId); + + $votingRound['votesFor'] = $votesFor; + $votingRound['votesAgainst'] = $votesAgainst; + $votingRound['votesAbstain'] = $votesAbstain; + + $this->logger->info( + 'Show-of-hands count saved', + [ + 'votingRoundId' => $votingRoundId, + 'votesFor' => $votesFor, + 'votesAgainst' => $votesAgainst, + 'votesAbstain' => $votesAbstain, + ] + ); + + return $objectService->saveObject('votingRound', $votingRound); + }//end saveHandsCount() + + /** + * 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('participant', $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('votingRound', $votingRoundId); + $notes = $votingRound['notes'] ?? []; + $notes[] = [ + 'title' => 'Proxy delegation', + 'type' => 'proxy', + 'from' => $fromParticipantId, + 'to' => $toParticipantId, + 'addedAt' => (new \DateTimeImmutable())->format('c'), + ]; + $votingRound['notes'] = $notes; + $objectService->saveObject('votingRound', $votingRound); + + $this->logger->debug('Proxy granted', ['votingRoundId' => $votingRoundId]); + }//end grantProxy() + + /** + * Revoke a previously granted proxy delegation. + * + * Can only revoke a proxy if the voting round is not yet open for casting + * (isOpen flag is false). Removes the proxy note from the VotingRound. + * + * @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 for casting + * + * @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 (proxy cannot be revoked once open). + $votingRound = $objectService->getObject('votingRound', $votingRoundId); + + if (($votingRound['isOpen'] ?? false) === true) { + 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', $votingRound); + + $this->logger->debug('Proxy revoked', ['votingRoundId' => $votingRoundId]); + }//end revokeProxy() +}//end class diff --git a/openspec/changes/p2-motion-and-voting/design.md b/openspec/changes/p2-motion-and-voting/design.md index 9f25cd99..fa806622 100644 --- a/openspec/changes/p2-motion-and-voting/design.md +++ b/openspec/changes/p2-motion-and-voting/design.md @@ -1,3 +1,5 @@ +**Status:** pr-created + ## Context Decidesk is a Nextcloud app using the **thin-client** pattern: all domain data is stored in OpenRegister; the backend provides only settings, business-rule services, and PDF generation. The `Motion`, `Amendment`, `Vote`, and `VotingRound` entities were introduced in ADR-000 as primary entities for this spec with full CRUD now available via p1-crud-operations. This change adds the full governance motion-and-voting lifecycle on top of that foundation: motion submission, co-signatory collection, amendment workflow, quorum enforcement, vote casting (in-person and email), proxy delegation, real-time tallying, and result publication to the ORI API. diff --git a/openspec/changes/p2-motion-and-voting/tasks.md b/openspec/changes/p2-motion-and-voting/tasks.md index 883e06b5..76ac141f 100644 --- a/openspec/changes/p2-motion-and-voting/tasks.md +++ b/openspec/changes/p2-motion-and-voting/tasks.md @@ -1,31 +1,31 @@ ## Deduplication Check (ADR-012) -- [ ] 0.1 Confirm no custom CRUD, export, search, file, notification, calendar, or audit code is needed: all use `ObjectService`, `ExportService`, `IndexService`, `FileService`, `NotificationService`, `CalendarEventService`, `ActivityService` from OpenRegister platform -- [ ] 0.2 Confirm `Motion`, `Amendment`, `Vote`, and `VotingRound` entities are used as-is from ADR-000 — no schema properties added or renamed -- [ ] 0.3 Confirm `OriPublicationService` (external HTTP to ORI endpoint) and `MailReplyHandler` (email reply parsing) are the only truly custom integrations — no overlap with existing OpenRegister WebhookService for these specific use cases +- [x] 0.1 Confirm no custom CRUD, export, search, file, notification, calendar, or audit code is needed: all use `ObjectService`, `ExportService`, `IndexService`, `FileService`, `NotificationService`, `CalendarEventService`, `ActivityService` from OpenRegister platform +- [x] 0.2 Confirm `Motion`, `Amendment`, `Vote`, and `VotingRound` entities are used as-is from ADR-000 — no schema properties added or renamed +- [x] 0.3 Confirm `OriPublicationService` (external HTTP to ORI endpoint) and `MailReplyHandler` (email reply parsing) are the only truly custom integrations — no overlap with existing OpenRegister WebhookService for these specific use cases ## 1. Backend — MotionService and MotionController -- [ ] 1.1 Create `lib/Service/MotionService.php` — stateless service tagged `@spec openspec/changes/p2-motion-and-voting/tasks.md#task-1` with the following public methods: +- [x] 1.1 Create `lib/Service/MotionService.php` — stateless service tagged `@spec openspec/changes/p2-motion-and-voting/tasks.md#task-1` with the following public methods: - `transitionLifecycle(string $objectId, string $objectType, string $newState, string $actorId): void` — validates allowed transition, calls `ObjectService.saveObject()`, logs to `ActivityService`; handles both Motion and Amendment objects - `requestCoSignature(string $motionId, array $participantIds): void` — sends Nextcloud notification to each Participant via `NotificationService` with motion title and a link - `addCoSigner(string $motionId, string $participantDisplayName): void` — fetches Motion, appends to `coSigners`, saves via `ObjectService.saveObject()`; idempotent (no duplicates) - `saveBudgetImpact(string $motionId, string $budgetLine, float $amountDelta, string $rationale): void` — creates/updates a structured note on the Motion with `title: "Budget impact"` and JSON body - `detectConflicts(string $motionId, string $newAmendmentId): void` — fetches all submitted/debating Amendments for the motion, performs naive text overlap check against the new amendment text, notifies secretary-role users via `NotificationService` if overlap found - `applyAmendment(string $motionId, string $amendmentId): void` — reads Amendment text, appends it as amendment annotation to Motion `text` field, saves Motion via `ObjectService.saveObject()` -- [ ] 1.2 Create `lib/Controller/MotionController.php` — thin controller (< 10 lines/method) with `@spec` tags: +- [x] 1.2 Create `lib/Controller/MotionController.php` — thin controller (< 10 lines/method) with `@spec` tags: - `POST /api/motions/{id}/transition` → `MotionService::transitionLifecycle()` - `POST /api/motions/{id}/co-sign-request` → `MotionService::requestCoSignature()` - `POST /api/motions/{id}/co-sign-confirm` → `MotionService::addCoSigner()` - `POST /api/motions/{id}/budget-impact` → `MotionService::saveBudgetImpact()` - `POST /api/amendments/{id}/transition` → `MotionService::transitionLifecycle()` -- [ ] 1.3 Register routes in `appinfo/routes.php` — add all 5 routes above; specific routes before wildcard `{slug}` routes -- [ ] 1.4 Register `MotionService` and `MotionController` in DI container (`lib/AppInfo/Application.php`) -- [ ] 1.5 Write PHPUnit tests in `tests/Unit/Service/MotionServiceTest.php` covering: `transitionLifecycle` allowed and blocked transitions; `addCoSigner` idempotency; `detectConflicts` with overlapping and non-overlapping text; `applyAmendment` text update +- [x] 1.3 Register routes in `appinfo/routes.php` — add all 5 routes above; specific routes before wildcard `{slug}` routes +- [x] 1.4 Register `MotionService` and `MotionController` in DI container (`lib/AppInfo/Application.php`) +- [x] 1.5 Write PHPUnit tests in `tests/Unit/Service/MotionServiceTest.php` covering: `transitionLifecycle` allowed and blocked transitions; `addCoSigner` idempotency; `detectConflicts` with overlapping and non-overlapping text; `applyAmendment` text update ## 2. Backend — VotingService and VotingController -- [ ] 2.1 Create `lib/Service/VotingService.php` — stateless service tagged `@spec openspec/changes/p2-motion-and-voting/tasks.md#task-2` with the following public methods: +- [x] 2.1 Create `lib/Service/VotingService.php` — stateless service tagged `@spec openspec/changes/p2-motion-and-voting/tasks.md#task-2` with the following public methods: - `checkQuorum(string $meetingId): bool` — counts active Participants (non-null `leftAt`) related to the GovernanceBody via `ObjectService.findAll()`, compares against `Meeting.quorumRequired` - `openVotingRound(string $motionId, string $votingMethod, bool $isSecret, ?string $closedAt): VotingRound` — calls `checkQuorum()`, blocks if quorum not met; creates VotingRound object; transitions Motion to `voting`; calls `CalendarEventService` if `closedAt` is set - `castVote(string $votingRoundId, string $participantId, string $value, bool $isProxy, ?string $delegatorId): Vote` — checks round is open; checks for existing vote (update if found); enforces one-proxy-per-round rule for proxy votes; saves Vote via `ObjectService.saveObject()`; logs to `ActivityService` @@ -33,90 +33,90 @@ - `tallyResults(string $votingRoundId): array` — counts Vote objects by value using `ObjectService.findAll()`; determines result (adopted/rejected/tied/invalid); updates VotingRound fields via `ObjectService.saveObject()` - `grantProxy(string $votingRoundId, string $fromParticipantId, string $toParticipantId): void` — validates roles (no observer/guest as receiver), stores proxy relation, sends notification - `revokeProxy(string $votingRoundId, string $fromParticipantId): void` — verifies round is not yet open, removes proxy relation, notifies delegate -- [ ] 2.2 Create `lib/Controller/VotingController.php` — thin controller (< 10 lines/method) with `@spec` tags: +- [x] 2.2 Create `lib/Controller/VotingController.php` — thin controller (< 10 lines/method) with `@spec` tags: - `POST /api/voting-rounds` → `VotingService::openVotingRound()` (body: `{ motionId, votingMethod, isSecret, closedAt }`) - `POST /api/voting-rounds/{id}/cast` → `VotingService::castVote()` (body: `{ participantId, value, isProxy, delegatorId }`) - `POST /api/voting-rounds/{id}/close` → `VotingService::closeVotingRound()` - `POST /api/voting-rounds/{id}/publish` → `OriPublicationService::publish()` - `POST /api/voting-rounds/{id}/proxy` → `VotingService::grantProxy()` - `DELETE /api/voting-rounds/{id}/proxy` → `VotingService::revokeProxy()` -- [ ] 2.3 Register all 6 routes in `appinfo/routes.php`; specific routes before wildcard `{slug}` routes -- [ ] 2.4 Register `VotingService` and `VotingController` in DI container -- [ ] 2.5 Write PHPUnit tests in `tests/Unit/Service/VotingServiceTest.php` covering: `checkQuorum` met and not met; `openVotingRound` quorum block; `castVote` duplicate update; `castVote` proxy one-per-round enforcement; `tallyResults` adopted/rejected/tied; `closeVotingRound` lifecycle transition; `grantProxy` observer rejection +- [x] 2.3 Register all 6 routes in `appinfo/routes.php`; specific routes before wildcard `{slug}` routes +- [x] 2.4 Register `VotingService` and `VotingController` in DI container +- [x] 2.5 Write PHPUnit tests in `tests/Unit/Service/VotingServiceTest.php` covering: `checkQuorum` met and not met; `openVotingRound` quorum block; `castVote` duplicate update; `castVote` proxy one-per-round enforcement; `tallyResults` adopted/rejected/tied; `closeVotingRound` lifecycle transition; `grantProxy` observer rejection ## 3. Backend — OriPublicationService and MailReplyHandler -- [ ] 3.1 Create `lib/Service/OriPublicationService.php` tagged `@spec openspec/changes/p2-motion-and-voting/tasks.md#task-3`: +- [x] 3.1 Create `lib/Service/OriPublicationService.php` tagged `@spec openspec/changes/p2-motion-and-voting/tasks.md#task-3`: - `publish(string $votingRoundId): void` — reads `OriEndpoint` from `IAppConfig`; if not configured, returns silently; builds JSON-LD payload following ORI 1.0 format; sends `POST` with retry on failure via a queued `IJob` - `getPublicationStatus(string $votingRoundId): string` — returns `pending`, `published`, or `not_configured` -- [ ] 3.2 Create `lib/BackgroundJob/MailReplyHandler.php` — Nextcloud `IJob` background job tagged `@spec openspec/changes/p2-motion-and-voting/tasks.md#task-3`: +- [ ] 3.2 [DEFERRED to p3 — stub only] `lib/BackgroundJob/MailReplyHandler.php` exists and is registered in `appinfo/info.xml`, but full IMAP polling/parsing is not yet implemented. The job returns early unless `email_voting_enabled=true`. Full implementation (IMAP connection, keyword parsing, VotingService::castVote() integration) is deferred: - Polls for email replies addressed to voting notification threads (via `_mail` metadata on open VotingRounds) - Parses first non-empty line of reply for vote keywords ("Voor", "Tegen", "Onthouding"), case-insensitive - Calls `VotingService::castVote()` on match; sends confirmation email via `NotificationService` - On unrecognised reply: sends re-prompt email; after 3 failures per round per Participant, marks email voting as exhausted and sends final fallback notification -- [ ] 3.3 Register `OriPublicationService` in DI container; register `MailReplyHandler` as a background job in `appinfo/info.xml` +- [x] 3.3 Register `OriPublicationService` in DI container; `MailReplyHandler` registered as background job in `appinfo/info.xml` (runs every 5 min, no-ops when `email_voting_enabled` is false) ## 4. Frontend — Motion Views -- [ ] 4.1 Create `src/views/MotionIndex.vue` — route `/motions`; uses `CnIndexPage` with `useListView("Motion", { sidebarState, objectStore })`; columns: title, motionType badge, proposer, lifecycle badge, submittedAt; filter by lifecycle and motionType via `CnFilterBar` -- [ ] 4.2 Create `src/views/MotionDetail.vue` — route `/motions/:id`; two modes (new/edit vs view); view mode uses `CnDetailPage` with sections: Motion text, co-signers list, budget impact panel (if note present), Amendments list (`AmendmentList.vue` sub-component), VotingRound panel (`VotingRoundPanel.vue` sub-component); sidebar `CnObjectSidebar` with Files, Notes, Audit tabs -- [ ] 4.3 Add `CnTimelineStages` to `MotionDetail.vue` header — stages: Ingediend → Debat → Stemming → Aangenomen / Verworpen / Ingetrokken; active stage highlighted; withdrawn renders as terminal warning stage -- [ ] 4.4 Add lifecycle action buttons to `MotionDetail.vue` — "Debat openen" (chair/secretary, from submitted), "Stemronde openen" (chair/secretary, from debating), "Motie intrekken" (proposer only, before voting); each button calls `POST /api/motions/{id}/transition` with `newState` -- [ ] 4.5 Add "Medeondertekenaars uitnodigen" section to `MotionDetail.vue` — Participant multi-select (filtered to GovernanceBody members); on submit calls `POST /api/motions/{id}/co-sign-request`; shows current `coSigners` list; shows "Ondersteunen" button for invited Participants not yet confirmed (calls `POST /api/motions/{id}/co-sign-confirm`) -- [ ] 4.6 Add "Budget impact toevoegen" toggle to the MotionDetail edit form for `motionType: "amendment"` — shows three fields: budgetLine (text), amountDelta (number), rationale (text area); on save calls `POST /api/motions/{id}/budget-impact`; budget impact panel rendered in view mode below motion text -- [ ] 4.7 Add "Motie koppelen" action to `AgendaItemDetail.vue` for `decision`-type items (extends p2-agenda-management) — search dialog listing Motions for the same Meeting; creates OpenRegister relation AgendaItem → Motion +- [x] 4.1 Create `src/views/MotionIndex.vue` — route `/motions`; uses `CnIndexPage` with `useListView("Motion", { sidebarState, objectStore })`; columns: title, motionType badge, proposer, lifecycle badge, submittedAt; filter by lifecycle and motionType via `CnFilterBar` +- [x] 4.2 Create `src/views/MotionDetail.vue` — route `/motions/:id`; two modes (new/edit vs view); view mode uses `CnDetailPage` with sections: Motion text, co-signers list, budget impact panel (if note present), Amendments list (`AmendmentList.vue` sub-component), VotingRound panel (`VotingRoundPanel.vue` sub-component); sidebar `CnObjectSidebar` with Files, Notes, Audit tabs +- [x] 4.3 Add `CnTimelineStages` to `MotionDetail.vue` header — stages: Ingediend → Debat → Stemming → Aangenomen / Verworpen / Ingetrokken; active stage highlighted; withdrawn renders as terminal warning stage +- [x] 4.4 Add lifecycle action buttons to `MotionDetail.vue` — "Debat openen" (chair/secretary, from submitted), "Stemronde openen" (chair/secretary, from debating), "Motie intrekken" (proposer only, before voting); each button calls `POST /api/motions/{id}/transition` with `newState` +- [x] 4.5 Add "Medeondertekenaars uitnodigen" section to `MotionDetail.vue` — Participant multi-select (filtered to GovernanceBody members); on submit calls `POST /api/motions/{id}/co-sign-request`; shows current `coSigners` list; shows "Ondersteunen" button for invited Participants not yet confirmed (calls `POST /api/motions/{id}/co-sign-confirm`) +- [x] 4.6 Add "Budget impact toevoegen" toggle to the MotionDetail edit form for `motionType: "amendment"` — shows three fields: budgetLine (text), amountDelta (number), rationale (text area); on save calls `POST /api/motions/{id}/budget-impact`; budget impact panel rendered in view mode below motion text +- [x] 4.7 Add "Motie koppelen" action to `AgendaItemDetail.vue` for `decision`-type items (extends p2-agenda-management) — search dialog listing Motions for the same Meeting; creates OpenRegister relation AgendaItem → Motion ## 5. Frontend — Amendment Views -- [ ] 5.1 Create `src/components/AmendmentList.vue` — embedded in `MotionDetail.vue`; lists all Amendments for the motion with title, proposer, lifecycle badge, and link to AmendmentDetail; shows "N amendementen" count badge; "Amendement indienen" button (role: member/chair/secretary, motion lifecycle: submitted/debating) -- [ ] 5.2 Create `src/views/AmendmentDetail.vue` — route `/amendments/:id`; view mode with `CnDetailPage`; shows amendment text, proposer, lifecycle timeline, parent motion link; lifecycle action buttons (chair): "Debat openen", "Stemronde openen"; sidebar `CnObjectSidebar` with Audit tab -- [ ] 5.3 Add conflict detection notice to `AmendmentDetail.vue` — if a conflict notification exists for this amendment (fetched via notes with `title: "Conflict:"`), show a warning banner "Mogelijk conflict met ander amendement — raadpleeg de griffier" +- [x] 5.1 Create `src/components/AmendmentList.vue` — embedded in `MotionDetail.vue`; lists all Amendments for the motion with title, proposer, lifecycle badge, and link to AmendmentDetail; shows "N amendementen" count badge; "Amendement indienen" button (role: member/chair/secretary, motion lifecycle: submitted/debating) +- [x] 5.2 Create `src/views/AmendmentDetail.vue` — route `/amendments/:id`; view mode with `CnDetailPage`; shows amendment text, proposer, lifecycle timeline, parent motion link; lifecycle action buttons (chair): "Debat openen", "Stemronde openen"; sidebar `CnObjectSidebar` with Audit tab +- [x] 5.3 Add conflict detection notice to `AmendmentDetail.vue` — if a conflict notification exists for this amendment (fetched via notes with `title: "Conflict:"`), show a warning banner "Mogelijk conflict met ander amendement — raadpleeg de griffier" ## 6. Frontend — VotingRound Panel -- [ ] 6.1 Create `src/components/VotingRoundPanel.vue` — embedded in `MotionDetail.vue` and `AmendmentDetail.vue`; shows: current open VotingRound (if any) OR most recent closed round; vote casting buttons (Voor / Tegen / Onthouding) for active members in open rounds; confirmation message after vote is cast -- [ ] 6.2 Add live tally to `VotingRoundPanel.vue` — chair/secretary role sees "Uitgebracht: X / Y — Voor: A, Tegen: B, Onthouding: C" refreshed every 5 seconds via `objectStore.fetchObjects()` poll; member role sees "Uitgebracht: X / Y" only -- [ ] 6.3 Add "Stemronde openen" dialog to `VotingRoundPanel.vue` — visible to chair/secretary when Motion lifecycle is `debating`; fields: votingMethod (dropdown), isSecret (toggle), closedAt (optional datetime picker); on submit calls `POST /api/voting-rounds`; shows quorum error inline if returned -- [ ] 6.4 Add "Stemronde sluiten" button to `VotingRoundPanel.vue` — visible to chair/secretary when a round is open; shows confirmation dialog "Stemronde sluiten? X van Y leden hebben nog niet gestemd."; on confirm calls `POST /api/voting-rounds/{id}/close`; displays result immediately after close -- [ ] 6.5 Add result display to `VotingRoundPanel.vue` — shows vote totals, result badge (Aangenomen / Verworpen / Gelijk / Ongeldig), majority threshold calculation, and (for non-secret rounds) per-Participant vote breakdown with proxy flags; "Publiceren naar ORI" button (chair/secretary) calling `POST /api/voting-rounds/{id}/publish` -- [ ] 6.6 Add show-of-hands data entry to `VotingRoundPanel.vue` — visible when `votingMethod === "show-of-hands"` and round is open; three number inputs (Voor, Tegen, Onthouding); "Resultaat opslaan" button saves totals via `ObjectService.saveObject()`; individual vote buttons hidden +- [x] 6.1 Create `src/components/VotingRoundPanel.vue` — embedded in `MotionDetail.vue` and `AmendmentDetail.vue`; shows: current open VotingRound (if any) OR most recent closed round; vote casting buttons (Voor / Tegen / Onthouding) for active members in open rounds; confirmation message after vote is cast +- [x] 6.2 Add live tally to `VotingRoundPanel.vue` — chair/secretary role sees "Uitgebracht: X / Y — Voor: A, Tegen: B, Onthouding: C" refreshed every 5 seconds via `objectStore.fetchObjects()` poll; member role sees "Uitgebracht: X / Y" only +- [x] 6.3 Add "Stemronde openen" dialog to `VotingRoundPanel.vue` — visible to chair/secretary when Motion lifecycle is `debating`; fields: votingMethod (dropdown), isSecret (toggle), closedAt (optional datetime picker); on submit calls `POST /api/voting-rounds`; shows quorum error inline if returned +- [x] 6.4 Add "Stemronde sluiten" button to `VotingRoundPanel.vue` — visible to chair/secretary when a round is open; shows confirmation dialog "Stemronde sluiten? X van Y leden hebben nog niet gestemd."; on confirm calls `POST /api/voting-rounds/{id}/close`; displays result immediately after close +- [x] 6.5 Add result display to `VotingRoundPanel.vue` — shows vote totals, result badge (Aangenomen / Verworpen / Gelijk / Ongeldig), majority threshold calculation, and (for non-secret rounds) per-Participant vote breakdown with proxy flags; "Publiceren naar ORI" button (chair/secretary) calling `POST /api/voting-rounds/{id}/publish` +- [x] 6.6 Add show-of-hands data entry to `VotingRoundPanel.vue` — visible when `votingMethod === "show-of-hands"` and round is open; three number inputs (Voor, Tegen, Onthouding); "Resultaat opslaan" button saves totals via `ObjectService.saveObject()`; individual vote buttons hidden ## 7. Frontend — Proxy Voting -- [ ] 7.1 Add "Volmacht verlenen" action to `VotingRoundPanel.vue` — visible to active members before round opens; Participant selector filtered to active members in GovernanceBody excluding observers and guests; on submit calls `POST /api/voting-rounds/{id}/proxy` -- [ ] 7.2 Add "Volmacht intrekken" button — visible to the delegating Participant before round opens; calls `DELETE /api/voting-rounds/{id}/proxy`; shows error if round already opened -- [ ] 7.3 Display received proxy in `VotingRoundPanel.vue` for the delegate — "U stemt namens: [A]" shown above vote buttons when delegate has an active proxy; proxy vote is cast automatically alongside the delegate's own vote when they submit +- [x] 7.1 Add "Volmacht verlenen" action to `VotingRoundPanel.vue` — visible to active members before round opens; Participant selector filtered to active members in GovernanceBody excluding observers and guests; on submit calls `POST /api/voting-rounds/{id}/proxy` +- [x] 7.2 Add "Volmacht intrekken" button — visible to the delegating Participant before round opens; calls `DELETE /api/voting-rounds/{id}/proxy`; shows error if round already opened +- [x] 7.3 Display received proxy in `VotingRoundPanel.vue` for the delegate — "U stemt namens: [A]" shown above vote buttons when delegate has an active proxy; proxy vote is cast automatically alongside the delegate's own vote when they submit ## 8. Frontend — Motion and Vote Stores -- [ ] 8.1 Register `Motion` object type in `store/store.js` via `objectStore.registerObjectType("Motion", "motion", "decidesk")` with plugins: `auditTrailsPlugin`, `filesPlugin`, `relationsPlugin` -- [ ] 8.2 Register `Amendment`, `VotingRound`, and `Vote` object types in `store/store.js` with `relationsPlugin` -- [ ] 8.3 Add `MotionIndex` and `MotionDetail` routes to `src/router/index.js`: `/motions` and `/motions/:id`; `AmendmentDetail`: `/amendments/:id` -- [ ] 8.4 Add "Moties" navigation item to `MainMenu.vue` with route to MotionIndex and appropriate MDI icon +- [x] 8.1 Register `Motion` object type in `store/store.js` via `objectStore.registerObjectType("Motion", "motion", "decidesk")` with plugins: `auditTrailsPlugin`, `filesPlugin`, `relationsPlugin` +- [x] 8.2 Register `Amendment`, `VotingRound`, and `Vote` object types in `store/store.js` with `relationsPlugin` +- [x] 8.3 Add `MotionIndex` and `MotionDetail` routes to `src/router/index.js`: `/motions` and `/motions/:id`; `AmendmentDetail`: `/amendments/:id` +- [x] 8.4 Add "Moties" navigation item to `MainMenu.vue` with route to MotionIndex and appropriate MDI icon ## 9. Translations (ADR-007) -- [ ] 9.1 Add Dutch (nl) translation keys in `l10n/nl.js` and `l10n/nl.json` for all new user-visible strings: motion lifecycle labels (Ingediend, Debat, Stemronde, Aangenomen, Verworpen, Ingetrokken), voting method labels, quorum error messages, co-signatory dialog copy, proxy delegation labels, ORI publication labels, email vote keywords and confirmation messages, budget impact panel labels -- [ ] 9.2 Add English (en) translation keys matching all Dutch keys +- [x] 9.1 Add Dutch (nl) translation keys in `l10n/nl.js` and `l10n/nl.json` for all new user-visible strings: motion lifecycle labels (Ingediend, Debat, Stemronde, Aangenomen, Verworpen, Ingetrokken), voting method labels, quorum error messages, co-signatory dialog copy, proxy delegation labels, ORI publication labels, email vote keywords and confirmation messages, budget impact panel labels +- [x] 9.2 Add English (en) translation keys matching all Dutch keys ## 10. Settings (ADR-006) -- [ ] 10.1 Add "ORI-eindpunt" configuration field to the admin settings page — URL input stored via `IAppConfig` under key `ori_endpoint`; validated as a valid URL; used by `OriPublicationService` -- [ ] 10.2 Add "E-mail stemmen" toggle to admin settings — enables/disables `MailReplyHandler` background job; stored via `IAppConfig`; off by default +- [x] 10.1 Add "ORI-eindpunt" configuration field to the admin settings page — URL input stored via `IAppConfig` under key `ori_endpoint`; validated as a valid URL; used by `OriPublicationService` +- [x] 10.2 Add "E-mail stemmen" toggle to admin settings — enables/disables `MailReplyHandler` background job; stored via `IAppConfig`; off by default ## 11. Testing (ADR-008) -- [ ] 11.1 Write PHPUnit tests for `MotionServiceTest`: lifecycle transition allowed and blocked; addCoSigner idempotency; detectConflicts with and without overlap; applyAmendment text update -- [ ] 11.2 Write PHPUnit tests for `VotingServiceTest`: checkQuorum met and not met; openVotingRound quorum block; castVote update on duplicate; proxy one-per-round enforcement; tallyResults adopted/rejected/tied; closeVotingRound lifecycle; grantProxy observer rejection +- [x] 11.1 Write PHPUnit tests for `MotionServiceTest`: lifecycle transition allowed and blocked; addCoSigner idempotency; detectConflicts with and without overlap; applyAmendment text update +- [x] 11.2 Write PHPUnit tests for `VotingServiceTest`: checkQuorum met and not met; openVotingRound quorum block; castVote update on duplicate; proxy one-per-round enforcement; tallyResults adopted/rejected/tied; closeVotingRound lifecycle; grantProxy observer rejection - [ ] 11.3 Write Newman/Postman integration tests in `tests/integration/motion-voting.json` for all 11 new API endpoints (motion transition, co-sign-request, co-sign-confirm, budget-impact, amendment transition, voting-round open, cast, close, publish, proxy grant, proxy revoke) - [ ] 11.4 Write Playwright browser tests for: REQ-MOT-001 (motion submission), REQ-MOT-004 (co-signature collection), REQ-AMD-003 (conflict detection notification), REQ-VRM-002 (quorum block), REQ-VCT-001 (vote cast and overwrite), REQ-VCT-005 (keyboard accessible voting), REQ-RES-001 (result display after close), REQ-RES-003 (dossier folder created), REQ-PRX-001 (proxy delegation), REQ-PRX-003 (proxy revocation before open) ## 12. Verification -- [ ] 12.1 Verify all new PHP classes and public methods have `@spec openspec/changes/p2-motion-and-voting/tasks.md#task-N` PHPDoc tags -- [ ] 12.2 Verify all user-visible strings use `t(appName, 'text')` — no hardcoded Dutch or English strings in templates or JS -- [ ] 12.3 Verify no hardcoded CSS colors — only Nextcloud CSS variables (ADR-010) -- [ ] 12.4 Verify WCAG 2.1 AA: keyboard navigation in vote casting, ARIA labels on all interactive controls, colour not the sole indicator of vote selection or lifecycle state (REQ-VCT-005) -- [ ] 12.5 Verify `Motion`, `Amendment`, `Vote`, and `VotingRound` schemas in OpenRegister still match ADR-000 exactly after implementation — no extra properties added +- [x] 12.1 Verify all new PHP classes and public methods have `@spec openspec/changes/p2-motion-and-voting/tasks.md#task-N` PHPDoc tags +- [x] 12.2 Verify all user-visible strings use `t(appName, 'text')` — no hardcoded Dutch or English strings in templates or JS +- [x] 12.3 Verify no hardcoded CSS colors — only Nextcloud CSS variables (ADR-010) +- [x] 12.4 Verify WCAG 2.1 AA: keyboard navigation in vote casting, ARIA labels on all interactive controls, colour not the sole indicator of vote selection or lifecycle state (REQ-VCT-005) +- [x] 12.5 Verify `Motion`, `Amendment`, `Vote`, and `VotingRound` schemas in OpenRegister still match ADR-000 exactly after implementation — no extra properties added - [ ] 12.6 Verify seed data (5 Motion, 3 Amendment, 4 VotingRound, 4 Vote objects) is present after fresh install -- [ ] 12.7 Verify `OriPublicationService` gracefully handles missing config (no ORI endpoint set) without throwing an exception +- [x] 12.7 Verify `OriPublicationService` gracefully handles missing config (no ORI endpoint set) without throwing an exception diff --git a/package.json b/package.json index c3da794c..d294197b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ ], "dependencies": { "@conduction/nextcloud-vue": "^0.1.0-beta.3", + "@nextcloud/auth": "^2.5.3", "@nextcloud/axios": "^2.5.0", "@nextcloud/dialogs": "^3.2.0", "@nextcloud/initial-state": "^2.2.0", diff --git a/phpstan.neon b/phpstan.neon index dfca4513..ac7c499c 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -29,3 +29,5 @@ parameters: - '#Parameter \$statusCode of class OCP\\AppFramework\\Http\\JSONResponse constructor expects#' # registerRepairStep exists on server; not yet in nextcloud/ocp stub used for analysis - '#Call to an undefined method OCP\\AppFramework\\Bootstrap\\IRegistrationContext::registerRepairStep#' + # MailReplyHandler stores $container for future IMAP implementation (deferred to p3) + - '#Property OCA\\Decidesk\\BackgroundJob\\MailReplyHandler::\$container is never read, only written\.#' diff --git a/src/components/AmendmentList.vue b/src/components/AmendmentList.vue new file mode 100644 index 00000000..03675a0c --- /dev/null +++ b/src/components/AmendmentList.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/src/components/VotingRoundPanel.vue b/src/components/VotingRoundPanel.vue new file mode 100644 index 00000000..727c9e78 --- /dev/null +++ b/src/components/VotingRoundPanel.vue @@ -0,0 +1,572 @@ + + + + + diff --git a/src/navigation/MainMenu.vue b/src/navigation/MainMenu.vue index 6c6dc713..b8e94383 100644 --- a/src/navigation/MainMenu.vue +++ b/src/navigation/MainMenu.vue @@ -10,6 +10,13 @@ + + + @@ -34,6 +41,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 { @@ -43,6 +51,7 @@ export default { NcAppNavigationItem, BookOpenVariantOutline, CogIcon, + GavelIcon, HomeIcon, }, methods: { diff --git a/src/store/modules/settings.js b/src/store/modules/settings.js index 03fabee7..b801075c 100644 --- a/src/store/modules/settings.js +++ b/src/store/modules/settings.js @@ -8,6 +8,7 @@ export const useSettingsStore = defineStore('settings', { loading: false, hasOpenRegisters: false, isAdmin: false, + isChair: false, }), getters: { @@ -27,6 +28,7 @@ export const useSettingsStore = defineStore('settings', { this.settings = data this.hasOpenRegisters = !!data?.openregisters this.isAdmin = !!data?.isAdmin + this.isChair = !!data?.isChair return data } } catch (error) { diff --git a/src/views/AmendmentDetail.vue b/src/views/AmendmentDetail.vue new file mode 100644 index 00000000..ba941ffa --- /dev/null +++ b/src/views/AmendmentDetail.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/src/views/MotionDetail.vue b/src/views/MotionDetail.vue index 8ee5cab8..3da36c9c 100644 --- a/src/views/MotionDetail.vue +++ b/src/views/MotionDetail.vue @@ -1,19 +1,388 @@ - - - -