Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.
Merged
6 changes: 2 additions & 4 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 /).
Expand Down
100 changes: 100 additions & 0 deletions lib/Controller/MeetingController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

/**
* Decidesk Meeting Controller
*
* Controller for meeting-specific operations, particularly lifecycle transitions.
*
* @category Controller
* @package OCA\Decidesk\Controller
*
* @author Conduction Development Team <dev@conductio.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @version GIT: <git-id>
*
* @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": "<schedule|open|pause|resume|adjourn|close>" }
*
* @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
194 changes: 194 additions & 0 deletions lib/Service/MeetingService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
<?php

/**
* Decidesk Meeting Service
*
* Service for managing meeting lifecycle state transitions.
*
* @category Service
* @package OCA\Decidesk\Service
*
* @author Conduction Development Team <dev@conductio.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @version GIT: <git-id>
*
* @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<string, array{from: string[], to: string}>
*/
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
4 changes: 4 additions & 0 deletions lib/Service/SettingsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
);
Expand Down
Loading
Loading