Skip to main content

What a citizen may write on their own case

A citizen with a case in the portal can correct an answer they already gave, add the document that was missing, and answer what the case worker asked. What they may touch is declared by the case app, on the case type. Portaliq keeps no list of its own and decides nothing: it reads the declaration on every request and renders exactly that.

This page is the contract a case app implements.

The three acts​

An amendment, a document and a task answer look alike to a database and nothing alike to a citizen or to the law, so each has its own route, its own refusal and its own event.

ActRouteGoverned by
Read the case and its writable setGET /portal/api/citizen/cases/{register}/{schema}/{id}the case type
Amend an answerPATCH /portal/api/citizen/cases/{register}/{schema}/{id}portalAmendmentWindow plus the field flag
Add a documentPOST /portal/api/citizen/cases/{register}/{schema}/{id}/documentsportalDocumentWindow
Answer a taskPOST /portal/api/tasks/{uuid}/completethe task itself, answered once

What the case app declares​

On the update action​

The contribution's type: update action for the case schema carries a citizenWrite block. Without it the citizen write surface does not exist for that schema, and all three acts answer 403.

{
"id": "amend-case",
"type": "update",
"register": "zaken",
"schema": "zaak",
"scopeField": "indiener",
"fields": ["omschrijving", "toelichting"],
"citizenWrite": {
"typeField": "zaaktype",
"typeRegister": "zaken",
"typeSchema": "zaaktype",
"statusField": "status",
"recordField": "portalWrites"
}
}

typeField, typeRegister and typeSchema are required: they say where the case type lives. statusField defaults to status and recordField to portalWrites. A declaration missing a required key is dropped whole, which closes the surface rather than opening it.

The action's own fields whitelist still applies. A case type can never widen what the contribution already granted.

On the case type​

The flag lives on the field, so the case type carries one entry per field.

{
"portalWritable": [
{ "field": "omschrijving", "audiences": ["client"] },
{
"field": "toelichting",
"audiences": ["client"],
"openStatuses": ["ontvangen"],
"closedReason": "De toelichting hoort bij de indiening en staat vast."
}
],
"portalAmendmentWindow": {
"openStatuses": ["ontvangen", "aanvullen"],
"closedReason": "De aanvraag is in behandeling genomen."
},
"portalDocumentWindow": {
"openStatuses": ["ontvangen", "aanvullen"],
"closedReason": "De zaak neemt geen stukken meer aan."
},
"portalStatusLabels": {
"ontvangen": {
"label": "Wij hebben uw aanvraag ontvangen",
"description": "U hoort binnen acht weken van ons."
}
}
}

A field is open when its flag names the audience, the amendment window is open in the case's current status, and the field's own openStatuses, if it declares any, include that status. Everything else is closed, and a closed field is shown as text with the sentence that says why, never as a control that fails on submit.

Both windows are closed when they are absent or malformed. So is the whole surface when the case type cannot be read.

portalStatusLabels is the vocabulary the citizen reads. Portaliq renders the label and description unchanged and shows nothing when a status carries none; it holds no status words of its own.

What lands on the case​

Every citizen write appends a record to recordField, naming the identity, the mandate it acted under, both answers and the time:

{
"act": "amendment",
"identity": { "subjectRef": "…", "audience": "client", "trust": "high", "jti": "…" },
"mandate": { "action": "amend-case", "audience": "client", "minTrust": "substantial" },
"changes": { "omschrijving": { "from": "Een dakkapel", "to": "Een dakkapel aan de achterzijde" } },
"at": "2026-09-14T10:00:00+00:00"
}

The record is appended, never written over, so a case worker sees the earlier answer beside the amendment. The same act also lands in the portal audit trail.

The event​

Portaliq dispatches OCA\Portaliq\Event\PortalClientWriteEvent (portal.write.client) once per act, carrying the case, the identity, the mandate, the act and the fields. Bind a listener by class name:

$context->registerEventListener(
\OCA\Portaliq\Event\PortalClientWriteEvent::class,
\OCA\YourApp\Listener\PortalWriteListener::class,
);

Naming the class in registerEventListener does not autoload it, so a case app keeps working with Portaliq absent (ADR-046). The event simply never fires.

A staff write never raises it. The internal write does not pass through the portal, and nothing else in Portaliq dispatches the event, so "de indiener heeft gereageerd" and "een collega heeft gereageerd" are different facts without anyone having to guess from the actor.

Refusals​

Each refusal carries one sentence the citizen can read, plus a slug for the portal to match on.

SlugStatusWhen
portal-writes-not-declared403no citizenWrite action for this schema, or the trust is too low
case-not-yours403the case is another citizen's, or does not exist
amendment-window-closed409the case type's amendment window has closed
documents-closed409the case type's document window has closed
field-not-writable422the write names a field that is not open
too-many-writes429past the per-identity or per-case throttle
task-already-answered409the task has an answer already

A case that is not the citizen's and a case number that does not exist answer identically, so the portal never confirms that someone else's case is real.

Limits​

The write surface is throttled per identity and per case on top of the framework's own per-address limit, because one household behind one address is not an attack and one identity across a mobile network's addresses is not innocent.

Documents go through the file surface the case app already declares. Portaliq adds no second upload path, and a citizen can add a document but never replace or remove one: a name that collides with a document already on the case is given a suffix.

The documents on the case​

The case screen lists three kinds of document, each under its own heading:

  • Decision ("Besluit"): what the case app marks kind: decision, newest first, with its date.
  • Documents ("Documenten"): the other documents the case app published.
  • Sent by you ("Door u gestuurd"): what the resident added through the portal. An upload is tagged portal:from-applicant when it is sent; a file in the case folder without that tag is never listed.

The case app decides what it publishes by declaring a method on its case collection:

{"id": "mijnZaken", "documents": {"label": "Stukken", "provider": "caseDocuments"}}

caseDocuments($caseId) returns entries {id, title, kind, date, file: {register, schema, id, fileId}, mimeType, size}. The file reference stays on the server: the screen gets the id, title, kind and date, and opens a document through GET /portal/api/citizen/cases/{register}/{schema}/{id}/documents/{documentId}, which asks the method again and streams only an entry it returned for this case. Every download is audited.

A case app that declares no method keeps what the case screen showed before: the files the organisation released on the case in OpenRegister, where the collection opted into filesDownload. A case with nothing to show says "There are no documents on this case yet."

Files a resident sent before this tagging existed carry no tag and are no longer listed to them; the handler still sees them on the case.