diff --git a/appinfo/info.xml b/appinfo/info.xml index 97dbbc9d..5b4e7200 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -10,11 +10,11 @@ **Key Features** -- **Vue 2 + Pinia frontend** — Modern reactive UI with state management +- **Minutes management** — Generate, review, approve, sign, and publish meeting minutes through a governed lifecycle +- **Decision tracking** — Record, publish, and audit decisions linked to motions and voting rounds +- **Action items** — Assign and track follow-up tasks from meetings with overdue notifications - **OpenRegister integration** — Built-in support for the OpenRegister data layer -- **Admin settings** — Pre-wired settings page with version info - **NL Design System ready** — CSS variables, WCAG AA, government theming -- **Full quality pipeline** — PHPCS, PHPMD, Psalm, PHPStan, ESLint, Stylelint Free and open source under the EUPL-1.2 license. @@ -24,18 +24,19 @@ Free and open source under the EUPL-1.2 license. **Belangrijkste functies** -- **Vue 2 + Pinia frontend** — Moderne reactieve UI met state management +- **Notulenbeheer** — Genereer, beoordeel, keur goed, onderteken en publiceer vergadernotulen via een bestuurde levenscyclus +- **Besluitregistratie** — Leg besluiten vast, publiceer en auditeer ze, gekoppeld aan moties en stemrondes +- **Actiepunten** — Wijs follow-uptaken uit vergaderingen toe en volg ze op met meldingen bij overschrijding - **OpenRegister-integratie** — Ingebouwde ondersteuning voor de OpenRegister-datalaag - **Beheerdersinstellingen** — Voorgeconfigureerde instellingenpagina met versie-informatie - **NL Design System klaar** — CSS-variabelen, WCAG AA, overheidsthema -- **Volledige kwaliteitspipeline** — PHPCS, PHPMD, Psalm, PHPStan, ESLint, Stylelint Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> 0.1.0 - agpl + eupl Conduction Decidesk @@ -65,6 +66,10 @@ Vrij en open source onder de EUPL-1.2-licentie. + + OCA\Decidesk\BackgroundJob\OverdueActionItemsJob + + OCA\Decidesk\Repair\InitializeSettings diff --git a/appinfo/routes.php b/appinfo/routes.php index b7487b73..a8cce2ef 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -10,6 +10,12 @@ ['name' => 'settings#create', 'url' => '/api/settings', 'verb' => 'POST'], ['name' => 'settings#load', 'url' => '/api/settings/load', 'verb' => 'POST'], + // Minutes endpoints — BEFORE wildcard routes. + ['name' => 'minutes#generateDraft', 'url' => '/api/minutes/{minutesId}/generate-draft', 'verb' => 'POST'], + + // Decision endpoints. + ['name' => 'decision#publish', 'url' => '/api/decisions/{decisionId}/publish', 'verb' => 'POST'], + // Prometheus metrics endpoint. ['name' => 'metrics#index', 'url' => '/api/metrics', 'verb' => 'GET'], // Health check endpoint. diff --git a/lib/BackgroundJob/OverdueActionItemsJob.php b/lib/BackgroundJob/OverdueActionItemsJob.php new file mode 100644 index 00000000..68279152 --- /dev/null +++ b/lib/BackgroundJob/OverdueActionItemsJob.php @@ -0,0 +1,149 @@ + + * @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-minutes-and-decisions/tasks.md#task-2 + */ + +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. +declare(strict_types=1); + +namespace OCA\Decidesk\BackgroundJob; + +use OCA\Decidesk\AppInfo\Application; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\IAppConfig; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Daily background job that detects overdue action items. + * + * Queries all ActionItems where taskStatus is 'open' or 'in-progress' + * and dueDate < now(), then updates their taskStatus to 'overdue'. + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-2 + */ +class OverdueActionItemsJob extends TimedJob +{ + + /** + * Run interval: once per day (24 hours in seconds). + * + * @var int + */ + private const RUN_INTERVAL = 86400; + + /** + * Constructor for OverdueActionItemsJob. + * + * @param ITimeFactory $time The time factory + * @param IAppConfig $appConfig The app config + * @param ContainerInterface $container The DI container + * @param LoggerInterface $logger The logger + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-2 + */ + public function __construct( + ITimeFactory $time, + private readonly IAppConfig $appConfig, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + // phpcs:ignore CustomSniffs.Functions.NamedParameters -- OCP parent class + parent::__construct($time); + // phpcs:ignore CustomSniffs.Functions.NamedParameters -- OCP parent class + $this->setInterval(self::RUN_INTERVAL); + }//end __construct() + + /** + * Execute the overdue detection job. + * + * @param mixed $argument Job argument (unused) + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-2 + */ + protected function run(mixed $argument): void + { + $this->logger->info('Decidesk: OverdueActionItemsJob started'); + + try { + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + } catch (\Throwable $e) { + $this->logger->error('Decidesk: ObjectService not available', ['exception' => $e->getMessage()]); + return; + } + + $register = $this->appConfig->getValueString(Application::APP_ID, 'register', 'decidesk'); + $now = (new \DateTime())->format('c'); + $updated = 0; + + foreach (['open', 'in-progress'] as $status) { + try { + $result = $objectService->findObjects( + register: $register, + schema: 'action-item', + params: ['taskStatus' => $status] + ); + $items = ($result['results'] ?? $result ?? []); + } catch (\Throwable $e) { + $this->logger->warning( + 'Decidesk: failed to query action items with status '.$status, + ['exception' => $e->getMessage()] + ); + continue; + } + + foreach ($items as $item) { + $dueDate = ($item['dueDate'] ?? null); + if ($dueDate === null) { + continue; + } + + if (strtotime($dueDate) < strtotime($now)) { + try { + $item['taskStatus'] = 'overdue'; + $objectService->saveObject( + register: $register, + schema: 'action-item', + object: $item + ); + $updated++; + } catch (\Throwable $e) { + $this->logger->warning( + 'Decidesk: failed to update action item to overdue', + [ + 'id' => ($item['id'] ?? 'unknown'), + 'exception' => $e->getMessage(), + ] + ); + } + } + }//end foreach + }//end foreach + + $this->logger->info('Decidesk: OverdueActionItemsJob completed, updated '.$updated.' items'); + }//end run() +}//end class diff --git a/lib/Controller/DecisionController.php b/lib/Controller/DecisionController.php new file mode 100644 index 00000000..3f55d3e7 --- /dev/null +++ b/lib/Controller/DecisionController.php @@ -0,0 +1,120 @@ + + * @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-minutes-and-decisions/tasks.md#task-6 + */ + +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. +declare(strict_types=1); + +namespace OCA\Decidesk\Controller; + +use OCA\Decidesk\AppInfo\Application; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUserSession; +use Psr\Container\ContainerInterface; + +/** + * Controller for decision actions requiring server-side authorization. + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-6 + */ +class DecisionController extends Controller +{ + /** + * Constructor for the DecisionController. + * + * @param IRequest $request The request object + * @param IGroupManager $groupManager The group manager + * @param IUserSession $userSession The user session + * @param ContainerInterface $container The DI container + * @param IAppConfig $appConfig The app config + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-6 + */ + public function __construct( + IRequest $request, + private readonly IGroupManager $groupManager, + private readonly IUserSession $userSession, + private readonly ContainerInterface $container, + private readonly IAppConfig $appConfig, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Publish a decision. + * + * Sets isPublished=true and records publishedAt. Any authenticated user may + * call this endpoint; admin role is enforced manually via IGroupManager::isAdmin(). + * The NoAdminRequired annotation is intentional — it prevents Nextcloud from + * performing its own framework-level admin check so that the explicit guard + * below is the single source of truth for this role gate. + * + * @param string $decisionId The UUID of the Decision object + * + * @return JSONResponse Updated object or error + * + * @NoAdminRequired + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-6 + */ + public function publish(string $decisionId): JSONResponse + { + // NoAdminRequired intentional: admin is enforced below via IGroupManager::isAdmin() + // rather than @AdminRequired so that the same guard handles the role check + // and the response body in a single place. + $user = $this->userSession->getUser(); + if ($user === null || $this->groupManager->isAdmin($user->getUID()) === false) { + return new JSONResponse(['message' => 'Insufficient permissions to publish a decision'], Http::STATUS_FORBIDDEN); + } + + try { + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + $register = $this->appConfig->getValueString(Application::APP_ID, 'register', 'decidesk'); + + $decision = $objectService->findObject(register: $register, schema: 'decision', id: $decisionId); + if (empty($decision) === true) { + return new JSONResponse(['message' => 'Decision object not found'], Http::STATUS_NOT_FOUND); + } + + if (($decision['outcome'] ?? '') !== 'adopted') { + return new JSONResponse(['message' => 'Only adopted decisions can be published'], Http::STATUS_UNPROCESSABLE_ENTITY); + } + + $decision['isPublished'] = true; + $decision['publishedAt'] = (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM); + + $updated = $objectService->saveObject(register: $register, schema: 'decision', object: $decision); + return new JSONResponse($updated); + } catch (\RuntimeException $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\Throwable $e) { + return new JSONResponse(['message' => 'Publication failed'], Http::STATUS_INTERNAL_SERVER_ERROR); + }//end try + + }//end publish() +}//end class diff --git a/lib/Controller/MinutesController.php b/lib/Controller/MinutesController.php new file mode 100644 index 00000000..8484db2d --- /dev/null +++ b/lib/Controller/MinutesController.php @@ -0,0 +1,106 @@ + + * @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-minutes-and-decisions/tasks.md#task-1 + */ + +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. +declare(strict_types=1); + +namespace OCA\Decidesk\Controller; + +use OCA\Decidesk\AppInfo\Application; +use OCA\Decidesk\Service\MinutesGenerationService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IAppConfig; +use OCP\IRequest; +use Psr\Container\ContainerInterface; + +/** + * Thin controller for minutes draft generation. + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1 + */ +class MinutesController extends Controller +{ + /** + * Constructor for the MinutesController. + * + * @param IRequest $request The request object + * @param MinutesGenerationService $minutesGenerationService The generation service + * @param ContainerInterface $container The DI container + * @param IAppConfig $appConfig The app config + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1 + */ + public function __construct( + IRequest $request, + private readonly MinutesGenerationService $minutesGenerationService, + private readonly ContainerInterface $container, + private readonly IAppConfig $appConfig, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Generate a draft minutes document from the linked meeting data. + * + * Authorization: row-level access is enforced by OpenRegister's ObjectService::findObject(). + * If the calling user does not have read permission on the Minutes object, findObject() will + * return empty and this endpoint returns 404. Broad read access for all authenticated members + * is intentional per the Decidesk governance model — minutes content is not private once a + * meeting has taken place. + * + * @param string $minutesId The UUID of the Minutes object + * + * @return JSONResponse JSON with preview text or error + * + * @NoAdminRequired + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1 + */ + public function generateDraft(string $minutesId): JSONResponse + { + try { + // Verify the Minutes object exists and is accessible before delegating to the service. + // OpenRegister enforces row-level ACL — findObject() returns empty when access is denied. + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + $register = $this->appConfig->getValueString(Application::APP_ID, 'register', 'decidesk'); + $minutes = $objectService->findObject(register: $register, schema: 'minutes', id: $minutesId); + if (empty($minutes) === true) { + return new JSONResponse(['message' => 'Minutes object not found'], Http::STATUS_NOT_FOUND); + } + + $preview = $this->minutesGenerationService->generateDraft($minutesId); + return new JSONResponse(['preview' => $preview]); + } catch (\RuntimeException $e) { + return new JSONResponse( + ['message' => $e->getMessage()], + Http::STATUS_NOT_FOUND + ); + } catch (\Throwable $e) { + return new JSONResponse(['message' => 'Failed to generate draft'], Http::STATUS_INTERNAL_SERVER_ERROR); + }//end try + + }//end generateDraft() +}//end class diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index c86a267b..55138c51 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -64,6 +64,8 @@ public function index(): JSONResponse /** * Update settings with provided data. * + * @AdminRequired + * * @return JSONResponse */ public function create(): JSONResponse @@ -85,6 +87,8 @@ public function create(): JSONResponse * Forces a fresh import regardless of version, auto-configuring * all schema and register IDs from the import result. * + * @AdminRequired + * * @return JSONResponse */ public function load(): JSONResponse diff --git a/lib/Service/MinutesGenerationService.php b/lib/Service/MinutesGenerationService.php new file mode 100644 index 00000000..490881e0 --- /dev/null +++ b/lib/Service/MinutesGenerationService.php @@ -0,0 +1,454 @@ + + * @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-minutes-and-decisions/tasks.md#task-1 + */ + +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. +declare(strict_types=1); + +namespace OCA\Decidesk\Service; + +use OCA\Decidesk\AppInfo\Application; +use OCP\IAppConfig; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Stateless service that generates draft minutes content from a linked meeting. + * + * Fetches the linked Meeting via OpenRegister relations, retrieves its + * AgendaItems (ordered by orderNumber), Motions, VotingRounds, and Decisions, + * and renders them into a structured Dutch text template. + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1 + */ +class MinutesGenerationService +{ + /** + * Constructor for MinutesGenerationService. + * + * @param IAppConfig $appConfig The app config interface + * @param ContainerInterface $container The container + * @param LoggerInterface $logger The logger + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1 + */ + public function __construct( + private readonly IAppConfig $appConfig, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Generate draft minutes content from the linked meeting. + * + * Fetches the Minutes object to find its linked Meeting, then retrieves + * AgendaItems, Motions, VotingRounds, and Decisions to render a + * structured Dutch text template. + * + * @param string $minutesId The UUID of the Minutes object + * + * @return string The generated draft content in Dutch + * + * @throws \RuntimeException When the Minutes object or linked Meeting cannot be found + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1 + */ + public function generateDraft(string $minutesId): string + { + $objectService = $this->getObjectService(); + $register = $this->getRegister(); + + // Fetch the minutes object. + $minutes = $objectService->findObject(register: $register, schema: 'minutes', id: $minutesId); + if (empty($minutes) === true) { + throw new \RuntimeException('Notulen object niet gevonden: '.$minutesId); + } + + // Find the linked meeting via relations. + $meetingId = $this->findRelatedObjectId(object: $minutes, relationType: 'meeting'); + if ($meetingId === null) { + throw new \RuntimeException('Geen vergadering gekoppeld aan deze notulen'); + } + + $meeting = $objectService->findObject(register: $register, schema: 'meeting', id: $meetingId); + if (empty($meeting) === true) { + throw new \RuntimeException('Gekoppelde vergadering niet gevonden: '.$meetingId); + } + + // Fetch agenda items sorted by orderNumber. + $agendaItems = $this->fetchRelatedObjects( + objectService: $objectService, + register: $register, + schema: 'agenda-item', + relationKey: 'meeting', + relationValue: $meetingId, + ); + usort( + $agendaItems, + function ($a, $b) { + return (int) ($a['orderNumber'] ?? 0) - (int) ($b['orderNumber'] ?? 0); + } + ); + + // Collect agenda item IDs to scope subsequent fetches to this meeting only. + $agendaItemIds = array_filter(array_map(fn($item) => ($item['id'] ?? null), $agendaItems)); + + // Fetch motions for each agenda item (scoped to this meeting). + $motions = []; + foreach ($agendaItemIds as $itemId) { + $itemMotions = $this->fetchRelatedObjects( + objectService: $objectService, + register: $register, + schema: 'motion', + relationKey: 'agendaItem', + relationValue: $itemId, + ); + $motions = array_merge($motions, $itemMotions); + } + + // Collect motion IDs to scope voting-round and decision fetches. + $motionIds = array_filter(array_map(fn($m) => ($m['id'] ?? null), $motions)); + + // Fetch voting rounds for each motion. + $votingRounds = []; + foreach ($motionIds as $motionId) { + $motionVotes = $this->fetchRelatedObjects( + objectService: $objectService, + register: $register, + schema: 'voting-round', + relationKey: 'motion', + relationValue: $motionId, + ); + $votingRounds = array_merge($votingRounds, $motionVotes); + } + + // Fetch decisions for each motion. + $decisions = []; + foreach ($motionIds as $motionId) { + $motionDecisions = $this->fetchRelatedObjects( + objectService: $objectService, + register: $register, + schema: 'decision', + relationKey: 'motion', + relationValue: $motionId, + ); + $decisions = array_merge($decisions, $motionDecisions); + } + + return $this->renderTemplate( + meeting: $meeting, + agendaItems: $agendaItems, + motions: $motions, + votingRounds: $votingRounds, + decisions: $decisions, + ); + }//end generateDraft() + + /** + * Find a related object ID from the relations array. + * + * @param array $object The source object + * @param string $relationType The relation type name + * + * @return string|null The related object ID or null + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1 + */ + private function findRelatedObjectId(array $object, string $relationType): ?string + { + $relations = ($object['relations'] ?? []); + foreach ($relations as $relation) { + if (isset($relation['schema']) === true + && strtolower($relation['schema']) === strtolower($relationType) + ) { + return ($relation['objectId'] ?? $relation['id'] ?? null); + } + } + + return null; + }//end findRelatedObjectId() + + /** + * Fetch related objects from a schema with optional filtering. + * + * @param object $objectService The ObjectService instance + * @param string $register The register slug + * @param string $schema The schema slug + * @param string $relationKey The relation key for filtering + * @param string|null $relationValue The relation value to filter by + * + * @return array The fetched objects + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1 + */ + private function fetchRelatedObjects( + object $objectService, + string $register, + string $schema, + string $relationKey, + ?string $relationValue, + ): array { + try { + $params = []; + if ($relationValue !== null) { + $params[$relationKey] = $relationValue; + } + + $result = $objectService->findObjects(register: $register, schema: $schema, params: $params); + return ($result['results'] ?? $result ?? []); + } catch (\Throwable $e) { + $this->logger->warning('MinutesGenerationService: failed to fetch '.$schema, ['exception' => $e->getMessage()]); + return []; + } + }//end fetchRelatedObjects() + + /** + * Render the structured Dutch minutes template. + * + * @param array $meeting The meeting data + * @param array $agendaItems The agenda items + * @param array $motions The motions + * @param array $votingRounds The voting rounds + * @param array $decisions The decisions + * + * @return string The rendered template + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1 + */ + private function renderTemplate( + array $meeting, + array $agendaItems, + array $motions, + array $votingRounds, + array $decisions, + ): string { + $title = ($meeting['title'] ?? 'Vergadering'); + $scheduledDate = ($meeting['scheduledDate'] ?? ''); + $location = ($meeting['location'] ?? ''); + + $lines = []; + $lines[] = 'NOTULEN'; + $lines[] = $title; + $lines[] = ''; + + if (empty($scheduledDate) === false) { + $timestamp = strtotime($scheduledDate); + if ($timestamp !== false) { + $dateFormatted = date('d-m-Y H:i', $timestamp); + } else { + $dateFormatted = $scheduledDate; + } + + $lines[] = 'Datum: '.$dateFormatted; + } + + if (empty($location) === false) { + $lines[] = 'Locatie: '.$location; + } + + $lines[] = ''; + $lines[] = '---'; + $lines[] = ''; + + if (empty($agendaItems) === true) { + $lines[] = 'Geen agendapunten gevonden voor deze vergadering.'; + return implode("\n", $lines); + } + + foreach ($agendaItems as $index => $item) { + $orderNumber = ($item['orderNumber'] ?? ($index + 1)); + $itemTitle = ($item['title'] ?? 'Agendapunt '.($index + 1)); + $itemType = ($item['itemType'] ?? 'informational'); + + $lines[] = 'Agendapunt '.$orderNumber.' — '.$itemTitle; + $lines[] = 'Type: '.$this->translateItemType(type: $itemType); + + if (empty($item['description']) === false) { + $lines[] = $item['description']; + } + + // Find motions for this agenda item. + $itemMotions = $this->filterByRelation(objects: $motions, relationKey: 'agendaItem', targetId: ($item['id'] ?? '')); + foreach ($itemMotions as $motion) { + $lines[] = ''; + $lines[] = 'Motie: '.($motion['title'] ?? 'Zonder titel'); + $lines[] = 'Indiener: '.($motion['proposer'] ?? 'Onbekend'); + $lines[] = 'Status: '.$this->translateLifecycle(lifecycle: ($motion['lifecycle'] ?? '')); + + // Find voting rounds for this motion. + $motionVotes = $this->filterByRelation(objects: $votingRounds, relationKey: 'motion', targetId: ($motion['id'] ?? '')); + foreach ($motionVotes as $vote) { + $lines[] = 'Stemming: voor '.($vote['votesFor'] ?? 0) + .', tegen '.($vote['votesAgainst'] ?? 0) + .', onthoudingen '.($vote['votesAbstain'] ?? 0) + .' — resultaat: '.$this->translateResult(result: ($vote['result'] ?? '')); + } + + // Find decisions for this motion. + $motionDecisions = $this->filterByRelation(objects: $decisions, relationKey: 'motion', targetId: ($motion['id'] ?? '')); + foreach ($motionDecisions as $decision) { + $lines[] = 'Besluit: '.($decision['title'] ?? ''); + $lines[] = ($decision['text'] ?? ''); + } + }//end foreach + + $lines[] = ''; + }//end foreach + + $lines[] = '---'; + $lines[] = 'Einde notulen.'; + + return implode("\n", $lines); + }//end renderTemplate() + + /** + * Filter objects by a relation key matching a given ID. + * + * @param array $objects The objects to filter + * @param string $relationKey The relation key + * @param string $targetId The target ID to match + * + * @return array Filtered objects + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1 + */ + private function filterByRelation(array $objects, string $relationKey, string $targetId): array + { + if (empty($targetId) === true) { + return []; + } + + return array_filter( + $objects, + function ($obj) use ($relationKey, $targetId) { + $relations = ($obj['relations'] ?? []); + foreach ($relations as $relation) { + $schema = strtolower(($relation['schema'] ?? '')); + if ($schema === strtolower($relationKey)) { + $relId = ($relation['objectId'] ?? $relation['id'] ?? ''); + if ($relId === $targetId) { + return true; + } + } + } + + return false; + } + ); + }//end filterByRelation() + + /** + * Translate agenda item type to Dutch. + * + * @param string $type The item type + * + * @return string Translated type + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1 + */ + private function translateItemType(string $type): string + { + $translations = [ + 'informational' => 'Ter informatie', + 'discussion' => 'Bespreekstuk', + 'decision' => 'Besluitstuk', + ]; + + return ($translations[$type] ?? $type); + }//end translateItemType() + + /** + * Translate lifecycle value to Dutch. + * + * @param string $lifecycle The lifecycle value + * + * @return string Translated lifecycle + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1 + */ + private function translateLifecycle(string $lifecycle): string + { + $translations = [ + 'submitted' => 'Ingediend', + 'debating' => 'In debat', + 'voting' => 'In stemming', + 'adopted' => 'Aangenomen', + 'rejected' => 'Verworpen', + 'withdrawn' => 'Ingetrokken', + ]; + + return ($translations[$lifecycle] ?? $lifecycle); + }//end translateLifecycle() + + /** + * Translate voting result to Dutch. + * + * @param string $result The voting result + * + * @return string Translated result + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1 + */ + private function translateResult(string $result): string + { + $translations = [ + 'adopted' => 'Aangenomen', + 'rejected' => 'Verworpen', + 'tied' => 'Gelijkspel', + 'invalid' => 'Ongeldig', + ]; + + return ($translations[$result] ?? $result); + }//end translateResult() + + /** + * Get the ObjectService from OpenRegister via the container. + * + * @return object The ObjectService instance + * + * @throws \RuntimeException When ObjectService is not available + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1 + */ + private function getObjectService(): object + { + try { + return $this->container->get('OCA\OpenRegister\Service\ObjectService'); + } catch (\Throwable $e) { + throw new \RuntimeException('OpenRegister ObjectService is niet beschikbaar: '.$e->getMessage()); + } + }//end getObjectService() + + /** + * Get the configured register slug. + * + * @return string The register slug + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1 + */ + private function getRegister(): string + { + return $this->appConfig->getValueString(Application::APP_ID, 'register', 'decidesk'); + }//end getRegister() +}//end class diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index c1151728..d1a81f61 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -99,6 +99,11 @@ public function getSettings(): array [ 'openregisters' => $this->isOpenRegisterAvailable(), 'isAdmin' => $isAdmin, + 'entityConfig' => [ + 'minutes' => ['schema' => 'minutes', 'register' => 'decidesk'], + 'decision' => ['schema' => 'decision', 'register' => 'decidesk'], + 'actionItem' => ['schema' => 'action-item', 'register' => 'decidesk'], + ], ] ); }//end getSettings() diff --git a/lib/Settings/decidesk_register.json b/lib/Settings/decidesk_register.json index 50c8ec00..1c17b65c 100644 --- a/lib/Settings/decidesk_register.json +++ b/lib/Settings/decidesk_register.json @@ -1037,41 +1037,67 @@ "@self": { "register": "decidesk", "schema": "Decision", - "slug": "besluit-begroting-2026" + "slug": "besluit-woningbouwplan-oost" }, - "title": "Vaststelling Programmabegroting 2026", - "text": "De gemeenteraad van Westerkwartier stelt de programmabegroting 2026 vast.", - "decisionDate": "2025-04-10T21:38:00Z", + "title": "Vaststelling Woningbouwplan Oost 2025-2030", + "text": "De gemeenteraad van Westerkwartier stelt het Woningbouwplan Oost 2025-2030 vast en machtigt het college tot uitvoering conform bijgaande planning en budgettering.", + "decisionDate": "2025-03-20T21:15:00Z", "outcome": "adopted", "isPublished": true, - "publishedAt": "2025-04-11T09:00:00Z", - "legalBasis": "Gemeentewet art. 189" + "publishedAt": "2025-03-21T09:00:00Z", + "legalBasis": "Wet ruimtelijke ordening art. 3.1" }, { "@self": { "register": "decidesk", "schema": "Decision", - "slug": "besluit-duurzaamheid-2025" + "slug": "besluit-duurzaamheidsagenda-2026" }, - "title": "Motie Duurzaamheid aangenomen", - "text": "De raad verzoekt het college een plan van aanpak zonnepanelen te presenteren voor 1 januari 2026.", - "decisionDate": "2025-04-10T21:05:00Z", + "title": "Vaststelling Duurzaamheidsagenda 2026", + "text": "De raad stelt de Duurzaamheidsagenda Westerkwartier 2026 vast en stelt \u20ac200.000 beschikbaar voor uitvoering van de daarin opgenomen maatregelen.", + "decisionDate": "2025-03-20T20:45:00Z", "outcome": "adopted", "isPublished": true, - "publishedAt": "2025-04-11T09:00:00Z" + "publishedAt": "2025-03-21T09:00:00Z", + "legalBasis": "Gemeentewet art. 147" }, { "@self": { "register": "decidesk", "schema": "Decision", - "slug": "besluit-bestemmingsplan-groningen-noord" + "slug": "besluit-bezwaar-kapvergunning" }, - "title": "Vaststelling bestemmingsplan Groningen-Noord", - "text": "De raad stelt het bestemmingsplan Groningen-Noord ongewijzigd vast.", - "decisionDate": "2025-03-27T20:45:00Z", + "title": "Besluit op bezwaar kapvergunning Dorpsstraat 12", + "text": "De gemeenteraad verklaart het bezwaar van de bewonersvereniging gegrond en herroept de verleende kapvergunning voor de eik aan Dorpsstraat 12.", + "decisionDate": "2025-04-01T14:30:00Z", "outcome": "adopted", "isPublished": false, - "legalBasis": "Wet ruimtelijke ordening art. 3.1" + "legalBasis": "Algemene wet bestuursrecht art. 7:11" + }, + { + "@self": { + "register": "decidesk", + "schema": "Decision", + "slug": "besluit-amendement-cultuursubsidie-afgewezen" + }, + "title": "Amendement verhoging cultuursubsidie \u2014 niet aanvaard", + "text": "Het amendement tot verhoging van het cultuursubsidiebudget 2026 met \u20ac75.000 wordt niet aanvaard.", + "decisionDate": "2025-04-10T21:00:00Z", + "outcome": "rejected", + "isPublished": false + }, + { + "@self": { + "register": "decidesk", + "schema": "Decision", + "slug": "besluit-fusie-dorpsraden" + }, + "title": "Instemming fusie dorpsraden Noord en Oost per 1 januari 2026", + "text": "De raad stemt in met de samenvoeging van dorpsraad Noord en dorpsraad Oost tot \u00e9\u00e9n dorpsraad Noord-Oost, ingaande 1 januari 2026.", + "decisionDate": "2025-04-10T20:30:00Z", + "outcome": "adopted", + "isPublished": true, + "publishedAt": "2025-04-11T08:30:00Z" } ] }, @@ -1147,36 +1173,63 @@ "@self": { "register": "decidesk", "schema": "ActionItem", - "slug": "actie-plan-zonnepanelen" + "slug": "actie-woningbouw-uitvoeringsplan" }, - "title": "Plan van aanpak zonnepanelen gemeentelijke gebouwen opstellen", - "description": "College presenteert plan voor 1 januari 2026 conform aangenomen motie.", - "assignee": "Wethouder Duurzaamheid", - "dueDate": "2026-01-01T00:00:00Z", - "taskStatus": "open" + "title": "Uitvoeringsplan Woningbouwplan Oost opstellen", + "description": "College stelt uitvoeringsplan op inclusief fasering, budgetten en planning en presenteert dit aan de raad per 1 juli 2025.", + "assignee": "Wethouder Wonen", + "dueDate": "2025-07-01T00:00:00Z", + "taskStatus": "in-progress" }, { "@self": { "register": "decidesk", "schema": "ActionItem", - "slug": "actie-communicatie-begroting-2026" + "slug": "actie-duurzaamheid-offerteverzoek" }, - "title": "Begroting 2026 communiceren naar inwoners", - "assignee": "Communicatieafdeling", - "dueDate": "2025-05-01T00:00:00Z", + "title": "Offerteverzoek zonnepanelen gemeentelijke gebouwen uitzetten", + "description": "Inkoopteam vraagt offertes op bij minimaal 3 leveranciers conform gemeentelijk aanbestedingsbeleid.", + "assignee": "Inkoopco\u00f6rdinator", + "dueDate": "2025-05-15T00:00:00Z", "taskStatus": "completed", - "completedAt": "2025-04-25T14:30:00Z" + "completedAt": "2025-05-10T16:00:00Z" }, { "@self": { "register": "decidesk", "schema": "ActionItem", - "slug": "actie-bestemmingsplan-publiceren" + "slug": "actie-notulen-distribueren" }, - "title": "Bestemmingsplan Groningen-Noord publiceren op ruimtelijkeplannen.nl", - "assignee": "Ruimtelijke Ordening", - "dueDate": "2025-04-10T00:00:00Z", + "title": "Definitieve notulen raadsvergadering 20 maart distribueren", + "description": "Griffier verstuurt vastgestelde notulen aan alle raadsleden en publiceert op gemeentelijke website.", + "assignee": "Griffier", + "dueDate": "2025-04-11T00:00:00Z", + "taskStatus": "completed", + "completedAt": "2025-04-11T09:30:00Z" + }, + { + "@self": { + "register": "decidesk", + "schema": "ActionItem", + "slug": "actie-besluiten-ori-publicatie" + }, + "title": "Besluiten raadsvergadering publiceren via ORI-koppeling", + "description": "Informatiemanager controleert ORI-export en bevestigt succesvolle aanlevering bij PLOOI.", + "assignee": "Informatiemanager", + "dueDate": "2025-03-22T00:00:00Z", "taskStatus": "overdue" + }, + { + "@self": { + "register": "decidesk", + "schema": "ActionItem", + "slug": "actie-fusie-dorpsraden-convenant" + }, + "title": "Convenant fusie dorpsraden Noord en Oost opstellen en laten ondertekenen", + "description": "Gemeentelijke contactpersoon stelt convenant op in samenspraak met beide dorpsraden en legt het ter ondertekening voor.", + "assignee": "Beleidsmedewerker Participatie", + "dueDate": "2025-10-01T00:00:00Z", + "taskStatus": "open" } ] }, @@ -1254,12 +1307,12 @@ "@self": { "register": "decidesk", "schema": "Minutes", - "slug": "notulen-raad-2025-04-10" + "slug": "notulen-raad-2025-03-20" }, - "title": "Notulen Raadsvergadering 10 april 2025", - "lifecycle": "approved", - "content": "De vergadering wordt geopend door de voorzitter om 19:30 uur...", - "approvedAt": "2025-05-08T19:45:00Z", + "title": "Notulen Gemeenteraadsvergadering 20 maart 2025", + "lifecycle": "published", + "content": "De vergadering wordt geopend door de voorzitter om 19:30 uur. Aanwezig: 33 van 37 raadsleden. De agenda wordt ongewijzigd vastgesteld. Punt 1 \u2014 opening en mededelingen: de voorzitter meldt dat de gemeente een subsidie heeft ontvangen voor verduurzaming. Punt 5 \u2014 Woningbouwplan Oost 2025-2030: na bespreking wordt het plan aangenomen met 30 stemmen voor en 3 tegen...", + "approvedAt": "2025-04-10T19:45:00Z", "signedBy": [ "Roos de Vries", "Jan Bakker" @@ -1270,12 +1323,12 @@ "@self": { "register": "decidesk", "schema": "Minutes", - "slug": "notulen-commissie-ruimte-2025-03-04" + "slug": "notulen-commissie-wonen-2025-04-01" }, - "title": "Notulen Commissievergadering Ruimte & Wonen 4 maart 2025", + "title": "Notulen Commissievergadering Wonen & Ruimte 1 april 2025", "lifecycle": "signed", - "content": "De commissievergadering Ruimte & Wonen wordt geopend...", - "approvedAt": "2025-04-01T10:00:00Z", + "content": "Aanvang: 19:00 uur. Voorzitter: Henk Bakker. Commissieleden aanwezig: 7 van 9. Agendapunt 2 \u2014 bestemmingsplan Groningen-Noord: de commissie adviseert de raad het plan ongewijzigd vast te stellen. Agendapunt 3 \u2014 huurbeleid sociale woningbouw: de commissie wenst aanvullende informatie van het college...", + "approvedAt": "2025-04-15T10:00:00Z", "signedBy": [ "Henk Bakker" ], @@ -1285,9 +1338,20 @@ "@self": { "register": "decidesk", "schema": "Minutes", - "slug": "notulen-ab-waterschap-2025-02-13" + "slug": "notulen-ab-waterschap-2025-04-10" + }, + "title": "Notulen Vergadering Algemeen Bestuur Waterschap Aa en Maas 10 april 2025", + "lifecycle": "review", + "content": "Concept-verslag ter vaststelling. De vergadering van het Algemeen Bestuur wordt geopend door de dijkgraaf om 14:00 uur. Aanwezig: 21 van 23 leden. Agendapunt 3 \u2014 Waterbeheerprogramma 2026: bespreking over de financiering van dijkversterking...", + "version": 1 + }, + { + "@self": { + "register": "decidesk", + "schema": "Minutes", + "slug": "notulen-directieoverleg-2025-04-14" }, - "title": "Notulen AB Waterschap Aa en Maas 13 februari 2025", + "title": "Verslag Directieoverleg Gemeente Utrecht 14 april 2025", "lifecycle": "draft", "version": 1 } diff --git a/openspec/changes/p2-minutes-and-decisions/design.md b/openspec/changes/p2-minutes-and-decisions/design.md index 4d52f12f..052f78f8 100644 --- a/openspec/changes/p2-minutes-and-decisions/design.md +++ b/openspec/changes/p2-minutes-and-decisions/design.md @@ -29,10 +29,10 @@ OpenRegister provides full-text search, audit trails, file attachments, relation **Rationale**: ADR-001 requires using platform capabilities. `status` is a built-in field with workflow engine support — no custom state machine code needed. **Alternative considered**: Custom `lifecycle` field managed in PHP service — rejected because it duplicates the platform's workflow engine. -### 2. Digital signing stores signer display names in `signedBy` array -**Decision**: `signedBy` on the Minutes object stores the `displayName` values of the chair and secretary who approved. The `approvedAt` timestamp is recorded when transitioning to `approved`. The audit trail provides full traceability. -**Rationale**: Full cryptographic PKI signing is out of scope for v1. Display name + audit trail is sufficient for legal governance traceability. Full digital signatures can be layered on in a future sprint via Nextcloud Sign integration. -**Alternative considered**: Integration with Nextcloud Sign — deferred to future sprint. +### 2. Digital signing stores signer UIDs in `signedBy` array +**Decision**: `signedBy` on the Minutes object stores the `getUID()` values (immutable Nextcloud user IDs) of the chair and secretary who performed the `approved` and `signed` transitions respectively. The `approvedAt` timestamp is recorded when transitioning to `approved`. The audit trail provides full traceability. +**Rationale**: UIDs are immutable in Nextcloud — a user cannot rename themselves to impersonate another signer. Using `getDisplayName()` was identified as OWASP A04:2021 (Insecure Design) since display names are mutable and user-controlled. UID provides a tamper-resistant audit record while the audit trail stores the full display name context. Full cryptographic PKI signing is out of scope for v1 and is deferred to a future sprint via Nextcloud Sign integration. +**Alternative considered**: Storing display names — rejected due to security finding (mutable, impersonation risk). Integration with Nextcloud Sign — deferred to future sprint. ### 3. Decision publication sets `isPublished` flag via explicit user action **Decision**: A "Publiceren" button on an adopted Decision's detail page calls `ObjectService::saveObject()` to set `isPublished: true` and `publishedAt: `. The actual ORI API push is deferred to p3. @@ -49,12 +49,17 @@ OpenRegister provides full-text search, audit trails, file attachments, relation **Rationale**: OpenRegister does not have a built-in scheduled status transition. A cron-style background job (ADR-003 pattern) is correct. The frontend also calculates and shows overdue state client-side for immediate feedback — the job is a best-effort sync. **Alternative considered**: Frontend-only overdue detection — rejected because it would not reliably update the persisted `taskStatus` field needed for filtering and reporting. +### 6. Minutes lifecycle transitions handled client-side via `ObjectService.saveObject()` (realignment with Decision 1) +**Decision**: The custom `POST /api/minutes/{id}/transition` backend endpoint initially introduced during implementation has been removed. Lifecycle transitions (`draft → review → approved → signed → published`) are now performed by calling `minutesStore.saveObject()` directly from `MinutesDetail.vue`, which delegates to OpenRegister's `ObjectService`. Side-effects (`signedBy` population with the current user's UID, `approvedAt` timestamp, `version` increment) are computed client-side before the save call and are stored as ordinary object fields. +**Rationale**: The custom endpoint was flagged as a spec deviation (Decision 1 specifies `WorkflowEngineController`). Removing it and performing transitions client-side via the standard object store aligns with the OpenRegister-first principle. The governance side-effects (signedBy, approvedAt, version) are light enough to compute in the Vue component; they do not require a dedicated server endpoint. The sequential state machine is enforced by the UI (buttons are only shown for valid next states) rather than server-side. +**Alternative considered**: Keeping the custom transition endpoint — rejected to eliminate the spec deviation and reduce backend surface area. + ## Reuse Analysis (ADR-012) | Capability | OpenRegister / Platform service used | |------------|--------------------------------------| | Minutes / Decision / ActionItem CRUD | `ObjectService` + `CnIndexPage` + `CnDetailPage` | -| Lifecycle transitions | `WorkflowEngineController` + `CnTimelineStages` | +| Lifecycle transitions | Client-side `ObjectService.saveObject()` via store + `CnTimelineStages` (see Decision 6) | | Version / revision history | `AuditTrailService` (built-in) + `CnObjectSidebar` → `CnAuditTrailTab` | | Decision full-text search | `IndexService` + `CnFilterBar` + `CnFacetSidebar` | | File attachments (signed minutes PDFs) | `FileService` + `CnObjectSidebar` → `CnFilesTab` | diff --git a/openspec/changes/p2-minutes-and-decisions/tasks.md b/openspec/changes/p2-minutes-and-decisions/tasks.md index 92c53623..72fce63b 100644 --- a/openspec/changes/p2-minutes-and-decisions/tasks.md +++ b/openspec/changes/p2-minutes-and-decisions/tasks.md @@ -1,74 +1,74 @@ ## 1. Backend — Minutes Generation Service -- [ ] 1.1 Create `lib/Service/MinutesGenerationService.php` — stateless service with `generateDraft(string $minutesId): string` that fetches the linked Meeting via OpenRegister relations, then retrieves its AgendaItems (ordered by `orderNumber`), Motions, VotingRounds, and Decisions, and renders them into a structured Dutch text template; annotate with `@spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1` -- [ ] 1.2 Create `lib/Controller/MinutesController.php` — thin controller with a single `generateDraft(string $minutesId): JSONResponse` action (POST); calls `MinutesGenerationService::generateDraft()`; returns `{ preview: '' }`; annotate with `@spec` tags -- [ ] 1.3 Register route in `appinfo/routes.php`: `POST /api/minutes/{minutesId}/generate-draft` → `MinutesController::generateDraft`; place this specific route BEFORE any wildcard `{slug}` route -- [ ] 1.4 Register `MinutesGenerationService` and `MinutesController` in DI container (`lib/AppInfo/Application.php`) +- [x] 1.1 Create `lib/Service/MinutesGenerationService.php` — stateless service with `generateDraft(string $minutesId): string` that fetches the linked Meeting via OpenRegister relations, then retrieves its AgendaItems (ordered by `orderNumber`), Motions, VotingRounds, and Decisions, and renders them into a structured Dutch text template; annotate with `@spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-1` +- [x] 1.2 Create `lib/Controller/MinutesController.php` — thin controller with a single `generateDraft(string $minutesId): JSONResponse` action (POST); calls `MinutesGenerationService::generateDraft()`; returns `{ preview: '' }`; annotate with `@spec` tags +- [x] 1.3 Register route in `appinfo/routes.php`: `POST /api/minutes/{minutesId}/generate-draft` → `MinutesController::generateDraft`; place this specific route BEFORE any wildcard `{slug}` route +- [x] 1.4 Register `MinutesGenerationService` and `MinutesController` in DI container (`lib/AppInfo/Application.php`) ## 2. Backend — Overdue Action Items Background Job -- [ ] 2.1 Create `lib/BackgroundJob/OverdueActionItemsJob.php` — extends `\OCP\BackgroundJob\TimedJob`; runs daily; queries all ActionItems where `taskStatus` is `open` or `in-progress` and `dueDate < now()`; calls `ObjectService::saveObject()` to set `taskStatus: overdue` for each; annotate with `@spec` tags -- [ ] 2.2 Register `OverdueActionItemsJob` in `appinfo/info.xml` under `` so Nextcloud schedules it automatically -- [ ] 2.3 Register `OverdueActionItemsJob` in DI container (`lib/AppInfo/Application.php`) +- [x] 2.1 Create `lib/BackgroundJob/OverdueActionItemsJob.php` — extends `\OCP\BackgroundJob\TimedJob`; runs daily; queries all ActionItems where `taskStatus` is `open` or `in-progress` and `dueDate < now()`; calls `ObjectService::saveObject()` to set `taskStatus: overdue` for each; annotate with `@spec` tags +- [x] 2.2 Register `OverdueActionItemsJob` in `appinfo/info.xml` under `` so Nextcloud schedules it automatically +- [x] 2.3 Register `OverdueActionItemsJob` in DI container (`lib/AppInfo/Application.php`) ## 3. Backend — Settings Extension -- [ ] 3.1 Extend `lib/Service/SettingsService.php::getSettings()` to include schema and register slugs for the three new entities (Minutes, Decision, ActionItem) so the frontend `initializeStores()` can register their object stores -- [ ] 3.2 Verify `ConfigurationService::importFromApp('decidesk')` is called by the existing repair step and that the Minutes, Decision, and ActionItem seed data objects defined in `design.md` are present in `lib/Settings/decidesk_register.json` under `x-openregister.seedData` +- [x] 3.1 Extend `lib/Service/SettingsService.php::getSettings()` to include schema and register slugs for the three new entities (Minutes, Decision, ActionItem) so the frontend `initializeStores()` can register their object stores +- [x] 3.2 Verify `ConfigurationService::importFromApp('decidesk')` is called by the existing repair step and that the Minutes, Decision, and ActionItem seed data objects defined in `design.md` are present in `lib/Settings/decidesk_register.json` under `x-openregister.seedData` ## 4. Frontend — Store and Route Registration -- [ ] 4.1 Create Pinia object stores in `src/store/modules/` for Minutes, Decision, and ActionItem using `createObjectStore(name)` with `files`, `auditTrails`, and `relations` plugins -- [ ] 4.2 Extend `src/store/store.js::initializeStores()` to call `objectStore.registerObjectType(name, schemaSlug, registerSlug)` for Minutes, Decision, and ActionItem after fetching settings -- [ ] 4.3 Add named routes to `src/router/index.js`: `Minutes` (`/minutes`), `MinutesDetail` (`/minutes/:id`), `Decisions` (`/decisions`), `DecisionDetail` (`/decisions/:id`), `ActionItems` (`/action-items`), `ActionItemDetail` (`/action-items/:id`) -- [ ] 4.4 Add `NcAppNavigationItem` entries to `src/components/MainMenu.vue` for Notulen (Minutes), Besluiten (Decisions), and Actiepunten (Action Items) with MDI icons and `:to` route bindings; all labels use `t(appName, 'text')` +- [x] 4.1 Create Pinia object stores in `src/store/modules/` for Minutes, Decision, and ActionItem using `createObjectStore(name)` with `files`, `auditTrails`, and `relations` plugins +- [x] 4.2 Extend `src/store/store.js::initializeStores()` to call `objectStore.registerObjectType(name, schemaSlug, registerSlug)` for Minutes, Decision, and ActionItem after fetching settings +- [x] 4.3 Add named routes to `src/router/index.js`: `Minutes` (`/minutes`), `MinutesDetail` (`/minutes/:id`), `Decisions` (`/decisions`), `DecisionDetail` (`/decisions/:id`), `ActionItems` (`/action-items`), `ActionItemDetail` (`/action-items/:id`) +- [x] 4.4 Add `NcAppNavigationItem` entries to `src/components/MainMenu.vue` for Notulen (Minutes), Besluiten (Decisions), and Actiepunten (Action Items) with MDI icons and `:to` route bindings; all labels use `t(appName, 'text')` ## 5. Frontend — Minutes Views -- [ ] 5.1 Create `src/views/Minutes.vue` — `CnIndexPage` with `useListView('minutes', { sidebarState, objectStore: minutesStore })`; columns: title, lifecycle (with `CnStatusBadge`), version, approvedAt; row click → `MinutesDetail`; all strings via `t()` -- [ ] 5.2 Create `src/views/MinutesDetail.vue` — `CnDetailPage` with `useDetailView`; property sections via `CnDetailCard` (title, lifecycle, version, approvedAt, signedBy); `CnTimelineStages` showing `draft → review → approved → signed → published` progression; "Concept genereren" button that calls `POST /api/minutes/:id/generate-draft`, shows a preview modal (`NcDialog`), and on confirmation updates the `content` field; Edit and Delete header actions; `CnObjectSidebar` with Files, Audit, Notes, Tasks tabs; all strings via `t()` -- [ ] 5.3 Ensure lifecycle transition buttons ("Ter goedkeuring indienen", "Goedkeuren", "Ondertekenen", "Publiceren") are shown only when the current `lifecycle` allows that transition; each button calls `ObjectService::saveObject()` with the new lifecycle value; on `approved` transition the current user's display name is appended to `signedBy` and `approvedAt` is set; all button labels via `t()` +- [x] 5.1 Create `src/views/Minutes.vue` — `CnIndexPage` with `useListView('minutes', { sidebarState, objectStore: minutesStore })`; columns: title, lifecycle (with `CnStatusBadge`), version, approvedAt; row click → `MinutesDetail`; all strings via `t()` +- [x] 5.2 Create `src/views/MinutesDetail.vue` — `CnDetailPage` with `useDetailView`; property sections via `CnDetailCard` (title, lifecycle, version, approvedAt, signedBy); `CnTimelineStages` showing `draft → review → approved → signed → published` progression; "Concept genereren" button that calls `POST /api/minutes/:id/generate-draft`, shows a preview modal (`NcDialog`), and on confirmation updates the `content` field; Edit and Delete header actions; `CnObjectSidebar` with Files, Audit, Notes, Tasks tabs; all strings via `t()` +- [x] 5.3 Ensure lifecycle transition buttons ("Ter goedkeuring indienen", "Goedkeuren", "Ondertekenen", "Publiceren") are shown only when the current `lifecycle` allows that transition; each button calls `ObjectService::saveObject()` with the new lifecycle value; on `approved` transition the current user's display name is appended to `signedBy` and `approvedAt` is set; all button labels via `t()` ## 6. Frontend — Decision Views -- [ ] 6.1 Create `src/views/Decisions.vue` — `CnIndexPage` with `useListView('decision', { sidebarState, objectStore: decisionStore })`; columns: title, outcome (with `CnStatusBadge`), decisionDate, isPublished; `CnFilterBar` with filters for `outcome` and `isPublished`; row click → `DecisionDetail`; all strings via `t()` -- [ ] 6.2 Create `src/views/DecisionDetail.vue` — `CnDetailPage` with `useDetailView`; property sections via `CnDetailCard` (title, text, decisionDate, outcome, legalBasis, isPublished, publishedAt); related Motion section (link to Motion detail); related ActionItems section (table with title, assignee, dueDate, taskStatus; row click → ActionItemDetail); "Publiceren" button shown only when `outcome: adopted` and `isPublished: false` — clicking sets `isPublished: true` and `publishedAt: ` via `ObjectService::saveObject()`; Edit and Delete header actions; `CnObjectSidebar`; all strings via `t()` +- [x] 6.1 Create `src/views/Decisions.vue` — `CnIndexPage` with `useListView('decision', { sidebarState, objectStore: decisionStore })`; columns: title, outcome (with `CnStatusBadge`), decisionDate, isPublished; `CnFilterBar` with filters for `outcome` and `isPublished`; row click → `DecisionDetail`; all strings via `t()` +- [x] 6.2 Create `src/views/DecisionDetail.vue` — `CnDetailPage` with `useDetailView`; property sections via `CnDetailCard` (title, text, decisionDate, outcome, legalBasis, isPublished, publishedAt); related Motion section (link to Motion detail); related ActionItems section (table with title, assignee, dueDate, taskStatus; row click → ActionItemDetail); "Publiceren" button shown only when `outcome: adopted` and `isPublished: false` — clicking sets `isPublished: true` and `publishedAt: ` via `ObjectService::saveObject()`; Edit and Delete header actions; `CnObjectSidebar`; all strings via `t()` ## 7. Frontend — Action Item Views -- [ ] 7.1 Create `src/views/ActionItems.vue` — `CnIndexPage` with `useListView('action-item', { sidebarState, objectStore: actionItemStore })`; columns: title, assignee, dueDate, taskStatus (with `CnStatusBadge`); overdue items highlighted using `CnStatusBadge` warning variant (Nextcloud CSS variables only, no hardcoded colours); `CnFilterBar` with filters for `taskStatus` and `assignee`; row click → `ActionItemDetail`; all strings via `t()` -- [ ] 7.2 Create `src/views/ActionItemDetail.vue` — `CnDetailPage` with `useDetailView`; property sections via `CnDetailCard` (title, description, assignee, dueDate, taskStatus, completedAt); related Decision section; related Meeting section; status update buttons: "In behandeling" (open → in-progress), "Afgerond" (in-progress → completed, sets completedAt to now); Edit and Delete header actions; `CnObjectSidebar`; all strings via `t()` -- [ ] 7.3 Implement client-side overdue visual indicator: in ActionItems.vue, compute `isOverdue` as `dueDate < today && taskStatus !== 'completed'`; render overdue `CnStatusBadge` using `--color-error` Nextcloud CSS variable; this is a display-only enhancement alongside the background job +- [x] 7.1 Create `src/views/ActionItems.vue` — `CnIndexPage` with `useListView('action-item', { sidebarState, objectStore: actionItemStore })`; columns: title, assignee, dueDate, taskStatus (with `CnStatusBadge`); overdue items highlighted using `CnStatusBadge` warning variant (Nextcloud CSS variables only, no hardcoded colours); `CnFilterBar` with filters for `taskStatus` and `assignee`; row click → `ActionItemDetail`; all strings via `t()` +- [x] 7.2 Create `src/views/ActionItemDetail.vue` — `CnDetailPage` with `useDetailView`; property sections via `CnDetailCard` (title, description, assignee, dueDate, taskStatus, completedAt); related Decision section; related Meeting section; status update buttons: "In behandeling" (open → in-progress), "Afgerond" (in-progress → completed, sets completedAt to now); Edit and Delete header actions; `CnObjectSidebar`; all strings via `t()` +- [x] 7.3 Implement client-side overdue visual indicator: in ActionItems.vue, compute `isOverdue` as `dueDate < today && taskStatus !== 'completed'`; render overdue `CnStatusBadge` using `--color-error` Nextcloud CSS variable; this is a display-only enhancement alongside the background job ## 8. Frontend — Dashboard Extensions -- [ ] 8.1 Add three new `CnStatsBlock` KPI cards to `src/views/Dashboard.vue`: "Notulen ter goedkeuring" (count of Minutes with `lifecycle: review`), "Gepubliceerde besluiten" (count of Decisions with `isPublished: true`), "Open actiepunten" (count of ActionItems with `taskStatus: open` or `in-progress`) -- [ ] 8.2 Fetch the three new KPI counts in parallel alongside existing counts using `Promise.all` in the Dashboard `created()` hook -- [ ] 8.3 All new KPI card labels use `t(appName, 'text')` +- [x] 8.1 Add three new `CnStatsBlock` KPI cards to `src/views/Dashboard.vue`: "Notulen ter goedkeuring" (count of Minutes with `lifecycle: review`), "Gepubliceerde besluiten" (count of Decisions with `isPublished: true`), "Open actiepunten" (count of ActionItems with `taskStatus: open` or `in-progress`) +- [x] 8.2 Fetch the three new KPI counts in parallel alongside existing counts using `Promise.all` in the Dashboard `created()` hook +- [x] 8.3 All new KPI card labels use `t(appName, 'text')` ## 9. Tests -- [ ] 9.1 Write `tests/Unit/Service/MinutesGenerationServiceTest.php` — PHPUnit tests covering: (a) happy path generates correct Dutch template with agenda items, motions, and decisions; (b) meeting with no agenda items returns minimal template; (c) missing linked meeting throws descriptive exception; minimum 3 test methods; add `@spec` tag -- [ ] 9.2 Write `tests/Unit/BackgroundJob/OverdueActionItemsJobTest.php` — PHPUnit tests covering: (a) action items past dueDate are set to overdue; (b) completed action items are not modified; (c) action items with no dueDate are not modified; minimum 3 test methods; add `@spec` tag -- [ ] 9.3 Write `tests/Unit/Controller/MinutesControllerTest.php` — PHPUnit tests covering: (a) generateDraft returns preview JSON; (b) generateDraft with invalid minutesId returns 404; (c) unauthenticated request returns 401; add `@spec` tag +- [x] 9.1 Write `tests/Unit/Service/MinutesGenerationServiceTest.php` — PHPUnit tests covering: (a) happy path generates correct Dutch template with agenda items, motions, and decisions; (b) meeting with no agenda items returns minimal template; (c) missing linked meeting throws descriptive exception; minimum 3 test methods; add `@spec` tag +- [x] 9.2 Write `tests/Unit/BackgroundJob/OverdueActionItemsJobTest.php` — PHPUnit tests covering: (a) action items past dueDate are set to overdue; (b) completed action items are not modified; (c) action items with no dueDate are not modified; minimum 3 test methods; add `@spec` tag +- [x] 9.3 Write `tests/Unit/Controller/MinutesControllerTest.php` — PHPUnit tests covering: (a) generateDraft returns preview JSON; (b) generateDraft with invalid minutesId returns 404; (c) unauthenticated request returns 401; add `@spec` tag ## 10. Seed Data -- [ ] 10.1 Add seed data objects for Minutes (4 objects), Decision (5 objects), and ActionItem (5 objects) to `lib/Settings/decidesk_register.json` under `x-openregister.seedData` using the `@self` envelope (`register`, `schema`, `slug`) with Dutch values from `design.md` -- [ ] 10.2 Verify the repair step imports these seed data objects on a fresh install without errors (slug-based upsert) +- [x] 10.1 Add seed data objects for Minutes (4 objects), Decision (5 objects), and ActionItem (5 objects) to `lib/Settings/decidesk_register.json` under `x-openregister.seedData` using the `@self` envelope (`register`, `schema`, `slug`) with Dutch values from `design.md` +- [x] 10.2 Verify the repair step imports these seed data objects on a fresh install without errors (slug-based upsert) ## 11. Verification -- [ ] 11.1 Verify Minutes CRUD: create, read, update, delete via UI; confirm `lifecycle: draft` on creation -- [ ] 11.2 Verify Minutes lifecycle: transition draft → review → approved → signed → published; confirm `signedBy` is updated on approve and sign; confirm `approvedAt` is set on approve; confirm audit trail entries are created for each transition -- [ ] 11.3 Verify "Concept genereren": click button, confirm preview modal appears, confirm `content` is populated after confirmation, confirm `lifecycle` stays `draft` -- [ ] 11.4 Verify Decision CRUD: create, read, update, delete; confirm linked Motion appears in detail view; confirm ActionItems table in detail view -- [ ] 11.5 Verify Decision publication: "Publiceren" button visible only for `outcome: adopted` and `isPublished: false`; clicking sets `isPublished: true` and `publishedAt`; button replaced by timestamp after publish -- [ ] 11.6 Verify Decision search: search by text, filter by outcome, filter by isPublished — correct results returned -- [ ] 11.7 Verify ActionItem CRUD: create with assignee and dueDate, update status, complete with completedAt set -- [ ] 11.8 Verify overdue detection: create an ActionItem with `dueDate` in the past; trigger background job manually (or set dueDate < today and refresh list); confirm `taskStatus` is `overdue` and badge is displayed -- [ ] 11.9 Verify Dashboard KPI cards show correct counts; confirm counts update when objects are created or status changes -- [ ] 11.10 Verify MainMenu shows Notulen, Besluiten, and Actiepunten navigation entries; confirm routes are navigable -- [ ] 11.11 Verify all user-visible strings use `t(appName, 'text')` — no hardcoded strings -- [ ] 11.12 Verify no hardcoded CSS colours — only Nextcloud CSS variables used for status badges and highlights -- [ ] 11.13 Confirm all `@spec` PHPDoc tags are present on new PHP classes and public methods linking to `openspec/changes/p2-minutes-and-decisions/tasks.md` +- [x] 11.1 Verify Minutes CRUD: create, read, update, delete via UI; confirm `lifecycle: draft` on creation +- [x] 11.2 Verify Minutes lifecycle: transition draft → review → approved → signed → published; confirm `signedBy` is updated on approve and sign; confirm `approvedAt` is set on approve; confirm audit trail entries are created for each transition +- [x] 11.3 Verify "Concept genereren": click button, confirm preview modal appears, confirm `content` is populated after confirmation, confirm `lifecycle` stays `draft` +- [x] 11.4 Verify Decision CRUD: create, read, update, delete; confirm linked Motion appears in detail view; confirm ActionItems table in detail view +- [x] 11.5 Verify Decision publication: "Publiceren" button visible only for `outcome: adopted` and `isPublished: false`; clicking sets `isPublished: true` and `publishedAt`; button replaced by timestamp after publish +- [x] 11.6 Verify Decision search: search by text, filter by outcome, filter by isPublished — correct results returned +- [x] 11.7 Verify ActionItem CRUD: create with assignee and dueDate, update status, complete with completedAt set +- [x] 11.8 Verify overdue detection: create an ActionItem with `dueDate` in the past; trigger background job manually (or set dueDate < today and refresh list); confirm `taskStatus` is `overdue` and badge is displayed +- [x] 11.9 Verify Dashboard KPI cards show correct counts; confirm counts update when objects are created or status changes +- [x] 11.10 Verify MainMenu shows Notulen, Besluiten, and Actiepunten navigation entries; confirm routes are navigable +- [x] 11.11 Verify all user-visible strings use `t(appName, 'text')` — no hardcoded strings +- [x] 11.12 Verify no hardcoded CSS colours — only Nextcloud CSS variables used for status badges and highlights +- [x] 11.13 Confirm all `@spec` PHPDoc tags are present on new PHP classes and public methods linking to `openspec/changes/p2-minutes-and-decisions/tasks.md` diff --git a/package.json b/package.json index c3da794c..d294197b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ ], "dependencies": { "@conduction/nextcloud-vue": "^0.1.0-beta.3", + "@nextcloud/auth": "^2.5.3", "@nextcloud/axios": "^2.5.0", "@nextcloud/dialogs": "^3.2.0", "@nextcloud/initial-state": "^2.2.0", diff --git a/src/navigation/MainMenu.vue b/src/navigation/MainMenu.vue index 6c6dc713..bdc19fc7 100644 --- a/src/navigation/MainMenu.vue +++ b/src/navigation/MainMenu.vue @@ -10,6 +10,27 @@ + + + + + + + + + @@ -33,8 +54,11 @@ + + diff --git a/src/views/ActionItems.vue b/src/views/ActionItems.vue new file mode 100644 index 00000000..c0be6f15 --- /dev/null +++ b/src/views/ActionItems.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index 9161111c..a0ab8fff 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -4,38 +4,38 @@

{{ t('decidesk', 'Dashboard') }}

- {{ t('decidesk', 'Starter overview with sample KPIs and activity placeholders. Replace this view with your own data.') }} + {{ t('decidesk', 'Overview of your governance activities and key metrics.') }}

- + @@ -59,10 +59,13 @@ diff --git a/src/views/DecisionDetail.vue b/src/views/DecisionDetail.vue new file mode 100644 index 00000000..a40955e7 --- /dev/null +++ b/src/views/DecisionDetail.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/src/views/Decisions.vue b/src/views/Decisions.vue new file mode 100644 index 00000000..87aa041e --- /dev/null +++ b/src/views/Decisions.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/views/Minutes.vue b/src/views/Minutes.vue new file mode 100644 index 00000000..d3c3ec11 --- /dev/null +++ b/src/views/Minutes.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/src/views/MinutesDetail.vue b/src/views/MinutesDetail.vue new file mode 100644 index 00000000..3ce8ca5a --- /dev/null +++ b/src/views/MinutesDetail.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/tests/Unit/BackgroundJob/OverdueActionItemsJobTest.php b/tests/Unit/BackgroundJob/OverdueActionItemsJobTest.php new file mode 100644 index 00000000..124fbd68 --- /dev/null +++ b/tests/Unit/BackgroundJob/OverdueActionItemsJobTest.php @@ -0,0 +1,236 @@ + + * @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-minutes-and-decisions/tasks.md#task-9 + */ + +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. + +declare(strict_types=1); + +namespace OCA\Decidesk\Tests\Unit\BackgroundJob; + +use OCA\Decidesk\BackgroundJob\OverdueActionItemsJob; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IAppConfig; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Tests for OverdueActionItemsJob. + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-9 + */ +class OverdueActionItemsJobTest extends TestCase +{ + + /** + * Mock ITimeFactory. + * + * @var ITimeFactory&MockObject + */ + private ITimeFactory&MockObject $timeFactory; + + /** + * Mock IAppConfig. + * + * @var IAppConfig&MockObject + */ + private IAppConfig&MockObject $appConfig; + + /** + * Mock ContainerInterface. + * + * @var ContainerInterface&MockObject + */ + private ContainerInterface&MockObject $container; + + /** + * Mock LoggerInterface. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface&MockObject $logger; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->timeFactory = $this->createMock(originalClassName: ITimeFactory::class); + $this->appConfig = $this->createMock(originalClassName: IAppConfig::class); + $this->container = $this->createMock(originalClassName: ContainerInterface::class); + $this->logger = $this->createMock(originalClassName: LoggerInterface::class); + + $this->appConfig->method('getValueString') + ->willReturn('decidesk'); + + }//end setUp() + + /** + * Test that past-due action items are set to overdue. + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-9 + */ + public function testOverdueItemsAreUpdated(): void + { + $pastDate = (new \DateTime('-7 days'))->format('c'); + $savedItems = []; + + $objectService = new class($pastDate, $savedItems) { + private string $pastDate; + public array $savedItems; + + public function __construct(string $pastDate, array &$savedItems) + { + $this->pastDate = $pastDate; + $this->savedItems = &$savedItems; + } + + public function findObjects(string $register, string $schema, array $params = []): array + { + if (($params['taskStatus'] ?? '') === 'open') { + return ['results' => [ + ['id' => 'ai-1', 'taskStatus' => 'open', 'dueDate' => $this->pastDate], + ]]; + } + return ['results' => []]; + } + + public function saveObject(string $register, string $schema, array $object): array + { + $this->savedItems[] = $object; + return $object; + } + }; + + $this->container->method('get') + ->willReturn($objectService); + + $job = new OverdueActionItemsJob( + time: $this->timeFactory, + appConfig: $this->appConfig, + container: $this->container, + logger: $this->logger, + ); + + // Use reflection to call protected run() method. + $method = new \ReflectionMethod($job, 'run'); + $method->invoke($job, null); + + self::assertCount(1, $objectService->savedItems); + self::assertSame('overdue', $objectService->savedItems[0]['taskStatus']); + + }//end testOverdueItemsAreUpdated() + + /** + * Test that completed action items are not modified. + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-9 + */ + public function testCompletedItemsAreNotModified(): void + { + $objectService = new class() { + public array $savedItems = []; + + public function findObjects(string $register, string $schema, array $params = []): array + { + // The job only queries 'open' and 'in-progress', never 'completed'. + return ['results' => []]; + } + + public function saveObject(string $register, string $schema, array $object): array + { + $this->savedItems[] = $object; + return $object; + } + }; + + $this->container->method('get') + ->willReturn($objectService); + + $job = new OverdueActionItemsJob( + time: $this->timeFactory, + appConfig: $this->appConfig, + container: $this->container, + logger: $this->logger, + ); + + $method = new \ReflectionMethod($job, 'run'); + $method->invoke($job, null); + + self::assertCount(0, $objectService->savedItems); + + }//end testCompletedItemsAreNotModified() + + /** + * Test that action items with no dueDate are not modified. + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-9 + */ + public function testItemsWithNoDueDateAreNotModified(): void + { + $objectService = new class() { + public array $savedItems = []; + + public function findObjects(string $register, string $schema, array $params = []): array + { + if (($params['taskStatus'] ?? '') === 'open') { + return ['results' => [ + ['id' => 'ai-2', 'taskStatus' => 'open', 'dueDate' => null], + ['id' => 'ai-3', 'taskStatus' => 'open'], + ]]; + } + return ['results' => []]; + } + + public function saveObject(string $register, string $schema, array $object): array + { + $this->savedItems[] = $object; + return $object; + } + }; + + $this->container->method('get') + ->willReturn($objectService); + + $job = new OverdueActionItemsJob( + time: $this->timeFactory, + appConfig: $this->appConfig, + container: $this->container, + logger: $this->logger, + ); + + $method = new \ReflectionMethod($job, 'run'); + $method->invoke($job, null); + + self::assertCount(0, $objectService->savedItems); + + }//end testItemsWithNoDueDateAreNotModified() +}//end class diff --git a/tests/Unit/Controller/DecisionControllerTest.php b/tests/Unit/Controller/DecisionControllerTest.php new file mode 100644 index 00000000..abf5ce3a --- /dev/null +++ b/tests/Unit/Controller/DecisionControllerTest.php @@ -0,0 +1,253 @@ + + * @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-minutes-and-decisions/tasks.md#task-6 + */ + +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. + +declare(strict_types=1); + +namespace OCA\Decidesk\Tests\Unit\Controller; + +use OCA\Decidesk\Controller\DecisionController; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; + +/** + * Tests for DecisionController::publish(). + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-6 + */ +class DecisionControllerTest extends TestCase +{ + + /** + * The controller under test. + * + * @var DecisionController + */ + private DecisionController $controller; + + /** + * Mock IRequest. + * + * @var IRequest&MockObject + */ + private IRequest&MockObject $request; + + /** + * Mock IGroupManager. + * + * @var IGroupManager&MockObject + */ + private IGroupManager&MockObject $groupManager; + + /** + * Mock IUserSession. + * + * @var IUserSession&MockObject + */ + private IUserSession&MockObject $userSession; + + /** + * Mock ContainerInterface. + * + * @var ContainerInterface&MockObject + */ + private ContainerInterface&MockObject $container; + + /** + * Mock IAppConfig. + * + * @var IAppConfig&MockObject + */ + private IAppConfig&MockObject $appConfig; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->request = $this->createMock(originalClassName: IRequest::class); + $this->groupManager = $this->createMock(originalClassName: IGroupManager::class); + $this->userSession = $this->createMock(originalClassName: IUserSession::class); + $this->container = $this->createMock(originalClassName: ContainerInterface::class); + $this->appConfig = $this->createMock(originalClassName: IAppConfig::class); + + $this->controller = new DecisionController( + request: $this->request, + groupManager: $this->groupManager, + userSession: $this->userSession, + container: $this->container, + appConfig: $this->appConfig, + ); + + }//end setUp() + + /** + * Test that publish() returns 403 when called by a non-admin user. + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-6 + */ + public function testPublishRejectsNonAdmin(): void + { + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getUID')->willReturn('user1'); + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->method('isAdmin')->with('user1')->willReturn(false); + + $result = $this->controller->publish('decision-uuid-1'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $result->getStatus()); + self::assertArrayHasKey(key: 'message', array: $result->getData()); + + }//end testPublishRejectsNonAdmin() + + /** + * Test that publish() returns 404 when the decision object is not found. + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-6 + */ + public function testPublishReturnsNotFoundWhenDecisionMissing(): void + { + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getUID')->willReturn('admin1'); + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->method('isAdmin')->with('admin1')->willReturn(true); + + $objectService = new class() { + public function findObject(string $register, string $schema, string $id): array + { + return []; + } + }; + + $this->container->method('get')->willReturn($objectService); + $this->appConfig->method('getValueString')->willReturn('decidesk'); + + $result = $this->controller->publish('non-existent-decision-uuid'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_NOT_FOUND, actual: $result->getStatus()); + self::assertArrayHasKey(key: 'message', array: $result->getData()); + + }//end testPublishReturnsNotFoundWhenDecisionMissing() + + /** + * Test that publish() returns 422 when the decision outcome is not 'adopted'. + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-6 + */ + public function testPublishRejectsNonAdoptedDecision(): void + { + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getUID')->willReturn('admin1'); + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->method('isAdmin')->with('admin1')->willReturn(true); + + $decisionData = ['id' => 'decision-uuid-1', 'outcome' => 'rejected']; + $objectService = new class($decisionData) { + public function __construct(private array $decision) + { + } + + public function findObject(string $register, string $schema, string $id): ?array + { + return $this->decision; + } + }; + + $this->container->method('get')->willReturn($objectService); + $this->appConfig->method('getValueString')->willReturn('decidesk'); + + $result = $this->controller->publish('decision-uuid-1'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_UNPROCESSABLE_ENTITY, actual: $result->getStatus()); + + }//end testPublishRejectsNonAdoptedDecision() + + /** + * Test that publish() sets isPublished=true and a valid publishedAt timestamp on success. + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-6 + */ + public function testPublishHappyPathSetsIsPublishedAndPublishedAt(): void + { + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getUID')->willReturn('admin1'); + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->method('isAdmin')->with('admin1')->willReturn(true); + + $decisionData = ['id' => 'decision-uuid-1', 'outcome' => 'adopted', 'isPublished' => false]; + $objectService = new class($decisionData) { + public function __construct(private array $decision) + { + } + + public function findObject(string $register, string $schema, string $id): ?array + { + return $this->decision; + } + + public function saveObject(string $register, string $schema, array $object): array + { + return $object; + } + }; + + $this->container->method('get')->willReturn($objectService); + $this->appConfig->method('getValueString')->willReturn('decidesk'); + + $result = $this->controller->publish('decision-uuid-1'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_OK, actual: $result->getStatus()); + + $data = $result->getData(); + self::assertTrue(condition: $data['isPublished']); + self::assertArrayHasKey(key: 'publishedAt', array: $data); + // Verify publishedAt is a valid ISO 8601 timestamp. + self::assertMatchesRegularExpression( + pattern: '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/', + string: $data['publishedAt'] + ); + + }//end testPublishHappyPathSetsIsPublishedAndPublishedAt() +}//end class diff --git a/tests/Unit/Controller/MinutesControllerTest.php b/tests/Unit/Controller/MinutesControllerTest.php new file mode 100644 index 00000000..f1bff953 --- /dev/null +++ b/tests/Unit/Controller/MinutesControllerTest.php @@ -0,0 +1,224 @@ + + * @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-minutes-and-decisions/tasks.md#task-9 + */ + +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. + +declare(strict_types=1); + +namespace OCA\Decidesk\Tests\Unit\Controller; + +use OCA\Decidesk\Controller\MinutesController; +use OCA\Decidesk\Service\MinutesGenerationService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IAppConfig; +use OCP\IRequest; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; + +/** + * Tests for MinutesController. + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-9 + */ +class MinutesControllerTest extends TestCase +{ + + /** + * The controller under test. + * + * @var MinutesController + */ + private MinutesController $controller; + + /** + * Mock IRequest. + * + * @var IRequest&MockObject + */ + private IRequest&MockObject $request; + + /** + * Mock MinutesGenerationService. + * + * @var MinutesGenerationService&MockObject + */ + private MinutesGenerationService&MockObject $generationService; + + /** + * Mock ContainerInterface. + * + * @var ContainerInterface&MockObject + */ + private ContainerInterface&MockObject $container; + + /** + * Mock IAppConfig. + * + * @var IAppConfig&MockObject + */ + private IAppConfig&MockObject $appConfig; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->request = $this->createMock(originalClassName: IRequest::class); + $this->generationService = $this->createMock(originalClassName: MinutesGenerationService::class); + $this->container = $this->createMock(originalClassName: ContainerInterface::class); + $this->appConfig = $this->createMock(originalClassName: IAppConfig::class); + + $this->controller = new MinutesController( + request: $this->request, + minutesGenerationService: $this->generationService, + container: $this->container, + appConfig: $this->appConfig, + ); + + }//end setUp() + + /** + * Test that generateDraft returns preview JSON on success. + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-9 + */ + public function testGenerateDraftReturnsPreviewJson(): void + { + $previewText = 'NOTULEN\nRaadsvergadering 10 april 2025\n...'; + $objectService = new class { + public function findObject(string $register, string $schema, string $id): array + { + return ['id' => $id, 'title' => 'Test']; + } + }; + $this->container->method('get')->willReturn($objectService); + $this->appConfig->method('getValueString')->willReturn('decidesk'); + + $this->generationService->expects($this->once()) + ->method('generateDraft') + ->with('minutes-uuid-1') + ->willReturn($previewText); + + $result = $this->controller->generateDraft('minutes-uuid-1'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: $previewText, actual: $result->getData()['preview']); + self::assertSame(expected: Http::STATUS_OK, actual: $result->getStatus()); + + }//end testGenerateDraftReturnsPreviewJson() + + /** + * Test that generateDraft returns 404 for invalid minutesId. + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-9 + */ + public function testGenerateDraftInvalidIdReturns404(): void + { + $objectService = new class { + public function findObject(string $register, string $schema, string $id): array + { + return ['id' => $id, 'title' => 'Test']; + } + }; + $this->container->method('get')->willReturn($objectService); + $this->appConfig->method('getValueString')->willReturn('decidesk'); + + $this->generationService->expects($this->once()) + ->method('generateDraft') + ->with('non-existent-id') + ->willThrowException(new \RuntimeException('Notulen object niet gevonden')); + + $result = $this->controller->generateDraft('non-existent-id'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_NOT_FOUND, actual: $result->getStatus()); + self::assertArrayHasKey(key: 'message', array: $result->getData()); + + }//end testGenerateDraftInvalidIdReturns404() + + /** + * Test that generateDraft returns 404 when meeting is not linked. + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-9 + */ + public function testGenerateDraftMissingMeetingReturns404(): void + { + $objectService = new class { + public function findObject(string $register, string $schema, string $id): array + { + return ['id' => $id, 'title' => 'Test']; + } + }; + $this->container->method('get')->willReturn($objectService); + $this->appConfig->method('getValueString')->willReturn('decidesk'); + + $this->generationService->expects($this->once()) + ->method('generateDraft') + ->with('minutes-no-meeting') + ->willThrowException(new \RuntimeException('Geen vergadering gekoppeld')); + + $result = $this->controller->generateDraft('minutes-no-meeting'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_NOT_FOUND, actual: $result->getStatus()); + + }//end testGenerateDraftMissingMeetingReturns404() + + /** + * Test that generateDraft endpoint requires authentication (spec task 9.3c). + * + * The @NoAdminRequired annotation allows any authenticated user. + * The absence of @PublicPage means Nextcloud's SessionMiddleware enforces + * authentication at the framework layer, returning HTTP 401 for unauthenticated + * requests. This cannot be tested at the unit level (middleware is outside the + * controller); this test verifies the annotations are correctly set to guarantee + * the framework guard fires. + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-9 + */ + public function testGenerateDraftRequiresAuthentication(): void + { + $reflection = new \ReflectionMethod(MinutesController::class, 'generateDraft'); + $docComment = (string) $reflection->getDocComment(); + + // @NoAdminRequired: any authenticated user may call this endpoint (not admin-only). + self::assertStringContainsString('@NoAdminRequired', $docComment); + + // @PublicPage would bypass session auth — its absence means the framework + // SessionMiddleware enforces authentication and returns 401 for unauthenticated requests. + self::assertStringNotContainsString('@PublicPage', $docComment); + + }//end testGenerateDraftRequiresAuthentication() + +}//end class diff --git a/tests/Unit/Service/MinutesGenerationServiceTest.php b/tests/Unit/Service/MinutesGenerationServiceTest.php new file mode 100644 index 00000000..a9e2da05 --- /dev/null +++ b/tests/Unit/Service/MinutesGenerationServiceTest.php @@ -0,0 +1,263 @@ + + * @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-minutes-and-decisions/tasks.md#task-9 + */ + +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. + +declare(strict_types=1); + +namespace OCA\Decidesk\Tests\Unit\Service; + +use OCA\Decidesk\Service\MinutesGenerationService; +use OCP\IAppConfig; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Tests for MinutesGenerationService. + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-9 + */ +class MinutesGenerationServiceTest extends TestCase +{ + + /** + * The service under test. + * + * @var MinutesGenerationService + */ + private MinutesGenerationService $service; + + /** + * Mock IAppConfig. + * + * @var IAppConfig&MockObject + */ + private IAppConfig&MockObject $appConfig; + + /** + * Mock ContainerInterface. + * + * @var ContainerInterface&MockObject + */ + private ContainerInterface&MockObject $container; + + /** + * Mock LoggerInterface. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface&MockObject $logger; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->appConfig = $this->createMock(originalClassName: IAppConfig::class); + $this->container = $this->createMock(originalClassName: ContainerInterface::class); + $this->logger = $this->createMock(originalClassName: LoggerInterface::class); + + $this->appConfig->method('getValueString') + ->willReturn('decidesk'); + + $this->service = new MinutesGenerationService( + appConfig: $this->appConfig, + container: $this->container, + logger: $this->logger, + ); + + }//end setUp() + + /** + * Test that generateDraft produces correct Dutch template with agenda items. + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-9 + */ + public function testGenerateDraftHappyPath(): void + { + $objectService = $this->createMockObjectService( + minutes: [ + 'id' => 'min-1', + 'title' => 'Test Notulen', + 'lifecycle' => 'draft', + 'relations' => [ + ['schema' => 'meeting', 'objectId' => 'meet-1'], + ], + ], + meeting: [ + 'id' => 'meet-1', + 'title' => 'Raadsvergadering 10 april 2025', + 'scheduledDate' => '2025-04-10T19:30:00Z', + 'location' => 'Raadzaal', + ], + agendaItems: [ + [ + 'id' => 'ai-1', + 'title' => 'Opening', + 'orderNumber' => 1, + 'itemType' => 'informational', + 'relations' => [], + ], + [ + 'id' => 'ai-2', + 'title' => 'Woningbouwplan', + 'orderNumber' => 2, + 'itemType' => 'decision', + 'relations' => [], + ], + ] + ); + + $this->container->method('get') + ->willReturn($objectService); + + $result = $this->service->generateDraft('min-1'); + + self::assertStringContainsString('NOTULEN', $result); + self::assertStringContainsString('Raadsvergadering 10 april 2025', $result); + self::assertStringContainsString('Opening', $result); + self::assertStringContainsString('Woningbouwplan', $result); + self::assertStringContainsString('Ter informatie', $result); + self::assertStringContainsString('Besluitstuk', $result); + self::assertStringContainsString('Locatie: Raadzaal', $result); + + }//end testGenerateDraftHappyPath() + + /** + * Test that generateDraft with no agenda items returns minimal template. + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-9 + */ + public function testGenerateDraftNoAgendaItems(): void + { + $objectService = $this->createMockObjectService( + minutes: [ + 'id' => 'min-2', + 'title' => 'Test Notulen', + 'lifecycle' => 'draft', + 'relations' => [ + ['schema' => 'meeting', 'objectId' => 'meet-2'], + ], + ], + meeting: [ + 'id' => 'meet-2', + 'title' => 'Lege vergadering', + 'scheduledDate' => '2025-04-15T10:00:00Z', + ], + agendaItems: [] + ); + + $this->container->method('get') + ->willReturn($objectService); + + $result = $this->service->generateDraft('min-2'); + + self::assertStringContainsString('NOTULEN', $result); + self::assertStringContainsString('Lege vergadering', $result); + self::assertStringContainsString('Geen agendapunten gevonden', $result); + + }//end testGenerateDraftNoAgendaItems() + + /** + * Test that generateDraft with missing linked meeting throws exception. + * + * @return void + * + * @spec openspec/changes/p2-minutes-and-decisions/tasks.md#task-9 + */ + public function testGenerateDraftMissingMeetingThrowsException(): void + { + $objectService = $this->createMockObjectService( + minutes: [ + 'id' => 'min-3', + 'title' => 'Test Notulen', + 'lifecycle' => 'draft', + 'relations' => [], + ], + meeting: null, + agendaItems: [] + ); + + $this->container->method('get') + ->willReturn($objectService); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Geen vergadering gekoppeld'); + + $this->service->generateDraft('min-3'); + + }//end testGenerateDraftMissingMeetingThrowsException() + + /** + * Create a mock ObjectService with prepared return values. + * + * @param array $minutes The minutes object to return + * @param array|null $meeting The meeting object to return + * @param array $agendaItems The agenda items to return + * + * @return object The mock ObjectService + */ + private function createMockObjectService(array $minutes, ?array $meeting, array $agendaItems): object + { + $mock = new class($minutes, $meeting, $agendaItems) { + public function __construct( + private array $minutes, + private ?array $meeting, + private array $agendaItems, + ) { + } + + public function findObject(string $register, string $schema, string $id): ?array + { + if ($schema === 'minutes') { + return $this->minutes; + } + + if ($schema === 'meeting') { + return $this->meeting; + } + + return null; + } + + public function findObjects(string $register, string $schema, array $params = []): array + { + if ($schema === 'agenda-item') { + return ['results' => $this->agendaItems]; + } + + return ['results' => []]; + } + }; + + return $mock; + + }//end createMockObjectService() +}//end class