diff --git a/appinfo/routes.php b/appinfo/routes.php index b7487b73..e0174e4f 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -10,10 +10,8 @@ ['name' => 'settings#create', 'url' => '/api/settings', 'verb' => 'POST'], ['name' => 'settings#load', 'url' => '/api/settings/load', 'verb' => 'POST'], - // Prometheus metrics endpoint. - ['name' => 'metrics#index', 'url' => '/api/metrics', 'verb' => 'GET'], - // Health check endpoint. - ['name' => 'health#index', 'url' => '/api/health', 'verb' => 'GET'], + // Meeting lifecycle transitions. + ['name' => 'meeting#lifecycle', 'url' => '/api/meetings/{id}/lifecycle', 'verb' => 'POST'], // SPA catch-all — same controller as the index route; must use a distinct route name // (duplicate names replace the earlier route in Symfony, which breaks GET /). diff --git a/lib/Controller/MeetingController.php b/lib/Controller/MeetingController.php new file mode 100644 index 00000000..1bf2e6e7 --- /dev/null +++ b/lib/Controller/MeetingController.php @@ -0,0 +1,100 @@ + + * @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-meeting-management/tasks.md#task-2.1 + */ + +declare(strict_types=1); + +namespace OCA\Decidesk\Controller; + +use OCA\Decidesk\AppInfo\Application; +use OCA\Decidesk\Service\MeetingService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * Controller for meeting lifecycle transitions. + * + * @spec openspec/changes/p2-meeting-management/tasks.md#task-2.1 + */ +class MeetingController extends Controller +{ + /** + * Constructor for MeetingController. + * + * @param IRequest $request The HTTP request + * @param MeetingService $meetingService The meeting service + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly MeetingService $meetingService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Apply a lifecycle transition to a meeting. + * + * Any authenticated Nextcloud user may call this endpoint. Meeting-level + * permission is enforced by OpenRegister's ObjectService: find() returns null + * (→ 422) when the caller lacks read access, and updateFromArray() throws an + * exception (→ 422 generic) when the caller lacks write access on the specific + * meeting object. In Dutch local government the meeting clerk is not a Nextcloud + * system administrator, so this route must be available to all logged-in users. + * + * Expects JSON body: { "action": "" } + * + * @param string $id UUID of the meeting + * + * @NoAdminRequired + * + * @spec openspec/changes/p2-meeting-management/tasks.md#task-2.1 + * + * @return JSONResponse HTTP 200 with updated meeting on success; 422 if transition is invalid + */ + #[NoAdminRequired] + public function lifecycle(string $id): JSONResponse + { + $action = $this->request->getParam('action', ''); + + if (empty($action) === true) { + return new JSONResponse( + ['message' => "Missing required parameter 'action'."], + Http::STATUS_UNPROCESSABLE_ENTITY + ); + } + + $result = $this->meetingService->transition(meetingId: $id, action: $action); + + if ($result['success'] === false) { + return new JSONResponse( + ['message' => $result['message']], + Http::STATUS_UNPROCESSABLE_ENTITY + ); + } + + return new JSONResponse($result); + + }//end lifecycle() +}//end class diff --git a/lib/Service/MeetingService.php b/lib/Service/MeetingService.php new file mode 100644 index 00000000..8f30799a --- /dev/null +++ b/lib/Service/MeetingService.php @@ -0,0 +1,194 @@ + + * @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-meeting-management/tasks.md#task-1.1 + */ + +declare(strict_types=1); + +namespace OCA\Decidesk\Service; + +use OCP\AppFramework\Db\DoesNotExistException; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Service for managing meeting lifecycle state transitions. + * + * Implements the state machine defined in design.md: + * draft → scheduled → opened ↔ paused → adjourned → (re-)opened → closed + * + * @spec openspec/changes/p2-meeting-management/tasks.md#task-1.1 + */ +class MeetingService +{ + + /** + * Valid lifecycle transitions keyed by action name. + * + * Each entry defines: + * - `from`: the set of states from which this action is permitted + * - `to`: the resulting state after the transition + * + * @var array + */ + private const TRANSITIONS = [ + 'schedule' => ['from' => ['draft'], 'to' => 'scheduled'], + 'open' => ['from' => ['scheduled', 'adjourned'], 'to' => 'opened'], + 'pause' => ['from' => ['opened'], 'to' => 'paused'], + 'resume' => ['from' => ['paused'], 'to' => 'opened'], + 'adjourn' => ['from' => ['opened', 'paused'], 'to' => 'adjourned'], + 'close' => ['from' => ['scheduled', 'opened', 'paused', 'adjourned'], 'to' => 'closed'], + ]; + + /** + * Constructor for MeetingService. + * + * @param ContainerInterface $container The DI container (used to retrieve ObjectService) + * @param LoggerInterface $logger The logger + * + * @spec openspec/changes/p2-meeting-management/tasks.md#task-1.1 + */ + public function __construct( + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Returns the list of valid action names for a given lifecycle state. + * + * @param string $currentLifecycle The current lifecycle value of the meeting + * + * @spec openspec/changes/p2-meeting-management/tasks.md#task-1.1 + * + * @return string[] List of action names the caller may invoke + */ + public function getAvailableActions(string $currentLifecycle): array + { + $available = []; + foreach (self::TRANSITIONS as $action => $transition) { + if (in_array($currentLifecycle, $transition['from'], true) === true) { + $available[] = $action; + } + } + + return $available; + + }//end getAvailableActions() + + /** + * Apply a lifecycle transition to a meeting object. + * + * Validates that `$action` is a known transition and that the meeting's + * current lifecycle state permits the transition, then patches the object + * via OpenRegister's ObjectService. + * + * @param string $meetingId UUID of the meeting to transition + * @param string $action Transition action: schedule|open|pause|resume|adjourn|close + * + * @spec openspec/changes/p2-meeting-management/tasks.md#task-1.1 + * + * @return array{success: bool, meeting: array|null, message: string} + */ + public function transition(string $meetingId, string $action): array + { + if (isset(self::TRANSITIONS[$action]) === false) { + return [ + 'success' => false, + 'meeting' => null, + 'message' => 'Unknown action. Valid actions: '.implode(', ', array_keys(self::TRANSITIONS)).'.', + ]; + } + + $transition = self::TRANSITIONS[$action]; + + try { + /* + * @var \OCA\OpenRegister\Service\ObjectService $objectService + */ + + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + + // Object-level read ACL: OpenRegister's ObjectService::find() resolves the + // current Nextcloud session user and returns null when the caller lacks read + // access to the requested object (same behaviour as a missing object). + // This prevents callers without read access from probing meeting UUIDs. + $entity = $objectService->find(id: $meetingId); + + if ($entity === null) { + return [ + 'success' => false, + 'meeting' => null, + 'message' => "Meeting '$meetingId' not found.", + ]; + } + + $currentLifecycle = $entity->getObject()['lifecycle'] ?? 'draft'; + + if (in_array($currentLifecycle, $transition['from'], true) === false) { + return [ + 'success' => false, + 'meeting' => null, + 'message' => "Cannot '$action' a meeting in '$currentLifecycle' state. " + ."Allowed from: ".implode(', ', $transition['from']).".", + ]; + } + + // Object-level write ACL: OpenRegister's ObjectService::updateFromArray() + // checks that the current Nextcloud session user has write access to this + // specific object before applying the patch. If the caller lacks write + // access an exception is thrown and caught by the \Throwable handler below, + // returning a generic error response without leaking object details. + $updated = $objectService->updateFromArray( + id: $meetingId, + object: ['lifecycle' => $transition['to']], + updateVersion: true, + patch: true, + ); + + $this->logger->info( + 'Decidesk: meeting lifecycle transitioned', + ['id' => $meetingId, 'action' => $action, 'to' => $transition['to']] + ); + + return [ + 'success' => true, + 'meeting' => $updated->jsonSerialize(), + 'message' => "Meeting transitioned to '{$transition['to']}'.", + ]; + } catch (DoesNotExistException) { + return [ + 'success' => false, + 'meeting' => null, + 'message' => "Meeting '$meetingId' not found.", + ]; + } catch (\Throwable $e) { + $this->logger->error( + 'Decidesk: meeting lifecycle transition failed', + ['id' => $meetingId, 'action' => $action, 'exception' => $e->getMessage()] + ); + return [ + 'success' => false, + 'meeting' => null, + 'message' => 'Transition failed. See server log for details.', + ]; + }//end try + + }//end transition() +}//end class diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index 1d6483bb..ae67be86 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -105,6 +105,10 @@ public function getSettings(): array $settings, [ 'openregisters' => $this->isOpenRegisterAvailable(), + // UI-HINT ONLY: isAdmin is used exclusively to control frontend rendering + // (e.g. showing/hiding admin-only settings panels). It MUST NOT be used + // for server-side access control decisions. All admin-gated backend routes + // enforce the admin check independently via IGroupManager::isAdmin(). 'isAdmin' => $isAdmin, ] ); diff --git a/openspec/changes/p2-meeting-management/design.md b/openspec/changes/p2-meeting-management/design.md new file mode 100644 index 00000000..7e6b8917 --- /dev/null +++ b/openspec/changes/p2-meeting-management/design.md @@ -0,0 +1,115 @@ +## Context + +Decidesk is a Nextcloud app built on the **thin-client** pattern with OpenRegister as the data layer. The `p1-crud-operations` change already delivered the Meeting entity schema, CRUD views, and basic detail pages. This change (p2) adds the domain-specific business logic for meeting management that cannot be expressed as generic CRUD: + +- **Meeting lifecycle** — a state machine governing valid state transitions (draft → scheduled → opened → paused → adjourned → closed). Business rules (e.g. you cannot open a closed meeting) must be enforced server-side, not just in the frontend. +- **Lifecycle UI** — action buttons in `MeetingDetail.vue` that reflect the current state and call the lifecycle endpoint. + +Everything else (attendance lists, speaking time, document attachments, series linking) is covered by the OpenRegister platform's built-in file attachments, relations, and CRUD — no custom code is needed. + +## Goals / Non-Goals + +**Goals:** +- Server-side lifecycle state machine for Meeting (`MeetingService::transition()`) +- Thin `MeetingController` exposing `POST /api/meetings/{id}/lifecycle` +- Vue component `MeetingLifecycle.vue` rendering valid action buttons from the current state +- PHPUnit tests (≥3 test methods each) for `MeetingService` and `MeetingController` + +**Non-Goals:** +- Attendance presence-list tracking beyond what OpenRegister relations provide (p3) +- Speaking-time clock / timeboxing UI (p3) +- Meeting template CRUD (relies on OpenRegister schema defaults — p3) +- ORI/Open-RIS publication (p3) + +## Decisions + +### 1. Server-side state machine in MeetingService +**Decision**: Lifecycle validation lives in `lib/Service/MeetingService.php` as a static transition table. +**Rationale**: ADR-003 mandates all business logic in the Service layer. The frontend cannot be trusted to enforce state machine rules — a malicious client could send any lifecycle value via `objectStore.saveObject()`. +**Alternative considered**: Frontend-only with schema validation — rejected because schema `enum` constraints allow any valid value, not only valid transitions from the current state. + +### 2. PATCH semantics for lifecycle update +**Decision**: The lifecycle endpoint calls `ObjectService::updateFromArray($uuid, ['lifecycle' => $newState], updateVersion: true, patch: true)`. +**Rationale**: Only the lifecycle field changes; patch mode preserves all other fields. `updateVersion: true` triggers OpenRegister's audit trail. + +### 3. Single POST endpoint with `action` body param +**Decision**: `POST /api/meetings/{id}/lifecycle` with `{ "action": "open|pause|resume|adjourn|close|schedule" }`. +**Rationale**: ADR-002 forbids custom HTTP methods/verbs. Using a single endpoint with an action parameter follows the NL API Design guidelines (7.2 — use POST for state transitions that cannot be expressed as a resource change). +**Alternative considered**: PUT to `/api/meetings/{id}` with new lifecycle value — rejected because it bypasses server-side transition validation. + +### 4. MeetingLifecycle.vue renders available actions +**Decision**: A dedicated `MeetingLifecycle.vue` component inside `MeetingDetail.vue` reads the current `lifecycle` value from the object and renders only the valid next-step action buttons. +**Rationale**: ADR-004 single responsibility: the detail page should not contain transition logic. Centralising in a component makes it reusable for future meeting list bulk-actions. + +## Lifecycle State Machine + +``` +draft ──schedule──► scheduled + │ + open + │ + ▼ + ◄──resume── opened ──pause──► paused + │ │ + adjourn adjourn + │ │ + ▼ ▼ + adjourned ◄─────────────── adjourned + │ + open (re-open) + │ + ▼ + opened + │ + close + ▼ + closed (terminal) +``` + +| Action | Valid from states | Result state | +|----------|-------------------------------|--------------| +| schedule | draft | scheduled | +| open | scheduled, adjourned | opened | +| pause | opened | paused | +| resume | paused | opened | +| adjourn | opened, paused | adjourned | +| close | opened, paused, adjourned, scheduled | closed | + +## Reuse Analysis (ADR-012) + +- CRUD, pagination, filtering — OpenRegister `ObjectService` (no rebuild) +- File attachments on meeting — OpenRegister `FileService` + `CnObjectSidebar` (no rebuild) +- Audit trail for lifecycle changes — OpenRegister automatic (no rebuild) +- Notifications — `NotificationService` available for future meeting-open notifications +- Frontend list/detail pattern — `CnIndexPage` + `CnDetailPage` (already implemented in p1) + +## Seed Data (Dutch examples — lifecycle transitions) + +These objects supplement the seed data from `p1-crud-operations`. No schema changes needed. + +### Meeting (lifecycle examples) + +```json +[ + { + "@self": { "register": "decidesk", "schema": "Meeting", "slug": "raadsvergadering-2026-04-01" }, + "title": "Raadsvergadering 1 april 2026", + "meetingType": "regular", + "scheduledDate": "2026-04-01T19:30:00Z", + "meetingMode": "in-person", + "lifecycle": "closed" + }, + { + "@self": { "register": "decidesk", "schema": "Meeting", "slug": "commissie-sociaal-2026-04-15" }, + "title": "Commissie Sociaal 15 april 2026", + "meetingType": "committee", + "scheduledDate": "2026-04-15T14:00:00Z", + "meetingMode": "hybrid", + "lifecycle": "scheduled" + } +] +``` + +## Status + +`pr-created` diff --git a/openspec/changes/p2-meeting-management/tasks.md b/openspec/changes/p2-meeting-management/tasks.md new file mode 100644 index 00000000..1a4be80d --- /dev/null +++ b/openspec/changes/p2-meeting-management/tasks.md @@ -0,0 +1,37 @@ +## 1. Backend — MeetingService + +- [x] 1.1 Create `lib/Service/MeetingService.php` — stateless service with `transition(string $meetingId, string $action): array`. Defines the state-machine transition table (`TRANSITIONS` constant). Validates the action name and current lifecycle state, then patches the lifecycle via `ObjectService::updateFromArray($id, ['lifecycle' => $newState], updateVersion: true, patch: true)`. Returns `['success' => bool, 'meeting' => array|null, 'message' => string]`. +- [x] 1.2 Add `@spec openspec/changes/p2-meeting-management/tasks.md#task-1.1` to every class and public method in `MeetingService.php`. + +## 2. Backend — MeetingController + +- [x] 2.1 Create `lib/Controller/MeetingController.php` — thin controller with one method: `lifecycle(string $id): JSONResponse`. Reads `action` from request body, calls `MeetingService::transition($id, $action)`, returns the result. Returns HTTP 422 if transition invalid, 200 on success. Annotate with `@NoAdminRequired`. +- [x] 2.2 Add `@spec openspec/changes/p2-meeting-management/tasks.md#task-2.1` to class and method. +- [x] 2.3 Register route in `appinfo/routes.php`: `POST /api/meetings/{id}/lifecycle` → `meeting#lifecycle`. + +## 3. Tests — PHPUnit + +- [x] 3.1 Create `tests/Unit/Service/MeetingServiceTest.php` with ≥3 test methods covering: + - Valid transition (e.g. scheduled → opened via `open`) returns success + - Invalid transition (e.g. trying to `pause` a draft) returns failure with message + - Unknown action name returns failure with message +- [x] 3.2 Create `tests/unit/Controller/MeetingControllerTest.php` with ≥3 test methods covering: + - Valid action returns HTTP 200 with meeting data + - Invalid transition returns HTTP 422 + - Missing action returns HTTP 422 + +## 4. Frontend — MeetingLifecycle Component + +- [x] 4.1 Create `src/components/MeetingLifecycle.vue` — receives `meeting` object as prop. Computes the list of valid actions for the current `lifecycle` value using the same transition table. Renders one `NcButton` per valid action. On click, sends `POST /api/meetings/{id}/lifecycle` and emits `lifecycle-updated` with the new meeting object. +- [x] 4.2 Add `@spec openspec/changes/p2-meeting-management/tasks.md#task-4.1` JSDoc comment to the component. + +## 5. Frontend — MeetingDetail update + +- [x] 5.1 Import and render `MeetingLifecycle` inside `MeetingDetail.vue` in the `#properties` slot below the `CnDetailCard`. Pass `object` as the `meeting` prop. On `lifecycle-updated` event, call `objectStore.fetchObject('meeting', id)` to refresh the view. + +## 6. Verification + +- [x] 6.1 Run `composer check:strict` — all PHP quality checks pass +- [x] 6.2 Run `npm run lint` — ESLint passes +- [ ] 6.3 Verify lifecycle transition: schedule a draft meeting via UI, open it, pause it, resume it, close it — all state changes persist +- [ ] 6.4 Verify invalid transition (e.g. closing an already-closed meeting) returns 422 and shows no UI button diff --git a/src/components/MeetingLifecycle.vue b/src/components/MeetingLifecycle.vue new file mode 100644 index 00000000..d2d6d4fd --- /dev/null +++ b/src/components/MeetingLifecycle.vue @@ -0,0 +1,172 @@ + + + + + + + + + diff --git a/src/views/MeetingDetail.vue b/src/views/MeetingDetail.vue index 27c3deb0..06d462e9 100644 --- a/src/views/MeetingDetail.vue +++ b/src/views/MeetingDetail.vue @@ -16,6 +16,11 @@ + + +