Skip to content
Merged
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
7 changes: 7 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,13 @@ Vrij en open source onder de EUPL-1.2-licentie.
-->
<step>OCA\Dossiq\Repair\RenameDutchSchemaSlugs</step>
<step>OCA\Dossiq\Repair\InitializeSettings</step>
<!--
The shipped register data assigns flow steps to Nextcloud
groups (task-behandelaar -> "behandelaars"). Provision them
before anything imports or runs that data: the completion
gate fails closed on a group that does not exist.
-->
<step>OCA\Dossiq\Repair\ProvisionAssignedGroups</step>
<step>OCA\Dossiq\Repair\LoadDefaultZgwMappings</step>
<step>OCA\Dossiq\Repair\SeedBezwaarBeroepData</step>
<step>OCA\Dossiq\Repair\MigrateWorkflowDefinitions</step>
Expand Down
30 changes: 30 additions & 0 deletions docs/admin/groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
id: groups
title: Groups Dossiq expects
sidebar_position: 5
description: The Nextcloud groups the shipped case flows assign work to, why Dossiq creates them at install, and what to do when your user backend refuses that.
---

# Groups Dossiq expects

The shipped case flow assigns its behandelaar step to the Nextcloud group `behandelaars`. Dossiq creates that group at install and on every upgrade. The step is idempotent: an existing group is left alone.

Membership stays yours. Dossiq never adds users to the group. Add your case handlers yourself:

```bash
occ group:adduser behandelaars <user>
```

## When the group is missing

Without the group, nobody can complete a step assigned to it. The completion signal is refused with "the user who completed the task is not the assignee of the awaiting step". That is deliberate: the gate fails closed rather than letting anyone answer.

Some user backends refuse group creation, LDAP-only setups for example. Dossiq then logs a warning during install. Create the group in your backend and the flow works without further changes.

## Which groups

| Group | Used by | Purpose |
|-------|---------|---------|
| `behandelaars` | shipped case flow, step `task-behandelaar` | case handlers who finish the inhoudelijke voorbereiding |

A test guards this table's code side: a shipped flow cannot assign work to a group the install does not provision.
38 changes: 34 additions & 4 deletions lib/Repair/LoadDefaultZgwMappings.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
* @SuppressWarnings(PHPMD.ExcessiveClassLength)
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*
* @spec openspec/specs/zgw-api-mapping/spec.md
*/
class LoadDefaultZgwMappings implements IRepairStep {
/**
Expand Down Expand Up @@ -71,6 +74,8 @@ public function __construct(
* Get the name of this repair step.
*
* @return string
*
* @spec openspec/specs/zgw-api-mapping/spec.md
*/
public function getName(): string {
return 'Load default ZGW API mapping configurations for Dossiq';
Expand Down Expand Up @@ -113,11 +118,26 @@ public function run(IOutput $output): void {
// Patch existing mappings that have known bugs (e.g., Twig renders false as "").
$this->patchExistingMappings(defaults: $defaults, output: $output);

// Create default test applicaties via ConsumerMapper.
$this->createDefaultApplicaties(output: $output);
// The two seeding phases below are conveniences, and a repair step
// that THROWS aborts the whole install. On a fresh install the schema
// settings this register was configured with can still be empty, and a
// lookup against an empty schema context throws — so each phase warns
// and continues instead of taking the install down with it.
try {
// Create default test applicaties via ConsumerMapper.
$this->createDefaultApplicaties(output: $output);
} catch (\Throwable $e) {
$output->warning('Could not create default applicaties: ' . $e->getMessage());
$this->logger->warning('Dossiq: default applicaties seed failed', ['exception' => $e->getMessage()]);
}

// Create default notification channels.
$this->createDefaultKanalen(output: $output);
try {
// Create default notification channels.
$this->createDefaultKanalen(output: $output);
} catch (\Throwable $e) {
$output->warning('Could not create default notification channels: ' . $e->getMessage());
$this->logger->warning('Dossiq: default kanalen seed failed', ['exception' => $e->getMessage()]);
}

$this->logger->info(
'Dossiq: Default ZGW mappings loaded',
Expand Down Expand Up @@ -1579,6 +1599,16 @@ private function createDefaultKanalen(IOutput $output): void {
return;
}

// On a fresh install the schema settings can still be empty when this
// step runs; a search against an empty schema context throws and would
// abort the install. Skip by name instead.
if ((string)($channelMapping['sourceRegister'] ?? '') === ''
|| (string)($channelMapping['sourceSchema'] ?? '') === ''
) {
$output->info('Kanaal mapping has no register/schema configured yet. Skipping default channels.');
return;
}

try {
$container = \OC::$server;
$objectService = $container->get(
Expand Down
121 changes: 121 additions & 0 deletions lib/Repair/ProvisionAssignedGroups.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

/**
* Dossiq Provision Assigned Groups Repair Step.
*
* The shipped case flow assigns its behandelaar step to the Nextcloud group
* `behandelaars` (dossiq_register.json, node `task-behandelaar`), but nothing
* ever created that group. On a fresh install the completion signal is then
* refused fail-closed ("the user who completed the task is not the assignee
* of the awaiting step"): the assignee gate resolves group membership, and
* membership of a group that does not exist is false for everyone. Creating
* the group is proven sufficient to unblock the shipped journey.
*
* Idempotent: an existing group is left exactly as it is (membership is the
* administrator's, never this step's). The step deliberately does NOT assign
* the flow to a different actor: reassigning shipped work to `admin` would
* hide a provisioning gap behind an over-privileged default.
*
* @category Repair
* @package OCA\Dossiq\Repair
*
* @author Conduction Development Team <info@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* SPDX-License-Identifier: EUPL-1.2
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
*
* @version GIT: <git-id>
*
* @link https://conduction.nl
*
* @spec openspec/specs/case-management/spec.md
*/

declare(strict_types=1);

namespace OCA\Dossiq\Repair;

use OCP\IGroupManager;
use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
use Psr\Log\LoggerInterface;

/**
* Creates the Nextcloud groups the shipped register data assigns work to.
*
* @spec openspec/specs/case-management/spec.md
*/
class ProvisionAssignedGroups implements IRepairStep {

/**
* Every group the shipped register data assigns steps to.
*
* This list is the provisioning counterpart of the literals in
* lib/Settings/dossiq_register.json; ProvisionAssignedGroupsTest sweeps
* the shipped flows and fails when a group is assigned there that this
* list does not provision, so the two cannot drift apart silently.
*
* @var array<int, string>
*/
public const ASSIGNED_GROUPS = ['behandelaars'];

/**
* Constructor.
*
* @param IGroupManager $groupManager Group manager used to provision the groups.
* @param LoggerInterface $logger Logger.
*/
public function __construct(
private readonly IGroupManager $groupManager,
private readonly LoggerInterface $logger,
) {
}//end __construct()

/**
* Get the repair-step display name.
*
* @return string
*
* @spec openspec/specs/case-management/spec.md
*/
public function getName(): string {
return 'Provision the Nextcloud groups Dossiq\'s shipped flows assign work to';
}//end getName()

/**
* Create each missing assigned group.
*
* @param IOutput $output Output sink.
*
* @return void
*
* @spec openspec/specs/case-management/spec.md
*/
public function run(IOutput $output): void {
foreach (self::ASSIGNED_GROUPS as $groupId) {
if ($this->groupManager->groupExists($groupId) === true) {
continue;
}

$group = $this->groupManager->createGroup($groupId);
if ($group === null) {
// A backend can refuse group creation (e.g. LDAP-only setups).
// That must be loud: without the group the shipped flow's
// completion signal is refused for every actor.
$output->warning(
'Dossiq: could not create group "' . $groupId . '"; shipped flow steps assigned to it cannot be completed until an admin creates it.'
);
$this->logger->warning(
'Dossiq: group provisioning refused by the backend',
['group' => $groupId]
);
continue;
}

$output->info('Dossiq: created group "' . $groupId . '" for shipped flow assignments.');
$this->logger->info('Dossiq: provisioned assigned group', ['group' => $groupId]);
}
}//end run()
}//end class
33 changes: 29 additions & 4 deletions lib/Repair/SeedDeadlineMonitoringData.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

namespace OCA\Dossiq\Repair;

use OCA\Dossiq\Repair\Support\RunsUnderSystemIdentity;
use OCA\Dossiq\Service\DeadlineMonitoringSeedDataService;
use OCA\Dossiq\Service\SettingsService;
use OCP\Migration\IOutput;
Expand All @@ -38,9 +39,17 @@
/**
* Repair step that seeds termijnbewaking demo data into OpenRegister.
*
* Runs under OpenRegister's system identity: a repair step executes during
* `occ upgrade` with no session, and OpenRegister refuses Anonymous writes
* per row. Without the identity every row failed, the failures were counted
* as nothing, and the step reported "0 definities (0 overgeslagen)" as
* success — so no TermijnDefinitie ever existed on a fresh install and no
* termijn timer could arm.
*
* @spec openspec/specs/termijnbewaking-schemas/spec.md
*/
class SeedDeadlineMonitoringData implements IRepairStep {
use RunsUnderSystemIdentity;
/**
* Constructor.
*
Expand Down Expand Up @@ -84,8 +93,16 @@ public function run(IOutput $output): void {
}

try {
$result = $this->seedService->seed();
if (($result['success'] ?? false) === true) {
$result = [];
$this->withSystemIdentity(
objectService: $this->settingsService->getObjectService(),
work: function () use (&$result): void {
$result = $this->seedService->seed();
}
);

$failed = (int)($result['failed'] ?? 0);
if (($result['success'] ?? false) === true && $failed === 0) {
$output->info(
'Termijnbewaking seed complete: '
. ((int)($result['definities'] ?? 0)) . ' definities ('
Expand All @@ -94,10 +111,18 @@ public function run(IOutput $output): void {
return;
}

$output->warning('Termijnbewaking seed issue: ' . ((string)($result['message'] ?? 'unknown error')));
// A seed that seeded nothing must not report success-shaped output:
// every failed row is named in the count, so an operator sees a
// broken fresh install instead of "0 definities (0 overgeslagen)".
$output->warning(
'Termijnbewaking seed issue: '
. ((int)($result['definities'] ?? 0)) . ' definities, '
. $failed . ' rijen geweigerd ('
. ((string)($result['message'] ?? 'per-row failures, see the log')) . ')'
);
} catch (\Throwable $e) {
$output->warning('Could not seed termijnbewaking data: ' . $e->getMessage());
$this->logger->error('Dossiq termijnbewaking seed failed', ['exception' => $e->getMessage()]);
}
}//end try
}//end run()
}//end class
61 changes: 49 additions & 12 deletions lib/Repair/SeedVerwerkingsactiviteiten.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
use OCP\Migration\IRepairStep;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;

/**
* Seeds the dossiq verwerkingsactiviteiten catalogue into OpenRegister (draft, upsert-by-code).
Expand Down Expand Up @@ -186,29 +187,65 @@ private function loadCatalogue(): array {
* @return void
*/
private function hydrate(object $entity, array $definition): void {
// OpenRegister renamed the entity's Dutch columns to English (naam ->
// name, beschrijving -> description, ...). QBMapper entities implement
// setters via __call over their DECLARED properties, so calling the
// old setter throws "naam is not a valid attribute" — which is exactly
// how all 7 catalogue rows failed on every fresh install. Each field
// therefore lists its candidate entity properties, newest first, and
// the one the deployed entity actually declares wins.
$stringFields = [
'name' => 'setNaam',
'beschrijving' => 'setBeschrijving',
'doelbinding' => 'setDoelbinding',
'rechtsgrond' => 'setRechtsgrond',
'bewaartermijn' => 'setBewaartermijn',
'name' => ['name', 'naam'],
'beschrijving' => ['description', 'beschrijving'],
'doelbinding' => ['purpose', 'doelbinding'],
'rechtsgrond' => ['legalBasis', 'rechtsgrond'],
'bewaartermijn' => ['retentionPeriod', 'bewaartermijn'],
];
foreach ($stringFields as $field => $setter) {
foreach ($stringFields as $field => $candidates) {
if (isset($definition[$field]) === true && is_string($definition[$field]) === true) {
$entity->{$setter}($definition[$field]);
$this->setFirstDeclared(entity: $entity, candidates: $candidates, value: $definition[$field]);
}
}

$arrayFields = [
'categorieenBetrokkenen' => 'setCategorieenBetrokkenen',
'categorieenPersoonsgegevens' => 'setCategorieenPersoonsgegevens',
'ontvangers' => 'setOntvangers',
'categorieenBetrokkenen' => ['dataSubjectCategories', 'categorieenBetrokkenen'],
'categorieenPersoonsgegevens' => ['personalDataCategories', 'categorieenPersoonsgegevens'],
'ontvangers' => ['recipients', 'ontvangers'],
];
foreach ($arrayFields as $field => $setter) {
foreach ($arrayFields as $field => $candidates) {
if (isset($definition[$field]) === true && is_array($definition[$field]) === true) {
$entity->{$setter}($definition[$field]);
$this->setFirstDeclared(entity: $entity, candidates: $candidates, value: $definition[$field]);
}
}

}//end hydrate()

/**
* Set the first property the deployed entity actually declares.
*
* `method_exists()` cannot answer this (the setters are magic __call), so
* the DECLARED PROPERTY decides. A value none of the candidates fit is
* loud: seeding a catalogue row that silently loses its purpose or
* retention period would ship an incomplete verwerkingsregister.
*
* @param object $entity OR Verwerkingsactiviteit entity.
* @param array<int, string> $candidates Property names, newest first.
* @param string|array<int|string, mixed> $value The value to set.
*
* @return void
*
* @throws RuntimeException When no candidate property is declared.
*/
private function setFirstDeclared(object $entity, array $candidates, string|array $value): void {
foreach ($candidates as $property) {
if (property_exists($entity, $property) === true) {
$entity->{'set' . ucfirst($property)}($value);
return;
}
}

throw new RuntimeException(
'The deployed Verwerkingsactiviteit entity declares none of: ' . implode(', ', $candidates)
);
}//end setFirstDeclared()
}//end class
Loading
Loading