Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
]]></description>
<version>0.1.0</version>
<licence>agpl</licence>
<licence>eupl</licence>
<author mail="info@conduction.nl" homepage="https://www.conduction.nl/">Conduction</author>
<namespace>Decidesk</namespace>
<documentation>
Expand Down Expand Up @@ -65,6 +66,10 @@ Vrij en open source onder de EUPL-1.2-licentie.
</navigation>
</navigations>

<background-jobs>
<job>OCA\Decidesk\BackgroundJob\OverdueActionItemsJob</job>
</background-jobs>

<repair-steps>
<post-migration>
<step>OCA\Decidesk\Repair\InitializeSettings</step>
Expand Down
6 changes: 6 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
149 changes: 149 additions & 0 deletions lib/BackgroundJob/OverdueActionItemsJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php

/**
* Overdue Action Items Background Job
*
* Daily job that marks overdue action items.
*
* @category BackgroundJob
* @package OCA\Decidesk\BackgroundJob
*
* @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-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
120 changes: 120 additions & 0 deletions lib/Controller/DecisionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

/**
* Decidesk Decision Controller
*
* Controller for decision-specific API actions.
*
* @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-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
Loading
Loading