Skip to content

Commit 9f1e1ef

Browse files
committed
Herhaalbare velden opslaan en pre-scan import fixen
Herhaalbare groepen (zoals persoonsgegevens) konden niet meerdere antwoorden opslaan. Oorzaak: het state-formaat had geen structuur voor meerdere instanties. Herschreven naar arrays met _index per element, namespace-wrapping verwijderd, completedTasks naar metadata, UI-state naar localStorage. Pre-scan import in DPIA: antwoorden gaan naar apart _prescanAnswers veld zodat usePreScanReferences ze vindt zonder DPIA-secties onterecht als voltooid te markeren. completedTasks worden niet meer afgeleid uit answer keys voor moderne exports (met $schema/urn). Backend diffStates en rebuildState herschreven voor nieuw formaat met backward-compatibiliteit voor opgeslagen legacy data.
1 parent 7b66219 commit 9f1e1ef

37 files changed

Lines changed: 2860 additions & 1500 deletions

.claude/CLAUDE.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pnpm monorepo met workspaces:
2121
- Package scope: `@overheid-assessment/*`
2222
- Node 22, pnpm (via `corepack enable`)
2323
- Transitive dependencies van `assessment-core` (zoals `pdfmake`) moeten ook in de consumerende app staan
24+
- Geen eenregelige wrapper-functies — roep de oorspronkelijke functie direct aan
2425

2526
## Ontwikkelen
2627

@@ -82,7 +83,47 @@ pnpm dev
8283
- `test.yaml` — type-check en tests
8384
- GHCR images: `ghcr.io/minbzk/par-dpia-form/dev/frontend` en `dev/backend` (publiek leesbaar)
8485

86+
## Assessment state format
87+
88+
Eén unified format voor DB (`cachedState`), file export én API communicatie. Gevalideerd door `schemas/assessment-output.v2.schema.json`.
89+
90+
```json
91+
{
92+
"$schema": "...assessment-output.v2.schema.json",
93+
"metadata": {
94+
"createdAt": "2026-03-20T12:00:00Z",
95+
"urn": "urn:nl:dpia:3.0",
96+
"completedTasks": ["0", "1"]
97+
},
98+
"answers": {
99+
"0.1": { "value": "Inleiding", "lastEditedAt": "..." },
100+
"2.1": [
101+
{ "_index": 0, "2.1.1": { "value": "E-mailadres" }, "2.1.2": { "value": "Medewerkers" } },
102+
{ "_index": 2, "2.1.1": { "value": "Telefoon" }, "2.1.2": { "value": "Klanten" } }
103+
]
104+
}
105+
}
106+
```
107+
108+
- Geen namespace-wrapping (`dpia`/`prescan`) — namespace afgeleid uit `metadata.urn`
109+
- Repeatable groepen als arrays met `_index` per element (gaps mogelijk bij verwijdering)
110+
- `completedTasks` in `metadata`, geen apart `taskState` object
111+
- `taskInstances` worden NIET opgeslagen — herbouwd bij laden uit task definities + answers
112+
- `currentRootTaskId` en `activeNamespace` zijn UI-state → `localStorage` (per assessmentId)
113+
114+
### URN field identifiers (assessment_edits tabel)
115+
116+
De `assessment_edits` tabel gebruikt URN-based field IDs conform RFC 8141 r-component syntax:
117+
118+
- `urn:nl:dpia:3.0?=task_id=2.1.3` — niet-herhaalbaar veld
119+
- `urn:nl:dpia:3.0?=task_id=2.1.3&task_index=0` — herhaalbaar veld met index
120+
- `urn:nl:dpia:3.0?=task_id=completed.1` — sectie-voltooiing
121+
122+
De `?=` is de URN r-component (resolution), niet een typo voor `?`.
123+
85124
## Debugging en verificatie
86125

87126
- Gebruik Playwright (via MCP) om UI-issues zelf te onderzoeken en te verifiëren. Log in als testgebruiker, navigeer naar de relevante pagina, en controleer het resultaat — zonder de gebruiker om screenshots te vragen.
88127
- Bij visuele bugs of onverwacht gedrag: neem een screenshot, inspecteer de DOM via snapshots, en lees console messages om de oorzaak te achterhalen.
128+
- Test altijd zowel de **standalone form** (`localhost:5175`) als de **boekhouding-frontend** (`localhost:5174`). Beide gebruiken assessment-core maar met verschillende persistence providers.
129+
- Sluit na een Playwright-testronde altijd de browser met `browser_close` om te voorkomen dat Chrome-processen blijven hangen en volgende sessies blokkeren.

apps/boekhouding-backend/src/routes/assessments.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ export async function assessmentRoutes(app: FastifyInstance) {
246246
&& lastVersion.createdBy === userId
247247
&& !forceNewVersion
248248
&& !changeDescription
249+
&& !lastVersion.changeDescription
249250
&& (Date.now() - lastVersion.createdAt.getTime()) < CONSOLIDATION_WINDOW_MS
250251

251252
if (canConsolidate) {

apps/boekhouding-backend/src/utils/diffStates.ts

Lines changed: 109 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
/**
2-
* Parse an instance ID into taskId and optional index.
3-
* "2.1.3" → { taskId: "2.1.3" }
4-
* "2.1.3[0]" → { taskId: "2.1.3", index: 0 }
5-
*/
61
export function parseInstanceId(instanceId: string): { taskId: string; index?: number } {
72
const match = instanceId.match(/^(.+)\[(\d+)\]$/)
83
if (match) return { taskId: match[1], index: parseInt(match[2]) }
@@ -28,40 +23,71 @@ export type EditRecord = {
2823
newValue: unknown
2924
}
3025

26+
function isGroupedArray(value: unknown): value is Array<{ _index: number; [key: string]: unknown }> {
27+
return Array.isArray(value) && value.length > 0 && typeof value[0]?._index === 'number'
28+
}
29+
3130
/**
32-
* Compares two states and produces field-level edit records.
33-
* Tracks answer changes, completed section changes, and task instance add/remove.
34-
* Uses URN-based field identifiers when available.
31+
* Compare two grouped arrays element by element, matching on _index.
3532
*/
36-
export function diffStates(
37-
oldState: unknown,
38-
newState: unknown,
33+
function diffGroupedArrays(
34+
oldArr: Array<{ _index: number; [key: string]: unknown }> | undefined,
35+
newArr: Array<{ _index: number; [key: string]: unknown }> | undefined,
36+
parentKey: string,
37+
urn: string | undefined,
3938
editedBy: string,
4039
): EditRecord[] {
4140
const edits: EditRecord[] = []
41+
const oldByIndex = new Map<number, Record<string, unknown>>()
42+
const newByIndex = new Map<number, Record<string, unknown>>()
43+
44+
for (const el of oldArr ?? []) oldByIndex.set(el._index, el)
45+
for (const el of newArr ?? []) newByIndex.set(el._index, el)
46+
47+
const allIndices = new Set([...oldByIndex.keys(), ...newByIndex.keys()])
48+
49+
for (const idx of allIndices) {
50+
const oldEl = oldByIndex.get(idx)
51+
const newEl = newByIndex.get(idx)
52+
53+
const childKeys = new Set<string>()
54+
if (oldEl) for (const k of Object.keys(oldEl)) { if (k !== '_index') childKeys.add(k) }
55+
if (newEl) for (const k of Object.keys(newEl)) { if (k !== '_index') childKeys.add(k) }
56+
57+
// Entire instance added or removed — bundle child values into one edit
58+
if ((!oldEl && newEl) || (oldEl && !newEl)) {
59+
// Skip default index 0 when the parent key was newly saved —
60+
// index 0 always exists implicitly via init() and is not a user action.
61+
const skipDefault = idx === 0 && !oldEl && (!oldArr || oldArr.length === 0)
62+
if (!skipDefault) {
63+
// Collect child field values from the instance that existed
64+
const source = (oldEl ?? newEl)!
65+
const fields: Record<string, unknown> = {}
66+
for (const k of Object.keys(source)) {
67+
if (k !== '_index') fields[k] = source[k]
68+
}
69+
70+
const instanceId = `${parentKey}[${idx}]`
71+
const fieldId = urn ? buildFieldUrn(urn, instanceId) : instanceId
72+
edits.push({
73+
fieldId,
74+
editType: !oldEl ? 'instance_added' : 'instance_removed',
75+
editedBy,
76+
oldValue: !oldEl ? null : (Object.keys(fields).length > 0 ? fields : null),
77+
newValue: !newEl ? null : (Object.keys(fields).length > 0 ? fields : null),
78+
})
79+
}
80+
continue
81+
}
4282

43-
const newMeta = (newState as any)?.metadata || {}
44-
const urn: string | undefined = newMeta.urn
45-
46-
const oldAnswers = (oldState as any)?.answers || {}
47-
const newAnswers = (newState as any)?.answers || {}
48-
49-
// Compare answers across namespaces
50-
const allNamespaces = new Set([...Object.keys(oldAnswers), ...Object.keys(newAnswers)])
51-
52-
for (const ns of allNamespaces) {
53-
const oldNs = oldAnswers[ns] || {}
54-
const newNs = newAnswers[ns] || {}
55-
const allKeys = new Set([...Object.keys(oldNs), ...Object.keys(newNs)])
56-
57-
for (const key of allKeys) {
58-
const oldVal = oldNs[key]
59-
const newVal = newNs[key]
83+
for (const childKey of childKeys) {
84+
const oldVal = oldEl?.[childKey]
85+
const newVal = newEl?.[childKey]
6086

6187
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
62-
const fieldId = urn ? buildFieldUrn(urn, key) : `${ns}.${key}`
88+
const instanceId = `${childKey}[${idx}]`
89+
const fieldId = urn ? buildFieldUrn(urn, instanceId) : instanceId
6390
edits.push({
64-
6591
fieldId,
6692
editType: 'answer_change',
6793
editedBy,
@@ -72,74 +98,68 @@ export function diffStates(
7298
}
7399
}
74100

75-
// Compare task state across namespaces
76-
const oldTaskState = (oldState as any)?.taskState || {}
77-
const newTaskState = (newState as any)?.taskState || {}
78-
const taskNamespaces = new Set([...Object.keys(oldTaskState), ...Object.keys(newTaskState)])
101+
return edits
102+
}
79103

80-
for (const ns of taskNamespaces) {
81-
// Compare completed sections
82-
const oldCompleted = new Set<string>(oldTaskState[ns]?.completedRootTaskIds || [])
83-
const newCompleted = new Set<string>(newTaskState[ns]?.completedRootTaskIds || [])
104+
/**
105+
* Compares two states and produces field-level edit records.
106+
* States use the unwrapped format: answers at top level, completedTasks in metadata.
107+
* Uses URN-based field identifiers when available.
108+
*/
109+
export function diffStates(
110+
oldState: unknown,
111+
newState: unknown,
112+
editedBy: string,
113+
): EditRecord[] {
114+
const edits: EditRecord[] = []
84115

85-
for (const id of newCompleted) {
86-
if (!oldCompleted.has(id)) {
87-
const fieldId = urn ? buildFieldUrn(urn, `completed.${id}`) : `${ns}.completed.${id}`
88-
edits.push({
116+
const newMeta = (newState as any)?.metadata || {}
117+
const urn: string | undefined = newMeta.urn
89118

90-
fieldId,
91-
editType: 'section_complete',
92-
editedBy,
93-
oldValue: false,
94-
newValue: true,
95-
})
96-
}
119+
const oldAnswers = (oldState as any)?.answers || {}
120+
const newAnswers = (newState as any)?.answers || {}
121+
const allKeys = new Set([...Object.keys(oldAnswers), ...Object.keys(newAnswers)])
122+
123+
for (const key of allKeys) {
124+
const oldVal = oldAnswers[key]
125+
const newVal = newAnswers[key]
126+
127+
// Handle grouped arrays: compare child-by-child
128+
if (isGroupedArray(oldVal) || isGroupedArray(newVal)) {
129+
edits.push(...diffGroupedArrays(
130+
isGroupedArray(oldVal) ? oldVal : undefined,
131+
isGroupedArray(newVal) ? newVal : undefined,
132+
key, urn, editedBy,
133+
))
134+
continue
97135
}
98-
for (const id of oldCompleted) {
99-
if (!newCompleted.has(id)) {
100-
const fieldId = urn ? buildFieldUrn(urn, `completed.${id}`) : `${ns}.completed.${id}`
101-
edits.push({
102136

103-
fieldId,
104-
editType: 'section_complete',
105-
editedBy,
106-
oldValue: true,
107-
newValue: false,
108-
})
109-
}
137+
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
138+
const fieldId = urn ? buildFieldUrn(urn, key) : key
139+
edits.push({
140+
fieldId,
141+
editType: 'answer_change',
142+
editedBy,
143+
oldValue: oldVal ?? null,
144+
newValue: newVal ?? null,
145+
})
110146
}
147+
}
111148

112-
// Compare task instances (add/remove)
113-
const oldInstances = Object.keys(oldTaskState[ns]?.taskInstances || {})
114-
const newInstances = Object.keys(newTaskState[ns]?.taskInstances || {})
115-
const oldInstanceSet = new Set(oldInstances)
116-
const newInstanceSet = new Set(newInstances)
117-
118-
for (const id of newInstances) {
119-
if (!oldInstanceSet.has(id)) {
120-
const fieldId = urn ? buildFieldUrn(urn, id) : `${ns}.${id}`
121-
edits.push({
149+
// Compare completedTasks in metadata
150+
const oldCompleted = new Set<string>((oldState as any)?.metadata?.completedTasks || [])
151+
const newCompleted = new Set<string>(newMeta.completedTasks || [])
122152

123-
fieldId,
124-
editType: 'task_instance_add',
125-
editedBy,
126-
oldValue: null,
127-
newValue: newTaskState[ns].taskInstances[id],
128-
})
129-
}
153+
for (const id of newCompleted) {
154+
if (!oldCompleted.has(id)) {
155+
const fieldId = urn ? buildFieldUrn(urn, `completed.${id}`) : `completed.${id}`
156+
edits.push({ fieldId, editType: 'section_complete', editedBy, oldValue: false, newValue: true })
130157
}
131-
for (const id of oldInstances) {
132-
if (!newInstanceSet.has(id)) {
133-
const fieldId = urn ? buildFieldUrn(urn, id) : `${ns}.${id}`
134-
edits.push({
135-
136-
fieldId,
137-
editType: 'task_instance_remove',
138-
editedBy,
139-
oldValue: oldTaskState[ns].taskInstances[id],
140-
newValue: null,
141-
})
142-
}
158+
}
159+
for (const id of oldCompleted) {
160+
if (!newCompleted.has(id)) {
161+
const fieldId = urn ? buildFieldUrn(urn, `completed.${id}`) : `completed.${id}`
162+
edits.push({ fieldId, editType: 'section_complete', editedBy, oldValue: true, newValue: false })
143163
}
144164
}
145165

0 commit comments

Comments
 (0)