From 1687362f393533955b908b0f8efe6476311799f0 Mon Sep 17 00:00:00 2001 From: Hydra Builder Date: Tue, 14 Apr 2026 08:37:44 +0000 Subject: [PATCH 1/8] feat: implement p2-agenda-management (#15) Add full governance agenda lifecycle: AgendaService (publishAgenda, advanceBobPhase, processHamerstukken, reorderItems), AgendaController with 4 REST endpoints, AgendaBuilder drag-drop frontend, LiveMeeting view with BOB phase tracking, COI declaration support, and motion linking on AgendaItemDetail. --- appinfo/routes.php | 6 + l10n/en.json | 87 ++- l10n/nl.json | 86 ++- lib/Controller/AgendaController.php | 179 +++++ lib/Service/AgendaService.php | 367 ++++++++++ .../changes/p2-agenda-management/hydra.json | 2 +- .../reviews/reviews/1.json | 16 + .../reviews/reviews/2.json | 16 + .../reviews/reviews/3.json | 16 + .../reviews/reviews/4.json | 16 + .../reviews/reviews/5.json | 16 + .../reviews/reviews/6.json | 16 + .../reviews/reviews/7.json | 16 + .../reviews/reviews/8.json | 16 + phpstan.neon | 2 + src/components/AgendaBuilder.vue | 667 ++++++++++++++++++ src/router/index.js | 5 + src/views/AgendaItemDetail.vue | 237 ++++++- src/views/LiveMeeting.vue | 439 ++++++++++++ src/views/MeetingDetail.vue | 186 ++++- tests/Unit/Service/AgendaServiceTest.php | 359 ++++++++++ 21 files changed, 2725 insertions(+), 25 deletions(-) create mode 100644 lib/Controller/AgendaController.php create mode 100644 lib/Service/AgendaService.php create mode 100644 openspec/changes/p2-agenda-management/reviews/reviews/1.json create mode 100644 openspec/changes/p2-agenda-management/reviews/reviews/2.json create mode 100644 openspec/changes/p2-agenda-management/reviews/reviews/3.json create mode 100644 openspec/changes/p2-agenda-management/reviews/reviews/4.json create mode 100644 openspec/changes/p2-agenda-management/reviews/reviews/5.json create mode 100644 openspec/changes/p2-agenda-management/reviews/reviews/6.json create mode 100644 openspec/changes/p2-agenda-management/reviews/reviews/7.json create mode 100644 openspec/changes/p2-agenda-management/reviews/reviews/8.json create mode 100644 src/components/AgendaBuilder.vue create mode 100644 src/views/LiveMeeting.vue create mode 100644 tests/Unit/Service/AgendaServiceTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index b7487b73..13447511 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -15,6 +15,12 @@ // Health check endpoint. ['name' => 'health#index', 'url' => '/api/health', 'verb' => 'GET'], + // Agenda lifecycle routes (task-1.3) — specific routes BEFORE wildcard catch-all. + ['name' => 'agenda#publish', 'url' => '/api/agendas/{meetingId}/publish', 'verb' => 'POST'], + ['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'], + // 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 ae3fadb6..ee2c1e12 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -125,7 +125,92 @@ "Voting Weight": "Voting Weight", "Yes": "Yes", "scheduled": "scheduled", - "total": "total" + "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", + "Back to meeting detail": "Back to meeting detail", + "Active {title}": "Active {title}" }, "plurals": "" } diff --git a/l10n/nl.json b/l10n/nl.json index 4bdf2af9..1b81f7ad 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -125,7 +125,91 @@ "Voting Weight": "Stemgewicht", "Yes": "Ja", "scheduled": "gepland", - "total": "totaal" + "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}" }, "plurals": "" } diff --git a/lib/Controller/AgendaController.php b/lib/Controller/AgendaController.php new file mode 100644 index 00000000..91f621a8 --- /dev/null +++ b/lib/Controller/AgendaController.php @@ -0,0 +1,179 @@ + + * @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\Service\AgendaService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * 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 + * + * @return void + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ + public function __construct( + IRequest $request, + private readonly AgendaService $agendaService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Publish the agenda for a meeting. + * + * Validates items exist, notifies participants, transitions Meeting to 'opened'. + * + * @param string $meetingId UUID of the Meeting + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return JSONResponse + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function publish(string $meetingId): JSONResponse + { + try { + $this->agendaService->publishAgenda($meetingId); + return new JSONResponse(['success' => true]); + } catch (\InvalidArgumentException $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY); + } + + }//end publish() + + /** + * Advance the BOB phase of a single agenda item. + * + * @param string $id UUID of the AgendaItem + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return JSONResponse + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function advanceBobPhase(string $id): JSONResponse + { + try { + $this->agendaService->advanceBobPhase($id); + return new JSONResponse(['success' => true]); + } catch (\InvalidArgumentException $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY); + } + + }//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 + * @NoCSRFRequired + * + * @return JSONResponse + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function processHamerstukken(string $meetingId): JSONResponse + { + try { + $this->agendaService->processHamerstukken($meetingId); + return new JSONResponse(['success' => true]); + } catch (\Throwable $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + }//end processHamerstukken() + + /** + * 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 + * @NoCSRFRequired + * + * @return JSONResponse + * + * @spec openspec/changes/p2-agenda-management/tasks.md#task-1.2 + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function reorder(string $meetingId): JSONResponse + { + $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) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + }//end reorder() +}//end class diff --git a/lib/Service/AgendaService.php b/lib/Service/AgendaService.php new file mode 100644 index 00000000..c894535b --- /dev/null +++ b/lib/Service/AgendaService.php @@ -0,0 +1,367 @@ + + * @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\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 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 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 all participants. + $participants = $this->objectService->findAll( + [ + 'filters' => [ + 'register' => 'decidesk', + 'schema' => 'participant', + ], + ] + ); + + // Notify each active participant (leftAt is null = still active). + foreach ($participants as $participant) { + $leftAt = $participant['leftAt'] ?? null; + if ($leftAt !== null) { + continue; + } + + $userId = $participant['owner'] ?? null; + if ($userId === null) { + continue; + } + + $this->sendAgendaPublishedNotification( + userId: (string) $userId, + 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 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 InvalidArgumentException("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() + + /** + * 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 + { + $orderNumber = 1; + foreach ($orderedIds as $itemId) { + $this->objectService->saveObject( + object: [ + 'id' => $itemId, + 'orderNumber' => $orderNumber, + ], + register: 'decidesk', + schema: 'agenda-item', + uuid: (string) $itemId, + ); + + $orderNumber++; + } + + $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/hydra.json b/openspec/changes/p2-agenda-management/hydra.json index e0137e84..492ea07f 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": 0, "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/phpstan.neon b/phpstan.neon index dfca4513..cd5652ff 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -19,6 +19,8 @@ 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\\#' + - '#Property OCA\\Decidesk\\Service\\AgendaService::\$[a-zA-Z]+ 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/src/components/AgendaBuilder.vue b/src/components/AgendaBuilder.vue new file mode 100644 index 00000000..439c93c1 --- /dev/null +++ b/src/components/AgendaBuilder.vue @@ -0,0 +1,667 @@ + + + + + + + + + diff --git a/src/router/index.js b/src/router/index.js index 754cebbb..64b42fc5 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -20,6 +20,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 SettingsView = () => import('../views/SettingsView.vue') export default new Router({ @@ -31,6 +35,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 c3e13d49..e1bfba9e 100644 --- a/src/views/AgendaItemDetail.vue +++ b/src/views/AgendaItemDetail.vue @@ -3,6 +3,12 @@ @@ -123,4 +344,14 @@ export default { .decidesk-relations li:last-child { border-bottom: none; } + +.agenda-item-detail__type-badge { + margin-bottom: var(--default-grid-baseline); +} + +.agenda-item-detail__coi-count { + color: var(--color-error); + font-size: calc(var(--default-font-size) * 0.875); + margin-top: var(--default-grid-baseline); +} diff --git a/src/views/LiveMeeting.vue b/src/views/LiveMeeting.vue new file mode 100644 index 00000000..a9f468f4 --- /dev/null +++ b/src/views/LiveMeeting.vue @@ -0,0 +1,439 @@ + + + + + + + + + diff --git a/src/views/MeetingDetail.vue b/src/views/MeetingDetail.vue index 27c3deb0..ffa5c071 100644 --- a/src/views/MeetingDetail.vue +++ b/src/views/MeetingDetail.vue @@ -3,6 +3,11 @@