diff --git a/l10n/en.js b/l10n/en.js index 297477a51..99b7656fa 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -2,6 +2,8 @@ OC.L10N.register( "dossiq", { "Contacts": "Contacts", + "Generic Role": "Generic Role", + "The generic role this case-type role plays, so routing rules and reports can speak about \"the handler\" without knowing every case type's own naming. Maps to the ZGW RolType field omschrijvingGeneriek. Set from the Roles tab in case-type settings, and shipped by the bezwaar and besluitvorming seed data.": "The generic role this case-type role plays, so routing rules and reports can speak about \"the handler\" without knowing every case type's own naming. Maps to the ZGW RolType field omschrijvingGeneriek. Set from the Roles tab in case-type settings, and shipped by the bezwaar and besluitvorming seed data.", "— choose —": "— choose —", "'Valid from' date must be set": "'Valid from' date must be set", "'Valid until' must be after 'Valid from'": "'Valid until' must be after 'Valid from'", diff --git a/l10n/en.json b/l10n/en.json index 18eb0262f..341bd7eb8 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1,6 +1,8 @@ { "translations": { "Contacts": "Contacts", + "Generic Role": "Generic Role", + "The generic role this case-type role plays, so routing rules and reports can speak about \"the handler\" without knowing every case type's own naming. Maps to the ZGW RolType field omschrijvingGeneriek. Set from the Roles tab in case-type settings, and shipped by the bezwaar and besluitvorming seed data.": "The generic role this case-type role plays, so routing rules and reports can speak about \"the handler\" without knowing every case type's own naming. Maps to the ZGW RolType field omschrijvingGeneriek. Set from the Roles tab in case-type settings, and shipped by the bezwaar and besluitvorming seed data.", "— choose —": "— choose —", "'Valid from' date must be set": "'Valid from' date must be set", "'Valid until' must be after 'Valid from'": "'Valid until' must be after 'Valid from'", diff --git a/l10n/nl.js b/l10n/nl.js index 0d0f74df8..e90c78508 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -2,6 +2,8 @@ OC.L10N.register( "dossiq", { "Contacts": "Contacten", + "Generic Role": "Generieke rol", + "The generic role this case-type role plays, so routing rules and reports can speak about \"the handler\" without knowing every case type's own naming. Maps to the ZGW RolType field omschrijvingGeneriek. Set from the Roles tab in case-type settings, and shipped by the bezwaar and besluitvorming seed data.": "De generieke rol die deze zaaktyperol speelt, zodat routeringsregels en rapportages over \"de behandelaar\" kunnen spreken zonder de eigen benaming van elk zaaktype te kennen. Komt overeen met het ZGW-veld omschrijvingGeneriek op RolType. Wordt gezet op het tabblad Rollen in de zaaktype-instellingen, en meegeleverd door de bezwaar- en besluitvormingsseeds.", "— choose —": "— kies —", "'Valid from' date must be set": "Datum 'Geldig vanaf' moet worden ingevuld", "'Valid until' must be after 'Valid from'": "'Geldig tot' moet na 'Geldig vanaf' liggen", diff --git a/l10n/nl.json b/l10n/nl.json index a63d7ce7e..490e9c05c 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1,6 +1,8 @@ { "translations": { "Contacts": "Contacten", + "Generic Role": "Generieke rol", + "The generic role this case-type role plays, so routing rules and reports can speak about \"the handler\" without knowing every case type's own naming. Maps to the ZGW RolType field omschrijvingGeneriek. Set from the Roles tab in case-type settings, and shipped by the bezwaar and besluitvorming seed data.": "De generieke rol die deze zaaktyperol speelt, zodat routeringsregels en rapportages over \"de behandelaar\" kunnen spreken zonder de eigen benaming van elk zaaktype te kennen. Komt overeen met het ZGW-veld omschrijvingGeneriek op RolType. Wordt gezet op het tabblad Rollen in de zaaktype-instellingen, en meegeleverd door de bezwaar- en besluitvormingsseeds.", "— choose —": "— kies —", "'Valid from' date must be set": "Datum 'Geldig vanaf' moet worden ingevuld", "'Valid until' must be after 'Valid from'": "'Geldig tot' moet na 'Geldig vanaf' liggen", diff --git a/lib/Repair/VthSeedDataRepairStep.php b/lib/Repair/VthSeedDataRepairStep.php index f78000c46..3e37bdd40 100644 --- a/lib/Repair/VthSeedDataRepairStep.php +++ b/lib/Repair/VthSeedDataRepairStep.php @@ -83,6 +83,8 @@ public function __construct( * Get the repair-step display name. * * @return string + * + * @spec openspec/changes/vth-workflow-configuration-01-config-foundation/tasks.md */ public function getName(): string { return 'Seed VTH case types and inspection-checklist templates for Dossiq'; @@ -291,6 +293,11 @@ private function seedInspectionChecklists( schema: $schema ); + $caseTypeIds = $this->caseTypeIdsBySlug( + objectService: $objectService, + register: $register + ); + $seeded = 0; $skipped = 0; foreach ($checklists as $checklist) { @@ -312,7 +319,7 @@ private function seedInspectionChecklists( $objectService->saveObject( register: $register, schema: $schema, - object: $checklist + object: $this->bindCaseType(checklist: $checklist, caseTypeIds: $caseTypeIds) ); $seeded++; } catch (Throwable $e) { @@ -346,6 +353,84 @@ private function stripChildren(array $caseType): array { return $caseType; }//end stripChildren() + /** + * Bind a checklist template to its case type, by slug. + * + * The seed names its case type by slug because that is the only stable + * identifier a shipped file can carry: the uuid is minted at install. The + * schema declares `caseType` (a uuid `$ref`) and declares no `caseTypeSlug`, + * so shipping the slug straight through wrote a key OpenRegister answers 200 + * to and stores nowhere, and every checklist installed unbound. + * + * An unresolvable slug drops the binding rather than the template: a + * checklist with no case type is still usable, `caseType` is optional + * ("null means any case type"), and a `caseTypeSlug` left in the payload + * would only be discarded again. + * + * @param array $checklist The shipped checklist payload. + * @param array $caseTypeIds Case-type uuid keyed by slug. + * + * @return array The payload as OpenRegister should receive it. + */ + private function bindCaseType(array $checklist, array $caseTypeIds): array { + $slug = (string)($checklist['caseTypeSlug'] ?? ''); + unset($checklist['caseTypeSlug']); + + $caseTypeId = (string)($caseTypeIds[$slug] ?? ''); + if ($slug !== '' && $caseTypeId === '') { + $this->logger->warning( + 'Dossiq VTH checklist seed could not resolve its case type', + ['checklist' => ($checklist['slug'] ?? ''), 'caseTypeSlug' => $slug] + ); + return $checklist; + } + + if ($caseTypeId !== '') { + $checklist['caseType'] = $caseTypeId; + } + + return $checklist; + }//end bindCaseType() + + /** + * Map every seeded case type's slug to its OpenRegister uuid. + * + * The slug lives in `@self`, the same place `existingSlugs()` reads it from. + * + * @param object $objectService OpenRegister ObjectService. + * @param string $register Register slug. + * + * @return array Case-type uuid keyed by slug. + */ + private function caseTypeIdsBySlug(object $objectService, string $register): array { + $schema = (string)$this->settingsService->getConfigValue('case_type_schema'); + if ($schema === '') { + return []; + } + + try { + $rows = $this->searchObjectsAsArrays( + objectService: $objectService, + register: $register, + schema: $schema + ); + } catch (Throwable) { + return []; + } + + $ids = []; + foreach ($rows as $row) { + $self = ($row['@self'] ?? []); + $slug = (string)($self['slug'] ?? $row['slug'] ?? ''); + $id = (string)($self['id'] ?? $row['id'] ?? ''); + if ($slug !== '' && $id !== '') { + $ids[$slug] = $id; + } + } + + return $ids; + }//end caseTypeIdsBySlug() + /** * Read existing slugs for idempotency. * diff --git a/lib/Settings/case_flow_seed_data.json b/lib/Settings/case_flow_seed_data.json index 25ae7e682..1f902031e 100644 --- a/lib/Settings/case_flow_seed_data.json +++ b/lib/Settings/case_flow_seed_data.json @@ -9,7 +9,7 @@ "processingDeadline": "P56D", "suspensionAllowed": true, "extensionAllowed": true, - "extensionDuration": "P42D", + "extensionPeriod": "P42D", "confidentiality": "openbaar", "origin": "aanvragen", "statusTypes": [ diff --git a/lib/Settings/dossiq_register.json b/lib/Settings/dossiq_register.json index 6e22fd6cc..aad3b2b41 100644 --- a/lib/Settings/dossiq_register.json +++ b/lib/Settings/dossiq_register.json @@ -359,7 +359,7 @@ "roleType": { "slug": "roleType", "icon": "BadgeAccountOutline", - "version": "1.0.0", + "version": "1.1.0", "x-schema-org": "schema:Role", "x-zgw-equivalent": "RolType", "title": "Role Type", @@ -389,6 +389,21 @@ "description": "Reference to the parent case type", "title": "Case Type" }, + "genericRole": { + "type": "string", + "enum": [ + "initiator", + "co_initiator", + "handler", + "advisor", + "decision_maker", + "coordinator", + "stakeholder", + "contact" + ], + "description": "The generic role this case-type role plays, so routing rules and reports can speak about \"the handler\" without knowing every case type's own naming. Maps to the ZGW RolType field omschrijvingGeneriek. Set from the Roles tab in case-type settings, and shipped by the bezwaar and besluitvorming seed data.", + "title": "Generic Role" + }, "ncGroupId": { "type": "string", "nullable": true, @@ -692,7 +707,7 @@ } }, "icon": "FolderAccountOutline", - "version": "1.12.0", + "version": "1.13.0", "x-schema-org": "schema:Project", "x-zgw-equivalent": "Zaak", "x-openregister-archival": { diff --git a/lib/Settings/templates/bvw-mandaatbesluit.json b/lib/Settings/templates/bvw-mandaatbesluit.json index 6ac4136bf..9c49ac4c0 100644 --- a/lib/Settings/templates/bvw-mandaatbesluit.json +++ b/lib/Settings/templates/bvw-mandaatbesluit.json @@ -15,7 +15,6 @@ "internalOrExternal": "intern", "isDraft": false, "confidentiality": "intern", - "mandateGuard": true, "statusTypes": [ { "name": "Voorstel opstellen", "order": 1, "isFinal": false, "description": "Steller stelt het DT-advies op" }, { "name": "Ambtelijk advies", "order": 2, "isFinal": false, "description": "Inhoudelijk advies" }, diff --git a/lib/Settings/templates/vth-handhavingszaak.json b/lib/Settings/templates/vth-handhavingszaak.json index f497e3920..653b4e0c8 100644 --- a/lib/Settings/templates/vth-handhavingszaak.json +++ b/lib/Settings/templates/vth-handhavingszaak.json @@ -3,15 +3,32 @@ "title": "VTH Handhavingszaak", "version": "1.0.0", "description": "Template voor handhavingszaken (VTH module)", - "statusTypes": ["Constatering", "Vooraankondiging", "Zienswijze", "Handhavingsbesluit", "Begunstigingstermijn", "Hercontrole", "Afgehandeld"], - "documentTypes": ["Constateringsrapport", "Vooraankondigingsbrief", "Handhavingsbesluit", "Dwangsombeschikking"], + "statusTypes": [ + {"name": "Constatering", "order": 1, "isFinal": false}, + {"name": "Vooraankondiging", "order": 2, "isFinal": false}, + {"name": "Zienswijze", "order": 3, "isFinal": false}, + {"name": "Handhavingsbesluit", "order": 4, "isFinal": false}, + {"name": "Begunstigingstermijn", "order": 5, "isFinal": false}, + {"name": "Hercontrole", "order": 6, "isFinal": false}, + {"name": "Afgehandeld", "order": 7, "isFinal": true} + ], + "documentTypes": [ + {"name": "Constateringsrapport"}, + {"name": "Vooraankondigingsbrief"}, + {"name": "Handhavingsbesluit"}, + {"name": "Dwangsombeschikking"} + ], "propertyDefinitions": [ - {"name": "overtredingstype", "type": "string"}, - {"name": "ernst", "type": "string", "enum": ["gering", "aanzienlijk", "ernstig"]}, - {"name": "gedrag", "type": "string", "enum": ["goedwillend", "onverschillig", "calculerend", "crimineel"]}, - {"name": "intervention", "type": "string"}, - {"name": "dwangsombedrag", "type": "number"}, - {"name": "compliancePeriod", "type": "integer"} + {"name": "overtredingstype", "propertyType": "string"}, + {"name": "ernst", "propertyType": "enum", "enumValues": ["gering", "aanzienlijk", "ernstig"]}, + {"name": "gedrag", "propertyType": "enum", "enumValues": ["goedwillend", "onverschillig", "calculerend", "crimineel"]}, + {"name": "intervention", "propertyType": "string"}, + {"name": "dwangsombedrag", "propertyType": "number"}, + {"name": "compliancePeriod", "propertyType": "number"} ], - "roleTypes": ["handler", "overtreder", "toezichthouder"] + "roleTypes": [ + {"name": "handler"}, + {"name": "overtreder"}, + {"name": "toezichthouder"} + ] } diff --git a/lib/Settings/templates/vth-omgevingsvergunning.json b/lib/Settings/templates/vth-omgevingsvergunning.json index 2ab3de79a..d56973b03 100644 --- a/lib/Settings/templates/vth-omgevingsvergunning.json +++ b/lib/Settings/templates/vth-omgevingsvergunning.json @@ -3,16 +3,34 @@ "title": "VTH Omgevingsvergunning", "version": "1.0.0", "description": "Template voor omgevingsvergunning aanvragen (VTH module)", - "statusTypes": ["Ontvangen", "Ontvankelijkheidstoets", "In behandeling", "Advies", "Besluitvorming", "Afgehandeld"], - "documentTypes": ["Bouwtekening", "Constructieberekening", "Situatietekening", "Welstandsadvies", "Fotos bestaande situatie"], + "statusTypes": [ + {"name": "Ontvangen", "order": 1, "isFinal": false}, + {"name": "Ontvankelijkheidstoets", "order": 2, "isFinal": false}, + {"name": "In behandeling", "order": 3, "isFinal": false}, + {"name": "Advies", "order": 4, "isFinal": false}, + {"name": "Besluitvorming", "order": 5, "isFinal": false}, + {"name": "Afgehandeld", "order": 6, "isFinal": true} + ], + "documentTypes": [ + {"name": "Bouwtekening"}, + {"name": "Constructieberekening"}, + {"name": "Situatietekening"}, + {"name": "Welstandsadvies"}, + {"name": "Fotos bestaande situatie"} + ], "propertyDefinitions": [ - {"name": "bouwkosten", "type": "number"}, - {"name": "oppervlakte", "type": "number"}, - {"name": "aantalBouwlagen", "type": "integer"}, - {"name": "bagObject", "type": "string"}, - {"name": "procedureType", "type": "string", "enum": ["regulier", "uitgebreid"]}, - {"name": "activiteiten", "type": "string"} + {"name": "bouwkosten", "propertyType": "number"}, + {"name": "oppervlakte", "propertyType": "number"}, + {"name": "aantalBouwlagen", "propertyType": "number"}, + {"name": "bagObject", "propertyType": "string"}, + {"name": "procedureType", "propertyType": "enum", "enumValues": ["regulier", "uitgebreid"]}, + {"name": "activiteiten", "propertyType": "string"} + ], + "roleTypes": [ + {"name": "handler"}, + {"name": "applicant"}, + {"name": "gemachtigde"}, + {"name": "adviseur"} ], - "roleTypes": ["handler", "applicant", "gemachtigde", "adviseur"], "processingDeadline": {"regulier": "P56D", "uitgebreid": "P182D"} } diff --git a/lib/Settings/templates/vth-toezichtzaak.json b/lib/Settings/templates/vth-toezichtzaak.json index 24bab7b4d..ad030537d 100644 --- a/lib/Settings/templates/vth-toezichtzaak.json +++ b/lib/Settings/templates/vth-toezichtzaak.json @@ -3,15 +3,25 @@ "title": "VTH Toezichtzaak", "version": "1.0.0", "description": "Template voor toezichtzaken inclusief inspectiefasen (VTH module)", - "statusTypes": ["Gepland", "In uitvoering", "Rapport", "Opvolging", "Afgehandeld"], + "statusTypes": [ + {"name": "Gepland", "order": 1, "isFinal": false}, + {"name": "In uitvoering", "order": 2, "isFinal": false}, + {"name": "Rapport", "order": 3, "isFinal": false}, + {"name": "Opvolging", "order": 4, "isFinal": false}, + {"name": "Afgehandeld", "order": 5, "isFinal": true} + ], "inspectionPhases": [ {"name": "Fase 1 - Fundering", "order": 1}, {"name": "Fase 2 - Ruwbouw", "order": 2}, {"name": "Fase 3 - Oplevering", "order": 3} ], - "roleTypes": ["inspecteur", "contactpersoon", "opdrachtgever"], + "roleTypes": [ + {"name": "inspecteur"}, + {"name": "contactpersoon"}, + {"name": "opdrachtgever"} + ], "propertyDefinitions": [ - {"name": "inspectionPhase", "type": "string"}, - {"name": "location", "type": "string"} + {"name": "inspectionPhase", "propertyType": "string"}, + {"name": "location", "propertyType": "string"} ] } diff --git a/lib/Settings/vth_seed_data.json b/lib/Settings/vth_seed_data.json index 8da44182f..c7837e57a 100644 --- a/lib/Settings/vth_seed_data.json +++ b/lib/Settings/vth_seed_data.json @@ -9,7 +9,7 @@ "processingDeadline": "P56D", "suspensionAllowed": true, "extensionAllowed": true, - "extensionDuration": "P42D", + "extensionPeriod": "P42D", "confidentiality": "openbaar", "origin": "aanvragen", "statusTypes": [ @@ -195,44 +195,68 @@ "inspectionChecklists": [ { "slug": "bouwtoezicht-fase-1-fundering", + "seedKey": "bouwtoezicht-fase-1-fundering", "name": "Bouwtoezicht fase 1 - Fundering", "caseTypeSlug": "toezichtzaak-bouw", "version": 1, "status": "active", - "items": [ - {"order": 1, "label": "Fundering conform tekening", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer of de fundering overeenkomt met de goedgekeurde bouwtekening"}, - {"order": 2, "label": "Wapening aanwezig en correct", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer diameter, hart-op-hart afstand en dekking van de wapening"}, - {"order": 3, "label": "Waterkering conform bestek", "type": "yes_no_na", "required": true, "photoRequired": true, "helpText": "Controleer waterkeringsmaatregelen en maak foto bij afwijking"}, - {"order": 4, "label": "Maatvoering gecontroleerd", "type": "getal", "required": true, "photoRequired": false, "helpText": "Meet de afwijking in mm ten opzichte van de tekening"} + "active": true, + "sections": [ + { + "order": 1, + "name": "Bouwtoezicht fase 1 - Fundering", + "items": [ + {"order": 1, "label": "Fundering conform tekening", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer of de fundering overeenkomt met de goedgekeurde bouwtekening"}, + {"order": 2, "label": "Wapening aanwezig en correct", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer diameter, hart-op-hart afstand en dekking van de wapening"}, + {"order": 3, "label": "Waterkering conform bestek", "responseType": "yes_no_na", "required": true, "photoRequired": "if_no", "helpText": "Controleer waterkeringsmaatregelen en maak foto bij afwijking"}, + {"order": 4, "label": "Maatvoering gecontroleerd", "responseType": "getal", "required": true, "photoRequired": "nooit", "helpText": "Meet de afwijking in mm ten opzichte van de tekening"} + ] + } ] }, { "slug": "bouwtoezicht-fase-2-ruwbouw", + "seedKey": "bouwtoezicht-fase-2-ruwbouw", "name": "Bouwtoezicht fase 2 - Ruwbouw", "caseTypeSlug": "toezichtzaak-bouw", "version": 1, "status": "active", - "items": [ - {"order": 1, "label": "Metselwerk conform tekening", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer metselwerk op conformiteit met de bouwtekening"}, - {"order": 2, "label": "Kozijnen correct geplaatst", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer plaatsing, waterdichtheid en isolatie van kozijnen"}, - {"order": 3, "label": "Dakconstructie conform bestek", "type": "yes_no_na", "required": true, "photoRequired": true, "helpText": "Controleer dakconstructie, gordingen en spanten"}, - {"order": 4, "label": "Leidingen correct aangelegd", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer water-, gas- en elektriciteitsleidingen"}, - {"order": 5, "label": "Brandwering aangebracht", "type": "yes_no_na", "required": true, "photoRequired": true, "helpText": "Controleer brandwerende doorvoeringen en scheidingen"} + "active": true, + "sections": [ + { + "order": 1, + "name": "Bouwtoezicht fase 2 - Ruwbouw", + "items": [ + {"order": 1, "label": "Metselwerk conform tekening", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer metselwerk op conformiteit met de bouwtekening"}, + {"order": 2, "label": "Kozijnen correct geplaatst", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer plaatsing, waterdichtheid en isolatie van kozijnen"}, + {"order": 3, "label": "Dakconstructie conform bestek", "responseType": "yes_no_na", "required": true, "photoRequired": "if_no", "helpText": "Controleer dakconstructie, gordingen en spanten"}, + {"order": 4, "label": "Leidingen correct aangelegd", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer water-, gas- en elektriciteitsleidingen"}, + {"order": 5, "label": "Brandwering aangebracht", "responseType": "yes_no_na", "required": true, "photoRequired": "if_no", "helpText": "Controleer brandwerende doorvoeringen en scheidingen"} + ] + } ] }, { "slug": "bouwtoezicht-fase-3-oplevering", + "seedKey": "bouwtoezicht-fase-3-oplevering", "name": "Bouwtoezicht fase 3 - Oplevering", "caseTypeSlug": "toezichtzaak-bouw", "version": 1, "status": "active", - "items": [ - {"order": 1, "label": "Afwerking conform bestek", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer afwerking vloeren, wanden en plafonds"}, - {"order": 2, "label": "Installaties werkend", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Test verwarming, ventilatie, elektra en sanitair"}, - {"order": 3, "label": "Brandveiligheid gewaarborgd", "type": "yes_no_na", "required": true, "photoRequired": true, "helpText": "Controleer rookmelders, vluchtroutes, noodverlichting"}, - {"order": 4, "label": "Toegankelijkheid conform Bouwbesluit", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer drempels, deurbreedtes, toilet toegankelijkheid"}, - {"order": 5, "label": "Energielabel bepaald", "type": "text", "required": true, "photoRequired": false, "helpText": "Noteer het energielabel (A++++ t/m G)"}, - {"order": 6, "label": "As-built tekeningen ontvangen", "type": "yes_no_na", "required": true, "photoRequired": false, "helpText": "Controleer of as-built tekeningen zijn aangeleverd"} + "active": true, + "sections": [ + { + "order": 1, + "name": "Bouwtoezicht fase 3 - Oplevering", + "items": [ + {"order": 1, "label": "Afwerking conform bestek", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer afwerking vloeren, wanden en plafonds"}, + {"order": 2, "label": "Installaties werkend", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Test verwarming, ventilatie, elektra en sanitair"}, + {"order": 3, "label": "Brandveiligheid gewaarborgd", "responseType": "yes_no_na", "required": true, "photoRequired": "if_no", "helpText": "Controleer rookmelders, vluchtroutes, noodverlichting"}, + {"order": 4, "label": "Toegankelijkheid conform Bouwbesluit", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer drempels, deurbreedtes, toilet toegankelijkheid"}, + {"order": 5, "label": "Energielabel bepaald", "responseType": "text", "required": true, "photoRequired": "nooit", "helpText": "Noteer het energielabel (A++++ t/m G)"}, + {"order": 6, "label": "As-built tekeningen ontvangen", "responseType": "yes_no_na", "required": true, "photoRequired": "nooit", "helpText": "Controleer of as-built tekeningen zijn aangeleverd"} + ] + } ] } ], diff --git a/tests/Unit/Settings/SeedPayloadSchemaConformanceTest.php b/tests/Unit/Settings/SeedPayloadSchemaConformanceTest.php new file mode 100644 index 000000000..5e09e8736 --- /dev/null +++ b/tests/Unit/Settings/SeedPayloadSchemaConformanceTest.php @@ -0,0 +1,509 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Tests\Unit\Settings; + +use OCA\Dossiq\Service\Settings\RegisterFragmentMerger; +use PHPUnit\Framework\TestCase; + +/** + * Asserts every shipped seed payload only writes properties its schema declares. + * + * @covers \OCA\Dossiq\Repair\VthSeedDataRepairStep + * @covers \OCA\Dossiq\Service\Besluitvorming\TemplateBundleSeeder + * @covers \OCA\Dossiq\Service\SeedDataService + */ +class SeedPayloadSchemaConformanceTest extends TestCase { + + /** + * Collection key to the schema slug its records are written to. + * + * These are the fan-out points the seeders share: `TemplateBundleSeeder:: + * seedChildren()` and `SeedDataService` both take the records under these + * keys off the case-type payload and write each one, verbatim plus a + * `caseType` back-reference, into the named schema. A key listed here is + * therefore BOTH a collection of payloads to check AND a key the parent + * payload is allowed to carry, because the parent save never sees it. + * + * @var array + */ + private const COLLECTION_SCHEMA = [ + 'caseTypes' => 'caseType', + 'statusTypes' => 'statusType', + 'roleTypes' => 'roleType', + 'resultTypes' => 'resultType', + 'documentTypes' => 'documentType', + 'decisionTypes' => 'decisionType', + 'propertyDefinitions' => 'propertyDefinition', + 'inspectionChecklists' => 'inspectionChecklistTemplate', + ]; + + /** + * Keys OpenRegister consumes itself, before the schema whitelist runs. + * + * `id` addresses the object: `ObjectService::extractUuidAndNormalizeObject()` + * reads `@self.id` then `id` and uses it as the target uuid. `slug` is read + * by `SaveObject::setSelfMetadata()` and stored on the entity as `@self.slug`, + * which is where `VthSeedDataRepairStep::existingSlugs()` reads it back from. + * Neither is a schema property and neither is lost. + * + * `uuid` is deliberately NOT on this list. Nothing reads it: the uuid comes + * from the `$uuid` parameter or from `id`, so a payload `uuid` is dropped + * like any other undeclared key. + * + * @var array + */ + private const OPENREGISTER_METADATA_KEYS = ['id', 'slug']; + + /** + * Case-type keys a seeder reads and takes off the payload before saving it. + * + * These never reach OpenRegister, so they are not drops. The list mirrors + * the `unset()` calls exactly and is not a waiver: adding a key here without + * a matching `unset()` in the seeder re-opens the defect this sweep exists + * to catch. + * + * - `statusTypes` … `resultTypes`, `workflowTemplate`, `initialStatusName`: + * `TemplateBundleSeeder::splitBundle()`, lib/Service/Besluitvorming/TemplateBundleSeeder.php. + * - `parafeerroute`: `BesluitvormingTemplateService::activate()`, + * lib/Service/BesluitvormingTemplateService.php. + * - `caseTypeSlug`: `VthSeedDataRepairStep::seedInspectionChecklists()` + * resolves it to the `caseType` uuid and drops the slug. + * + * @var array + */ + private const SEEDER_CONSUMED_KEYS = [ + 'workflowTemplate', + 'initialStatusName', + 'parafeerroute', + 'caseTypeSlug', + ]; + + /** + * Seed files whose payloads reach OpenRegister on install or on demand. + * + * @var array + */ + private const SEED_FILES = [ + 'vth_seed_data.json', + 'case_flow_seed_data.json', + 'bezwaar_seed_data.json', + 'templates/bvw-college-besluit.json', + 'templates/bvw-mandaatbesluit.json', + 'templates/bvw-raadsbesluit.json', + 'templates/omgevingsvergunning.json', + 'templates/woo-verzoek.json', + 'templates/woo_verzoek.json', + 'templates/vth-handhavingszaak.json', + 'templates/vth-omgevingsvergunning.json', + 'templates/vth-toezichtzaak.json', + ]; + + /** + * The merged register configuration, keyed by schema slug. + * + * @var array> + */ + private array $schemas = []; + + /** + * Load the same merged configuration the installer imports. + * + * The base monolith alone is not the authority: `SettingsService:: + * loadConfiguration()` deep-merges `register.d/*.json` on top of it (ADR-037), + * and a fragment may add the very property a seed writes. Reading only the + * monolith would fail this sweep on properties that do install. + * + * @return void + */ + protected function setUp(): void { + $settingsDir = __DIR__ . '/../../../lib/Settings'; + $base = json_decode(file_get_contents($settingsDir . '/dossiq_register.json'), true); + $this->assertIsArray(actual: $base, message: 'dossiq_register.json must parse'); + + [$merged] = (new RegisterFragmentMerger())->merge( + base: $base, + fragmentDir: $settingsDir . '/register.d' + ); + + $this->schemas = ($merged['components']['schemas'] ?? []); + $this->assertNotEmpty(actual: $this->schemas, message: 'the merged configuration must carry schemas'); + + }//end setUp() + + /** + * Every key every shipped seed payload writes is declared by its schema. + * + * @return void + */ + public function testSeedPayloadsOnlyWriteDeclaredProperties(): void { + $settingsDir = __DIR__ . '/../../../lib/Settings'; + $findings = []; + + foreach (self::SEED_FILES as $relative) { + $path = $settingsDir . '/' . $relative; + if (file_exists($path) === false) { + continue; + } + + $data = json_decode(file_get_contents($path), true); + $this->assertIsArray(actual: $data, message: $relative . ' must parse as JSON'); + + $this->collectFindings( + node: $data, + pointer: $relative, + findings: $findings + ); + }//end foreach + + $this->assertSame( + expected: [], + actual: $findings, + message: + "Seed payloads write properties no schema declares. OpenRegister answers 200\n" + . "and stores nothing, so each of these values is silently lost on every install.\n" + . "Either declare the property on the schema, or stop writing it.\n\n" + . implode("\n", $findings) + ); + + }//end testSeedPayloadsOnlyWriteDeclaredProperties() + + /** + * Walk a decoded seed tree and record every undeclared payload key. + * + * A key named in COLLECTION_SCHEMA carries payloads for the schema it maps + * to. `caseType` is handled as its singular form too, because the + * besluitvorming templates wrap one case type in that key. + * + * @param mixed $node The current node in the decoded tree. + * @param string $pointer Human-readable path to the node, for the failure message. + * @param array $findings Accumulated findings, appended in place. + * + * @return void + */ + private function collectFindings(mixed $node, string $pointer, array &$findings): void { + if (is_array($node) === false) { + return; + } + + foreach ($node as $key => $value) { + $name = (string)$key; + $childPointer = $pointer . '/' . $name; + + if ($name === 'caseType' && $this->isPayload(node: $value) === true) { + $this->checkPayload( + payload: $value, + schemaSlug: 'caseType', + pointer: $childPointer, + findings: $findings + ); + continue; + } + + if (isset(self::COLLECTION_SCHEMA[$name]) === true && is_array($value) === true) { + $slug = self::COLLECTION_SCHEMA[$name]; + foreach ($value as $index => $record) { + if ($this->isPayload(node: $record) === false) { + // A record that is not an object is not a payload at + // all. `VTHTemplateService::seedSubObjects()` and + // `TemplateBundleSeeder::seedChildren()` both skip a + // non-array record, so a collection shipped as bare + // strings seeds NOTHING and still reports success. + $findings[] = $childPointer . '[' . $index . ']: not an object, so the' + . ' seeder skips it and no "' . $slug . '" row is written'; + continue; + } + + $this->checkPayload( + payload: $record, + schemaSlug: $slug, + pointer: $childPointer . '[' . $index . ']', + findings: $findings + ); + } + + continue; + } + + $this->collectFindings(node: $value, pointer: $childPointer, findings: $findings); + }//end foreach + + }//end collectFindings() + + /** + * Check one payload's keys against its schema's declared properties. + * + * Nested collections are checked in their own right by the walker, so they + * are skipped here: the seeders split them off the parent before saving it. + * + * @param array $payload The object payload as shipped. + * @param string $schemaSlug The schema it is written to. + * @param string $pointer Human-readable path to the payload. + * @param array $findings Accumulated findings, appended in place. + * + * @return void + */ + private function checkPayload( + array $payload, + string $schemaSlug, + string $pointer, + array &$findings, + ): void { + $schema = ($this->schemas[$schemaSlug] ?? null); + if (is_array($schema) === false) { + $findings[] = $pointer . ': no schema "' . $schemaSlug . '" in the merged configuration'; + return; + } + + $declared = array_keys(($schema['properties'] ?? [])); + + foreach (array_keys($payload) as $key) { + $name = (string)$key; + if ($name === '' || $name[0] === '@' || $name[0] === '_') { + continue; + } + + if (in_array($name, $declared, true) === true) { + continue; + } + + if (in_array($name, self::OPENREGISTER_METADATA_KEYS, true) === true) { + continue; + } + + if (isset(self::COLLECTION_SCHEMA[$name]) === true) { + continue; + } + + if (in_array($name, self::SEEDER_CONSUMED_KEYS, true) === true) { + continue; + } + + $findings[] = $pointer . '/' . $name . ': schema "' . $schemaSlug . '" does not declare it'; + }//end foreach + + $this->checkNested( + payload: $payload, + properties: ($schema['properties'] ?? []), + schemaSlug: $schemaSlug, + pointer: $pointer, + findings: $findings + ); + + $this->checkRequired( + payload: $payload, + schema: $schema, + schemaSlug: $schemaSlug, + pointer: $pointer, + findings: $findings + ); + + }//end checkPayload() + + /** + * Check the objects nested inside a payload against their sub-schemas. + * + * The top-level keys are only half the surface. An `inspectionChecklist + * Template` declares `sections`, each section declares `items`, and each + * item declares `responseType` — a checklist can name every top-level key + * correctly and still write `type` on every question. Those nested keys are + * stored as part of the parent column, so a wrong one does not vanish from + * the database the way a top-level key does; it lands as data no reader + * understands, which is the same defect wearing a different coat. + * + * @param array $payload The payload, or a nested object in it. + * @param array $properties The matching schema property map. + * @param string $schemaSlug The owning schema slug, for the message. + * @param string $pointer Human-readable path to the payload. + * @param array $findings Accumulated findings, appended in place. + * + * @return void + */ + private function checkNested( + array $payload, + array $properties, + string $schemaSlug, + string $pointer, + array &$findings, + ): void { + foreach ($properties as $name => $definition) { + if (is_array($definition) === false || array_key_exists($name, $payload) === false) { + continue; + } + + $value = $payload[$name]; + + if (($definition['type'] ?? '') === 'array') { + $itemSchema = ($definition['items'] ?? []); + if (is_array($value) === false || is_array($itemSchema) === false) { + continue; + } + + foreach ($value as $index => $entry) { + if ($this->isPayload(node: $entry) === false) { + continue; + } + + $this->checkNestedObject( + payload: $entry, + definition: $itemSchema, + schemaSlug: $schemaSlug, + pointer: $pointer . '/' . $name . '[' . $index . ']', + findings: $findings + ); + } + + continue; + } + + if ($this->isPayload(node: $value) === true) { + $this->checkNestedObject( + payload: $value, + definition: $definition, + schemaSlug: $schemaSlug, + pointer: $pointer . '/' . $name, + findings: $findings + ); + } + }//end foreach + + }//end checkNested() + + /** + * Check one nested object against the sub-schema that declares it. + * + * A sub-schema with no `properties` map declares nothing and constrains + * nothing, so a free-form object is left alone rather than reported. + * + * @param array $payload The nested object. + * @param array $definition Its sub-schema. + * @param string $schemaSlug The owning schema slug, for the message. + * @param string $pointer Human-readable path to the object. + * @param array $findings Accumulated findings, appended in place. + * + * @return void + */ + private function checkNestedObject( + array $payload, + array $definition, + string $schemaSlug, + string $pointer, + array &$findings, + ): void { + $properties = ($definition['properties'] ?? []); + if (is_array($properties) === false || $properties === []) { + return; + } + + foreach (array_keys($payload) as $key) { + $name = (string)$key; + if ($name === '' || $name[0] === '@' || $name[0] === '_') { + continue; + } + + if (array_key_exists($name, $properties) === false) { + $findings[] = $pointer . '/' . $name . ': schema "' . $schemaSlug + . '" does not declare it on this nested object'; + } + } + + foreach (($definition['required'] ?? []) as $required) { + $name = (string)$required; + if ($name !== '' && array_key_exists($name, $payload) === false) { + $findings[] = $pointer . ': schema "' . $schemaSlug . '" requires "' + . $name . '" on this nested object, which it does not carry'; + } + } + + $this->checkNested( + payload: $payload, + properties: $properties, + schemaSlug: $schemaSlug, + pointer: $pointer, + findings: $findings + ); + + }//end checkNestedObject() + + /** + * Check that a payload carries every property its schema requires. + * + * A missing required property is the loud half of the same defect: the save + * throws instead of dropping, the seeder logs a warning and carries on, and + * the collection ends at zero rows while the install reports success. That + * is how the three `bouwtoezicht-*` checklist templates never installed. + * + * @param array $payload The object payload as shipped. + * @param array $schema The schema definition. + * @param string $schemaSlug The schema slug, for the message. + * @param string $pointer Human-readable path to the payload. + * @param array $findings Accumulated findings, appended in place. + * + * @return void + */ + private function checkRequired( + array $payload, + array $schema, + string $schemaSlug, + string $pointer, + array &$findings, + ): void { + foreach (($schema['required'] ?? []) as $required) { + $name = (string)$required; + if ($name === '' || array_key_exists($name, $payload) === true) { + continue; + } + + // A `$ref` property the seeder resolves and injects itself (the + // `caseType` back-reference `seedChildren()` sets) is not expected + // in the shipped file. + if ($name === 'caseType' && $schemaSlug !== 'case') { + continue; + } + + $findings[] = $pointer . ': schema "' . $schemaSlug + . '" requires "' . $name . '", which the payload does not carry'; + }//end foreach + + }//end checkRequired() + + /** + * Whether a node is an object payload rather than a list or a scalar. + * + * @param mixed $node The node to test. + * + * @return bool True when the node is a string-keyed map. + */ + private function isPayload(mixed $node): bool { + return (is_array($node) === true && $node !== [] && array_is_list($node) === false); + + }//end isPayload() +}//end class