Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 87 additions & 22 deletions lib/Flow/DossiqAskPersonNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -536,11 +536,26 @@ private function caseIdFrom(array $items): string {
* what orphaned every applicant task live: FlowRunAssignee compared real
* uids against the un-rendered placeholder and refused all of them.
*
* 🔴 AN EMPTY RENDERING REFUSES LOUDLY. A template that resolves to
* nothing would create an UNASSIGNED task, and OpenRegister's resume guard
* deliberately lets anyone answer a step that names no assignee — so a
* quiet fallback here would open the case's progress to any authenticated
* user. Failing the step is the safe direction.
* 🔴 AN EMPTY RENDERING NEVER FALLS BACK TO NOBODY. A template that
* resolves to nothing would create an UNASSIGNED task, and OpenRegister's
* resume guard deliberately lets anyone answer a step that names no
* assignee — so a quiet empty here would open the case's progress to any
* authenticated user.
*
* 🔴 BUT REFUSING WAS NOT A DEFINED BEHAVIOUR EITHER, AND IT KILLED RUNS.
* `{{ case.assignee }}` is the shipped spelling, and `assignee` is NOT in
* the case schema's `required`: a case filed from the New case dialog with
* only a title and a case type has none. The step then threw, the run
* failed, and the case sat in `Wacht op aanvulling` with nothing waiting
* on it and no task for anybody. Reproduced twice on clean installs.
*
* So a declaration may name an `assigneeFallback`: the principal the ask
* goes to when the primary resolves to nobody. That is a DECLARED second
* choice, not a silent one. It is written in the flow, CaseFlowDeclaration
* Test requires it of every templated assignee, and ProvisionAssignedGroups
* Test requires the group it names to be provisioned. A declaration with no
* fallback still refuses, and a fallback that itself resolves to nothing
* refuses too: failing closed stays the last word.
*
* The case is offered under both its own keys and a `case.` prefix,
* because the declarations write `{{ case.assignee }}` — the same spelling
Expand All @@ -551,42 +566,92 @@ private function caseIdFrom(array $items): string {
*
* @return string The rendered assignee.
*
* @throws RuntimeException When the assignee renders empty or unresolved.
*
* @SuppressWarnings(PHPMD.StaticAccess) FlowValueTemplate is the engine's
* canonical rendering API and is published as a static, final class —
* there is no instance to inject.
* @throws RuntimeException When neither the assignee nor its declared
* fallback resolves to a principal.
*
* @spec openspec/changes/case-flow-human-steps/specs/case-flow-human-steps/spec.md
*/
private function renderedAssignee(array $config, array $items): string {
$raw = trim((string) ($config['assignee'] ?? ''));

$case = [];
$first = ($items[0] ?? null);
if (is_array($first) === true) {
$case = (array) ($first['json'] ?? []);
}

$rendered = FlowValueTemplate::renderTracked(value: $raw, json: array_merge($case, ['case' => $case]));
$json = array_merge($case, ['case' => $case]);

$primary = trim((string) ($config['assignee'] ?? ''));
$resolved = $this->renderPrincipal(raw: $primary, json: $json);
if ($resolved !== '') {
return $resolved;
}

$value = $rendered['value'];
if (is_array($value) === true || trim((string) $value) === '' || $rendered['unresolved'] !== []) {
$detail = '';
if ($rendered['unresolved'] !== []) {
$detail = ' (unresolved: ' . implode(', ', $rendered['unresolved']) . ')';
$fallback = trim((string) ($config['assigneeFallback'] ?? ''));
if ($fallback !== '') {
$resolved = $this->renderPrincipal(raw: $fallback, json: $json);
if ($resolved !== '') {
$this->logger->info(
'Dossiq askPerson: "' . $primary . '" named nobody on this case, so the ask goes to its '
. 'declared fallback "' . $resolved . '"',
['case' => (string) ($case['id'] ?? ($case['uuid'] ?? ''))]
);

return $resolved;
}
}

throw new RuntimeException(
sprintf('dossiq.askPerson could not resolve the assignee "%s" against the case%s', $raw, $detail)
);
$why = 'the step declares no assigneeFallback to send the ask to instead';
if ($fallback !== '') {
$why = sprintf('its fallback "%s" resolved to nobody either', $fallback);
}

return trim((string) $value);
throw new RuntimeException(
sprintf(
'dossiq.askPerson could not resolve the assignee "%s" against the case, and %s',
$primary,
$why
)
);

}//end renderedAssignee()


/**
* Render one authored principal against the case, or return nothing.
*
* "Nothing" covers all three ways an authored value fails to name
* somebody: an empty rendering, one the engine could not resolve, and one
* that came back as a structure rather than a name. The caller decides
* what to do about it, because the answer differs between the primary
* assignee and its fallback.
*
* @param string $raw The authored value, template or literal.
* @param array $json The case, under its own keys and a `case.` prefix.
*
* @return string The rendered principal, or '' when it names nobody.
*
* @SuppressWarnings(PHPMD.StaticAccess) FlowValueTemplate is the engine's
* canonical rendering API and is published as a static, final class —
* there is no instance to inject.
*
* @spec openspec/changes/case-flow-human-steps/specs/case-flow-human-steps/spec.md
*/
private function renderPrincipal(string $raw, array $json): string {
if ($raw === '') {
return '';
}

$rendered = FlowValueTemplate::renderTracked(value: $raw, json: $json);
$value = $rendered['value'];
if (is_array($value) === true || $rendered['unresolved'] !== []) {
return '';
}

return trim((string) $value);

}//end renderPrincipal()


/**
* The task record this step asks somebody to complete.
*
Expand Down
20 changes: 15 additions & 5 deletions lib/Repair/ProvisionAssignedGroups.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,23 @@ class ProvisionAssignedGroups implements IRepairStep {
* Every group the shipped register data assigns steps to.
*
* This list is the provisioning counterpart of the literals in
* lib/Settings/dossiq_register.json; ProvisionAssignedGroupsTest sweeps
* the shipped flows and fails when a group is assigned there that this
* list does not provision, so the two cannot drift apart silently.
* lib/Settings/dossiq_register.json and lib/Settings/register.d/*.json;
* ProvisionAssignedGroupsTest sweeps the shipped flows and fails when a
* group is assigned there that this list does not provision, so the two
* cannot drift apart silently. The sweep reads `assigneeFallback` as well
* as `assignee`: a fallback nobody is a member of fails exactly as loudly
* as a primary nobody is a member of, and it fails on the case the primary
* was already unable to serve.
*
* `bezwaarcommissie` is the bezwaaradviescommissie's own group, kept
* separate from `behandelaars` on purpose: Awb art. 7:13 requires the
* advisory committee to be independent of the officials who handled the
* case, so routing its two steps to the handlers would be worse than the
* unassigned steps it replaces.
*
* @var array<int, string>
*/
public const ASSIGNED_GROUPS = ['behandelaars'];
public const ASSIGNED_GROUPS = ['behandelaars', 'bezwaarcommissie'];

/**
* Constructor.
Expand Down Expand Up @@ -200,7 +210,7 @@ private function seedWithAdministrators(IGroup $group, string $groupId, IOutput

$output->info(
'Dossiq: seeded group "' . $groupId . '" with ' . count($added) . ' administrator(s): '
. implode(', ', $added) . '. Replace them with the real behandelaars in Users & groups.'
. implode(', ', $added) . '. Replace them with the real members in Users & groups.'
);
$this->logger->info(
'Dossiq: seeded assigned group with administrators',
Expand Down
14 changes: 8 additions & 6 deletions lib/Settings/dossiq_register.json
Original file line number Diff line number Diff line change
Expand Up @@ -1109,12 +1109,14 @@
}
},
{
"id": "ask-indiener",
"id": "ask-aanvulling",
"type": "dossiq.askPerson",
"_note": "THIS STEP ADDRESSES THE BEHANDELAAR, NOT THE INDIENER, AND IT USED TO BE NAMED AND WORDED AS THOUGH IT DID THE OPPOSITE. An indiener has no Nextcloud identity in this app at all: the case carries them as `initiatorSourceId` (a BSN, a KvK number or a contact URI) or as the pseudonymous `portalSubject`, while a task's `assignee` is a Nextcloud uid or group id and the completion gate resolves it as one. So the ask cannot go to the applicant. It goes to the handler, who asks the applicant and records the supplement on the case, which is also the only way `description` gets filled and the loop back to `check-complete` ever takes the `compleet` exit.\n\nAND IT MUST NAME SOMEBODY WHEN THE CASE NAMES NOBODY. `assignee` is not in the case schema's `required`, so a case filed from the New case dialog with only a title and a case type has none, `{{ case.assignee }}` resolved to nothing, and the run DIED here: reproduced twice on clean installs. `assigneeFallback` is the declared second choice, the same `behandelaars` group `task-behandelaar` already uses and ProvisionAssignedGroups already creates. Unassigned work goes to the group's queue rather than killing the case.",
"config": {
"question": "Vul uw aanvraag aan",
"details": "Uw aanvraag is nog niet compleet. Vul de ontbrekende gegevens aan zodat wij verder kunnen.",
"question": "Vraag de indiener om aanvulling",
"details": "Deze aanvraag is niet compleet. Vraag de indiener om de ontbrekende gegevens en vul ze aan in de zaak.",
"assignee": "{{ case.assignee }}",
"assigneeFallback": "behandelaars",
"dueInDays": "14",
"signalKey": "aanvulling"
}
Expand Down Expand Up @@ -1251,11 +1253,11 @@
{
"id": "e-count",
"from": "count-round",
"to": "ask-indiener"
"to": "ask-aanvulling"
},
{
"id": "e-heraanbieding",
"from": "ask-indiener",
"from": "ask-aanvulling",
"to": "check-complete"
},
{
Expand Down Expand Up @@ -1778,7 +1780,7 @@
"aanvulling": {
"type": "object",
"title": "Supplement answer",
"description": "The signal payload of the applicant's supplement task (ask-indiener, signalKey aanvulling). The flow stamps it onto the case when the task completes. Declared because the store strips what the schema does not declare: an undeclared signal field is silently gone the moment any writer saves the case.",
"description": "The signal payload of the supplement task (ask-aanvulling, signalKey aanvulling) the handler completes once the applicant has supplied what was missing. The flow stamps it onto the case when the task completes. Declared because the store strips what the schema does not declare: an undeclared signal field is silently gone the moment any writer saves the case.",
"visible": false
},
"voorbereiding": {
Expand Down
3 changes: 3 additions & 0 deletions lib/Settings/register.d/72-committees-to-decidiq.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@
{
"id": "open-deliberation",
"type": "dossiq.askPerson",
"_note": "BOTH ASKS IN THIS FLOW USED TO NAME NOBODY, WHICH IS NOT A LOOSE END BUT A DEAD FLOW. DossiqAskPersonNode::validateConfig refuses an empty assignee outright (an unassigned task is answerable by anyone, because OpenRegister's resume guard reads silence as no restriction), so this node threw on its first execute and every bacAdviceRequest ever created failed its run at this step. The flow's own description says the secretary opens deliberation and the chair issues the advice, and both uids DO exist, on the bezwaaradviescommissie. They are not reachable from here: the item json is the bacAdviceRequest, whose `committee` is a bare reference the trigger does not expand, so `{{ committee.secretary }}` would resolve to nothing on every run and quietly mean the fallback. The group is the honest principal until the committee reference is expanded into the run.",
"config": {
"question": "Open de beraadslaging",
"details": "De commissie heeft dit bezwaar toegewezen gekregen. Bevestig dat de beraadslaging is gestart zodra de zitting is belegd.",
"assignee": "bezwaarcommissie",
"dueInDays": "14",
"signalKey": "beraadslagingGestart"
}
Expand All @@ -69,6 +71,7 @@
"config": {
"question": "Breng het advies uit",
"details": "Leg de bevindingen, de juridische beoordeling en de aanbeveling vast en onderteken het advies. Awb art. 7:13 lid 7.",
"assignee": "bezwaarcommissie",
"dueInDays": "84",
"signalKey": "adviesUitgebracht"
}
Expand Down
2 changes: 1 addition & 1 deletion src/dialogs/BeschikkingComposerDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
<NcTextArea
:modelValue="rationale"
:label="t('dossiq', 'Motivering')"
@update:modelValue="(v) => (motivering = v)" />
@update:modelValue="(v) => (rationale = v)" />
</div>
<NcNoteCard v-if="error" type="error">
{{ error }}
Expand Down
29 changes: 25 additions & 4 deletions src/dialogs/DsoCaseDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,17 @@
<!-- Sub-dialogs -->
<BeschikkingDialog
v-if="showBeschikkingDialog"
:zaakId="caseId"
:zaakId="zaakId"
@close="showBeschikkingDialog = false"
@generated="onBeschikkingGenerated" />
<SamenwerkverzoekDialog
v-if="showSamenwerkDialog"
:zaakId="caseId"
:zaakId="zaakId"
@close="showSamenwerkDialog = false"
@initiated="onSamenwerkInitiated" />
<DoorstuurDialog
v-if="showDoorstuurDialog"
:zaakId="caseId"
:zaakId="zaakId"
@close="showDoorstuurDialog = false" />

<!-- Inline transition form -->
Expand Down Expand Up @@ -195,6 +195,27 @@ export default {
},

computed: {
/**
* The case, under a name a template expression may actually use.
*
* 🔴 THE PROP IS CALLED `case`, AND A TEMPLATE CANNOT READ IT. Vue
* parses every template expression as JavaScript, and `case` is a
* reserved word: `{{ case.title }}` is a compile error, not a lookup
* that returns undefined. So this alias is not a nicety, it is the
* only way the template reaches the prop at all.
*
* The template was written against `zaak` and the prop was later
* renamed to `case` without it, which is why every field in this
* dialog rendered as nothing.
*
* @return {object} The case this dialog shows.
*
* @spec exclude presentational alias for a reserved-word prop name
*/
zaak() {
return this.case
},

zaakId() {
return this.case.uuid || this.case.id || ''
},
Expand Down Expand Up @@ -250,7 +271,7 @@ export default {
const { data } = await axios.post(
generateUrl(
'/apps/dossiq/api/dso/cases/'
+ encodeURIComponent(this.caseId)
+ encodeURIComponent(this.zaakId)
+ '/transition',
),
payload,
Expand Down
4 changes: 2 additions & 2 deletions src/dialogs/SamenwerkverzoekDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
v-for="org in commonOrganizations"
:key="org"
type="tertiary"
@click="aangezochtBevoegdGezag = org">
@click="requestedCompetentAuthority = org">
{{ org }}
</NcButton>
</div>
Expand All @@ -56,7 +56,7 @@
</NcButton>
<NcButton
type="primary"
:disabled="!aangezochtBevoegdGezag || submitting"
:disabled="!requestedCompetentAuthority || submitting"
@click="submit">
{{ t('dossiq', 'Initiate') }}
</NcButton>
Expand Down
Loading
Loading