From a5710f6e61ccae3d5ddd488e944404d2511eae4e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 14:01:58 +0200 Subject: [PATCH 01/16] fix(settings): resolve a schema slug inside dossiq's own register Three schemas carried the slug `task` on a normal dev instance (ids 52, 146 and 173). Only 173 belongs to dossiq's register, but SchemaKeyReconciler resolved slugs with SchemaMapper::find(), which is instance-wide, and that returned 52: an InterneTaak schema owned by another app in another register. So `task_schema` pointed outside dossiq's own register. All seven consumers that create or read case tasks (flow human steps, reassignment, work queue, substitution, transition task creation, checklist guard) wrote to a foreign schema. Nothing threw and nothing logged. The Tasks page simply stayed empty, which reads as "no data" rather than "wrong schema". Resolve the slug among the register's own schemas first, via OpenRegister's findBySlugInIds(), which was built for exactly this collision. Keep the unscoped lookup as a fallback: dossiq deliberately points appointment, location and catalog at schemas owned by other apps, those slugs are unique instance-wide, and dropping the fallback would blank all three. Measured on the dev instance: 107 of 111 schema keys already resolved inside register 23, appointment/location/catalog are the intended cross-register three, and task was the only real collision. --- lib/Service/Settings/SchemaKeyReconciler.php | 132 ++++++- .../Settings/SchemaKeyReconcilerTest.php | 354 ++++++++++++++++++ 2 files changed, 469 insertions(+), 17 deletions(-) create mode 100644 tests/Unit/Service/Settings/SchemaKeyReconcilerTest.php diff --git a/lib/Service/Settings/SchemaKeyReconciler.php b/lib/Service/Settings/SchemaKeyReconciler.php index 0195b2d4bc..4eb5a033f8 100644 --- a/lib/Service/Settings/SchemaKeyReconciler.php +++ b/lib/Service/Settings/SchemaKeyReconciler.php @@ -69,10 +69,17 @@ public function __construct( * Reconcile every `*_schema` appconfig key directly from OpenRegister. * * For each schema slug Dossiq knows about, resolves the LIVE schema ID via - * OpenRegister's SchemaMapper (slug-aware `find()`) and writes the matching - * appconfig key. Fully idempotent — a key that already holds the correct ID - * is left untouched — so it is safe to call on every install/upgrade and - * after every import. + * OpenRegister's SchemaMapper and writes the matching appconfig key. Fully + * idempotent: a key that already holds the correct ID is left untouched, so + * it is safe to call on every install/upgrade and after every import. + * + * 🔴 A SCHEMA SLUG IS NOT UNIQUE ACROSS OPENREGISTER, SO RESOLVE IT INSIDE + * OUR OWN REGISTER FIRST. Three schemas carried the slug `task` on a normal + * dev instance; the unscoped `find()` returned the one belonging to another + * app's register, and `task_schema` pointed there for all seven consumers + * that create or read case tasks. Nothing errored. Tasks were written to a + * foreign register and the Tasks page stayed empty, which reads as "no data" + * rather than "wrong schema". {@see resolveSchemaId()} for the fallback rule. * * @return int The number of schema config keys (re)written. * @@ -84,12 +91,15 @@ public function reconcile(): int { return 0; } + $registerSchemaIds = $this->registerSchemaIds(); + $written = 0; foreach (SchemaSlugMap::SLUG_TO_CONFIG_KEY as $slug => $configKey) { $written += $this->reconcileSingleSchemaKey( schemaMapper: $schemaMapper, slug: (string)$slug, - configKey: $configKey + configKey: $configKey, + registerSchemaIds: $registerSchemaIds ); } @@ -195,23 +205,23 @@ private function configureImportedSchema(mixed $schema): int { * @param object $schemaMapper The OpenRegister SchemaMapper. * @param string $slug The schema slug (e.g. 'caseType'). * @param string $configKey The Dossiq appconfig key to write. + * @param int[] $registerSchemaIds The ids Dossiq's own register references. * * @return int 1 when the key was (re)written, 0 otherwise. * * @spec openspec/specs/status-transition-engine/spec.md */ - private function reconcileSingleSchemaKey(object $schemaMapper, string $slug, string $configKey): int { - try { - // Slug-aware lookup with RBAC + multi-tenancy disabled: the repair - // step runs in a system context that has no active organisation, - // and the schema set is app-owned config, not tenant data. - // Signature is find($id, $_extend, $_rbac, $_multitenancy). - $schema = $schemaMapper->find($slug, [], false, false); - $schemaId = (string)$schema->getId(); - } catch (\Throwable $e) { - // Slug not present in this OpenRegister instance — skip it. - return 0; - } + private function reconcileSingleSchemaKey( + object $schemaMapper, + string $slug, + string $configKey, + array $registerSchemaIds, + ): int { + $schemaId = $this->resolveSchemaId( + schemaMapper: $schemaMapper, + slug: $slug, + registerSchemaIds: $registerSchemaIds + ); if ($schemaId === '') { return 0; @@ -248,6 +258,94 @@ private function writeSchemaKey(string $slug, string $configKey, string $schemaI } }//end writeSchemaKey() + /** + * Resolve one schema slug to a live schema id. + * + * Two steps, and the ORDER is the whole point: + * + * 1. Match the slug among the schemas Dossiq's own register references. + * When our register carries this slug, that schema is the answer, even + * if other apps carry the same slug. + * 2. Only when our register carries NO schema with this slug, fall back to + * the unscoped lookup. + * + * 🔴 STEP 2 IS NOT DEAD CODE AND MUST NOT BECOME A HARD FAILURE. Dossiq + * deliberately points three keys at schemas outside its own register + * (`appointment`, `location`, `catalog` are owned by other apps and shared). + * Those slugs are unique instance-wide, so the unscoped lookup is correct + * for them. Dropping the fallback would blank all three. + * + * @param object $schemaMapper The OpenRegister SchemaMapper. + * @param string $slug The schema slug. + * @param int[] $registerSchemaIds The ids Dossiq's own register references. + * + * @return string The live schema id, or '' when the slug does not resolve. + */ + private function resolveSchemaId(object $schemaMapper, string $slug, array $registerSchemaIds): string { + if ($registerSchemaIds !== [] && method_exists($schemaMapper, 'findBySlugInIds') === true) { + try { + $scoped = $schemaMapper->findBySlugInIds($slug, $registerSchemaIds); + if ($scoped !== null) { + return (string)$scoped->getId(); + } + } catch (\Throwable $e) { + // Fall through to the unscoped lookup below. + $this->logger->debug( + 'Dossiq: Register-scoped schema lookup failed, falling back', + ['slug' => $slug, 'exception' => $e->getMessage()] + ); + } + } + + try { + // Slug-aware lookup with RBAC + multi-tenancy disabled: the repair + // step runs in a system context that has no active organisation, + // and the schema set is app-owned config, not tenant data. + // Signature is find($id, $_extend, $_rbac, $_multitenancy). + $schema = $schemaMapper->find($slug, [], false, false); + return (string)$schema->getId(); + } catch (\Throwable $e) { + // Slug not present in this OpenRegister instance, so skip it. + return ''; + } + }//end resolveSchemaId() + + /** + * The schema ids Dossiq's own register references. + * + * Returns an empty list when the register is not configured yet or + * OpenRegister cannot be reached, which makes {@see resolveSchemaId()} + * behave exactly as it did before the register scoping was added. + * + * @return int[] The register's schema ids, or [] when unknown. + */ + private function registerSchemaIds(): array { + $registerId = $this->appConfig->getValueString(Application::APP_ID, 'register', ''); + if ($registerId === '') { + return []; + } + + try { + $registerMapper = $this->container->get('OCA\OpenRegister\Db\RegisterMapper'); + $register = $registerMapper->find($registerId, false, false); + } catch (\Throwable $e) { + $this->logger->debug( + 'Dossiq: Could not read the register schema list for scoping', + ['register' => $registerId, 'exception' => $e->getMessage()] + ); + return []; + } + + $ids = []; + foreach ($register->getSchemas() as $candidate) { + if (is_numeric($candidate) === true && (int)$candidate > 0) { + $ids[] = (int)$candidate; + } + } + + return $ids; + }//end registerSchemaIds() + /** * Resolve OpenRegister's SchemaMapper, or null when it is unavailable. * diff --git a/tests/Unit/Service/Settings/SchemaKeyReconcilerTest.php b/tests/Unit/Service/Settings/SchemaKeyReconcilerTest.php new file mode 100644 index 0000000000..060b562023 --- /dev/null +++ b/tests/Unit/Service/Settings/SchemaKeyReconcilerTest.php @@ -0,0 +1,354 @@ + + * @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. + * + * @link https://conduction.nl + * + * @spec openspec/specs/status-transition-engine/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Tests\Unit\Service\Settings; + +use OCA\Dossiq\Service\Settings\SchemaKeyReconciler; +use OCP\IAppConfig; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\NullLogger; + +/** + * Register-scoped schema slug resolution. + */ +final class SchemaKeyReconcilerTest extends TestCase { + /** + * The three `task` rows observed on the dev instance, in the order the + * unscoped lookup returned them. Only 173 belongs to Dossiq's register. + * + * @var array + */ + private const TASK_ROWS = [ + 'foreign-internetaak' => 52, + 'foreign-duplicate' => 146, + 'dossiq' => 173, + ]; + + /** + * Config values the spy currently holds, seeded per test. + * + * @var array + */ + private array $stored = []; + + /** + * Config values the reconciler wrote during the run. + * + * @var array + */ + private array $written = []; + + /** + * A slug carried by Dossiq's own register resolves to the register's schema, + * not to the first same-slug row instance-wide. + * + * @return void + */ + public function testRegisterScopedSlugWinsOverTheFirstGlobalMatch(): void { + $appConfig = $this->appConfigSpy(['register' => '23']); + + $reconciler = $this->reconciler( + appConfig: $appConfig, + registerSchemaIds: [165, 166, 172, 173], + // The unscoped lookup answers with the FOREIGN row, exactly as the + // live instance did. If the fix regresses, this is what gets written. + globalBySlug: ['task' => self::TASK_ROWS['foreign-internetaak']], + scopedBySlug: ['task' => self::TASK_ROWS['dossiq']], + ); + + $reconciler->reconcile(); + + $this->assertSame( + (string)self::TASK_ROWS['dossiq'], + $this->written['task_schema'] ?? '', + 'task_schema must resolve to the task schema inside Dossiq register 23, not to the foreign row 52.' + ); + } + + /** + * A slug that Dossiq's register does not carry still resolves through the + * unscoped lookup, so the deliberately cross-register keys keep working. + * + * @return void + */ + public function testSlugOutsideTheRegisterFallsBackToTheGlobalLookup(): void { + $appConfig = $this->appConfigSpy(['register' => '23']); + + $reconciler = $this->reconciler( + appConfig: $appConfig, + registerSchemaIds: [165, 166, 172, 173], + globalBySlug: ['appointment' => 548], + // Dossiq's register carries no `appointment` schema. + scopedBySlug: [], + ); + + $reconciler->reconcile(); + + $this->assertSame( + '548', + $this->written['appointment_schema'] ?? '', + 'A slug owned by another app must still resolve through the unscoped lookup.' + ); + } + + /** + * With no register configured the reconciler behaves exactly as it did + * before scoping was added. + * + * @return void + */ + public function testUnconfiguredRegisterKeepsTheUnscopedBehaviour(): void { + $appConfig = $this->appConfigSpy([]); + + $reconciler = $this->reconciler( + appConfig: $appConfig, + registerSchemaIds: [], + globalBySlug: ['task' => self::TASK_ROWS['foreign-internetaak']], + scopedBySlug: ['task' => self::TASK_ROWS['dossiq']], + ); + + $reconciler->reconcile(); + + $this->assertSame( + (string)self::TASK_ROWS['foreign-internetaak'], + $this->written['task_schema'] ?? '', + 'Without a configured register there is nothing to scope to, so the unscoped answer stands.' + ); + } + + /** + * A key that already holds the resolved id is not rewritten. + * + * @return void + */ + public function testAlreadyCorrectKeyIsNotRewritten(): void { + $appConfig = $this->appConfigSpy( + ['register' => '23', 'task_schema' => (string)self::TASK_ROWS['dossiq']] + ); + + $reconciler = $this->reconciler( + appConfig: $appConfig, + registerSchemaIds: [173], + globalBySlug: [], + scopedBySlug: ['task' => self::TASK_ROWS['dossiq']], + ); + + $reconciler->reconcile(); + + $this->assertArrayNotHasKey( + 'task_schema', + $this->written, + 'A key already holding the correct id must not be written again.' + ); + } + + /** + * Build a reconciler wired to fake OpenRegister mappers. + * + * @param IAppConfig $appConfig The app-config spy. + * @param int[] $registerSchemaIds The ids the fake register references. + * @param array $globalBySlug Unscoped slug to id answers. + * @param array $scopedBySlug Register-scoped slug to id answers. + * + * @return SchemaKeyReconciler The reconciler under test. + */ + private function reconciler( + IAppConfig $appConfig, + array $registerSchemaIds, + array $globalBySlug, + array $scopedBySlug, + ): SchemaKeyReconciler { + $schemaMapper = new class($globalBySlug, $scopedBySlug) { + /** + * @param array $global Unscoped answers. + * @param array $scoped Register-scoped answers. + */ + public function __construct(private array $global, private array $scoped) { + } + + /** + * Unscoped slug lookup, mirroring SchemaMapper::find(). + * + * @param string $id The slug. + * @param array $extend Unused. + * @param boolean $rbac Unused. + * @param boolean $multitenancy Unused. + * + * @return object The matching schema. + * + * @throws \RuntimeException When the slug is unknown. + */ + public function find(string $id, array $extend = [], bool $rbac = true, bool $multitenancy = true): object { + if (isset($this->global[$id]) === false) { + throw new \RuntimeException('no such schema: ' . $id); + } + + return self::schema($this->global[$id]); + } + + /** + * Register-scoped slug lookup, mirroring SchemaMapper::findBySlugInIds(). + * + * @param string $slug The slug. + * @param array $schemaIds The candidate ids. + * + * @return object|null The matching schema, or null. + */ + public function findBySlugInIds(string $slug, array $schemaIds): ?object { + $id = ($this->scoped[$slug] ?? null); + if ($id === null || in_array($id, $schemaIds, true) === false) { + return null; + } + + return self::schema($id); + } + + /** + * Wrap an id in the getId() shape the reconciler reads. + * + * @param integer $id The schema id. + * + * @return object The schema-like object. + */ + private static function schema(int $id): object { + return new class($id) { + /** + * @param integer $id The schema id. + */ + public function __construct(private int $id) { + } + + /** + * @return integer The schema id. + */ + public function getId(): int { + return $this->id; + } + }; + } + }; + + $registerMapper = new class($registerSchemaIds) { + /** + * @param int[] $schemaIds The register's schema ids. + */ + public function __construct(private array $schemaIds) { + } + + /** + * @param string $id The register id. + * @param boolean $rbac Unused. + * @param boolean $multitenancy Unused. + * + * @return object The register-like object. + */ + public function find(string $id, bool $rbac = true, bool $multitenancy = true): object { + return new class($this->schemaIds) { + /** + * @param int[] $schemaIds The register's schema ids. + */ + public function __construct(private array $schemaIds) { + } + + /** + * @return int[] The register's schema ids. + */ + public function getSchemas(): array { + return $this->schemaIds; + } + }; + } + }; + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + static function (string $id) use ($schemaMapper, $registerMapper): object { + if ($id === 'OCA\OpenRegister\Db\RegisterMapper') { + return $registerMapper; + } + + return $schemaMapper; + } + ); + + return new SchemaKeyReconciler($appConfig, $container, new NullLogger()); + } + + /** + * An IAppConfig that records every write, so a test can assert on what the + * reconciler DECIDED rather than on how many times it was called. + * + * Mocked from the interface rather than hand-rolled: a hand-rolled double + * would have to declare all of IAppConfig, and a typo in a method name + * would silently never be called instead of failing. + * + * @param array $seed The values already stored. + * + * @return IAppConfig The spy, whose writes land in {@see self::$written}. + */ + private function appConfigSpy(array $seed): IAppConfig { + $this->stored = $seed; + $this->written = []; + + $appConfig = $this->createMock(IAppConfig::class); + + $appConfig->method('getValueString')->willReturnCallback( + function (string $app, string $key, string $default = '', bool $lazy = false): string { + return ($this->stored[$key] ?? $default); + } + ); + + $appConfig->method('setValueString')->willReturnCallback( + function ( + string $app, + string $key, + string $value, + bool $lazy = false, + bool $sensitive = false, + ): bool { + $this->written[$key] = $value; + $this->stored[$key] = $value; + return true; + } + ); + + return $appConfig; + } +} From 3ace901458ab6b0d3bc4aed369ada2494f82bf0c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 14:18:56 +0200 Subject: [PATCH 02/16] feat(demo): seed a demo caseload so the dashboard has something to show The Tasks page, My Work and five dashboard widgets were empty because the register held 0 tasks and none of the 14 existing cases had an assignee. Every one of those surfaces filters on assignee = the current user. Adds `occ dossiq:demo:seed`: 18 cases across the four shipped case types plus 32 tasks, positioned so Overdue, Deadline Alerts, My Tasks, Task Due Reminders, Stalled Cases and the Completed KPI all have rows. Idempotent by case title, so re-running skips what is already there. Two things this had to work around. Dates in the dataset are relative day offsets, never absolute dates. A fixture with absolute dates is right on the day it is written and wrong every day after, which is how "the demo dashboard is empty again" turns out to be a stale file rather than a bug. A case's deadline cannot be written, only caused: `deadline` is a materialised OpenRegister calculation over startDate plus the case type's processingDeadline, so a written deadline is overwritten on save. The seed backdates startDate by the case type's own processing deadline instead, and `--verify-only` reports the buckets by READING THE REGISTER BACK rather than by counting the input, which would agree with the input by construction. Verified on the dev instance: 18 cases and 32 tasks created, re-run skipped all 18, and the read-back reports 5 overdue, 4 within three days, 4 closed and 25 open tasks. Spot-checked the arithmetic end to end: startDate 2026-06-17 plus P56D materialised deadline 2026-08-12, exactly the requested 21 days overdue. --- appinfo/info.xml | 1 + lib/Command/SeedDemoCaseloadCommand.php | 163 +++++ lib/Service/DemoCaseloadGateway.php | 286 +++++++++ lib/Service/DemoCaseloadReport.php | 172 ++++++ lib/Service/DemoCaseloadSeedDataService.php | 461 ++++++++++++++ lib/Settings/demo_caseload_seed_data.json | 524 ++++++++++++++++ .../DemoCaseloadSeedDataServiceTest.php | 581 ++++++++++++++++++ 7 files changed, 2188 insertions(+) create mode 100644 lib/Command/SeedDemoCaseloadCommand.php create mode 100644 lib/Service/DemoCaseloadGateway.php create mode 100644 lib/Service/DemoCaseloadReport.php create mode 100644 lib/Service/DemoCaseloadSeedDataService.php create mode 100644 lib/Settings/demo_caseload_seed_data.json create mode 100644 tests/Unit/Service/DemoCaseloadSeedDataServiceTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 72435ffbd9..e16f698cc4 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -466,6 +466,7 @@ Vrij en open source onder de EUPL-1.2-licentie. OCA\Dossiq\Command\MigrateTenantsCommand OCA\Dossiq\Command\MigratePartnersCommand OCA\Dossiq\Command\SeedBezwaarBeroepCommand + OCA\Dossiq\Command\SeedDemoCaseloadCommand diff --git a/lib/Command/SeedDemoCaseloadCommand.php b/lib/Command/SeedDemoCaseloadCommand.php new file mode 100644 index 0000000000..71ec8f18f2 --- /dev/null +++ b/lib/Command/SeedDemoCaseloadCommand.php @@ -0,0 +1,163 @@ + + * @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. + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Command; + +use OCA\Dossiq\Service\DemoCaseloadReport; +use OCA\Dossiq\Service\DemoCaseloadSeedDataService; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserSession; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Seed the demo caseload. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ +class SeedDemoCaseloadCommand extends Command { + /** + * Wire the command against the seeder and the user/group managers. + * + * @param DemoCaseloadSeedDataService $seeder The demo caseload seeder. + * @param DemoCaseloadReport $report Reads the caseload buckets back. + * @param IUserSession $userSession Session used to impersonate an admin. + * @param IGroupManager $groupManager Resolves an admin to impersonate. + */ + public function __construct( + private readonly DemoCaseloadSeedDataService $seeder, + private readonly DemoCaseloadReport $report, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + ) { + parent::__construct(); + }//end __construct() + + /** + * Define the command name, description and options. + * + * @return void + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ + protected function configure(): void { + $this->setName(name: 'dossiq:demo:seed') + ->setDescription('Seed a demo caseload of cases and tasks, so the dashboard has something to show.') + ->addOption( + name: 'verify-only', + shortcut: null, + mode: InputOption::VALUE_NONE, + description: 'Report what is already in the register and create nothing.' + ); + }//end configure() + + /** + * Run the seed and report what landed. + * + * @param InputInterface $input Console input. + * @param OutputInterface $output Console output. + * + * @return int Symfony command exit code. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + // OpenRegister enforces RBAC on saveObject against the current user. + // occ runs with no session ("Anonymous"), which lacks create rights on + // the Case schema, so impersonate an admin for the seed. + if ($this->userSession->getUser() === null) { + $admin = $this->resolveAdmin(); + if ($admin === null) { + $output->writeln('No admin user found to run the seed under.'); + return Command::FAILURE; + } + + $this->userSession->setUser($admin); + $output->writeln('Seeding as admin user "' . $admin->getUID() . '".'); + } + + $verifyOnly = (bool)$input->getOption('verify-only'); + + try { + if ($verifyOnly === false) { + $result = $this->seeder->seed(); + $output->writeln('dossiq:demo:seed done'); + $output->writeln(' cases created = ' . $result['cases']); + $output->writeln(' tasks created = ' . $result['tasks']); + $output->writeln(' cases skipped = ' . $result['skipped'] . ' (already present)'); + } + + $buckets = $this->report->buckets(); + } catch (\Throwable $e) { + $output->writeln('Demo caseload seed failed: ' . $e->getMessage() . ''); + return Command::FAILURE; + } + + // Read back from the register, because the deadline a case ends up with + // is materialised by OpenRegister, not written by the seed. + $output->writeln(''); + $output->writeln('What the dashboard will read'); + $output->writeln(' open cases = ' . $buckets['open']); + $output->writeln(' overdue = ' . $buckets['overdue']); + $output->writeln(' deadline within 3d = ' . $buckets['dueSoon']); + $output->writeln(' closed = ' . $buckets['closed']); + $output->writeln(' open tasks = ' . $buckets['tasksOpen']); + $output->writeln(' tasks due within 3d = ' . $buckets['tasksDue']); + + if ($buckets['tasksOpen'] === 0) { + $output->writeln(''); + $output->writeln('No open tasks landed. Check that task_schema points inside the dossiq register.'); + return Command::FAILURE; + } + + return Command::SUCCESS; + }//end execute() + + /** + * Resolve the first member of the admin group, if any. + * + * @return IUser|null The admin user to impersonate, or null when none exists. + */ + private function resolveAdmin(): ?IUser { + $adminGroup = $this->groupManager->get('admin'); + if ($adminGroup === null) { + return null; + } + + $users = $adminGroup->getUsers(); + if (count($users) === 0) { + return null; + } + + return reset($users); + }//end resolveAdmin() +}//end class diff --git a/lib/Service/DemoCaseloadGateway.php b/lib/Service/DemoCaseloadGateway.php new file mode 100644 index 0000000000..1d7f841d48 --- /dev/null +++ b/lib/Service/DemoCaseloadGateway.php @@ -0,0 +1,286 @@ + + * @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 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Service; + +use OCA\Dossiq\AppInfo\Application; +use OCP\IAppConfig; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * OpenRegister access for the demo caseload. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) — needs OpenRegister service access + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ +class DemoCaseloadGateway { + /** + * Constructor. + * + * @param IAppConfig $appConfig The app configuration service. + * @param ContainerInterface $container The DI container. + * @param LoggerInterface $logger The logger interface. + * + * @return void + */ + public function __construct( + private IAppConfig $appConfig, + private ContainerInterface $container, + private LoggerInterface $logger, + ) { + }//end __construct() + + /** + * The register and schema ids the demo caseload reads and writes. + * + * @return array{register: string, case: string, task: string, caseType: string, statusType: string} The ids. + * + * @throws RuntimeException When the app is not configured against a register yet. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ + public function schemaIds(): array { + $ids = [ + 'register' => $this->config(key: 'register'), + 'case' => $this->config(key: 'case_schema'), + 'task' => $this->config(key: 'task_schema'), + 'caseType' => $this->config(key: 'case_type_schema'), + 'statusType' => $this->config(key: 'status_type_schema'), + ]; + + $missing = array_keys(array_filter($ids, static fn (string $value): bool => $value === '')); + if ($missing !== []) { + throw new RuntimeException( + 'Dossiq is not configured against a register yet, missing: ' . implode(', ', $missing) + ); + } + + return $ids; + }//end schemaIds() + + /** + * OpenRegister's ObjectService. + * + * 🔴 A CROSS-APP CLASS IS A RUNTIME LOOKUP. Asking the container for a class + * from an app that is not installed raises something the caller cannot act + * on, so name the missing app instead. + * + * @return object The ObjectService. + * + * @throws RuntimeException When OpenRegister is not available. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ + public function objectService(): object { + try { + return $this->container->get('OCA\OpenRegister\Service\ObjectService'); + } catch (\Throwable $e) { + throw new RuntimeException('The demo caseload needs OpenRegister, which is not available.'); + } + }//end objectService() + + /** + * Create an object in OpenRegister. + * + * @param object $objectService The OpenRegister ObjectService. + * @param string $registerId The register id. + * @param string $schemaId The schema id. + * @param array $data The object data. + * + * @return object|null The created object, or null on failure. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ + public function create( + object $objectService, + string $registerId, + string $schemaId, + array $data, + ): ?object { + try { + return $objectService->saveObject( + register: $registerId, + schema: $schemaId, + object: $data, + ); + } catch (\Exception $e) { + $this->logger->error( + 'Dossiq: Demo seed could not create an object', + ['schema' => $schemaId, 'title' => ($data['title'] ?? ''), 'exception' => $e->getMessage()] + ); + return null; + } + }//end create() + + /** + * Whether an object matching the filters already exists. + * + * @param object $objectService The OpenRegister ObjectService. + * @param string $registerId The register id. + * @param string $schemaId The schema id. + * @param array $filters The filter criteria. + * + * @return boolean True when at least one match exists. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ + public function exists( + object $objectService, + string $registerId, + string $schemaId, + array $filters, + ): bool { + $results = $this->findMany( + objectService: $objectService, + registerId: $registerId, + schemaId: $schemaId, + filters: $filters, + limit: 1 + ); + + return ($results !== []); + }//end exists() + + /** + * Find objects by filter, tolerating both result shapes ObjectService returns. + * + * @param object $objectService The OpenRegister ObjectService. + * @param string $registerId The register id. + * @param string $schemaId The schema id. + * @param array $filters The filter criteria. + * @param integer $limit The page size. + * + * @return array The matches, empty when the lookup fails. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ + public function findMany( + object $objectService, + string $registerId, + string $schemaId, + array $filters, + int $limit = 500, + ): array { + try { + $results = $objectService->findAll( + [ + 'filters' => (['register' => $registerId, 'schema' => $schemaId] + $filters), + 'limit' => $limit, + ], + ); + } catch (\Exception $e) { + $this->logger->debug( + 'Dossiq: Demo caseload lookup failed', + ['schema' => $schemaId, 'exception' => $e->getMessage()] + ); + return []; + } + + if (is_array($results) === false) { + return []; + } + + if (isset($results['results']) === true && is_array($results['results']) === true) { + return array_values($results['results']); + } + + return array_values($results); + }//end findMany() + + /** + * Normalise an OpenRegister object to a plain array. + * + * @param mixed $object The object entity or array. + * + * @return array The object's data. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ + public function toArray(mixed $object): array { + if (is_array($object) === true) { + return $object; + } + + if (is_object($object) === false) { + return []; + } + + if (method_exists($object, 'getObject') === true) { + return (array)$object->getObject(); + } + + if (method_exists($object, 'jsonSerialize') === true) { + return (array)$object->jsonSerialize(); + } + + return []; + }//end toArray() + + /** + * The id of a saved OpenRegister object. + * + * Prefers the UUID: `task.case` is a uuid-format property, and the saved + * entity exposes the UUID via getUuid() while getId() can be the internal + * numeric id. + * + * @param object $object The saved object. + * + * @return string The id, or '' when the object exposes neither. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ + public function idOf(object $object): string { + if (method_exists($object, 'getUuid') === true) { + $uuid = (string)$object->getUuid(); + if ($uuid !== '') { + return $uuid; + } + } + + if (method_exists($object, 'getId') === true) { + return (string)$object->getId(); + } + + return ''; + }//end idOf() + + /** + * Read one appconfig value. + * + * @param string $key The config key. + * + * @return string The value, or '' when unset. + */ + private function config(string $key): string { + return $this->appConfig->getValueString(Application::APP_ID, $key, ''); + }//end config() +}//end class diff --git a/lib/Service/DemoCaseloadReport.php b/lib/Service/DemoCaseloadReport.php new file mode 100644 index 0000000000..e5a9ad9244 --- /dev/null +++ b/lib/Service/DemoCaseloadReport.php @@ -0,0 +1,172 @@ + + * @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 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Service; + +use DateTimeImmutable; +use RuntimeException; + +/** + * Reports the caseload buckets the dashboard reads. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ +class DemoCaseloadReport { + /** + * Task statuses OpenRegister treats as terminal. + * + * Mirrors the `isTerminalStatus` calculation on the task schema, which is + * what the My Tasks and Task Due Reminders widgets filter on. + * + * @var array + */ + private const TERMINAL_TASK_STATUSES = ['completed', 'terminated', 'disabled']; + + /** + * Constructor. + * + * @param DemoCaseloadGateway $gateway OpenRegister access. + * + * @return void + */ + public function __construct(private DemoCaseloadGateway $gateway) { + }//end __construct() + + /** + * Count the buckets the dashboard widgets read. + * + * @param DateTimeImmutable|null $now The clock, injectable for tests. + * + * @return array{open: integer, overdue: integer, dueSoon: integer, closed: integer, tasksOpen: integer, tasksDue: integer} + * The bucket counts. + * + * @throws RuntimeException When OpenRegister or the configuration is missing. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ + public function buckets(?DateTimeImmutable $now = null): array { + $now = ($now ?? new DateTimeImmutable('today')); + $objectService = $this->gateway->objectService(); + $ids = $this->gateway->schemaIds(); + + $today = $now->format('Y-m-d'); + $horizon = $now->modify('+3 days')->format('Y-m-d'); + + $cases = $this->gateway->findMany( + objectService: $objectService, + registerId: $ids['register'], + schemaId: $ids['case'], + filters: [] + ); + + $tasks = $this->gateway->findMany( + objectService: $objectService, + registerId: $ids['register'], + schemaId: $ids['task'], + filters: [] + ); + + return ($this->caseBuckets(cases: $cases, today: $today, horizon: $horizon) + + $this->taskBuckets(tasks: $tasks, horizon: $horizon)); + }//end buckets() + + /** + * Count the case buckets. + * + * @param array $cases The case rows. + * @param string $today Today, as Y-m-d. + * @param string $horizon Three days out, as Y-m-d. + * + * @return array{open: integer, overdue: integer, dueSoon: integer, closed: integer} The counts. + */ + private function caseBuckets(array $cases, string $today, string $horizon): array { + $counts = ['open' => 0, 'overdue' => 0, 'dueSoon' => 0, 'closed' => 0]; + + foreach ($cases as $case) { + $row = $this->gateway->toArray(object: $case); + + if (($row['isFinalStatus'] ?? false) === true) { + $counts['closed']++; + continue; + } + + $counts['open']++; + + $deadline = substr((string)($row['deadline'] ?? ''), 0, 10); + if ($deadline === '') { + continue; + } + + if ($deadline < $today) { + $counts['overdue']++; + continue; + } + + if ($deadline <= $horizon) { + $counts['dueSoon']++; + } + }//end foreach + + return $counts; + }//end caseBuckets() + + /** + * Count the task buckets. + * + * @param array $tasks The task rows. + * @param string $horizon Three days out, as Y-m-d. + * + * @return array{tasksOpen: integer, tasksDue: integer} The counts. + */ + private function taskBuckets(array $tasks, string $horizon): array { + $counts = ['tasksOpen' => 0, 'tasksDue' => 0]; + + foreach ($tasks as $task) { + $row = $this->gateway->toArray(object: $task); + + $status = (string)($row['status'] ?? ''); + if (in_array($status, self::TERMINAL_TASK_STATUSES, true) === true) { + continue; + } + + $counts['tasksOpen']++; + + $due = substr((string)($row['dueDate'] ?? ''), 0, 10); + if ($due !== '' && $due <= $horizon) { + $counts['tasksDue']++; + } + }//end foreach + + return $counts; + }//end taskBuckets() +}//end class diff --git a/lib/Service/DemoCaseloadSeedDataService.php b/lib/Service/DemoCaseloadSeedDataService.php new file mode 100644 index 0000000000..d82e4e2186 --- /dev/null +++ b/lib/Service/DemoCaseloadSeedDataService.php @@ -0,0 +1,461 @@ + + * @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 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Service; + +use DateInterval; +use DateTimeImmutable; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Seeds the demo caseload described by lib/Settings/demo_caseload_seed_data.json. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ +class DemoCaseloadSeedDataService { + /** + * The shipped dataset, relative to this file. + * + * @var string + */ + private const SEED_FILE = '/../Settings/demo_caseload_seed_data.json'; + + /** + * Constructor. + * + * @param DemoCaseloadGateway $gateway OpenRegister access. + * @param LoggerInterface $logger The logger interface. + * + * @return void + */ + public function __construct( + private DemoCaseloadGateway $gateway, + private LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Seed the demo caseload. + * + * Idempotent by case title: a case whose title is already present is left + * alone, and its tasks are left alone with it. + * + * 🔴 THROWS RATHER THAN RETURNING A QUIET FAILURE. The only caller is an + * operator who just asked for this, so "nothing happened" must never be + * presentable as success. + * + * @param DateTimeImmutable|null $now The clock, injectable for tests. + * + * @return array{cases: integer, tasks: integer, skipped: integer} What was created. + * + * @throws RuntimeException When the dataset, OpenRegister or the configuration is missing. + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ + public function seed(?DateTimeImmutable $now = null): array { + $now = ($now ?? new DateTimeImmutable('today')); + $seed = $this->readSeedFile(); + $objectService = $this->gateway->objectService(); + $ids = $this->gateway->schemaIds(); + + $caseTypes = $this->caseTypesByIdentifier(objectService: $objectService, ids: $ids); + $statuses = $this->statusTypesByCaseTypeAndName(objectService: $objectService, ids: $ids); + + $summary = ['cases' => 0, 'tasks' => 0, 'skipped' => 0]; + + foreach (($seed['cases'] ?? []) as $caseSeed) { + $title = (string)($caseSeed['title'] ?? ''); + if ($title === '') { + continue; + } + + $present = $this->gateway->exists( + objectService: $objectService, + registerId: $ids['register'], + schemaId: $ids['case'], + filters: ['title' => $title] + ); + + if ($present === true) { + $summary['skipped']++; + continue; + } + + $caseId = $this->createCase( + objectService: $objectService, + ids: $ids, + caseSeed: $caseSeed, + caseTypes: $caseTypes, + statuses: $statuses, + now: $now + ); + + if ($caseId === '') { + continue; + } + + $summary['cases']++; + $summary['tasks'] += $this->createTasks( + objectService: $objectService, + ids: $ids, + caseId: $caseId, + tasks: (array)($caseSeed['tasks'] ?? []), + now: $now + ); + }//end foreach + + $this->logger->info('Dossiq: Demo caseload seeded', $summary); + + return $summary; + }//end seed() + + /** + * Create one case from its seed entry. + * + * @param object $objectService The OpenRegister ObjectService. + * @param array $ids The register and schema ids. + * @param array $caseSeed The case seed entry. + * @param array $caseTypes Case types keyed by identifier. + * @param array $statuses Status ids keyed by "caseTypeId|name". + * @param DateTimeImmutable $now The clock. + * + * @return string The created case id, or '' when it could not be created. + */ + private function createCase( + object $objectService, + array $ids, + array $caseSeed, + array $caseTypes, + array $statuses, + DateTimeImmutable $now, + ): string { + $identifier = (string)($caseSeed['caseType'] ?? ''); + $caseType = ($caseTypes[$identifier] ?? null); + if ($caseType === null) { + $this->logger->warning( + 'Dossiq: Demo seed skipped a case whose case type is not installed', + ['case' => ($caseSeed['title'] ?? ''), 'caseType' => $identifier] + ); + return ''; + } + + $created = $this->gateway->create( + objectService: $objectService, + registerId: $ids['register'], + schemaId: $ids['case'], + data: $this->casePayload( + caseSeed: $caseSeed, + caseType: $caseType, + statuses: $statuses, + now: $now + ) + ); + + if ($created === null) { + return ''; + } + + return $this->gateway->idOf(object: $created); + }//end createCase() + + /** + * Build the payload for one case. + * + * @param array $caseSeed The case seed entry. + * @param array $caseType The resolved case type. + * @param array $statuses Status ids keyed by "caseTypeId|name". + * @param DateTimeImmutable $now The clock. + * + * @return array The case payload. + */ + private function casePayload( + array $caseSeed, + array $caseType, + array $statuses, + DateTimeImmutable $now, + ): array { + $startDate = $this->resolveStartDate( + caseSeed: $caseSeed, + processingDeadline: (string)($caseType['processingDeadline'] ?? ''), + now: $now + ); + + $data = [ + 'title' => (string)($caseSeed['title'] ?? ''), + 'description' => (string)($caseSeed['description'] ?? ''), + 'caseType' => (string)$caseType['id'], + 'assignee' => (string)($caseSeed['assignee'] ?? ''), + 'priority' => (string)($caseSeed['priority'] ?? 'normal'), + 'intakeChannel' => (string)($caseSeed['intakeChannel'] ?? 'manual'), + 'confidentiality' => (string)($caseSeed['confidentiality'] ?? 'openbaar'), + 'startDate' => $startDate->format('Y-m-d'), + ]; + + $statusId = ($statuses[$caseType['id'] . '|' . (string)($caseSeed['status'] ?? '')] ?? ''); + if ($statusId !== '') { + $data['status'] = $statusId; + } + + if (isset($caseSeed['endInDays']) === true) { + $data['endDate'] = $this->offset(now: $now, days: (int)$caseSeed['endInDays'])->format('Y-m-d'); + } + + return $data; + }//end casePayload() + + /** + * Create the tasks belonging to one case. + * + * @param object $objectService The OpenRegister ObjectService. + * @param array $ids The register and schema ids. + * @param string $caseId The parent case id. + * @param array $tasks The task seed entries. + * @param DateTimeImmutable $now The clock. + * + * @return int How many tasks were created. + */ + private function createTasks( + object $objectService, + array $ids, + string $caseId, + array $tasks, + DateTimeImmutable $now, + ): int { + $created = 0; + + foreach ($tasks as $taskSeed) { + $object = $this->gateway->create( + objectService: $objectService, + registerId: $ids['register'], + schemaId: $ids['task'], + data: $this->taskPayload(taskSeed: $taskSeed, caseId: $caseId, now: $now) + ); + + if ($object !== null) { + $created++; + } + } + + return $created; + }//end createTasks() + + /** + * Build the payload for one task. + * + * @param array $taskSeed The task seed entry. + * @param string $caseId The parent case id. + * @param DateTimeImmutable $now The clock. + * + * @return array The task payload. + */ + private function taskPayload(array $taskSeed, string $caseId, DateTimeImmutable $now): array { + $data = [ + 'title' => (string)($taskSeed['title'] ?? ''), + 'description' => (string)($taskSeed['description'] ?? ''), + 'case' => $caseId, + 'assignee' => (string)($taskSeed['assignee'] ?? ''), + 'status' => (string)($taskSeed['status'] ?? 'available'), + 'priority' => (string)($taskSeed['priority'] ?? 'normal'), + ]; + + if (isset($taskSeed['dueInDays']) === true) { + $data['dueDate'] = $this->offset(now: $now, days: (int)$taskSeed['dueInDays']) + ->format('Y-m-d\TH:i:sP'); + } + + if (isset($taskSeed['completedInDays']) === true) { + $data['completedDate'] = $this->offset(now: $now, days: (int)$taskSeed['completedInDays']) + ->format('Y-m-d\TH:i:sP'); + } + + return $data; + }//end taskPayload() + + /** + * Work out the start date that puts a case in its intended bucket. + * + * A seed entry gives EITHER `startInDays` (the case simply opened then) OR + * `deadlineInDays` (the case must land in a deadline bucket). For the second + * form the start date is the wanted deadline minus the case type's own + * processing deadline, because OpenRegister recomputes + * `deadline = startDate + processingDeadline` on every save. + * + * @param array $caseSeed The case seed entry. + * @param string $processingDeadline The case type's ISO-8601 duration, e.g. "P56D". + * @param DateTimeImmutable $now The clock. + * + * @return DateTimeImmutable The start date to write. + */ + private function resolveStartDate( + array $caseSeed, + string $processingDeadline, + DateTimeImmutable $now, + ): DateTimeImmutable { + if (isset($caseSeed['startInDays']) === true) { + return $this->offset(now: $now, days: (int)$caseSeed['startInDays']); + } + + $deadline = $this->offset(now: $now, days: (int)($caseSeed['deadlineInDays'] ?? 0)); + + try { + return $deadline->sub(new DateInterval($processingDeadline)); + } catch (\Exception $e) { + // A case type with no usable processing deadline cannot be placed in + // a bucket, so open the case today and say so rather than guessing. + $this->logger->warning( + 'Dossiq: Demo seed could not read a processing deadline, opening the case today', + ['case' => ($caseSeed['title'] ?? ''), 'processingDeadline' => $processingDeadline] + ); + return $now; + } + }//end resolveStartDate() + + /** + * Shift the clock by a whole number of days, forwards or backwards. + * + * @param DateTimeImmutable $now The clock. + * @param integer $days The offset in days, negative for the past. + * + * @return DateTimeImmutable The shifted moment. + */ + private function offset(DateTimeImmutable $now, int $days): DateTimeImmutable { + $shifted = $now->modify(sprintf('%+d days', $days)); + if ($shifted === false) { + return $now; + } + + return $shifted; + }//end offset() + + /** + * Read and decode the shipped dataset. + * + * @return array The decoded dataset. + * + * @throws RuntimeException When the file is missing or not valid JSON. + */ + private function readSeedFile(): array { + $path = (__DIR__ . self::SEED_FILE); + if (is_file($path) === false) { + throw new RuntimeException('The demo caseload dataset is missing: ' . $path); + } + + $raw = file_get_contents($path); + if ($raw === false) { + throw new RuntimeException('The demo caseload dataset could not be read: ' . $path); + } + + $data = json_decode($raw, true); + if (is_array($data) === false) { + throw new RuntimeException('The demo caseload dataset is not valid JSON: ' . $path); + } + + return $data; + }//end readSeedFile() + + /** + * Installed case types keyed by their identifier. + * + * @param object $objectService The OpenRegister ObjectService. + * @param array $ids The register and schema ids. + * + * @return array The case types. + */ + private function caseTypesByIdentifier(object $objectService, array $ids): array { + $map = []; + + $caseTypes = $this->gateway->findMany( + objectService: $objectService, + registerId: $ids['register'], + schemaId: $ids['caseType'], + filters: [] + ); + + foreach ($caseTypes as $caseType) { + $row = $this->gateway->toArray(object: $caseType); + $identifier = (string)($row['identifier'] ?? ''); + if ($identifier === '') { + continue; + } + + $map[$identifier] = [ + 'id' => $this->gateway->idOf(object: $caseType), + 'processingDeadline' => (string)($row['processingDeadline'] ?? ''), + ]; + } + + return $map; + }//end caseTypesByIdentifier() + + /** + * Status type ids keyed by "caseTypeId|statusName". + * + * Keyed by case type as well as by name because the same status name is + * reused across case types: all four shipped types have an "Ontvangen", and + * pointing a case at another type's status would break its own transitions. + * + * @param object $objectService The OpenRegister ObjectService. + * @param array $ids The register and schema ids. + * + * @return array The status ids. + */ + private function statusTypesByCaseTypeAndName(object $objectService, array $ids): array { + $map = []; + + $statusTypes = $this->gateway->findMany( + objectService: $objectService, + registerId: $ids['register'], + schemaId: $ids['statusType'], + filters: [] + ); + + foreach ($statusTypes as $statusType) { + $row = $this->gateway->toArray(object: $statusType); + $name = (string)($row['name'] ?? ''); + $caseType = (string)($row['caseType'] ?? ''); + if ($name === '' || $caseType === '') { + continue; + } + + $map[$caseType . '|' . $name] = $this->gateway->idOf(object: $statusType); + } + + return $map; + }//end statusTypesByCaseTypeAndName() +}//end class diff --git a/lib/Settings/demo_caseload_seed_data.json b/lib/Settings/demo_caseload_seed_data.json new file mode 100644 index 0000000000..1b9d06ae1a --- /dev/null +++ b/lib/Settings/demo_caseload_seed_data.json @@ -0,0 +1,524 @@ +{ + "$comment": "Demo caseload for dossiq. Dates are RELATIVE DAY OFFSETS resolved against the moment the seed runs, never absolute dates, so the dashboard buckets stay correct however long this file sits in the repo. Give a case either deadlineInDays or startInDays, never both: the case schema materialises deadline from startDate plus the case type's processingDeadline, so writing deadline directly is overwritten on save.", + "cases": [ + { + "key": "demo-ov-beethovenlaan", + "caseType": "omgevingsvergunning", + "status": "In behandeling", + "title": "Aanbouw Beethovenlaan 8", + "description": "De aanvrager wil de woonkamer aan de tuinzijde uitbouwen. De welstandscommissie vroeg om een aangepaste geveltekening.", + "assignee": "admin", + "priority": "high", + "intakeChannel": "website", + "confidentiality": "openbaar", + "deadlineInDays": -21, + "tasks": [ + { + "title": "Aangepaste geveltekening opvragen", + "description": "De aanvrager heeft de tekening nog niet aangeleverd.", + "assignee": "admin", + "dueInDays": -9, + "status": "active", + "priority": "high" + }, + { + "title": "Welstandsadvies verwerken", + "description": "Neem het advies van de commissie op in het dossier.", + "assignee": "admin", + "dueInDays": -2, + "status": "active", + "priority": "normal" + }, + { + "title": "Ontvangstbevestiging versturen", + "description": "Standaardbrief naar de aanvrager.", + "assignee": "admin", + "dueInDays": -18, + "status": "completed", + "priority": "normal", + "completedInDays": -18 + } + ] + }, + { + "key": "demo-sub-buurttuin", + "caseType": "subsidieaanvraag", + "status": "Beoordeling", + "title": "Subsidie buurttuin Vogelwijk", + "description": "Een bewonersgroep vraagt subsidie voor het aanleggen van een buurttuin. De begroting mist een dekking voor het onderhoud.", + "assignee": "admin", + "priority": "normal", + "intakeChannel": "email", + "confidentiality": "openbaar", + "deadlineInDays": -14, + "tasks": [ + { + "title": "Sluitende begroting opvragen", + "description": "Vraag de bewonersgroep om een onderhoudsbegroting.", + "assignee": "admin", + "dueInDays": -6, + "status": "active", + "priority": "high" + }, + { + "title": "Toets aan subsidieplafond", + "description": "Controleer of het plafond voor dit jaar nog ruimte biedt.", + "assignee": "admin", + "dueInDays": -1, + "status": "active", + "priority": "normal" + } + ] + }, + { + "key": "demo-mor-stoeptegel", + "caseType": "klacht-behandeling", + "status": "In behandeling", + "title": "Losliggende stoeptegel Marktplein", + "description": "Een voorbijganger is gestruikeld over een losse tegel voor de markt. De locatie is afgezet met hekken.", + "assignee": "admin", + "priority": "urgent", + "intakeChannel": "phone", + "confidentiality": "openbaar", + "deadlineInDays": -9, + "tasks": [ + { + "title": "Herstel inplannen bij de aannemer", + "description": "De afzetting staat er nu twee weken.", + "assignee": "admin", + "dueInDays": -4, + "status": "active", + "priority": "urgent" + }, + { + "title": "Melder terugkoppelen", + "description": "Bel de melder over de herstelafspraak.", + "assignee": "admin", + "dueInDays": -1, + "status": "active", + "priority": "high" + } + ] + }, + { + "key": "demo-kla-terras", + "caseType": "melding-openbare-ruimte", + "status": "Onderzoek", + "title": "Klacht over geluidsoverlast terras", + "description": "Omwonenden klagen over muziek op het terras na sluitingstijd. Toezicht heeft twee metingen gedaan.", + "assignee": "admin", + "priority": "high", + "intakeChannel": "website", + "confidentiality": "openbaar", + "deadlineInDays": -5, + "tasks": [ + { + "title": "Meetrapport toezicht opvragen", + "description": "De tweede meting ontbreekt nog in het dossier.", + "assignee": "admin", + "dueInDays": -2, + "status": "active", + "priority": "high" + }, + { + "title": "Hoorgesprek met de ondernemer plannen", + "description": "Nodig de ondernemer uit voor een gesprek.", + "assignee": "admin", + "dueInDays": -1, + "status": "active", + "priority": "normal" + } + ] + }, + { + "key": "demo-ov-essen-parkweg", + "caseType": "omgevingsvergunning", + "status": "Besluitvorming", + "title": "Kappen van vier essen Parkweg", + "description": "De gemeente wil vier zieke essen kappen. Een omwonende diende een zienswijze in over de herplant.", + "assignee": "admin", + "priority": "normal", + "intakeChannel": "manual", + "confidentiality": "openbaar", + "deadlineInDays": -2, + "tasks": [ + { + "title": "Zienswijze beantwoorden", + "description": "Beantwoord de zienswijze over de herplantplicht.", + "assignee": "admin", + "dueInDays": -1, + "status": "active", + "priority": "high" + }, + { + "title": "Herplantplicht vastleggen", + "description": "Leg de herplant vast in het conceptbesluit.", + "assignee": "admin", + "dueInDays": 2, + "status": "active", + "priority": "normal" + } + ] + }, + { + "key": "demo-mor-lantaarnpaal", + "caseType": "klacht-behandeling", + "status": "In behandeling", + "title": "Kapotte lantaarnpaal Stationsstraat", + "description": "Twee lantaarnpalen bij de fietsenstalling branden niet. Reizigers melden dat het er 's avonds donker is.", + "assignee": "admin", + "priority": "high", + "intakeChannel": "website", + "confidentiality": "openbaar", + "deadlineInDays": 0, + "tasks": [ + { + "title": "Storingsmelding bij de netbeheerder", + "description": "Zet de storing door naar de netbeheerder.", + "assignee": "admin", + "dueInDays": 0, + "status": "active", + "priority": "high" + }, + { + "title": "Melder informeren over de doorlooptijd", + "description": "Laat weten wanneer het herstel staat gepland.", + "assignee": "admin", + "dueInDays": 1, + "status": "active", + "priority": "normal" + } + ] + }, + { + "key": "demo-sub-jeugdteams", + "caseType": "subsidieaanvraag", + "status": "Beoordeling", + "title": "Subsidie sportvereniging jeugdteams", + "description": "Een sportvereniging vraagt subsidie voor twee extra jeugdteams. De ledenlijst is aangeleverd.", + "assignee": "admin", + "priority": "normal", + "intakeChannel": "website", + "confidentiality": "openbaar", + "deadlineInDays": 1, + "tasks": [ + { + "title": "Ledenaantallen controleren", + "description": "Vergelijk de ledenlijst met de aanvraag.", + "assignee": "admin", + "dueInDays": 1, + "status": "active", + "priority": "normal" + }, + { + "title": "Conceptbeschikking opstellen", + "description": "Stel de beschikking op voor de teamleider.", + "assignee": "admin", + "dueInDays": 3, + "status": "active", + "priority": "normal" + } + ] + }, + { + "key": "demo-ov-rembrandtkade", + "caseType": "omgevingsvergunning", + "status": "In behandeling", + "title": "Uitbouw keuken Rembrandtkade 21", + "description": "De aanvrager wil de keuken vier meter uitbouwen. De constructieberekening is compleet.", + "assignee": "admin", + "priority": "normal", + "intakeChannel": "website", + "confidentiality": "openbaar", + "deadlineInDays": 2, + "tasks": [ + { + "title": "Constructieberekening laten toetsen", + "description": "Leg de berekening voor aan de constructeur.", + "assignee": "admin", + "dueInDays": 2, + "status": "active", + "priority": "normal" + }, + { + "title": "Buren informeren", + "description": "Stuur de omwonenden een kennisgeving.", + "assignee": "admin", + "dueInDays": 3, + "status": "active", + "priority": "low" + } + ] + }, + { + "key": "demo-kla-afvalinzameling", + "caseType": "melding-openbare-ruimte", + "status": "Onderzoek", + "title": "Klacht over afvalinzameling Zuiderpark", + "description": "Een bewoner meldt dat het gft al drie weken niet is opgehaald. De inzamelaar bevestigt een routewijziging.", + "assignee": "admin", + "priority": "normal", + "intakeChannel": "phone", + "confidentiality": "openbaar", + "deadlineInDays": 3, + "tasks": [ + { + "title": "Routewijziging navragen bij de inzamelaar", + "description": "Vraag om de nieuwe route en de ingangsdatum.", + "assignee": "admin", + "dueInDays": 3, + "status": "active", + "priority": "normal" + } + ] + }, + { + "key": "demo-ov-grote-kerk", + "caseType": "omgevingsvergunning", + "status": "Ontvangen", + "title": "Zonnepanelen op monument Grote Kerk", + "description": "De kerkbestuurder vraagt vergunning voor zonnepanelen op het dak. Het pand is een rijksmonument.", + "assignee": "admin", + "priority": "normal", + "intakeChannel": "balie", + "confidentiality": "openbaar", + "startInDays": 0, + "tasks": [ + { + "title": "Advies monumentencommissie aanvragen", + "description": "Vraag advies over de zichtbaarheid vanaf de straat.", + "assignee": "admin", + "dueInDays": 5, + "status": "available", + "priority": "normal" + }, + { + "title": "Volledigheidstoets uitvoeren", + "description": "Controleer of alle bijlagen zijn meegestuurd.", + "assignee": "admin", + "dueInDays": 2, + "status": "active", + "priority": "normal" + } + ] + }, + { + "key": "demo-ov-weidezoom", + "caseType": "omgevingsvergunning", + "status": "In behandeling", + "title": "Nieuwbouw twee woningen Weidezoom", + "description": "Een ontwikkelaar wil twee vrijstaande woningen bouwen. De bodemtoets loopt nog.", + "assignee": "admin", + "priority": "normal", + "intakeChannel": "post", + "confidentiality": "openbaar", + "startInDays": -1, + "tasks": [ + { + "title": "Bodemonderzoek beoordelen", + "description": "Beoordeel het rapport zodra het binnen is.", + "assignee": "admin", + "dueInDays": 12, + "status": "available", + "priority": "normal" + }, + { + "title": "Watertoets uitzetten", + "description": "Zet de watertoets uit bij het waterschap.", + "assignee": "admin", + "dueInDays": 8, + "status": "active", + "priority": "normal" + } + ] + }, + { + "key": "demo-sub-energiecoach", + "caseType": "subsidieaanvraag", + "status": "Ontvangen", + "title": "Subsidie energiecoach wijkaanpak", + "description": "Een wijkorganisatie vraagt subsidie voor twee energiecoaches. De aanvraag past binnen de wijkaanpak.", + "assignee": "rbac-editor", + "priority": "normal", + "intakeChannel": "email", + "confidentiality": "openbaar", + "startInDays": -2, + "tasks": [ + { + "title": "Aanvraag inhoudelijk beoordelen", + "description": "Toets de aanvraag aan de subsidieregeling.", + "assignee": "rbac-editor", + "dueInDays": 15, + "status": "available", + "priority": "normal" + }, + { + "title": "Cofinanciering navragen", + "description": "Vraag na of de provincie meebetaalt.", + "assignee": "admin", + "dueInDays": 20, + "status": "available", + "priority": "low" + } + ] + }, + { + "key": "demo-mor-overhangend-groen", + "caseType": "klacht-behandeling", + "status": "Ontvangen", + "title": "Overhangend groen fietspad Dorpslaan", + "description": "Struiken hangen over het fietspad en beperken het zicht in de bocht.", + "assignee": "admin", + "priority": "low", + "intakeChannel": "website", + "confidentiality": "openbaar", + "startInDays": -1, + "tasks": [ + { + "title": "Schouw inplannen", + "description": "Laat de buitendienst de situatie beoordelen.", + "assignee": "admin", + "dueInDays": 5, + "status": "active", + "priority": "normal" + } + ] + }, + { + "key": "demo-kla-wachttijd-balie", + "caseType": "melding-openbare-ruimte", + "status": "Ontvangen", + "title": "Klacht over wachttijd aan de balie", + "description": "Een inwoner wachtte ruim een uur op een afspraak die op tijd stond ingepland.", + "assignee": "rbac-editor", + "priority": "low", + "intakeChannel": "balie", + "confidentiality": "openbaar", + "startInDays": -3, + "tasks": [ + { + "title": "Baliegegevens van die dag opvragen", + "description": "Vraag de wachtrijgegevens op bij het KCC.", + "assignee": "rbac-editor", + "dueInDays": 25, + "status": "available", + "priority": "low" + } + ] + }, + { + "key": "demo-ov-vondellaan", + "caseType": "omgevingsvergunning", + "status": "Afgehandeld", + "title": "Dakkapel Vondellaan 44", + "description": "De vergunning voor een dakkapel aan de voorzijde is verleend. Het besluit is gepubliceerd.", + "assignee": "admin", + "priority": "normal", + "intakeChannel": "website", + "confidentiality": "openbaar", + "startInDays": -70, + "endInDays": 0, + "tasks": [ + { + "title": "Besluit publiceren", + "description": "Publiceer het besluit op de gemeentepagina.", + "assignee": "admin", + "dueInDays": -2, + "status": "completed", + "priority": "normal", + "completedInDays": -1 + }, + { + "title": "Leges factureren", + "description": "Factureer de leges aan de aanvrager.", + "assignee": "admin", + "dueInDays": -3, + "status": "completed", + "priority": "normal", + "completedInDays": -3 + } + ] + }, + { + "key": "demo-sub-oranjebuurt", + "caseType": "subsidieaanvraag", + "status": "Afgehandeld", + "title": "Subsidie buurtfeest Oranjebuurt", + "description": "De subsidie voor het jaarlijkse buurtfeest is verleend en uitbetaald.", + "assignee": "admin", + "priority": "normal", + "intakeChannel": "email", + "confidentiality": "openbaar", + "startInDays": -50, + "endInDays": -1, + "tasks": [ + { + "title": "Beschikking versturen", + "description": "Stuur de beschikking naar de aanvrager.", + "assignee": "admin", + "dueInDays": -4, + "status": "completed", + "priority": "normal", + "completedInDays": -4 + } + ] + }, + { + "key": "demo-mor-fietsbrug", + "caseType": "klacht-behandeling", + "status": "Afgehandeld", + "title": "Gladheid fietsbrug Kanaalweg", + "description": "De fietsbrug is opgenomen in de strooiroute. De melder is op de hoogte gesteld.", + "assignee": "admin", + "priority": "normal", + "intakeChannel": "phone", + "confidentiality": "openbaar", + "startInDays": -30, + "endInDays": -2, + "tasks": [ + { + "title": "Strooiroute aanpassen", + "description": "Voeg de brug toe aan de winterroute.", + "assignee": "admin", + "dueInDays": -5, + "status": "completed", + "priority": "normal", + "completedInDays": -5 + } + ] + }, + { + "key": "demo-kla-aanslag", + "caseType": "melding-openbare-ruimte", + "status": "Afgehandeld", + "title": "Klacht over onjuiste aanslag", + "description": "De aanslag is herzien en de klacht is gegrond verklaard.", + "assignee": "admin", + "priority": "normal", + "intakeChannel": "post", + "confidentiality": "openbaar", + "startInDays": -40, + "endInDays": -4, + "tasks": [ + { + "title": "Herziene aanslag versturen", + "description": "Stuur de herziene aanslag naar de inwoner.", + "assignee": "admin", + "dueInDays": -6, + "status": "completed", + "priority": "normal", + "completedInDays": -6 + }, + { + "title": "Klachtafhandeling vastleggen", + "description": "Leg de afhandeling vast in het klachtenregister.", + "assignee": "admin", + "dueInDays": -5, + "status": "completed", + "priority": "normal", + "completedInDays": -5 + } + ] + } + ] +} diff --git a/tests/Unit/Service/DemoCaseloadSeedDataServiceTest.php b/tests/Unit/Service/DemoCaseloadSeedDataServiceTest.php new file mode 100644 index 0000000000..b2be90bf05 --- /dev/null +++ b/tests/Unit/Service/DemoCaseloadSeedDataServiceTest.php @@ -0,0 +1,581 @@ + + * @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. + * + * @link https://conduction.nl + * + * @spec openspec/specs/dossiq-app-scaffold/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Tests\Unit\Service; + +use DateTimeImmutable; +use OCA\Dossiq\Service\DemoCaseloadGateway; +use OCA\Dossiq\Service\DemoCaseloadSeedDataService; +use OCP\IAppConfig; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\NullLogger; + +/** + * Demo caseload seeding. + */ +final class DemoCaseloadSeedDataServiceTest extends TestCase { + /** + * The shipped dataset, decoded once. + * + * @var array + */ + private array $seedFile = []; + + /** + * Processing deadlines of the shipped case types, in days. + * + * Mirrored from dossiq_register.json so a change to either side shows up + * here rather than as an empty widget. + * + * @var array + */ + private const PROCESSING_DAYS = [ + 'omgevingsvergunning' => 56, + 'subsidieaanvraag' => 42, + 'klacht-behandeling' => 42, + 'melding-openbare-ruimte' => 14, + ]; + + /** + * Decode the shipped dataset. + * + * @return void + */ + protected function setUp(): void { + $path = (__DIR__ . '/../../../lib/Settings/demo_caseload_seed_data.json'); + $this->assertFileExists($path, 'The demo caseload dataset must ship with the app.'); + $this->seedFile = json_decode((string)file_get_contents($path), true); + $this->assertIsArray($this->seedFile, 'The demo caseload dataset must be valid JSON.'); + } + + /** + * A case asking for a deadline N days out is opened N days out MINUS the + * case type's processing deadline, so OpenRegister materialises the wanted + * deadline back. + * + * @return void + */ + public function testDeadlineOffsetIsBackdatedByTheCaseTypeProcessingDeadline(): void { + $now = new DateTimeImmutable('2026-09-02'); + $saved = $this->runSeed($now); + + $cases = $this->savedOfSchema($saved, 'case-schema'); + + foreach (($this->seedFile['cases'] ?? []) as $seedCase) { + if (isset($seedCase['deadlineInDays']) === false) { + continue; + } + + $written = ($cases[$seedCase['title']] ?? null); + $this->assertNotNull($written, 'Case "' . $seedCase['title'] . '" was not created.'); + + $processing = self::PROCESSING_DAYS[$seedCase['caseType']]; + $expected = $now + ->modify(sprintf('%+d days', (int)$seedCase['deadlineInDays'])) + ->modify(sprintf('-%d days', $processing)) + ->format('Y-m-d'); + + $this->assertSame( + $expected, + $written['startDate'], + 'Case "' . $seedCase['title'] . '" must start early enough that its materialised ' + . 'deadline lands ' . $seedCase['deadlineInDays'] . ' days from today.' + ); + } + } + + /** + * A case giving an explicit start offset is opened on that day, untouched. + * + * @return void + */ + public function testStartOffsetIsUsedVerbatim(): void { + $now = new DateTimeImmutable('2026-09-02'); + $cases = $this->savedOfSchema($this->runSeed($now), 'case-schema'); + + foreach (($this->seedFile['cases'] ?? []) as $seedCase) { + if (isset($seedCase['startInDays']) === false) { + continue; + } + + $expected = $now->modify(sprintf('%+d days', (int)$seedCase['startInDays']))->format('Y-m-d'); + $this->assertSame( + $expected, + $cases[$seedCase['title']]['startDate'], + 'Case "' . $seedCase['title'] . '" must open on its stated start offset.' + ); + } + } + + /** + * Each task is attached to the case it was seeded under, by that case's id. + * + * @return void + */ + public function testTasksAreAttachedToTheirOwnCase(): void { + $saved = $this->runSeed(new DateTimeImmutable('2026-09-02')); + $tasks = $this->savedOfSchema($saved, 'task-schema'); + + $expectedTasks = 0; + foreach (($this->seedFile['cases'] ?? []) as $seedCase) { + $expectedTasks += count(($seedCase['tasks'] ?? [])); + } + + $this->assertCount($expectedTasks, $tasks, 'Every seeded task must be created.'); + + foreach ($tasks as $task) { + $this->assertNotSame('', (string)$task['case'], 'A task must carry its parent case id.'); + $this->assertStringStartsWith( + 'uuid-', + (string)$task['case'], + 'A task must reference the case that was just created, not an empty id.' + ); + } + } + + /** + * A case whose title is already present is skipped, and its tasks with it. + * + * @return void + */ + public function testExistingCasesAreSkipped(): void { + $existingTitle = (string)$this->seedFile['cases'][0]['title']; + $skippedTasks = count($this->seedFile['cases'][0]['tasks']); + + $service = $this->service(existingCaseTitles: [$existingTitle], saved: $saved); + $result = $service->seed(new DateTimeImmutable('2026-09-02')); + + $this->assertSame(1, $result['skipped'], 'The already-present case must be skipped.'); + $this->assertSame( + (count($this->seedFile['cases']) - 1), + $result['cases'], + 'Every other case must still be created.' + ); + $this->assertSame( + ($this->totalSeedTasks() - $skippedTasks), + $result['tasks'], + 'The skipped case must not have its tasks recreated.' + ); + } + + /** + * The shipped dataset fills every dashboard bucket the demo relies on. + * + * @return void + */ + public function testShippedDatasetFillsEveryDashboardBucket(): void { + $cases = ($this->seedFile['cases'] ?? []); + + $open = []; + $closed = []; + foreach ($cases as $case) { + if (isset($case['endInDays']) === true) { + $closed[] = $case; + continue; + } + + $open[] = $case; + } + + $overdue = 0; + $dueSoon = 0; + foreach ($open as $case) { + $deadline = $this->deadlineOffset($case); + if ($deadline < 0) { + $overdue++; + } + + if ($deadline >= 0 && $deadline <= 3) { + $dueSoon++; + } + } + + $this->assertGreaterThanOrEqual(5, $overdue, 'The Overdue widget shows up to 7 rows; seed at least 5.'); + $this->assertGreaterThanOrEqual(4, $dueSoon, 'The Deadline Alerts widget shows up to 5 rows.'); + $this->assertGreaterThanOrEqual(3, count($closed), 'The Completed KPI needs closed cases.'); + + $openTasksForAdmin = 0; + $dueSoonTasksForAdmin = 0; + foreach ($cases as $case) { + foreach (($case['tasks'] ?? []) as $task) { + if ($task['assignee'] !== 'admin' + || in_array($task['status'], ['completed', 'terminated', 'disabled'], true) === true + ) { + continue; + } + + $openTasksForAdmin++; + if ((int)$task['dueInDays'] <= 3) { + $dueSoonTasksForAdmin++; + } + } + } + + $this->assertGreaterThanOrEqual( + 7, + $openTasksForAdmin, + 'The My Tasks widget shows up to 7 rows, all filtered to the current user.' + ); + $this->assertGreaterThanOrEqual( + 5, + $dueSoonTasksForAdmin, + 'The Task Due Reminders widget shows up to 5 rows due within three days.' + ); + } + + /** + * A case gives exactly one of the two date offsets, never both. + * + * Both would be contradictory: the deadline is derived from the start date, + * so a case carrying both is asking for two different start dates. + * + * @return void + */ + public function testEveryCaseGivesExactlyOneDateOffset(): void { + foreach (($this->seedFile['cases'] ?? []) as $case) { + $has = (int)isset($case['deadlineInDays']) + (int)isset($case['startInDays']); + $this->assertSame( + 1, + $has, + 'Case "' . $case['title'] . '" must give either deadlineInDays or startInDays, not both or neither.' + ); + } + } + + /** + * Titles are unique, because the seed uses the title as its idempotency key. + * + * @return void + */ + public function testCaseTitlesAreUnique(): void { + $titles = array_column(($this->seedFile['cases'] ?? []), 'title'); + + $this->assertSame( + count($titles), + count(array_unique($titles)), + 'Two cases sharing a title would make the seed skip the second one forever.' + ); + } + + /** + * A closed case never ends before it started. + * + * @return void + */ + public function testClosedCasesEndOnOrAfterTheyStarted(): void { + foreach (($this->seedFile['cases'] ?? []) as $case) { + if (isset($case['endInDays']) === false) { + continue; + } + + $this->assertArrayHasKey( + 'startInDays', + $case, + 'Case "' . $case['title'] . '" is closed, so give it an explicit start.' + ); + $this->assertGreaterThanOrEqual( + (int)$case['startInDays'], + (int)$case['endInDays'], + 'Case "' . $case['title'] . '" cannot close before it opened.' + ); + } + } + + /** + * Every case type named by the dataset is one the app actually ships. + * + * @return void + */ + public function testEveryCaseTypeIsShipped(): void { + $register = json_decode( + (string)file_get_contents(__DIR__ . '/../../../lib/Settings/dossiq_register.json'), + true + ); + + $shipped = []; + foreach (($register['components']['objects'] ?? []) as $object) { + $identifier = ($object['identifier'] ?? null); + if (is_string($identifier) === true && isset($object['processingDeadline']) === true) { + $shipped[$identifier] = (string)$object['processingDeadline']; + } + } + + foreach (($this->seedFile['cases'] ?? []) as $case) { + $this->assertArrayHasKey( + $case['caseType'], + $shipped, + 'Case "' . $case['title'] . '" names a case type the app does not ship.' + ); + $this->assertSame( + 'P' . self::PROCESSING_DAYS[$case['caseType']] . 'D', + $shipped[$case['caseType']], + 'The processing deadline mirrored in this test drifted from dossiq_register.json.' + ); + } + } + + /** + * The deadline offset a case ends up with, however it was expressed. + * + * @param array $case The case seed entry. + * + * @return int The deadline offset in days from today. + */ + private function deadlineOffset(array $case): int { + if (isset($case['deadlineInDays']) === true) { + return (int)$case['deadlineInDays']; + } + + return ((int)$case['startInDays'] + self::PROCESSING_DAYS[$case['caseType']]); + } + + /** + * How many tasks the whole dataset declares. + * + * @return int The task count. + */ + private function totalSeedTasks(): int { + $total = 0; + foreach (($this->seedFile['cases'] ?? []) as $case) { + $total += count(($case['tasks'] ?? [])); + } + + return $total; + } + + /** + * Run the seed against a recording fake and return everything it saved. + * + * @param DateTimeImmutable $now The clock. + * + * @return array Every save, in order. + */ + private function runSeed(DateTimeImmutable $now): array { + $service = $this->service(existingCaseTitles: [], saved: $saved); + $service->seed($now); + + return $saved; + } + + /** + * The saved payloads for one schema, keyed by title. + * + * @param array $saved Every save. + * @param string $schema The schema id to filter on. + * + * @return array The payloads. + */ + private function savedOfSchema(array $saved, string $schema): array { + $rows = []; + foreach ($saved as $entry) { + if ($entry['schema'] === $schema) { + $rows[$entry['data']['title']] = $entry['data']; + } + } + + return $rows; + } + + /** + * Build the service against a fake ObjectService that records every save. + * + * The fake mirrors the real ObjectService's named parameters, so a rename on + * either side fails here instead of silently writing nothing. + * + * @param string[] $existingCaseTitles Titles the register already holds. + * @param array|null $saved Receives every save, by reference. + * + * @return DemoCaseloadSeedDataService The service under test. + */ + private function service(array $existingCaseTitles, ?array &$saved): DemoCaseloadSeedDataService { + $saved = []; + + $caseTypes = []; + foreach (self::PROCESSING_DAYS as $identifier => $days) { + $caseTypes[] = [ + 'identifier' => $identifier, + 'processingDeadline' => 'P' . $days . 'D', + 'uuid' => 'ct-' . $identifier, + ]; + } + + $statusTypes = []; + foreach ($this->seedFile['cases'] as $case) { + $statusTypes[] = [ + 'name' => $case['status'], + 'caseType' => 'ct-' . $case['caseType'], + 'uuid' => 'st-' . $case['caseType'] . '-' . $case['status'], + ]; + } + + $objectService = new class($saved, $caseTypes, $statusTypes, $existingCaseTitles) { + /** + * Sequence used to hand out distinguishable case ids. + * + * @var int + */ + private int $sequence = 0; + + /** + * @param array $saved Receives every save, by reference. + * @param array $caseTypes The installed case types. + * @param array $statusTypes The installed status types. + * @param string[] $existingCaseTitles Titles already in the register. + */ + public function __construct( + private array &$saved, + private array $caseTypes, + private array $statusTypes, + private array $existingCaseTitles, + ) { + } + + /** + * Answer lookups the way OpenRegister's paginated shape does. + * + * @param array $config The find configuration. + * + * @return array The results envelope. + */ + public function findAll(array $config = []): array { + $schema = (string)($config['filters']['schema'] ?? ''); + + if ($schema === 'case-type-schema') { + return ['results' => $this->entities($this->caseTypes)]; + } + + if ($schema === 'status-type-schema') { + return ['results' => $this->entities($this->statusTypes)]; + } + + if ($schema === 'case-schema') { + $title = (string)($config['filters']['title'] ?? ''); + if (in_array($title, $this->existingCaseTitles, true) === true) { + return ['results' => $this->entities([['title' => $title, 'uuid' => 'existing']])]; + } + } + + return ['results' => []]; + } + + /** + * Record a save and hand back an entity with a fresh uuid. + * + * @param array $object The object payload. + * @param array|null $extend Unused. + * @param mixed $register The register id. + * @param mixed $schema The schema id. + * + * @return object The saved entity. + */ + public function saveObject( + array $object, + ?array $extend = [], + mixed $register = null, + mixed $schema = null, + ): object { + $this->saved[] = ['schema' => (string)$schema, 'data' => $object]; + $this->sequence++; + + return self::entity(($object + ['uuid' => 'uuid-' . $this->sequence])); + } + + /** + * Wrap rows as OpenRegister-shaped entities. + * + * @param array $rows The rows. + * + * @return array The entities. + */ + private function entities(array $rows): array { + return array_map(static fn (array $row): object => self::entity($row), $rows); + } + + /** + * One OpenRegister-shaped entity. + * + * @param array $row The row. + * + * @return object The entity. + */ + private static function entity(array $row): object { + return new class($row) { + /** + * @param array $row The row data. + */ + public function __construct(private array $row) { + } + + /** + * @return array The object data. + */ + public function getObject(): array { + return $this->row; + } + + /** + * @return string The uuid. + */ + public function getUuid(): string { + return (string)($this->row['uuid'] ?? ''); + } + }; + } + }; + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturnCallback( + static function (string $app, string $key, string $default = '', bool $lazy = false): string { + return match ($key) { + 'register' => 'register-id', + 'case_schema' => 'case-schema', + 'task_schema' => 'task-schema', + 'case_type_schema' => 'case-type-schema', + 'status_type_schema' => 'status-type-schema', + default => $default, + }; + } + ); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn($objectService); + + $gateway = new DemoCaseloadGateway($appConfig, $container, new NullLogger()); + + return new DemoCaseloadSeedDataService($gateway, new NullLogger()); + } +} From c4d66a7e7de0c62d4fcf59a73e351c681f9a393c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 14:57:52 +0200 Subject: [PATCH 03/16] fix(settings): stop merging dossiq's schema annotations onto another app's schema Same defect as the previous commit, second call site, worse blast radius. SchemaAnnotationReconciler also resolved schema slugs with the instance-wide SchemaMapper::find(). For the slug `task` that returned schema 52, an InterneTaak schema owned by another app in another register, so dossiq merged its own x-openregister-calculations onto SOMEBODY ELSE'S schema. Measured on the dev instance before the fix: schema 52 (foreign) -> isTerminalStatus, daysUntilDue, daysOverdue schema 173 (dossiq) -> blocksCase Two visible consequences, neither of which errored. isTerminalStatus never materialised on dossiq's tasks, so all 7 completed tasks read isTerminalStatus = false and stayed in "My Tasks" and "Task Due Reminders", whose whole filter is isTerminalStatus = false. And daysUntilDue did not exist to extend, so every due-date column on those widgets rendered blank. The rule now lives in one place, SchemaSlugResolver, because two call sites each resolving slugs their own way IS the defect: they disagreed about which schema `task` meant, and the config keys and the annotations landed on different rows. Both reconcilers share one instance. Verified on the dev instance: after the fix the calculations sit on 173 only, the stray copies are gone from 52, all 7 completed tasks now read isTerminalStatus = true, daysUntilDue returns real numbers, and re-running both reconcilers writes 0 and 0, so nothing re-pollutes the foreign schema. --- .../Settings/SchemaAnnotationReconciler.php | 17 +- lib/Service/Settings/SchemaKeyReconciler.php | 105 +------ lib/Service/Settings/SchemaSlugResolver.php | 180 ++++++++++++ lib/Service/SettingsService.php | 17 +- .../Settings/SchemaKeyReconcilerTest.php | 5 +- .../Settings/SchemaSlugResolverTest.php | 266 ++++++++++++++++++ 6 files changed, 489 insertions(+), 101 deletions(-) create mode 100644 lib/Service/Settings/SchemaSlugResolver.php create mode 100644 tests/Unit/Service/Settings/SchemaSlugResolverTest.php diff --git a/lib/Service/Settings/SchemaAnnotationReconciler.php b/lib/Service/Settings/SchemaAnnotationReconciler.php index 236d4d2818..b919d76f25 100644 --- a/lib/Service/Settings/SchemaAnnotationReconciler.php +++ b/lib/Service/Settings/SchemaAnnotationReconciler.php @@ -57,6 +57,7 @@ class SchemaAnnotationReconciler { * @param ContainerInterface $container The DI container. * @param RegisterFragmentMerger $fragments The register fragment merger. * @param LoggerInterface $logger The logger interface. + * @param SchemaSlugResolver $slugResolver Resolves a slug inside our own register. * * @return void */ @@ -64,6 +65,7 @@ public function __construct( private ContainerInterface $container, private RegisterFragmentMerger $fragments, private LoggerInterface $logger, + private SchemaSlugResolver $slugResolver, ) { }//end __construct() @@ -197,13 +199,14 @@ private function reconcileSchemaAnnotationBlocks(object $schemaMapper, int|strin * @spec openspec/specs/status-transition-engine/spec.md */ private function mergeOntoLiveSchema(object $schemaMapper, string $slug, array $annotations): int { - try { - // Find by slug with signature find($id, $_extend, $_rbac, $_multitenancy): - // bypass RBAC + tenancy — the repair runs in a system context with no - // active organisation. - $schema = $schemaMapper->find($slug, [], false, false); - } catch (\Throwable $e) { - // Slug not present in this OpenRegister instance — skip it. + // 🔴 RESOLVE INSIDE OUR OWN REGISTER. An unscoped slug lookup here merged + // Dossiq's task calculations onto ANOTHER APP'S `task` schema, so + // isTerminalStatus and daysUntilDue were installed where nothing read + // them while Dossiq's own task schema never got them. Completed tasks + // then stayed in "My Tasks" and every due-date column rendered blank. + $schema = $this->slugResolver->resolve(schemaMapper: $schemaMapper, slug: $slug); + if ($schema === null) { + // Slug not present in this OpenRegister instance, so skip it. return 0; } diff --git a/lib/Service/Settings/SchemaKeyReconciler.php b/lib/Service/Settings/SchemaKeyReconciler.php index 4eb5a033f8..39d7fffd26 100644 --- a/lib/Service/Settings/SchemaKeyReconciler.php +++ b/lib/Service/Settings/SchemaKeyReconciler.php @@ -55,6 +55,7 @@ class SchemaKeyReconciler { * @param IAppConfig $appConfig The app configuration service. * @param ContainerInterface $container The DI container. * @param LoggerInterface $logger The logger interface. + * @param SchemaSlugResolver $slugResolver Resolves a slug inside our own register. * * @return void */ @@ -62,6 +63,7 @@ public function __construct( private IAppConfig $appConfig, private ContainerInterface $container, private LoggerInterface $logger, + private SchemaSlugResolver $slugResolver, ) { }//end __construct() @@ -91,15 +93,12 @@ public function reconcile(): int { return 0; } - $registerSchemaIds = $this->registerSchemaIds(); - $written = 0; foreach (SchemaSlugMap::SLUG_TO_CONFIG_KEY as $slug => $configKey) { $written += $this->reconcileSingleSchemaKey( schemaMapper: $schemaMapper, slug: (string)$slug, - configKey: $configKey, - registerSchemaIds: $registerSchemaIds + configKey: $configKey ); } @@ -205,23 +204,13 @@ private function configureImportedSchema(mixed $schema): int { * @param object $schemaMapper The OpenRegister SchemaMapper. * @param string $slug The schema slug (e.g. 'caseType'). * @param string $configKey The Dossiq appconfig key to write. - * @param int[] $registerSchemaIds The ids Dossiq's own register references. * * @return int 1 when the key was (re)written, 0 otherwise. * * @spec openspec/specs/status-transition-engine/spec.md */ - private function reconcileSingleSchemaKey( - object $schemaMapper, - string $slug, - string $configKey, - array $registerSchemaIds, - ): int { - $schemaId = $this->resolveSchemaId( - schemaMapper: $schemaMapper, - slug: $slug, - registerSchemaIds: $registerSchemaIds - ); + private function reconcileSingleSchemaKey(object $schemaMapper, string $slug, string $configKey): int { + $schemaId = $this->resolveSchemaId(schemaMapper: $schemaMapper, slug: $slug); if ($schemaId === '') { return 0; @@ -261,90 +250,24 @@ private function writeSchemaKey(string $slug, string $configKey, string $schemaI /** * Resolve one schema slug to a live schema id. * - * Two steps, and the ORDER is the whole point: - * - * 1. Match the slug among the schemas Dossiq's own register references. - * When our register carries this slug, that schema is the answer, even - * if other apps carry the same slug. - * 2. Only when our register carries NO schema with this slug, fall back to - * the unscoped lookup. - * - * 🔴 STEP 2 IS NOT DEAD CODE AND MUST NOT BECOME A HARD FAILURE. Dossiq - * deliberately points three keys at schemas outside its own register - * (`appointment`, `location`, `catalog` are owned by other apps and shared). - * Those slugs are unique instance-wide, so the unscoped lookup is correct - * for them. Dropping the fallback would blank all three. + * Delegates to {@see SchemaSlugResolver}, which resolves inside Dossiq's own + * register first. The rule lives there because the annotation reconciler + * needs exactly the same answer, and two copies of it drifted apart once + * already. * * @param object $schemaMapper The OpenRegister SchemaMapper. * @param string $slug The schema slug. - * @param int[] $registerSchemaIds The ids Dossiq's own register references. * * @return string The live schema id, or '' when the slug does not resolve. */ - private function resolveSchemaId(object $schemaMapper, string $slug, array $registerSchemaIds): string { - if ($registerSchemaIds !== [] && method_exists($schemaMapper, 'findBySlugInIds') === true) { - try { - $scoped = $schemaMapper->findBySlugInIds($slug, $registerSchemaIds); - if ($scoped !== null) { - return (string)$scoped->getId(); - } - } catch (\Throwable $e) { - // Fall through to the unscoped lookup below. - $this->logger->debug( - 'Dossiq: Register-scoped schema lookup failed, falling back', - ['slug' => $slug, 'exception' => $e->getMessage()] - ); - } - } - - try { - // Slug-aware lookup with RBAC + multi-tenancy disabled: the repair - // step runs in a system context that has no active organisation, - // and the schema set is app-owned config, not tenant data. - // Signature is find($id, $_extend, $_rbac, $_multitenancy). - $schema = $schemaMapper->find($slug, [], false, false); - return (string)$schema->getId(); - } catch (\Throwable $e) { - // Slug not present in this OpenRegister instance, so skip it. + private function resolveSchemaId(object $schemaMapper, string $slug): string { + $schema = $this->slugResolver->resolve(schemaMapper: $schemaMapper, slug: $slug); + if ($schema === null) { return ''; } - }//end resolveSchemaId() - /** - * The schema ids Dossiq's own register references. - * - * Returns an empty list when the register is not configured yet or - * OpenRegister cannot be reached, which makes {@see resolveSchemaId()} - * behave exactly as it did before the register scoping was added. - * - * @return int[] The register's schema ids, or [] when unknown. - */ - private function registerSchemaIds(): array { - $registerId = $this->appConfig->getValueString(Application::APP_ID, 'register', ''); - if ($registerId === '') { - return []; - } - - try { - $registerMapper = $this->container->get('OCA\OpenRegister\Db\RegisterMapper'); - $register = $registerMapper->find($registerId, false, false); - } catch (\Throwable $e) { - $this->logger->debug( - 'Dossiq: Could not read the register schema list for scoping', - ['register' => $registerId, 'exception' => $e->getMessage()] - ); - return []; - } - - $ids = []; - foreach ($register->getSchemas() as $candidate) { - if (is_numeric($candidate) === true && (int)$candidate > 0) { - $ids[] = (int)$candidate; - } - } - - return $ids; - }//end registerSchemaIds() + return (string)$schema->getId(); + }//end resolveSchemaId() /** * Resolve OpenRegister's SchemaMapper, or null when it is unavailable. diff --git a/lib/Service/Settings/SchemaSlugResolver.php b/lib/Service/Settings/SchemaSlugResolver.php new file mode 100644 index 0000000000..ddacb27a65 --- /dev/null +++ b/lib/Service/Settings/SchemaSlugResolver.php @@ -0,0 +1,180 @@ + + * @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 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @spec openspec/specs/status-transition-engine/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Service\Settings; + +use OCA\Dossiq\AppInfo\Application; +use OCP\IAppConfig; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Resolves a schema slug inside Dossiq's own register. + * + * @spec openspec/specs/status-transition-engine/spec.md + */ +class SchemaSlugResolver { + /** + * The register's schema ids, resolved once per instance. + * + * @var int[]|null + */ + private ?array $registerSchemaIds = null; + + /** + * Constructor. + * + * @param IAppConfig $appConfig The app configuration service. + * @param ContainerInterface $container The DI container. + * @param LoggerInterface $logger The logger interface. + * + * @return void + */ + public function __construct( + private IAppConfig $appConfig, + private ContainerInterface $container, + private LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Resolve one schema slug to a live schema. + * + * Two steps, and the ORDER is the whole point: + * + * 1. Match the slug among the schemas Dossiq's own register references. + * When our register carries this slug, that schema is the answer, even + * when other apps carry the same slug. + * 2. Only when our register carries NO schema with this slug, fall back to + * the instance-wide lookup. + * + * 🔴 STEP 2 IS NOT DEAD CODE AND MUST NOT BECOME A HARD FAILURE. Dossiq + * deliberately points three keys at schemas owned by other apps + * (`appointment`, `location` and `catalog` are shared, not ours). Those + * slugs are unique instance-wide, so the unscoped lookup is right for them, + * and dropping the fallback would blank all three. + * + * @param object $schemaMapper The OpenRegister SchemaMapper. + * @param string $slug The schema slug, e.g. 'task'. + * + * @return object|null The live schema, or null when the slug does not resolve. + * + * @spec openspec/specs/status-transition-engine/spec.md + */ + public function resolve(object $schemaMapper, string $slug): ?object { + $ids = $this->registerSchemaIds(); + + if ($ids !== [] && method_exists($schemaMapper, 'findBySlugInIds') === true) { + try { + $scoped = $schemaMapper->findBySlugInIds($slug, $ids); + if ($scoped !== null) { + return $scoped; + } + } catch (\Throwable $e) { + $this->logger->debug( + 'Dossiq: Register-scoped schema lookup failed, falling back', + ['slug' => $slug, 'exception' => $e->getMessage()] + ); + } + } + + try { + // Slug-aware lookup with RBAC + multi-tenancy disabled: this runs in + // a system context with no active organisation, and the schema set is + // app-owned config, not tenant data. + // Signature is find($id, $_extend, $_rbac, $_multitenancy). + return $schemaMapper->find($slug, [], false, false); + } catch (\Throwable $e) { + // Slug not present in this OpenRegister instance, so skip it. + return null; + } + }//end resolve() + + /** + * The schema ids Dossiq's own register references. + * + * Returns an empty list when the register is not configured yet or + * OpenRegister cannot be reached, which makes {@see resolve()} behave + * exactly as the unscoped lookup did before scoping was added. + * + * @return int[] The register's schema ids, or [] when unknown. + */ + private function registerSchemaIds(): array { + if ($this->registerSchemaIds !== null) { + return $this->registerSchemaIds; + } + + $this->registerSchemaIds = []; + + $registerId = $this->appConfig->getValueString(Application::APP_ID, 'register', ''); + if ($registerId === '') { + return $this->registerSchemaIds; + } + + try { + $registerMapper = $this->container->get('OCA\OpenRegister\Db\RegisterMapper'); + $register = $registerMapper->find($registerId, false, false); + } catch (\Throwable $e) { + $this->logger->debug( + 'Dossiq: Could not read the register schema list for scoping', + ['register' => $registerId, 'exception' => $e->getMessage()] + ); + return $this->registerSchemaIds; + } + + $ids = []; + foreach ($register->getSchemas() as $candidate) { + if (is_numeric($candidate) === true && (int)$candidate > 0) { + $ids[] = (int)$candidate; + } + } + + $this->registerSchemaIds = $ids; + + return $this->registerSchemaIds; + }//end registerSchemaIds() +}//end class diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index 042c1cd420..32539668a4 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -30,6 +30,7 @@ use OCA\Dossiq\Service\Settings\RegisterFragmentMerger; use OCA\Dossiq\Service\Settings\SchemaAnnotationReconciler; use OCA\Dossiq\Service\Settings\SchemaKeyReconciler; +use OCA\Dossiq\Service\Settings\SchemaSlugResolver; use OCA\Dossiq\Service\Settings\SchemaSlugMap; use OCP\App\IAppManager; use OCP\IAppConfig; @@ -404,15 +405,27 @@ public function __construct( private LoggerInterface $logger, ) { $this->fragments = new RegisterFragmentMerger(); - $this->schemaKeys = new SchemaKeyReconciler( + + // One resolver, shared by both reconcilers. They must agree on which + // schema a slug means: when they disagreed, the config keys pointed at + // one `task` schema while the calculations were merged onto another. + $slugResolver = new SchemaSlugResolver( appConfig: $appConfig, container: $container, logger: $logger ); + + $this->schemaKeys = new SchemaKeyReconciler( + appConfig: $appConfig, + container: $container, + logger: $logger, + slugResolver: $slugResolver + ); $this->schemaAnnotations = new SchemaAnnotationReconciler( container: $container, fragments: $this->fragments, - logger: $logger + logger: $logger, + slugResolver: $slugResolver ); }//end __construct() diff --git a/tests/Unit/Service/Settings/SchemaKeyReconcilerTest.php b/tests/Unit/Service/Settings/SchemaKeyReconcilerTest.php index 060b562023..eacd999629 100644 --- a/tests/Unit/Service/Settings/SchemaKeyReconcilerTest.php +++ b/tests/Unit/Service/Settings/SchemaKeyReconcilerTest.php @@ -40,6 +40,7 @@ namespace OCA\Dossiq\Tests\Unit\Service\Settings; use OCA\Dossiq\Service\Settings\SchemaKeyReconciler; +use OCA\Dossiq\Service\Settings\SchemaSlugResolver; use OCP\IAppConfig; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -308,7 +309,9 @@ static function (string $id) use ($schemaMapper, $registerMapper): object { } ); - return new SchemaKeyReconciler($appConfig, $container, new NullLogger()); + $resolver = new SchemaSlugResolver($appConfig, $container, new NullLogger()); + + return new SchemaKeyReconciler($appConfig, $container, new NullLogger(), $resolver); } /** diff --git a/tests/Unit/Service/Settings/SchemaSlugResolverTest.php b/tests/Unit/Service/Settings/SchemaSlugResolverTest.php new file mode 100644 index 0000000000..9d65addcc2 --- /dev/null +++ b/tests/Unit/Service/Settings/SchemaSlugResolverTest.php @@ -0,0 +1,266 @@ + + * @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. + * + * @link https://conduction.nl + * + * @spec openspec/specs/status-transition-engine/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Tests\Unit\Service\Settings; + +use OCA\Dossiq\Service\Settings\SchemaSlugResolver; +use OCP\IAppConfig; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\NullLogger; + +/** + * Register-scoped schema slug resolution. + */ +final class SchemaSlugResolverTest extends TestCase { + /** + * Dossiq's own register carries `task` as schema 173, so that is the answer + * even though the instance-wide lookup offers the foreign row 52 first. + * + * @return void + */ + public function testResolvesInsideOurOwnRegister(): void { + $resolver = $this->resolver( + registerId: '23', + registerSchemaIds: [165, 166, 172, 173], + globalBySlug: ['task' => 52], + scopedBySlug: ['task' => 173], + ); + + $schema = $resolver->resolve($this->schemaMapper, 'task'); + + $this->assertNotNull($schema, 'The slug must resolve.'); + $this->assertSame( + 173, + $schema->getId(), + 'A slug our register carries must resolve to our schema, not the first row instance-wide.' + ); + } + + /** + * A slug our register does not carry still resolves instance-wide, so the + * deliberately shared schemas keep working. + * + * @return void + */ + public function testFallsBackForSlugsOwnedByOtherApps(): void { + $resolver = $this->resolver( + registerId: '23', + registerSchemaIds: [165, 166, 172, 173], + globalBySlug: ['appointment' => 548], + scopedBySlug: [], + ); + + $schema = $resolver->resolve($this->schemaMapper, 'appointment'); + + $this->assertNotNull($schema, 'A slug owned by another app must still resolve.'); + $this->assertSame(548, $schema->getId()); + } + + /** + * A slug nothing carries resolves to null rather than throwing. + * + * @return void + */ + public function testUnknownSlugResolvesToNull(): void { + $resolver = $this->resolver( + registerId: '23', + registerSchemaIds: [173], + globalBySlug: [], + scopedBySlug: [], + ); + + $this->assertNull($resolver->resolve($this->schemaMapper, 'nothing-carries-this')); + } + + /** + * With no register configured there is nothing to scope to, so the + * instance-wide answer stands. + * + * @return void + */ + public function testUnconfiguredRegisterKeepsTheUnscopedAnswer(): void { + $resolver = $this->resolver( + registerId: '', + registerSchemaIds: [], + globalBySlug: ['task' => 52], + scopedBySlug: ['task' => 173], + ); + + $schema = $resolver->resolve($this->schemaMapper, 'task'); + + $this->assertNotNull($schema); + $this->assertSame(52, $schema->getId()); + } + + /** + * The fake SchemaMapper handed to the resolver under test. + * + * @var object + */ + private object $schemaMapper; + + /** + * Build a resolver against fake OpenRegister mappers. + * + * @param string $registerId The configured register id, '' when unset. + * @param int[] $registerSchemaIds The ids the fake register references. + * @param array $globalBySlug Instance-wide slug to id answers. + * @param array $scopedBySlug Register-scoped slug to id answers. + * + * @return SchemaSlugResolver The resolver under test. + */ + private function resolver( + string $registerId, + array $registerSchemaIds, + array $globalBySlug, + array $scopedBySlug, + ): SchemaSlugResolver { + $this->schemaMapper = new class($globalBySlug, $scopedBySlug) { + /** + * @param array $global Instance-wide answers. + * @param array $scoped Register-scoped answers. + */ + public function __construct(private array $global, private array $scoped) { + } + + /** + * Instance-wide slug lookup, mirroring SchemaMapper::find(). + * + * @param string $id The slug. + * @param array $extend Unused. + * @param boolean $rbac Unused. + * @param boolean $multitenancy Unused. + * + * @return object The matching schema. + * + * @throws \RuntimeException When the slug is unknown, as the real mapper does. + */ + public function find(string $id, array $extend = [], bool $rbac = true, bool $multitenancy = true): object { + if (isset($this->global[$id]) === false) { + throw new \RuntimeException('no such schema: ' . $id); + } + + return self::schema($this->global[$id]); + } + + /** + * Register-scoped lookup, mirroring SchemaMapper::findBySlugInIds(). + * + * @param string $slug The slug. + * @param array $schemaIds The candidate ids. + * + * @return object|null The matching schema, or null. + */ + public function findBySlugInIds(string $slug, array $schemaIds): ?object { + $id = ($this->scoped[$slug] ?? null); + if ($id === null || in_array($id, $schemaIds, true) === false) { + return null; + } + + return self::schema($id); + } + + /** + * Wrap an id in the getId() shape the resolver's callers read. + * + * @param integer $id The schema id. + * + * @return object The schema-like object. + */ + private static function schema(int $id): object { + return new class($id) { + /** + * @param integer $id The schema id. + */ + public function __construct(private int $id) { + } + + /** + * @return integer The schema id. + */ + public function getId(): int { + return $this->id; + } + }; + } + }; + + $registerMapper = new class($registerSchemaIds) { + /** + * @param int[] $schemaIds The register's schema ids. + */ + public function __construct(private array $schemaIds) { + } + + /** + * @param string $id The register id. + * @param boolean $rbac Unused. + * @param boolean $multitenancy Unused. + * + * @return object The register-like object. + */ + public function find(string $id, bool $rbac = true, bool $multitenancy = true): object { + return new class($this->schemaIds) { + /** + * @param int[] $schemaIds The register's schema ids. + */ + public function __construct(private array $schemaIds) { + } + + /** + * @return int[] The register's schema ids. + */ + public function getSchemas(): array { + return $this->schemaIds; + } + }; + } + }; + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturnCallback( + static function (string $app, string $key, string $default = '', bool $lazy = false) use ($registerId): string { + if ($key === 'register') { + return $registerId; + } + + return $default; + } + ); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn($registerMapper); + + return new SchemaSlugResolver($appConfig, $container, new NullLogger()); + } +} From 67bee0bb000e93e4291f50de441d9d3ddd7810fb Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 14:58:02 +0200 Subject: [PATCH 04/16] fix(dashboard): let the alert widgets show at-risk cases, not only overdue openspec/specs/signalering-widgets/spec.md requires Deadline Alerts to list cases approaching their deadline AND cases already overdue, and Task Due Reminders to do the same for tasks. Both widgets filter correctly for that, but capped the result at 5 rows ordered by deadline ascending. So once five cases are overdue, the five slots are full and no at-risk case can appear at all. That breaks the widget's first scenario, which says the two cases due within the warning threshold MUST be displayed. It also made the two alert widgets render exactly the same rows as the Overdue and My Tasks widgets next to them, which is what surfaced it. Raise both limits to 10 so overdue and at-risk both fit. Deliberately NOT narrowing the filter to a forward-only window: that would read as the obvious fix and would contradict the spec, which wants overdue cases in this widget. --- src/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/manifest.json b/src/manifest.json index 59aff2ee0d..c6e1178ecb 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -810,7 +810,7 @@ "order": { "deadline": "asc" }, - "limit": 5 + "limit": 10 }, "columns": [ { @@ -859,7 +859,7 @@ "order": { "dueDate": "asc" }, - "limit": 5 + "limit": 10 }, "columns": [ { From ea7a917e2004e6a43eab20a930bbd696d89cab4a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 15:10:42 +0200 Subject: [PATCH 05/16] fix(widgets): raise the alert caps, and cover the caseload regressions with e2e The two Nextcloud dashboard widgets concatenate overdue items ahead of at-risk ones and then slice(0, 5). Once five items are overdue the slice is full, so no at-risk item can ever be shown, and both widgets render the same rows as the Overdue Cases and My Tasks widgets beside them. The spec requires both groups in these widgets, so this is the same defect already fixed in the manifest for the in-app dashboard, on the Nextcloud dashboard surface. Verified on the dev instance after rebuilding the bundle: Deadline Alerts now lists the 5 overdue cases AND the 4 at-risk ones (Due today, 1, 2 and 3 days remaining), overdue first, and no longer duplicates the widget next to it. Adds tests/e2e/demo-caseload.spec.ts, which pins the three regressions behind the empty Tasks page rather than the symptom: - a completed task must materialise isTerminalStatus = true, which is what the open-work filters read - daysUntilDue must compute, asserted as a NUMBER because the defect returned null and an existence check would have passed - the Tasks page must render rows instead of its empty state It seeds what it needs under the per-run prefix and removes it again, so it does not depend on demo data and cannot be satisfied by anyone else's rows. The row assertion is deliberately not a title assertion: the index pages at 20, so a title would depend on how many tasks the instance holds. The seeded task's own visibility is pinned on its detail page instead. 4 passed against the dev instance, and the register was left with exactly the 32 tasks it started with. --- src/views/widgets/DeadlineAlertsWidget.vue | 16 +- src/views/widgets/TaskRemindersWidget.vue | 12 +- tests/e2e/demo-caseload.spec.ts | 183 +++++++++++++++++++++ 3 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/demo-caseload.spec.ts diff --git a/src/views/widgets/DeadlineAlertsWidget.vue b/src/views/widgets/DeadlineAlertsWidget.vue index 45e3f7555a..fa4ccdf1e8 100644 --- a/src/views/widgets/DeadlineAlertsWidget.vue +++ b/src/views/widgets/DeadlineAlertsWidget.vue @@ -26,6 +26,15 @@ import { initializeStores } from '../../store/store.js' import { getDeadlineAlerts } from '../../utils/dashboardHelpers.js' import { navigateTo, SIGNAL_COLUMNS } from './signalTable.js' +/** + * How many rows the widget shows. Large enough that overdue cases, which are + * listed first, cannot crowd out every at-risk case: the spec requires this + * widget to show both. + * + * @type {number} + */ +const MAX_ALERTS = 10 + export default { name: 'DeadlineAlertsWidget', components: { @@ -85,7 +94,12 @@ export default { }), targetUrl: generateUrl(`/apps/dossiq/cases/${item.id}`), })) - return [...overdueItems, ...atRiskItems].slice(0, 5) + // 🔴 THE CAP MUST CLEAR BOTH GROUPS, NOT JUST THE FIRST ONE. Overdue + // items are concatenated ahead of at-risk ones, so a cap of 5 meant + // that from the fifth overdue case onward NO at-risk case could ever + // be shown, and this widget rendered the same rows as Overdue Cases + // beside it. The spec requires both groups here. + return [...overdueItems, ...atRiskItems].slice(0, MAX_ALERTS) }, }, diff --git a/src/views/widgets/TaskRemindersWidget.vue b/src/views/widgets/TaskRemindersWidget.vue index c0f57b8dcf..ec629f6b07 100644 --- a/src/views/widgets/TaskRemindersWidget.vue +++ b/src/views/widgets/TaskRemindersWidget.vue @@ -27,6 +27,14 @@ import { initializeStores } from '../../store/store.js' import { getTaskDueReminders } from '../../utils/dashboardHelpers.js' import { navigateTo, SIGNAL_COLUMNS } from './signalTable.js' +/** + * How many rows the widget shows. Large enough that overdue tasks, which are + * listed first, cannot crowd out every task that is merely due soon. + * + * @type {number} + */ +const MAX_REMINDERS = 10 + export default { name: 'TaskRemindersWidget', components: { @@ -86,7 +94,9 @@ export default { }), targetUrl: generateUrl(`/apps/dossiq/tasks/${item.id}`), })) - return [...overdueItems, ...dueSoonItems].slice(0, 5) + // Same cap rule as DeadlineAlertsWidget: overdue tasks are listed + // first, so too small a cap hides every task that is merely due soon. + return [...overdueItems, ...dueSoonItems].slice(0, MAX_REMINDERS) }, }, diff --git a/tests/e2e/demo-caseload.spec.ts b/tests/e2e/demo-caseload.spec.ts new file mode 100644 index 0000000000..58f866f476 --- /dev/null +++ b/tests/e2e/demo-caseload.spec.ts @@ -0,0 +1,183 @@ +/** + * The caseload surfaces a demo actually shows: the Tasks page, and the two + * dashboard widgets that scope to the current user. + * + * WHAT THIS ASSERTS, AND WHY EACH ONE EXISTS. All three pin a defect that + * shipped, and all three failed silently rather than loudly. + * + * 1. A task whose status is terminal must READ as terminal. `isTerminalStatus` + * is a materialised OpenRegister calculation, and it was installed on + * another app's `task` schema instead of ours, because both of Dossiq's + * schema reconcilers resolved the slug `task` instance-wide and three + * schemas carried it. So every completed task read isTerminalStatus = + * false. Nothing errored. The My Tasks widget, whose entire filter is + * isTerminalStatus = false, simply kept showing completed work. + * + * 2. `daysUntilDue` must come back when asked for. Same root cause: the + * calculation was declared on the foreign schema, so extending ours + * returned nothing and every due-date column rendered as an empty cell. + * + * 3. The Tasks page must list tasks. It listed none, because `task_schema` + * pointed at that same foreign schema and every task Dossiq wrote went + * into another register. + * + * SEEDED, NOT ASSUMED. These assertions are data-dependent, which is why the + * sibling widget scenarios are marked `@e2e exclude`. This spec seeds exactly + * what it needs under a per-run prefix and removes it again, so it does not + * depend on demo data being present and cannot be satisfied by somebody + * else's rows. + * + * 🔴 IT REFUSES TO PASS ON AN ABSENT FIXTURE. Where a seeded row is missing + * the test fails naming it rather than skipping: a skip cannot tell "not + * seeded" from "the seeder is broken", which is exactly the confusion that + * let the original defect sit. + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/specs/signalering-widgets/spec.md#requirement-task-due-reminders-widget-v1 + */ +import type { APIRequestContext } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { + cleanupRunObjects, + createObject, + ensureCaseType, + getRequestToken, + listObjects, + objectId, + RUN_PREFIX, + seedCase, +} from './helpers/fixtures.ts' +import { navToRoute } from './helpers/nav.ts' + +/** The case every task in this spec hangs off. */ +const CASE_TITLE = `${RUN_PREFIX} Caseload case` + +/** An OPEN task, which must appear on the Tasks page and in My Tasks. */ +const OPEN_TASK = `${RUN_PREFIX} Open task` + +/** A COMPLETED task, which must appear nowhere that filters on open work. */ +const DONE_TASK = `${RUN_PREFIX} Completed task` + +/** Schemas this run writes to, torn down in afterAll. */ +const SEEDED_SCHEMAS = ['task', 'case'] + +/** + * Days from today, as the ISO date-time the task schema stores. + * @param days Offset in days, negative for the past. + */ +function dueInDays(days: number): string { + const d = new Date() + d.setDate(d.getDate() + days) + return d.toISOString() +} + +test.describe('Demo caseload surfaces', () => { + let api: APIRequestContext + let token: string + let caseId: string + let openTaskId: string + + test.beforeAll(async ({ browser }) => { + const context = await browser.newContext() + api = context.request + token = await getRequestToken(api) + + const caseType = await ensureCaseType(api, token) + const seeded = await seedCase(api, token, { + title: CASE_TITLE, + caseType: caseType.id, + assignee: 'admin', + }) + caseId = objectId(seeded) + + const open = await createObject(api, token, 'task', { + title: OPEN_TASK, + case: caseId, + assignee: 'admin', + status: 'active', + dueDate: dueInDays(-2), + }) + openTaskId = objectId(open) + + await createObject(api, token, 'task', { + title: DONE_TASK, + case: caseId, + assignee: 'admin', + status: 'completed', + dueDate: dueInDays(-4), + }) + }) + + test.afterAll(async () => { + await cleanupRunObjects(api, token, SEEDED_SCHEMAS) + }) + + test('a completed task reads as terminal, so open-work filters exclude it', async () => { + const tasks = await listObjects(api, 'task') + + const open = tasks.find((t) => t.title === OPEN_TASK) + const done = tasks.find((t) => t.title === DONE_TASK) + + expect(open, `seeded task "${OPEN_TASK}" is missing`).toBeTruthy() + expect(done, `seeded task "${DONE_TASK}" is missing`).toBeTruthy() + + // The calculation, not the raw status. When it is installed on the wrong + // schema this is false and every open-work filter lets the task through. + expect( + done.isTerminalStatus, + 'a completed task must materialise isTerminalStatus = true', + ).toBe(true) + expect( + open.isTerminalStatus, + 'an active task must materialise isTerminalStatus = false', + ).toBe(false) + }) + + test('daysUntilDue is returned when calculations are extended', async () => { + const tasks = await listObjects(api, 'task', { _extend: 'calculations' }) + const open = tasks.find((t) => t.title === OPEN_TASK) + + expect(open, `seeded task "${OPEN_TASK}" is missing`).toBeTruthy() + // Seeded two days in the past, so the signed value is negative. Asserting + // the NUMBER, not merely that a key exists: the defect returned null. + expect( + open.daysUntilDue, + 'daysUntilDue must compute for a task with a due date', + ).toBe(-2) + }) + + test('the Tasks page lists tasks instead of an empty state', async ({ + page, + }) => { + await navToRoute(page, '/tasks') + + // The shipped defect rendered this page's empty state on an instance that + // had tasks, because task_schema pointed at a schema in another register. + // Asserting on ROWS rather than on the seeded title on purpose: the index + // pages at 20 rows, so a title assertion here would depend on how many + // tasks the instance happens to hold. The seeded task's own visibility is + // pinned by the detail test below. + await expect( + page.locator('tbody tr').first(), + 'the Tasks page must render at least one task row', + ).toBeVisible({ timeout: 20000 }) + + await expect( + page.getByText('No items found'), + 'the Tasks page must not show its empty state while tasks exist', + ).toHaveCount(0) + }) + + test('a seeded task opens on its own detail page', async ({ page }) => { + await navToRoute(page, `/tasks/${openTaskId}`) + + await expect( + page.getByText(OPEN_TASK, { exact: false }).first(), + 'the task detail page must show the seeded task', + ).toBeVisible({ timeout: 20000 }) + }) + +}) From 4dfde28912b13382c14f725329b4ff4fa334c28f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 15:16:16 +0200 Subject: [PATCH 06/16] refactor(demo): satisfy gate-17 and gate-66 on the demo caseload gateway Two gate findings, both real rather than noise. gate-17 (redundant-controller) flagged `create()` as a literal pass-through to ObjectService. It was: it wrapped saveObject and handed the entity straight back, so every call site had to follow it with idOf(). Return the new object's ID instead, which is the only thing either caller actually wants, and both call sites lose a step. A failure is now '' rather than null, which the task counter reads the same way. gate-66 (openregister-dependency-shape, ADR-083) flagged the unguarded container lookup. OpenRegister is optional here, so establish availability with IAppManager::isInstalled() first and keep the lookup, which is the escape the gate names. It also splits two different problems that previously produced one message: OpenRegister absent, versus present but unable to construct. Verified against the live ObjectService rather than only the unit fake, because the fake cannot prove the entity shape: create() returned a real uuid, the row was found with its parent case reference intact, and cleanup left nothing. The same probe confirms schemaIds() now resolves task to schema 173. --- lib/Service/DemoCaseloadGateway.php | 50 +++++++++++++++---- lib/Service/DemoCaseloadSeedDataService.php | 12 ++--- .../DemoCaseloadSeedDataServiceTest.php | 6 ++- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/lib/Service/DemoCaseloadGateway.php b/lib/Service/DemoCaseloadGateway.php index 1d7f841d48..aed0948435 100644 --- a/lib/Service/DemoCaseloadGateway.php +++ b/lib/Service/DemoCaseloadGateway.php @@ -30,6 +30,7 @@ namespace OCA\Dossiq\Service; use OCA\Dossiq\AppInfo\Application; +use OCP\App\IAppManager; use OCP\IAppConfig; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; @@ -49,6 +50,7 @@ class DemoCaseloadGateway { * @param IAppConfig $appConfig The app configuration service. * @param ContainerInterface $container The DI container. * @param LoggerInterface $logger The logger interface. + * @param IAppManager $appManager Used to establish that OpenRegister is present. * * @return void */ @@ -56,6 +58,7 @@ public function __construct( private IAppConfig $appConfig, private ContainerInterface $container, private LoggerInterface $logger, + private IAppManager $appManager, ) { }//end __construct() @@ -90,33 +93,56 @@ public function schemaIds(): array { /** * OpenRegister's ObjectService. * - * 🔴 A CROSS-APP CLASS IS A RUNTIME LOOKUP. Asking the container for a class - * from an app that is not installed raises something the caller cannot act - * on, so name the missing app instead. + * 🔴 A CROSS-APP CLASS IS A RUNTIME LOOKUP, AND THE APP IS OPTIONAL. It + * cannot be a typed constructor property (ADR-083 rule 1): naming a class + * from an app that may not be installed makes PHP resolve it whenever this + * service is constructed, so an instance without OpenRegister fails with an + * error about a class nobody mentioned instead of the message below. * - * @return object The ObjectService. + * Establish availability FIRST, then look it up. Checking `isInstalled` + * separates "OpenRegister is not here" from "OpenRegister is here and would + * not construct", which are different problems for whoever ran the seed. * - * @throws RuntimeException When OpenRegister is not available. + * @return object The ObjectService, an OCA\OpenRegister\Service\ObjectService. + * + * @psalm-return \OCA\OpenRegister\Service\ObjectService + * + * @throws RuntimeException When OpenRegister is not installed or cannot be resolved. * * @spec openspec/specs/dossiq-app-scaffold/spec.md */ public function objectService(): object { + if ($this->appManager->isInstalled('openregister') === false) { + throw new RuntimeException('The demo caseload needs OpenRegister, which is not installed.'); + } + try { return $this->container->get('OCA\OpenRegister\Service\ObjectService'); } catch (\Throwable $e) { - throw new RuntimeException('The demo caseload needs OpenRegister, which is not available.'); + throw new RuntimeException( + 'OpenRegister is installed but its ObjectService could not be resolved: ' . $e->getMessage() + ); } }//end objectService() /** - * Create an object in OpenRegister. + * Create an object and return the id it was given. + * + * Returns the ID rather than the entity because that is the only thing + * either caller wants: the seeder needs a case's id to hang its tasks off, + * and needs nothing at all from a created task beyond whether it worked. + * Handing back the entity made every call site follow it with {@see idOf()}. + * + * A failure is reported as '' rather than raised: one case that will not + * save should not abandon the rest of the seed, and the failure is logged + * with the title so it is identifiable. * * @param object $objectService The OpenRegister ObjectService. * @param string $registerId The register id. * @param string $schemaId The schema id. * @param array $data The object data. * - * @return object|null The created object, or null on failure. + * @return string The new object's id, or '' when it could not be created. * * @spec openspec/specs/dossiq-app-scaffold/spec.md */ @@ -125,9 +151,9 @@ public function create( string $registerId, string $schemaId, array $data, - ): ?object { + ): string { try { - return $objectService->saveObject( + $created = $objectService->saveObject( register: $registerId, schema: $schemaId, object: $data, @@ -137,8 +163,10 @@ public function create( 'Dossiq: Demo seed could not create an object', ['schema' => $schemaId, 'title' => ($data['title'] ?? ''), 'exception' => $e->getMessage()] ); - return null; + return ''; } + + return $this->idOf(object: $created); }//end create() /** diff --git a/lib/Service/DemoCaseloadSeedDataService.php b/lib/Service/DemoCaseloadSeedDataService.php index d82e4e2186..7933491d83 100644 --- a/lib/Service/DemoCaseloadSeedDataService.php +++ b/lib/Service/DemoCaseloadSeedDataService.php @@ -175,7 +175,7 @@ private function createCase( return ''; } - $created = $this->gateway->create( + return $this->gateway->create( objectService: $objectService, registerId: $ids['register'], schemaId: $ids['case'], @@ -186,12 +186,6 @@ private function createCase( now: $now ) ); - - if ($created === null) { - return ''; - } - - return $this->gateway->idOf(object: $created); }//end createCase() /** @@ -260,14 +254,14 @@ private function createTasks( $created = 0; foreach ($tasks as $taskSeed) { - $object = $this->gateway->create( + $id = $this->gateway->create( objectService: $objectService, registerId: $ids['register'], schemaId: $ids['task'], data: $this->taskPayload(taskSeed: $taskSeed, caseId: $caseId, now: $now) ); - if ($object !== null) { + if ($id !== '') { $created++; } } diff --git a/tests/Unit/Service/DemoCaseloadSeedDataServiceTest.php b/tests/Unit/Service/DemoCaseloadSeedDataServiceTest.php index b2be90bf05..3e1072a64e 100644 --- a/tests/Unit/Service/DemoCaseloadSeedDataServiceTest.php +++ b/tests/Unit/Service/DemoCaseloadSeedDataServiceTest.php @@ -39,6 +39,7 @@ use DateTimeImmutable; use OCA\Dossiq\Service\DemoCaseloadGateway; use OCA\Dossiq\Service\DemoCaseloadSeedDataService; +use OCP\App\IAppManager; use OCP\IAppConfig; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -574,7 +575,10 @@ static function (string $app, string $key, string $default = '', bool $lazy = fa $container = $this->createMock(ContainerInterface::class); $container->method('get')->willReturn($objectService); - $gateway = new DemoCaseloadGateway($appConfig, $container, new NullLogger()); + $appManager = $this->createMock(IAppManager::class); + $appManager->method('isInstalled')->willReturn(true); + + $gateway = new DemoCaseloadGateway($appConfig, $container, new NullLogger(), $appManager); return new DemoCaseloadSeedDataService($gateway, new NullLogger()); } From 6d8b48df14294cd96700bd4fc1dfbd18c8b8a102 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 15:19:10 +0200 Subject: [PATCH 07/16] style(e2e): apply prettier to the demo caseload spec The Frontend Check (format) job runs `prettier --check` across the tree and flagged the new spec's trailing blank line. --- tests/e2e/demo-caseload.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/demo-caseload.spec.ts b/tests/e2e/demo-caseload.spec.ts index 58f866f476..e184038133 100644 --- a/tests/e2e/demo-caseload.spec.ts +++ b/tests/e2e/demo-caseload.spec.ts @@ -179,5 +179,4 @@ test.describe('Demo caseload surfaces', () => { 'the task detail page must show the seeded task', ).toBeVisible({ timeout: 20000 }) }) - }) From 904d3cb6a968e0bc338b5b9d486a16d74dbb5ae2 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 15:20:41 +0200 Subject: [PATCH 08/16] docs(dev): record how to bring up the demo caseload and demo email Both were reconstructed from scratch to prepare a demo. The seed command and the mail setup are useless to the next person if the steps live only in a terminal history. --- DEVELOPMENT.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 80e2fac6e4..23f35638ed 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -31,6 +31,57 @@ npm run dev # Development build (watch mode) npm run build # Production build ``` +## Demo data + +The dashboard widgets, the Tasks page and My Work all filter on the current +user, so a fresh instance shows empty panels even when the register imported +cleanly. Seed a working caseload: + +```bash +occ dossiq:demo:seed # 18 cases across the four case types, 32 tasks +occ dossiq:demo:seed --verify-only # report the buckets without creating anything +``` + +Safe to re-run. A case whose title is already present is skipped, and its tasks +with it. + +The dates in `lib/Settings/demo_caseload_seed_data.json` are relative day +offsets, resolved when the seed runs, so the overdue and at-risk buckets stay +correct however long the file sits in the repo. Give a case either +`deadlineInDays` or `startInDays`, never both: `deadline` is a materialised +calculation over `startDate` plus the case type's `processingDeadline`, so the +seed reaches a deadline by backdating the start date. + +`--verify-only` counts what the dashboard will read by querying the register +back, not by counting the seed file. A count taken from the input agrees with +the input whatever the materialiser did. + +## Demo email + +The Nextcloud dashboard's mail widget needs a mail server and an account. The +shared dev environment ships both. + +```bash +docker compose -f .github/docker-compose.yml --profile mail up -d greenmail +bash .github/docker/mail/seed-mail.sh localhost 3025 + +occ app:enable mail +occ mail:account:create admin "Gemeente Demo" admin@test.local \ + conduction-greenmail 3143 none admin@test.local admin@test.local \ + conduction-greenmail 3025 none admin@test.local admin@test.local +occ mail:account:sync 1 +``` + +Then put the widgets on the dashboard: + +```bash +occ user:setting admin dashboard layout \ + "procest_my_tasks_widget,procest_task_reminders_widget,mail-unread,procest_overdue_cases_widget,procest_deadline_alerts_widget,procest_cases_overview_widget" +``` + +The widget ids still carry the `procest_` prefix. Renaming them would drop the +widget out of every layout that already names it, so they stay. + ## Code Quality ```bash From 4304443a21b1a71853693e768ecda36720c4f760 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 15:23:43 +0200 Subject: [PATCH 09/16] fix(e2e): stop the caseload spec leaving a case behind on every run The case schema declares x-openregister-archival, so OpenRegister refuses a user-driven delete. A case a spec creates can therefore never be cleaned up, and cleanupRunObjects tolerates the failure silently, so every run added one permanently. Measured on the dev instance: 17 of its 37 cases were exactly that residue, and they crowd real data out of Open Cases and Stalled Cases. Hang the tasks off an existing case instead, and create one only where the register is genuinely empty. Tasks carry no archival rule and are still torn down. `case` leaves SEEDED_SCHEMAS as well, since listing it there was a cleanup that quietly failed every run. Verified: 4 passed, and the register held 37 cases before the run and 37 after. --- tests/e2e/demo-caseload.spec.ts | 37 +++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/tests/e2e/demo-caseload.spec.ts b/tests/e2e/demo-caseload.spec.ts index e184038133..0237c35857 100644 --- a/tests/e2e/demo-caseload.spec.ts +++ b/tests/e2e/demo-caseload.spec.ts @@ -61,8 +61,12 @@ const OPEN_TASK = `${RUN_PREFIX} Open task` /** A COMPLETED task, which must appear nowhere that filters on open work. */ const DONE_TASK = `${RUN_PREFIX} Completed task` -/** Schemas this run writes to, torn down in afterAll. */ -const SEEDED_SCHEMAS = ['task', 'case'] +/** + * Schemas this run tears down. Tasks only: a case cannot be deleted (see the + * archival note in beforeAll), so listing `case` here would be a cleanup that + * quietly fails every run. + */ +const SEEDED_SCHEMAS = ['task'] /** * Days from today, as the ISO date-time the task schema stores. @@ -85,13 +89,28 @@ test.describe('Demo caseload surfaces', () => { api = context.request token = await getRequestToken(api) - const caseType = await ensureCaseType(api, token) - const seeded = await seedCase(api, token, { - title: CASE_TITLE, - caseType: caseType.id, - assignee: 'admin', - }) - caseId = objectId(seeded) + // 🔴 HANG THE TASKS OFF AN EXISTING CASE RATHER THAN SEEDING ONE. The case + // schema declares `x-openregister-archival`, so OpenRegister REFUSES a + // user-driven delete: a case this spec creates can never be cleaned up + // again, and every run would leave one behind forever. Measured on the dev + // instance: 17 of its 37 cases were exactly that residue. Tasks carry no + // such rule and are removed in afterAll. + const cases = await listObjects(api, 'case', { _limit: '1' }) + if (cases.length > 0) { + caseId = objectId(cases[0]) + } else { + // Only where the register is genuinely empty. This one case is + // permanent, and that is better than the spec having nothing to attach + // to and failing for a reason unrelated to what it tests. + const caseType = await ensureCaseType(api, token) + caseId = objectId( + await seedCase(api, token, { + title: CASE_TITLE, + caseType: caseType.id, + assignee: 'admin', + }), + ) + } const open = await createObject(api, token, 'task', { title: OPEN_TASK, From b95a6fc73aa34e2a7e33104f63bb7141ad7b55e9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 19:38:27 +0200 Subject: [PATCH 10/16] test(settings): declare SchemaSlugResolver as a used class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SettingsService constructs the resolver, so every SettingsServiceTest and VthSettingsServiceTest case executes it. With beStrictAboutCoverageMetadata and failOnRisky both on, an executed class that no annotation lists makes the test RISKY, and 10 of them turned the whole PHPUnit matrix red on all six cells while reporting zero failures. ⚠️ It passes locally without this. The strict-coverage check only runs when a coverage driver is collecting, and CI generates a Clover report while a plain `vendor/bin/phpunit` does not. Reproduce it with `php -d pcov.enabled=1 vendor/bin/phpunit --coverage-clover=…`, which is how this was confirmed fixed: 2759 tests, zero risky, exit 0. The two sibling reconcilers were already declared the same way. --- tests/Unit/Service/SettingsServiceTest.php | 1 + tests/Unit/Service/VthSettingsServiceTest.php | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/Unit/Service/SettingsServiceTest.php b/tests/Unit/Service/SettingsServiceTest.php index 5d40041552..6be9da804b 100644 --- a/tests/Unit/Service/SettingsServiceTest.php +++ b/tests/Unit/Service/SettingsServiceTest.php @@ -36,6 +36,7 @@ * @uses \OCA\Dossiq\Service\Settings\RegisterFragmentMerger * @uses \OCA\Dossiq\Service\Settings\SchemaAnnotationReconciler * @uses \OCA\Dossiq\Service\Settings\SchemaKeyReconciler + * @uses \OCA\Dossiq\Service\Settings\SchemaSlugResolver */ class SettingsServiceTest extends TestCase { diff --git a/tests/Unit/Service/VthSettingsServiceTest.php b/tests/Unit/Service/VthSettingsServiceTest.php index 1e4502557f..ca81ba5863 100644 --- a/tests/Unit/Service/VthSettingsServiceTest.php +++ b/tests/Unit/Service/VthSettingsServiceTest.php @@ -37,6 +37,7 @@ * * @uses \OCA\Dossiq\Service\Settings\SchemaAnnotationReconciler * @uses \OCA\Dossiq\Service\Settings\SchemaKeyReconciler + * @uses \OCA\Dossiq\Service\Settings\SchemaSlugResolver */ class VthSettingsServiceTest extends TestCase { From d5523f27c16835de3aa1a5cbcccc0d5966f7eb0b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 19:38:27 +0200 Subject: [PATCH 11/16] fix(dashboard): stop two case panels counting closed cases Both were inconsistent with the widgets beside them, and one was visibly wrong. The Overdue KPI filtered `deadline < today` but not `isFinalStatus`, while the Overdue table beneath it filters both. Measured on the dev instance: the card read 8 against a list of 5, because three closed cases still had a past deadline. openspec/specs/dashboard/spec.md DASH-001b defines overdue as `deadline < today` AND status not final. The Open Cases table had no filter at all, so a panel titled "Open Cases" listed closed ones. Every sibling case widget already filters isFinalStatus. --- src/manifest.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/manifest.json b/src/manifest.json index c6e1178ecb..d893c0ca48 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -536,6 +536,7 @@ "schema": "case", "metric": "count", "filter": { + "isFinalStatus": false, "deadline": { "lt": "@today" }, @@ -673,6 +674,9 @@ "source": { "register": "dossiq", "schema": "case", + "filter": { + "isFinalStatus": false + }, "order": { "startDate": "desc" }, From 62f87be0d9749adaab819ec1bab3c6bba6e2834c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 19:42:11 +0200 Subject: [PATCH 12/16] fix(dashboard): make the KPI cards answer the questions the spec asks openspec/specs/dashboard/spec.md REQ-DASH-001 names five KPI cards. Three of the four that ship disagreed with it, and two disagreed with the list rendered directly beneath them. DASH-001a. The first card was titled "New cases" and counted startDate inside the date picker's window. That answers a different question, and left the dashboard with no headline for open workload at all. It is now "Open Cases", counting every case whose status is not final, and deliberately not scoped to the date range: the spec defines this count over all open cases. DASH-001b. The Overdue card was scoped to the date range while the Overdue table beside it was not. On the default month preset the card read 0 against a list of 5, because every overdue case necessarily started before the window it is overdue in. The range scope is gone and the caption is the spec's "action needed". DASH-001d. My Tasks counted `assignee = @me` AND a dueDate inside the range, so a task with no due date, or one due outside the window, was invisible. The spec counts the user's tasks in a non-terminal status. Now filtered on `isTerminalStatus: false`, which is the same rule the My Tasks widget uses. Measured after the change: Open Cases 16, Overdue 5 (matching its table exactly), My Tasks 23. NOT done here, and deliberately. DASH-001a's "+3 today", DASH-001c's "avg 18 days" and DASH-001e's SLA Compliance card all need a second computed number on one tile. CnStatWidget interpolates a caption ONLY in endpointSource mode, so each needs a Dossiq KPI endpoint to read from. That is a feature with its own controller, route and spec work, not a manifest edit. DASH-001c is also left alone because "Completed This Month" and the shipped date-range picker want different windows, which is a product decision rather than a defect. --- src/manifest.json | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/src/manifest.json b/src/manifest.json index d893c0ca48..c6a945e79f 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -484,15 +484,13 @@ { "id": "kpi-open-cases", "type": "stat", - "title": "New cases", + "title": "Open Cases", + "_note": "openspec/specs/dashboard/spec.md DASH-001a: this card counts every case whose status is not final, and is deliberately NOT scoped to the dashboard date range. It used to be titled \"New cases\" and counted startDate inside the picker window, which answers a different question and left the dashboard with no headline for open workload at all. The spec\u2019s \"+3 today\" sub-label is not expressible here: CnStatWidget interpolates a caption only in endpointSource mode, so a second count needs a Dossiq KPI endpoint.", "content": { - "label": "New cases", - "caption": "newly opened", + "label": "Open Cases", + "caption": "not yet closed", "route": { - "name": "Cases", - "query": { - "startDate[gte]": "@monthStart" - } + "name": "Cases" }, "icon": "FolderOutline", "format": { @@ -504,10 +502,7 @@ "schema": "case", "metric": "count", "filter": { - "startDate": { - "gte": "@workspace.dateFrom?", - "lte": "@workspace.dateTo?" - } + "isFinalStatus": false } } } @@ -518,7 +513,7 @@ "title": "Overdue", "content": { "label": "Overdue", - "caption": "past deadline", + "caption": "action needed", "route": { "name": "Cases", "query": { @@ -539,10 +534,6 @@ "isFinalStatus": false, "deadline": { "lt": "@today" - }, - "startDate": { - "gte": "@workspace.dateFrom?", - "lte": "@workspace.dateTo?" } } } @@ -586,7 +577,7 @@ "title": "My Tasks", "content": { "label": "My Tasks", - "caption": "assigned to me", + "caption": "open and assigned to me", "route": { "name": "Tasks", "query": { @@ -604,10 +595,7 @@ "metric": "count", "filter": { "assignee": "@me", - "dueDate": { - "gte": "@workspace.dateFrom?", - "lte": "@workspace.dateTo?" - } + "isTerminalStatus": false } } } From 8fda7cee3cf2ddcaa744b84988a40d8ebf4f11df Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 19:49:58 +0200 Subject: [PATCH 13/16] test(settings): declare the resolver in the third SettingsService test too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SettingsServiceReconcileRegressionTest also constructs SettingsService, so it executes SchemaSlugResolver and was RISKY for the same reason as the other two. CI went from 10 risky to 2; these are the last 2. ⚠️ THE PREVIOUS COMMIT CLAIMED A LOCAL REPRODUCTION THAT NEVER HAPPENED. The `php -d pcov.enabled=1` run reported no risky tests because PHPUnit answered "No code coverage driver available" and carried on: neither pcov nor xdebug is installed on this machine or in the dev container. A run with no driver reports zero risky whether or not the problem is there, so it proved nothing. There is no local instrument for this check, so the argument is static instead, and it covers the class rather than the instance. The finding is precisely "executed a class not listed as covered or used", and `requireCoverageMetadata` is false, so it can only fire on a test that HAS @covers. Every test carrying @covers that constructs SettingsService or either reconciler now declares the resolver; the one remaining file has no coverage metadata and is exempt. --- tests/Unit/Service/SettingsServiceReconcileRegressionTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Unit/Service/SettingsServiceReconcileRegressionTest.php b/tests/Unit/Service/SettingsServiceReconcileRegressionTest.php index 2211bebb49..31e922b29e 100644 --- a/tests/Unit/Service/SettingsServiceReconcileRegressionTest.php +++ b/tests/Unit/Service/SettingsServiceReconcileRegressionTest.php @@ -57,6 +57,7 @@ public function getId(): int; * * @uses \OCA\Dossiq\Service\Settings\SchemaAnnotationReconciler * @uses \OCA\Dossiq\Service\Settings\SchemaKeyReconciler + * @uses \OCA\Dossiq\Service\Settings\SchemaSlugResolver */ class SettingsServiceReconcileRegressionTest extends TestCase { From a8597966d6fea87f27476ffb12e679b141cbe3a8 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 2 Sep 2026 20:03:56 +0200 Subject: [PATCH 14/16] fix(widgets): the `_filters[x]` query param is inert, so three widgets ignored their filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useObjectStore.fetchCollection passes its params straight to the query string, and OpenRegister reads a BARE field name. Measured against the live API: _filters[assignee]=rbac-editor -> 32 rows, every assignee assignee=rbac-editor -> 2 rows, only that user _filters[isFinalStatus]=false -> 37 rows, closed cases included isFinalStatus=false -> 16 rows, open only So the filter never applied. MyTasksWidget and TaskRemindersWidget fetched EVERY user's tasks and then narrowed only by status, which is not something a widget titled "My Tasks" may do: on a real team it shows other people's work. On this instance admin happens to own 30 of 32 tasks, which is exactly why it looked right. CasesOverviewWidget had no status filter at all, so it listed closed cases; and because it orders by startDate desc, a burst of recently created-and-closed rows pushed every live case out of all 7 slots. All three now filter server-side on bare field names. Verified in the browser: Cases overview lists only open demo cases, My Tasks lists 7 open tasks, and the dashboard no longer shows a single e2e row. ⚠️ 38 OTHER CALL SITES USE THE SAME INERT IDIOM (settings tabs, workflow editors, the workflow store), so `_filters[caseType]` is not scoping those lists to the selected case type either. They are left alone here deliberately: that is a separate change across a dozen files with its own testing, not a rider on a demo-data PR. --- src/views/widgets/CasesOverviewWidget.vue | 7 +++++++ src/views/widgets/MyTasksWidget.vue | 9 ++++++++- src/views/widgets/TaskRemindersWidget.vue | 5 ++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/views/widgets/CasesOverviewWidget.vue b/src/views/widgets/CasesOverviewWidget.vue index b74d503ee9..7784deeccb 100644 --- a/src/views/widgets/CasesOverviewWidget.vue +++ b/src/views/widgets/CasesOverviewWidget.vue @@ -117,7 +117,14 @@ export default { async fetchData() { this.loading = true try { + // Open cases only. Without this the widget listed CLOSED cases + // too, and since it orders by startDate desc a burst of recently + // created-and-closed rows (an e2e run leaves them behind: cases + // are archival-immutable and cannot be cleaned up) pushed every + // live case out of all 7 slots. Every sibling case widget already + // filters on the materialised isFinalStatus. const results = await this.objectStore.fetchCollection('case', { + isFinalStatus: false, _limit: 7, _order: { startDate: 'desc' }, }) diff --git a/src/views/widgets/MyTasksWidget.vue b/src/views/widgets/MyTasksWidget.vue index 567297aca0..dced8a6749 100644 --- a/src/views/widgets/MyTasksWidget.vue +++ b/src/views/widgets/MyTasksWidget.vue @@ -120,8 +120,15 @@ export default { this.loading = true try { const currentUser = getCurrentUser()?.uid || '' + // 🔴 `_filters[x]` IS INERT. The store passes params straight to the + // query string, and OpenRegister reads a BARE field name; measured + // against the live API, `_filters[assignee]=rbac-editor` returned all + // 32 tasks while `assignee=rbac-editor` returned the 2 that match. So + // this widget was fetching EVERY user's tasks and filtering only by + // status, which is not what a widget called "My Tasks" may show. const results = await this.objectStore.fetchCollection('task', { - '_filters[assignee]': currentUser, + assignee: currentUser, + isTerminalStatus: false, _limit: 7, }) // Filter to active/available tasks only. diff --git a/src/views/widgets/TaskRemindersWidget.vue b/src/views/widgets/TaskRemindersWidget.vue index ec629f6b07..214d8b6b53 100644 --- a/src/views/widgets/TaskRemindersWidget.vue +++ b/src/views/widgets/TaskRemindersWidget.vue @@ -141,8 +141,11 @@ export default { this.loading = true try { const currentUser = getCurrentUser()?.uid || '' + // Bare field names, not `_filters[x]`: that form is inert and this + // widget was reading every user's tasks. See MyTasksWidget. const tasks = await this.objectStore.fetchCollection('task', { - '_filters[assignee]': currentUser, + assignee: currentUser, + isTerminalStatus: false, _limit: 100, }) const activeTasks = (tasks || []).filter( From 15b3bb58a369096ed7eb909749b449b8da0fae86 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 3 Sep 2026 06:49:59 +0200 Subject: [PATCH 15/16] fix(store): the inert `_filters[x]` param, everywhere it was used 38 remaining call sites across 19 files, all silently unfiltered. useObjectStore.fetchCollection passes params straight to the query string and OpenRegister reads a bare field name, so `_filters[caseType]` was never a filter. Measured against the live API: _filters[caseType]= -> 46 statusTypes, only 6 of them matching caseType= -> 6 statusTypes Two consequences, both wrong data rather than slow queries. 25 `_filters[caseType]` sites: every case-type settings tab (statuses, results, roles, document types, decision types, properties, workflow) listed EVERY case type's children instead of the selected one's, and the workflow editor and store did the same. 9 `_filters[case]` sites: case-scoped reads returned every case's rows. None of them narrowed the result afterwards, and bezwaar.js takes `objections?.[0]` from that unfiltered list, so a case detail page could attribute ANOTHER case's objection to the case being viewed. inspection.js and advice.js assigned the whole unfiltered list straight to their state. The rewrite is mechanical, one form to another, and the diff is only that: every added line is a bare field key and nothing else moved. `case` as a property key is legal, and the build confirms it. Verified: prettier clean, eslint 0 errors, 360 vitest tests pass, production build succeeds. In the browser the case detail page now lists its own three tasks and none of the other 29, and the API returns 6 statuses for a case type where it previously returned all 46. --- src/components/tabs/CaseDocumentsTab.vue | 2 +- src/components/tabs/CaseTasksTab.vue | 2 +- src/modals/DeelzaakCreateModal.vue | 2 +- src/store/modules/advice.js | 2 +- src/store/modules/bezwaar.js | 10 +++++----- src/store/modules/enforcement.js | 2 +- src/store/modules/inspection.js | 4 ++-- src/store/modules/workflow.js | 8 ++++---- src/views/settings/CaseTypeDetail.vue | 4 ++-- src/views/settings/CaseTypeList.vue | 6 +++--- src/views/settings/WorkflowEditor.vue | 6 +++--- src/views/settings/tabs/DecisionTypesTab.vue | 2 +- src/views/settings/tabs/DocumentTypesTab.vue | 2 +- src/views/settings/tabs/PropertiesTab.vue | 4 ++-- src/views/settings/tabs/ResultsTab.vue | 2 +- src/views/settings/tabs/RolesTab.vue | 2 +- src/views/settings/tabs/StatusesTab.vue | 2 +- src/views/settings/tabs/WorkflowTab.vue | 12 ++++++------ src/views/voorstellen/VoorstelDetail.vue | 2 +- 19 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/components/tabs/CaseDocumentsTab.vue b/src/components/tabs/CaseDocumentsTab.vue index 4f6ec0bbb6..6f035fb4a8 100644 --- a/src/components/tabs/CaseDocumentsTab.vue +++ b/src/components/tabs/CaseDocumentsTab.vue @@ -189,7 +189,7 @@ export default { const results = await this.objectStore.fetchCollection( 'caseDocument', { - '_filters[case]': this.resolvedCaseId, + case: this.resolvedCaseId, _limit: 100, }, ) diff --git a/src/components/tabs/CaseTasksTab.vue b/src/components/tabs/CaseTasksTab.vue index 88e2eaa61d..7041551a4f 100644 --- a/src/components/tabs/CaseTasksTab.vue +++ b/src/components/tabs/CaseTasksTab.vue @@ -147,7 +147,7 @@ export default { this.loading = true try { const results = await this.objectStore.fetchCollection('task', { - '_filters[case]': this.resolvedCaseId, + case: this.resolvedCaseId, _limit: 50, }) this.tasks = results || [] diff --git a/src/modals/DeelzaakCreateModal.vue b/src/modals/DeelzaakCreateModal.vue index c3d9f5a00f..c7775e857d 100644 --- a/src/modals/DeelzaakCreateModal.vue +++ b/src/modals/DeelzaakCreateModal.vue @@ -267,7 +267,7 @@ export default { const results = await this.objectStore.fetchCollection( 'statusType', { - '_filters[caseType]': caseType.id, + caseType: caseType.id, _order: JSON.stringify({ order: 'asc' }), _limit: 100, }, diff --git a/src/store/modules/advice.js b/src/store/modules/advice.js index 7345a2a8f8..03f2a02fea 100644 --- a/src/store/modules/advice.js +++ b/src/store/modules/advice.js @@ -105,7 +105,7 @@ export const useAdviceStore = defineStore('advice', { const response = await objectStore.fetchCollection( 'adviesAanvraag', { - '_filters[case]': caseId, + case: caseId, limit: 100, }, ) diff --git a/src/store/modules/bezwaar.js b/src/store/modules/bezwaar.js index e5565675a0..a011e714cd 100644 --- a/src/store/modules/bezwaar.js +++ b/src/store/modules/bezwaar.js @@ -142,7 +142,7 @@ export const useBezwaarStore = defineStore('objectionProceeding', { // Load objection. const objections = await objectStore.fetchCollection('objection', { - '_filters[case]': caseId, + case: caseId, _limit: 1, }) this.currentObjection = objections?.[0] || null @@ -151,14 +151,14 @@ export const useBezwaarStore = defineStore('objectionProceeding', { const hearings = await objectStore.fetchCollection( 'hearingSession', { - '_filters[case]': caseId, + case: caseId, }, ) this.hearingSessions = hearings || [] // Load advisory report. const reports = await objectStore.fetchCollection('advisoryReport', { - '_filters[case]': caseId, + case: caseId, _limit: 1, }) this.currentAdvisoryReport = reports?.[0] || null @@ -167,7 +167,7 @@ export const useBezwaarStore = defineStore('objectionProceeding', { const decisions = await objectStore.fetchCollection( 'appealDecision', { - '_filters[case]': caseId, + case: caseId, _limit: 1, }, ) @@ -570,7 +570,7 @@ export const useBezwaarStore = defineStore('objectionProceeding', { // Find the Beroep case type. const caseTypes = await objectStore.fetchCollection('caseType', { - '_filters[identifier]': 'beroep', + identifier: 'beroep', _limit: 1, }) const beroepCaseType = caseTypes?.[0] diff --git a/src/store/modules/enforcement.js b/src/store/modules/enforcement.js index 88badc4900..2113d468e0 100644 --- a/src/store/modules/enforcement.js +++ b/src/store/modules/enforcement.js @@ -126,7 +126,7 @@ export const useEnforcementStore = defineStore('enforcement', { const response = await objectStore.fetchCollection( 'handhavingsactie', { - '_filters[case]': caseId, + case: caseId, limit: 100, }, ) diff --git a/src/store/modules/inspection.js b/src/store/modules/inspection.js index ddc596aeb6..fc0de61877 100644 --- a/src/store/modules/inspection.js +++ b/src/store/modules/inspection.js @@ -86,7 +86,7 @@ export const useInspectionStore = defineStore('inspection', { const response = await objectStore.fetchCollection( 'inspectieChecklist', { - '_filters[caseType]': caseTypeId, + caseType: caseTypeId, limit: 100, }, ) @@ -204,7 +204,7 @@ export const useInspectionStore = defineStore('inspection', { const response = await objectStore.fetchCollection( 'inspectieRapport', { - '_filters[case]': caseId, + case: caseId, limit: 100, }, ) diff --git a/src/store/modules/workflow.js b/src/store/modules/workflow.js index dc13c5ba65..0fe1dc6878 100644 --- a/src/store/modules/workflow.js +++ b/src/store/modules/workflow.js @@ -131,7 +131,7 @@ export const useWorkflowStore = defineStore('workflow', { const results = await objectStore.fetchCollection( 'workflowTemplate', { - '_filters[caseType]': caseTypeId, + caseType: caseTypeId, _limit: 100, _order: { version: 'desc' }, }, @@ -193,9 +193,9 @@ export const useWorkflowStore = defineStore('workflow', { const results = await objectStore.fetchCollection( 'workflowTemplate', { - '_filters[caseType]': caseTypeId, - '_filters[isActive]': true, - '_filters[isDraft]': false, + caseType: caseTypeId, + isActive: true, + isDraft: false, _limit: 1, }, ) diff --git a/src/views/settings/CaseTypeDetail.vue b/src/views/settings/CaseTypeDetail.vue index 0743ab8f12..f8fb13521f 100644 --- a/src/views/settings/CaseTypeDetail.vue +++ b/src/views/settings/CaseTypeDetail.vue @@ -286,7 +286,7 @@ export default { // Count active cases of this type try { const cases = await this.objectStore.fetchCollection('case', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 1, }) this.activeCaseCount = cases?.length || 0 @@ -356,7 +356,7 @@ export default { const statusTypes = await this.objectStore.fetchCollection( 'statusType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, }, ) diff --git a/src/views/settings/CaseTypeList.vue b/src/views/settings/CaseTypeList.vue index c59d9115d3..01d8c32528 100644 --- a/src/views/settings/CaseTypeList.vue +++ b/src/views/settings/CaseTypeList.vue @@ -162,7 +162,7 @@ export default { const statusTypes = await this.objectStore.fetchCollection( 'statusType', { - '_filters[caseType]': caseTypeId, + caseType: caseTypeId, _limit: 100, }, ) @@ -248,7 +248,7 @@ export default { try { const cases = await this.objectStore.fetchCollection('case', { - '_filters[caseType]': ct.id, + caseType: ct.id, _limit: 1, }) if (cases && cases.length > 0) { @@ -284,7 +284,7 @@ export default { const statusTypes = await this.objectStore.fetchCollection( 'statusType', { - '_filters[caseType]': ct.id, + caseType: ct.id, _limit: 100, }, ) diff --git a/src/views/settings/WorkflowEditor.vue b/src/views/settings/WorkflowEditor.vue index 04a2bfe5dd..55c565daea 100644 --- a/src/views/settings/WorkflowEditor.vue +++ b/src/views/settings/WorkflowEditor.vue @@ -234,7 +234,7 @@ export default { // Load status types for this case type this.statusNodes = (await this.objectStore.fetchCollection('statusType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, _order: { order: 'asc' }, })) || [] @@ -242,14 +242,14 @@ export default { // Load role types this.roleTypes = (await this.objectStore.fetchCollection('roleType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, })) || [] // Load document types this.documentTypes = (await this.objectStore.fetchCollection('documentType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, })) || [] diff --git a/src/views/settings/tabs/DecisionTypesTab.vue b/src/views/settings/tabs/DecisionTypesTab.vue index 3c2c1b7202..d12b2eaae4 100644 --- a/src/views/settings/tabs/DecisionTypesTab.vue +++ b/src/views/settings/tabs/DecisionTypesTab.vue @@ -227,7 +227,7 @@ export default { try { const objectStore = useObjectStore() const results = await objectStore.fetchCollection('decisionType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, }) this.items = results || [] diff --git a/src/views/settings/tabs/DocumentTypesTab.vue b/src/views/settings/tabs/DocumentTypesTab.vue index 50c9dd90e1..b39fcee01a 100644 --- a/src/views/settings/tabs/DocumentTypesTab.vue +++ b/src/views/settings/tabs/DocumentTypesTab.vue @@ -187,7 +187,7 @@ export default { this.loading = true const objectStore = useObjectStore() const results = await objectStore.fetchCollection('documentType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, }) this.items = results || [] diff --git a/src/views/settings/tabs/PropertiesTab.vue b/src/views/settings/tabs/PropertiesTab.vue index 372ded8edc..e434156b5a 100644 --- a/src/views/settings/tabs/PropertiesTab.vue +++ b/src/views/settings/tabs/PropertiesTab.vue @@ -327,7 +327,7 @@ export default { const result = await this.objectStore.fetchCollection( 'propertyDefinition', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, }, ) @@ -342,7 +342,7 @@ export default { async fetchStatusTypes() { try { const result = await this.objectStore.fetchCollection('statusType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, }) this.statusTypes = result || [] diff --git a/src/views/settings/tabs/ResultsTab.vue b/src/views/settings/tabs/ResultsTab.vue index 09b52d7eb7..2418df19b3 100644 --- a/src/views/settings/tabs/ResultsTab.vue +++ b/src/views/settings/tabs/ResultsTab.vue @@ -303,7 +303,7 @@ export default { this.loading = true try { const result = await this.objectStore.fetchCollection('resultType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, }) this.resultTypes = result || [] diff --git a/src/views/settings/tabs/RolesTab.vue b/src/views/settings/tabs/RolesTab.vue index 745ffea2ab..f251eb6661 100644 --- a/src/views/settings/tabs/RolesTab.vue +++ b/src/views/settings/tabs/RolesTab.vue @@ -246,7 +246,7 @@ export default { this.loading = true try { const result = await this.objectStore.fetchCollection('roleType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, }) this.roleTypes = result || [] diff --git a/src/views/settings/tabs/StatusesTab.vue b/src/views/settings/tabs/StatusesTab.vue index ff8370b2a5..21da886efb 100644 --- a/src/views/settings/tabs/StatusesTab.vue +++ b/src/views/settings/tabs/StatusesTab.vue @@ -316,7 +316,7 @@ export default { this.loading = true try { const result = await this.objectStore.fetchCollection('statusType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, }) this.statusTypes = result || [] diff --git a/src/views/settings/tabs/WorkflowTab.vue b/src/views/settings/tabs/WorkflowTab.vue index d0e6011e31..cd76fa2f5c 100644 --- a/src/views/settings/tabs/WorkflowTab.vue +++ b/src/views/settings/tabs/WorkflowTab.vue @@ -324,17 +324,17 @@ export default { // Fetch type data for name mapping const statusTypes = (await this.objectStore.fetchCollection('statusType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, })) || [] const roleTypes = (await this.objectStore.fetchCollection('roleType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, })) || [] const docTypes = (await this.objectStore.fetchCollection('documentType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, })) || [] @@ -376,17 +376,17 @@ export default { // Fetch type data for UUID mapping const statusTypes = (await this.objectStore.fetchCollection('statusType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, })) || [] const roleTypes = (await this.objectStore.fetchCollection('roleType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, })) || [] const docTypes = (await this.objectStore.fetchCollection('documentType', { - '_filters[caseType]': this.caseTypeId, + caseType: this.caseTypeId, _limit: 100, })) || [] diff --git a/src/views/voorstellen/VoorstelDetail.vue b/src/views/voorstellen/VoorstelDetail.vue index 3a47bf091d..5b771e1b0f 100644 --- a/src/views/voorstellen/VoorstelDetail.vue +++ b/src/views/voorstellen/VoorstelDetail.vue @@ -276,7 +276,7 @@ export default { const results = await this.objectStore.fetchCollection( 'parafeeractie', { - '_filters[voorstel]': this.voorstelId, + voorstel: this.voorstelId, _limit: 100, _order: '_self.created', _direction: 'asc', From 638c44e47a00616f9135361b6927c49285535eb9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 3 Sep 2026 06:55:58 +0200 Subject: [PATCH 16/16] docs(spec): tag the three methods the filter rewrite touched gate-16 is diff-scoped, so changing one line inside these three methods made them 'changed' and required a @spec tag. Each points at the spec its own file already references, and each docblock records why the line changed: the `_filters[x]` form they used is inert, so all three were reading every case's or every case type's rows. Verified: all 78 applicable hydra gates green, prettier clean, 360 vitest tests pass. --- src/components/tabs/CaseDocumentsTab.vue | 10 ++++++++++ src/components/tabs/CaseTasksTab.vue | 10 ++++++++++ src/modals/DeelzaakCreateModal.vue | 13 +++++++++++++ 3 files changed, 33 insertions(+) diff --git a/src/components/tabs/CaseDocumentsTab.vue b/src/components/tabs/CaseDocumentsTab.vue index 6f035fb4a8..5ece504513 100644 --- a/src/components/tabs/CaseDocumentsTab.vue +++ b/src/components/tabs/CaseDocumentsTab.vue @@ -179,6 +179,16 @@ export default { methods: { formatDate, + /** + * Load the documents belonging to THIS case. + * + * Filters on a bare `case` field name: the `_filters[case]` form this + * used is inert, so the tab was reading every case's documents. + * + * @return {Promise} + * + * @spec openspec/specs/document-zaakdossier/spec.md#requirement-req-zak-001-zaak-objects-must-support-linked-documents-via-zgw-informatieobject-and-zaakinformatieobject + */ async reload() { if (!this.resolvedCaseId) { this.loading = false diff --git a/src/components/tabs/CaseTasksTab.vue b/src/components/tabs/CaseTasksTab.vue index 7041551a4f..87f89ede58 100644 --- a/src/components/tabs/CaseTasksTab.vue +++ b/src/components/tabs/CaseTasksTab.vue @@ -139,6 +139,16 @@ export default { }, methods: { + /** + * Load the tasks belonging to THIS case. + * + * Filters on a bare `case` field name: the `_filters[case]` form this + * used is inert, so the tab was reading every case's tasks. + * + * @return {Promise} + * + * @spec openspec/specs/task-management/spec.md#requirement-task-list-must-be-reached-via-mijn-werk-not-a-sibling-top-level-menu + */ async reload() { if (!this.resolvedCaseId) { this.loading = false diff --git a/src/modals/DeelzaakCreateModal.vue b/src/modals/DeelzaakCreateModal.vue index c7775e857d..a4879ce952 100644 --- a/src/modals/DeelzaakCreateModal.vue +++ b/src/modals/DeelzaakCreateModal.vue @@ -256,6 +256,19 @@ export default { } }, + /** + * Load the status types of the SELECTED sub-case type. + * + * Filters on a bare `caseType` field name: the `_filters[caseType]` + * form this used is inert, so the picker offered every case type's + * statuses rather than the chosen one's. + * + * @param {object|null} caseType The selected sub-case type. + * + * @return {Promise} + * + * @spec openspec/specs/deelzaak-support/spec.md#requirement-sub-case-creation-from-parent-case + */ async onCaseTypeSelected(caseType) { this.form.caseType = caseType?.id || null this.errors.caseType = ''