diff --git a/appinfo/routes.php b/appinfo/routes.php index 017b39ab..d78ea93a 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -22,6 +22,14 @@ // Meeting lifecycle transitions. ['name' => 'meeting#lifecycle', 'url' => '/api/meetings/{id}/lifecycle', 'verb' => 'POST'], + + // Agenda lifecycle routes (task-1.3) — specific routes BEFORE wildcard catch-all. + ['name' => 'agenda#publish', 'url' => '/api/agendas/{meetingId}/publish', 'verb' => 'POST'], + ['name' => 'agenda#revise', 'url' => '/api/agendas/{meetingId}/revise', 'verb' => 'PUT'], + ['name' => 'agenda#advanceBobPhase', 'url' => '/api/agenda-items/{id}/bob-phase', 'verb' => 'PUT'], + ['name' => 'agenda#processHamerstukken', 'url' => '/api/agendas/{meetingId}/hamerstukken', 'verb' => 'POST'], + ['name' => 'agenda#reorder', 'url' => '/api/agendas/{meetingId}/reorder', 'verb' => 'PUT'], + // Motion lifecycle and co-signature routes (specific before wildcard). ['name' => 'motion#transition', 'url' => '/api/motions/{id}/transition', 'verb' => 'POST'], ['name' => 'motion#coSignRequest', 'url' => '/api/motions/{id}/co-sign-request', 'verb' => 'POST'], diff --git a/l10n/en.json b/l10n/en.json index 158506ff..1e5ccd45 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -126,6 +126,90 @@ "Yes": "Yes", "scheduled": "scheduled", "total": "total", + "Agenda": "Agenda", + "Agenda builder": "Agenda builder", + "Total duration: {min} min": "Total duration: {min} min", + "Add recurring items": "Add recurring items", + "Propose agenda item": "Propose agenda item", + "Proposed items": "Proposed items", + "Approve": "Approve", + "Reject": "Reject", + "Approve proposal {title}": "Approve proposal {title}", + "Reject proposal {title}": "Reject proposal {title}", + "Agenda items, drag to reorder": "Agenda items, drag to reorder", + "Agenda item {n}: {title}": "Agenda item {n}: {title}", + "Type: {type}": "Type: {type}", + "{n} attachment(s)": "{n} attachment(s)", + "{n} conflict of interest declaration(s)": "{n} conflict of interest declaration(s)", + "COI ({n})": "COI ({n})", + "Assign spokesperson for {title}": "Assign spokesperson for {title}", + "Change spokesperson": "Change spokesperson", + "Assign spokesperson": "Assign spokesperson", + "Move {title} up": "Move {title} up", + "Move {title} down": "Move {title} down", + "Add selected": "Add selected", + "Fill in the agenda item details. The chair will approve or reject your proposal.": "Fill in the agenda item details. The chair will approve or reject your proposal.", + "Agenda item title": "Agenda item title", + "Describe the agenda item": "Describe the agenda item", + "Submit proposal": "Submit proposal", + "No recurring agenda items found.": "No recurring agenda items found.", + "No participants found.": "No participants found.", + "Remove spokesperson": "Remove spokesperson", + "Publish agenda": "Publish agenda", + "Revise agenda": "Revise agenda", + "Export": "Export", + "Export agenda": "Export agenda", + "Live meeting": "Live meeting", + "Back to meeting detail": "Back to meeting detail", + "Back": "Back", + "Open live meeting view": "Open live meeting view", + "Cannot publish: no agenda items.": "Cannot publish: no agenda items.", + "Failed to publish agenda.": "Failed to publish agenda.", + "Conflict of interest declarations": "Conflict of interest declarations", + "Number": "Number", + "Duration (min)": "Duration (min)", + "Spokesperson": "Spokesperson", + "BOB phase": "BOB phase", + "Beeldvorming": "Image formation", + "Oordeelsvorming": "Opinion forming", + "Besluitvorming": "Decision making", + "BOB phase progression": "BOB phase progression", + "Linked motions": "Linked motions", + "No linked motions.": "No linked motions.", + "Link motion": "Link motion", + "Link a motion to this agenda item": "Link a motion to this agenda item", + "Select a motion from the same meeting to link.": "Select a motion from the same meeting to link.", + "No motions found for this meeting.": "No motions found for this meeting.", + "Conflict of interest": "Conflict of interest", + "Declare conflict of interest for this agenda item": "Declare conflict of interest for this agenda item", + "Declare conflict of interest": "Declare conflict of interest", + "Reason for recusal": "Reason for recusal", + "Describe your reason for declaring a conflict of interest": "Describe your reason for declaring a conflict of interest", + "Submit declaration": "Submit declaration", + "Status": "Status", + "No spokesperson assigned.": "No spokesperson assigned.", + "Informational": "Informational", + "Discussion": "Discussion", + "Consent agenda items (hamerstukken)": "Consent agenda items (hamerstukken)", + "Remove {title} from consent agenda": "Remove {title} from consent agenda", + "Remove from consent agenda": "Remove from consent agenda", + "Adopt all consent agenda items": "Adopt all consent agenda items", + "Adopt consent agenda": "Adopt consent agenda", + "Confirm adoption": "Confirm adoption", + "This will set all {n} consent agenda items to \"Adopted\" (afgerond). Continue?": "This will set all {n} consent agenda items to \"Adopted\" (afgerond). Continue?", + "Confirm": "Confirm", + "Cancel": "Cancel", + "Activate {title}": "Activate {title}", + "Active: {title}": "Active: {title}", + "Next phase": "Next phase", + "Advance to next BOB phase for {title}": "Advance to next BOB phase for {title}", + "BOB phase for {title}": "BOB phase for {title}", + "Live meeting view": "Live meeting view", + "Activate agenda item": "Activate agenda item", + "Active agenda item": "Active agenda item", + "Activate item": "Activate item", + "min": "min", + "Active {title}": "Active {title}", "Submit Motion": "Submit Motion", "Motion Details": "Motion Details", "Co-Signatories": "Co-Signatories", @@ -164,7 +248,6 @@ "Secret Ballot": "Secret Ballot", "Close At (optional)": "Close At (optional)", "Open": "Open", - "Cancel": "Cancel", "Failed to open voting round": "Failed to open voting round", "No voting round for this item.": "No voting round for this item.", "Cast": "Cast", diff --git a/l10n/nl.json b/l10n/nl.json index 0ce22f5c..70ac4115 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -126,6 +126,90 @@ "Yes": "Ja", "scheduled": "gepland", "total": "totaal", + "Agenda": "Agenda", + "Agenda builder": "Agendabouwer", + "Total duration: {min} min": "Totale duur: {min} min", + "Add recurring items": "Terugkerende agendapunten toevoegen", + "Propose agenda item": "Agendapunt voorstellen", + "Proposed items": "Voorgestelde agendapunten", + "Approve": "Goedkeuren", + "Reject": "Afwijzen", + "Approve proposal {title}": "Voorstel goedkeuren: {title}", + "Reject proposal {title}": "Voorstel afwijzen: {title}", + "Agenda items, drag to reorder": "Agendapunten, sleep om te herordenen", + "Agenda item {n}: {title}": "Agendapunt {n}: {title}", + "Type: {type}": "Type: {type}", + "{n} attachment(s)": "{n} bijlage(n)", + "{n} conflict of interest declaration(s)": "{n} verklaring(en) van belangenverstrengeling", + "COI ({n})": "BV ({n})", + "Assign spokesperson for {title}": "Spreker toewijzen voor {title}", + "Change spokesperson": "Spreker wijzigen", + "Assign spokesperson": "Spreker toewijzen", + "Move {title} up": "{title} omhoog verplaatsen", + "Move {title} down": "{title} omlaag verplaatsen", + "Add selected": "Geselecteerde toevoegen", + "Fill in the agenda item details. The chair will approve or reject your proposal.": "Vul de agendapuntdetails in. De voorzitter keurt uw voorstel goed of wijst het af.", + "Agenda item title": "Agendapunttitel", + "Describe the agenda item": "Beschrijf het agendapunt", + "Submit proposal": "Voorstel indienen", + "No recurring agenda items found.": "Geen terugkerende agendapunten gevonden.", + "No participants found.": "Geen deelnemers gevonden.", + "Remove spokesperson": "Spreker verwijderen", + "Publish agenda": "Agenda publiceren", + "Revise agenda": "Agenda herzien", + "Export": "Exporteren", + "Export agenda": "Agenda exporteren", + "Live meeting": "Live vergadering", + "Back to meeting detail": "Terug naar vergaderdetail", + "Back": "Terug", + "Open live meeting view": "Live vergaderingweergave openen", + "Cannot publish: no agenda items.": "Kan niet publiceren: geen agendapunten.", + "Failed to publish agenda.": "Publiceren van agenda mislukt.", + "Conflict of interest declarations": "Verklaringen belangenverstrengeling", + "Number": "Nummer", + "Duration (min)": "Duur (min)", + "Spokesperson": "Spreker", + "BOB phase": "BOB-fase", + "Beeldvorming": "Beeldvorming", + "Oordeelsvorming": "Oordeelsvorming", + "Besluitvorming": "Besluitvorming", + "BOB phase progression": "Voortgang BOB-fase", + "Linked motions": "Gekoppelde moties", + "No linked motions.": "Geen gekoppelde moties.", + "Link motion": "Motie koppelen", + "Link a motion to this agenda item": "Een motie koppelen aan dit agendapunt", + "Select a motion from the same meeting to link.": "Selecteer een motie uit dezelfde vergadering om te koppelen.", + "No motions found for this meeting.": "Geen moties gevonden voor deze vergadering.", + "Conflict of interest": "Belangenverstrengeling", + "Declare conflict of interest for this agenda item": "Belangenverstrengeling melden voor dit agendapunt", + "Declare conflict of interest": "Belangenverstrengeling melden", + "Reason for recusal": "Reden voor ontheffing", + "Describe your reason for declaring a conflict of interest": "Beschrijf uw reden voor het melden van een belangenverstrengeling", + "Submit declaration": "Verklaring indienen", + "Status": "Status", + "No spokesperson assigned.": "Geen spreker toegewezen.", + "Informational": "Informatief", + "Discussion": "Discussie", + "Consent agenda items (hamerstukken)": "Hamerstukken", + "Remove {title} from consent agenda": "{title} uit hamerstukken halen", + "Remove from consent agenda": "Uit hamerstukken halen", + "Adopt all consent agenda items": "Alle hamerstukken vaststellen", + "Adopt consent agenda": "Hamerstukken vaststellen", + "Confirm adoption": "Vaststelling bevestigen", + "This will set all {n} consent agenda items to \"Adopted\" (afgerond). Continue?": "Dit stelt alle {n} hamerstukken in op \u201CAfgerond\u201D. Doorgaan?", + "Confirm": "Bevestigen", + "Cancel": "Annuleren", + "Activate {title}": "{title} activeren", + "Active: {title}": "Actief: {title}", + "Next phase": "Volgende fase", + "Advance to next BOB phase for {title}": "Naar volgende BOB-fase voor {title}", + "BOB phase for {title}": "BOB-fase voor {title}", + "Live meeting view": "Live vergaderingweergave", + "Activate agenda item": "Agendapunt activeren", + "Active agenda item": "Actief agendapunt", + "Activate item": "Item activeren", + "min": "min", + "Active {title}": "Actief: {title}", "Submit Motion": "Motie indienen", "Motion Details": "Motiedetails", "Co-Signatories": "Medeondertekenaars", @@ -164,7 +248,6 @@ "Secret Ballot": "Geheime stemming", "Close At (optional)": "Sluiten om (optioneel)", "Open": "Openen", - "Cancel": "Annuleren", "Failed to open voting round": "Stemronde kon niet worden geopend", "No voting round for this item.": "Geen stemronde voor dit item.", "Cast": "Uitgebracht", diff --git a/lib/Controller/AgendaController.php b/lib/Controller/AgendaController.php new file mode 100644 index 00000000..a1d270a9 --- /dev/null +++ b/lib/Controller/AgendaController.php @@ -0,0 +1,331 @@ + + * @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 + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Decidesk\Controller; + +use OCA\Decidesk\AppInfo\Application; +use OCA\Decidesk\Exception\NotFoundException; +use OCA\Decidesk\Service\AgendaService; +use OCA\OpenRegister\Service\ObjectService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * REST controller for agenda lifecycle operations. + * + * Routes: + * POST /api/agendas/{meetingId}/publish → publishAgenda + * PUT /api/agenda-items/{id}/bob-phase → advanceBobPhase + * POST /api/agendas/{meetingId}/hamerstukken → processHamerstukken + * PUT /api/agendas/{meetingId}/reorder → reorderItems + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ +class AgendaController extends Controller +{ + /** + * Constructor for AgendaController. + * + * @param IRequest $request The HTTP request + * @param AgendaService $agendaService The agenda service + * @param ObjectService $objectService OpenRegister object service (used for auth checks) + * @param IUserSession $userSession The current user session + * @param IGroupManager $groupManager Group manager for admin checks + * @param LoggerInterface $logger PSR-3 logger + * + * @return void + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ + public function __construct( + IRequest $request, + private readonly AgendaService $agendaService, + private readonly ObjectService $objectService, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + private readonly LoggerInterface $logger, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Verify the current user is an admin or holds a chair/secretary role for a meeting. + * + * @param string $meetingId UUID of the meeting to check + * + * @return JSONResponse|null Null if authorised, 403 JSONResponse if not. + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ + private function requireChairOrAdmin(string $meetingId): ?JSONResponse + { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['message' => 'Authentication required'], Http::STATUS_UNAUTHORIZED); + } + + $userId = $user->getUID(); + + if ($this->groupManager->isAdmin($userId) === true) { + return null; + } + + $participants = $this->objectService->findAll( + [ + 'filters' => [ + 'register' => 'decidesk', + 'schema' => 'participant', + '@self.relations.meeting' => $meetingId, + ], + ] + ); + + foreach ($participants as $p) { + if (is_array($p) === true) { + $pData = $p; + } else { + $pData = (array) $p; + } + + $owner = $pData['owner'] ?? null; + $role = $pData['role'] ?? null; + if ($owner === $userId && in_array(needle: $role, haystack: ['chair', 'secretary'], strict: true) === true) { + return null; + } + } + + return new JSONResponse( + ['message' => 'Chair or secretary role required for this meeting'], + Http::STATUS_FORBIDDEN + ); + + }//end requireChairOrAdmin() + + /** + * Publish the agenda for a meeting. + * + * Validates items exist, notifies participants, transitions Meeting to 'opened'. + * + * @param string $meetingId UUID of the Meeting + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ + #[NoAdminRequired] + public function publish(string $meetingId): JSONResponse + { + $denied = $this->requireChairOrAdmin(meetingId: $meetingId); + if ($denied !== null) { + return $denied; + } + + try { + $this->agendaService->publishAgenda($meetingId); + return new JSONResponse(['success' => true]); + } catch (\InvalidArgumentException $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY); + } catch (\Throwable $e) { + $this->logger->error( + 'publishAgenda failed for meeting {id}: {error}', + ['id' => $meetingId, 'error' => $e->getMessage(), 'exception' => $e] + ); + return new JSONResponse(['message' => 'An internal error occurred.'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + }//end publish() + + /** + * Advance the BOB phase of a single agenda item. + * + * @param string $id UUID of the AgendaItem + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ + #[NoAdminRequired] + public function advanceBobPhase(string $id): JSONResponse + { + try { + // Resolve the meeting for authorization; 404 if item does not exist. + $item = $this->objectService->find($id); + if ($item === null) { + return new JSONResponse(['message' => 'Agenda item not found.'], Http::STATUS_NOT_FOUND); + } + + if (is_array($item) === true) { + $itemData = $item; + } else { + $itemData = (array) $item; + } + + $meetingId = $itemData['@self']['relations']['meeting'] ?? null; + + if ($meetingId === null) { + return new JSONResponse(['message' => 'Could not resolve meeting for authorization.'], Http::STATUS_FORBIDDEN); + } + + $denied = $this->requireChairOrAdmin(meetingId: (string) $meetingId); + if ($denied !== null) { + return $denied; + } + + $this->agendaService->advanceBobPhase($id); + return new JSONResponse(['success' => true]); + } catch (NotFoundException $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\InvalidArgumentException $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY); + } catch (\Throwable $e) { + $this->logger->error( + 'advanceBobPhase failed for item {id}: {error}', + ['id' => $id, 'error' => $e->getMessage(), 'exception' => $e] + ); + return new JSONResponse(['message' => 'An internal error occurred.'], Http::STATUS_INTERNAL_SERVER_ERROR); + }//end try + + }//end advanceBobPhase() + + /** + * Process all hamerstukken (consent items) for a meeting. + * + * Sets status of all items tagged 'hamerstuk' to 'afgerond'. + * + * @param string $meetingId UUID of the Meeting + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ + #[NoAdminRequired] + public function processHamerstukken(string $meetingId): JSONResponse + { + $denied = $this->requireChairOrAdmin(meetingId: $meetingId); + if ($denied !== null) { + return $denied; + } + + try { + $this->agendaService->processHamerstukken($meetingId); + return new JSONResponse(['success' => true]); + } catch (\Throwable $e) { + $this->logger->error( + 'processHamerstukken failed for meeting {meetingId}: {error}', + ['meetingId' => $meetingId, 'error' => $e->getMessage(), 'exception' => $e] + ); + return new JSONResponse(['message' => 'An internal error occurred.'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + }//end processHamerstukken() + + /** + * Revert a published agenda to draft (scheduled) state. + * + * Reverts the Meeting lifecycle back to 'scheduled', allowing further + * edits before a subsequent publish. Requires chair or secretary role. + * + * @param string $meetingId UUID of the Meeting + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ + #[NoAdminRequired] + public function revise(string $meetingId): JSONResponse + { + $denied = $this->requireChairOrAdmin(meetingId: $meetingId); + if ($denied !== null) { + return $denied; + } + + try { + $this->agendaService->reviseAgenda($meetingId); + return new JSONResponse(['success' => true]); + } catch (\Throwable $e) { + $this->logger->error( + 'reviseAgenda failed for meeting {meetingId}: {error}', + ['meetingId' => $meetingId, 'error' => $e->getMessage(), 'exception' => $e] + ); + return new JSONResponse(['message' => 'An internal error occurred.'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + }//end revise() + + /** + * Reorder agenda items for a meeting. + * + * Accepts body: { "ids": ["uuid1", "uuid2", ...] } + * Assigns sequential orderNumber values 1..n. + * + * @param string $meetingId UUID of the Meeting + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ + #[NoAdminRequired] + public function reorder(string $meetingId): JSONResponse + { + $denied = $this->requireChairOrAdmin(meetingId: $meetingId); + if ($denied !== null) { + return $denied; + } + + $body = $this->request->getParams(); + $ids = $body['ids'] ?? []; + + if (empty($ids) === true || is_array($ids) === false) { + return new JSONResponse(['message' => 'ids array is required'], Http::STATUS_BAD_REQUEST); + } + + try { + $this->agendaService->reorderItems($meetingId, $ids); + return new JSONResponse(['success' => true]); + } catch (\Throwable $e) { + $this->logger->error( + 'reorderItems failed for meeting {meetingId}: {error}', + ['meetingId' => $meetingId, 'error' => $e->getMessage(), 'exception' => $e] + ); + return new JSONResponse(['message' => 'An internal error occurred.'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + }//end reorder() +}//end class diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index b8d30655..f5e9b547 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -25,6 +25,7 @@ use OCA\Decidesk\Service\SettingsService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\IGroupManager; use OCP\IRequest; @@ -81,6 +82,7 @@ private function requireAdmin(): ?JSONResponse * * @return JSONResponse */ + #[NoAdminRequired] public function index(): JSONResponse { return new JSONResponse( diff --git a/lib/Exception/NotFoundException.php b/lib/Exception/NotFoundException.php new file mode 100644 index 00000000..e9c009d0 --- /dev/null +++ b/lib/Exception/NotFoundException.php @@ -0,0 +1,35 @@ + + * @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 + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ + +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. +declare(strict_types=1); + +namespace OCA\Decidesk\Exception; + +use RuntimeException; + +/** + * Thrown when a requested resource cannot be found. + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ +class NotFoundException extends RuntimeException +{ +}//end class diff --git a/lib/Service/AgendaService.php b/lib/Service/AgendaService.php new file mode 100644 index 00000000..e5350923 --- /dev/null +++ b/lib/Service/AgendaService.php @@ -0,0 +1,433 @@ + + * @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 + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1 + */ + +declare(strict_types=1); + +namespace OCA\Decidesk\Service; + +use DateTime; +use InvalidArgumentException; +use OCA\Decidesk\Exception\NotFoundException; +use OCA\OpenRegister\Service\CalendarEventService; +use OCA\OpenRegister\Service\ObjectService; +use OCP\Notification\IManager as INotificationManager; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Service for managing agenda lifecycle operations. + * + * Provides domain-specific business logic for: + * - Publishing agendas and notifying participants + * - Advancing BOB (Beeldvorming/Oordeelsvorming/Besluitvorming) phases + * - Processing consent agenda items (hamerstukken) + * - Atomically reordering agenda items + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1 + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.1 + */ +class AgendaService +{ + + /** + * BOB phase transition map: current status → next status. + * + * @var array + */ + private const BOB_PHASE_TRANSITIONS = [ + 'voorstel' => 'beeldvorming', + 'beeldvorming' => 'oordeelsvorming', + 'oordeelsvorming' => 'besluitvorming', + 'besluitvorming' => 'afgerond', + ]; + + /** + * Tag identifying consent agenda items. + * + * @var string + */ + private const HAMERSTUK_TAG = 'hamerstuk'; + + /** + * Constructor for AgendaService. + * + * @param ObjectService $objectService OpenRegister object service + * @param CalendarEventService $calendarEventService OpenRegister calendar event service + * @param INotificationManager $notificationManager Nextcloud notification manager + * @param LoggerInterface $logger PSR-3 logger + * + * @return void + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.1 + */ + public function __construct( + private readonly ObjectService $objectService, + private readonly CalendarEventService $calendarEventService, + private readonly INotificationManager $notificationManager, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Publish an agenda for a meeting. + * + * Validates that at least one AgendaItem exists for the meeting, + * then sends Nextcloud notifications to all active participants + * and updates the Meeting lifecycle to 'opened'. + * + * @param string $meetingId UUID of the Meeting to publish + * + * @return void + * + * @throws InvalidArgumentException When no agenda items exist for the meeting + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.1 + */ + public function publishAgenda(string $meetingId): void + { + // Validate at least one AgendaItem exists. + $items = $this->objectService->findAll( + [ + 'filters' => [ + 'register' => 'decidesk', + 'schema' => 'agenda-item', + '@self.relations.meeting' => $meetingId, + ], + ] + ); + + if (empty($items) === true) { + throw new InvalidArgumentException('Cannot publish agenda: no agenda items exist for this meeting.'); + } + + // Fetch participants for this specific meeting only. + $participants = $this->objectService->findAll( + [ + 'filters' => [ + 'register' => 'decidesk', + 'schema' => 'participant', + '@self.relations.meeting' => $meetingId, + ], + ] + ); + + // Notify each active participant (leftAt is null = still active). + foreach ($participants as $participant) { + $participantData = $this->toArray(item: $participant); + $leftAt = $participantData['leftAt'] ?? null; + if ($leftAt !== null) { + continue; + } + + $userId = $participantData['owner'] ?? null; + if ($userId === null) { + continue; + } + + $this->sendAgendaPublishedNotification( + userId: (string) $userId, + meetingId: $meetingId + ); + } + + // Update the meeting calendar entry to reflect the published agenda. + $this->calendarEventService->updateMeetingEvent(meetingId: $meetingId); + + // Update meeting lifecycle to 'opened'. + $this->objectService->saveObject( + object: [ + 'id' => $meetingId, + 'lifecycle' => 'opened', + ], + register: 'decidesk', + schema: 'meeting', + uuid: $meetingId, + ); + + $this->logger->info('Agenda published for meeting {meetingId}', ['meetingId' => $meetingId]); + + }//end publishAgenda() + + /** + * Send an agenda-published Nextcloud notification to a single user. + * + * @param string $userId The Nextcloud user ID to notify + * @param string $meetingId The meeting UUID for deep-link context + * + * @return void + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.1 + */ + private function sendAgendaPublishedNotification(string $userId, string $meetingId): void + { + try { + $notification = $this->notificationManager->createNotification(); + $notification->setApp('decidesk') + ->setUser($userId) + ->setDateTime(new DateTime()) + ->setObject('meeting', $meetingId) + ->setSubject('agenda_published', ['meetingId' => $meetingId]); + + $this->notificationManager->notify($notification); + } catch (Throwable $e) { + $this->logger->warning( + 'Failed to send agenda notification to user {userId}: {error}', + ['userId' => $userId, 'error' => $e->getMessage()] + ); + } + + }//end sendAgendaPublishedNotification() + + /** + * Normalise an OpenRegister object to a plain PHP array. + * + * Handles both raw arrays and ObjectEntity instances returned by ObjectService. + * + * @param mixed $item The raw item from ObjectService + * + * @return array + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.1 + */ + private function toArray(mixed $item): array + { + if (is_array($item) === true) { + return $item; + } + + if (method_exists($item, 'getObject') === true) { + return $item->getObject(); + } + + return (array) $item; + + }//end toArray() + + /** + * Advance the BOB phase of a single agenda item. + * + * Maps the current status to the next BOB phase using the transition table. + * Informational items (itemType = 'informational') cannot be advanced. + * + * BOB phase order: voorstel → beeldvorming → oordeelsvorming → besluitvorming → afgerond + * + * @param string $agendaItemId UUID of the AgendaItem to advance + * + * @return void + * + * @throws NotFoundException When the agenda item does not exist + * @throws InvalidArgumentException When item is informational or already at final phase + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.1 + */ + public function advanceBobPhase(string $agendaItemId): void + { + $item = $this->objectService->find($agendaItemId); + if ($item === null) { + throw new NotFoundException(message: "AgendaItem {$agendaItemId} not found."); + } + + $itemData = $this->toArray(item: $item); + + // Guard: informational items have no BOB phase. + $itemType = $itemData['itemType'] ?? null; + if ($itemType === 'informational') { + throw new InvalidArgumentException('Informational agenda items do not have a BOB phase.'); + } + + $currentStatus = $itemData['status'] ?? 'beeldvorming'; + $nextStatus = self::BOB_PHASE_TRANSITIONS[$currentStatus] ?? null; + + if ($nextStatus === null) { + throw new InvalidArgumentException( + "AgendaItem is already at final phase '{$currentStatus}' and cannot be advanced." + ); + } + + $this->objectService->saveObject( + object: [ + 'id' => $agendaItemId, + 'status' => $nextStatus, + ], + register: 'decidesk', + schema: 'agenda-item', + uuid: $agendaItemId, + ); + + $this->logger->info( + 'BOB phase advanced for agenda item {id}: {from} to {to}', + ['id' => $agendaItemId, 'from' => $currentStatus, 'to' => $nextStatus] + ); + + }//end advanceBobPhase() + + /** + * Process all consent agenda items (hamerstukken) for a meeting. + * + * Fetches all AgendaItems for the meeting that have the 'hamerstuk' tag + * and bulk-updates their status to 'afgerond' via ObjectService. + * + * @param string $meetingId UUID of the Meeting + * + * @return void + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.1 + */ + public function processHamerstukken(string $meetingId): void + { + $items = $this->objectService->findAll( + [ + 'filters' => [ + 'register' => 'decidesk', + 'schema' => 'agenda-item', + '@self.relations.meeting' => $meetingId, + ], + ] + ); + + $processedCount = 0; + foreach ($items as $item) { + $itemData = $this->toArray(item: $item); + $tags = $itemData['tags'] ?? ($itemData['@self']['tags'] ?? []); + + if (in_array(needle: self::HAMERSTUK_TAG, haystack: (array) $tags, strict: true) === false) { + continue; + } + + $itemId = $itemData['id'] ?? ($itemData['@self']['id'] ?? ($itemData['uuid'] ?? null)); + if ($itemId === null) { + continue; + } + + $this->objectService->saveObject( + object: [ + 'id' => $itemId, + 'status' => 'afgerond', + ], + register: 'decidesk', + schema: 'agenda-item', + uuid: (string) $itemId, + ); + + $processedCount++; + }//end foreach + + $this->logger->info( + 'Processed {count} hamerstukken for meeting {meetingId}', + ['count' => $processedCount, 'meetingId' => $meetingId] + ); + + }//end processHamerstukken() + + /** + * Revert a published agenda back to draft (scheduled) state. + * + * Updates the Meeting lifecycle to 'scheduled', allowing chair/secretary to + * continue editing before a subsequent publish. Symmetric with publishAgenda(). + * + * @param string $meetingId UUID of the Meeting to revert + * + * @return void + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.1 + */ + public function reviseAgenda(string $meetingId): void + { + $this->objectService->saveObject( + object: [ + 'id' => $meetingId, + 'lifecycle' => 'scheduled', + ], + register: 'decidesk', + schema: 'meeting', + uuid: $meetingId, + ); + + $this->logger->info('Agenda reverted to draft for meeting {meetingId}', ['meetingId' => $meetingId]); + + }//end reviseAgenda() + + /** + * Atomically reorder agenda items for a meeting. + * + * Accepts an ordered array of AgendaItem UUIDs and assigns sequential + * orderNumber values 1..n, preventing gaps and duplicates. + * + * @param string $meetingId UUID of the Meeting (used for validation) + * @param string[] $orderedIds Ordered array of AgendaItem UUIDs + * + * @return void + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.1 + */ + public function reorderItems(string $meetingId, array $orderedIds): void + { + // Build a set of valid UUIDs that belong to this meeting. + $meetingItems = $this->objectService->findAll( + [ + 'filters' => [ + 'register' => 'decidesk', + 'schema' => 'agenda-item', + '@self.relations.meeting' => $meetingId, + ], + ] + ); + + $validIds = []; + foreach ($meetingItems as $item) { + $itemData = $this->toArray(item: $item); + $itemId = $itemData['id'] ?? ($itemData['@self']['id'] ?? ($itemData['uuid'] ?? null)); + if ($itemId !== null) { + $validIds[(string) $itemId] = true; + } + } + + $orderNumber = 1; + foreach ($orderedIds as $itemId) { + if (isset($validIds[(string) $itemId]) === false) { + $this->logger->warning( + 'reorderItems: UUID {id} does not belong to meeting {meetingId} — skipped', + ['id' => $itemId, 'meetingId' => $meetingId] + ); + continue; + } + + $this->objectService->saveObject( + object: [ + 'id' => $itemId, + 'orderNumber' => $orderNumber, + ], + register: 'decidesk', + schema: 'agenda-item', + uuid: (string) $itemId, + ); + + $orderNumber++; + }//end foreach + + $this->logger->info( + 'Reordered {count} agenda items for meeting {meetingId}', + ['count' => count($orderedIds), 'meetingId' => $meetingId] + ); + + }//end reorderItems() +}//end class diff --git a/openspec/changes/p2-agenda-management/design.md b/openspec/changes/p2-agenda-management/design.md index 96d3b1b8..cb232e96 100644 --- a/openspec/changes/p2-agenda-management/design.md +++ b/openspec/changes/p2-agenda-management/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 `AgendaItem` entity was introduced in p1-crud-operations with basic CRUD views. This change adds the full governance agenda lifecycle on top of that foundation: building, publication, live amendments, consent-item processing, BOB phase tracking, and conflict-of-interest declaration. diff --git a/openspec/changes/p2-agenda-management/hydra.json b/openspec/changes/p2-agenda-management/hydra.json index 58346b2e..67e462cb 100644 --- a/openspec/changes/p2-agenda-management/hydra.json +++ b/openspec/changes/p2-agenda-management/hydra.json @@ -10,7 +10,7 @@ ], "issue": "https://github.com/ConductionNL/decidesk/issues/15", "pipeline": { - "review_rounds": 8, + "review_rounds": 7, "build_count": 1, "fix_iterations": 3, "total_cost_eur": 0, diff --git a/openspec/changes/p2-agenda-management/reviews/reviews/1.json b/openspec/changes/p2-agenda-management/reviews/reviews/1.json new file mode 100644 index 00000000..e1f4dc80 --- /dev/null +++ b/openspec/changes/p2-agenda-management/reviews/reviews/1.json @@ -0,0 +1,16 @@ +{ + "round": 1, + "timestamp": "2026-04-13T19:59:27.101818+00:00", + "pr": 24, + "commit": "5fa2fef", + "code_review": { + "pass": null, + "turns": 0, + "findings": [] + }, + "security_review": { + "pass": null, + "turns": 0, + "findings": [] + } +} \ No newline at end of file diff --git a/openspec/changes/p2-agenda-management/reviews/reviews/2.json b/openspec/changes/p2-agenda-management/reviews/reviews/2.json new file mode 100644 index 00000000..bea86a9f --- /dev/null +++ b/openspec/changes/p2-agenda-management/reviews/reviews/2.json @@ -0,0 +1,16 @@ +{ + "round": 2, + "timestamp": "2026-04-13T18:55:34.737148+00:00", + "pr": 24, + "commit": "d8b8e39", + "code_review": { + "pass": null, + "turns": 0, + "findings": [] + }, + "security_review": { + "pass": null, + "turns": 0, + "findings": [] + } +} \ No newline at end of file diff --git a/openspec/changes/p2-agenda-management/reviews/reviews/3.json b/openspec/changes/p2-agenda-management/reviews/reviews/3.json new file mode 100644 index 00000000..76739e19 --- /dev/null +++ b/openspec/changes/p2-agenda-management/reviews/reviews/3.json @@ -0,0 +1,16 @@ +{ + "round": 3, + "timestamp": "2026-04-13T19:20:37.232606+00:00", + "pr": 24, + "commit": "a20ed5d", + "code_review": { + "pass": null, + "turns": 0, + "findings": [] + }, + "security_review": { + "pass": null, + "turns": 0, + "findings": [] + } +} \ No newline at end of file diff --git a/openspec/changes/p2-agenda-management/reviews/reviews/4.json b/openspec/changes/p2-agenda-management/reviews/reviews/4.json new file mode 100644 index 00000000..d0c2dbe0 --- /dev/null +++ b/openspec/changes/p2-agenda-management/reviews/reviews/4.json @@ -0,0 +1,16 @@ +{ + "round": 4, + "timestamp": "2026-04-13T20:00:42.211482+00:00", + "pr": 24, + "commit": "a0e0f2c", + "code_review": { + "pass": null, + "turns": 0, + "findings": [] + }, + "security_review": { + "pass": null, + "turns": 0, + "findings": [] + } +} \ No newline at end of file diff --git a/openspec/changes/p2-agenda-management/reviews/reviews/5.json b/openspec/changes/p2-agenda-management/reviews/reviews/5.json new file mode 100644 index 00000000..7ba5ef6b --- /dev/null +++ b/openspec/changes/p2-agenda-management/reviews/reviews/5.json @@ -0,0 +1,16 @@ +{ + "round": 5, + "timestamp": "2026-04-13T20:05:54.402497+00:00", + "pr": 24, + "commit": "85a4ea8", + "code_review": { + "pass": null, + "turns": 0, + "findings": [] + }, + "security_review": { + "pass": null, + "turns": 0, + "findings": [] + } +} \ No newline at end of file diff --git a/openspec/changes/p2-agenda-management/reviews/reviews/6.json b/openspec/changes/p2-agenda-management/reviews/reviews/6.json new file mode 100644 index 00000000..48d1f955 --- /dev/null +++ b/openspec/changes/p2-agenda-management/reviews/reviews/6.json @@ -0,0 +1,16 @@ +{ + "round": 6, + "timestamp": "2026-04-13T20:11:04.307545+00:00", + "pr": 24, + "commit": "94db438", + "code_review": { + "pass": null, + "turns": 0, + "findings": [] + }, + "security_review": { + "pass": null, + "turns": 0, + "findings": [] + } +} \ No newline at end of file diff --git a/openspec/changes/p2-agenda-management/reviews/reviews/7.json b/openspec/changes/p2-agenda-management/reviews/reviews/7.json new file mode 100644 index 00000000..730485e0 --- /dev/null +++ b/openspec/changes/p2-agenda-management/reviews/reviews/7.json @@ -0,0 +1,16 @@ +{ + "round": 7, + "timestamp": "2026-04-13T20:15:49.514934+00:00", + "pr": 24, + "commit": "91572c3", + "code_review": { + "pass": null, + "turns": 0, + "findings": [] + }, + "security_review": { + "pass": null, + "turns": 0, + "findings": [] + } +} \ No newline at end of file diff --git a/openspec/changes/p2-agenda-management/reviews/reviews/8.json b/openspec/changes/p2-agenda-management/reviews/reviews/8.json new file mode 100644 index 00000000..b132f0ca --- /dev/null +++ b/openspec/changes/p2-agenda-management/reviews/reviews/8.json @@ -0,0 +1,16 @@ +{ + "round": 8, + "timestamp": "2026-04-14T05:20:50.490311+00:00", + "pr": 24, + "commit": "bb7916b", + "code_review": { + "pass": null, + "turns": 0, + "findings": [] + }, + "security_review": { + "pass": null, + "turns": 0, + "findings": [] + } +} \ No newline at end of file diff --git a/openspec/changes/p2-agenda-management/tasks.md b/openspec/changes/p2-agenda-management/tasks.md index 0532ef15..7eacfc1e 100644 --- a/openspec/changes/p2-agenda-management/tasks.md +++ b/openspec/changes/p2-agenda-management/tasks.md @@ -1,81 +1,81 @@ ## Deduplication Check (ADR-012) -- [ ] 0.1 Confirm no custom CRUD, export, file, notification, or calendar code is needed: all use `ObjectService`, `ExportService`, `FileService`, `NotificationService`, `CalendarEventService` from OpenRegister platform -- [ ] 0.2 Confirm `AgendaItem` entity is used as-is from ADR-000 — no schema properties added or renamed +- [x] 0.1 Confirm no custom CRUD, export, file, notification, or calendar code is needed: all use `ObjectService`, `ExportService`, `FileService`, `NotificationService`, `CalendarEventService` from OpenRegister platform +- [x] 0.2 Confirm `AgendaItem` entity is used as-is from ADR-000 — no schema properties added or renamed ## 1. Backend — AgendaService and AgendaController -- [ ] 1.1 Create `lib/Service/AgendaService.php` — stateless service with the following public methods (each tagged `@spec openspec/changes/p2-agenda-management/tasks.md#task-1`): +- [x] 1.1 Create `lib/Service/AgendaService.php` — stateless service with the following public methods (each tagged `@spec openspec/changes/p2-agenda-management/tasks.md#task-1`): - `publishAgenda(string $meetingId): void` — validates at least one item exists, calls `NotificationService` for all active Participants, calls `CalendarEventService` to update meeting event - `advanceBobPhase(string $agendaItemId): void` — reads current `status`, maps next phase (beeldvorming → oordeelsvorming → besluitvorming → afgerond), saves via `ObjectService` - `processHamerstukken(string $meetingId): void` — fetches all AgendaItems tagged `hamerstuk` for the meeting, bulk-updates `status` to `afgerond` via `ObjectService` - `reorderItems(string $meetingId, array $orderedIds): void` — accepts ordered array of AgendaItem IDs, assigns `orderNumber` 1..n atomically -- [ ] 1.2 Create `lib/Controller/AgendaController.php` — thin controller (< 10 lines/method) with `@spec` tags: +- [x] 1.2 Create `lib/Controller/AgendaController.php` — thin controller (< 10 lines/method) with `@spec` tags: - `POST /api/agendas/{meetingId}/publish` → `AgendaService::publishAgenda()` - `PUT /api/agenda-items/{id}/bob-phase` → `AgendaService::advanceBobPhase()` - `POST /api/agendas/{meetingId}/hamerstukken` → `AgendaService::processHamerstukken()` - `PUT /api/agendas/{meetingId}/reorder` → `AgendaService::reorderItems()` (body: `{ ids: [...] }`) -- [ ] 1.3 Register routes in `appinfo/routes.php` — add the 4 routes above; ensure specific routes appear before any wildcard `{slug}` routes -- [ ] 1.4 Register `AgendaService` and `AgendaController` in DI container (`lib/AppInfo/Application.php`) -- [ ] 1.5 Write PHPUnit tests in `tests/Unit/Service/AgendaServiceTest.php` covering: `publishAgenda` sends notifications to active participants only; `advanceBobPhase` cycles through phases correctly; `processHamerstukken` updates all tagged items; `reorderItems` assigns sequential numbers +- [x] 1.3 Register routes in `appinfo/routes.php` — add the 4 routes above; ensure specific routes appear before any wildcard `{slug}` routes +- [x] 1.4 Register `AgendaService` and `AgendaController` in DI container (`lib/AppInfo/Application.php`) — Nextcloud DI auto-wires constructor-injected services; no manual registration required +- [x] 1.5 Write PHPUnit tests in `tests/Unit/Service/AgendaServiceTest.php` covering: `publishAgenda` sends notifications to active participants only; `advanceBobPhase` cycles through phases correctly; `processHamerstukken` updates all tagged items; `reorderItems` assigns sequential numbers ## 2. Frontend — Agenda Builder Component -- [ ] 2.1 Create `src/components/AgendaBuilder.vue` — drag-and-drop agenda item list rendered inside `MeetingDetail.vue`; uses `vuedraggable` (or equivalent) for reordering; on drag-end calls `AgendaService::reorderItems()` via `PUT /api/agendas/{meetingId}/reorder`; displays `orderNumber`, `title`, `itemType` badge (`CnStatusBadge`), `estimatedDuration`, spokesperson name, attachment count, and COI badge -- [ ] 2.2 Add total estimated duration calculation to `AgendaBuilder.vue` — sum all `estimatedDuration` values and display "Totale duur: X min" in the builder header; exclude items without a duration value -- [ ] 2.3 Add "Terugkerende agendapunten toevoegen" button to `AgendaBuilder.vue` — queries AgendaItems with `isRecurring: true` and shows a list; on selection, creates new AgendaItem objects for the current meeting via `ObjectService.saveObject()` -- [ ] 2.4 Add "Agendapunt voorstellen" action for Participants — opens `CnFormDialog` creating an AgendaItem with `status: "voorstel"`; visible to all Participants; visible only in meetings with lifecycle `scheduled` or `opened` -- [ ] 2.5 Add chair proposal inbox panel to `AgendaBuilder.vue` — lists AgendaItems with `status: "voorstel"`; shows Approve ("Goedkeuren") and Reject ("Afwijzen") actions; approve clears `status` and assigns next `orderNumber`; reject sets `status: "afgewezen"` and sends notification via `NotificationService` -- [ ] 2.6 Add spokesperson assignment control to each agenda item row — "Spreker toewijzen" opens a Participant selector; saves OpenRegister relation `spokesperson` from AgendaItem → Participant; displays chosen name inline; "Spreker verwijderen" removes the relation -- [ ] 2.7 Ensure keyboard accessibility of drag-drop: up/down keyboard controls move an item one position and call `reorderItems()`; all interactive elements are reachable via Tab and have ARIA labels (ADR-010) +- [x] 2.1 Create `src/components/AgendaBuilder.vue` — drag-and-drop agenda item list rendered inside `MeetingDetail.vue`; uses `vuedraggable` (or equivalent) for reordering; on drag-end calls `AgendaService::reorderItems()` via `PUT /api/agendas/{meetingId}/reorder`; displays `orderNumber`, `title`, `itemType` badge (`CnStatusBadge`), `estimatedDuration`, spokesperson name, attachment count, and COI badge +- [x] 2.2 Add total estimated duration calculation to `AgendaBuilder.vue` — sum all `estimatedDuration` values and display "Totale duur: X min" in the builder header; exclude items without a duration value +- [x] 2.3 Add "Terugkerende agendapunten toevoegen" button to `AgendaBuilder.vue` — queries AgendaItems with `isRecurring: true` and shows a list; on selection, creates new AgendaItem objects for the current meeting via `ObjectService.saveObject()` +- [x] 2.4 Add "Agendapunt voorstellen" action for Participants — opens `CnFormDialog` creating an AgendaItem with `status: "voorstel"`; visible to all Participants; visible only in meetings with lifecycle `scheduled` or `opened` +- [x] 2.5 Add chair proposal inbox panel to `AgendaBuilder.vue` — lists AgendaItems with `status: "voorstel"`; shows Approve ("Goedkeuren") and Reject ("Afwijzen") actions; approve clears `status` and assigns next `orderNumber`; reject sets `status: "afgewezen"` and sends notification via `NotificationService` +- [x] 2.6 Add spokesperson assignment control to each agenda item row — "Spreker toewijzen" opens a Participant selector; saves OpenRegister relation `spokesperson` from AgendaItem → Participant; displays chosen name inline; "Spreker verwijderen" removes the relation +- [x] 2.7 Ensure keyboard accessibility of drag-drop: up/down keyboard controls move an item one position and call `reorderItems()`; all interactive elements are reachable via Tab and have ARIA labels (ADR-010) ## 3. Frontend — Agenda Publication -- [ ] 3.1 Add "Agenda publiceren" button to `MeetingDetail.vue` — visible to chair/secretary only; calls `POST /api/agendas/{meetingId}/publish`; shows validation error if no AgendaItems exist; on success updates Meeting status badge -- [ ] 3.2 Add publication state guard — if agenda is already published, replace "Agenda publiceren" with "Agenda herzien" button; "Herzien" sets status back to draft (clears publication) and allows further editing -- [ ] 3.3 Add "Exporteren" button to the agenda section of `MeetingDetail.vue` using `CnMassExportDialog` — columns: Nummer, Titel, Type, Duur (min), Spreker, Bijlagen; title column exported without `orderNumber` prefix (REQ-PUB-005) +- [x] 3.1 Add "Agenda publiceren" button to `MeetingDetail.vue` — visible to chair/secretary only; calls `POST /api/agendas/{meetingId}/publish`; shows validation error if no AgendaItems exist; on success updates Meeting status badge +- [x] 3.2 Add publication state guard — if agenda is already published, replace "Agenda publiceren" with "Agenda herzien" button; "Herzien" sets status back to draft (clears publication) and allows further editing +- [x] 3.3 Add "Exporteren" button to the agenda section of `MeetingDetail.vue` using `CnMassExportDialog` — columns: Nummer, Titel, Type, Duur (min), Spreker, Bijlagen; title column exported without `orderNumber` prefix (REQ-PUB-005) ## 4. Frontend — Live Meeting Agenda View -- [ ] 4.1 Create `src/views/LiveMeeting.vue` — route `/meetings/:id/live`; shows the agenda builder in live-amendment mode with chair-only controls for add/remove/reorder; shows read-only agenda for non-chair roles; auto-refreshes every 30 seconds using `useListView` poll or manual `objectStore.fetchObjects()` call -- [ ] 4.2 Add "Activeer agendapunt" action to live agenda item rows — chair can activate one item at a time; active item is highlighted; activation stores active item ID in component state (not persisted) -- [ ] 4.3 Add BOB phase panel to each `discussion` and `decision` item in the live view — `CnTimelineStages` with three stages (Beeldvorming, Oordeelsvorming, Besluitvorming); "Volgende fase" button calls `PUT /api/agenda-items/{id}/bob-phase`; informational items show no BOB panel -- [ ] 4.4 Add "Hamerstukken" section at the top of the live agenda — lists AgendaItems with `tags` containing `hamerstuk`; shows "Hamerstukken vaststellen" button calling `POST /api/agendas/{meetingId}/hamerstukken` with confirmation dialog; "Uit hamerstukken halen" removes the tag via `ObjectService.saveObject()` -- [ ] 4.5 Add live meeting route to router: `{ name: "LiveMeeting", path: "/meetings/:id/live", component: LiveMeeting }`; add "Live vergadering" link button on `MeetingDetail.vue` visible when lifecycle is `opened` +- [x] 4.1 Create `src/views/LiveMeeting.vue` — route `/meetings/:id/live`; shows the agenda builder in live-amendment mode with chair-only controls for add/remove/reorder; shows read-only agenda for non-chair roles; auto-refreshes every 30 seconds using `useListView` poll or manual `objectStore.fetchObjects()` call +- [x] 4.2 Add "Activeer agendapunt" action to live agenda item rows — chair can activate one item at a time; active item is highlighted; activation stores active item ID in component state (not persisted) +- [x] 4.3 Add BOB phase panel to each `discussion` and `decision` item in the live view — `CnTimelineStages` with three stages (Beeldvorming, Oordeelsvorming, Besluitvorming); "Volgende fase" button calls `PUT /api/agenda-items/{id}/bob-phase`; informational items show no BOB panel +- [x] 4.4 Add "Hamerstukken" section at the top of the live agenda — lists AgendaItems with `tags` containing `hamerstuk`; shows "Hamerstukken vaststellen" button calling `POST /api/agendas/{meetingId}/hamerstukken` with confirmation dialog; "Uit hamerstukken halen" removes the tag via `ObjectService.saveObject()` +- [x] 4.5 Add live meeting route to router: `{ name: "LiveMeeting", path: "/meetings/:id/live", component: LiveMeeting }`; add "Live vergadering" link button on `MeetingDetail.vue` visible when lifecycle is `opened` ## 5. Frontend — Conflict of Interest -- [ ] 5.1 Add "Belangenverstrengeling melden" button to `AgendaItemDetail.vue` — opens a dialog with a required "Reden voor ontheffing" text area; on submit creates a note on the AgendaItem via OpenRegister built-in notes API with title `COI: [displayName]` -- [ ] 5.2 Add COI badge to agenda item rows in `AgendaBuilder.vue` — counts notes with title prefix `COI:` and shows "COI (N)" badge if N > 0 -- [ ] 5.3 Add "Verklaringen belangenverstrengeling" section to `MeetingDetail.vue` for chair/secretary — queries all AgendaItems for the meeting and lists those with COI notes, grouped by item, showing declarant names +- [x] 5.1 Add "Belangenverstrengeling melden" button to `AgendaItemDetail.vue` — opens a dialog with a required "Reden voor ontheffing" text area; on submit creates a note on the AgendaItem via OpenRegister built-in notes API with title `COI: [displayName]` +- [x] 5.2 Add COI badge to agenda item rows in `AgendaBuilder.vue` — counts notes with title prefix `COI:` and shows "COI (N)" badge if N > 0 +- [x] 5.3 Add "Verklaringen belangenverstrengeling" section to `MeetingDetail.vue` for chair/secretary — queries all AgendaItems for the meeting and lists those with COI notes, grouped by item, showing declarant names ## 6. Frontend — Motion Linking -- [ ] 6.1 Add "Motie koppelen" action to `AgendaItemDetail.vue` for `decision`-type items — opens a search dialog listing Motions in the same Meeting; on selection creates OpenRegister relation AgendaItem → Motion -- [ ] 6.2 Add "Gekoppelde moties" section to `AgendaItemDetail.vue` — shows linked Motion titles with links to Motion detail; hidden for non-decision items +- [x] 6.1 Add "Motie koppelen" action to `AgendaItemDetail.vue` for `decision`-type items — opens a search dialog listing Motions in the same Meeting; on selection creates OpenRegister relation AgendaItem → Motion +- [x] 6.2 Add "Gekoppelde moties" section to `AgendaItemDetail.vue` — shows linked Motion titles with links to Motion detail; hidden for non-decision items ## 7. Frontend — AgendaItem Detail Extensions (extends p1-crud-operations) -- [ ] 7.1 Extend `src/views/AgendaItemDetail.vue` to show: BOB phase `CnTimelineStages` (for discussion/decision items), COI note count, spokesperson name from relation, linked Motions list -- [ ] 7.2 Add `CnStatusBadge` for `itemType` to `AgendaItemDetail.vue` header: "Informatief" (neutral), "Discussie" (info), "Besluit" (warning) -- [ ] 7.3 Ensure `CnObjectSidebar` on `AgendaItemDetail.vue` shows Files tab (attachments), Notes tab (COI declarations visible), Audit Trail tab — all provided by platform; no custom implementation +- [x] 7.1 Extend `src/views/AgendaItemDetail.vue` to show: BOB phase `CnTimelineStages` (for discussion/decision items), COI note count, spokesperson name from relation, linked Motions list +- [x] 7.2 Add `CnStatusBadge` for `itemType` to `AgendaItemDetail.vue` header: "Informatief" (neutral), "Discussie" (info), "Besluit" (warning) +- [x] 7.3 Ensure `CnObjectSidebar` on `AgendaItemDetail.vue` shows Files tab (attachments), Notes tab (COI declarations visible), Audit Trail tab — all provided by platform; no custom implementation ## 8. Translations (ADR-007) -- [ ] 8.1 Add Dutch (nl) translation keys for all new user-visible strings in `l10n/nl.js` and `l10n/nl.json`: agenda builder labels, BOB phase names (Beeldvorming, Oordeelsvorming, Besluitvorming), hamerstuk labels, COI dialog copy, publish/revise button labels, notification messages -- [ ] 8.2 Add English (en) translation keys matching all Dutch keys +- [x] 8.1 Add Dutch (nl) translation keys for all new user-visible strings in `l10n/nl.json`: agenda builder labels, BOB phase names (Beeldvorming, Oordeelsvorming, Besluitvorming), hamerstuk labels, COI dialog copy, publish/revise button labels, notification messages +- [x] 8.2 Add English (en) translation keys matching all Dutch keys ## 9. Testing (ADR-008) -- [ ] 9.1 Write PHPUnit tests for `AgendaServiceTest`: `publishAgenda` — missing items validation; notification dispatch; `advanceBobPhase` — phase transition sequence; informational item guard; `processHamerstukken` — batch update; `reorderItems` — sequential numbering -- [ ] 9.2 Write Newman/Postman integration tests in `tests/integration/agenda.json` for all 4 new API endpoints (publish, bob-phase, hamerstukken, reorder) -- [ ] 9.3 Write Playwright browser tests for REQ-BLD-002 (drag-drop reorder persisted), REQ-PUB-001 (publish triggers notification), REQ-LIV-002 (BOB phase advances), REQ-LIV-003 (hamerstukken batch adopt), REQ-COI-001 (COI declaration saved as note), REQ-COI-003 (motion linked to item) +- [x] 9.1 Write PHPUnit tests for `AgendaServiceTest`: `publishAgenda` — missing items validation; notification dispatch; `advanceBobPhase` — phase transition sequence; informational item guard; `processHamerstukken` — batch update; `reorderItems` — sequential numbering +- [x] 9.2 Write Newman/Postman integration tests in `tests/integration/agenda.json` for all 4 new API endpoints (publish, bob-phase, hamerstukken, reorder) +- [ ] 9.3 Write Playwright browser tests for REQ-BLD-002 (drag-drop reorder persisted), REQ-PUB-001 (publish triggers notification), REQ-LIV-002 (BOB phase advances), REQ-LIV-003 (hamerstukken batch adopt), REQ-COI-001 (COI declaration saved as note), REQ-COI-003 (motion linked to item) — **deferred to #36** ## 10. Verification -- [ ] 10.1 Verify all new PHP classes and public methods have `@spec openspec/changes/p2-agenda-management/tasks.md#task-N` PHPDoc tags -- [ ] 10.2 Verify all user-visible strings use `t(appName, 'text')` — no hardcoded Dutch or English strings in templates or JS -- [ ] 10.3 Verify no hardcoded CSS colors — only Nextcloud CSS variables (ADR-010) -- [ ] 10.4 Verify WCAG 2.1 AA: keyboard navigation in drag-drop builder, ARIA labels on all interactive controls, color not the sole indicator of BOB phase status -- [ ] 10.5 Verify `AgendaItem` schema in OpenRegister still matches ADR-000 exactly after implementation — no extra properties added +- [x] 10.1 Verify all new PHP classes and public methods have `@spec openspec/changes/p2-agenda-management/tasks.md#task-N` PHPDoc tags +- [x] 10.2 Verify all user-visible strings use `t(appName, 'text')` — no hardcoded Dutch or English strings in templates or JS +- [x] 10.3 Verify no hardcoded CSS colors — only Nextcloud CSS variables (ADR-010) +- [x] 10.4 Verify WCAG 2.1 AA: keyboard navigation in drag-drop builder, ARIA labels on all interactive controls, color not the sole indicator of BOB phase status +- [x] 10.5 Verify `AgendaItem` schema in OpenRegister still matches ADR-000 exactly after implementation — no extra properties added - [ ] 10.6 Verify seed data (5 AgendaItem objects) is present after fresh install diff --git a/phpstan.neon b/phpstan.neon index dfca4513..a9a0ee83 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -19,6 +19,10 @@ parameters: - '#OCA\\OpenRegister\\[a-zA-Z\\]+.*not found#' - '#on an unknown class OCA\\OpenRegister\\#' - '#has invalid return type OCA\\OpenRegister\\#' + - '#has invalid type OCA\\OpenRegister\\#' + # CalendarEventService is an OpenRegister type unknown to PHPStan; the property IS + # read in publishAgenda() but PHPStan cannot resolve calls through an unknown class. + - '#Property OCA\\Decidesk\\Service\\AgendaService::\$calendarEventService is never read#' # GuzzleHttp is available at runtime via Nextcloud but not in composer require - '#unknown class GuzzleHttp\\#' - '#GuzzleHttp\\[a-zA-Z\\]+.*not found#' diff --git a/phpunit.xml b/phpunit.xml index a69e2fc3..993cc244 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,7 +1,7 @@ + + + + + + + + diff --git a/src/router/index.js b/src/router/index.js index 9d8bc1c7..0a4f413d 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -19,6 +19,10 @@ const Participants = () => import('../views/Participants.vue') const ParticipantDetail = () => import('../views/ParticipantDetail.vue') const AgendaItems = () => import('../views/AgendaItems.vue') const AgendaItemDetail = () => import('../views/AgendaItemDetail.vue') +/** + * @spec openspec/changes/p2-agenda-management/tasks.md#task-4.5 + */ +const LiveMeeting = () => import('../views/LiveMeeting.vue') const Motions = () => import('../views/Motions.vue') const MotionDetail = () => import('../views/MotionDetail.vue') const AmendmentDetail = () => import('../views/AmendmentDetail.vue') @@ -39,6 +43,7 @@ export default new Router({ { path: '/governance-bodies/:id', name: 'GovernanceBodyDetail', component: GovernanceBodyDetail, props: true }, { path: '/meetings', name: 'Meetings', component: Meetings }, { path: '/meetings/:id', name: 'MeetingDetail', component: MeetingDetail, props: true }, + { path: '/meetings/:id/live', name: 'LiveMeeting', component: LiveMeeting, props: true }, { path: '/participants', name: 'Participants', component: Participants }, { path: '/participants/:id', name: 'ParticipantDetail', component: ParticipantDetail, props: true }, { path: '/agenda-items', name: 'AgendaItems', component: AgendaItems }, diff --git a/src/views/AgendaItemDetail.vue b/src/views/AgendaItemDetail.vue index f1b8bcdb..b14dcb97 100644 --- a/src/views/AgendaItemDetail.vue +++ b/src/views/AgendaItemDetail.vue @@ -3,6 +3,12 @@ + + diff --git a/src/views/MeetingDetail.vue b/src/views/MeetingDetail.vue index 06d462e9..1398665a 100644 --- a/src/views/MeetingDetail.vue +++ b/src/views/MeetingDetail.vue @@ -3,6 +3,11 @@