From b5009447638cae8b54cd6b0a4528e37574b8e089 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 7 Mar 2026 09:40:17 +0100 Subject: [PATCH 001/145] =?UTF-8?q?feat:=20Add=20ZGW=20API=20mapping=20?= =?UTF-8?q?=E2=80=94=20controller,=20routes,=20config,=20admin=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ZgwController with full CRUD for ZGW-compliant API endpoints using bidirectional Twig-based mapping (English <-> Dutch) - Add ZgwMappingService for IAppConfig-based mapping configuration - Add ZgwPaginationHelper for HAL-style pagination wrapper - Add LoadDefaultZgwMappings repair step with 12 default resources - Add ZgwMappingController + admin UI (ZgwMappingSettings.vue) - Add Vue store module for zgwMapping - Register 6 ZGW API routes and 5 mapping admin routes - Load OpenRegister services via cross-app DI with graceful fallback --- appinfo/info.xml | 1 + appinfo/routes.php | 12 + lib/Controller/ZgwController.php | 734 ++++++++++++++++++ lib/Controller/ZgwMappingController.php | 177 +++++ lib/Repair/LoadDefaultZgwMappings.php | 724 +++++++++++++++++ lib/Service/ZgwMappingService.php | 210 +++++ lib/Service/ZgwPaginationHelper.php | 99 +++ .../.openspec.yaml | 2 + .../2026-03-06-create-procest-app/design.md | 278 +++++++ .../2026-03-06-create-procest-app/proposal.md | 87 +++ .../specs/pipelinq-app-scaffold/spec.md | 77 ++ .../specs/pipelinq-client-management/spec.md | 133 ++++ .../specs/pipelinq-object-store/spec.md | 101 +++ .../specs/procest-app-scaffold/spec.md | 77 ++ .../specs/procest-case-management/spec.md | 108 +++ .../specs/procest-object-store/spec.md | 101 +++ .../2026-03-06-create-procest-app/tasks.md | 321 ++++++++ openspec/specs/pipelinq-app-scaffold/spec.md | 77 ++ .../specs/pipelinq-client-management/spec.md | 133 ++++ openspec/specs/pipelinq-object-store/spec.md | 101 +++ openspec/specs/procest-app-scaffold/spec.md | 77 ++ .../specs/procest-case-management/spec.md | 108 +++ openspec/specs/procest-object-store/spec.md | 101 +++ src/store/modules/zgwMapping.js | 106 +++ src/views/settings/AdminRoot.vue | 9 + src/views/settings/ZgwMappingSettings.vue | 283 +++++++ 26 files changed, 4237 insertions(+) create mode 100644 lib/Controller/ZgwController.php create mode 100644 lib/Controller/ZgwMappingController.php create mode 100644 lib/Repair/LoadDefaultZgwMappings.php create mode 100644 lib/Service/ZgwMappingService.php create mode 100644 lib/Service/ZgwPaginationHelper.php create mode 100644 openspec/changes/archive/2026-03-06-create-procest-app/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-06-create-procest-app/design.md create mode 100644 openspec/changes/archive/2026-03-06-create-procest-app/proposal.md create mode 100644 openspec/changes/archive/2026-03-06-create-procest-app/specs/pipelinq-app-scaffold/spec.md create mode 100644 openspec/changes/archive/2026-03-06-create-procest-app/specs/pipelinq-client-management/spec.md create mode 100644 openspec/changes/archive/2026-03-06-create-procest-app/specs/pipelinq-object-store/spec.md create mode 100644 openspec/changes/archive/2026-03-06-create-procest-app/specs/procest-app-scaffold/spec.md create mode 100644 openspec/changes/archive/2026-03-06-create-procest-app/specs/procest-case-management/spec.md create mode 100644 openspec/changes/archive/2026-03-06-create-procest-app/specs/procest-object-store/spec.md create mode 100644 openspec/changes/archive/2026-03-06-create-procest-app/tasks.md create mode 100644 openspec/specs/pipelinq-app-scaffold/spec.md create mode 100644 openspec/specs/pipelinq-client-management/spec.md create mode 100644 openspec/specs/pipelinq-object-store/spec.md create mode 100644 openspec/specs/procest-app-scaffold/spec.md create mode 100644 openspec/specs/procest-case-management/spec.md create mode 100644 openspec/specs/procest-object-store/spec.md create mode 100644 src/store/modules/zgwMapping.js create mode 100644 src/views/settings/ZgwMappingSettings.vue diff --git a/appinfo/info.xml b/appinfo/info.xml index 6971b39..6df69b6 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -71,6 +71,7 @@ Vrij en open source onder de AGPL-licentie. OCA\Procest\Repair\InitializeSettings + OCA\Procest\Repair\LoadDefaultZgwMappings diff --git a/appinfo/routes.php b/appinfo/routes.php index 34fb35f..87a9e37 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -8,5 +8,17 @@ ['name' => 'settings#index', 'url' => '/api/settings', 'verb' => 'GET'], ['name' => 'settings#create', 'url' => '/api/settings', 'verb' => 'POST'], ['name' => 'settings#load', 'url' => '/api/settings/load', 'verb' => 'POST'], + ['name' => 'zgw_mapping#index', 'url' => '/api/zgw-mappings', 'verb' => 'GET'], + ['name' => 'zgw_mapping#show', 'url' => '/api/zgw-mappings/{resourceKey}', 'verb' => 'GET'], + ['name' => 'zgw_mapping#update', 'url' => '/api/zgw-mappings/{resourceKey}', 'verb' => 'PUT'], + ['name' => 'zgw_mapping#destroy', 'url' => '/api/zgw-mappings/{resourceKey}', 'verb' => 'DELETE'], + ['name' => 'zgw_mapping#reset', 'url' => '/api/zgw-mappings/{resourceKey}/reset', 'verb' => 'POST'], + // ZGW API routes. + ['name' => 'zgw#index', 'url' => '/api/zgw/{zgwApi}/v1/{resource}', 'verb' => 'GET'], + ['name' => 'zgw#create', 'url' => '/api/zgw/{zgwApi}/v1/{resource}', 'verb' => 'POST'], + ['name' => 'zgw#show', 'url' => '/api/zgw/{zgwApi}/v1/{resource}/{uuid}', 'verb' => 'GET'], + ['name' => 'zgw#update', 'url' => '/api/zgw/{zgwApi}/v1/{resource}/{uuid}', 'verb' => 'PUT'], + ['name' => 'zgw#patch', 'url' => '/api/zgw/{zgwApi}/v1/{resource}/{uuid}', 'verb' => 'PATCH'], + ['name' => 'zgw#destroy', 'url' => '/api/zgw/{zgwApi}/v1/{resource}/{uuid}', 'verb' => 'DELETE'], ], ]; diff --git a/lib/Controller/ZgwController.php b/lib/Controller/ZgwController.php new file mode 100644 index 0000000..1d6244b --- /dev/null +++ b/lib/Controller/ZgwController.php @@ -0,0 +1,734 @@ + Dutch) with Twig templates. + * + * @category Controller + * @package OCA\Procest\Controller + * + * @author Conduction Development Team + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://procest.nl + */ + +declare(strict_types=1); + +namespace OCA\Procest\Controller; + +use OCA\OpenRegister\Db\Mapping; +use OCA\Procest\Service\ZgwMappingService; +use OCA\Procest\Service\ZgwPaginationHelper; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use Psr\Log\LoggerInterface; + +/** + * ZGW API Controller + * + * Dispatches ZGW API requests to the correct OpenRegister schema based on + * mapping configuration stored in Procest's IAppConfig. + * + * Route pattern: /api/zgw/{zgwApi}/v1/{resource}/{uuid?} + * + * @psalm-suppress UnusedClass + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.ExcessiveClassLength) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.ElseExpression) + * @SuppressWarnings(PHPMD.LongVariable) + */ +class ZgwController extends Controller +{ + + /** + * Map of ZGW API + resource to the config key suffix used in Procest. + * + * @var array> + */ + private const RESOURCE_MAP = [ + 'zaken' => [ + 'zaken' => 'zaak', + 'statussen' => 'status', + 'resultaten' => 'resultaat', + 'rollen' => 'rol', + ], + 'catalogi' => [ + 'zaaktypen' => 'zaaktype', + 'statustypen' => 'statustype', + 'resultaattypen' => 'resultaattype', + 'roltypen' => 'roltype', + 'eigenschappen' => 'eigenschap', + 'informatieobjecttypen' => 'informatieobjecttype', + 'besluittypen' => 'besluittype', + ], + 'besluiten' => [ + 'besluiten' => 'besluit', + 'besluittypen' => 'besluittype', + ], + ]; + + /** + * The OpenRegister MappingService (loaded dynamically). + * + * @var object|null + */ + private $openRegisterMappingService = null; + + /** + * The OpenRegister ObjectService (loaded dynamically). + * + * @var object|null + */ + private $openRegisterObjectService = null; + + /** + * Constructor + * + * @param string $appName The app name + * @param IRequest $request The request + * @param ZgwMappingService $zgwMappingService The ZGW mapping service + * @param ZgwPaginationHelper $paginationHelper The pagination helper + * @param LoggerInterface $logger The logger + * + * @return void + */ + public function __construct( + string $appName, + IRequest $request, + private readonly ZgwMappingService $zgwMappingService, + private readonly ZgwPaginationHelper $paginationHelper, + private readonly LoggerInterface $logger, + ) { + parent::__construct(appName: $appName, request: $request); + + // Dynamically load OpenRegister services. + try { + $container = \OC::$server; + $this->openRegisterMappingService = $container->get( + 'OCA\OpenRegister\Service\MappingService' + ); + $this->openRegisterObjectService = $container->get( + 'OCA\OpenRegister\Service\ObjectService' + ); + } catch (\Throwable $e) { + $this->logger->warning( + 'ZgwController: OpenRegister services not available', + ['exception' => $e->getMessage()] + ); + } + }//end __construct() + + /** + * List ZGW resources (GET collection). + * + * @param string $zgwApi The ZGW API group (zaken, catalogi, besluiten) + * @param string $resource The ZGW resource name (zaken, zaaktypen, etc.) + * + * @return JSONResponse + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function index(string $zgwApi, string $resource): JSONResponse + { + if ($this->openRegisterObjectService === null) { + return new JSONResponse( + data: ['detail' => 'OpenRegister is not available'], + statusCode: Http::STATUS_SERVICE_UNAVAILABLE + ); + } + + $mappingConfig = $this->loadMappingConfig( + zgwApi: $zgwApi, + resource: $resource + ); + if ($mappingConfig === null) { + return new JSONResponse( + data: ['detail' => "No ZGW mapping configured for {$zgwApi}/{$resource}"], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + if (($mappingConfig['enabled'] ?? true) === false) { + return new JSONResponse( + data: ['detail' => "ZGW mapping for {$zgwApi}/{$resource} is disabled"], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + try { + // Translate ZGW query params to OpenRegister filters. + $params = $this->request->getParams(); + $filters = $this->translateQueryParams( + params: $params, + mappingConfig: $mappingConfig + ); + + $page = max(1, (int) ($params['page'] ?? 1)); + $pageSize = max(1, min(100, (int) ($params['pageSize'] ?? 20))); + + // Build query params for OpenRegister searchObjectsPaginated. + $searchParams = array_merge( + $filters, + [ + '_limit' => $pageSize, + '_offset' => (($page - 1) * $pageSize), + ] + ); + + // Set register/schema context and search. + $query = $this->openRegisterObjectService->buildSearchQuery( + requestParams: $searchParams, + register: $mappingConfig['sourceRegister'], + schema: $mappingConfig['sourceSchema'] + ); + $result = $this->openRegisterObjectService->searchObjectsPaginated( + query: $query + ); + + $objects = $result['results'] ?? []; + $totalCount = $result['total'] ?? count($objects); + $baseUrl = $this->buildBaseUrl(zgwApi: $zgwApi, resource: $resource); + + // Apply outbound mapping to each result. + $outboundMapping = $this->createOutboundMapping(mappingConfig: $mappingConfig); + $mapped = []; + foreach ($objects as $object) { + if (is_array($object) === true) { + $objectData = $object; + } else { + $objectData = $object->jsonSerialize(); + } + + $mapped[] = $this->applyOutboundMapping( + objectData: $objectData, + mapping: $outboundMapping, + mappingConfig: $mappingConfig, + baseUrl: $baseUrl + ); + } + + // Wrap in ZGW pagination. + $paginatedResult = $this->paginationHelper->wrapResults( + mappedObjects: $mapped, + totalCount: $totalCount, + page: $page, + pageSize: $pageSize, + baseUrl: $baseUrl, + queryParams: $params + ); + + return new JSONResponse(data: $paginatedResult); + } catch (\Exception $e) { + $this->logger->error( + 'ZGW list error: '.$e->getMessage(), + ['exception' => $e] + ); + return new JSONResponse( + data: ['detail' => 'Internal server error'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + }//end index() + + /** + * Create a ZGW resource (POST collection). + * + * @param string $zgwApi The ZGW API group + * @param string $resource The ZGW resource name + * + * @return JSONResponse + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function create(string $zgwApi, string $resource): JSONResponse + { + if ($this->openRegisterObjectService === null) { + return new JSONResponse( + data: ['detail' => 'OpenRegister is not available'], + statusCode: Http::STATUS_SERVICE_UNAVAILABLE + ); + } + + $mappingConfig = $this->loadMappingConfig( + zgwApi: $zgwApi, + resource: $resource + ); + if ($mappingConfig === null) { + return new JSONResponse( + data: ['detail' => "No ZGW mapping configured for {$zgwApi}/{$resource}"], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + try { + // Apply inbound mapping (Dutch to English). + $body = $this->request->getParams(); + $inboundMapping = $this->createInboundMapping(mappingConfig: $mappingConfig); + $englishData = $this->applyInboundMapping( + body: $body, + mapping: $inboundMapping, + mappingConfig: $mappingConfig + ); + + // Save via ObjectService. + $object = $this->openRegisterObjectService->saveObject( + register: $mappingConfig['sourceRegister'], + schema: $mappingConfig['sourceSchema'], + object: $englishData + ); + + // Apply outbound mapping for response. + $baseUrl = $this->buildBaseUrl(zgwApi: $zgwApi, resource: $resource); + $outboundMapping = $this->createOutboundMapping(mappingConfig: $mappingConfig); + if (is_array($object) === true) { + $objectData = $object; + } else { + $objectData = $object->jsonSerialize(); + } + + $mapped = $this->applyOutboundMapping( + objectData: $objectData, + mapping: $outboundMapping, + mappingConfig: $mappingConfig, + baseUrl: $baseUrl + ); + + return new JSONResponse(data: $mapped, statusCode: Http::STATUS_CREATED); + } catch (\Exception $e) { + $this->logger->error( + 'ZGW create error: '.$e->getMessage(), + ['exception' => $e] + ); + return new JSONResponse( + data: ['detail' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + }//end try + }//end create() + + /** + * Get a single ZGW resource (GET item). + * + * @param string $zgwApi The ZGW API group + * @param string $resource The ZGW resource name + * @param string $uuid The resource UUID + * + * @return JSONResponse + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function show(string $zgwApi, string $resource, string $uuid): JSONResponse + { + if ($this->openRegisterObjectService === null) { + return new JSONResponse( + data: ['detail' => 'OpenRegister is not available'], + statusCode: Http::STATUS_SERVICE_UNAVAILABLE + ); + } + + $mappingConfig = $this->loadMappingConfig( + zgwApi: $zgwApi, + resource: $resource + ); + if ($mappingConfig === null) { + return new JSONResponse( + data: ['detail' => "No ZGW mapping configured for {$zgwApi}/{$resource}"], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + try { + $object = $this->openRegisterObjectService->find( + register: $mappingConfig['sourceRegister'], + schema: $mappingConfig['sourceSchema'], + id: $uuid + ); + + $baseUrl = $this->buildBaseUrl(zgwApi: $zgwApi, resource: $resource); + $outboundMapping = $this->createOutboundMapping(mappingConfig: $mappingConfig); + if (is_array($object) === true) { + $objectData = $object; + } else { + $objectData = $object->jsonSerialize(); + } + + $mapped = $this->applyOutboundMapping( + objectData: $objectData, + mapping: $outboundMapping, + mappingConfig: $mappingConfig, + baseUrl: $baseUrl + ); + + return new JSONResponse(data: $mapped); + } catch (\Exception $e) { + $this->logger->error( + 'ZGW show error: '.$e->getMessage(), + ['exception' => $e] + ); + return new JSONResponse( + data: ['detail' => 'Not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + }//end try + }//end show() + + /** + * Update a ZGW resource (PUT item). + * + * @param string $zgwApi The ZGW API group + * @param string $resource The ZGW resource name + * @param string $uuid The resource UUID + * + * @return JSONResponse + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function update(string $zgwApi, string $resource, string $uuid): JSONResponse + { + return $this->handleUpdate(zgwApi: $zgwApi, resource: $resource, uuid: $uuid); + }//end update() + + /** + * Partial update a ZGW resource (PATCH item). + * + * @param string $zgwApi The ZGW API group + * @param string $resource The ZGW resource name + * @param string $uuid The resource UUID + * + * @return JSONResponse + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function patch(string $zgwApi, string $resource, string $uuid): JSONResponse + { + return $this->handleUpdate(zgwApi: $zgwApi, resource: $resource, uuid: $uuid); + }//end patch() + + /** + * Delete a ZGW resource (DELETE item). + * + * @param string $zgwApi The ZGW API group + * @param string $resource The ZGW resource name + * @param string $uuid The resource UUID + * + * @return JSONResponse + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function destroy(string $zgwApi, string $resource, string $uuid): JSONResponse + { + if ($this->openRegisterObjectService === null) { + return new JSONResponse( + data: ['detail' => 'OpenRegister is not available'], + statusCode: Http::STATUS_SERVICE_UNAVAILABLE + ); + } + + $mappingConfig = $this->loadMappingConfig( + zgwApi: $zgwApi, + resource: $resource + ); + if ($mappingConfig === null) { + return new JSONResponse( + data: ['detail' => "No ZGW mapping configured for {$zgwApi}/{$resource}"], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + try { + $this->openRegisterObjectService->deleteObject(uuid: $uuid); + + return new JSONResponse(data: [], statusCode: Http::STATUS_NO_CONTENT); + } catch (\Exception $e) { + $this->logger->error( + 'ZGW delete error: '.$e->getMessage(), + ['exception' => $e] + ); + return new JSONResponse( + data: ['detail' => 'Not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + }//end destroy() + + /** + * Handle PUT/PATCH update requests. + * + * @param string $zgwApi The ZGW API group + * @param string $resource The ZGW resource name + * @param string $uuid The resource UUID + * + * @return JSONResponse + */ + private function handleUpdate(string $zgwApi, string $resource, string $uuid): JSONResponse + { + if ($this->openRegisterObjectService === null) { + return new JSONResponse( + data: ['detail' => 'OpenRegister is not available'], + statusCode: Http::STATUS_SERVICE_UNAVAILABLE + ); + } + + $mappingConfig = $this->loadMappingConfig( + zgwApi: $zgwApi, + resource: $resource + ); + if ($mappingConfig === null) { + return new JSONResponse( + data: ['detail' => "No ZGW mapping configured for {$zgwApi}/{$resource}"], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + try { + $body = $this->request->getParams(); + $inboundMapping = $this->createInboundMapping(mappingConfig: $mappingConfig); + $englishData = $this->applyInboundMapping( + body: $body, + mapping: $inboundMapping, + mappingConfig: $mappingConfig + ); + + // Include the UUID so ObjectService can locate the existing object. + $englishData['uuid'] = $uuid; + + $object = $this->openRegisterObjectService->saveObject( + register: $mappingConfig['sourceRegister'], + schema: $mappingConfig['sourceSchema'], + object: $englishData + ); + + $baseUrl = $this->buildBaseUrl(zgwApi: $zgwApi, resource: $resource); + $outboundMapping = $this->createOutboundMapping(mappingConfig: $mappingConfig); + if (is_array($object) === true) { + $objectData = $object; + } else { + $objectData = $object->jsonSerialize(); + } + + $mapped = $this->applyOutboundMapping( + objectData: $objectData, + mapping: $outboundMapping, + mappingConfig: $mappingConfig, + baseUrl: $baseUrl + ); + + return new JSONResponse(data: $mapped); + } catch (\Exception $e) { + $this->logger->error( + 'ZGW update error: '.$e->getMessage(), + ['exception' => $e] + ); + return new JSONResponse( + data: ['detail' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + }//end try + }//end handleUpdate() + + /** + * Load ZGW mapping configuration. + * + * @param string $zgwApi The ZGW API group + * @param string $resource The ZGW resource name + * + * @return array|null The mapping configuration or null if not found + */ + private function loadMappingConfig(string $zgwApi, string $resource): ?array + { + $resourceKey = self::RESOURCE_MAP[$zgwApi][$resource] ?? null; + if ($resourceKey === null) { + return null; + } + + return $this->zgwMappingService->getMapping(resourceKey: $resourceKey); + }//end loadMappingConfig() + + /** + * Translate ZGW query parameters to OpenRegister filter parameters. + * + * @param array $params The request query parameters + * @param array $mappingConfig The ZGW mapping configuration + * + * @return array Translated filter parameters + */ + private function translateQueryParams(array $params, array $mappingConfig): array + { + $queryMapping = $mappingConfig['queryParameterMapping'] ?? []; + $filters = []; + + // Remove framework params. + $reserved = [ + 'page', + 'pageSize', + '_route', + 'zgwApi', + 'resource', + 'uuid', + ]; + + foreach ($params as $key => $value) { + if (in_array($key, $reserved, true) === true) { + continue; + } + + if (isset($queryMapping[$key]) === true) { + $mapped = $queryMapping[$key]; + $field = $mapped['field'] ?? $key; + $operator = $mapped['operator'] ?? null; + + // Extract UUID from URL if configured. + if (($mapped['extractUuid'] ?? false) === true + && is_string($value) === true + ) { + $parts = explode('/', rtrim($value, '/')); + $value = end($parts); + } + + if ($operator !== null) { + $filters[$field.'.'.$operator] = $value; + } else { + $filters[$field] = $value; + } + } + + // Unmapped parameters are ignored per ZGW spec. + }//end foreach + + return $filters; + }//end translateQueryParams() + + /** + * Create a Mapping object for outbound (English to Dutch) transformation. + * + * @param array $mappingConfig The ZGW mapping configuration + * + * @return Mapping The outbound mapping entity + */ + private function createOutboundMapping(array $mappingConfig): object + { + $mapping = new Mapping(); + $mappingData = [ + 'name' => 'zgw-outbound-'.($mappingConfig['zgwResource'] ?? 'unknown'), + 'mapping' => $mappingConfig['propertyMapping'] ?? [], + 'unset' => $mappingConfig['unset'] ?? [], + 'cast' => $mappingConfig['cast'] ?? [], + 'passThrough' => false, + ]; + $mapping->hydrate(object: $mappingData); + + return $mapping; + }//end createOutboundMapping() + + /** + * Create a Mapping object for inbound (Dutch to English) transformation. + * + * @param array $mappingConfig The ZGW mapping configuration + * + * @return Mapping The inbound mapping entity + */ + private function createInboundMapping(array $mappingConfig): object + { + $mapping = new Mapping(); + $mappingData = [ + 'name' => 'zgw-inbound-'.($mappingConfig['zgwResource'] ?? 'unknown'), + 'mapping' => $mappingConfig['reverseMapping'] ?? [], + 'unset' => $mappingConfig['reverseUnset'] ?? [], + 'cast' => $mappingConfig['reverseCast'] ?? [], + 'passThrough' => false, + ]; + $mapping->hydrate(object: $mappingData); + + return $mapping; + }//end createInboundMapping() + + /** + * Apply outbound mapping (English to Dutch) to an object. + * + * @param array $objectData The English-language object data + * @param object $mapping The outbound mapping entity + * @param array $mappingConfig The ZGW mapping configuration + * @param string $baseUrl The base URL for ZGW URL references + * + * @return array The mapped Dutch-language object + */ + private function applyOutboundMapping( + array $objectData, + object $mapping, + array $mappingConfig, + string $baseUrl + ): array { + // Inject template context variables and @self metadata. + $objectData['_baseUrl'] = $baseUrl; + $objectData['_valueMappings'] = $mappingConfig['valueMapping'] ?? []; + $selfMeta = $objectData['@self'] ?? []; + $objectData['_uuid'] = $objectData['id'] ?? ($selfMeta['id'] ?? ''); + $objectData['_created'] = $selfMeta['created'] ?? ''; + $objectData['_updated'] = $selfMeta['updated'] ?? ''; + + return $this->openRegisterMappingService->executeMapping( + mapping: $mapping, + input: $objectData + ); + }//end applyOutboundMapping() + + /** + * Apply inbound mapping (Dutch to English) to request data. + * + * @param array $body The Dutch-language request body + * @param object $mapping The inbound mapping entity + * @param array $mappingConfig The ZGW mapping configuration + * + * @return array The mapped English-language data + */ + private function applyInboundMapping( + array $body, + object $mapping, + array $mappingConfig + ): array { + // Inject _valueMappings for reverse enum lookup. + $body['_valueMappings'] = $mappingConfig['valueMapping'] ?? []; + + // Remove framework parameters from the body. + unset($body['_route'], $body['zgwApi'], $body['resource'], $body['uuid']); + + return $this->openRegisterMappingService->executeMapping( + mapping: $mapping, + input: $body + ); + }//end applyInboundMapping() + + /** + * Build the base URL for ZGW API responses. + * + * @param string $zgwApi The ZGW API group + * @param string $resource The ZGW resource name + * + * @return string The base URL + */ + private function buildBaseUrl(string $zgwApi, string $resource): string + { + $serverHost = $this->request->getServerHost(); + $scheme = $this->request->getServerProtocol(); + + return $scheme.'://'.$serverHost.'/index.php/apps/procest/api/zgw/'.$zgwApi.'/v1/'.$resource; + }//end buildBaseUrl() +}//end class diff --git a/lib/Controller/ZgwMappingController.php b/lib/Controller/ZgwMappingController.php new file mode 100644 index 0000000..879e41f --- /dev/null +++ b/lib/Controller/ZgwMappingController.php @@ -0,0 +1,177 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://procest.nl + */ + +declare(strict_types=1); + +namespace OCA\Procest\Controller; + +use OCA\Procest\AppInfo\Application; +use OCA\Procest\Repair\LoadDefaultZgwMappings; +use OCA\Procest\Service\SettingsService; +use OCA\Procest\Service\ZgwMappingService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use Psr\Log\LoggerInterface; + +/** + * Controller for managing ZGW API mapping configurations. + */ +class ZgwMappingController extends Controller +{ + /** + * Constructor for the ZgwMappingController. + * + * @param IRequest $request The request object + * @param ZgwMappingService $zgwMappingService The ZGW mapping service + * @param SettingsService $settingsService The settings service + * @param LoggerInterface $logger The logger interface + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly ZgwMappingService $zgwMappingService, + private readonly SettingsService $settingsService, + private readonly LoggerInterface $logger, + ) { + parent::__construct(Application::APP_ID, $request); + }//end __construct() + + /** + * List all ZGW mapping configurations. + * + * @return JSONResponse + */ + public function index(): JSONResponse + { + return new JSONResponse( + [ + 'success' => true, + 'mappings' => $this->zgwMappingService->listMappings(), + ] + ); + }//end index() + + /** + * Get a single ZGW mapping configuration. + * + * @param string $resourceKey The ZGW resource key + * + * @return JSONResponse + */ + public function show(string $resourceKey): JSONResponse + { + $mapping = $this->zgwMappingService->getMapping($resourceKey); + + if ($mapping === null) { + return new JSONResponse( + [ + 'success' => false, + 'message' => "No mapping configured for {$resourceKey}", + ] + ); + } + + return new JSONResponse( + [ + 'success' => true, + 'mapping' => $mapping, + ] + ); + }//end show() + + /** + * Save a ZGW mapping configuration. + * + * @param string $resourceKey The ZGW resource key + * + * @return JSONResponse + */ + public function update(string $resourceKey): JSONResponse + { + $params = $this->request->getParams(); + + // Remove framework params. + unset($params['_route'], $params['resourceKey']); + + $this->zgwMappingService->saveMapping(resourceKey: $resourceKey, config: $params); + + return new JSONResponse( + [ + 'success' => true, + 'mapping' => $this->zgwMappingService->getMapping($resourceKey), + ] + ); + }//end update() + + /** + * Delete a ZGW mapping configuration. + * + * @param string $resourceKey The ZGW resource key + * + * @return JSONResponse + */ + public function destroy(string $resourceKey): JSONResponse + { + $this->zgwMappingService->deleteMapping($resourceKey); + + return new JSONResponse( + [ + 'success' => true, + ] + ); + }//end destroy() + + /** + * Reset a single mapping to its default configuration. + * + * @param string $resourceKey The ZGW resource key + * + * @return JSONResponse + */ + public function reset(string $resourceKey): JSONResponse + { + $registerId = $this->settingsService->getConfigValue(key: 'register', default: ''); + if ($registerId === '') { + return new JSONResponse( + [ + 'success' => false, + 'message' => 'No Procest register configured', + ] + ); + } + + $loader = new LoadDefaultZgwMappings( + zgwMappingService: $this->zgwMappingService, + settingsService: $this->settingsService, + logger: $this->logger, + ); + $defaults = $loader->getDefaultMappings(registerId: $registerId); + + $this->zgwMappingService->resetToDefault(resourceKey: $resourceKey, defaults: $defaults); + + return new JSONResponse( + [ + 'success' => true, + 'mapping' => $this->zgwMappingService->getMapping($resourceKey), + ] + ); + }//end reset() +}//end class diff --git a/lib/Repair/LoadDefaultZgwMappings.php b/lib/Repair/LoadDefaultZgwMappings.php new file mode 100644 index 0000000..10dd57d --- /dev/null +++ b/lib/Repair/LoadDefaultZgwMappings.php @@ -0,0 +1,724 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://procest.nl + */ + +declare(strict_types=1); + +namespace OCA\Procest\Repair; + +use OCA\Procest\Service\SettingsService; +use OCA\Procest\Service\ZgwMappingService; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; + +/** + * Repair step that loads default ZGW API mapping configurations. + * + * @SuppressWarnings(PHPMD.ExcessiveClassLength) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ +class LoadDefaultZgwMappings implements IRepairStep +{ + /** + * Constructor for LoadDefaultZgwMappings. + * + * @param ZgwMappingService $zgwMappingService The ZGW mapping service + * @param SettingsService $settingsService The settings service + * @param LoggerInterface $logger The logger interface + * + * @return void + */ + public function __construct( + private readonly ZgwMappingService $zgwMappingService, + private readonly SettingsService $settingsService, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Get the name of this repair step. + * + * @return string + */ + public function getName(): string + { + return 'Load default ZGW API mapping configurations for Procest'; + }//end getName() + + /** + * Run the repair step to load default ZGW mappings. + * + * Only loads mappings that do not already exist (does not overwrite). + * + * @param IOutput $output The output interface for progress reporting + * + * @return void + */ + public function run(IOutput $output): void + { + $output->info('Loading default ZGW API mappings...'); + + $registerId = $this->settingsService->getConfigValue(key: 'register', default: ''); + if ($registerId === '') { + $output->warning('No Procest register configured yet. Skipping ZGW mapping defaults.'); + return; + } + + $defaults = $this->getDefaultMappings($registerId); + $loaded = 0; + + foreach ($defaults as $resourceKey => $config) { + if ($this->zgwMappingService->hasMapping($resourceKey) === true) { + continue; + } + + $this->zgwMappingService->saveMapping(resourceKey: $resourceKey, config: $config); + $loaded++; + } + + $output->info("Loaded {$loaded} default ZGW mapping configurations."); + + $this->logger->info( + 'Procest: Default ZGW mappings loaded', + ['loaded' => $loaded, 'total' => count($defaults)] + ); + }//end run() + + /** + * Get the default mapping configurations for all 12 ZGW resources. + * + * @param string $registerId The Procest register ID + * + * @return array Mapping configurations keyed by resource key + * + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function getDefaultMappings(string $registerId): array + { + $settings = $this->settingsService->getSettings(); + + return [ + 'zaak' => $this->getZaakMapping( + registerId: $registerId, + settings: $settings + ), + 'zaaktype' => $this->getZaakTypeMapping( + registerId: $registerId, + settings: $settings + ), + 'status' => $this->getStatusMapping( + registerId: $registerId, + settings: $settings + ), + 'statustype' => $this->getStatusTypeMapping( + registerId: $registerId, + settings: $settings + ), + 'resultaat' => $this->getResultaatMapping( + registerId: $registerId, + settings: $settings + ), + 'resultaattype' => $this->getResultaatTypeMapping( + registerId: $registerId, + settings: $settings + ), + 'rol' => $this->getRolMapping( + registerId: $registerId, + settings: $settings + ), + 'roltype' => $this->getRolTypeMapping( + registerId: $registerId, + settings: $settings + ), + 'eigenschap' => $this->getEigenschapMapping( + registerId: $registerId, + settings: $settings + ), + 'besluit' => $this->getBesluitMapping( + registerId: $registerId, + settings: $settings + ), + 'besluittype' => $this->getBesluitTypeMapping( + registerId: $registerId, + settings: $settings + ), + 'informatieobjecttype' => $this->getInformatieObjectTypeMapping( + registerId: $registerId, + settings: $settings + ), + ]; + }//end getDefaultMappings() + + /** + * Get default mapping for Zaak (case). + * + * @param string $registerId The register ID + * @param array $settings The Procest settings + * + * @return array + */ + private function getZaakMapping(string $registerId, array $settings): array + { + return [ + 'zgwResource' => 'zaak', + 'zgwApiVersion' => '1', + 'sourceRegister' => $registerId, + 'sourceSchema' => ($settings['case_schema'] ?? ''), + 'enabled' => true, + 'propertyMapping' => [ + 'url' => '{{ _baseUrl }}/{{ _uuid }}', + 'uuid' => '{{ _uuid }}', + 'identificatie' => '{{ identifier }}', + 'omschrijving' => '{{ title }}', + 'toelichting' => '{{ description }}', + 'zaaktype' => '{{ _baseUrl | replace({"zaken/zaken": "catalogi/zaaktypen"}) }}/{{ caseType }}', + 'registratiedatum' => '{{ _created }}', + 'startdatum' => '{{ startDate }}', + 'einddatum' => '{{ endDate }}', + 'einddatumGepland' => '{{ plannedEndDate }}', + 'uiterlijkeEinddatumAfdoening' => '{{ deadline }}', + 'vertrouwelijkheidaanduiding' => '{{ confidentiality }}', + 'verantwoordelijkeOrganisatie' => '{{ assignee }}', + ], + 'reverseMapping' => [ + 'title' => '{{ omschrijving }}', + 'description' => '{{ toelichting }}', + 'identifier' => '{{ identificatie }}', + 'caseType' => '{{ zaaktype | zgw_extract_uuid }}', + 'startDate' => '{{ startdatum }}', + 'endDate' => '{{ einddatum }}', + 'plannedEndDate' => '{{ einddatumGepland }}', + 'deadline' => '{{ uiterlijkeEinddatumAfdoening }}', + 'confidentiality' => '{{ vertrouwelijkheidaanduiding }}', + 'assignee' => '{{ verantwoordelijkeOrganisatie }}', + ], + 'valueMapping' => [ + 'confidentiality' => [ + 'openbaar' => 'openbaar', + 'beperkt_openbaar' => 'beperkt_openbaar', + 'intern' => 'intern', + 'zaakvertrouwelijk' => 'zaakvertrouwelijk', + 'vertrouwelijk' => 'vertrouwelijk', + 'confidentieel' => 'confidentieel', + 'geheim' => 'geheim', + 'zeer_geheim' => 'zeer_geheim', + ], + ], + 'queryParameterMapping' => [ + 'zaaktype' => [ + 'field' => 'caseType', + 'extractUuid' => true, + ], + 'identificatie' => [ + 'field' => 'identifier', + ], + 'startdatum' => [ + 'field' => 'startDate', + ], + 'startdatum__gte' => [ + 'field' => 'startDate', + 'operator' => 'gte', + ], + 'startdatum__lte' => [ + 'field' => 'startDate', + 'operator' => 'lte', + ], + ], + ]; + }//end getZaakMapping() + + /** + * Get default mapping for ZaakType (caseType). + * + * @param string $registerId The register ID + * @param array $settings The Procest settings + * + * @return array + */ + private function getZaakTypeMapping(string $registerId, array $settings): array + { + return [ + 'zgwResource' => 'zaaktype', + 'zgwApiVersion' => '1', + 'sourceRegister' => $registerId, + 'sourceSchema' => ($settings['case_type_schema'] ?? ''), + 'enabled' => true, + 'propertyMapping' => [ + 'url' => '{{ _baseUrl }}/{{ _uuid }}', + 'uuid' => '{{ _uuid }}', + 'identificatie' => '{{ identifier }}', + 'omschrijving' => '{{ title }}', + 'omschrijvingGeneriek' => '{{ description }}', + 'doel' => '{{ purpose }}', + 'aanleiding' => '{{ trigger }}', + 'onderwerp' => '{{ subject }}', + 'doorlooptijd' => '{{ processingDeadline }}', + 'vertrouwelijkheidaanduiding' => '{{ confidentiality }}', + 'concept' => '{{ isDraft }}', + 'beginGeldigheid' => '{{ validFrom }}', + 'eindeGeldigheid' => '{{ validUntil }}', + 'handelingInitiator' => '{{ origin }}', + 'opschortingEnAanhoudingMogelijk' => '{{ suspensionAllowed }}', + 'verlengingMogelijk' => '{{ extensionAllowed }}', + 'verlengingstermijn' => '{{ extensionPeriod }}', + 'publicatieIndicatie' => '{{ publicationRequired }}', + ], + 'reverseMapping' => [ + 'title' => '{{ omschrijving }}', + 'description' => '{{ omschrijvingGeneriek }}', + 'identifier' => '{{ identificatie }}', + 'purpose' => '{{ doel }}', + 'trigger' => '{{ aanleiding }}', + 'subject' => '{{ onderwerp }}', + 'processingDeadline' => '{{ doorlooptijd }}', + 'confidentiality' => '{{ vertrouwelijkheidaanduiding }}', + 'isDraft' => '{{ concept }}', + 'validFrom' => '{{ beginGeldigheid }}', + 'validUntil' => '{{ eindeGeldigheid }}', + 'origin' => '{{ handelingInitiator }}', + 'suspensionAllowed' => '{{ opschortingEnAanhoudingMogelijk }}', + 'extensionAllowed' => '{{ verlengingMogelijk }}', + 'extensionPeriod' => '{{ verlengingstermijn }}', + 'publicationRequired' => '{{ publicatieIndicatie }}', + ], + 'valueMapping' => [], + 'queryParameterMapping' => [ + 'identificatie' => [ + 'field' => 'identifier', + ], + ], + ]; + }//end getZaakTypeMapping() + + /** + * Get default mapping for Status. + * + * @param string $registerId The register ID + * @param array $settings The Procest settings + * + * @return array + */ + private function getStatusMapping(string $registerId, array $settings): array + { + return [ + 'zgwResource' => 'status', + 'zgwApiVersion' => '1', + 'sourceRegister' => $registerId, + 'sourceSchema' => ($settings['status_schema'] ?? ''), + 'enabled' => true, + 'propertyMapping' => [ + 'url' => '{{ _baseUrl }}/{{ _uuid }}', + 'uuid' => '{{ _uuid }}', + 'zaak' => '{{ _baseUrl | replace({"zaken/statussen": "zaken/zaken"}) }}/{{ case }}', + 'statustype' => '{{ _baseUrl | replace({"zaken/statussen": "catalogi/statustypen"}) }}/{{ statusType }}', + 'datumStatusGezet' => '{{ _created }}', + 'statustoelichting' => '{{ description }}', + ], + 'reverseMapping' => [ + 'case' => '{{ zaak | zgw_extract_uuid }}', + 'statusType' => '{{ statustype | zgw_extract_uuid }}', + 'description' => '{{ statustoelichting }}', + ], + 'valueMapping' => [], + 'queryParameterMapping' => [ + 'zaak' => [ + 'field' => 'case', + 'extractUuid' => true, + ], + ], + ]; + }//end getStatusMapping() + + /** + * Get default mapping for StatusType (statusType). + * + * @param string $registerId The register ID + * @param array $settings The Procest settings + * + * @return array + */ + private function getStatusTypeMapping(string $registerId, array $settings): array + { + return [ + 'zgwResource' => 'statustype', + 'zgwApiVersion' => '1', + 'sourceRegister' => $registerId, + 'sourceSchema' => ($settings['status_type_schema'] ?? ''), + 'enabled' => true, + 'propertyMapping' => [ + 'url' => '{{ _baseUrl }}/{{ _uuid }}', + 'uuid' => '{{ _uuid }}', + 'omschrijving' => '{{ name }}', + 'omschrijvingGeneriek' => '{{ description }}', + 'zaaktype' => '{{ _baseUrl | replace({"catalogi/statustypen": "catalogi/zaaktypen"}) }}/{{ caseType }}', + 'volgnummer' => '{{ order }}', + 'isEindstatus' => '{{ isFinal }}', + ], + 'reverseMapping' => [ + 'name' => '{{ omschrijving }}', + 'description' => '{{ omschrijvingGeneriek }}', + 'caseType' => '{{ zaaktype | zgw_extract_uuid }}', + 'order' => '{{ volgnummer }}', + 'isFinal' => '{{ isEindstatus }}', + ], + 'valueMapping' => [], + 'queryParameterMapping' => [ + 'zaaktype' => [ + 'field' => 'caseType', + 'extractUuid' => true, + ], + ], + ]; + }//end getStatusTypeMapping() + + /** + * Get default mapping for Resultaat (result). + * + * @param string $registerId The register ID + * @param array $settings The Procest settings + * + * @return array + */ + private function getResultaatMapping(string $registerId, array $settings): array + { + return [ + 'zgwResource' => 'resultaat', + 'zgwApiVersion' => '1', + 'sourceRegister' => $registerId, + 'sourceSchema' => ($settings['result_schema'] ?? ''), + 'enabled' => true, + 'propertyMapping' => [ + 'url' => '{{ _baseUrl }}/{{ _uuid }}', + 'uuid' => '{{ _uuid }}', + 'zaak' => '{{ _baseUrl | replace({"zaken/resultaten": "zaken/zaken"}) }}/{{ case }}', + 'resultaattype' => '{{ _baseUrl | replace({"zaken/resultaten": "catalogi/resultaattypen"}) }}/{{ resultType }}', + 'toelichting' => '{{ description }}', + ], + 'reverseMapping' => [ + 'name' => '{{ toelichting }}', + 'case' => '{{ zaak | zgw_extract_uuid }}', + 'resultType' => '{{ resultaattype | zgw_extract_uuid }}', + 'description' => '{{ toelichting }}', + ], + 'valueMapping' => [], + 'queryParameterMapping' => [ + 'zaak' => [ + 'field' => 'case', + 'extractUuid' => true, + ], + ], + ]; + }//end getResultaatMapping() + + /** + * Get default mapping for ResultaatType (resultType). + * + * @param string $registerId The register ID + * @param array $settings The Procest settings + * + * @return array + */ + private function getResultaatTypeMapping(string $registerId, array $settings): array + { + return [ + 'zgwResource' => 'resultaattype', + 'zgwApiVersion' => '1', + 'sourceRegister' => $registerId, + 'sourceSchema' => ($settings['result_type_schema'] ?? ''), + 'enabled' => true, + 'propertyMapping' => [ + 'url' => '{{ _baseUrl }}/{{ _uuid }}', + 'uuid' => '{{ _uuid }}', + 'omschrijving' => '{{ name }}', + 'toelichting' => '{{ description }}', + 'zaaktype' => '{{ _baseUrl | replace({"catalogi/resultaattypen": "catalogi/zaaktypen"}) }}/{{ caseType }}', + 'archiefnominatie' => '{{ archivalAction }}', + 'archiefactietermijn' => '{{ archivalPeriod }}', + ], + 'reverseMapping' => [ + 'name' => '{{ omschrijving }}', + 'description' => '{{ toelichting }}', + 'caseType' => '{{ zaaktype | zgw_extract_uuid }}', + 'archivalAction' => '{{ archiefnominatie }}', + 'archivalPeriod' => '{{ archiefactietermijn }}', + ], + 'valueMapping' => [ + 'archivalAction' => [ + 'bewaren' => 'bewaren', + 'vernietigen' => 'vernietigen', + ], + ], + 'queryParameterMapping' => [ + 'zaaktype' => [ + 'field' => 'caseType', + 'extractUuid' => true, + ], + ], + ]; + }//end getResultaatTypeMapping() + + /** + * Get default mapping for Rol (role). + * + * @param string $registerId The register ID + * @param array $settings The Procest settings + * + * @return array + */ + private function getRolMapping(string $registerId, array $settings): array + { + return [ + 'zgwResource' => 'rol', + 'zgwApiVersion' => '1', + 'sourceRegister' => $registerId, + 'sourceSchema' => ($settings['role_schema'] ?? ''), + 'enabled' => true, + 'propertyMapping' => [ + 'url' => '{{ _baseUrl }}/{{ _uuid }}', + 'uuid' => '{{ _uuid }}', + 'zaak' => '{{ _baseUrl | replace({"zaken/rollen": "zaken/zaken"}) }}/{{ case }}', + 'roltype' => '{{ _baseUrl | replace({"zaken/rollen": "catalogi/roltypen"}) }}/{{ roleType }}', + 'omschrijving' => '{{ name }}', + 'omschrijvingGeneriek' => '{{ description }}', + 'betrokkeneIdentificatie' => '{{ participant }}', + ], + 'reverseMapping' => [ + 'name' => '{{ omschrijving }}', + 'description' => '{{ omschrijvingGeneriek }}', + 'case' => '{{ zaak | zgw_extract_uuid }}', + 'roleType' => '{{ roltype | zgw_extract_uuid }}', + 'participant' => '{{ betrokkeneIdentificatie }}', + ], + 'valueMapping' => [], + 'queryParameterMapping' => [ + 'zaak' => [ + 'field' => 'case', + 'extractUuid' => true, + ], + ], + ]; + }//end getRolMapping() + + /** + * Get default mapping for RolType (roleType). + * + * @param string $registerId The register ID + * @param array $settings The Procest settings + * + * @return array + */ + private function getRolTypeMapping(string $registerId, array $settings): array + { + return [ + 'zgwResource' => 'roltype', + 'zgwApiVersion' => '1', + 'sourceRegister' => $registerId, + 'sourceSchema' => ($settings['role_type_schema'] ?? ''), + 'enabled' => true, + 'propertyMapping' => [ + 'url' => '{{ _baseUrl }}/{{ _uuid }}', + 'uuid' => '{{ _uuid }}', + 'omschrijving' => '{{ name }}', + 'omschrijvingGeneriek' => '{{ description }}', + 'zaaktype' => '{{ _baseUrl | replace({"catalogi/roltypen": "catalogi/zaaktypen"}) }}/{{ caseType }}', + ], + 'reverseMapping' => [ + 'name' => '{{ omschrijving }}', + 'description' => '{{ omschrijvingGeneriek }}', + 'caseType' => '{{ zaaktype | zgw_extract_uuid }}', + ], + 'valueMapping' => [], + 'queryParameterMapping' => [ + 'zaaktype' => [ + 'field' => 'caseType', + 'extractUuid' => true, + ], + ], + ]; + }//end getRolTypeMapping() + + /** + * Get default mapping for Eigenschap (propertyDefinition). + * + * @param string $registerId The register ID + * @param array $settings The Procest settings + * + * @return array + */ + private function getEigenschapMapping(string $registerId, array $settings): array + { + return [ + 'zgwResource' => 'eigenschap', + 'zgwApiVersion' => '1', + 'sourceRegister' => $registerId, + 'sourceSchema' => ($settings['property_definition_schema'] ?? ''), + 'enabled' => true, + 'propertyMapping' => [ + 'url' => '{{ _baseUrl }}/{{ _uuid }}', + 'uuid' => '{{ _uuid }}', + 'naam' => '{{ name }}', + 'toelichting' => '{{ description }}', + 'zaaktype' => '{{ _baseUrl | replace({"catalogi/eigenschappen": "catalogi/zaaktypen"}) }}/{{ caseType }}', + ], + 'reverseMapping' => [ + 'name' => '{{ naam }}', + 'description' => '{{ toelichting }}', + 'caseType' => '{{ zaaktype | zgw_extract_uuid }}', + ], + 'valueMapping' => [], + 'queryParameterMapping' => [ + 'zaaktype' => [ + 'field' => 'caseType', + 'extractUuid' => true, + ], + ], + ]; + }//end getEigenschapMapping() + + /** + * Get default mapping for Besluit (decision). + * + * @param string $registerId The register ID + * @param array $settings The Procest settings + * + * @return array + */ + private function getBesluitMapping(string $registerId, array $settings): array + { + return [ + 'zgwResource' => 'besluit', + 'zgwApiVersion' => '1', + 'sourceRegister' => $registerId, + 'sourceSchema' => ($settings['decision_schema'] ?? ''), + 'enabled' => true, + 'propertyMapping' => [ + 'url' => '{{ _baseUrl }}/{{ _uuid }}', + 'uuid' => '{{ _uuid }}', + 'identificatie' => '{{ title }}', + 'toelichting' => '{{ description }}', + 'zaak' => '{{ _baseUrl | replace({"besluiten/besluiten": "zaken/zaken"}) }}/{{ case }}', + 'besluittype' => '{{ _baseUrl | replace({"besluiten/besluiten": "catalogi/besluittypen"}) }}/{{ decisionType }}', + 'verantwoordelijkeOrganisatie' => '{{ decidedBy }}', + 'datum' => '{{ decidedAt }}', + 'ingangsdatum' => '{{ effectiveDate }}', + 'vervaldatum' => '{{ expiryDate }}', + ], + 'reverseMapping' => [ + 'title' => '{{ identificatie }}', + 'description' => '{{ toelichting }}', + 'case' => '{{ zaak | zgw_extract_uuid }}', + 'decisionType' => '{{ besluittype | zgw_extract_uuid }}', + 'decidedBy' => '{{ verantwoordelijkeOrganisatie }}', + 'decidedAt' => '{{ datum }}', + 'effectiveDate' => '{{ ingangsdatum }}', + 'expiryDate' => '{{ vervaldatum }}', + ], + 'valueMapping' => [], + 'queryParameterMapping' => [ + 'zaak' => [ + 'field' => 'case', + 'extractUuid' => true, + ], + ], + ]; + }//end getBesluitMapping() + + /** + * Get default mapping for BesluitType (decisionType). + * + * @param string $registerId The register ID + * @param array $settings The Procest settings + * + * @return array + */ + private function getBesluitTypeMapping(string $registerId, array $settings): array + { + return [ + 'zgwResource' => 'besluittype', + 'zgwApiVersion' => '1', + 'sourceRegister' => $registerId, + 'sourceSchema' => ($settings['decision_type_schema'] ?? ''), + 'enabled' => true, + 'propertyMapping' => [ + 'url' => '{{ _baseUrl }}/{{ _uuid }}', + 'uuid' => '{{ _uuid }}', + 'omschrijving' => '{{ name }}', + 'toelichting' => '{{ description }}', + 'zaaktypen' => '{{ _baseUrl | replace({"catalogi/besluittypen": "catalogi/zaaktypen"}) }}/{{ caseType }}', + 'publicatieIndicatie' => '{{ publicationRequired }}', + ], + 'reverseMapping' => [ + 'name' => '{{ omschrijving }}', + 'description' => '{{ toelichting }}', + 'caseType' => '{{ zaaktypen | zgw_extract_uuid }}', + 'publicationRequired' => '{{ publicatieIndicatie }}', + ], + 'valueMapping' => [], + 'queryParameterMapping' => [ + 'zaaktypen' => [ + 'field' => 'caseType', + 'extractUuid' => true, + ], + ], + ]; + }//end getBesluitTypeMapping() + + /** + * Get default mapping for InformatieObjectType (documentType). + * + * @param string $registerId The register ID + * @param array $settings The Procest settings + * + * @return array + */ + private function getInformatieObjectTypeMapping(string $registerId, array $settings): array + { + return [ + 'zgwResource' => 'informatieobjecttype', + 'zgwApiVersion' => '1', + 'sourceRegister' => $registerId, + 'sourceSchema' => ($settings['document_type_schema'] ?? ''), + 'enabled' => true, + 'propertyMapping' => [ + 'url' => '{{ _baseUrl }}/{{ _uuid }}', + 'uuid' => '{{ _uuid }}', + 'omschrijving' => '{{ name }}', + 'toelichting' => '{{ description }}', + 'zaaktypen' => '{{ _baseUrl | replace({"catalogi/informatieobjecttypen": "catalogi/zaaktypen"}) }}/{{ caseType }}', + 'verplicht' => '{{ required }}', + ], + 'reverseMapping' => [ + 'name' => '{{ omschrijving }}', + 'description' => '{{ toelichting }}', + 'caseType' => '{{ zaaktypen | zgw_extract_uuid }}', + 'required' => '{{ verplicht }}', + ], + 'valueMapping' => [], + 'queryParameterMapping' => [ + 'zaaktypen' => [ + 'field' => 'caseType', + 'extractUuid' => true, + ], + ], + ]; + }//end getInformatieObjectTypeMapping() +}//end class diff --git a/lib/Service/ZgwMappingService.php b/lib/Service/ZgwMappingService.php new file mode 100644 index 0000000..cc9066a --- /dev/null +++ b/lib/Service/ZgwMappingService.php @@ -0,0 +1,210 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://procest.nl + */ + +declare(strict_types=1); + +namespace OCA\Procest\Service; + +use OCA\Procest\AppInfo\Application; +use OCP\IAppConfig; +use Psr\Log\LoggerInterface; + +/** + * Service for managing ZGW API mapping configuration. + * + * Stores mapping configuration as JSON in IAppConfig under keys like + * `zgw_mapping_zaak`, `zgw_mapping_zaaktype`, etc. + * + * @SuppressWarnings(PHPMD.ExcessiveClassLength) + */ +class ZgwMappingService +{ + + /** + * Prefix for ZGW mapping config keys in IAppConfig. + */ + private const CONFIG_PREFIX = 'zgw_mapping_'; + + /** + * All known ZGW resource keys. + * + * @var string[] + */ + private const RESOURCE_KEYS = [ + 'zaak', + 'zaaktype', + 'status', + 'statustype', + 'resultaat', + 'resultaattype', + 'rol', + 'roltype', + 'eigenschap', + 'besluit', + 'besluittype', + 'informatieobjecttype', + ]; + + /** + * Constructor for the ZgwMappingService. + * + * @param IAppConfig $appConfig The app configuration service + * @param LoggerInterface $logger The logger interface + * + * @return void + */ + public function __construct( + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Get the mapping configuration for a specific ZGW resource. + * + * @param string $resourceKey The ZGW resource key (e.g., 'zaak', 'zaaktype') + * + * @return array|null The mapping configuration or null if not found + */ + public function getMapping(string $resourceKey): ?array + { + $json = $this->appConfig->getValueString( + Application::APP_ID, + self::CONFIG_PREFIX.$resourceKey, + '' + ); + + if ($json === '') { + return null; + } + + $config = json_decode($json, true); + if ($config === null || is_array($config) === false) { + return null; + } + + return $config; + }//end getMapping() + + /** + * Save the mapping configuration for a specific ZGW resource. + * + * @param string $resourceKey The ZGW resource key (e.g., 'zaak', 'zaaktype') + * @param array $config The mapping configuration + * + * @return void + */ + public function saveMapping(string $resourceKey, array $config): void + { + $json = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $this->appConfig->setValueString( + Application::APP_ID, + self::CONFIG_PREFIX.$resourceKey, + $json + ); + + $this->logger->info( + 'ZGW mapping saved', + ['resourceKey' => $resourceKey] + ); + }//end saveMapping() + + /** + * List all ZGW mapping configurations. + * + * Returns an associative array keyed by resource key. Resources without + * a saved configuration will have null values. + * + * @return array + */ + public function listMappings(): array + { + $mappings = []; + + foreach (self::RESOURCE_KEYS as $key) { + $mappings[$key] = $this->getMapping($key); + } + + return $mappings; + }//end listMappings() + + /** + * Delete the mapping configuration for a specific ZGW resource. + * + * @param string $resourceKey The ZGW resource key (e.g., 'zaak', 'zaaktype') + * + * @return void + */ + public function deleteMapping(string $resourceKey): void + { + $configKey = self::CONFIG_PREFIX.$resourceKey; + $this->appConfig->deleteKey(app: Application::APP_ID, key: $configKey); + + $this->logger->info( + 'ZGW mapping deleted', + ['resourceKey' => $resourceKey] + ); + }//end deleteMapping() + + /** + * Get all known ZGW resource keys. + * + * @return string[] + */ + public function getResourceKeys(): array + { + return self::RESOURCE_KEYS; + }//end getResourceKeys() + + /** + * Check whether a mapping exists for a given resource. + * + * @param string $resourceKey The ZGW resource key + * + * @return bool + */ + public function hasMapping(string $resourceKey): bool + { + return $this->getMapping($resourceKey) !== null; + }//end hasMapping() + + /** + * Reset a mapping to its default configuration. + * + * Loads the default from the defaults array and saves it. + * + * @param string $resourceKey The ZGW resource key + * @param array $defaults The default mapping configurations + * + * @return void + */ + public function resetToDefault(string $resourceKey, array $defaults): void + { + if (isset($defaults[$resourceKey]) === true) { + $this->saveMapping(resourceKey: $resourceKey, config: $defaults[$resourceKey]); + $this->logger->info( + 'ZGW mapping reset to default', + ['resourceKey' => $resourceKey] + ); + } + }//end resetToDefault() +}//end class diff --git a/lib/Service/ZgwPaginationHelper.php b/lib/Service/ZgwPaginationHelper.php new file mode 100644 index 0000000..5310530 --- /dev/null +++ b/lib/Service/ZgwPaginationHelper.php @@ -0,0 +1,99 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://procest.nl + */ + +declare(strict_types=1); + +namespace OCA\Procest\Service; + +/** + * ZGW pagination helper + * + * Wraps standard pagination results into the ZGW HAL-style format: + * { "count": N, "next": url|null, "previous": url|null, "results": [...] } + * + * @category Service + * @package OCA\Procest\Service + * + * @psalm-suppress UnusedClass + */ +class ZgwPaginationHelper +{ + /** + * Wrap paginated results in ZGW format. + * + * @param array $mappedObjects The mapped objects for the current page + * @param int $totalCount The total number of matching objects + * @param int $page The current page number (1-based) + * @param int $pageSize The page size + * @param string $baseUrl The base URL for pagination links + * @param array $queryParams The original query parameters + * + * @return array ZGW-formatted paginated response + */ + public function wrapResults( + array $mappedObjects, + int $totalCount, + int $page, + int $pageSize, + string $baseUrl, + array $queryParams + ): array { + $totalPages = 1; + if ($pageSize > 0) { + $totalPages = (int) ceil($totalCount / $pageSize); + } + + // Remove pagination and framework params from query string. + $filteredParams = array_diff_key( + $queryParams, + [ + 'page' => 1, + '_page' => 1, + '_route' => 1, + 'zgwApi' => 1, + 'resource' => 1, + 'uuid' => 1, + ] + ); + $queryString = http_build_query(data: $filteredParams); + + $separator = '?'; + if ($queryString !== '') { + $separator = '?'.$queryString.'&'; + } + + $next = null; + $previous = null; + + if ($page < $totalPages) { + $next = $baseUrl.$separator.'page='.($page + 1); + } + + if ($page > 1) { + $previous = $baseUrl.$separator.'page='.($page - 1); + } + + return [ + 'count' => $totalCount, + 'next' => $next, + 'previous' => $previous, + 'results' => $mappedObjects, + ]; + }//end wrapResults() +}//end class diff --git a/openspec/changes/archive/2026-03-06-create-procest-app/.openspec.yaml b/openspec/changes/archive/2026-03-06-create-procest-app/.openspec.yaml new file mode 100644 index 0000000..0cba84b --- /dev/null +++ b/openspec/changes/archive/2026-03-06-create-procest-app/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-02-18 diff --git a/openspec/changes/archive/2026-03-06-create-procest-app/design.md b/openspec/changes/archive/2026-03-06-create-procest-app/design.md new file mode 100644 index 0000000..e968ffe --- /dev/null +++ b/openspec/changes/archive/2026-03-06-create-procest-app/design.md @@ -0,0 +1,278 @@ +# Design: create-procest-app + +## Architecture Overview + +Both Procest and Pipelinq follow the **softwarecatalog thin-client pattern**: a rich Vue 2 + Pinia frontend that queries OpenRegister directly, with a minimal PHP backend for settings/configuration only. + +``` +┌─────────────────────────────────────────────────┐ +│ Browser │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ Procest │ │ Pipelinq │ │ +│ │ Vue SPA │ │ Vue SPA │ │ +│ │ Pinia Store │ │ Pinia Store │ │ +│ └──────┬───────┘ └──────┬───────┘ │ +│ │ fetch() │ fetch() │ +└─────────┼──────────────────────┼─────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────┐ +│ OpenRegister API │ +│ /api/objects/{register}/{schema} │ +│ CRUD, search, pagination, RBAC │ +│ │ +│ ┌────────────────┐ ┌─────────────────┐ │ +│ │ case-management │ │ client-management│ │ +│ │ register │ │ register │ │ +│ │ │ │ │ │ +│ │ - case │ │ - client │ │ +│ │ - task │ │ - request │ │ +│ │ - status │ │ - contact │ │ +│ │ - role │ │ │ │ +│ │ - result │ │ │ │ +│ │ - decision │ │ │ │ +│ └─────────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +Each app has a thin PHP backend for: +- Settings management (register/schema IDs) +- Auto-configuration on install (repair step) +- Admin settings page rendering + +No own database tables. No entity CRUD controllers. No backend business logic for domain objects. + +## API Design + +### Procest Backend Endpoints (minimal) + +#### `GET /api/settings` +Returns app configuration (register/schema mappings). + +**Response:** +```json +{ + "success": true, + "config": { + "register": "5", + "case_schema": "30", + "task_schema": "31", + "status_schema": "32", + "role_schema": "33", + "result_schema": "34", + "decision_schema": "35" + } +} +``` + +#### `POST /api/settings` +Saves register/schema configuration. Admin only. + +**Request:** +```json +{ + "register": "5", + "case_schema": "30", + "task_schema": "31" +} +``` + +#### `GET /api/settings/status` +Returns app health status (OpenRegister available, schemas configured, object counts). + +### Pipelinq Backend Endpoints (minimal) + +Same pattern — `GET/POST /api/settings`, `GET /api/settings/status` with client-management register/schema IDs. + +### Frontend → OpenRegister API (direct) + +All data operations go directly to OpenRegister from the frontend: + +``` +GET /apps/openregister/api/objects/{register}/{schema} → List +GET /apps/openregister/api/objects/{register}/{schema}/{id} → Read +POST /apps/openregister/api/objects/{register}/{schema} → Create +PUT /apps/openregister/api/objects/{register}/{schema}/{id} → Update +DELETE /apps/openregister/api/objects/{register}/{schema}/{id} → Delete +``` + +Query parameters: `_limit`, `_offset`, `_order`, `_search`, `_fields`, plus field-level filters. + +## Database Changes + +**None.** Both apps store all data in OpenRegister. No migrations needed. + +Configuration stored via `IAppConfig` (Nextcloud key-value config store). + +## OpenRegister Schema Definitions + +### case-management register (Procest) + +| Schema | Key Fields | Description | +|--------|-----------|-------------| +| `case` | `title`, `description`, `status`, `assignee`, `priority`, `created`, `updated`, `closed` | The core case entity | +| `task` | `title`, `description`, `status`, `assignee`, `case`, `dueDate`, `priority` | Tasks within a case | +| `status` | `name`, `description`, `order`, `isFinal` | Status definitions (configurable workflow) | +| `role` | `name`, `description`, `permissions` | Role definitions for case participants | +| `result` | `name`, `description`, `case` | Case outcome/result | +| `decision` | `title`, `description`, `case`, `decidedBy`, `decidedAt` | Decisions made on a case | + +### client-management register (Pipelinq) + +| Schema | Key Fields | Description | +|--------|-----------|-------------| +| `client` | `name`, `email`, `phone`, `type` (person/organization), `address`, `notes` | Client entity | +| `request` | `title`, `description`, `client`, `status`, `priority`, `requestedAt`, `category` | Request/verzoek — the pre-state of a case | +| `contact` | `name`, `email`, `phone`, `role`, `client` | Contact person linked to a client | + +## Nextcloud Integration + +### Controllers (per app) +- `DashboardController` — serves the main Vue SPA page (`templates/index.php`) +- `SettingsController` — register/schema configuration CRUD + +### Services (per app) +- `SettingsService` — reads/writes config from `IAppConfig` + +### Settings Registration (per app) +- `AdminSettings` — renders the admin settings Vue entry point +- `AdminSection` — registers the section in Nextcloud settings sidebar + +### Repair Steps (per app) +- `InitializeSettings` — auto-detects or creates register/schemas on install + +### DI Registration (`Application.php`) +```php +class Application extends App implements IBootstrap { + const APP_ID = 'procest'; // or 'pipelinq' + + public function register(IRegistrationContext $context): void { + $context->registerService(SettingsService::class, function($c) { + return new SettingsService( + $c->get(IAppConfig::class), + $c->get(LoggerInterface::class) + ); + }); + } + + public function boot(IBootContext $context): void { + // Nothing needed at boot for now + } +} +``` + +## File Structure + +Both apps share the same structure: + +``` +procest/ pipelinq/ +├── appinfo/ ├── appinfo/ +│ ├── info.xml │ ├── info.xml +│ └── routes.php │ └── routes.php +├── lib/ ├── lib/ +│ ├── AppInfo/ │ ├── AppInfo/ +│ │ └── Application.php │ │ └── Application.php +│ ├── Controller/ │ ├── Controller/ +│ │ ├── DashboardController.php │ │ ├── DashboardController.php +│ │ └── SettingsController.php │ │ └── SettingsController.php +│ ├── Service/ │ ├── Service/ +│ │ └── SettingsService.php │ │ └── SettingsService.php +│ ├── Repair/ │ ├── Repair/ +│ │ └── InitializeSettings.php│ │ └── InitializeSettings.php +│ ├── Settings/ │ ├── Settings/ +│ │ ├── AdminSettings.php │ │ ├── AdminSettings.php +│ │ └── AdminSection.php │ │ └── AdminSection.php +│ └── Sections/ │ └── Sections/ +│ └── SettingsSection.php │ └── SettingsSection.php +├── src/ ├── src/ +│ ├── main.js │ ├── main.js +│ ├── settings.js │ ├── settings.js +│ ├── pinia.js │ ├── pinia.js +│ ├── App.vue │ ├── App.vue +│ ├── store/ │ ├── store/ +│ │ ├── store.js │ │ ├── store.js +│ │ └── modules/ │ │ └── modules/ +│ │ ├── object.js │ │ ├── object.js +│ │ ├── navigation.js │ │ ├── navigation.js +│ │ └── settings.js │ │ └── settings.js +│ ├── views/ │ ├── views/ +│ │ ├── Dashboard.vue │ │ ├── Dashboard.vue +│ │ ├── cases/ │ │ ├── clients/ +│ │ │ ├── CaseList.vue │ │ │ ├── ClientList.vue +│ │ │ └── CaseDetail.vue │ │ │ └── ClientDetail.vue +│ │ └── settings/ │ │ ├── requests/ +│ │ └── Settings.vue │ │ │ ├── RequestList.vue +│ │ │ │ │ └── RequestDetail.vue +│ │ │ │ └── settings/ +│ │ │ │ └── Settings.vue +│ ├── navigation/ │ ├── navigation/ +│ │ └── MainMenu.vue │ │ └── MainMenu.vue +│ └── components/ │ └── components/ +│ └── (shared UI) │ └── (shared UI) +├── templates/ ├── templates/ +│ └── index.php │ └── index.php +├── img/ ├── img/ +│ └── app.svg │ └── app.svg +├── l10n/ ├── l10n/ +│ ├── en.json │ ├── en.json +│ └── nl.json │ └── nl.json +├── webpack.config.js ├── webpack.config.js +├── package.json ├── package.json +├── composer.json ├── composer.json +└── .github/ └── .github/ + └── workflows/ └── workflows/ +``` + +## Translation / l10n + +Both apps are multilingual from day one: +- Use `t('procest', 'key')` in Vue templates and `$this->l->t('key')` in PHP +- Provide base translations in `l10n/en.json` (English primary) and `l10n/nl.json` (Dutch) +- All user-facing strings wrapped in translation functions — no hardcoded text +- Nextcloud's Transifex integration handles additional languages + +## Security Considerations + +- **Authentication**: Nextcloud session auth (automatic for logged-in users) +- **CSRF**: Nextcloud `requesttoken` header on all API calls (automatic via `@nextcloud/axios` or manual with `OC.requestToken`) +- **RBAC**: Handled entirely by OpenRegister — no additional access control layer +- **Input validation**: Delegated to OpenRegister schema validation +- **CORS**: Not needed — same-origin requests only (Nextcloud app) + +## NL Design System + +- Use `@nextcloud/vue` components as the base (NcButton, NcSelect, NcModal, etc.) +- Compatible with nldesign app for government-standard theming +- Avoid hardcoded colors — use CSS variables +- Ensure WCAG AA contrast and accessibility + +## Trade-offs + +### Thin client vs. thick client +**Chosen: Thin client (like softwarecatalog)** +- Pro: Much less code, no DB migrations, leverages OpenRegister fully +- Pro: Frontend drives the experience — faster iteration +- Con: Complex business logic harder to implement without backend +- Con: Multiple API calls from frontend (no backend aggregation) +- Mitigation: If business logic grows, add targeted backend services later + +### Two separate apps vs. one combined app +**Chosen: Two separate apps** +- Pro: Clear separation of concerns (cases vs. clients/requests) +- Pro: Can install independently — not everyone needs both +- Pro: Smaller, more focused codebases +- Con: Some code duplication (object store, settings pattern) +- Mitigation: Shared patterns are small and well-understood; copy is fine + +### Vue 2 vs. Vue 3 +**Chosen: Vue 2** +- Nextcloud ecosystem is standardized on Vue 2 +- All `@nextcloud/vue` components are Vue 2 +- Vue 3 migration can follow Nextcloud's timeline + +### Native fetch vs. @nextcloud/axios +**Chosen: Native fetch (following softwarecatalog pattern)** +- Simpler, no extra dependency for API calls +- Manual `requesttoken` header required but straightforward +- Consistent with the existing pattern in the codebase diff --git a/openspec/changes/archive/2026-03-06-create-procest-app/proposal.md b/openspec/changes/archive/2026-03-06-create-procest-app/proposal.md new file mode 100644 index 0000000..836385f --- /dev/null +++ b/openspec/changes/archive/2026-03-06-create-procest-app/proposal.md @@ -0,0 +1,87 @@ +# Proposal: create-procest-app + +## Summary +Create two new Nextcloud apps — **Procest** (case management) and **Pipelinq** (client & request management) — as thin clients on top of OpenRegister. Both follow the softwarecatalog architectural pattern: rich frontend Pinia store, direct OpenRegister API interaction, minimal backend. Procest focuses on cases (the equivalent of "zaken" in zaakafhandelapp). Pipelinq handles clients and "verzoeken" (requests — the pre-state of a case, or a yet-to-be-determined case). Both apps are multilingual from the start using Nextcloud's l10n framework. + +## Motivation +The zaakafhandelapp implements Dutch GEMMA-Zaken standards for case management but is tightly coupled to Dutch terminology and follows a "thick client" pattern with 15+ backend controllers and its own entity abstraction layer. By splitting the functionality into two focused apps and rebuilding as thin clients on OpenRegister, we get: +- **Separation of concerns**: cases (Procest) vs. clients & requests (Pipelinq) +- **Simpler codebase**: no own DB entities, leverages OpenRegister's CRUD, search, pagination, and RBAC +- **International reach**: multilingual from day one, English as primary language +- **Consistency**: same architecture as softwarecatalog — familiar patterns for the team + +## Affected Projects +- [ ] Project: `procest` (NEW) — Case management app +- [ ] Project: `pipelinq` (NEW) — Client & request management app +- [ ] Project: `openregister` — Register and schema definitions for both apps +- [ ] Project: `zaakafhandelapp` — Functional reference only; no changes + +## Scope + +### In Scope + +**Procest (Case Management)** +- New Nextcloud app scaffolding (appinfo, routes, webpack, Vue 2 + Pinia) +- Register: `case-management` with schemas for cases, tasks, statuses, results, roles, decisions +- Pinia-based object store querying OpenRegister directly +- Core views: Dashboard, Cases (list/detail), Tasks, Search +- Minimal backend: SettingsController + auto-configuration service +- Multilingual: Nextcloud l10n with English as primary, Dutch included + +**Pipelinq (Client & Request Management)** +- New Nextcloud app scaffolding (same stack as Procest) +- Register: `client-management` with schemas for clients, requests (verzoeken), contacts +- Same thin-client architecture as Procest +- Core views: Dashboard, Clients (list/detail), Requests (list/detail), Search +- Minimal backend: SettingsController + auto-configuration service +- Multilingual: same approach as Procest + +**GitHub Repositories** +- Create `ConductionNL/procest` repository +- Create `ConductionNL/pipelinq` repository + +**Shared Patterns** +- NL Design System compatible theming +- Dynamic navigation from available schemas (like softwarecatalog's MainMenu) +- RBAC handled entirely by OpenRegister — no additional access control layer + +### Out of Scope +- GEMMA-Zaken API compliance (stays in zaakafhandelapp) +- Own database entities or migrations — all data lives in OpenRegister +- Elasticsearch integration (may be added later) +- Cloud Events / webhooks (may be added later) +- Migration tooling from zaakafhandelapp +- Case-to-request linking between apps (future feature) + +## Approach +1. **Scaffold both apps** with standard Nextcloud app structure (info.xml, routes, webpack, Vue 2 + Pinia, l10n) +2. **Define registers/schemas** in OpenRegister — `case-management` for Procest, `client-management` for Pipelinq +3. **Build shared object store pattern** — Pinia store with actions that construct OpenRegister API URLs, handle pagination, search, and CRUD (same pattern for both apps) +4. **Build views** for each entity type — list/detail pages using Nextcloud Vue components +5. **Minimal backend** — SettingsController for register/schema config, auto-config service per app +6. **Navigation** — Dynamic menu from schemas (like softwarecatalog) +7. **Translations** — Set up l10n from day one with English + Dutch + +## Cross-Project Dependencies +- **OpenRegister** — Must be installed and active; both apps store all data there +- **NL Design** — Optional but recommended for government-standard theming +- **zaakafhandelapp** — No runtime dependency; functional reference only +- **Procest ↔ Pipelinq** — Independent apps, no direct dependency (future linking possible) + +## Rollback Strategy +- Both apps are standalone — disabling either has no impact on other apps +- Data lives in OpenRegister and persists independently +- Simply disable the app in Nextcloud admin to roll back + +## Capabilities + +### New Capabilities +- `procest-app-scaffold` — Nextcloud app scaffolding, build system, l10n setup for Procest +- `procest-case-management` — Case CRUD, task management, statuses, roles, decisions +- `procest-object-store` — Pinia store pattern for OpenRegister interaction +- `pipelinq-app-scaffold` — Nextcloud app scaffolding, build system, l10n setup for Pipelinq +- `pipelinq-client-management` — Client CRUD, request (verzoek) management, contacts +- `pipelinq-object-store` — Pinia store pattern for OpenRegister interaction + +### Modified Capabilities +(none — these are new apps) diff --git a/openspec/changes/archive/2026-03-06-create-procest-app/specs/pipelinq-app-scaffold/spec.md b/openspec/changes/archive/2026-03-06-create-procest-app/specs/pipelinq-app-scaffold/spec.md new file mode 100644 index 0000000..60473d2 --- /dev/null +++ b/openspec/changes/archive/2026-03-06-create-procest-app/specs/pipelinq-app-scaffold/spec.md @@ -0,0 +1,77 @@ +# pipelinq-app-scaffold Specification + +## Purpose +Define the Nextcloud app scaffolding, build system, translation setup, and admin settings for the Pipelinq client and request management app. Mirrors the Procest scaffold with its own app identity. + +## ADDED Requirements + +### Requirement: App MUST be a valid Nextcloud app +The Pipelinq app MUST be installable as a standard Nextcloud app with proper metadata, namespace, and dependency declarations. + +#### Scenario: App registration +- GIVEN the Pipelinq app directory exists in apps-extra +- WHEN Nextcloud scans for available apps +- THEN the app MUST appear in the apps list with id `pipelinq`, name "Pipelinq", and namespace `Pipelinq` +- AND it MUST declare compatibility with Nextcloud 28-33 +- AND it MUST declare PHP 8.1+ as minimum requirement + +#### Scenario: App enable +- GIVEN Nextcloud is running and OpenRegister is installed +- WHEN an admin enables the Pipelinq app +- THEN the app MUST activate without errors +- AND it MUST register a navigation entry in the top bar + +### Requirement: App MUST provide a single-page application entry point +The app MUST serve a Vue 2 SPA from a dashboard controller that mounts to the `#content` element. + +#### Scenario: Dashboard page load +- GIVEN the app is enabled and a user is logged in +- WHEN the user navigates to `/apps/pipelinq/` +- THEN the server MUST return an HTML page with a `#content` mount point +- AND the page MUST load the `pipelinq-main.js` webpack bundle +- AND the Vue app MUST initialize with Pinia state management + +### Requirement: App MUST use webpack build system extending Nextcloud base config +The build system MUST extend `@nextcloud/webpack-vue-config` with two entry points. + +#### Scenario: Build produces correct bundles +- GIVEN the source files exist in `src/` +- WHEN `npm run build` is executed +- THEN it MUST produce `js/pipelinq-main.js` for the dashboard SPA +- AND it MUST produce `js/pipelinq-settings.js` for the admin settings page + +### Requirement: App MUST support multilingual translations +All user-facing strings MUST be wrapped in translation functions with English as the primary language and Dutch included. + +#### Scenario: English translation +- GIVEN a user with English locale +- WHEN viewing the Pipelinq app +- THEN all UI text MUST be displayed in English + +#### Scenario: Dutch translation +- GIVEN a user with Dutch locale +- WHEN viewing the Pipelinq app +- THEN all UI text MUST be displayed in Dutch + +#### Scenario: Translation function usage +- GIVEN any Vue component with user-facing text +- WHEN the component renders +- THEN all strings MUST use `t('pipelinq', 'key')` in templates +- AND all PHP strings MUST use `$this->l->t('key')` + +### Requirement: App MUST provide admin settings page +The app MUST register an admin settings section for register/schema configuration. + +#### Scenario: Settings page access +- GIVEN an admin user +- WHEN navigating to `/settings/admin/pipelinq` +- THEN the admin settings page MUST load with the `pipelinq-settings.js` bundle +- AND it MUST display configuration options for register and schema mappings + +### Requirement: App MUST have a GitHub repository +The app source code MUST be hosted at `ConductionNL/pipelinq` on GitHub. + +#### Scenario: Repository exists +- GIVEN the ConductionNL GitHub organization +- WHEN checking for the pipelinq repository +- THEN `https://github.com/ConductionNL/pipelinq` MUST exist and be public diff --git a/openspec/changes/archive/2026-03-06-create-procest-app/specs/pipelinq-client-management/spec.md b/openspec/changes/archive/2026-03-06-create-procest-app/specs/pipelinq-client-management/spec.md new file mode 100644 index 0000000..64b6781 --- /dev/null +++ b/openspec/changes/archive/2026-03-06-create-procest-app/specs/pipelinq-client-management/spec.md @@ -0,0 +1,133 @@ +# pipelinq-client-management Specification + +## Purpose +Define the client and request management domain for Pipelinq: clients, requests (verzoeken), and contacts. All entities are stored in OpenRegister under the `client-management` register. Requests represent the pre-state of a case — a yet-to-be-determined or incoming case before it enters formal case management in Procest. + +## ADDED Requirements + +### Requirement: Client-management register MUST be auto-configured on install +The app MUST create or detect the `client-management` register and its schemas in OpenRegister during app initialization. + +#### Scenario: First install with no existing register +- GIVEN OpenRegister is active and no `client-management` register exists +- WHEN the Pipelinq app is enabled for the first time +- THEN a repair step MUST create the `client-management` register +- AND it MUST create schemas for: client, request, contact +- AND it MUST store the register and schema IDs in app configuration + +#### Scenario: Install with existing register +- GIVEN OpenRegister has a `client-management` register already configured +- WHEN the Pipelinq app is enabled +- THEN the repair step MUST detect and use the existing register +- AND it MUST store the found register/schema IDs in app configuration + +### Requirement: Settings endpoint MUST return register/schema configuration +The backend MUST provide an API endpoint that returns the configured register and schema IDs. + +#### Scenario: Get configuration +- GIVEN the app is configured with register and schema IDs +- WHEN a GET request is made to `/api/settings` +- THEN the response MUST include `register`, `client_schema`, `request_schema`, `contact_schema` +- AND the response status MUST be 200 + +#### Scenario: Save configuration +- GIVEN an admin user +- WHEN a POST request is made to `/api/settings` with register/schema IDs +- THEN the configuration MUST be persisted in app config +- AND the response MUST confirm success + +### Requirement: App MUST provide a clients list view +The frontend MUST display a paginated, searchable list of clients. + +#### Scenario: Clients list page +- GIVEN the user navigates to the clients section +- WHEN the page loads +- THEN the object store MUST fetch clients from OpenRegister using the configured register/schema +- AND the list MUST display client name, type (person/organization), email, and phone +- AND the list MUST support pagination + +#### Scenario: Clients search +- GIVEN the clients list is displayed +- WHEN the user enters a search term +- THEN the object store MUST query OpenRegister with the `_search` parameter +- AND the list MUST update to show matching results + +### Requirement: App MUST provide a client detail view +The frontend MUST display client details with related requests and contacts. + +#### Scenario: Client detail page +- GIVEN the user clicks a client in the list +- WHEN the detail view loads +- THEN the object store MUST fetch the full client object by ID +- AND the view MUST display all client fields (name, type, email, phone, address, notes) +- AND the view MUST list requests associated with this client +- AND the view MUST list contacts associated with this client + +### Requirement: App MUST support client CRUD operations +The frontend MUST allow creating, editing, and deleting clients via OpenRegister. + +#### Scenario: Create client +- GIVEN the user is on the clients list +- WHEN the user clicks "New client" and fills in the form +- THEN the object store MUST POST to OpenRegister with the client data +- AND the new client MUST appear in the list + +#### Scenario: Edit client +- GIVEN the user is viewing a client detail +- WHEN the user modifies fields and saves +- THEN the object store MUST PUT to OpenRegister with the updated data +- AND the detail view MUST reflect the changes + +#### Scenario: Delete client +- GIVEN the user is viewing a client detail +- WHEN the user confirms deletion +- THEN the object store MUST DELETE the client from OpenRegister +- AND the user MUST be navigated back to the list + +### Requirement: App MUST provide a requests list view +The frontend MUST display a paginated, searchable list of requests (verzoeken). + +#### Scenario: Requests list page +- GIVEN the user navigates to the requests section +- WHEN the page loads +- THEN the object store MUST fetch requests from OpenRegister +- AND the list MUST display request title, client name, status, priority, and requested date +- AND the list MUST support pagination + +### Requirement: App MUST provide a request detail view +The frontend MUST display request details with the linked client. + +#### Scenario: Request detail page +- GIVEN the user clicks a request in the list +- WHEN the detail view loads +- THEN the object store MUST fetch the full request object by ID +- AND the view MUST display all request fields (title, description, client, status, priority, category, requestedAt) +- AND the view MUST show a link to the associated client + +### Requirement: App MUST support request CRUD operations +The frontend MUST allow creating, editing, and deleting requests via OpenRegister. + +#### Scenario: Create request +- GIVEN the user is on the requests list or a client detail +- WHEN the user creates a new request +- THEN the request MUST be saved to OpenRegister +- AND if created from a client detail, it MUST include a reference to that client + +#### Scenario: Edit request +- GIVEN the user is viewing a request detail +- WHEN the user modifies fields and saves +- THEN the object store MUST PUT to OpenRegister with the updated data + +#### Scenario: Delete request +- GIVEN the user is viewing a request detail +- WHEN the user confirms deletion +- THEN the object store MUST DELETE the request from OpenRegister + +### Requirement: Navigation MUST include clients and requests menu items +The app navigation MUST show menu items for the primary entity types. + +#### Scenario: Navigation rendering +- GIVEN the user opens the Pipelinq app +- WHEN the navigation loads +- THEN the menu MUST include at minimum "Dashboard", "Clients", and "Requests" items +- AND clicking each item MUST navigate to the corresponding list view diff --git a/openspec/changes/archive/2026-03-06-create-procest-app/specs/pipelinq-object-store/spec.md b/openspec/changes/archive/2026-03-06-create-procest-app/specs/pipelinq-object-store/spec.md new file mode 100644 index 0000000..645a2ad --- /dev/null +++ b/openspec/changes/archive/2026-03-06-create-procest-app/specs/pipelinq-object-store/spec.md @@ -0,0 +1,101 @@ +# pipelinq-object-store Specification + +## Purpose +Define the Pinia-based object store that provides the data layer for Pipelinq. Identical pattern to the Procest object store — queries OpenRegister directly from the frontend for all CRUD, search, and pagination operations. + +## ADDED Requirements + +### Requirement: Object store MUST use Pinia with dynamic type registration +The store MUST support registering object types at runtime, each mapped to an OpenRegister register/schema pair. + +#### Scenario: Register object type +- GIVEN the app settings have been loaded with register/schema IDs +- WHEN `registerObjectType('client', schemaId, registerId)` is called +- THEN the store MUST record the mapping in `objectTypeRegistry` +- AND subsequent CRUD actions for type `client` MUST use the correct register/schema + +#### Scenario: Unregister object type +- GIVEN an object type is registered +- WHEN `unregisterObjectType('client')` is called +- THEN the type MUST be removed from the registry +- AND its cached data MUST be cleared + +### Requirement: Object store MUST fetch collections from OpenRegister +The store MUST provide a `fetchCollection` action that queries OpenRegister's list endpoint with pagination and search support. + +#### Scenario: Fetch paginated collection +- GIVEN object type `client` is registered with register=6, schema=40 +- WHEN `fetchCollection('client', { _limit: 20, _offset: 0 })` is called +- THEN the store MUST fetch `GET /apps/openregister/api/objects/6/40?_limit=20&_offset=0` +- AND the response results MUST be stored in `collections.client` +- AND pagination metadata MUST be stored in `pagination.client` + +#### Scenario: Fetch with search +- GIVEN the user searches for "Gemeente Amsterdam" +- WHEN `fetchCollection('client', { _search: 'Gemeente Amsterdam' })` is called +- THEN the store MUST include `_search=Gemeente+Amsterdam` in the query +- AND results MUST reflect the search filter + +### Requirement: Object store MUST fetch individual objects +The store MUST provide a `fetchObject` action that retrieves a single object by ID. + +#### Scenario: Fetch single object +- GIVEN object type `client` is registered +- WHEN `fetchObject('client', 'uuid-456')` is called +- THEN the store MUST fetch `GET /apps/openregister/api/objects/6/40/uuid-456` +- AND the object MUST be stored in `objects.client['uuid-456']` + +### Requirement: Object store MUST support create, update, and delete +The store MUST provide actions for full CRUD operations against OpenRegister. + +#### Scenario: Create object +- GIVEN object type `request` is registered +- WHEN `saveObject('request', { title: 'New request', client: 'uuid-456' })` is called with no existing ID +- THEN the store MUST POST to OpenRegister +- AND the created object MUST be added to the store + +#### Scenario: Update object +- GIVEN a client object exists with ID `uuid-456` +- WHEN `saveObject('client', { id: 'uuid-456', name: 'Updated' })` is called +- THEN the store MUST PUT to OpenRegister +- AND the store MUST update `objects.client['uuid-456']` + +#### Scenario: Delete object +- GIVEN a request object exists with ID `uuid-789` +- WHEN `deleteObject('request', 'uuid-789')` is called +- THEN the store MUST DELETE from OpenRegister +- AND the object MUST be removed from the store + +### Requirement: Object store MUST track loading and error states +The store MUST provide reactive loading and error states per object type. + +#### Scenario: Loading state during fetch +- GIVEN a collection fetch is in progress for type `client` +- WHEN a component checks `isLoading('client')` +- THEN it MUST return `true` +- AND when the fetch completes, it MUST return `false` + +#### Scenario: Error state on failure +- GIVEN an API call fails with a network error +- WHEN the store processes the error +- THEN `errors.client` MUST contain the error message +- AND the loading state MUST be set to `false` + +### Requirement: Object store MUST load settings before data operations +The store MUST fetch app settings on initialization before any object type can be registered. + +#### Scenario: Settings initialization +- GIVEN the app is loading for the first time +- WHEN the store initializes +- THEN it MUST fetch `/apps/pipelinq/api/settings` to get register/schema configuration +- AND it MUST register all object types using the returned IDs +- AND data fetching MUST NOT proceed until settings are loaded + +### Requirement: All API calls MUST include Nextcloud authentication headers +Every fetch request to OpenRegister MUST include the CSRF token and OCS header. + +#### Scenario: Authenticated request +- GIVEN a store action makes a fetch call +- WHEN the request is constructed +- THEN it MUST include `requesttoken: OC.requestToken` header +- AND it MUST include `OCS-APIREQUEST: true` header diff --git a/openspec/changes/archive/2026-03-06-create-procest-app/specs/procest-app-scaffold/spec.md b/openspec/changes/archive/2026-03-06-create-procest-app/specs/procest-app-scaffold/spec.md new file mode 100644 index 0000000..0cbc70b --- /dev/null +++ b/openspec/changes/archive/2026-03-06-create-procest-app/specs/procest-app-scaffold/spec.md @@ -0,0 +1,77 @@ +# procest-app-scaffold Specification + +## Purpose +Define the Nextcloud app scaffolding, build system, translation setup, and admin settings for the Procest case management app. This capability establishes the foundational structure that all other capabilities build upon. + +## ADDED Requirements + +### Requirement: App MUST be a valid Nextcloud app +The Procest app MUST be installable as a standard Nextcloud app with proper metadata, namespace, and dependency declarations. + +#### Scenario: App registration +- GIVEN the Procest app directory exists in apps-extra +- WHEN Nextcloud scans for available apps +- THEN the app MUST appear in the apps list with id `procest`, name "Procest", and namespace `Procest` +- AND it MUST declare compatibility with Nextcloud 28-33 +- AND it MUST declare PHP 8.1+ as minimum requirement + +#### Scenario: App enable +- GIVEN Nextcloud is running and OpenRegister is installed +- WHEN an admin enables the Procest app +- THEN the app MUST activate without errors +- AND it MUST register a navigation entry in the top bar + +### Requirement: App MUST provide a single-page application entry point +The app MUST serve a Vue 2 SPA from a dashboard controller that mounts to the `#content` element. + +#### Scenario: Dashboard page load +- GIVEN the app is enabled and a user is logged in +- WHEN the user navigates to `/apps/procest/` +- THEN the server MUST return an HTML page with a `#content` mount point +- AND the page MUST load the `procest-main.js` webpack bundle +- AND the Vue app MUST initialize with Pinia state management + +### Requirement: App MUST use webpack build system extending Nextcloud base config +The build system MUST extend `@nextcloud/webpack-vue-config` with two entry points. + +#### Scenario: Build produces correct bundles +- GIVEN the source files exist in `src/` +- WHEN `npm run build` is executed +- THEN it MUST produce `js/procest-main.js` for the dashboard SPA +- AND it MUST produce `js/procest-settings.js` for the admin settings page + +### Requirement: App MUST support multilingual translations +All user-facing strings MUST be wrapped in translation functions with English as the primary language and Dutch included. + +#### Scenario: English translation +- GIVEN a user with English locale +- WHEN viewing the Procest app +- THEN all UI text MUST be displayed in English + +#### Scenario: Dutch translation +- GIVEN a user with Dutch locale +- WHEN viewing the Procest app +- THEN all UI text MUST be displayed in Dutch + +#### Scenario: Translation function usage +- GIVEN any Vue component with user-facing text +- WHEN the component renders +- THEN all strings MUST use `t('procest', 'key')` in templates +- AND all PHP strings MUST use `$this->l->t('key')` + +### Requirement: App MUST provide admin settings page +The app MUST register an admin settings section for register/schema configuration. + +#### Scenario: Settings page access +- GIVEN an admin user +- WHEN navigating to `/settings/admin/procest` +- THEN the admin settings page MUST load with the `procest-settings.js` bundle +- AND it MUST display configuration options for register and schema mappings + +### Requirement: App MUST have a GitHub repository +The app source code MUST be hosted at `ConductionNL/procest` on GitHub. + +#### Scenario: Repository exists +- GIVEN the ConductionNL GitHub organization +- WHEN checking for the procest repository +- THEN `https://github.com/ConductionNL/procest` MUST exist and be public diff --git a/openspec/changes/archive/2026-03-06-create-procest-app/specs/procest-case-management/spec.md b/openspec/changes/archive/2026-03-06-create-procest-app/specs/procest-case-management/spec.md new file mode 100644 index 0000000..bee6637 --- /dev/null +++ b/openspec/changes/archive/2026-03-06-create-procest-app/specs/procest-case-management/spec.md @@ -0,0 +1,108 @@ +# procest-case-management Specification + +## Purpose +Define the case management domain for Procest: cases, tasks, statuses, roles, results, and decisions. All entities are stored in OpenRegister under the `case-management` register. The frontend provides list and detail views for cases and tasks. + +## ADDED Requirements + +### Requirement: Case-management register MUST be auto-configured on install +The app MUST create or detect the `case-management` register and its schemas in OpenRegister during app initialization. + +#### Scenario: First install with no existing register +- GIVEN OpenRegister is active and no `case-management` register exists +- WHEN the Procest app is enabled for the first time +- THEN a repair step MUST create the `case-management` register +- AND it MUST create schemas for: case, task, status, role, result, decision +- AND it MUST store the register and schema IDs in app configuration + +#### Scenario: Install with existing register +- GIVEN OpenRegister has a `case-management` register already configured +- WHEN the Procest app is enabled +- THEN the repair step MUST detect and use the existing register +- AND it MUST store the found register/schema IDs in app configuration + +### Requirement: Settings endpoint MUST return register/schema configuration +The backend MUST provide an API endpoint that returns the configured register and schema IDs. + +#### Scenario: Get configuration +- GIVEN the app is configured with register and schema IDs +- WHEN a GET request is made to `/api/settings` +- THEN the response MUST include `register`, `case_schema`, `task_schema`, `status_schema`, `role_schema`, `result_schema`, `decision_schema` +- AND the response status MUST be 200 + +#### Scenario: Save configuration +- GIVEN an admin user +- WHEN a POST request is made to `/api/settings` with register/schema IDs +- THEN the configuration MUST be persisted in app config +- AND the response MUST confirm success + +### Requirement: App MUST provide a cases list view +The frontend MUST display a paginated, searchable list of cases. + +#### Scenario: Cases list page +- GIVEN the user navigates to the cases section +- WHEN the page loads +- THEN the object store MUST fetch cases from OpenRegister using the configured register/schema +- AND the list MUST display case title, status, assignee, and created date +- AND the list MUST support pagination + +#### Scenario: Cases search +- GIVEN the cases list is displayed +- WHEN the user enters a search term +- THEN the object store MUST query OpenRegister with the `_search` parameter +- AND the list MUST update to show matching results + +### Requirement: App MUST provide a case detail view +The frontend MUST display case details with related tasks. + +#### Scenario: Case detail page +- GIVEN the user clicks a case in the list +- WHEN the detail view loads +- THEN the object store MUST fetch the full case object by ID +- AND the view MUST display all case fields (title, description, status, assignee, priority, dates) +- AND the view MUST list tasks associated with this case + +### Requirement: App MUST support case CRUD operations +The frontend MUST allow creating, editing, and deleting cases via OpenRegister. + +#### Scenario: Create case +- GIVEN the user is on the cases list +- WHEN the user clicks "New case" and fills in the form +- THEN the object store MUST POST to OpenRegister with the case data +- AND the new case MUST appear in the list + +#### Scenario: Edit case +- GIVEN the user is viewing a case detail +- WHEN the user modifies fields and saves +- THEN the object store MUST PUT to OpenRegister with the updated data +- AND the detail view MUST reflect the changes + +#### Scenario: Delete case +- GIVEN the user is viewing a case detail +- WHEN the user confirms deletion +- THEN the object store MUST DELETE the case from OpenRegister +- AND the user MUST be navigated back to the list + +### Requirement: App MUST provide task management within cases +The frontend MUST support creating, editing, and completing tasks linked to a case. + +#### Scenario: Task list within case +- GIVEN the user is viewing a case detail +- WHEN the tasks section loads +- THEN tasks MUST be fetched from OpenRegister filtered by the case ID +- AND each task MUST show title, status, assignee, and due date + +#### Scenario: Create task +- GIVEN the user is viewing a case detail +- WHEN the user creates a new task +- THEN the task MUST be saved to OpenRegister with a reference to the parent case +- AND it MUST appear in the case's task list + +### Requirement: Navigation MUST include cases and tasks menu items +The app navigation MUST show menu items for Cases and optionally Tasks. + +#### Scenario: Navigation rendering +- GIVEN the user opens the Procest app +- WHEN the navigation loads +- THEN the menu MUST include at minimum a "Dashboard" item and a "Cases" item +- AND clicking "Cases" MUST navigate to the cases list view diff --git a/openspec/changes/archive/2026-03-06-create-procest-app/specs/procest-object-store/spec.md b/openspec/changes/archive/2026-03-06-create-procest-app/specs/procest-object-store/spec.md new file mode 100644 index 0000000..3bcfc97 --- /dev/null +++ b/openspec/changes/archive/2026-03-06-create-procest-app/specs/procest-object-store/spec.md @@ -0,0 +1,101 @@ +# procest-object-store Specification + +## Purpose +Define the Pinia-based object store that provides the data layer for Procest. The store queries OpenRegister directly from the frontend for all CRUD, search, and pagination operations — following the softwarecatalog thin-client pattern. + +## ADDED Requirements + +### Requirement: Object store MUST use Pinia with dynamic type registration +The store MUST support registering object types at runtime, each mapped to an OpenRegister register/schema pair. + +#### Scenario: Register object type +- GIVEN the app settings have been loaded with register/schema IDs +- WHEN `registerObjectType('case', schemaId, registerId)` is called +- THEN the store MUST record the mapping in `objectTypeRegistry` +- AND subsequent CRUD actions for type `case` MUST use the correct register/schema + +#### Scenario: Unregister object type +- GIVEN an object type is registered +- WHEN `unregisterObjectType('case')` is called +- THEN the type MUST be removed from the registry +- AND its cached data MUST be cleared + +### Requirement: Object store MUST fetch collections from OpenRegister +The store MUST provide a `fetchCollection` action that queries OpenRegister's list endpoint with pagination and search support. + +#### Scenario: Fetch paginated collection +- GIVEN object type `case` is registered with register=5, schema=30 +- WHEN `fetchCollection('case', { _limit: 20, _offset: 0 })` is called +- THEN the store MUST fetch `GET /apps/openregister/api/objects/5/30?_limit=20&_offset=0` +- AND the response results MUST be stored in `collections.case` +- AND pagination metadata MUST be stored in `pagination.case` + +#### Scenario: Fetch with search +- GIVEN the user searches for "building permit" +- WHEN `fetchCollection('case', { _search: 'building permit' })` is called +- THEN the store MUST include `_search=building+permit` in the query +- AND results MUST reflect the search filter + +### Requirement: Object store MUST fetch individual objects +The store MUST provide a `fetchObject` action that retrieves a single object by ID. + +#### Scenario: Fetch single object +- GIVEN object type `case` is registered +- WHEN `fetchObject('case', 'uuid-123')` is called +- THEN the store MUST fetch `GET /apps/openregister/api/objects/5/30/uuid-123` +- AND the object MUST be stored in `objects.case['uuid-123']` + +### Requirement: Object store MUST support create, update, and delete +The store MUST provide actions for full CRUD operations against OpenRegister. + +#### Scenario: Create object +- GIVEN object type `case` is registered +- WHEN `saveObject('case', { title: 'New case', status: 'open' })` is called with no existing ID +- THEN the store MUST POST to `/apps/openregister/api/objects/5/30` +- AND the created object MUST be added to the store + +#### Scenario: Update object +- GIVEN a case object exists with ID `uuid-123` +- WHEN `saveObject('case', { id: 'uuid-123', title: 'Updated' })` is called +- THEN the store MUST PUT to `/apps/openregister/api/objects/5/30/uuid-123` +- AND the store MUST update `objects.case['uuid-123']` + +#### Scenario: Delete object +- GIVEN a case object exists with ID `uuid-123` +- WHEN `deleteObject('case', 'uuid-123')` is called +- THEN the store MUST DELETE `/apps/openregister/api/objects/5/30/uuid-123` +- AND `objects.case['uuid-123']` MUST be removed from the store + +### Requirement: Object store MUST track loading and error states +The store MUST provide reactive loading and error states per object type. + +#### Scenario: Loading state during fetch +- GIVEN a collection fetch is in progress for type `case` +- WHEN a component checks `isLoading('case')` +- THEN it MUST return `true` +- AND when the fetch completes, it MUST return `false` + +#### Scenario: Error state on failure +- GIVEN an API call fails with a network error +- WHEN the store processes the error +- THEN `errors.case` MUST contain the error message +- AND the loading state MUST be set to `false` + +### Requirement: Object store MUST load settings before data operations +The store MUST fetch app settings (register/schema IDs) on initialization before any object type can be registered. + +#### Scenario: Settings initialization +- GIVEN the app is loading for the first time +- WHEN the store initializes +- THEN it MUST fetch `/apps/procest/api/settings` to get register/schema configuration +- AND it MUST register all object types using the returned IDs +- AND data fetching MUST NOT proceed until settings are loaded + +### Requirement: All API calls MUST include Nextcloud authentication headers +Every fetch request to OpenRegister MUST include the CSRF token and OCS header. + +#### Scenario: Authenticated request +- GIVEN a store action makes a fetch call +- WHEN the request is constructed +- THEN it MUST include `requesttoken: OC.requestToken` header +- AND it MUST include `OCS-APIREQUEST: true` header diff --git a/openspec/changes/archive/2026-03-06-create-procest-app/tasks.md b/openspec/changes/archive/2026-03-06-create-procest-app/tasks.md new file mode 100644 index 0000000..db8e73e --- /dev/null +++ b/openspec/changes/archive/2026-03-06-create-procest-app/tasks.md @@ -0,0 +1,321 @@ +# Tasks: create-procest-app + +## 1. Procest App Scaffold + +### Task 1: Create Procest app directory structure and info.xml +- **spec_ref**: `openspec/changes/create-procest-app/specs/procest-app-scaffold/spec.md#requirement-app-must-be-a-valid-nextcloud-app` +- **files**: `procest/appinfo/info.xml`, `procest/composer.json`, `procest/img/app.svg` +- **acceptance_criteria**: + - GIVEN the procest directory exists in apps-extra WHEN Nextcloud scans for apps THEN it MUST appear with id `procest`, name "Procest", namespace `Procest` + - AND it MUST declare compatibility with Nextcloud 28-33 and PHP 8.1+ +- [x] Create `procest/` directory with `appinfo/info.xml`, `composer.json`, `img/app.svg` + +### Task 2: Create Procest PHP backend (Application, Controllers, Services) +- **spec_ref**: `openspec/changes/create-procest-app/specs/procest-app-scaffold/spec.md#requirement-app-must-provide-a-single-page-application-entry-point` +- **files**: `procest/lib/AppInfo/Application.php`, `procest/lib/Controller/DashboardController.php`, `procest/lib/Controller/SettingsController.php`, `procest/lib/Service/SettingsService.php`, `procest/appinfo/routes.php`, `procest/templates/index.php` +- **acceptance_criteria**: + - GIVEN the app is enabled WHEN a user navigates to `/apps/procest/` THEN the server MUST return an HTML page with a `#content` mount point + - GIVEN a GET request to `/api/settings` THEN it MUST return register and schema IDs + - GIVEN an admin POST to `/api/settings` THEN it MUST persist config +- [x] Create Application.php with IBootstrap registration +- [x] Create DashboardController with index action +- [x] Create SettingsController with get/save endpoints +- [x] Create SettingsService for IAppConfig read/write +- [x] Create routes.php and templates/index.php + +### Task 3: Create Procest admin settings page +- **spec_ref**: `openspec/changes/create-procest-app/specs/procest-app-scaffold/spec.md#requirement-app-must-provide-admin-settings-page` +- **files**: `procest/lib/Settings/AdminSettings.php`, `procest/lib/Settings/AdminSection.php`, `procest/lib/Sections/SettingsSection.php` +- **acceptance_criteria**: + - GIVEN an admin user WHEN navigating to `/settings/admin/procest` THEN the admin settings page MUST load with the `procest-settings.js` bundle +- [x] Create AdminSettings and AdminSection classes +- [x] Register settings section in info.xml + +### Task 4: Create Procest repair step for auto-configuration +- **spec_ref**: `openspec/changes/create-procest-app/specs/procest-case-management/spec.md#requirement-case-management-register-must-be-auto-configured-on-install` +- **files**: `procest/lib/Repair/InitializeSettings.php` +- **acceptance_criteria**: + - GIVEN OpenRegister is active and no `case-management` register exists WHEN Procest is enabled THEN a repair step MUST create the register with schemas for case, task, status, role, result, decision + - GIVEN a `case-management` register already exists WHEN Procest is enabled THEN it MUST detect and use the existing register +- [x] Create InitializeSettings repair step +- [x] Register repair step in info.xml + +### Task 5: Create Procest webpack and frontend entry points +- **spec_ref**: `openspec/changes/create-procest-app/specs/procest-app-scaffold/spec.md#requirement-app-must-use-webpack-build-system-extending-nextcloud-base-config` +- **files**: `procest/webpack.config.js`, `procest/package.json`, `procest/src/main.js`, `procest/src/settings.js`, `procest/src/pinia.js`, `procest/src/App.vue` +- **acceptance_criteria**: + - GIVEN source files exist in `src/` WHEN `npm run build` is executed THEN it MUST produce `js/procest-main.js` and `js/procest-settings.js` + - AND the Vue app MUST initialize with Pinia state management +- [x] Create package.json with Nextcloud dependencies +- [x] Create webpack.config.js extending @nextcloud/webpack-vue-config +- [x] Create main.js, settings.js, pinia.js, and App.vue entry points + +### Task 6: Create Procest l10n translations +- **spec_ref**: `openspec/changes/create-procest-app/specs/procest-app-scaffold/spec.md#requirement-app-must-support-multilingual-translations` +- **files**: `procest/l10n/en.json`, `procest/l10n/nl.json` +- **acceptance_criteria**: + - GIVEN a user with English locale WHEN viewing Procest THEN all text MUST be in English + - GIVEN a user with Dutch locale WHEN viewing Procest THEN all text MUST be in Dutch +- [x] Create l10n/en.json and l10n/nl.json with all UI strings + +## 2. Procest Object Store + +### Task 7: Create Procest Pinia object store with type registration +- **spec_ref**: `openspec/changes/create-procest-app/specs/procest-object-store/spec.md#requirement-object-store-must-use-pinia-with-dynamic-type-registration` +- **files**: `procest/src/store/modules/object.js` +- **acceptance_criteria**: + - GIVEN app settings are loaded WHEN `registerObjectType('case', schemaId, registerId)` is called THEN the store MUST record the mapping + - GIVEN an object type is registered WHEN `unregisterObjectType('case')` is called THEN the type MUST be removed and cached data cleared +- [x] Create object store with registerObjectType/unregisterObjectType actions +- [x] Implement objectTypeRegistry state and getters + +### Task 8: Create Procest object store CRUD and collection actions +- **spec_ref**: `openspec/changes/create-procest-app/specs/procest-object-store/spec.md#requirement-object-store-must-fetch-collections-from-openregister` +- **files**: `procest/src/store/modules/object.js` +- **acceptance_criteria**: + - GIVEN type `case` is registered WHEN `fetchCollection('case', { _limit: 20 })` is called THEN it MUST fetch from OpenRegister with correct URL + - GIVEN type `case` is registered WHEN `fetchObject('case', 'uuid-123')` is called THEN it MUST fetch single object by ID + - GIVEN `saveObject('case', { title: 'New' })` is called with no ID THEN it MUST POST to OpenRegister + - GIVEN `saveObject('case', { id: 'uuid-123' })` is called THEN it MUST PUT to OpenRegister + - GIVEN `deleteObject('case', 'uuid-123')` is called THEN it MUST DELETE from OpenRegister +- [x] Implement fetchCollection with pagination and search +- [x] Implement fetchObject for single objects +- [x] Implement saveObject (create/update) and deleteObject +- [x] All requests MUST include requesttoken and OCS-APIREQUEST headers + +### Task 9: Create Procest settings store and initialization +- **spec_ref**: `openspec/changes/create-procest-app/specs/procest-object-store/spec.md#requirement-object-store-must-load-settings-before-data-operations` +- **files**: `procest/src/store/modules/settings.js`, `procest/src/store/store.js` +- **acceptance_criteria**: + - GIVEN the app is loading WHEN the store initializes THEN it MUST fetch `/apps/procest/api/settings` first + - AND it MUST register all object types using the returned IDs + - AND data fetching MUST NOT proceed until settings are loaded +- [x] Create settings store module with fetchSettings action +- [x] Create main store.js that initializes settings then registers types +- [x] Implement loading and error state tracking per object type + +## 3. Procest Case Management Views + +### Task 10: Create Procest navigation and routing +- **spec_ref**: `openspec/changes/create-procest-app/specs/procest-case-management/spec.md#requirement-navigation-must-include-cases-and-tasks-menu-items` +- **files**: `procest/src/navigation/MainMenu.vue`, `procest/src/App.vue` +- **acceptance_criteria**: + - GIVEN the user opens Procest WHEN the navigation loads THEN the menu MUST include "Dashboard" and "Cases" items + - AND clicking each item MUST navigate to the corresponding view +- [x] Create MainMenu.vue with navigation items +- [x] Set up Vue Router in App.vue with routes for dashboard, cases list, case detail + +### Task 11: Create Procest cases list and detail views +- **spec_ref**: `openspec/changes/create-procest-app/specs/procest-case-management/spec.md#requirement-app-must-provide-a-cases-list-view` +- **files**: `procest/src/views/Dashboard.vue`, `procest/src/views/cases/CaseList.vue`, `procest/src/views/cases/CaseDetail.vue` +- **acceptance_criteria**: + - GIVEN the user navigates to cases WHEN the page loads THEN it MUST display case title, status, assignee, and created date with pagination + - GIVEN the user clicks a case WHEN the detail loads THEN it MUST display all case fields and associated tasks + - GIVEN the user searches THEN the list MUST query OpenRegister with `_search` +- [x] Create Dashboard.vue with summary/welcome content +- [x] Create CaseList.vue with paginated list, search, and "New case" button +- [x] Create CaseDetail.vue with full case fields and task list + +### Task 12: Create Procest case and task CRUD forms +- **spec_ref**: `openspec/changes/create-procest-app/specs/procest-case-management/spec.md#requirement-app-must-support-case-crud-operations` +- **files**: `procest/src/views/cases/CaseDetail.vue` +- **acceptance_criteria**: + - GIVEN the user creates a new case THEN the object store MUST POST to OpenRegister + - GIVEN the user edits a case THEN the object store MUST PUT to OpenRegister + - GIVEN the user deletes a case THEN the object store MUST DELETE and navigate back to list + - GIVEN the user creates a task within a case THEN it MUST include a reference to the parent case +- [x] Implement create/edit form in CaseDetail (inline editing mode) +- [x] Implement delete with confirmation +- [x] Implement task creation within case detail + +### Task 13: Create Procest admin settings Vue component +- **spec_ref**: `openspec/changes/create-procest-app/specs/procest-app-scaffold/spec.md#requirement-app-must-provide-admin-settings-page` +- **files**: `procest/src/views/settings/Settings.vue` +- **acceptance_criteria**: + - GIVEN an admin WHEN on the settings page THEN they MUST see configuration options for register and schema mappings +- [x] Create Settings.vue with register/schema ID configuration fields + +## 4. Pipelinq App Scaffold + +### Task 14: Create Pipelinq app directory structure and info.xml +- **spec_ref**: `openspec/changes/create-procest-app/specs/pipelinq-app-scaffold/spec.md#requirement-app-must-be-a-valid-nextcloud-app` +- **files**: `pipelinq/appinfo/info.xml`, `pipelinq/composer.json`, `pipelinq/img/app.svg` +- **acceptance_criteria**: + - GIVEN the pipelinq directory exists in apps-extra WHEN Nextcloud scans for apps THEN it MUST appear with id `pipelinq`, name "Pipelinq", namespace `Pipelinq` + - AND it MUST declare compatibility with Nextcloud 28-33 and PHP 8.1+ +- [x] Create `pipelinq/` directory with `appinfo/info.xml`, `composer.json`, `img/app.svg` + +### Task 15: Create Pipelinq PHP backend (Application, Controllers, Services) +- **spec_ref**: `openspec/changes/create-procest-app/specs/pipelinq-app-scaffold/spec.md#requirement-app-must-provide-a-single-page-application-entry-point` +- **files**: `pipelinq/lib/AppInfo/Application.php`, `pipelinq/lib/Controller/DashboardController.php`, `pipelinq/lib/Controller/SettingsController.php`, `pipelinq/lib/Service/SettingsService.php`, `pipelinq/appinfo/routes.php`, `pipelinq/templates/index.php` +- **acceptance_criteria**: + - GIVEN the app is enabled WHEN a user navigates to `/apps/pipelinq/` THEN the server MUST return an HTML page with a `#content` mount point + - GIVEN a GET request to `/api/settings` THEN it MUST return register and schema IDs (register, client_schema, request_schema, contact_schema) + - GIVEN an admin POST to `/api/settings` THEN it MUST persist config +- [x] Create Application.php with IBootstrap registration +- [x] Create DashboardController with index action +- [x] Create SettingsController with get/save endpoints +- [x] Create SettingsService for IAppConfig read/write +- [x] Create routes.php and templates/index.php + +### Task 16: Create Pipelinq admin settings page +- **spec_ref**: `openspec/changes/create-procest-app/specs/pipelinq-app-scaffold/spec.md#requirement-app-must-provide-admin-settings-page` +- **files**: `pipelinq/lib/Settings/AdminSettings.php`, `pipelinq/lib/Settings/AdminSection.php`, `pipelinq/lib/Sections/SettingsSection.php` +- **acceptance_criteria**: + - GIVEN an admin user WHEN navigating to `/settings/admin/pipelinq` THEN the admin settings page MUST load with the `pipelinq-settings.js` bundle +- [x] Create AdminSettings and AdminSection classes +- [x] Register settings section in info.xml + +### Task 17: Create Pipelinq repair step for auto-configuration +- **spec_ref**: `openspec/changes/create-procest-app/specs/pipelinq-client-management/spec.md#requirement-client-management-register-must-be-auto-configured-on-install` +- **files**: `pipelinq/lib/Repair/InitializeSettings.php` +- **acceptance_criteria**: + - GIVEN OpenRegister is active and no `client-management` register exists WHEN Pipelinq is enabled THEN a repair step MUST create the register with schemas for client, request, contact + - GIVEN a `client-management` register already exists WHEN Pipelinq is enabled THEN it MUST detect and use the existing register +- [x] Create InitializeSettings repair step +- [x] Register repair step in info.xml + +### Task 18: Create Pipelinq webpack and frontend entry points +- **spec_ref**: `openspec/changes/create-procest-app/specs/pipelinq-app-scaffold/spec.md#requirement-app-must-use-webpack-build-system-extending-nextcloud-base-config` +- **files**: `pipelinq/webpack.config.js`, `pipelinq/package.json`, `pipelinq/src/main.js`, `pipelinq/src/settings.js`, `pipelinq/src/pinia.js`, `pipelinq/src/App.vue` +- **acceptance_criteria**: + - GIVEN source files exist in `src/` WHEN `npm run build` is executed THEN it MUST produce `js/pipelinq-main.js` and `js/pipelinq-settings.js` + - AND the Vue app MUST initialize with Pinia state management +- [x] Create package.json with Nextcloud dependencies +- [x] Create webpack.config.js extending @nextcloud/webpack-vue-config +- [x] Create main.js, settings.js, pinia.js, and App.vue entry points + +### Task 19: Create Pipelinq l10n translations +- **spec_ref**: `openspec/changes/create-procest-app/specs/pipelinq-app-scaffold/spec.md#requirement-app-must-support-multilingual-translations` +- **files**: `pipelinq/l10n/en.json`, `pipelinq/l10n/nl.json` +- **acceptance_criteria**: + - GIVEN a user with English locale WHEN viewing Pipelinq THEN all text MUST be in English + - GIVEN a user with Dutch locale WHEN viewing Pipelinq THEN all text MUST be in Dutch +- [x] Create l10n/en.json and l10n/nl.json with all UI strings + +## 5. Pipelinq Object Store + +### Task 20: Create Pipelinq Pinia object store with type registration +- **spec_ref**: `openspec/changes/create-procest-app/specs/pipelinq-object-store/spec.md#requirement-object-store-must-use-pinia-with-dynamic-type-registration` +- **files**: `pipelinq/src/store/modules/object.js` +- **acceptance_criteria**: + - GIVEN app settings are loaded WHEN `registerObjectType('client', schemaId, registerId)` is called THEN the store MUST record the mapping + - GIVEN an object type is registered WHEN `unregisterObjectType('client')` is called THEN the type MUST be removed and cached data cleared +- [x] Create object store with registerObjectType/unregisterObjectType actions +- [x] Implement objectTypeRegistry state and getters + +### Task 21: Create Pipelinq object store CRUD and collection actions +- **spec_ref**: `openspec/changes/create-procest-app/specs/pipelinq-object-store/spec.md#requirement-object-store-must-fetch-collections-from-openregister` +- **files**: `pipelinq/src/store/modules/object.js` +- **acceptance_criteria**: + - GIVEN type `client` is registered WHEN `fetchCollection('client', { _limit: 20 })` is called THEN it MUST fetch from OpenRegister with correct URL + - GIVEN type `client` is registered WHEN `fetchObject('client', 'uuid-456')` is called THEN it MUST fetch single object by ID + - GIVEN `saveObject('request', { title: 'New' })` is called with no ID THEN it MUST POST to OpenRegister + - GIVEN `deleteObject('request', 'uuid-789')` is called THEN it MUST DELETE from OpenRegister +- [x] Implement fetchCollection with pagination and search +- [x] Implement fetchObject for single objects +- [x] Implement saveObject (create/update) and deleteObject +- [x] All requests MUST include requesttoken and OCS-APIREQUEST headers + +### Task 22: Create Pipelinq settings store and initialization +- **spec_ref**: `openspec/changes/create-procest-app/specs/pipelinq-object-store/spec.md#requirement-object-store-must-load-settings-before-data-operations` +- **files**: `pipelinq/src/store/modules/settings.js`, `pipelinq/src/store/store.js` +- **acceptance_criteria**: + - GIVEN the app is loading WHEN the store initializes THEN it MUST fetch `/apps/pipelinq/api/settings` first + - AND it MUST register all object types using the returned IDs + - AND data fetching MUST NOT proceed until settings are loaded +- [x] Create settings store module with fetchSettings action +- [x] Create main store.js that initializes settings then registers types +- [x] Implement loading and error state tracking per object type + +## 6. Pipelinq Client & Request Management Views + +### Task 23: Create Pipelinq navigation and routing +- **spec_ref**: `openspec/changes/create-procest-app/specs/pipelinq-client-management/spec.md#requirement-navigation-must-include-clients-and-requests-menu-items` +- **files**: `pipelinq/src/navigation/MainMenu.vue`, `pipelinq/src/App.vue` +- **acceptance_criteria**: + - GIVEN the user opens Pipelinq WHEN the navigation loads THEN the menu MUST include "Dashboard", "Clients", and "Requests" items + - AND clicking each item MUST navigate to the corresponding view +- [x] Create MainMenu.vue with navigation items +- [x] Set up Vue Router in App.vue with routes for dashboard, clients, requests + +### Task 24: Create Pipelinq clients list and detail views +- **spec_ref**: `openspec/changes/create-procest-app/specs/pipelinq-client-management/spec.md#requirement-app-must-provide-a-clients-list-view` +- **files**: `pipelinq/src/views/Dashboard.vue`, `pipelinq/src/views/clients/ClientList.vue`, `pipelinq/src/views/clients/ClientDetail.vue` +- **acceptance_criteria**: + - GIVEN the user navigates to clients WHEN the page loads THEN it MUST display client name, type, email, and phone with pagination + - GIVEN the user clicks a client WHEN the detail loads THEN it MUST display all client fields, related requests, and contacts + - GIVEN the user searches THEN the list MUST query OpenRegister with `_search` +- [x] Create Dashboard.vue with summary/welcome content +- [x] Create ClientList.vue with paginated list, search, and "New client" button +- [x] Create ClientDetail.vue with full client fields, requests list, and contacts list + +### Task 25: Create Pipelinq client CRUD forms +- **spec_ref**: `openspec/changes/create-procest-app/specs/pipelinq-client-management/spec.md#requirement-app-must-support-client-crud-operations` +- **files**: `pipelinq/src/views/clients/ClientDetail.vue` +- **acceptance_criteria**: + - GIVEN the user creates a new client THEN the object store MUST POST to OpenRegister + - GIVEN the user edits a client THEN the object store MUST PUT to OpenRegister + - GIVEN the user deletes a client THEN the object store MUST DELETE and navigate back to list +- [x] Implement create/edit form in ClientDetail (inline editing mode) +- [x] Implement delete with confirmation + +### Task 26: Create Pipelinq requests list and detail views +- **spec_ref**: `openspec/changes/create-procest-app/specs/pipelinq-client-management/spec.md#requirement-app-must-provide-a-requests-list-view` +- **files**: `pipelinq/src/views/requests/RequestList.vue`, `pipelinq/src/views/requests/RequestDetail.vue` +- **acceptance_criteria**: + - GIVEN the user navigates to requests WHEN the page loads THEN it MUST display request title, client name, status, priority, and requested date with pagination + - GIVEN the user clicks a request WHEN the detail loads THEN it MUST display all fields and a link to the associated client +- [x] Create RequestList.vue with paginated list, search, and "New request" button +- [x] Create RequestDetail.vue with full request fields and client link + +### Task 27: Create Pipelinq request CRUD forms +- **spec_ref**: `openspec/changes/create-procest-app/specs/pipelinq-client-management/spec.md#requirement-app-must-support-request-crud-operations` +- **files**: `pipelinq/src/views/requests/RequestDetail.vue` +- **acceptance_criteria**: + - GIVEN the user creates a request THEN it MUST be saved to OpenRegister + - GIVEN the user creates a request from a client detail THEN it MUST include a reference to that client + - GIVEN the user deletes a request THEN the object store MUST DELETE from OpenRegister +- [x] Implement create/edit form in RequestDetail +- [x] Implement delete with confirmation +- [x] Support creating request pre-linked to a client + +### Task 28: Create Pipelinq admin settings Vue component +- **spec_ref**: `openspec/changes/create-procest-app/specs/pipelinq-app-scaffold/spec.md#requirement-app-must-provide-admin-settings-page` +- **files**: `pipelinq/src/views/settings/Settings.vue` +- **acceptance_criteria**: + - GIVEN an admin WHEN on the settings page THEN they MUST see configuration options for register and schema mappings +- [x] Create Settings.vue with register/schema ID configuration fields + +## 7. GitHub & Build + +### Task 29: Push initial code to GitHub repositories +- **spec_ref**: `openspec/changes/create-procest-app/specs/procest-app-scaffold/spec.md#requirement-app-must-have-a-github-repository` +- **files**: N/A (git operations) +- **acceptance_criteria**: + - GIVEN the ConductionNL GitHub org THEN `ConductionNL/procest` MUST exist and be public with the Procest source code + - AND `ConductionNL/pipelinq` MUST exist and be public with the Pipelinq source code +- [x] Initialize git repos, push Procest code to ConductionNL/procest +- [x] Push Pipelinq code to ConductionNL/pipelinq + +## 8. Integration Testing + +### Task 30: Build, install, and verify both apps in Docker +- **spec_ref**: All specs +- **files**: N/A (testing) +- **acceptance_criteria**: + - GIVEN both apps are built WHEN enabled in Nextcloud THEN they MUST activate without errors + - GIVEN both apps are enabled WHEN browsing to `/apps/procest/` and `/apps/pipelinq/` THEN the SPAs MUST load + - GIVEN admin settings pages THEN register/schema config MUST be saveable and retrievable + - GIVEN configured registers THEN list/detail/CRUD operations MUST work for all entity types +- [x] Run `npm run build` in both app directories +- [x] Enable both apps via occ and verify no errors +- [x] Test Procest: dashboard, cases list, case detail, case CRUD +- [x] Test Pipelinq: dashboard, clients list, client detail, client CRUD, requests list, request CRUD +- [x] Test admin settings for both apps + +## Verification +- [x] All tasks checked off +- [x] `openspec validate` passes +- [x] Manual testing against acceptance criteria +- [x] Code review against spec requirements diff --git a/openspec/specs/pipelinq-app-scaffold/spec.md b/openspec/specs/pipelinq-app-scaffold/spec.md new file mode 100644 index 0000000..60473d2 --- /dev/null +++ b/openspec/specs/pipelinq-app-scaffold/spec.md @@ -0,0 +1,77 @@ +# pipelinq-app-scaffold Specification + +## Purpose +Define the Nextcloud app scaffolding, build system, translation setup, and admin settings for the Pipelinq client and request management app. Mirrors the Procest scaffold with its own app identity. + +## ADDED Requirements + +### Requirement: App MUST be a valid Nextcloud app +The Pipelinq app MUST be installable as a standard Nextcloud app with proper metadata, namespace, and dependency declarations. + +#### Scenario: App registration +- GIVEN the Pipelinq app directory exists in apps-extra +- WHEN Nextcloud scans for available apps +- THEN the app MUST appear in the apps list with id `pipelinq`, name "Pipelinq", and namespace `Pipelinq` +- AND it MUST declare compatibility with Nextcloud 28-33 +- AND it MUST declare PHP 8.1+ as minimum requirement + +#### Scenario: App enable +- GIVEN Nextcloud is running and OpenRegister is installed +- WHEN an admin enables the Pipelinq app +- THEN the app MUST activate without errors +- AND it MUST register a navigation entry in the top bar + +### Requirement: App MUST provide a single-page application entry point +The app MUST serve a Vue 2 SPA from a dashboard controller that mounts to the `#content` element. + +#### Scenario: Dashboard page load +- GIVEN the app is enabled and a user is logged in +- WHEN the user navigates to `/apps/pipelinq/` +- THEN the server MUST return an HTML page with a `#content` mount point +- AND the page MUST load the `pipelinq-main.js` webpack bundle +- AND the Vue app MUST initialize with Pinia state management + +### Requirement: App MUST use webpack build system extending Nextcloud base config +The build system MUST extend `@nextcloud/webpack-vue-config` with two entry points. + +#### Scenario: Build produces correct bundles +- GIVEN the source files exist in `src/` +- WHEN `npm run build` is executed +- THEN it MUST produce `js/pipelinq-main.js` for the dashboard SPA +- AND it MUST produce `js/pipelinq-settings.js` for the admin settings page + +### Requirement: App MUST support multilingual translations +All user-facing strings MUST be wrapped in translation functions with English as the primary language and Dutch included. + +#### Scenario: English translation +- GIVEN a user with English locale +- WHEN viewing the Pipelinq app +- THEN all UI text MUST be displayed in English + +#### Scenario: Dutch translation +- GIVEN a user with Dutch locale +- WHEN viewing the Pipelinq app +- THEN all UI text MUST be displayed in Dutch + +#### Scenario: Translation function usage +- GIVEN any Vue component with user-facing text +- WHEN the component renders +- THEN all strings MUST use `t('pipelinq', 'key')` in templates +- AND all PHP strings MUST use `$this->l->t('key')` + +### Requirement: App MUST provide admin settings page +The app MUST register an admin settings section for register/schema configuration. + +#### Scenario: Settings page access +- GIVEN an admin user +- WHEN navigating to `/settings/admin/pipelinq` +- THEN the admin settings page MUST load with the `pipelinq-settings.js` bundle +- AND it MUST display configuration options for register and schema mappings + +### Requirement: App MUST have a GitHub repository +The app source code MUST be hosted at `ConductionNL/pipelinq` on GitHub. + +#### Scenario: Repository exists +- GIVEN the ConductionNL GitHub organization +- WHEN checking for the pipelinq repository +- THEN `https://github.com/ConductionNL/pipelinq` MUST exist and be public diff --git a/openspec/specs/pipelinq-client-management/spec.md b/openspec/specs/pipelinq-client-management/spec.md new file mode 100644 index 0000000..64b6781 --- /dev/null +++ b/openspec/specs/pipelinq-client-management/spec.md @@ -0,0 +1,133 @@ +# pipelinq-client-management Specification + +## Purpose +Define the client and request management domain for Pipelinq: clients, requests (verzoeken), and contacts. All entities are stored in OpenRegister under the `client-management` register. Requests represent the pre-state of a case — a yet-to-be-determined or incoming case before it enters formal case management in Procest. + +## ADDED Requirements + +### Requirement: Client-management register MUST be auto-configured on install +The app MUST create or detect the `client-management` register and its schemas in OpenRegister during app initialization. + +#### Scenario: First install with no existing register +- GIVEN OpenRegister is active and no `client-management` register exists +- WHEN the Pipelinq app is enabled for the first time +- THEN a repair step MUST create the `client-management` register +- AND it MUST create schemas for: client, request, contact +- AND it MUST store the register and schema IDs in app configuration + +#### Scenario: Install with existing register +- GIVEN OpenRegister has a `client-management` register already configured +- WHEN the Pipelinq app is enabled +- THEN the repair step MUST detect and use the existing register +- AND it MUST store the found register/schema IDs in app configuration + +### Requirement: Settings endpoint MUST return register/schema configuration +The backend MUST provide an API endpoint that returns the configured register and schema IDs. + +#### Scenario: Get configuration +- GIVEN the app is configured with register and schema IDs +- WHEN a GET request is made to `/api/settings` +- THEN the response MUST include `register`, `client_schema`, `request_schema`, `contact_schema` +- AND the response status MUST be 200 + +#### Scenario: Save configuration +- GIVEN an admin user +- WHEN a POST request is made to `/api/settings` with register/schema IDs +- THEN the configuration MUST be persisted in app config +- AND the response MUST confirm success + +### Requirement: App MUST provide a clients list view +The frontend MUST display a paginated, searchable list of clients. + +#### Scenario: Clients list page +- GIVEN the user navigates to the clients section +- WHEN the page loads +- THEN the object store MUST fetch clients from OpenRegister using the configured register/schema +- AND the list MUST display client name, type (person/organization), email, and phone +- AND the list MUST support pagination + +#### Scenario: Clients search +- GIVEN the clients list is displayed +- WHEN the user enters a search term +- THEN the object store MUST query OpenRegister with the `_search` parameter +- AND the list MUST update to show matching results + +### Requirement: App MUST provide a client detail view +The frontend MUST display client details with related requests and contacts. + +#### Scenario: Client detail page +- GIVEN the user clicks a client in the list +- WHEN the detail view loads +- THEN the object store MUST fetch the full client object by ID +- AND the view MUST display all client fields (name, type, email, phone, address, notes) +- AND the view MUST list requests associated with this client +- AND the view MUST list contacts associated with this client + +### Requirement: App MUST support client CRUD operations +The frontend MUST allow creating, editing, and deleting clients via OpenRegister. + +#### Scenario: Create client +- GIVEN the user is on the clients list +- WHEN the user clicks "New client" and fills in the form +- THEN the object store MUST POST to OpenRegister with the client data +- AND the new client MUST appear in the list + +#### Scenario: Edit client +- GIVEN the user is viewing a client detail +- WHEN the user modifies fields and saves +- THEN the object store MUST PUT to OpenRegister with the updated data +- AND the detail view MUST reflect the changes + +#### Scenario: Delete client +- GIVEN the user is viewing a client detail +- WHEN the user confirms deletion +- THEN the object store MUST DELETE the client from OpenRegister +- AND the user MUST be navigated back to the list + +### Requirement: App MUST provide a requests list view +The frontend MUST display a paginated, searchable list of requests (verzoeken). + +#### Scenario: Requests list page +- GIVEN the user navigates to the requests section +- WHEN the page loads +- THEN the object store MUST fetch requests from OpenRegister +- AND the list MUST display request title, client name, status, priority, and requested date +- AND the list MUST support pagination + +### Requirement: App MUST provide a request detail view +The frontend MUST display request details with the linked client. + +#### Scenario: Request detail page +- GIVEN the user clicks a request in the list +- WHEN the detail view loads +- THEN the object store MUST fetch the full request object by ID +- AND the view MUST display all request fields (title, description, client, status, priority, category, requestedAt) +- AND the view MUST show a link to the associated client + +### Requirement: App MUST support request CRUD operations +The frontend MUST allow creating, editing, and deleting requests via OpenRegister. + +#### Scenario: Create request +- GIVEN the user is on the requests list or a client detail +- WHEN the user creates a new request +- THEN the request MUST be saved to OpenRegister +- AND if created from a client detail, it MUST include a reference to that client + +#### Scenario: Edit request +- GIVEN the user is viewing a request detail +- WHEN the user modifies fields and saves +- THEN the object store MUST PUT to OpenRegister with the updated data + +#### Scenario: Delete request +- GIVEN the user is viewing a request detail +- WHEN the user confirms deletion +- THEN the object store MUST DELETE the request from OpenRegister + +### Requirement: Navigation MUST include clients and requests menu items +The app navigation MUST show menu items for the primary entity types. + +#### Scenario: Navigation rendering +- GIVEN the user opens the Pipelinq app +- WHEN the navigation loads +- THEN the menu MUST include at minimum "Dashboard", "Clients", and "Requests" items +- AND clicking each item MUST navigate to the corresponding list view diff --git a/openspec/specs/pipelinq-object-store/spec.md b/openspec/specs/pipelinq-object-store/spec.md new file mode 100644 index 0000000..645a2ad --- /dev/null +++ b/openspec/specs/pipelinq-object-store/spec.md @@ -0,0 +1,101 @@ +# pipelinq-object-store Specification + +## Purpose +Define the Pinia-based object store that provides the data layer for Pipelinq. Identical pattern to the Procest object store — queries OpenRegister directly from the frontend for all CRUD, search, and pagination operations. + +## ADDED Requirements + +### Requirement: Object store MUST use Pinia with dynamic type registration +The store MUST support registering object types at runtime, each mapped to an OpenRegister register/schema pair. + +#### Scenario: Register object type +- GIVEN the app settings have been loaded with register/schema IDs +- WHEN `registerObjectType('client', schemaId, registerId)` is called +- THEN the store MUST record the mapping in `objectTypeRegistry` +- AND subsequent CRUD actions for type `client` MUST use the correct register/schema + +#### Scenario: Unregister object type +- GIVEN an object type is registered +- WHEN `unregisterObjectType('client')` is called +- THEN the type MUST be removed from the registry +- AND its cached data MUST be cleared + +### Requirement: Object store MUST fetch collections from OpenRegister +The store MUST provide a `fetchCollection` action that queries OpenRegister's list endpoint with pagination and search support. + +#### Scenario: Fetch paginated collection +- GIVEN object type `client` is registered with register=6, schema=40 +- WHEN `fetchCollection('client', { _limit: 20, _offset: 0 })` is called +- THEN the store MUST fetch `GET /apps/openregister/api/objects/6/40?_limit=20&_offset=0` +- AND the response results MUST be stored in `collections.client` +- AND pagination metadata MUST be stored in `pagination.client` + +#### Scenario: Fetch with search +- GIVEN the user searches for "Gemeente Amsterdam" +- WHEN `fetchCollection('client', { _search: 'Gemeente Amsterdam' })` is called +- THEN the store MUST include `_search=Gemeente+Amsterdam` in the query +- AND results MUST reflect the search filter + +### Requirement: Object store MUST fetch individual objects +The store MUST provide a `fetchObject` action that retrieves a single object by ID. + +#### Scenario: Fetch single object +- GIVEN object type `client` is registered +- WHEN `fetchObject('client', 'uuid-456')` is called +- THEN the store MUST fetch `GET /apps/openregister/api/objects/6/40/uuid-456` +- AND the object MUST be stored in `objects.client['uuid-456']` + +### Requirement: Object store MUST support create, update, and delete +The store MUST provide actions for full CRUD operations against OpenRegister. + +#### Scenario: Create object +- GIVEN object type `request` is registered +- WHEN `saveObject('request', { title: 'New request', client: 'uuid-456' })` is called with no existing ID +- THEN the store MUST POST to OpenRegister +- AND the created object MUST be added to the store + +#### Scenario: Update object +- GIVEN a client object exists with ID `uuid-456` +- WHEN `saveObject('client', { id: 'uuid-456', name: 'Updated' })` is called +- THEN the store MUST PUT to OpenRegister +- AND the store MUST update `objects.client['uuid-456']` + +#### Scenario: Delete object +- GIVEN a request object exists with ID `uuid-789` +- WHEN `deleteObject('request', 'uuid-789')` is called +- THEN the store MUST DELETE from OpenRegister +- AND the object MUST be removed from the store + +### Requirement: Object store MUST track loading and error states +The store MUST provide reactive loading and error states per object type. + +#### Scenario: Loading state during fetch +- GIVEN a collection fetch is in progress for type `client` +- WHEN a component checks `isLoading('client')` +- THEN it MUST return `true` +- AND when the fetch completes, it MUST return `false` + +#### Scenario: Error state on failure +- GIVEN an API call fails with a network error +- WHEN the store processes the error +- THEN `errors.client` MUST contain the error message +- AND the loading state MUST be set to `false` + +### Requirement: Object store MUST load settings before data operations +The store MUST fetch app settings on initialization before any object type can be registered. + +#### Scenario: Settings initialization +- GIVEN the app is loading for the first time +- WHEN the store initializes +- THEN it MUST fetch `/apps/pipelinq/api/settings` to get register/schema configuration +- AND it MUST register all object types using the returned IDs +- AND data fetching MUST NOT proceed until settings are loaded + +### Requirement: All API calls MUST include Nextcloud authentication headers +Every fetch request to OpenRegister MUST include the CSRF token and OCS header. + +#### Scenario: Authenticated request +- GIVEN a store action makes a fetch call +- WHEN the request is constructed +- THEN it MUST include `requesttoken: OC.requestToken` header +- AND it MUST include `OCS-APIREQUEST: true` header diff --git a/openspec/specs/procest-app-scaffold/spec.md b/openspec/specs/procest-app-scaffold/spec.md new file mode 100644 index 0000000..0cbc70b --- /dev/null +++ b/openspec/specs/procest-app-scaffold/spec.md @@ -0,0 +1,77 @@ +# procest-app-scaffold Specification + +## Purpose +Define the Nextcloud app scaffolding, build system, translation setup, and admin settings for the Procest case management app. This capability establishes the foundational structure that all other capabilities build upon. + +## ADDED Requirements + +### Requirement: App MUST be a valid Nextcloud app +The Procest app MUST be installable as a standard Nextcloud app with proper metadata, namespace, and dependency declarations. + +#### Scenario: App registration +- GIVEN the Procest app directory exists in apps-extra +- WHEN Nextcloud scans for available apps +- THEN the app MUST appear in the apps list with id `procest`, name "Procest", and namespace `Procest` +- AND it MUST declare compatibility with Nextcloud 28-33 +- AND it MUST declare PHP 8.1+ as minimum requirement + +#### Scenario: App enable +- GIVEN Nextcloud is running and OpenRegister is installed +- WHEN an admin enables the Procest app +- THEN the app MUST activate without errors +- AND it MUST register a navigation entry in the top bar + +### Requirement: App MUST provide a single-page application entry point +The app MUST serve a Vue 2 SPA from a dashboard controller that mounts to the `#content` element. + +#### Scenario: Dashboard page load +- GIVEN the app is enabled and a user is logged in +- WHEN the user navigates to `/apps/procest/` +- THEN the server MUST return an HTML page with a `#content` mount point +- AND the page MUST load the `procest-main.js` webpack bundle +- AND the Vue app MUST initialize with Pinia state management + +### Requirement: App MUST use webpack build system extending Nextcloud base config +The build system MUST extend `@nextcloud/webpack-vue-config` with two entry points. + +#### Scenario: Build produces correct bundles +- GIVEN the source files exist in `src/` +- WHEN `npm run build` is executed +- THEN it MUST produce `js/procest-main.js` for the dashboard SPA +- AND it MUST produce `js/procest-settings.js` for the admin settings page + +### Requirement: App MUST support multilingual translations +All user-facing strings MUST be wrapped in translation functions with English as the primary language and Dutch included. + +#### Scenario: English translation +- GIVEN a user with English locale +- WHEN viewing the Procest app +- THEN all UI text MUST be displayed in English + +#### Scenario: Dutch translation +- GIVEN a user with Dutch locale +- WHEN viewing the Procest app +- THEN all UI text MUST be displayed in Dutch + +#### Scenario: Translation function usage +- GIVEN any Vue component with user-facing text +- WHEN the component renders +- THEN all strings MUST use `t('procest', 'key')` in templates +- AND all PHP strings MUST use `$this->l->t('key')` + +### Requirement: App MUST provide admin settings page +The app MUST register an admin settings section for register/schema configuration. + +#### Scenario: Settings page access +- GIVEN an admin user +- WHEN navigating to `/settings/admin/procest` +- THEN the admin settings page MUST load with the `procest-settings.js` bundle +- AND it MUST display configuration options for register and schema mappings + +### Requirement: App MUST have a GitHub repository +The app source code MUST be hosted at `ConductionNL/procest` on GitHub. + +#### Scenario: Repository exists +- GIVEN the ConductionNL GitHub organization +- WHEN checking for the procest repository +- THEN `https://github.com/ConductionNL/procest` MUST exist and be public diff --git a/openspec/specs/procest-case-management/spec.md b/openspec/specs/procest-case-management/spec.md new file mode 100644 index 0000000..bee6637 --- /dev/null +++ b/openspec/specs/procest-case-management/spec.md @@ -0,0 +1,108 @@ +# procest-case-management Specification + +## Purpose +Define the case management domain for Procest: cases, tasks, statuses, roles, results, and decisions. All entities are stored in OpenRegister under the `case-management` register. The frontend provides list and detail views for cases and tasks. + +## ADDED Requirements + +### Requirement: Case-management register MUST be auto-configured on install +The app MUST create or detect the `case-management` register and its schemas in OpenRegister during app initialization. + +#### Scenario: First install with no existing register +- GIVEN OpenRegister is active and no `case-management` register exists +- WHEN the Procest app is enabled for the first time +- THEN a repair step MUST create the `case-management` register +- AND it MUST create schemas for: case, task, status, role, result, decision +- AND it MUST store the register and schema IDs in app configuration + +#### Scenario: Install with existing register +- GIVEN OpenRegister has a `case-management` register already configured +- WHEN the Procest app is enabled +- THEN the repair step MUST detect and use the existing register +- AND it MUST store the found register/schema IDs in app configuration + +### Requirement: Settings endpoint MUST return register/schema configuration +The backend MUST provide an API endpoint that returns the configured register and schema IDs. + +#### Scenario: Get configuration +- GIVEN the app is configured with register and schema IDs +- WHEN a GET request is made to `/api/settings` +- THEN the response MUST include `register`, `case_schema`, `task_schema`, `status_schema`, `role_schema`, `result_schema`, `decision_schema` +- AND the response status MUST be 200 + +#### Scenario: Save configuration +- GIVEN an admin user +- WHEN a POST request is made to `/api/settings` with register/schema IDs +- THEN the configuration MUST be persisted in app config +- AND the response MUST confirm success + +### Requirement: App MUST provide a cases list view +The frontend MUST display a paginated, searchable list of cases. + +#### Scenario: Cases list page +- GIVEN the user navigates to the cases section +- WHEN the page loads +- THEN the object store MUST fetch cases from OpenRegister using the configured register/schema +- AND the list MUST display case title, status, assignee, and created date +- AND the list MUST support pagination + +#### Scenario: Cases search +- GIVEN the cases list is displayed +- WHEN the user enters a search term +- THEN the object store MUST query OpenRegister with the `_search` parameter +- AND the list MUST update to show matching results + +### Requirement: App MUST provide a case detail view +The frontend MUST display case details with related tasks. + +#### Scenario: Case detail page +- GIVEN the user clicks a case in the list +- WHEN the detail view loads +- THEN the object store MUST fetch the full case object by ID +- AND the view MUST display all case fields (title, description, status, assignee, priority, dates) +- AND the view MUST list tasks associated with this case + +### Requirement: App MUST support case CRUD operations +The frontend MUST allow creating, editing, and deleting cases via OpenRegister. + +#### Scenario: Create case +- GIVEN the user is on the cases list +- WHEN the user clicks "New case" and fills in the form +- THEN the object store MUST POST to OpenRegister with the case data +- AND the new case MUST appear in the list + +#### Scenario: Edit case +- GIVEN the user is viewing a case detail +- WHEN the user modifies fields and saves +- THEN the object store MUST PUT to OpenRegister with the updated data +- AND the detail view MUST reflect the changes + +#### Scenario: Delete case +- GIVEN the user is viewing a case detail +- WHEN the user confirms deletion +- THEN the object store MUST DELETE the case from OpenRegister +- AND the user MUST be navigated back to the list + +### Requirement: App MUST provide task management within cases +The frontend MUST support creating, editing, and completing tasks linked to a case. + +#### Scenario: Task list within case +- GIVEN the user is viewing a case detail +- WHEN the tasks section loads +- THEN tasks MUST be fetched from OpenRegister filtered by the case ID +- AND each task MUST show title, status, assignee, and due date + +#### Scenario: Create task +- GIVEN the user is viewing a case detail +- WHEN the user creates a new task +- THEN the task MUST be saved to OpenRegister with a reference to the parent case +- AND it MUST appear in the case's task list + +### Requirement: Navigation MUST include cases and tasks menu items +The app navigation MUST show menu items for Cases and optionally Tasks. + +#### Scenario: Navigation rendering +- GIVEN the user opens the Procest app +- WHEN the navigation loads +- THEN the menu MUST include at minimum a "Dashboard" item and a "Cases" item +- AND clicking "Cases" MUST navigate to the cases list view diff --git a/openspec/specs/procest-object-store/spec.md b/openspec/specs/procest-object-store/spec.md new file mode 100644 index 0000000..3bcfc97 --- /dev/null +++ b/openspec/specs/procest-object-store/spec.md @@ -0,0 +1,101 @@ +# procest-object-store Specification + +## Purpose +Define the Pinia-based object store that provides the data layer for Procest. The store queries OpenRegister directly from the frontend for all CRUD, search, and pagination operations — following the softwarecatalog thin-client pattern. + +## ADDED Requirements + +### Requirement: Object store MUST use Pinia with dynamic type registration +The store MUST support registering object types at runtime, each mapped to an OpenRegister register/schema pair. + +#### Scenario: Register object type +- GIVEN the app settings have been loaded with register/schema IDs +- WHEN `registerObjectType('case', schemaId, registerId)` is called +- THEN the store MUST record the mapping in `objectTypeRegistry` +- AND subsequent CRUD actions for type `case` MUST use the correct register/schema + +#### Scenario: Unregister object type +- GIVEN an object type is registered +- WHEN `unregisterObjectType('case')` is called +- THEN the type MUST be removed from the registry +- AND its cached data MUST be cleared + +### Requirement: Object store MUST fetch collections from OpenRegister +The store MUST provide a `fetchCollection` action that queries OpenRegister's list endpoint with pagination and search support. + +#### Scenario: Fetch paginated collection +- GIVEN object type `case` is registered with register=5, schema=30 +- WHEN `fetchCollection('case', { _limit: 20, _offset: 0 })` is called +- THEN the store MUST fetch `GET /apps/openregister/api/objects/5/30?_limit=20&_offset=0` +- AND the response results MUST be stored in `collections.case` +- AND pagination metadata MUST be stored in `pagination.case` + +#### Scenario: Fetch with search +- GIVEN the user searches for "building permit" +- WHEN `fetchCollection('case', { _search: 'building permit' })` is called +- THEN the store MUST include `_search=building+permit` in the query +- AND results MUST reflect the search filter + +### Requirement: Object store MUST fetch individual objects +The store MUST provide a `fetchObject` action that retrieves a single object by ID. + +#### Scenario: Fetch single object +- GIVEN object type `case` is registered +- WHEN `fetchObject('case', 'uuid-123')` is called +- THEN the store MUST fetch `GET /apps/openregister/api/objects/5/30/uuid-123` +- AND the object MUST be stored in `objects.case['uuid-123']` + +### Requirement: Object store MUST support create, update, and delete +The store MUST provide actions for full CRUD operations against OpenRegister. + +#### Scenario: Create object +- GIVEN object type `case` is registered +- WHEN `saveObject('case', { title: 'New case', status: 'open' })` is called with no existing ID +- THEN the store MUST POST to `/apps/openregister/api/objects/5/30` +- AND the created object MUST be added to the store + +#### Scenario: Update object +- GIVEN a case object exists with ID `uuid-123` +- WHEN `saveObject('case', { id: 'uuid-123', title: 'Updated' })` is called +- THEN the store MUST PUT to `/apps/openregister/api/objects/5/30/uuid-123` +- AND the store MUST update `objects.case['uuid-123']` + +#### Scenario: Delete object +- GIVEN a case object exists with ID `uuid-123` +- WHEN `deleteObject('case', 'uuid-123')` is called +- THEN the store MUST DELETE `/apps/openregister/api/objects/5/30/uuid-123` +- AND `objects.case['uuid-123']` MUST be removed from the store + +### Requirement: Object store MUST track loading and error states +The store MUST provide reactive loading and error states per object type. + +#### Scenario: Loading state during fetch +- GIVEN a collection fetch is in progress for type `case` +- WHEN a component checks `isLoading('case')` +- THEN it MUST return `true` +- AND when the fetch completes, it MUST return `false` + +#### Scenario: Error state on failure +- GIVEN an API call fails with a network error +- WHEN the store processes the error +- THEN `errors.case` MUST contain the error message +- AND the loading state MUST be set to `false` + +### Requirement: Object store MUST load settings before data operations +The store MUST fetch app settings (register/schema IDs) on initialization before any object type can be registered. + +#### Scenario: Settings initialization +- GIVEN the app is loading for the first time +- WHEN the store initializes +- THEN it MUST fetch `/apps/procest/api/settings` to get register/schema configuration +- AND it MUST register all object types using the returned IDs +- AND data fetching MUST NOT proceed until settings are loaded + +### Requirement: All API calls MUST include Nextcloud authentication headers +Every fetch request to OpenRegister MUST include the CSRF token and OCS header. + +#### Scenario: Authenticated request +- GIVEN a store action makes a fetch call +- WHEN the request is constructed +- THEN it MUST include `requesttoken: OC.requestToken` header +- AND it MUST include `OCS-APIREQUEST: true` header diff --git a/src/store/modules/zgwMapping.js b/src/store/modules/zgwMapping.js new file mode 100644 index 0000000..c3ccec4 --- /dev/null +++ b/src/store/modules/zgwMapping.js @@ -0,0 +1,106 @@ +import { defineStore } from 'pinia' + +export const useZgwMappingStore = defineStore('zgwMapping', { + state: () => ({ + mappings: {}, + loading: false, + error: null, + }), + getters: { + isLoading: (state) => state.loading, + getError: (state) => state.error, + getMappings: (state) => state.mappings, + }, + actions: { + async fetchMappings() { + this.loading = true + this.error = null + + try { + const response = await fetch('/apps/procest/api/zgw-mappings', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + requesttoken: OC.requestToken, + 'OCS-APIREQUEST': 'true', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch ZGW mappings: ${response.statusText}`) + } + + const data = await response.json() + this.mappings = data.mappings || {} + return this.mappings + } catch (error) { + this.error = error.message + console.error('Error fetching ZGW mappings:', error) + return null + } finally { + this.loading = false + } + }, + + async saveMapping(resourceKey, config) { + this.loading = true + this.error = null + + try { + const response = await fetch(`/apps/procest/api/zgw-mappings/${resourceKey}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + requesttoken: OC.requestToken, + 'OCS-APIREQUEST': 'true', + }, + body: JSON.stringify(config), + }) + + if (!response.ok) { + throw new Error(`Failed to save ZGW mapping: ${response.statusText}`) + } + + const data = await response.json() + this.mappings[resourceKey] = data.mapping + return data.mapping + } catch (error) { + this.error = error.message + console.error('Error saving ZGW mapping:', error) + return null + } finally { + this.loading = false + } + }, + + async resetMapping(resourceKey) { + this.loading = true + this.error = null + + try { + const response = await fetch(`/apps/procest/api/zgw-mappings/${resourceKey}/reset`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + requesttoken: OC.requestToken, + 'OCS-APIREQUEST': 'true', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to reset ZGW mapping: ${response.statusText}`) + } + + const data = await response.json() + this.mappings[resourceKey] = data.mapping + return data.mapping + } catch (error) { + this.error = error.message + console.error('Error resetting ZGW mapping:', error) + return null + } finally { + this.loading = false + } + }, + }, +}) diff --git a/src/views/settings/AdminRoot.vue b/src/views/settings/AdminRoot.vue index dbd6892..1015063 100644 --- a/src/views/settings/AdminRoot.vue +++ b/src/views/settings/AdminRoot.vue @@ -8,6 +8,13 @@ :loading="!storesReady"> + + + + @@ -15,6 +22,7 @@ import { CnSettingsSection } from '@conduction/nextcloud-vue' import Settings from './Settings.vue' import CaseTypeAdmin from './CaseTypeAdmin.vue' +import ZgwMappingSettings from './ZgwMappingSettings.vue' import { initializeStores } from '../../store/store.js' export default { @@ -23,6 +31,7 @@ export default { CnSettingsSection, Settings, CaseTypeAdmin, + ZgwMappingSettings, }, data() { return { diff --git a/src/views/settings/ZgwMappingSettings.vue b/src/views/settings/ZgwMappingSettings.vue new file mode 100644 index 0000000..80dc858 --- /dev/null +++ b/src/views/settings/ZgwMappingSettings.vue @@ -0,0 +1,283 @@ +