Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34211e848b | ||
|
|
1236b861b8 | ||
|
|
cd78800a65 | ||
|
|
29470d9a6e | ||
|
|
cc15233a44 | ||
|
|
2ea7afaead | ||
|
|
f6c13ccd4f | ||
|
|
87d12519b7 | ||
|
|
d83bb92ec8 | ||
|
|
eea3db3d2e | ||
|
|
e494cff561 | ||
|
|
cb824749a4 | ||
|
|
3c292b8c07 |
@@ -8,8 +8,23 @@
|
|||||||
including validation, permitted drafts, attachment/signature references, status
|
including validation, permitted drafts, attachment/signature references, status
|
||||||
history, receipts, and handoff evidence.
|
history, receipts, and handoff evidence.
|
||||||
|
|
||||||
|
Administrators can configure applicant status for each exact published Form
|
||||||
|
revision as account-authenticated, a short-lived link delivered to a matching
|
||||||
|
submitted email address, or a permanent high-entropy bearer link. Forms Runtime
|
||||||
|
owns the policy, grant, hash-only secrets, and bounded status projection;
|
||||||
|
Notifications owns email delivery, and Portal owns the applicant-facing page.
|
||||||
|
|
||||||
Its runtime module ID is `forms_runtime`; the repository and Python distribution retain the hyphenated `govoplan-forms-runtime` name.
|
Its runtime module ID is `forms_runtime`; the repository and Python distribution retain the hyphenated `govoplan-forms-runtime` name.
|
||||||
|
|
||||||
|
The module also contributes `privacy.dsar.forms_runtime`. Exact-tenant actor,
|
||||||
|
email-status, confirmation, acknowledgement, and explicit runtime selectors
|
||||||
|
produce bounded submission and lifecycle exports. Assisted operators receive
|
||||||
|
only attribution unless they are also the identified applicant. Credential-like
|
||||||
|
form keys, access hashes, replay keys, opaque details, validation internals, and
|
||||||
|
evidence identifiers are excluded. Immutable submission/handoff evidence is
|
||||||
|
retained; current drafts and active status grants require manual review through
|
||||||
|
their normal lifecycle, with no automatic DSAR mutation.
|
||||||
|
|
||||||
The module persists tenant-bound immutable revisions and events, exposes bounded
|
The module persists tenant-bound immutable revisions and events, exposes bounded
|
||||||
owner/manager APIs and WebUI routes, and provides both
|
owner/manager APIs and WebUI routes, and provides both
|
||||||
`forms_runtime.registry` and `forms_runtime.service_launcher`.
|
`forms_runtime.registry` and `forms_runtime.service_launcher`.
|
||||||
@@ -60,6 +75,7 @@ Optional integrations:
|
|||||||
- cases
|
- cases
|
||||||
- policy
|
- policy
|
||||||
- audit
|
- audit
|
||||||
|
- notifications
|
||||||
|
|
||||||
## Development Install
|
## Development Install
|
||||||
|
|
||||||
@@ -88,3 +104,21 @@ From the core checkout, labels can be synced once a local `GITEA_TOKEN` is avail
|
|||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan-core
|
||||||
/mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-forms-runtime --apply
|
/mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-forms-runtime --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Git-source WebUI package
|
||||||
|
|
||||||
|
The repository root exposes `@govoplan/forms-runtime-webui` for Git-tagged release
|
||||||
|
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||||
|
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||||
|
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||||
|
development or install scripts. The source archive contains `webui/src`, this
|
||||||
|
README and any repository license file. Run module development checks from `webui/`; Python
|
||||||
|
installation remains governed by `pyproject.toml`.
|
||||||
|
|
||||||
|
Das Repository stellt `@govoplan/forms-runtime-webui` am Wurzelpfad für versionierte
|
||||||
|
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||||
|
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||||
|
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||||
|
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||||
|
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||||
|
`pyproject.toml` definiert.
|
||||||
|
|||||||
@@ -51,8 +51,46 @@ Runtime form submissions for validation, drafts, attachments, signatures, status
|
|||||||
- native Case and Workflow handoffs that commit a durable effect intent before
|
- native Case and Workflow handoffs that commit a durable effect intent before
|
||||||
invoking the owner capability, use stable provider idempotency keys, and
|
invoking the owner capability, use stable provider idempotency keys, and
|
||||||
reconcile outcome-unknown execution
|
reconcile outcome-unknown execution
|
||||||
|
- invitation and explicitly enabled anonymous public-intake profiles with
|
||||||
|
hash-only bearer tokens, bounded profile-level rate limits, 14-day invitation
|
||||||
|
expiry, 30-day draft expiry, isolated synthetic actors, and no anonymous
|
||||||
|
identity-claim path
|
||||||
|
- an administrator-only public-intake dialog that selects exact published Form
|
||||||
|
revisions, configures expiry/rate limits, enables or disables profiles, and
|
||||||
|
exposes invitation secrets only once
|
||||||
|
- authenticated assisted-intake profiles and operator sessions that retain the
|
||||||
|
channel, affected and represented party references, authority basis, purpose,
|
||||||
|
notice, responsible function, language, accessibility needs, and field-level
|
||||||
|
source/confidence provenance on the same exact Form revision used digitally
|
||||||
|
- a responsive operator source editor that classifies each populated field
|
||||||
|
independently, including its confidence and optional governed declaring-party,
|
||||||
|
document, or source-system reference; it restores the current revision's
|
||||||
|
retained source map instead of flattening mixed provenance
|
||||||
|
- append-only assisted read-back outcomes bound to the exact current Form
|
||||||
|
revision, normalized values, attachments, and signatures; draft corrections
|
||||||
|
invalidate earlier confirmation evidence and submission fails closed until a
|
||||||
|
new confirmation or explicitly noted unavailable-confirmation record exists
|
||||||
|
- Files-owned one-time evidence upload grants bound to the tenant, exact Form
|
||||||
|
instance and definition revision, purpose, custodian, size, media types, and a
|
||||||
|
maximum 15-minute upload window
|
||||||
|
- final-submission evidence inspection that rejects wrong-submission, deleted,
|
||||||
|
quarantined, unverifiable, cross-tenant, or checksum-mismatched managed files
|
||||||
|
- authenticated acknowledgement evidence bound to the acting account,
|
||||||
|
statement/version, exact Form revision, submitted values, and attachments
|
||||||
|
- authenticated and public WebUI attachment capture through the shared drop
|
||||||
|
target; changing values or attachments invalidates the local acknowledgement
|
||||||
|
selection before submission
|
||||||
- migrations, uninstall guards, tenant summaries, events, recovery notes, and
|
- migrations, uninstall guards, tenant summaries, events, recovery notes, and
|
||||||
tenant/replay/stale-write/validation/handoff tests
|
tenant/replay/stale-write/validation/handoff tests
|
||||||
|
- an administrator-configured applicant-status policy on each exact published
|
||||||
|
Form revision: authenticated applicant access, short-lived links delivered
|
||||||
|
after a linked-email match, or a permanent bearer link
|
||||||
|
- per-submission, high-entropy tracking grants and a deliberately bounded
|
||||||
|
projection containing only title, lifecycle status, update time, receipt
|
||||||
|
identifier, and deduplicated public lifecycle events
|
||||||
|
- hash-only short-lived secrets, grant-bound email comparison, generic link
|
||||||
|
request responses, bounded hourly requests, resend revocation, and
|
||||||
|
Notifications-owned mail delivery
|
||||||
|
|
||||||
## Security And Policy
|
## Security And Policy
|
||||||
|
|
||||||
@@ -66,11 +104,67 @@ revision; a mismatched provider response fails closed.
|
|||||||
Policy-referenced definitions fail closed when no compatible
|
Policy-referenced definitions fail closed when no compatible
|
||||||
`forms_runtime.policy_evaluator` is active.
|
`forms_runtime.policy_evaluator` is active.
|
||||||
|
|
||||||
|
Assisted intake is not part of the default authenticated role. Starting a
|
||||||
|
session or recording read-back evidence requires the dedicated
|
||||||
|
`forms_runtime:submission:assist` permission or the manager write permission.
|
||||||
|
The assistant role combines that permission with ordinary participation so the
|
||||||
|
operator can resume only their own drafts; tenant-wide takeover still requires
|
||||||
|
manager authority.
|
||||||
|
|
||||||
Files and signature providers retain their own content and key custody. Runtime
|
Files and signature providers retain their own content and key custody. Runtime
|
||||||
stores only same-tenant evidence references. Cases and Workflow Engine retain
|
stores only same-tenant evidence references and bounded inspection snapshots.
|
||||||
|
Files stores only upload-token digests and never grants a public intake actor
|
||||||
|
general Files permissions. Cases and Workflow Engine retain
|
||||||
their own target state; Runtime stores only a permitted same-tenant handoff
|
their own target state; Runtime stores only a permitted same-tenant handoff
|
||||||
reference and status evidence.
|
reference and status evidence.
|
||||||
|
|
||||||
|
Administrators create public profiles from the Forms Runtime workspace. An
|
||||||
|
anonymous profile has one reusable public URL. An invitation profile creates a
|
||||||
|
new one-time bearer URL for each participant. The UI can copy a newly issued
|
||||||
|
secret but cannot retrieve it later. Disabling a profile prevents new starts;
|
||||||
|
already submitted revisions and their evidence remain governed records.
|
||||||
|
|
||||||
|
Applicant status is separately configured for one exact published Form
|
||||||
|
revision. The administrator selects one of three disclosure profiles:
|
||||||
|
|
||||||
|
- `authenticated` binds access to the submitting account. Assisted intake can
|
||||||
|
bind this profile only when its affected party is an explicit `account:`
|
||||||
|
reference; otherwise no status grant is issued.
|
||||||
|
- `email_link` binds a grant to the normalized value of one configured Form
|
||||||
|
field. A request supplies the tracking identifier and email address, always
|
||||||
|
receives the same response, and results in delivery only after a match. The
|
||||||
|
new short-lived link revokes its predecessor and Notifications owns the raw
|
||||||
|
delivery address, delivered URL, and attempt under its retention policy.
|
||||||
|
Forms Runtime stores only the secret digest.
|
||||||
|
- `permanent_link` makes the high-entropy tracking URL itself a non-expiring
|
||||||
|
bearer credential. Anyone possessing it can read the bounded projection.
|
||||||
|
|
||||||
|
Policy changes apply to later submissions; grants already issued retain their
|
||||||
|
mode and limits. Disabling the policy suspends all its grants immediately.
|
||||||
|
Submitted values, applicant identity, evidence, internal notes, and handoff
|
||||||
|
details never enter the public projection. Administrators must therefore choose
|
||||||
|
permanent links only where their possession-based disclosure and forwarding
|
||||||
|
risk is acceptable.
|
||||||
|
|
||||||
|
Administrators enable assisted profiles against the same published Form
|
||||||
|
revisions. An authenticated operator starts the session only after recording
|
||||||
|
the governed party and function references, authority and purpose, channel,
|
||||||
|
notice state, language, and required communication support. The resulting
|
||||||
|
draft remains resumable through the ordinary Forms workspace and uses the same
|
||||||
|
server validation, evidence inspection, receipt, and downstream handoff rules
|
||||||
|
as digital intake. Assisted mode is provenance, not a privileged validation or
|
||||||
|
authorization bypass.
|
||||||
|
|
||||||
|
Before submission, the operator must make the exact values and managed evidence
|
||||||
|
available through spoken read-back, a written preview, or an accessible copy.
|
||||||
|
The immutable outcome identifies the confirming party and operator. Corrections
|
||||||
|
are saved as a new Form revision and require a new read-back. When confirmation
|
||||||
|
cannot be obtained, the operator must choose that outcome and record the
|
||||||
|
exception; policy or downstream review may still stop or escalate the case.
|
||||||
|
Newly started assisted sessions may be saved as their first ordinary draft;
|
||||||
|
this is required before read-back whenever the operator entered or corrected
|
||||||
|
values, attachments, or acknowledgements.
|
||||||
|
|
||||||
## Approved Intake And Evidence Profiles
|
## Approved Intake And Evidence Profiles
|
||||||
|
|
||||||
The product and security profile approved on 2026-08-04 sets the next
|
The product and security profile approved on 2026-08-04 sets the next
|
||||||
@@ -100,6 +194,16 @@ not an advanced or qualified electronic signature. Those assurance levels
|
|||||||
require a separately selected external trust-service provider and current
|
require a separately selected external trust-service provider and current
|
||||||
provider evidence; a required signature never silently degrades.
|
provider evidence; a required signature never silently degrades.
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
When Search is enabled, `forms_runtime.submissions` indexes only the Form
|
||||||
|
definition identity and revision, lifecycle state, receipt identifier, Service
|
||||||
|
reference, and route back to the instance. Submitted field values and managed
|
||||||
|
evidence content are never copied into the search index. Workspace readers may
|
||||||
|
resolve tenant submissions; participants may resolve only instances owned by
|
||||||
|
their current actor identity. Every result receives a current authorization
|
||||||
|
recheck before disclosure.
|
||||||
|
|
||||||
## Recovery And Operations
|
## Recovery And Operations
|
||||||
|
|
||||||
Database recovery restores identities, revisions, and events together. After
|
Database recovery restores identities, revisions, and events together. After
|
||||||
@@ -113,8 +217,32 @@ Destructive retirement is blocked while state exists and requires a verified
|
|||||||
database snapshot plus an export or retention decision for referenced evidence.
|
database snapshot plus an export or retention decision for referenced evidence.
|
||||||
No local generated files are required, so API and worker nodes remain stateless.
|
No local generated files are required, so API and worker nodes remain stateless.
|
||||||
|
|
||||||
The approved public-intake and concrete file/signature provider profiles remain
|
## Data-subject requests
|
||||||
implementation depth. Conditional multi-page definitions are resolved from Forms, and
|
|
||||||
native Case/Workflow handoffs execute automatically when the exact owner
|
Forms Runtime publishes `privacy.dsar.forms_runtime`. It correlates exact-tenant
|
||||||
capability is installed. Additional target kinds remain adapter depth; the
|
canonical actors, normalized email addresses against grant-specific hashes,
|
||||||
owner and security boundaries no longer depend on those additions.
|
applicant confirmations and acknowledgements, and explicit instance or
|
||||||
|
lifecycle references. An assisted session prevents its operator from being
|
||||||
|
mistaken for the applicant when the session identifies a different actor; the
|
||||||
|
operator still receives a minimized accountability-attribution record.
|
||||||
|
|
||||||
|
Corroborated subject instances export bounded form values and typed definition,
|
||||||
|
status, intake, acknowledgement, status-access, token-lifecycle, and handoff
|
||||||
|
facts. Credential-like value keys are redacted. Raw token/email hashes,
|
||||||
|
idempotency and provider keys, request/payload hashes, opaque metadata/details,
|
||||||
|
validation internals, evidence identifiers, errors, and unrelated submissions
|
||||||
|
are excluded. Immutable revisions, events, handoffs, confirmations,
|
||||||
|
acknowledgements, intake evidence, and minimized token lifecycle are retained.
|
||||||
|
Current drafts and active status grants receive non-executable manual-review
|
||||||
|
actions and may only be changed through authorized runtime lifecycles. Forms
|
||||||
|
owns definitions, Portal owns presentation, and Cases/Workflow Engine own
|
||||||
|
handoff targets.
|
||||||
|
|
||||||
|
Public and assisted intake, Files-backed attachment evidence, and authenticated
|
||||||
|
acknowledgements implement the approved first profiles. Conditional multi-page
|
||||||
|
definitions are resolved from Forms, and native Case/Workflow handoffs execute
|
||||||
|
automatically when the exact owner capability is installed. CAPTCHA,
|
||||||
|
pseudonymous intake, advanced or qualified electronic signatures, richer
|
||||||
|
attachment classification/retention controls, dedicated party pickers, and
|
||||||
|
additional target kinds remain provider or product depth; none may weaken the
|
||||||
|
implemented owner and security boundaries.
|
||||||
|
|||||||
@@ -30,4 +30,7 @@ instance revisions, validation, receipts, and handoff evidence.
|
|||||||
Native controls preserve keyboard order, shared dialogs manage focus, changed
|
Native controls preserve keyboard order, shared dialogs manage focus, changed
|
||||||
values use the global unsaved-draft guard, and bounded list/detail regions keep
|
values use the global unsaved-draft guard, and bounded list/detail regions keep
|
||||||
their existing responsive scrolling. English and German catalogues cover
|
their existing responsive scrolling. English and German catalogues cover
|
||||||
module-owned copy; definition content uses its own published localization.
|
module-owned copy; definition content uses its own published localization. The
|
||||||
|
assisted read-back dialog uses one responsive fieldset per populated value so
|
||||||
|
mixed person, representative, document, system, and derived provenance remains
|
||||||
|
legible and independently editable at desktop and mobile widths.
|
||||||
|
|||||||
+29
-3
@@ -1,8 +1,34 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/forms-runtime",
|
"name": "@govoplan/forms-runtime-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Definition-aware form submissions and service launch for GovOPlaN.",
|
"description": "Definition-aware form submissions and service launch for GovOPlaN.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"peerDependencies": {}
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9"
|
||||||
|
},
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/forms-runtime.css": "./webui/src/styles/forms-runtime.css"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-forms-runtime"
|
name = "govoplan-forms-runtime"
|
||||||
version = "0.1.18"
|
version = "0.1.22"
|
||||||
description = "Definition-aware form submissions and service launch for GovOPlaN."
|
description = "Definition-aware form submissions and service launch for GovOPlaN."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import EvidenceReference
|
||||||
|
from govoplan_forms_runtime.backend.domain import FormInstance
|
||||||
|
|
||||||
|
|
||||||
|
ASSISTED_CHANNELS = frozenset(
|
||||||
|
{
|
||||||
|
"counter",
|
||||||
|
"telephone",
|
||||||
|
"paper",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"representative",
|
||||||
|
"offline_import",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ASSISTED_CONFIRMATION_METHODS = frozenset(
|
||||||
|
{"spoken_readback", "written_preview", "accessible_copy", "unavailable"}
|
||||||
|
)
|
||||||
|
ASSISTED_CONFIRMATION_OUTCOMES = frozenset(
|
||||||
|
{"confirmed", "corrected", "confirmation_unavailable"}
|
||||||
|
)
|
||||||
|
ASSISTED_SOURCE_KINDS = frozenset(
|
||||||
|
{"person_statement", "representative_statement", "document", "system", "derived"}
|
||||||
|
)
|
||||||
|
ASSISTED_CONFIDENCE_LEVELS = frozenset({"stated", "verified", "uncertain"})
|
||||||
|
|
||||||
|
|
||||||
|
def assisted_submission_payload_sha256(
|
||||||
|
instance: FormInstance,
|
||||||
|
*,
|
||||||
|
values: Mapping[str, object],
|
||||||
|
attachment_refs: Sequence[EvidenceReference],
|
||||||
|
signature_refs: Sequence[EvidenceReference],
|
||||||
|
) -> str:
|
||||||
|
return _hash(
|
||||||
|
{
|
||||||
|
"tenant_id": instance.tenant_id,
|
||||||
|
"instance_id": instance.instance_id,
|
||||||
|
"instance_revision": instance.revision,
|
||||||
|
"definition_ref": instance.definition_ref.to_dict(),
|
||||||
|
"values": dict(values),
|
||||||
|
"attachment_refs": [item.to_dict() for item in attachment_refs],
|
||||||
|
"signature_refs": [item.to_dict() for item in signature_refs],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def confirmation_payload(value: object) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"confirmation_id": str(getattr(value, "confirmation_id")),
|
||||||
|
"instance_id": str(getattr(value, "instance_id")),
|
||||||
|
"instance_revision": int(getattr(value, "instance_revision")),
|
||||||
|
"outcome": str(getattr(value, "outcome")),
|
||||||
|
"method": str(getattr(value, "method")),
|
||||||
|
"confirmed_by_ref": str(getattr(value, "confirmed_by_ref")),
|
||||||
|
"operator_actor_id": str(getattr(value, "operator_actor_id")),
|
||||||
|
"confirmed_at": getattr(value, "confirmed_at").isoformat(),
|
||||||
|
"payload_sha256": str(getattr(value, "payload_sha256")),
|
||||||
|
"correction_note": getattr(value, "correction_note"),
|
||||||
|
"metadata": dict(getattr(value, "details")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(value: Mapping[str, object]) -> str:
|
||||||
|
return hashlib.sha256(
|
||||||
|
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ASSISTED_CHANNELS",
|
||||||
|
"ASSISTED_CONFIDENCE_LEVELS",
|
||||||
|
"ASSISTED_CONFIRMATION_METHODS",
|
||||||
|
"ASSISTED_CONFIRMATION_OUTCOMES",
|
||||||
|
"ASSISTED_SOURCE_KINDS",
|
||||||
|
"assisted_submission_payload_sha256",
|
||||||
|
"confirmation_payload",
|
||||||
|
]
|
||||||
@@ -5,6 +5,7 @@ from typing import Any
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
@@ -189,9 +190,352 @@ class FormHandoffEffect(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormIntakeProfile(Base, TimestampMixin):
|
||||||
|
__tablename__ = "form_intake_profiles"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"profile_id",
|
||||||
|
name="uq_form_intake_profile",
|
||||||
|
),
|
||||||
|
UniqueConstraint("public_id", name="uq_form_intake_public_id"),
|
||||||
|
Index(
|
||||||
|
"ix_form_intake_definition",
|
||||||
|
"tenant_id",
|
||||||
|
"definition_id",
|
||||||
|
"definition_revision",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
profile_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
public_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
definition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
definition_revision: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, index=True
|
||||||
|
)
|
||||||
|
mode: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
custodian_ref: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
draft_ttl_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
invitation_ttl_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
rate_limit_per_minute: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
rate_window_started_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
rate_window_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
created_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
updated_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
details: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata", JSON, default=dict, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormIntakeSession(Base, TimestampMixin):
|
||||||
|
__tablename__ = "form_intake_sessions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"session_id",
|
||||||
|
name="uq_form_intake_session",
|
||||||
|
),
|
||||||
|
UniqueConstraint("token_sha256", name="uq_form_intake_token"),
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_form_intake_session_idempotency",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_form_intake_session_state",
|
||||||
|
"tenant_id",
|
||||||
|
"profile_id",
|
||||||
|
"status",
|
||||||
|
"expires_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
session_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
profile_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("form_intake_profiles.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
token_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
mode: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
instance_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True, index=True
|
||||||
|
)
|
||||||
|
actor_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
submitted_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
created_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
details: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata", JSON, default=dict, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormAssistedConfirmation(Base, TimestampMixin):
|
||||||
|
__tablename__ = "form_assisted_confirmations"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"confirmation_id",
|
||||||
|
name="uq_form_assisted_confirmation",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_form_assisted_confirmation_idempotency",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_form_assisted_confirmation_instance",
|
||||||
|
"tenant_id",
|
||||||
|
"instance_id",
|
||||||
|
"instance_revision",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
confirmation_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), nullable=False, index=True
|
||||||
|
)
|
||||||
|
intake_session_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("form_intake_sessions.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
instance_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
outcome: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
method: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
confirmed_by_ref: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
operator_actor_id: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, index=True
|
||||||
|
)
|
||||||
|
confirmed_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
payload_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
correction_note: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
details: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata", JSON, default=dict, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormStatusAccessPolicy(Base, TimestampMixin):
|
||||||
|
__tablename__ = "form_status_access_policies"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"policy_id",
|
||||||
|
name="uq_form_status_access_policy",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"definition_id",
|
||||||
|
"definition_revision",
|
||||||
|
name="uq_form_status_access_definition",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
policy_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
definition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
definition_revision: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, index=True
|
||||||
|
)
|
||||||
|
mode: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
email_field_key: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
token_ttl_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
request_limit_per_hour: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
created_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
updated_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
details: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata", JSON, default=dict, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormStatusAccessGrant(Base, TimestampMixin):
|
||||||
|
__tablename__ = "form_status_access_grants"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"grant_id",
|
||||||
|
name="uq_form_status_access_grant",
|
||||||
|
),
|
||||||
|
UniqueConstraint("tracking_id", name="uq_form_status_tracking_id"),
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"instance_id",
|
||||||
|
name="uq_form_status_access_instance",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_form_status_access_policy_state",
|
||||||
|
"tenant_id",
|
||||||
|
"policy_id",
|
||||||
|
"revoked_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
grant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
policy_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("form_status_access_policies.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
tracking_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
mode: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
applicant_actor_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True, index=True
|
||||||
|
)
|
||||||
|
recipient_email_sha256: Mapped[str | None] = mapped_column(
|
||||||
|
String(64), nullable=True
|
||||||
|
)
|
||||||
|
token_ttl_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
request_limit_per_hour: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
request_window_started_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
request_window_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, default=0, nullable=False
|
||||||
|
)
|
||||||
|
issued_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
last_accessed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, index=True
|
||||||
|
)
|
||||||
|
details: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata", JSON, default=dict, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormStatusAccessToken(Base, TimestampMixin):
|
||||||
|
__tablename__ = "form_status_access_tokens"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"token_id",
|
||||||
|
name="uq_form_status_access_token",
|
||||||
|
),
|
||||||
|
UniqueConstraint("token_sha256", name="uq_form_status_access_token_digest"),
|
||||||
|
Index(
|
||||||
|
"ix_form_status_access_token_state",
|
||||||
|
"tenant_id",
|
||||||
|
"grant_id",
|
||||||
|
"expires_at",
|
||||||
|
"revoked_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
token_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
grant_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("form_status_access_grants.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
token_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
issued_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, index=True
|
||||||
|
)
|
||||||
|
last_used_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
notification_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
details: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata", JSON, default=dict, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormAcknowledgement(Base, TimestampMixin):
|
||||||
|
__tablename__ = "form_acknowledgements"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"acknowledgement_id",
|
||||||
|
name="uq_form_acknowledgement",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_form_acknowledgement_idempotency",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_form_acknowledgement_instance",
|
||||||
|
"tenant_id",
|
||||||
|
"instance_id",
|
||||||
|
"instance_revision",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
acknowledgement_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), nullable=False, index=True
|
||||||
|
)
|
||||||
|
instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
instance_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
statement_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
statement_version: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
actor_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
accepted_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
payload_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
details: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata", JSON, default=dict, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"FormAcknowledgement",
|
||||||
|
"FormAssistedConfirmation",
|
||||||
"FormInstanceEvent",
|
"FormInstanceEvent",
|
||||||
"FormHandoffEffect",
|
"FormHandoffEffect",
|
||||||
|
"FormIntakeProfile",
|
||||||
|
"FormIntakeSession",
|
||||||
"FormInstanceIdentity",
|
"FormInstanceIdentity",
|
||||||
"FormInstanceRevision",
|
"FormInstanceRevision",
|
||||||
|
"FormStatusAccessGrant",
|
||||||
|
"FormStatusAccessPolicy",
|
||||||
|
"FormStatusAccessToken",
|
||||||
]
|
]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
"""Existing Forms Runtime email-selector policy, shared by status access and DSAR.
|
||||||
|
|
||||||
|
This is selector canonicalization, not a transformation of submitted form values
|
||||||
|
or a general-purpose email-address validator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_status_email(value: object) -> str | None:
|
||||||
|
candidate = str(value or "").strip().casefold()
|
||||||
|
if (
|
||||||
|
not candidate
|
||||||
|
or len(candidate) > 320
|
||||||
|
or candidate.count("@") != 1
|
||||||
|
or any(character.isspace() for character in candidate)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
local, domain = candidate.rsplit("@", 1)
|
||||||
|
if not local or "." not in domain or domain.startswith(".") or domain.endswith("."):
|
||||||
|
return None
|
||||||
|
return candidate
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
EventActorRef,
|
||||||
|
EventObjectRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
emit_platform_event,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.form_evidence import (
|
||||||
|
FormEvidenceGrant,
|
||||||
|
FormEvidenceGrantRequest,
|
||||||
|
FormEvidenceInspection,
|
||||||
|
FormEvidenceInspectionRequest,
|
||||||
|
form_evidence_provider,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
EvidenceReference,
|
||||||
|
FormDefinition,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.db.models import (
|
||||||
|
FormAcknowledgement,
|
||||||
|
FormInstanceEvent,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.domain import FormInstance
|
||||||
|
|
||||||
|
|
||||||
|
class FormEvidenceError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FormEvidenceCoordinator:
|
||||||
|
def __init__(self, registry: object | None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def create_upload_grant(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
instance: FormInstance,
|
||||||
|
definition: FormDefinition,
|
||||||
|
provider_id: str,
|
||||||
|
custodian_ref: str,
|
||||||
|
purpose: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
expires_at: datetime,
|
||||||
|
max_size_bytes: int | None = None,
|
||||||
|
allowed_content_types: Sequence[str] = (),
|
||||||
|
attachment_refs: Sequence[EvidenceReference] = (),
|
||||||
|
) -> FormEvidenceGrant:
|
||||||
|
if instance.status not in {"started", "draft"}:
|
||||||
|
raise FormEvidenceError(
|
||||||
|
"Evidence upload grants are available only while a Form is editable."
|
||||||
|
)
|
||||||
|
prospective_attachments = tuple(attachment_refs)
|
||||||
|
if any(
|
||||||
|
item.tenant_id != instance.tenant_id for item in prospective_attachments
|
||||||
|
):
|
||||||
|
raise FormEvidenceError("Form attachments cannot cross tenants.")
|
||||||
|
if len(prospective_attachments) >= definition.max_attachments:
|
||||||
|
raise FormEvidenceError("This Form does not permit another attachment.")
|
||||||
|
provider = form_evidence_provider(self._registry, provider_id)
|
||||||
|
if provider is None or "document" not in set(provider.supported_kinds()):
|
||||||
|
raise FormEvidenceError(
|
||||||
|
"The selected Form attachment provider is unavailable."
|
||||||
|
)
|
||||||
|
grant = provider.create_upload_grant(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request=FormEvidenceGrantRequest(
|
||||||
|
tenant_id=instance.tenant_id,
|
||||||
|
instance_id=instance.instance_id,
|
||||||
|
definition_ref=definition.reference,
|
||||||
|
evidence_kind="document",
|
||||||
|
purpose=purpose,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
expires_at=expires_at,
|
||||||
|
custodian_ref=custodian_ref,
|
||||||
|
max_size_bytes=max_size_bytes,
|
||||||
|
allowed_content_types=tuple(allowed_content_types),
|
||||||
|
metadata={
|
||||||
|
"remaining_attachments": definition.max_attachments
|
||||||
|
- len(prospective_attachments),
|
||||||
|
"existing_attachment_ids": tuple(
|
||||||
|
item.evidence_id for item in prospective_attachments
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if grant.provider_id != provider.provider_id:
|
||||||
|
raise FormEvidenceError(
|
||||||
|
"The Form evidence provider returned a mismatched grant."
|
||||||
|
)
|
||||||
|
return grant
|
||||||
|
|
||||||
|
def inspect(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
instance: FormInstance,
|
||||||
|
definition: FormDefinition,
|
||||||
|
values: Mapping[str, object],
|
||||||
|
attachment_refs: Sequence[EvidenceReference],
|
||||||
|
signature_refs: Sequence[EvidenceReference],
|
||||||
|
purpose: str,
|
||||||
|
final: bool,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> tuple[tuple[Mapping[str, object], ...], tuple[Mapping[str, object], ...]]:
|
||||||
|
diagnostics: list[Mapping[str, object]] = []
|
||||||
|
snapshots: list[Mapping[str, object]] = []
|
||||||
|
expected_acknowledgement_digest = acknowledgement_payload_sha256(
|
||||||
|
instance,
|
||||||
|
values=values,
|
||||||
|
attachment_refs=attachment_refs,
|
||||||
|
)
|
||||||
|
for reference in (*attachment_refs, *signature_refs):
|
||||||
|
if reference.owner_module == "forms_runtime":
|
||||||
|
inspection = self._inspect_acknowledgement(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance=instance,
|
||||||
|
reference=reference,
|
||||||
|
expected_payload_sha256=expected_acknowledgement_digest,
|
||||||
|
observed_at=observed_at,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
provider = form_evidence_provider(
|
||||||
|
self._registry,
|
||||||
|
reference.owner_module,
|
||||||
|
)
|
||||||
|
if provider is None:
|
||||||
|
inspection = FormEvidenceInspection(
|
||||||
|
provider_id=reference.owner_module,
|
||||||
|
reference=reference,
|
||||||
|
state="unavailable",
|
||||||
|
observed_at=observed_at,
|
||||||
|
retryable=True,
|
||||||
|
reason="The evidence owner is not installed or available.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
inspection = provider.inspect_evidence(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request=FormEvidenceInspectionRequest(
|
||||||
|
tenant_id=instance.tenant_id,
|
||||||
|
instance_id=instance.instance_id,
|
||||||
|
definition_ref=definition.reference,
|
||||||
|
evidence=reference,
|
||||||
|
purpose=purpose,
|
||||||
|
final=final,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
inspection.provider_id != provider.provider_id
|
||||||
|
or inspection.reference != reference
|
||||||
|
):
|
||||||
|
raise FormEvidenceError(
|
||||||
|
"The Form evidence provider returned a mismatched inspection."
|
||||||
|
)
|
||||||
|
snapshots.append(_inspection_payload(inspection))
|
||||||
|
if not inspection.accepted:
|
||||||
|
diagnostics.append(
|
||||||
|
{
|
||||||
|
"field": None,
|
||||||
|
"severity": "error" if final else "warning",
|
||||||
|
"code": f"evidence.{inspection.state}",
|
||||||
|
"message": inspection.reason
|
||||||
|
or "Attached evidence is not currently accepted.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if final:
|
||||||
|
rejected = [item for item in snapshots if item["state"] != "accepted"]
|
||||||
|
if rejected:
|
||||||
|
states = ", ".join(sorted({str(item["state"]) for item in rejected}))
|
||||||
|
raise FormEvidenceError(
|
||||||
|
f"Form evidence failed final verification: {states}."
|
||||||
|
)
|
||||||
|
return tuple(diagnostics), tuple(snapshots)
|
||||||
|
|
||||||
|
def create_acknowledgement(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
instance: FormInstance,
|
||||||
|
expected_revision: int,
|
||||||
|
statement_id: str,
|
||||||
|
statement_version: str,
|
||||||
|
values: Mapping[str, object],
|
||||||
|
attachment_refs: Sequence[EvidenceReference],
|
||||||
|
accepted_at: datetime,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> EvidenceReference:
|
||||||
|
if instance.revision != expected_revision:
|
||||||
|
raise FormEvidenceError(
|
||||||
|
"Form acknowledgement revision conflict: the expected revision is stale."
|
||||||
|
)
|
||||||
|
if instance.status not in {"started", "draft"}:
|
||||||
|
raise FormEvidenceError(
|
||||||
|
"An acknowledgement can be recorded only while the Form is editable."
|
||||||
|
)
|
||||||
|
actor_id = _principal_actor(principal)
|
||||||
|
if actor_id.startswith("form-public:"):
|
||||||
|
raise FormEvidenceError(
|
||||||
|
"The native acknowledgement profile requires an authenticated actor."
|
||||||
|
)
|
||||||
|
if accepted_at.tzinfo is None or accepted_at.utcoffset() is None:
|
||||||
|
raise FormEvidenceError(
|
||||||
|
"Form acknowledgement time must include a timezone."
|
||||||
|
)
|
||||||
|
clean_statement_id = _text(statement_id, "Acknowledgement statement", 255)
|
||||||
|
clean_statement_version = _text(
|
||||||
|
statement_version,
|
||||||
|
"Acknowledgement statement version",
|
||||||
|
255,
|
||||||
|
)
|
||||||
|
clean_key = _text(idempotency_key, "Acknowledgement idempotency key", 255)
|
||||||
|
payload_sha256 = acknowledgement_payload_sha256(
|
||||||
|
instance,
|
||||||
|
values=values,
|
||||||
|
attachment_refs=attachment_refs,
|
||||||
|
)
|
||||||
|
request = {
|
||||||
|
"instance_id": instance.instance_id,
|
||||||
|
"instance_revision": expected_revision,
|
||||||
|
"statement_id": clean_statement_id,
|
||||||
|
"statement_version": clean_statement_version,
|
||||||
|
"accepted_at": accepted_at.isoformat(),
|
||||||
|
"payload_sha256": payload_sha256,
|
||||||
|
}
|
||||||
|
request_sha256 = _hash(request)
|
||||||
|
existing = (
|
||||||
|
session.query(FormAcknowledgement)
|
||||||
|
.filter(
|
||||||
|
FormAcknowledgement.tenant_id == instance.tenant_id,
|
||||||
|
FormAcknowledgement.idempotency_key == clean_key,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if (
|
||||||
|
existing.request_sha256 != request_sha256
|
||||||
|
or existing.actor_id != actor_id
|
||||||
|
):
|
||||||
|
raise FormEvidenceError("Form acknowledgement idempotency conflict.")
|
||||||
|
return _acknowledgement_reference(existing)
|
||||||
|
acknowledgement = FormAcknowledgement(
|
||||||
|
tenant_id=instance.tenant_id,
|
||||||
|
acknowledgement_id=str(uuid.uuid4()),
|
||||||
|
instance_id=instance.instance_id,
|
||||||
|
instance_revision=instance.revision,
|
||||||
|
statement_id=clean_statement_id,
|
||||||
|
statement_version=clean_statement_version,
|
||||||
|
actor_id=actor_id,
|
||||||
|
accepted_at=accepted_at,
|
||||||
|
payload_sha256=payload_sha256,
|
||||||
|
idempotency_key=clean_key,
|
||||||
|
request_sha256=request_sha256,
|
||||||
|
details={"profile": "authenticated_acknowledgement_v1"},
|
||||||
|
)
|
||||||
|
event_id = str(uuid.uuid4())
|
||||||
|
event = FormInstanceEvent(
|
||||||
|
tenant_id=instance.tenant_id,
|
||||||
|
instance_id=instance.instance_id,
|
||||||
|
instance_revision=instance.revision,
|
||||||
|
event_id=event_id,
|
||||||
|
event_type="forms_runtime.instance.acknowledged",
|
||||||
|
status=instance.status,
|
||||||
|
occurred_at=accepted_at,
|
||||||
|
actor_id=actor_id,
|
||||||
|
idempotency_key=f"ack:{clean_key}",
|
||||||
|
request_sha256=request_sha256,
|
||||||
|
payload={
|
||||||
|
"acknowledgement_id": acknowledgement.acknowledgement_id,
|
||||||
|
"statement_id": clean_statement_id,
|
||||||
|
"statement_version": clean_statement_version,
|
||||||
|
"payload_sha256": payload_sha256,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.add_all((acknowledgement, event))
|
||||||
|
session.flush()
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
event_id=event_id,
|
||||||
|
type=event.event_type,
|
||||||
|
module_id="forms_runtime",
|
||||||
|
payload=dict(event.payload),
|
||||||
|
occurred_at=accepted_at,
|
||||||
|
actor=EventActorRef(type="account", id=actor_id),
|
||||||
|
tenant=EventTenantRef(id=instance.tenant_id),
|
||||||
|
resource=EventObjectRef(
|
||||||
|
type="form_submission",
|
||||||
|
id=instance.instance_id,
|
||||||
|
label=instance.definition_ref.label,
|
||||||
|
),
|
||||||
|
classification="confidential",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return _acknowledgement_reference(acknowledgement)
|
||||||
|
|
||||||
|
def _inspect_acknowledgement(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
instance: FormInstance,
|
||||||
|
reference: EvidenceReference,
|
||||||
|
expected_payload_sha256: str,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> FormEvidenceInspection:
|
||||||
|
acknowledgement = (
|
||||||
|
session.query(FormAcknowledgement)
|
||||||
|
.filter(
|
||||||
|
FormAcknowledgement.tenant_id == instance.tenant_id,
|
||||||
|
FormAcknowledgement.acknowledgement_id == reference.evidence_id,
|
||||||
|
FormAcknowledgement.instance_id == instance.instance_id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
accepted = bool(
|
||||||
|
acknowledgement is not None
|
||||||
|
and reference.kind == "signature"
|
||||||
|
and reference.version == "1"
|
||||||
|
and reference.checksum == acknowledgement.payload_sha256
|
||||||
|
and acknowledgement.payload_sha256 == expected_payload_sha256
|
||||||
|
and acknowledgement.actor_id == _principal_actor(principal)
|
||||||
|
)
|
||||||
|
return FormEvidenceInspection(
|
||||||
|
provider_id="forms_runtime",
|
||||||
|
reference=reference,
|
||||||
|
state="accepted" if accepted else "rejected",
|
||||||
|
observed_at=observed_at,
|
||||||
|
retryable=False,
|
||||||
|
reason=(
|
||||||
|
None
|
||||||
|
if accepted
|
||||||
|
else "The acknowledgement does not match this actor and exact Form payload."
|
||||||
|
),
|
||||||
|
metadata={"profile": "authenticated_acknowledgement_v1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def acknowledgement_payload_sha256(
|
||||||
|
instance: FormInstance,
|
||||||
|
*,
|
||||||
|
values: Mapping[str, object],
|
||||||
|
attachment_refs: Sequence[EvidenceReference],
|
||||||
|
) -> str:
|
||||||
|
return _hash(
|
||||||
|
{
|
||||||
|
"tenant_id": instance.tenant_id,
|
||||||
|
"instance_id": instance.instance_id,
|
||||||
|
"definition_ref": instance.definition_ref.to_dict(),
|
||||||
|
"values": dict(values),
|
||||||
|
"attachment_refs": [item.to_dict() for item in attachment_refs],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _acknowledgement_reference(
|
||||||
|
acknowledgement: FormAcknowledgement,
|
||||||
|
) -> EvidenceReference:
|
||||||
|
return EvidenceReference(
|
||||||
|
kind="signature",
|
||||||
|
owner_module="forms_runtime",
|
||||||
|
evidence_id=acknowledgement.acknowledgement_id,
|
||||||
|
tenant_id=acknowledgement.tenant_id,
|
||||||
|
version="1",
|
||||||
|
checksum=acknowledgement.payload_sha256,
|
||||||
|
source_ref=(
|
||||||
|
f"form_submission:{acknowledgement.instance_id}:"
|
||||||
|
f"{acknowledgement.instance_revision}"
|
||||||
|
),
|
||||||
|
responsible_actor_ref=acknowledgement.actor_id,
|
||||||
|
captured_at=acknowledgement.accepted_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _inspection_payload(inspection: FormEvidenceInspection) -> Mapping[str, object]:
|
||||||
|
return {
|
||||||
|
"provider_id": inspection.provider_id,
|
||||||
|
"evidence_id": inspection.reference.evidence_id,
|
||||||
|
"owner_module": inspection.reference.owner_module,
|
||||||
|
"version": inspection.reference.version,
|
||||||
|
"state": inspection.state,
|
||||||
|
"observed_at": inspection.observed_at.isoformat(),
|
||||||
|
"retryable": inspection.retryable,
|
||||||
|
"reason": inspection.reason,
|
||||||
|
"metadata": dict(inspection.metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_actor(principal: object) -> str:
|
||||||
|
for name in ("account_id", "identity_id", "membership_id"):
|
||||||
|
value = str(getattr(principal, name, "") or "").strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
raise FormEvidenceError("Form evidence requires an acting identity.")
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: str, label: str, maximum: int) -> str:
|
||||||
|
clean = str(value or "").strip()
|
||||||
|
if not clean or len(clean) > maximum:
|
||||||
|
raise FormEvidenceError(
|
||||||
|
f"{label} is required and limited to {maximum} characters."
|
||||||
|
)
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(value: Mapping[str, object]) -> str:
|
||||||
|
return hashlib.sha256(
|
||||||
|
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FormEvidenceCoordinator",
|
||||||
|
"FormEvidenceError",
|
||||||
|
"acknowledgement_payload_sha256",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_core.core.application_status import (
|
||||||
|
CAPABILITY_APPLICATION_STATUS_PROJECTION,
|
||||||
|
)
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
@@ -14,8 +18,10 @@ from govoplan_core.core.module_guards import (
|
|||||||
drop_table_retirement_provider,
|
drop_table_retirement_provider,
|
||||||
persistent_table_uninstall_guard,
|
persistent_table_uninstall_guard,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
CapabilityDocumentation,
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
@@ -27,12 +33,26 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
|
PublicFrontendRoute,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_forms_runtime.backend.db import models as runtime_models
|
from govoplan_forms_runtime.backend.db import models as runtime_models
|
||||||
|
from govoplan_forms_runtime.backend.dsar_provider import (
|
||||||
|
FORMS_RUNTIME_DSAR_CAPABILITY,
|
||||||
|
FormsRuntimeDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.record_source import (
|
||||||
|
CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME,
|
||||||
|
create_forms_runtime_record_source,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.search_source import (
|
||||||
|
create_forms_runtime_search_source,
|
||||||
|
)
|
||||||
from govoplan_forms_runtime.backend.service import (
|
from govoplan_forms_runtime.backend.service import (
|
||||||
CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR,
|
CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR,
|
||||||
CAPABILITY_FORMS_RUNTIME_REGISTRY,
|
CAPABILITY_FORMS_RUNTIME_REGISTRY,
|
||||||
@@ -40,12 +60,14 @@ from govoplan_forms_runtime.backend.service import (
|
|||||||
FormRuntimeService,
|
FormRuntimeService,
|
||||||
FormsServiceLauncher,
|
FormsServiceLauncher,
|
||||||
)
|
)
|
||||||
|
from govoplan_forms_runtime.backend.status_access import FormStatusAccessService
|
||||||
|
|
||||||
|
|
||||||
MODULE_ID = "forms_runtime"
|
MODULE_ID = "forms_runtime"
|
||||||
MODULE_NAME = "Forms Runtime"
|
MODULE_NAME = "Forms Runtime"
|
||||||
MODULE_VERSION = "0.1.18"
|
MODULE_VERSION = "0.1.22"
|
||||||
PARTICIPATE_SCOPE = "forms_runtime:submission:participate"
|
PARTICIPATE_SCOPE = "forms_runtime:submission:participate"
|
||||||
|
ASSIST_SCOPE = "forms_runtime:submission:assist"
|
||||||
READ_SCOPE = "forms_runtime:workspace:read"
|
READ_SCOPE = "forms_runtime:workspace:read"
|
||||||
WRITE_SCOPE = "forms_runtime:workspace:write"
|
WRITE_SCOPE = "forms_runtime:workspace:write"
|
||||||
ADMIN_SCOPE = "forms_runtime:workspace:admin"
|
ADMIN_SCOPE = "forms_runtime:workspace:admin"
|
||||||
@@ -57,6 +79,8 @@ OPTIONAL_DEPENDENCIES = (
|
|||||||
"cases",
|
"cases",
|
||||||
"policy",
|
"policy",
|
||||||
"audit",
|
"audit",
|
||||||
|
"records",
|
||||||
|
"notifications",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -80,6 +104,11 @@ PERMISSIONS = (
|
|||||||
"Complete assigned forms",
|
"Complete assigned forms",
|
||||||
"Start, read, save, and submit the acting account's own Form instances.",
|
"Start, read, save, and submit the acting account's own Form instances.",
|
||||||
),
|
),
|
||||||
|
_permission(
|
||||||
|
ASSIST_SCOPE,
|
||||||
|
"Conduct assisted Form intake",
|
||||||
|
"Start authenticated assisted sessions and record party read-back or correction evidence.",
|
||||||
|
),
|
||||||
_permission(
|
_permission(
|
||||||
READ_SCOPE,
|
READ_SCOPE,
|
||||||
"View form submissions",
|
"View form submissions",
|
||||||
@@ -93,7 +122,7 @@ PERMISSIONS = (
|
|||||||
_permission(
|
_permission(
|
||||||
ADMIN_SCOPE,
|
ADMIN_SCOPE,
|
||||||
"Administer Forms Runtime",
|
"Administer Forms Runtime",
|
||||||
"Administer Forms Runtime policy, recovery, and retirement.",
|
"Administer public intake profiles, Forms Runtime policy, recovery, and retirement.",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,11 +134,17 @@ ROLE_TEMPLATES = (
|
|||||||
permissions=(PARTICIPATE_SCOPE,),
|
permissions=(PARTICIPATE_SCOPE,),
|
||||||
default_authenticated=True,
|
default_authenticated=True,
|
||||||
),
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="forms_runtime_assistant",
|
||||||
|
name="Forms intake assistant",
|
||||||
|
description="Conduct purpose-bound assisted intake for another party.",
|
||||||
|
permissions=(PARTICIPATE_SCOPE, ASSIST_SCOPE),
|
||||||
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="forms_runtime_manager",
|
slug="forms_runtime_manager",
|
||||||
name="Forms Runtime manager",
|
name="Forms Runtime manager",
|
||||||
description="Review, transition, and hand off Form submissions.",
|
description="Review, transition, and hand off Form submissions.",
|
||||||
permissions=(PARTICIPATE_SCOPE, READ_SCOPE, WRITE_SCOPE),
|
permissions=(PARTICIPATE_SCOPE, ASSIST_SCOPE, READ_SCOPE, WRITE_SCOPE),
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="forms_runtime_viewer",
|
slug="forms_runtime_viewer",
|
||||||
@@ -126,6 +161,52 @@ def _router(context: ModuleContext):
|
|||||||
return create_router(context.registry)
|
return create_router(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _status_projection(context: ModuleContext) -> FormStatusAccessService:
|
||||||
|
return FormStatusAccessService(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _public_tenant_resolver(request: object, session: object) -> str | None:
|
||||||
|
if not hasattr(session, "query"):
|
||||||
|
return None
|
||||||
|
path = str(getattr(getattr(request, "url", None), "path", ""))
|
||||||
|
path_params = getattr(request, "path_params", {})
|
||||||
|
if "/forms-runtime/public/status/" in path:
|
||||||
|
tracking_id = str(path_params.get("tracking_id") or "").strip()
|
||||||
|
if not tracking_id:
|
||||||
|
return None
|
||||||
|
grant = (
|
||||||
|
session.query(runtime_models.FormStatusAccessGrant)
|
||||||
|
.filter(runtime_models.FormStatusAccessGrant.tracking_id == tracking_id)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
return grant.tenant_id if grant is not None else None
|
||||||
|
if "/forms-runtime/public/profiles/" in path:
|
||||||
|
public_id = str(path_params.get("public_id") or "").strip()
|
||||||
|
if not public_id:
|
||||||
|
return None
|
||||||
|
profile = (
|
||||||
|
session.query(runtime_models.FormIntakeProfile)
|
||||||
|
.filter(runtime_models.FormIntakeProfile.public_id == public_id)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
return profile.tenant_id if profile is not None else None
|
||||||
|
if "/forms-runtime/public/intake" not in path:
|
||||||
|
return None
|
||||||
|
headers = getattr(request, "headers", {})
|
||||||
|
token = str(headers.get("X-Form-Intake-Token") or "").strip()
|
||||||
|
if len(token) < 32:
|
||||||
|
return None
|
||||||
|
intake_session = (
|
||||||
|
session.query(runtime_models.FormIntakeSession)
|
||||||
|
.filter(
|
||||||
|
runtime_models.FormIntakeSession.token_sha256
|
||||||
|
== hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
return intake_session.tenant_id if intake_session is not None else None
|
||||||
|
|
||||||
|
|
||||||
def _registry(context: ModuleContext) -> FormRuntimeService:
|
def _registry(context: ModuleContext) -> FormRuntimeService:
|
||||||
return FormRuntimeService(context.registry)
|
return FormRuntimeService(context.registry)
|
||||||
|
|
||||||
@@ -134,6 +215,11 @@ def _service_launcher(context: ModuleContext) -> FormsServiceLauncher:
|
|||||||
return FormsServiceLauncher(context.registry)
|
return FormsServiceLauncher(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(context: ModuleContext) -> FormsRuntimeDsarProvider:
|
||||||
|
del context
|
||||||
|
return FormsRuntimeDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
current = session.query(runtime_models.FormInstanceRevision).filter(
|
current = session.query(runtime_models.FormInstanceRevision).filter(
|
||||||
runtime_models.FormInstanceRevision.tenant_id == tenant_id,
|
runtime_models.FormInstanceRevision.tenant_id == tenant_id,
|
||||||
@@ -146,6 +232,48 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
|||||||
("started", "draft", "submitted", "validated", "needs_review")
|
("started", "draft", "submitted", "validated", "needs_review")
|
||||||
)
|
)
|
||||||
).count(),
|
).count(),
|
||||||
|
"public_intake_profiles": session.query(runtime_models.FormIntakeProfile)
|
||||||
|
.filter(
|
||||||
|
runtime_models.FormIntakeProfile.tenant_id == tenant_id,
|
||||||
|
runtime_models.FormIntakeProfile.mode.in_(("anonymous", "invitation")),
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
"public_intake_sessions": session.query(runtime_models.FormIntakeSession)
|
||||||
|
.filter(
|
||||||
|
runtime_models.FormIntakeSession.tenant_id == tenant_id,
|
||||||
|
runtime_models.FormIntakeSession.mode.in_(("anonymous", "invitation")),
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
"assisted_intake_profiles": session.query(runtime_models.FormIntakeProfile)
|
||||||
|
.filter(
|
||||||
|
runtime_models.FormIntakeProfile.tenant_id == tenant_id,
|
||||||
|
runtime_models.FormIntakeProfile.mode == "assisted",
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
"assisted_intake_sessions": session.query(runtime_models.FormIntakeSession)
|
||||||
|
.filter(
|
||||||
|
runtime_models.FormIntakeSession.tenant_id == tenant_id,
|
||||||
|
runtime_models.FormIntakeSession.mode == "assisted",
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
"assisted_confirmations": session.query(
|
||||||
|
runtime_models.FormAssistedConfirmation
|
||||||
|
)
|
||||||
|
.filter(runtime_models.FormAssistedConfirmation.tenant_id == tenant_id)
|
||||||
|
.count(),
|
||||||
|
"status_access_policies": session.query(
|
||||||
|
runtime_models.FormStatusAccessPolicy
|
||||||
|
)
|
||||||
|
.filter(runtime_models.FormStatusAccessPolicy.tenant_id == tenant_id)
|
||||||
|
.count(),
|
||||||
|
"status_access_grants": session.query(runtime_models.FormStatusAccessGrant)
|
||||||
|
.filter(runtime_models.FormStatusAccessGrant.tenant_id == tenant_id)
|
||||||
|
.count(),
|
||||||
|
"authenticated_acknowledgements": session.query(
|
||||||
|
runtime_models.FormAcknowledgement
|
||||||
|
)
|
||||||
|
.filter(runtime_models.FormAcknowledgement.tenant_id == tenant_id)
|
||||||
|
.count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -163,12 +291,14 @@ manifest = ModuleManifest(
|
|||||||
optional_capabilities=(
|
optional_capabilities=(
|
||||||
CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR,
|
CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR,
|
||||||
CAPABILITY_SERVICE_DEFINITIONS,
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
|
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||||
"cases.service_launcher",
|
"cases.service_launcher",
|
||||||
"workflow_engine.service_launcher",
|
"workflow_engine.service_launcher",
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
route_factory=_router,
|
route_factory=_router,
|
||||||
|
public_tenant_resolver=_public_tenant_resolver,
|
||||||
nav_items=(
|
nav_items=(
|
||||||
NavItem(
|
NavItem(
|
||||||
path="/forms-runtime",
|
path="/forms-runtime",
|
||||||
@@ -195,6 +325,18 @@ manifest = ModuleManifest(
|
|||||||
order=38,
|
order=38,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
public_routes=(
|
||||||
|
PublicFrontendRoute(
|
||||||
|
path="/forms/public/:publicId",
|
||||||
|
component="PublicFormPage",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
PublicFrontendRoute(
|
||||||
|
path="/forms/intake/:token",
|
||||||
|
component="PublicFormPage",
|
||||||
|
order=11,
|
||||||
|
),
|
||||||
|
),
|
||||||
nav_items=(
|
nav_items=(
|
||||||
NavItem(
|
NavItem(
|
||||||
path="/forms-runtime",
|
path="/forms-runtime",
|
||||||
@@ -204,6 +346,21 @@ manifest = ModuleManifest(
|
|||||||
order=37,
|
order=37,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="services-cases",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="i18n:govoplan-core.product_area.services_cases",
|
||||||
|
icon="landmark",
|
||||||
|
description="i18n:govoplan-core.product_area.services_cases_description",
|
||||||
|
surface_ids=(
|
||||||
|
"forms_runtime.nav.forms.runtime",
|
||||||
|
"forms_runtime.route.forms.runtime",
|
||||||
|
"forms_runtime.route.forms.runtime.instanceid",
|
||||||
|
),
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
view_surfaces=(
|
view_surfaces=(
|
||||||
ViewSurface(
|
ViewSurface(
|
||||||
id="forms_runtime.navigation",
|
id="forms_runtime.navigation",
|
||||||
@@ -231,6 +388,25 @@ manifest = ModuleManifest(
|
|||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="forms_runtime.registry", version="0.1.0"),
|
ModuleInterfaceProvider(name="forms_runtime.registry", version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name="forms_runtime.service_launcher", version="0.1.0"),
|
ModuleInterfaceProvider(name="forms_runtime.service_launcher", version="0.1.0"),
|
||||||
|
ModuleInterfaceProvider(name="forms_runtime.public_intake", version="1.0.0"),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="forms_runtime.assisted_intake", version="1.0.0"
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_APPLICATION_STATUS_PROJECTION, version="1.0.0"
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="forms_runtime.authenticated_acknowledgement",
|
||||||
|
version="1.0.0",
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME,
|
||||||
|
version="1.0.0",
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=FORMS_RUNTIME_DSAR_CAPABILITY,
|
||||||
|
version="0.1.0",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -256,10 +432,19 @@ manifest = ModuleManifest(
|
|||||||
version_max_exclusive="0.2.0",
|
version_max_exclusive="0.2.0",
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name=CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_FORMS_RUNTIME_REGISTRY: _registry,
|
CAPABILITY_FORMS_RUNTIME_REGISTRY: _registry,
|
||||||
CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER: _service_launcher,
|
CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER: _service_launcher,
|
||||||
|
CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME: create_forms_runtime_record_source,
|
||||||
|
CAPABILITY_APPLICATION_STATUS_PROJECTION: _status_projection,
|
||||||
|
FORMS_RUNTIME_DSAR_CAPABILITY: _dsar_provider,
|
||||||
},
|
},
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
CAPABILITY_FORMS_RUNTIME_REGISTRY: CapabilityDocumentation(
|
CAPABILITY_FORMS_RUNTIME_REGISTRY: CapabilityDocumentation(
|
||||||
@@ -272,6 +457,21 @@ manifest = ModuleManifest(
|
|||||||
summary="Starts a replay-safe Form instance from an exact published Service and Form revision.",
|
summary="Starts a replay-safe Form instance from an exact published Service and Form revision.",
|
||||||
contract_version="0.1.0",
|
contract_version="0.1.0",
|
||||||
),
|
),
|
||||||
|
CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME: CapabilityDocumentation(
|
||||||
|
label="Form submission record source",
|
||||||
|
summary="Resolves currently authorized immutable submission revisions for Records filing.",
|
||||||
|
contract_version="1.0.0",
|
||||||
|
),
|
||||||
|
CAPABILITY_APPLICATION_STATUS_PROJECTION: CapabilityDocumentation(
|
||||||
|
label="Applicant status projection",
|
||||||
|
summary="Resolves tenant-bound, deliberately limited application status and configured access challenges.",
|
||||||
|
contract_version="1.0.0",
|
||||||
|
),
|
||||||
|
FORMS_RUNTIME_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Forms Runtime data-subject request provider",
|
||||||
|
summary="Finds minimized tenant-scoped submission, intake, status-access, and handoff facts without exposing credentials.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
@@ -280,6 +480,13 @@ manifest = ModuleManifest(
|
|||||||
migration_after=("forms",),
|
migration_after=("forms",),
|
||||||
retirement_supported=True,
|
retirement_supported=True,
|
||||||
retirement_provider=drop_table_retirement_provider(
|
retirement_provider=drop_table_retirement_provider(
|
||||||
|
runtime_models.FormAcknowledgement,
|
||||||
|
runtime_models.FormAssistedConfirmation,
|
||||||
|
runtime_models.FormStatusAccessToken,
|
||||||
|
runtime_models.FormStatusAccessGrant,
|
||||||
|
runtime_models.FormStatusAccessPolicy,
|
||||||
|
runtime_models.FormIntakeSession,
|
||||||
|
runtime_models.FormIntakeProfile,
|
||||||
runtime_models.FormHandoffEffect,
|
runtime_models.FormHandoffEffect,
|
||||||
runtime_models.FormInstanceEvent,
|
runtime_models.FormInstanceEvent,
|
||||||
runtime_models.FormInstanceRevision,
|
runtime_models.FormInstanceRevision,
|
||||||
@@ -290,6 +497,13 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
uninstall_guard_providers=(
|
uninstall_guard_providers=(
|
||||||
persistent_table_uninstall_guard(
|
persistent_table_uninstall_guard(
|
||||||
|
runtime_models.FormAcknowledgement,
|
||||||
|
runtime_models.FormAssistedConfirmation,
|
||||||
|
runtime_models.FormStatusAccessToken,
|
||||||
|
runtime_models.FormStatusAccessGrant,
|
||||||
|
runtime_models.FormStatusAccessPolicy,
|
||||||
|
runtime_models.FormIntakeSession,
|
||||||
|
runtime_models.FormIntakeProfile,
|
||||||
runtime_models.FormHandoffEffect,
|
runtime_models.FormHandoffEffect,
|
||||||
runtime_models.FormInstanceIdentity,
|
runtime_models.FormInstanceIdentity,
|
||||||
runtime_models.FormInstanceRevision,
|
runtime_models.FormInstanceRevision,
|
||||||
@@ -297,19 +511,82 @@ manifest = ModuleManifest(
|
|||||||
label=MODULE_NAME,
|
label=MODULE_NAME,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
search_sources=(
|
||||||
|
SearchSourceProviderRegistration(
|
||||||
|
id="forms_runtime.submissions",
|
||||||
|
factory=create_forms_runtime_search_source,
|
||||||
|
),
|
||||||
|
),
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="forms_runtime.data-subject-requests",
|
||||||
|
title="Form-submission data-subject requests",
|
||||||
|
summary="Export subject-owned form values and governed lifecycle evidence without exposing access credentials or unrelated assisted-intake data.",
|
||||||
|
body=(
|
||||||
|
"The Forms Runtime DSAR provider matches exact-tenant canonical actors, normalized email status grants, applicant confirmations and acknowledgements, and explicit runtime references. Assisted operators are recorded as minimized attribution but are not treated as the applicant when the assisted session identifies another actor. Corroborated subject instances export bounded form values and typed definition, status, intake, acknowledgement, status-access, and handoff facts. Credential-like form keys are redacted; token and email hashes, idempotency keys, request and payload hashes, opaque metadata/details, validation internals, evidence identifiers, provider replay keys, errors, and unrelated submissions are excluded. "
|
||||||
|
"Immutable revisions, events, handoffs, confirmations, acknowledgements, intake evidence, and minimized token lifecycle receive retention actions. Current drafts and active status grants require authorized manual review; Forms Runtime publishes no automatic DSAR mutation. Forms owns definitions, Portal presents public/status routes, and Cases or Workflow Engine own downstream handoff targets."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=("forms", "portal", "cases", "workflow_engine"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Forms Runtime security and recovery",
|
||||||
|
href="govoplan-forms-runtime/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={"kind": "reference"},
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Datenschutzanfragen zu Formulareinreichungen",
|
||||||
|
"summary": (
|
||||||
|
"Personenbezogene Formularwerte und gesteuerte Lebenszyklusnachweise ausgeben, ohne Zugangsdaten oder fremde Daten einer assistierten Erfassung offenzulegen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Der DSAR-Anbieter von Forms Runtime gleicht kanonische Akteure im exakten Mandanten, normalisierte "
|
||||||
|
"E-Mail-Statusfreigaben, Bestätigungen und Kenntnisnahmen von Antragstellenden sowie ausdrückliche "
|
||||||
|
"Runtime-Verweise ab. Assistierende Bearbeitende werden als minimierte Zuordnung erfasst, gelten aber "
|
||||||
|
"nicht als Antragsteller, wenn die assistierte Sitzung eine andere Person bezeichnet. Bestätigte "
|
||||||
|
"Instanzen geben begrenzte Formularwerte und typisierte Fakten zu Definition, Status, Erfassung, "
|
||||||
|
"Kenntnisnahme, Statuszugriff und Übergabe aus. Zugangsdatenähnliche Formularschlüssel werden geschwärzt; "
|
||||||
|
"Token- und E-Mail-Prüfsummen, Idempotenzschlüssel, Anfrage- und Nutzdatenprüfsummen, undurchsichtige "
|
||||||
|
"Metadaten und Details, Validierungsinterna, Nachweiskennungen, Anbieterwiederholungsschlüssel, Fehler und "
|
||||||
|
"fremde Einreichungen bleiben ausgeschlossen. Unveränderliche Revisionen, Ereignisse, Übergaben, "
|
||||||
|
"Bestätigungen, Kenntnisnahmen, Erfassungsnachweise und minimierte Token-Lebenszyklen erhalten "
|
||||||
|
"Aufbewahrungsaktionen. Aktuelle Entwürfe und aktive Statusfreigaben erfordern eine autorisierte manuelle "
|
||||||
|
"Prüfung; Forms Runtime veröffentlicht keine automatische DSAR-Änderung. Forms führt Definitionen, Portal "
|
||||||
|
"zeigt öffentliche und Statusrouten, und Cases beziehungsweise Workflow Engine führen nachgelagerte Ziele."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="forms_runtime.submissions",
|
id="forms_runtime.submissions",
|
||||||
title="Complete and manage Forms",
|
title="Complete and manage Forms",
|
||||||
summary="Save permitted drafts, submit validated values, and retain exact definition and handoff evidence.",
|
summary="Save permitted drafts, submit validated values, and retain exact definition and handoff evidence.",
|
||||||
body=(
|
body=(
|
||||||
|
"Documentation books sit beside Forms runtime or Form instance. Editing guidance is beside "
|
||||||
|
"the displayed form title while the instance is editable; field help stays with its label. "
|
||||||
"Every instance resolves one immutable published Form revision. Draft and final values are validated on the server; final submission also enforces attachment, signature, and policy requirements. "
|
"Every instance resolves one immutable published Form revision. Draft and final values are validated on the server; final submission also enforces attachment, signature, and policy requirements. "
|
||||||
"Service launches retain the exact Service and binding. Native Case and Workflow handoffs persist intent before execution, use owner capabilities with stable provider keys, and require reconciliation after unknown outcomes. History, receipts, and handoffs are replay-safe and optimistic-concurrency guarded."
|
"Service launches retain the exact Service and binding. Native Case and Workflow handoffs persist intent before execution, use owner capabilities with stable provider keys, and require reconciliation after unknown outcomes. History, receipts, and handoffs are replay-safe and optimistic-concurrency guarded."
|
||||||
|
" Invitation and explicitly enabled anonymous intake use hash-only expiring tokens, bounded rate limits, and isolated synthetic actors. Authenticated assisted sessions retain channel, affected and represented parties, authority, purpose, notice, responsible function, language, accessibility needs, and field provenance without bypassing the exact Form rules. Submission requires immutable read-back evidence bound to the current revision, values, attachments, and signatures; any later draft edit invalidates it. Administrators can configure applicant status per exact Form revision as authenticated-only, a short-lived link sent to a matching Form email, or a non-expiring public link. Status projections expose only a bounded lifecycle timeline and receipt reference, never Form values, actors, evidence, internal notes, or handoff details. Files-backed attachments use one-time purpose-bound grants, while authenticated acknowledgements bind an exact actor and payload digest without claiming advanced or qualified signature assurance. When Search is enabled, Forms Runtime contributes a rebuildable metadata-only projection; submitted values and evidence content are excluded, and every candidate receives a current workspace or participant access check."
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("user", "operator", "module_admin", "product_owner"),
|
audience=("user", "operator", "module_admin", "product_owner"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
any_scopes=(
|
||||||
|
PARTICIPATE_SCOPE,
|
||||||
|
ASSIST_SCOPE,
|
||||||
|
READ_SCOPE,
|
||||||
|
WRITE_SCOPE,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Forms Runtime security and recovery",
|
label="Forms Runtime security and recovery",
|
||||||
@@ -319,10 +596,17 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"seed": True,
|
||||||
|
"kind": "workflow",
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"forms_runtime.navigation",
|
"forms_runtime.navigation",
|
||||||
"forms_runtime.workspace",
|
"forms_runtime.workspace",
|
||||||
"forms_runtime.instance",
|
"forms_runtime.instance",
|
||||||
|
"forms_runtime.public-intake",
|
||||||
|
"forms_runtime.assisted-intake",
|
||||||
|
"forms_runtime.assisted-confirmation",
|
||||||
|
"forms_runtime.status-access",
|
||||||
|
"forms_runtime.status-policy",
|
||||||
|
"forms_runtime.search.result",
|
||||||
"forms_runtime.state.read-only",
|
"forms_runtime.state.read-only",
|
||||||
"forms_runtime.state.permission-blocked",
|
"forms_runtime.state.permission-blocked",
|
||||||
],
|
],
|
||||||
@@ -330,8 +614,58 @@ manifest = ModuleManifest(
|
|||||||
"Form values are returned only through tenant-bound instance permissions and ownership rules.",
|
"Form values are returned only through tenant-bound instance permissions and ownership rules.",
|
||||||
"Validation messages expose field-level diagnostics without disclosing unrelated submissions.",
|
"Validation messages expose field-level diagnostics without disclosing unrelated submissions.",
|
||||||
"Handoff rows retain provider references and outcomes but do not bypass target-module authorization.",
|
"Handoff rows retain provider references and outcomes but do not bypass target-module authorization.",
|
||||||
|
"Public intake tokens and Files upload tokens are retained only as cryptographic digests; anonymous submissions cannot later be claimed by an identity.",
|
||||||
|
"Assisted session provenance names governed party/function references and purpose; operators should not duplicate names or evidence content in free-text references and notes.",
|
||||||
|
"Forms Runtime retains short-lived status secrets only as digests. The delivered URL necessarily passes to Notifications and Mail under their own retention; email comparison uses a grant-bound digest and unmatched requests receive the same response.",
|
||||||
|
"A permanent status URL is a non-expiring bearer link. Anyone holding it can see the bounded status projection until an administrator disables the exact Form policy or the grant is revoked.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Formulare ausfüllen und verwalten",
|
||||||
|
"summary": (
|
||||||
|
"Zulässige Entwürfe speichern, validierte Werte einreichen und exakte Definitions- und Übergabenachweise bewahren."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Dokumentationsbücher stehen neben Formularlaufzeit oder Formularinstanz. Solange die Instanz "
|
||||||
|
"bearbeitbar ist, steht die Bearbeitungshilfe neben dem angezeigten Formulartitel; Feldhilfe "
|
||||||
|
"bleibt bei der Feldbezeichnung. "
|
||||||
|
"Jede Instanz löst genau eine unveränderliche veröffentlichte Formularrevision auf. Entwurfs- und Endwerte "
|
||||||
|
"werden auf dem Server validiert; die endgültige Einreichung erzwingt zusätzlich Anforderungen an Anlagen, "
|
||||||
|
"Signaturen und Regeln. Service-Aufrufe bewahren den exakten Service und seine Bindung. Native Übergaben an "
|
||||||
|
"Cases und Workflow speichern die Absicht vor der Ausführung, verwenden Eigentümerfähigkeiten mit stabilen "
|
||||||
|
"Anbieterschlüsseln und erfordern bei unbekanntem Ergebnis einen Abgleich. Verlauf, Belege und Übergaben sind "
|
||||||
|
"wiederholungssicher und durch optimistische Nebenläufigkeit geschützt. Einladungen und ausdrücklich aktivierte "
|
||||||
|
"anonyme Erfassung verwenden nur als Prüfsumme gespeicherte, ablaufende Tokens, begrenzte Raten und isolierte "
|
||||||
|
"synthetische Akteure. Authentifizierte assistierte Sitzungen bewahren Kanal, betroffene und vertretene Parteien, "
|
||||||
|
"Befugnis, Zweck, Hinweis, zuständige Funktion, Sprache, Barrierefreiheitsbedarfe und Feldherkunft, ohne die "
|
||||||
|
"exakten Formularregeln zu umgehen. Die Einreichung erfordert unveränderliche Rücklesenachweise, die an aktuelle "
|
||||||
|
"Revision, Werte, Anlagen und Signaturen gebunden sind; jede spätere Entwurfsänderung macht sie ungültig. "
|
||||||
|
"Administratoren konfigurieren den Antragstellerstatus je exakter Formularrevision als nur authentifiziert, "
|
||||||
|
"kurzlebigen Link an eine passende Formular-E-Mail oder nicht ablaufenden öffentlichen Link. Statusprojektionen "
|
||||||
|
"zeigen nur eine begrenzte Lebenszykluszeitleiste und Belegreferenz, niemals Formularwerte, Akteure, Nachweise, "
|
||||||
|
"interne Notizen oder Übergabedetails. Files-Anlagen nutzen einmalige zweckgebundene Freigaben; authentifizierte "
|
||||||
|
"Kenntnisnahmen binden einen exakten Akteur und eine Nutzdatenprüfsumme, ohne fortgeschrittene oder qualifizierte "
|
||||||
|
"Signaturzusicherung zu behaupten. Bei aktiviertem Search trägt Forms Runtime eine wiederaufbaubare reine "
|
||||||
|
"Metadatenprojektion bei; eingereichte Werte und Nachweisinhalte bleiben ausgeschlossen und jeder Treffer wird "
|
||||||
|
"erneut gegen aktuelle Arbeitsbereichs- oder Teilnahmeberechtigung geprüft."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"privacy_notes": [
|
||||||
|
"Formularwerte werden nur nach mandantengebundenen Instanzberechtigungen und Eigentumsregeln ausgegeben.",
|
||||||
|
"Validierungsmeldungen zeigen feldbezogene Diagnosen, ohne fremde Einreichungen offenzulegen.",
|
||||||
|
"Übergabezeilen bewahren Anbieterverweise und Ergebnisse, umgehen aber nicht die Autorisierung des Zielmoduls.",
|
||||||
|
"Tokens für öffentliche Erfassung und Files-Uploads werden nur als kryptografische Prüfsummen bewahrt; anonyme Einreichungen können später keiner Identität zugeschrieben werden.",
|
||||||
|
"Die Herkunft assistierter Sitzungen nennt gesteuerte Partei- und Funktionsverweise sowie den Zweck; Bearbeitende sollen Namen oder Nachweisinhalte nicht zusätzlich in Freitextverweisen und Notizen erfassen.",
|
||||||
|
"Forms Runtime bewahrt kurzlebige Statusgeheimnisse nur als Prüfsummen. Die zugestellte URL durchläuft notwendigerweise Notifications und Mail unter deren Aufbewahrung; der E-Mail-Vergleich verwendet eine freigabegebundene Prüfsumme und nicht passende Anfragen erhalten dieselbe Antwort.",
|
||||||
|
"Eine dauerhafte Status-URL ist ein nicht ablaufender Inhaberlink. Wer ihn besitzt, kann die begrenzte Statusprojektion sehen, bis ein Administrator die exakte Formularregel deaktiviert oder die Freigabe widerruft.",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="forms_runtime.reference.fields-and-consequences",
|
id="forms_runtime.reference.fields-and-consequences",
|
||||||
@@ -344,6 +678,9 @@ manifest = ModuleManifest(
|
|||||||
"not an editable draft save. A Case or Workflow handoff records intent before calling its optional provider and uses a "
|
"not an editable draft save. A Case or Workflow handoff records intent before calling its optional provider and uses a "
|
||||||
"stable idempotency key. Rejected effects may be retried. Unknown outcomes must be reconciled before retry to avoid a "
|
"stable idempotency key. Rejected effects may be retried. Unknown outcomes must be reconciled before retry to avoid a "
|
||||||
"duplicate target. Administrative compensation records verified absence and never deletes a remote target. "
|
"duplicate target. Administrative compensation records verified absence and never deletes a remote target. "
|
||||||
|
"When Records is enabled, only immutable submitted revisions can be resolved for filing; current Forms Runtime access is rechecked and editable drafts fail closed."
|
||||||
|
" Assisted intake begins with an authenticated, purpose-bound session. The operator records the channel, party and representation references, authority basis, notice, responsible function, language, accessibility support, and per-field sources. The operator interface classifies every populated value independently and restores source, confidence, and declaring-party references retained for the current revision. A newly started assisted session can be saved as its first draft before read-back. Read-back outcomes are append-only and bind the exact current payload. Corrections must first be saved as a new draft revision and confirmed again; an unavailable confirmation requires an explicit exception note."
|
||||||
|
" Applicant status policies are attached to one exact published Form revision. Authenticated access is object-bound to the applicant account; assisted intake can bind it only from an explicit account party reference. Email-link mode compares the submitted email without revealing whether it matched, revokes the earlier link on resend, and delegates delivery to Notifications. Permanent-link mode deliberately trades authentication and expiry for possession of a stable high-entropy URL. Disabling a policy immediately suspends all grants issued under it; changing policy fields affects future submissions while existing grants retain their issued access profile."
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
@@ -358,6 +695,7 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"seed": True,
|
||||||
|
"kind": "reference",
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"forms_runtime.field.dynamic-value",
|
"forms_runtime.field.dynamic-value",
|
||||||
"forms_runtime.field.change-reason",
|
"forms_runtime.field.change-reason",
|
||||||
@@ -368,6 +706,15 @@ manifest = ModuleManifest(
|
|||||||
"forms_runtime.action.start-handoff",
|
"forms_runtime.action.start-handoff",
|
||||||
"forms_runtime.action.reconcile-handoff",
|
"forms_runtime.action.reconcile-handoff",
|
||||||
"forms_runtime.action.compensate-handoff",
|
"forms_runtime.action.compensate-handoff",
|
||||||
|
"forms_runtime.action.create-intake-profile",
|
||||||
|
"forms_runtime.action.issue-invitation",
|
||||||
|
"forms_runtime.action.upload-evidence",
|
||||||
|
"forms_runtime.action.acknowledge",
|
||||||
|
"forms_runtime.action.start-assisted-intake",
|
||||||
|
"forms_runtime.action.confirm-assisted-readback",
|
||||||
|
"forms_runtime.action.configure-status-access",
|
||||||
|
"forms_runtime.action.request-status-link",
|
||||||
|
"records.action.file",
|
||||||
],
|
],
|
||||||
"consequence_classes": {
|
"consequence_classes": {
|
||||||
"save_draft": "Creates an immutable draft revision with a change reason.",
|
"save_draft": "Creates an immutable draft revision with a change reason.",
|
||||||
@@ -375,8 +722,71 @@ manifest = ModuleManifest(
|
|||||||
"start_handoff": "Persists intent before invoking an optional Case or Workflow provider.",
|
"start_handoff": "Persists intent before invoking an optional Case or Workflow provider.",
|
||||||
"reconcile": "Resolves an outcome-unknown effect without unsafe duplicate execution.",
|
"reconcile": "Resolves an outcome-unknown effect without unsafe duplicate execution.",
|
||||||
"compensate": "Records an administrative proof that no target effect exists.",
|
"compensate": "Records an administrative proof that no target effect exists.",
|
||||||
|
"public_intake": "Starts an isolated, expiring invitation or explicitly enabled anonymous submission without granting general platform access.",
|
||||||
|
"upload_evidence": "Issues a short-lived provider grant; the returned immutable evidence reference must pass owner verification again at submission.",
|
||||||
|
"acknowledge": "Binds the acting account, statement version, exact Form revision, values, and attachments in an authenticated acknowledgement digest.",
|
||||||
|
"start_assisted_intake": "Creates a resumable authenticated draft with explicit channel, party, authority, purpose, notice, function, accessibility, and source provenance.",
|
||||||
|
"confirm_assisted_readback": "Creates immutable evidence for the exact current revision and payload; a later correction requires a new confirmation before submission.",
|
||||||
|
"configure_status_access": "Selects the applicant-status access and disclosure profile for future submissions of one exact published Form revision; disabling it suspends existing grants.",
|
||||||
|
"request_status_link": "Returns a generic response, rate-limits attempts, and—only after a linked-email match—revokes the previous short-lived secret and asks Notifications to deliver a new one.",
|
||||||
|
"file_submission": "Resolves the exact immutable submission revision under current access and preserves only a digest-bound reference in Records.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Formularwerte sowie Folgen von Einreichung und Übergabe",
|
||||||
|
"summary": (
|
||||||
|
"Laufzeitverhalten von Feldern, unveränderliche Belege, Entwurfsrevisionen, optionale Nachweise und wiederherstellbare externe Wirkungen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Die aktive Instanz löst eine exakte veröffentlichte Formulardefinitionsrevision auf. Sichtbarkeitsbedingungen "
|
||||||
|
"ändern die Darstellung, nicht Servervalidierung oder Autorisierung. Das Speichern eines zulässigen Entwurfs "
|
||||||
|
"erzeugt eine neue Revision mit Änderungsgrund. Einreichen validiert Werte, Anlagen, Signaturen und Regeln und "
|
||||||
|
"erzeugt einen unveränderlichen Beleg; es ist kein bearbeitbares Entwurfsspeichern. Eine Case- oder "
|
||||||
|
"Workflow-Übergabe speichert die Absicht vor dem Aufruf des optionalen Anbieters und verwendet einen stabilen "
|
||||||
|
"Idempotenzschlüssel. Abgelehnte Wirkungen können erneut versucht werden. Unbekannte Ergebnisse müssen vor "
|
||||||
|
"einem erneuten Versuch abgeglichen werden, um doppelte Ziele zu vermeiden. Administrative Kompensation hält "
|
||||||
|
"die bestätigte Abwesenheit fest und löscht niemals ein entferntes Ziel. Bei aktiviertem Records können nur "
|
||||||
|
"unveränderliche eingereichte Revisionen für die Ablage aufgelöst werden; der aktuelle Zugriff in Forms Runtime "
|
||||||
|
"wird erneut geprüft und bearbeitbare Entwürfe werden abgewiesen. Assistierte Erfassung beginnt mit einer "
|
||||||
|
"authentifizierten zweckgebundenen Sitzung. Bearbeitende erfassen Kanal, Partei- und Vertretungsverweise, "
|
||||||
|
"Befugnisgrundlage, Hinweis, zuständige Funktion, Sprache, Barrierefreiheit und Quellen je Feld. Die "
|
||||||
|
"Bearbeitungsoberfläche klassifiziert jeden ausgefüllten Wert unabhängig und stellt für die aktuelle Revision "
|
||||||
|
"bewahrte Quellen-, Vertrauens- und Erklärendenreferenzen wieder her. Eine neu begonnene assistierte Sitzung "
|
||||||
|
"kann vor dem Rücklesen als erster Entwurf gespeichert werden. "
|
||||||
|
"Rückleseergebnisse werden nur angefügt und binden die exakten aktuellen Nutzdaten. Korrekturen müssen zuerst "
|
||||||
|
"als neue Entwurfsrevision gespeichert und erneut bestätigt werden; eine nicht mögliche Bestätigung erfordert "
|
||||||
|
"einen ausdrücklichen Ausnahmevermerk. Regeln für Antragstellerstatus sind an eine exakte veröffentlichte "
|
||||||
|
"Formularrevision gebunden. Authentifizierter Zugriff ist objektgebunden an das Antragstellerkonto; assistierte "
|
||||||
|
"Erfassung darf ihn nur aus einem ausdrücklichen Konto-Parteiverweis binden. Der E-Mail-Link-Modus vergleicht "
|
||||||
|
"die eingereichte E-Mail, ohne die Übereinstimmung offenzulegen, widerruft beim erneuten Senden den früheren "
|
||||||
|
"Link und übergibt die Zustellung an Notifications. Der Dauerlink-Modus tauscht bewusst Authentifizierung und "
|
||||||
|
"Ablauf gegen den Besitz einer stabilen hochentropischen URL. Das Deaktivieren einer Regel setzt alle darunter "
|
||||||
|
"ausgestellten Freigaben sofort aus; Feldänderungen gelten für künftige Einreichungen, während bestehende "
|
||||||
|
"Freigaben ihr ausgestelltes Zugriffsprofil behalten."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"consequence_classes": {
|
||||||
|
"save_draft": "Erzeugt eine unveränderliche Entwurfsrevision mit Änderungsgrund.",
|
||||||
|
"submit": "Validiert die exakte Definition und erzeugt einen unveränderlichen Einreichungsbeleg.",
|
||||||
|
"start_handoff": "Speichert die Absicht vor dem Aufruf eines optionalen Case- oder Workflow-Anbieters.",
|
||||||
|
"reconcile": "Klärt eine Wirkung mit unbekanntem Ergebnis ohne unsichere doppelte Ausführung.",
|
||||||
|
"compensate": "Hält administrativ nachgewiesen fest, dass keine Zielwirkung besteht.",
|
||||||
|
"public_intake": "Startet eine isolierte ablaufende Einladung oder ausdrücklich aktivierte anonyme Einreichung, ohne allgemeinen Plattformzugriff zu gewähren.",
|
||||||
|
"upload_evidence": "Stellt eine kurzlebige Anbieterfreigabe aus; der unveränderliche Nachweisverweis muss bei der Einreichung erneut vom Eigentümer geprüft werden.",
|
||||||
|
"acknowledge": "Bindet handelndes Konto, Erklärungsversion, exakte Formularrevision, Werte und Anlagen in einer authentifizierten Kenntnisnahmeprüfsumme.",
|
||||||
|
"start_assisted_intake": "Erzeugt einen fortsetzbaren authentifizierten Entwurf mit Kanal, Parteien, Befugnis, Zweck, Hinweis, Funktion, Barrierefreiheit und Quellenherkunft.",
|
||||||
|
"confirm_assisted_readback": "Erzeugt unveränderliche Nachweise für exakte aktuelle Revision und Nutzdaten; spätere Korrekturen erfordern vor Einreichung eine neue Bestätigung.",
|
||||||
|
"configure_status_access": "Wählt Zugriffs- und Offenlegungsprofil des Antragstellerstatus für künftige Einreichungen einer exakten Formularrevision; Deaktivierung setzt bestehende Freigaben aus.",
|
||||||
|
"request_status_link": "Gibt eine generische Antwort zurück, begrenzt Versuche und widerruft nur bei passender verknüpfter E-Mail das frühere kurzlebige Geheimnis, bevor Notifications einen neuen Link zustellt.",
|
||||||
|
"file_submission": "Löst die exakte unveränderliche Einreichungsrevision unter aktuellem Zugriff auf und bewahrt in Records nur einen prüfsummengebundenen Verweis.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
architecture=declared_module_architecture(
|
architecture=declared_module_architecture(
|
||||||
@@ -386,7 +796,8 @@ manifest = ModuleManifest(
|
|||||||
documentation_ref="docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
|
documentation_ref="docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
|
||||||
test_ref="tests/test_forms_runtime.py",
|
test_ref="tests/test_forms_runtime.py",
|
||||||
known_limits=(
|
known_limits=(
|
||||||
"Anonymous public intake, concrete Files/signature adapters, and target kinds beyond the native Case/Workflow handoffs remain adapter depth; authenticated Portal entry is supported.",
|
"External advanced or qualified signature providers, scheduled expiry cleanup, and target kinds beyond native Case/Workflow handoffs remain adapter depth. Public links intentionally cannot substitute the native authenticated acknowledgement profile.",
|
||||||
|
"Status policy field changes apply to future submissions; existing grants retain their issued mode and limits, while disabling the policy suspends all of them immediately. Scheduled token cleanup and per-IP throttling remain operations depth.",
|
||||||
),
|
),
|
||||||
supported_authority_modes=("native_authoritative",),
|
supported_authority_modes=("native_authoritative",),
|
||||||
owned_concepts=(
|
owned_concepts=(
|
||||||
@@ -395,6 +806,8 @@ manifest = ModuleManifest(
|
|||||||
"runtime validation",
|
"runtime validation",
|
||||||
"submission receipt",
|
"submission receipt",
|
||||||
"form handoff evidence",
|
"form handoff evidence",
|
||||||
|
"applicant status access policy",
|
||||||
|
"applicant status grant",
|
||||||
),
|
),
|
||||||
non_owned_concepts=(
|
non_owned_concepts=(
|
||||||
"form definition",
|
"form definition",
|
||||||
@@ -402,6 +815,7 @@ manifest = ModuleManifest(
|
|||||||
"case",
|
"case",
|
||||||
"workflow definition",
|
"workflow definition",
|
||||||
"signature key custody",
|
"signature key custody",
|
||||||
|
"notification delivery",
|
||||||
),
|
),
|
||||||
reference_packages=("product.service-to-decision",),
|
reference_packages=("product.service-to-decision",),
|
||||||
migration_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",),
|
migration_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",),
|
||||||
|
|||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
"""Add governed public intake and acknowledgement evidence.
|
||||||
|
|
||||||
|
Revision ID: b4e6f8a0c2d3
|
||||||
|
Revises: a3d5f7b9c1e2
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "b4e6f8a0c2d3"
|
||||||
|
down_revision = "a3d5f7b9c1e2"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"form_intake_profiles",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("profile_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("public_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("definition_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("definition_revision", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("mode", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("custodian_ref", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("draft_ttl_seconds", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("invitation_ttl_seconds", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("rate_limit_per_minute", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("rate_window_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("rate_window_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_by", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("updated_by", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_intake_profiles")),
|
||||||
|
sa.UniqueConstraint("public_id", name="uq_form_intake_public_id"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "profile_id", name="uq_form_intake_profile"),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"profile_id",
|
||||||
|
"public_id",
|
||||||
|
"definition_id",
|
||||||
|
"definition_revision",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_form_intake_profiles_{column}"),
|
||||||
|
"form_intake_profiles",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_form_intake_definition",
|
||||||
|
"form_intake_profiles",
|
||||||
|
["tenant_id", "definition_id", "definition_revision"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"form_intake_sessions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("session_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("profile_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("token_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("mode", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("instance_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("actor_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_by", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["profile_id"],
|
||||||
|
["form_intake_profiles.id"],
|
||||||
|
name=op.f("fk_form_intake_sessions_profile_id_form_intake_profiles"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_intake_sessions")),
|
||||||
|
sa.UniqueConstraint("token_sha256", name="uq_form_intake_token"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "idempotency_key", name="uq_form_intake_session_idempotency"
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("tenant_id", "session_id", name="uq_form_intake_session"),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"session_id",
|
||||||
|
"profile_id",
|
||||||
|
"token_sha256",
|
||||||
|
"mode",
|
||||||
|
"status",
|
||||||
|
"instance_id",
|
||||||
|
"actor_id",
|
||||||
|
"expires_at",
|
||||||
|
"started_at",
|
||||||
|
"submitted_at",
|
||||||
|
"revoked_at",
|
||||||
|
"created_by",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_form_intake_sessions_{column}"),
|
||||||
|
"form_intake_sessions",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_form_intake_session_state",
|
||||||
|
"form_intake_sessions",
|
||||||
|
["tenant_id", "profile_id", "status", "expires_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"form_acknowledgements",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("acknowledgement_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("instance_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("instance_revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("statement_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("statement_version", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("actor_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("payload_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_acknowledgements")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "acknowledgement_id", name="uq_form_acknowledgement"
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_form_acknowledgement_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"acknowledgement_id",
|
||||||
|
"instance_id",
|
||||||
|
"actor_id",
|
||||||
|
"accepted_at",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_form_acknowledgements_{column}"),
|
||||||
|
"form_acknowledgements",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_form_acknowledgement_instance",
|
||||||
|
"form_acknowledgements",
|
||||||
|
["tenant_id", "instance_id", "instance_revision"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("form_acknowledgements")
|
||||||
|
op.drop_table("form_intake_sessions")
|
||||||
|
op.drop_table("form_intake_profiles")
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
"""Add immutable assisted-intake read-back confirmations.
|
||||||
|
|
||||||
|
Revision ID: c5f7a9b1d3e4
|
||||||
|
Revises: b4e6f8a0c2d3
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "c5f7a9b1d3e4"
|
||||||
|
down_revision = "b4e6f8a0c2d3"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"form_assisted_confirmations",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("confirmation_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("intake_session_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("instance_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("instance_revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("outcome", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("method", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("confirmed_by_ref", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("operator_actor_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("payload_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("correction_note", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["intake_session_id"],
|
||||||
|
["form_intake_sessions.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_form_assisted_confirmations_intake_session_id_form_intake_sessions"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_assisted_confirmations")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "confirmation_id", name="uq_form_assisted_confirmation"
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_form_assisted_confirmation_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"confirmation_id",
|
||||||
|
"intake_session_id",
|
||||||
|
"instance_id",
|
||||||
|
"outcome",
|
||||||
|
"method",
|
||||||
|
"operator_actor_id",
|
||||||
|
"confirmed_at",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_form_assisted_confirmations_{column}"),
|
||||||
|
"form_assisted_confirmations",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_form_assisted_confirmation_instance",
|
||||||
|
"form_assisted_confirmations",
|
||||||
|
["tenant_id", "instance_id", "instance_revision"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("form_assisted_confirmations")
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
"""Add configurable applicant status access policies and grants.
|
||||||
|
|
||||||
|
Revision ID: d6a8b0c2e4f6
|
||||||
|
Revises: c5f7a9b1d3e4
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "d6a8b0c2e4f6"
|
||||||
|
down_revision = "c5f7a9b1d3e4"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"form_status_access_policies",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("policy_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("definition_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("definition_revision", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("mode", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("email_field_key", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("token_ttl_seconds", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("request_limit_per_hour", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_by", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("updated_by", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_status_access_policies")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "policy_id", name="uq_form_status_access_policy"
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"definition_id",
|
||||||
|
"definition_revision",
|
||||||
|
name="uq_form_status_access_definition",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"policy_id",
|
||||||
|
"definition_id",
|
||||||
|
"definition_revision",
|
||||||
|
"mode",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_form_status_access_policies_{column}"),
|
||||||
|
"form_status_access_policies",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"form_status_access_grants",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("grant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("policy_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("instance_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("tracking_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("mode", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("applicant_actor_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("recipient_email_sha256", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("token_ttl_seconds", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("request_limit_per_hour", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("request_window_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("request_window_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("last_accessed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["policy_id"],
|
||||||
|
["form_status_access_policies.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_form_status_access_grants_policy_id_form_status_access_policies"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_status_access_grants")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "grant_id", name="uq_form_status_access_grant"
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("tracking_id", name="uq_form_status_tracking_id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "instance_id", name="uq_form_status_access_instance"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"grant_id",
|
||||||
|
"policy_id",
|
||||||
|
"instance_id",
|
||||||
|
"tracking_id",
|
||||||
|
"mode",
|
||||||
|
"applicant_actor_id",
|
||||||
|
"issued_at",
|
||||||
|
"revoked_at",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_form_status_access_grants_{column}"),
|
||||||
|
"form_status_access_grants",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_form_status_access_policy_state",
|
||||||
|
"form_status_access_grants",
|
||||||
|
["tenant_id", "policy_id", "revoked_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"form_status_access_tokens",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("token_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("grant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("token_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("notification_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["grant_id"],
|
||||||
|
["form_status_access_grants.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_form_status_access_tokens_grant_id_form_status_access_grants"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_status_access_tokens")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id", "token_id", name="uq_form_status_access_token"
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"token_sha256", name="uq_form_status_access_token_digest"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"token_id",
|
||||||
|
"grant_id",
|
||||||
|
"token_sha256",
|
||||||
|
"issued_at",
|
||||||
|
"expires_at",
|
||||||
|
"revoked_at",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_form_status_access_tokens_{column}"),
|
||||||
|
"form_status_access_tokens",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_form_status_access_token_state",
|
||||||
|
"form_status_access_tokens",
|
||||||
|
["tenant_id", "grant_id", "expires_at", "revoked_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("form_status_access_tokens")
|
||||||
|
op.drop_table("form_status_access_grants")
|
||||||
|
op.drop_table("form_status_access_policies")
|
||||||
+6
-2
@@ -72,13 +72,17 @@ def upgrade() -> None:
|
|||||||
sa.ForeignKeyConstraint(
|
sa.ForeignKeyConstraint(
|
||||||
["identity_id"],
|
["identity_id"],
|
||||||
["form_instance_identities.id"],
|
["form_instance_identities.id"],
|
||||||
name=op.f("fk_form_instance_revisions_identity_id_form_instance_identities"),
|
name=op.f(
|
||||||
|
"fk_form_instance_revisions_identity_id_form_instance_identities"
|
||||||
|
),
|
||||||
ondelete="RESTRICT",
|
ondelete="RESTRICT",
|
||||||
),
|
),
|
||||||
sa.ForeignKeyConstraint(
|
sa.ForeignKeyConstraint(
|
||||||
["previous_revision_id"],
|
["previous_revision_id"],
|
||||||
["form_instance_revisions.id"],
|
["form_instance_revisions.id"],
|
||||||
name=op.f("fk_form_instance_revisions_previous_revision_id_form_instance_revisions"),
|
name=op.f(
|
||||||
|
"fk_form_instance_revisions_previous_revision_id_form_instance_revisions"
|
||||||
|
),
|
||||||
ondelete="RESTRICT",
|
ondelete="RESTRICT",
|
||||||
),
|
),
|
||||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_instance_revisions")),
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_instance_revisions")),
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.records import (
|
||||||
|
RecordContractError,
|
||||||
|
RecordSourceLocator,
|
||||||
|
RecordSourceReference,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.db.models import FormInstanceRevision
|
||||||
|
from govoplan_forms_runtime.backend.service import FormRuntimeService
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME = "records.source.forms_runtime"
|
||||||
|
_FILEABLE_STATUSES = frozenset(
|
||||||
|
{
|
||||||
|
"submitted",
|
||||||
|
"validated",
|
||||||
|
"needs_review",
|
||||||
|
"accepted",
|
||||||
|
"rejected",
|
||||||
|
"handed_off",
|
||||||
|
"archived",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormsRuntimeRecordSource:
|
||||||
|
provider_id = "forms_runtime"
|
||||||
|
|
||||||
|
def resource_types(self) -> Sequence[str]:
|
||||||
|
return ("form_submission_revision",)
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
locator: RecordSourceLocator,
|
||||||
|
purpose: str,
|
||||||
|
) -> RecordSourceReference:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise RecordContractError(
|
||||||
|
"Form submission record references require a database session."
|
||||||
|
)
|
||||||
|
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||||
|
if not tenant_id or locator.tenant_id != tenant_id:
|
||||||
|
raise RecordContractError(
|
||||||
|
"Form submission record references cannot cross tenants."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
locator.source_module != "forms_runtime"
|
||||||
|
or locator.resource_type != "form_submission_revision"
|
||||||
|
):
|
||||||
|
raise RecordContractError("Unsupported Forms Runtime record source type.")
|
||||||
|
if not str(purpose or "").strip():
|
||||||
|
raise RecordContractError(
|
||||||
|
"Form submission record references require a purpose."
|
||||||
|
)
|
||||||
|
has_participant_scope = _has(principal, "forms_runtime:submission:participate")
|
||||||
|
has_tenant_read = _has(principal, "forms_runtime:workspace:read")
|
||||||
|
if not has_participant_scope and not has_tenant_read:
|
||||||
|
raise RecordContractError(
|
||||||
|
"Current Forms Runtime read permission is required."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
revision = int(locator.source_revision)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RecordContractError(
|
||||||
|
"Form submission source revisions must be numeric."
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
instance = FormRuntimeService(None).get_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=locator.resource_id,
|
||||||
|
revision=revision,
|
||||||
|
allow_all=has_tenant_read,
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise RecordContractError(
|
||||||
|
"The current principal cannot read this Form submission."
|
||||||
|
) from exc
|
||||||
|
if instance is None:
|
||||||
|
raise RecordContractError(
|
||||||
|
"The exact Form submission revision does not exist."
|
||||||
|
)
|
||||||
|
if instance.status not in _FILEABLE_STATUSES:
|
||||||
|
raise RecordContractError(
|
||||||
|
"Editable Form drafts cannot be filed as an institutional record."
|
||||||
|
)
|
||||||
|
row = (
|
||||||
|
session.query(FormInstanceRevision)
|
||||||
|
.filter(
|
||||||
|
FormInstanceRevision.tenant_id == tenant_id,
|
||||||
|
FormInstanceRevision.instance_id == locator.resource_id,
|
||||||
|
FormInstanceRevision.revision == revision,
|
||||||
|
)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
snapshot_json = json.dumps(
|
||||||
|
row.snapshot,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
label_id = instance.receipt_id or instance.instance_id
|
||||||
|
return RecordSourceReference(
|
||||||
|
locator=locator,
|
||||||
|
label=f"Form submission {label_id}",
|
||||||
|
authority_mode="external_authoritative",
|
||||||
|
content_sha256=hashlib.sha256(snapshot_json).hexdigest(),
|
||||||
|
content_type="application/vnd.govoplan.form-submission-revision+json",
|
||||||
|
size_bytes=len(snapshot_json),
|
||||||
|
valid_from=instance.recorded_at,
|
||||||
|
recorded_at=instance.recorded_at,
|
||||||
|
launch_url=f"/forms-runtime/{quote(instance.instance_id, safe='')}",
|
||||||
|
metadata={
|
||||||
|
"status": instance.status,
|
||||||
|
"definition_ref": instance.definition_ref.to_dict(),
|
||||||
|
"service_ref": (
|
||||||
|
instance.service_ref.to_dict() if instance.service_ref else None
|
||||||
|
),
|
||||||
|
"receipt_id": instance.receipt_id,
|
||||||
|
"attachment_count": len(instance.attachment_refs),
|
||||||
|
"signature_count": len(instance.signature_refs),
|
||||||
|
"handoff_count": len(instance.handoff_refs),
|
||||||
|
"snapshot_sha256": hashlib.sha256(snapshot_json).hexdigest(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_forms_runtime_record_source(
|
||||||
|
_context: object,
|
||||||
|
) -> FormsRuntimeRecordSource:
|
||||||
|
return FormsRuntimeRecordSource()
|
||||||
|
|
||||||
|
|
||||||
|
def _has(principal: object, scope: str) -> bool:
|
||||||
|
return bool(hasattr(principal, "has") and principal.has(scope))
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME",
|
||||||
|
"FormsRuntimeRecordSource",
|
||||||
|
"create_forms_runtime_record_source",
|
||||||
|
]
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||||
@@ -12,12 +14,17 @@ from govoplan_core.core.institutional import (
|
|||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_forms_runtime.backend.manifest import (
|
from govoplan_forms_runtime.backend.manifest import (
|
||||||
ADMIN_SCOPE,
|
ADMIN_SCOPE,
|
||||||
|
ASSIST_SCOPE,
|
||||||
PARTICIPATE_SCOPE,
|
PARTICIPATE_SCOPE,
|
||||||
READ_SCOPE,
|
READ_SCOPE,
|
||||||
WRITE_SCOPE,
|
WRITE_SCOPE,
|
||||||
)
|
)
|
||||||
from govoplan_forms_runtime.backend.schemas import (
|
from govoplan_forms_runtime.backend.schemas import (
|
||||||
|
AssistedFormConfirmationRequest,
|
||||||
|
AssistedFormStartRequest,
|
||||||
|
FormAcknowledgementRequest,
|
||||||
FormDraftUpdateRequest,
|
FormDraftUpdateRequest,
|
||||||
|
FormEvidenceGrantCreateRequest,
|
||||||
FormHandoffRequest,
|
FormHandoffRequest,
|
||||||
FormHandoffActionRequest,
|
FormHandoffActionRequest,
|
||||||
FormHandoffCompensateRequest,
|
FormHandoffCompensateRequest,
|
||||||
@@ -25,18 +32,523 @@ from govoplan_forms_runtime.backend.schemas import (
|
|||||||
FormInstanceEventsResponse,
|
FormInstanceEventsResponse,
|
||||||
FormInstanceHistoryResponse,
|
FormInstanceHistoryResponse,
|
||||||
FormInstanceListResponse,
|
FormInstanceListResponse,
|
||||||
|
FormIntakeInvitationRequest,
|
||||||
|
FormIntakeProfileCreateRequest,
|
||||||
|
FormIntakeProfileStateRequest,
|
||||||
|
FormStatusAccessPolicyRequest,
|
||||||
|
FormStatusEmailLinkRequest,
|
||||||
|
PublicFormStartRequest,
|
||||||
FormSubmitRequest,
|
FormSubmitRequest,
|
||||||
FormTransitionRequest,
|
FormTransitionRequest,
|
||||||
FormNativeHandoffRequest,
|
FormNativeHandoffRequest,
|
||||||
)
|
)
|
||||||
|
from govoplan_forms_runtime.backend.assisted import confirmation_payload
|
||||||
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
|
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
|
||||||
|
from govoplan_forms_runtime.backend.intake import (
|
||||||
|
FormIntakeError,
|
||||||
|
FormIntakeService,
|
||||||
|
profile_payload,
|
||||||
|
)
|
||||||
from govoplan_forms_runtime.backend.service import FormRuntimeError, FormRuntimeService
|
from govoplan_forms_runtime.backend.service import FormRuntimeError, FormRuntimeService
|
||||||
|
from govoplan_forms_runtime.backend.status_access import (
|
||||||
|
FormStatusAccessError,
|
||||||
|
FormStatusAccessService,
|
||||||
|
FormStatusUnavailable,
|
||||||
|
status_policy_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_router(registry: object | None) -> APIRouter:
|
def create_router(registry: object | None) -> APIRouter:
|
||||||
router = APIRouter(prefix="/forms-runtime", tags=["forms-runtime"])
|
router = APIRouter(prefix="/forms-runtime", tags=["forms-runtime"])
|
||||||
runtime = FormRuntimeService(registry)
|
runtime = FormRuntimeService(registry)
|
||||||
handoffs = FormHandoffService(registry)
|
handoffs = FormHandoffService(registry)
|
||||||
|
intake = FormIntakeService(registry)
|
||||||
|
status_access = FormStatusAccessService(registry)
|
||||||
|
|
||||||
|
@router.get("/intake-profiles", response_model=dict[str, object])
|
||||||
|
def api_list_intake_profiles(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, ADMIN_SCOPE)
|
||||||
|
return {
|
||||||
|
"profiles": [
|
||||||
|
profile_payload(item)
|
||||||
|
for item in intake.list_profiles(session, principal)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/status-access/policies", response_model=dict[str, object])
|
||||||
|
def api_list_status_access_policies(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, ADMIN_SCOPE)
|
||||||
|
return {
|
||||||
|
"policies": [
|
||||||
|
status_policy_payload(item)
|
||||||
|
for item in status_access.list_policies(session, principal)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.put("/status-access/policies", response_model=dict[str, object])
|
||||||
|
def api_upsert_status_access_policy(
|
||||||
|
payload: FormStatusAccessPolicyRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
policy = status_access.upsert_policy(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition_ref=InstitutionalReference.from_mapping(
|
||||||
|
payload.definition_ref
|
||||||
|
),
|
||||||
|
mode=payload.mode,
|
||||||
|
enabled=payload.enabled,
|
||||||
|
email_field_key=payload.email_field_key,
|
||||||
|
token_ttl_seconds=payload.token_ttl_seconds,
|
||||||
|
request_limit_per_hour=payload.request_limit_per_hour,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
FormStatusAccessError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return status_policy_payload(policy)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/public/status/{tracking_id}/access",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
)
|
||||||
|
def api_public_status_access_challenge(
|
||||||
|
tracking_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
return status_access.public_access_challenge(
|
||||||
|
session,
|
||||||
|
tracking_id=tracking_id,
|
||||||
|
)
|
||||||
|
except FormStatusUnavailable as exc:
|
||||||
|
raise _status_unavailable() from exc
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/public/status/{tracking_id}/email-links",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
def api_request_public_status_email_link(
|
||||||
|
tracking_id: str,
|
||||||
|
payload: FormStatusEmailLinkRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
status_access.request_email_link(
|
||||||
|
session,
|
||||||
|
tracking_id=tracking_id,
|
||||||
|
email=payload.email,
|
||||||
|
requested_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except Exception:
|
||||||
|
# A matched email, a missing provider, and an unknown tracking id
|
||||||
|
# must remain indistinguishable to an unauthenticated caller.
|
||||||
|
session.rollback()
|
||||||
|
return {
|
||||||
|
"accepted": True,
|
||||||
|
"message": (
|
||||||
|
"If the application and email address match, a new short-lived "
|
||||||
|
"status link will be sent. Any earlier link is then revoked."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/public/status/{tracking_id}", response_model=dict[str, object])
|
||||||
|
def api_get_public_status(
|
||||||
|
tracking_id: str,
|
||||||
|
token: str | None = Query(default=None, max_length=500),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
projection = status_access.get_public_projection(
|
||||||
|
session,
|
||||||
|
tracking_id=tracking_id,
|
||||||
|
token=token,
|
||||||
|
observed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except FormStatusUnavailable as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _status_unavailable() from exc
|
||||||
|
return projection
|
||||||
|
|
||||||
|
@router.get("/status/{tracking_id}", response_model=dict[str, object])
|
||||||
|
def api_get_authenticated_status(
|
||||||
|
tracking_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
projection = status_access.get_authenticated_projection(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tracking_id=tracking_id,
|
||||||
|
observed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except FormStatusUnavailable as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _status_unavailable() from exc
|
||||||
|
return projection
|
||||||
|
|
||||||
|
@router.get("/intake-profile-definitions", response_model=dict[str, object])
|
||||||
|
def api_list_intake_profile_definitions(
|
||||||
|
query: str = Query(default="", max_length=255),
|
||||||
|
limit: int = Query(default=200, ge=1, le=200),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
definitions = intake.list_available_definitions(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
query=query,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
except (FormIntakeError, PermissionError) as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return {"definitions": [item.to_dict() for item in definitions]}
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/intake-profiles",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_create_intake_profile(
|
||||||
|
payload: FormIntakeProfileCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
profile = intake.create_profile(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition_ref=InstitutionalReference.from_mapping(
|
||||||
|
payload.definition_ref
|
||||||
|
),
|
||||||
|
mode=payload.mode,
|
||||||
|
custodian_ref=_custodian_ref(principal),
|
||||||
|
draft_ttl_seconds=payload.draft_ttl_seconds,
|
||||||
|
invitation_ttl_seconds=payload.invitation_ttl_seconds,
|
||||||
|
rate_limit_per_minute=payload.rate_limit_per_minute,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (FormIntakeError, InstitutionalContextError, PermissionError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return profile_payload(profile)
|
||||||
|
|
||||||
|
@router.patch("/intake-profiles/{profile_id}", response_model=dict[str, object])
|
||||||
|
def api_set_intake_profile_state(
|
||||||
|
profile_id: str,
|
||||||
|
payload: FormIntakeProfileStateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
profile = intake.set_profile_enabled(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
profile_id=profile_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
enabled=payload.enabled,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (FormIntakeError, LookupError, PermissionError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return profile_payload(profile)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/intake-profiles/{profile_id}/invitations",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_issue_intake_invitation(
|
||||||
|
profile_id: str,
|
||||||
|
payload: FormIntakeInvitationRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
result = intake.issue_invitation(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
profile_id=profile_id,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
expires_at=payload.expires_at,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (FormIntakeError, LookupError, PermissionError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return result.to_dict()
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/public/profiles/{public_id}/start",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_start_anonymous_intake(
|
||||||
|
public_id: str,
|
||||||
|
payload: PublicFormStartRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
result = intake.start_anonymous(
|
||||||
|
session,
|
||||||
|
public_id=public_id,
|
||||||
|
values=payload.values,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (FormIntakeError, InstitutionalContextError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _public_error(exc) from exc
|
||||||
|
return result.to_dict()
|
||||||
|
|
||||||
|
@router.get("/assisted-intake/profiles", response_model=dict[str, object])
|
||||||
|
def api_list_assisted_intake_profiles(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, ASSIST_SCOPE, WRITE_SCOPE)
|
||||||
|
return {
|
||||||
|
"profiles": [
|
||||||
|
profile_payload(item)
|
||||||
|
for item in intake.list_assisted_profiles(session, principal)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/assisted-intake/start",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_start_assisted_intake(
|
||||||
|
payload: AssistedFormStartRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, ASSIST_SCOPE, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
result = intake.start_assisted(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
profile_id=payload.profile_id,
|
||||||
|
values=payload.values,
|
||||||
|
channel=payload.channel,
|
||||||
|
affected_party_ref=payload.affected_party_ref,
|
||||||
|
represented_party_ref=payload.represented_party_ref,
|
||||||
|
authority_basis=payload.authority_basis,
|
||||||
|
purpose=payload.purpose,
|
||||||
|
legal_basis_ref=payload.legal_basis_ref,
|
||||||
|
consent_basis=payload.consent_basis,
|
||||||
|
notice_given=payload.notice_given,
|
||||||
|
responsible_function_ref=payload.responsible_function_ref,
|
||||||
|
language=payload.language,
|
||||||
|
accessibility_needs=payload.accessibility_needs,
|
||||||
|
field_sources={
|
||||||
|
key: value.model_dump()
|
||||||
|
for key, value in payload.field_sources.items()
|
||||||
|
},
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
FormIntakeError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return result.to_dict()
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/instances/{instance_id}/assisted-confirmations",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
)
|
||||||
|
def api_list_assisted_confirmations(
|
||||||
|
instance_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, ASSIST_SCOPE, READ_SCOPE, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
items = intake.list_assisted_confirmations(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
allow_all=has_scope(principal, READ_SCOPE)
|
||||||
|
or has_scope(principal, WRITE_SCOPE),
|
||||||
|
)
|
||||||
|
except (FormIntakeError, LookupError, PermissionError) as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return {"confirmations": [confirmation_payload(item) for item in items]}
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/assisted-confirmations",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_record_assisted_confirmation(
|
||||||
|
instance_id: str,
|
||||||
|
payload: AssistedFormConfirmationRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, ASSIST_SCOPE, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
item = intake.record_assisted_confirmation(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
values=payload.values,
|
||||||
|
attachment_refs=_evidence(payload.attachment_refs),
|
||||||
|
signature_refs=_evidence(payload.signature_refs),
|
||||||
|
outcome=payload.outcome,
|
||||||
|
method=payload.method,
|
||||||
|
confirmed_by_ref=payload.confirmed_by_ref,
|
||||||
|
confirmed_at=payload.confirmed_at,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
field_sources={
|
||||||
|
key: value.model_dump()
|
||||||
|
for key, value in payload.field_sources.items()
|
||||||
|
},
|
||||||
|
correction_note=payload.correction_note,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
allow_all=has_scope(principal, WRITE_SCOPE),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
FormRuntimeError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return confirmation_payload(item)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/public/intake/start",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_start_invitation_intake(
|
||||||
|
payload: PublicFormStartRequest,
|
||||||
|
x_form_intake_token: str = Header(alias="X-Form-Intake-Token"),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
result = intake.start_invitation(
|
||||||
|
session,
|
||||||
|
token=x_form_intake_token,
|
||||||
|
values=payload.values,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (FormIntakeError, InstitutionalContextError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _public_error(exc) from exc
|
||||||
|
return result.to_dict()
|
||||||
|
|
||||||
|
@router.get("/public/intake", response_model=dict[str, object])
|
||||||
|
def api_get_public_intake(
|
||||||
|
x_form_intake_token: str = Header(alias="X-Form-Intake-Token"),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
instance, definition = intake.get_public_instance(
|
||||||
|
session,
|
||||||
|
token=x_form_intake_token,
|
||||||
|
now=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (FormIntakeError, InstitutionalContextError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _public_error(exc) from exc
|
||||||
|
return {
|
||||||
|
"instance": _instance_payload(instance, session, status_access),
|
||||||
|
"definition": definition.to_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.patch("/public/intake", response_model=dict[str, object])
|
||||||
|
def api_update_public_intake(
|
||||||
|
payload: FormDraftUpdateRequest,
|
||||||
|
x_form_intake_token: str = Header(alias="X-Form-Intake-Token"),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
instance = intake.update_public_draft(
|
||||||
|
session,
|
||||||
|
token=x_form_intake_token,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
values=payload.values,
|
||||||
|
attachment_refs=_evidence(payload.attachment_refs),
|
||||||
|
signature_refs=_evidence(payload.signature_refs),
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
change_reason=payload.change_reason,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (FormRuntimeError, InstitutionalContextError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _public_error(exc, disclose_valid_request=True) from exc
|
||||||
|
return _instance_payload(instance, session, status_access)
|
||||||
|
|
||||||
|
@router.post("/public/intake/submit", response_model=dict[str, object])
|
||||||
|
def api_submit_public_intake(
|
||||||
|
payload: FormSubmitRequest,
|
||||||
|
x_form_intake_token: str = Header(alias="X-Form-Intake-Token"),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
instance = intake.submit_public(
|
||||||
|
session,
|
||||||
|
token=x_form_intake_token,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
values=payload.values,
|
||||||
|
attachment_refs=_evidence(payload.attachment_refs),
|
||||||
|
signature_refs=_evidence(payload.signature_refs),
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (FormRuntimeError, InstitutionalContextError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _public_error(exc, disclose_valid_request=True) from exc
|
||||||
|
return _instance_payload(instance, session, status_access)
|
||||||
|
|
||||||
@router.get("/instances", response_model=FormInstanceListResponse)
|
@router.get("/instances", response_model=FormInstanceListResponse)
|
||||||
def api_list_instances(
|
def api_list_instances(
|
||||||
@@ -97,7 +609,7 @@ def create_router(registry: object | None) -> APIRouter:
|
|||||||
except (FormRuntimeError, InstitutionalContextError, PermissionError) as exc:
|
except (FormRuntimeError, InstitutionalContextError, PermissionError) as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise _error(exc) from exc
|
raise _error(exc) from exc
|
||||||
return item.to_dict()
|
return _instance_payload(item, session, status_access)
|
||||||
|
|
||||||
@router.get("/instances/{instance_id}", response_model=dict[str, object])
|
@router.get("/instances/{instance_id}", response_model=dict[str, object])
|
||||||
def api_get_instance(
|
def api_get_instance(
|
||||||
@@ -119,7 +631,7 @@ def create_router(registry: object | None) -> APIRouter:
|
|||||||
raise _error(exc) from exc
|
raise _error(exc) from exc
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="Form instance not found")
|
raise HTTPException(status_code=404, detail="Form instance not found")
|
||||||
return item.to_dict()
|
return _instance_payload(item, session, status_access)
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/instances/{instance_id}/definition",
|
"/instances/{instance_id}/definition",
|
||||||
@@ -210,7 +722,111 @@ def create_router(registry: object | None) -> APIRouter:
|
|||||||
) as exc:
|
) as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise _error(exc) from exc
|
raise _error(exc) from exc
|
||||||
return item.to_dict()
|
return _instance_payload(item, session, status_access)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/evidence-grants",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_create_evidence_grant(
|
||||||
|
instance_id: str,
|
||||||
|
payload: FormEvidenceGrantCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
grant = runtime.create_evidence_grant(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
provider_id=payload.provider_id,
|
||||||
|
custodian_ref=_custodian_ref(principal),
|
||||||
|
purpose=payload.purpose,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
expires_at=payload.expires_at,
|
||||||
|
max_size_bytes=payload.max_size_bytes,
|
||||||
|
allowed_content_types=payload.allowed_content_types,
|
||||||
|
attachment_refs=_evidence(payload.attachment_refs),
|
||||||
|
allow_all=has_scope(principal, WRITE_SCOPE),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (FormRuntimeError, InstitutionalContextError, PermissionError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return _grant_payload(grant)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/public/intake/evidence-grants",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_create_public_evidence_grant(
|
||||||
|
payload: FormEvidenceGrantCreateRequest,
|
||||||
|
x_form_intake_token: str = Header(alias="X-Form-Intake-Token"),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
intake_session, profile, public_principal = intake.session_context(
|
||||||
|
session,
|
||||||
|
token=x_form_intake_token,
|
||||||
|
now=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
if not intake_session.instance_id:
|
||||||
|
raise FormIntakeError("Public Form intake is unavailable.")
|
||||||
|
grant = runtime.create_evidence_grant(
|
||||||
|
session,
|
||||||
|
public_principal,
|
||||||
|
instance_id=intake_session.instance_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
provider_id=payload.provider_id,
|
||||||
|
custodian_ref=profile.custodian_ref,
|
||||||
|
purpose=payload.purpose,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
expires_at=payload.expires_at,
|
||||||
|
max_size_bytes=payload.max_size_bytes,
|
||||||
|
allowed_content_types=payload.allowed_content_types,
|
||||||
|
attachment_refs=_evidence(payload.attachment_refs),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (FormRuntimeError, InstitutionalContextError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _public_error(exc, disclose_valid_request=True) from exc
|
||||||
|
return _grant_payload(grant)
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instances/{instance_id}/acknowledgements",
|
||||||
|
response_model=dict[str, object],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_acknowledge_form(
|
||||||
|
instance_id: str,
|
||||||
|
payload: FormAcknowledgementRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
reference = runtime.acknowledge(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
statement_id=payload.statement_id,
|
||||||
|
statement_version=payload.statement_version,
|
||||||
|
values=payload.values,
|
||||||
|
attachment_refs=_evidence(payload.attachment_refs),
|
||||||
|
accepted_at=payload.accepted_at,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
allow_all=has_scope(principal, WRITE_SCOPE),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (FormRuntimeError, InstitutionalContextError, PermissionError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return {"evidence": reference.to_dict(include_inspection=True)}
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/instances/{instance_id}/transition",
|
"/instances/{instance_id}/transition",
|
||||||
@@ -542,4 +1158,69 @@ def _error(exc: Exception) -> HTTPException:
|
|||||||
return HTTPException(status_code=code, detail=message)
|
return HTTPException(status_code=code, detail=message)
|
||||||
|
|
||||||
|
|
||||||
|
def _status_unavailable() -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Application status is unavailable.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _instance_payload(
|
||||||
|
instance: object,
|
||||||
|
session: Session,
|
||||||
|
status_access: FormStatusAccessService,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
payload = dict(getattr(instance, "to_dict")())
|
||||||
|
summary = status_access.access_summary_for_instance(
|
||||||
|
session,
|
||||||
|
tenant_id=str(getattr(instance, "tenant_id")),
|
||||||
|
instance_id=str(getattr(instance, "instance_id")),
|
||||||
|
)
|
||||||
|
if summary is not None:
|
||||||
|
payload["status_access"] = summary
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _public_error(
|
||||||
|
exc: Exception,
|
||||||
|
*,
|
||||||
|
disclose_valid_request: bool = False,
|
||||||
|
) -> HTTPException:
|
||||||
|
message = str(exc)
|
||||||
|
if "rate limited" in message.casefold():
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Public Form intake is temporarily unavailable.",
|
||||||
|
)
|
||||||
|
if disclose_valid_request and not isinstance(exc, FormIntakeError):
|
||||||
|
lowered = message.casefold()
|
||||||
|
code = 409 if any(word in lowered for word in ("conflict", "stale")) else 422
|
||||||
|
return HTTPException(status_code=code, detail=message)
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Public Form intake is unavailable.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _custodian_ref(principal: ApiPrincipal) -> str:
|
||||||
|
user_id = str(getattr(principal.user, "id", "") or "").strip()
|
||||||
|
if user_id:
|
||||||
|
return f"user:{user_id}"
|
||||||
|
return f"account:{principal.account_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _grant_payload(grant: object) -> dict[str, object]:
|
||||||
|
upload_token = getattr(grant, "upload_token")
|
||||||
|
return {
|
||||||
|
"provider_id": str(getattr(grant, "provider_id")),
|
||||||
|
"grant_id": str(getattr(grant, "grant_id")),
|
||||||
|
"upload_token": str(upload_token) if upload_token is not None else None,
|
||||||
|
"upload_url": str(getattr(grant, "upload_url")),
|
||||||
|
"expires_at": getattr(grant, "expires_at").isoformat(),
|
||||||
|
"max_size_bytes": int(getattr(grant, "max_size_bytes")),
|
||||||
|
"allowed_content_types": list(getattr(grant, "allowed_content_types")),
|
||||||
|
"replayed": bool(getattr(grant, "replayed", False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["create_router"]
|
__all__ = ["create_router"]
|
||||||
|
|||||||
@@ -89,6 +89,151 @@ class FormHandoffCompensateRequest(FormHandoffActionRequest):
|
|||||||
change_reason: str = Field(min_length=1, max_length=1000)
|
change_reason: str = Field(min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class FormIntakeProfileCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
definition_ref: dict[str, Any]
|
||||||
|
mode: Literal["anonymous", "invitation", "assisted"]
|
||||||
|
draft_ttl_seconds: int = Field(default=2_592_000, ge=60, le=31_536_000)
|
||||||
|
invitation_ttl_seconds: int = Field(default=1_209_600, ge=60, le=7_776_000)
|
||||||
|
rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000)
|
||||||
|
recorded_at: datetime
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FormIntakeProfileStateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class FormIntakeInvitationRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
recorded_at: datetime
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicFormStartRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
values: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
recorded_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AssistedFieldSourceRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
source: Literal[
|
||||||
|
"person_statement",
|
||||||
|
"representative_statement",
|
||||||
|
"document",
|
||||||
|
"system",
|
||||||
|
"derived",
|
||||||
|
]
|
||||||
|
confidence: Literal["stated", "verified", "uncertain"]
|
||||||
|
declared_by_ref: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class AssistedFormStartRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
profile_id: str = Field(min_length=1, max_length=255)
|
||||||
|
values: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
channel: Literal[
|
||||||
|
"counter",
|
||||||
|
"telephone",
|
||||||
|
"paper",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"representative",
|
||||||
|
"offline_import",
|
||||||
|
]
|
||||||
|
affected_party_ref: str = Field(min_length=1, max_length=255)
|
||||||
|
represented_party_ref: str | None = Field(
|
||||||
|
default=None, min_length=1, max_length=255
|
||||||
|
)
|
||||||
|
authority_basis: str = Field(min_length=1, max_length=255)
|
||||||
|
purpose: str = Field(min_length=1, max_length=500)
|
||||||
|
legal_basis_ref: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
consent_basis: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
notice_given: bool
|
||||||
|
responsible_function_ref: str = Field(min_length=1, max_length=255)
|
||||||
|
language: str = Field(min_length=1, max_length=35)
|
||||||
|
accessibility_needs: list[str] = Field(default_factory=list, max_length=30)
|
||||||
|
field_sources: dict[str, AssistedFieldSourceRequest] = Field(default_factory=dict)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
recorded_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AssistedFormConfirmationRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
values: dict[str, Any]
|
||||||
|
attachment_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=1000)
|
||||||
|
signature_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=100)
|
||||||
|
outcome: Literal["confirmed", "corrected", "confirmation_unavailable"]
|
||||||
|
method: Literal[
|
||||||
|
"spoken_readback", "written_preview", "accessible_copy", "unavailable"
|
||||||
|
]
|
||||||
|
confirmed_by_ref: str = Field(min_length=1, max_length=255)
|
||||||
|
confirmed_at: datetime
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
field_sources: dict[str, AssistedFieldSourceRequest]
|
||||||
|
correction_note: str | None = Field(default=None, min_length=1, max_length=1000)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FormStatusAccessPolicyRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
definition_ref: dict[str, Any]
|
||||||
|
mode: Literal["authenticated", "email_link", "permanent_link"]
|
||||||
|
enabled: bool = True
|
||||||
|
email_field_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
token_ttl_seconds: int = Field(default=3600, ge=300, le=604_800)
|
||||||
|
request_limit_per_hour: int = Field(default=5, ge=1, le=60)
|
||||||
|
expected_revision: int | None = Field(default=None, ge=1)
|
||||||
|
recorded_at: datetime
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FormStatusEmailLinkRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
email: str = Field(min_length=3, max_length=320)
|
||||||
|
|
||||||
|
|
||||||
|
class FormEvidenceGrantCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
provider_id: str = Field(default="files", min_length=1, max_length=120)
|
||||||
|
purpose: str = Field(min_length=1, max_length=255)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
expires_at: datetime
|
||||||
|
max_size_bytes: int | None = Field(default=None, ge=1)
|
||||||
|
allowed_content_types: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
attachment_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class FormAcknowledgementRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
statement_id: str = Field(min_length=1, max_length=255)
|
||||||
|
statement_version: str = Field(min_length=1, max_length=255)
|
||||||
|
values: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
attachment_refs: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
accepted_at: datetime
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
class FormInstanceListResponse(BaseModel):
|
class FormInstanceListResponse(BaseModel):
|
||||||
instances: list[dict[str, Any]]
|
instances: list[dict[str, Any]]
|
||||||
total: int
|
total: int
|
||||||
@@ -105,7 +250,14 @@ class FormInstanceEventsResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"AssistedFieldSourceRequest",
|
||||||
|
"AssistedFormConfirmationRequest",
|
||||||
|
"AssistedFormStartRequest",
|
||||||
|
"FormAcknowledgementRequest",
|
||||||
"FormDraftUpdateRequest",
|
"FormDraftUpdateRequest",
|
||||||
|
"FormEvidenceGrantCreateRequest",
|
||||||
|
"FormStatusAccessPolicyRequest",
|
||||||
|
"FormStatusEmailLinkRequest",
|
||||||
"FormHandoffRequest",
|
"FormHandoffRequest",
|
||||||
"FormHandoffActionRequest",
|
"FormHandoffActionRequest",
|
||||||
"FormHandoffCompensateRequest",
|
"FormHandoffCompensateRequest",
|
||||||
@@ -114,6 +266,10 @@ __all__ = [
|
|||||||
"FormInstanceEventsResponse",
|
"FormInstanceEventsResponse",
|
||||||
"FormInstanceHistoryResponse",
|
"FormInstanceHistoryResponse",
|
||||||
"FormInstanceListResponse",
|
"FormInstanceListResponse",
|
||||||
|
"FormIntakeInvitationRequest",
|
||||||
|
"FormIntakeProfileCreateRequest",
|
||||||
|
"FormIntakeProfileStateRequest",
|
||||||
|
"PublicFormStartRequest",
|
||||||
"FormSubmitRequest",
|
"FormSubmitRequest",
|
||||||
"FormTransitionRequest",
|
"FormTransitionRequest",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.events import PlatformEvent
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.core.search import (
|
||||||
|
SearchAuthorizationRequest,
|
||||||
|
SearchBackfillPage,
|
||||||
|
SearchBackfillRequest,
|
||||||
|
SearchDocument,
|
||||||
|
SearchIndexChange,
|
||||||
|
SearchResourceReference,
|
||||||
|
SearchResourceType,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.db.models import (
|
||||||
|
FormInstanceIdentity,
|
||||||
|
FormInstanceRevision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDER_ID = "forms_runtime.submissions"
|
||||||
|
RESOURCE_TYPE = "form_submission"
|
||||||
|
PARTICIPATE_SCOPE = "forms_runtime:submission:participate"
|
||||||
|
READ_SCOPE = "forms_runtime:workspace:read"
|
||||||
|
ADMIN_SCOPE = "forms_runtime:workspace:admin"
|
||||||
|
|
||||||
|
|
||||||
|
class FormsRuntimeSearchSource:
|
||||||
|
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||||
|
return (
|
||||||
|
SearchResourceType(
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
module_id="forms_runtime",
|
||||||
|
resource_type=RESOURCE_TYPE,
|
||||||
|
label="Form submissions",
|
||||||
|
requires_authorization_recheck=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def backfill(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: SearchBackfillRequest,
|
||||||
|
) -> SearchBackfillPage:
|
||||||
|
_assert_source(request.provider_id, request.resource_type)
|
||||||
|
db = _session(session)
|
||||||
|
statement = (
|
||||||
|
select(FormInstanceRevision, FormInstanceIdentity)
|
||||||
|
.join(
|
||||||
|
FormInstanceIdentity,
|
||||||
|
FormInstanceIdentity.id == FormInstanceRevision.identity_id,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
FormInstanceRevision.tenant_id == request.tenant_id,
|
||||||
|
FormInstanceRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if request.cursor:
|
||||||
|
statement = statement.where(
|
||||||
|
FormInstanceRevision.instance_id > request.cursor
|
||||||
|
)
|
||||||
|
rows = list(
|
||||||
|
db.execute(
|
||||||
|
statement.order_by(FormInstanceRevision.instance_id).limit(
|
||||||
|
request.limit + 1
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
has_more = len(rows) > request.limit
|
||||||
|
selected = rows[: request.limit]
|
||||||
|
high_watermark = db.scalar(
|
||||||
|
select(func.max(FormInstanceRevision.recorded_at)).where(
|
||||||
|
FormInstanceRevision.tenant_id == request.tenant_id,
|
||||||
|
FormInstanceRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return SearchBackfillPage(
|
||||||
|
documents=tuple(_document(row, identity=identity) for row, identity in selected),
|
||||||
|
next_cursor=selected[-1][0].instance_id if has_more and selected else None,
|
||||||
|
complete=not has_more,
|
||||||
|
high_watermark=high_watermark.isoformat() if high_watermark else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
requests: Sequence[SearchAuthorizationRequest],
|
||||||
|
) -> Mapping[str, bool]:
|
||||||
|
decisions = {item.reference.key: False for item in requests}
|
||||||
|
if not isinstance(principal, ApiPrincipal):
|
||||||
|
return decisions
|
||||||
|
may_read_all = principal.has(READ_SCOPE) or principal.has(ADMIN_SCOPE)
|
||||||
|
may_participate = principal.has(PARTICIPATE_SCOPE)
|
||||||
|
if not may_read_all and not may_participate:
|
||||||
|
return decisions
|
||||||
|
eligible = tuple(
|
||||||
|
item
|
||||||
|
for item in requests
|
||||||
|
if item.reference.tenant_id == principal.tenant_id
|
||||||
|
and item.reference.module_id == "forms_runtime"
|
||||||
|
and item.reference.resource_type == RESOURCE_TYPE
|
||||||
|
)
|
||||||
|
ids = {item.reference.resource_id for item in eligible}
|
||||||
|
identities = {
|
||||||
|
row.instance_id: row
|
||||||
|
for row in _session(session).scalars(
|
||||||
|
select(FormInstanceIdentity).where(
|
||||||
|
FormInstanceIdentity.tenant_id == principal.tenant_id,
|
||||||
|
FormInstanceIdentity.instance_id.in_(ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} if ids else {}
|
||||||
|
actors = {
|
||||||
|
str(value)
|
||||||
|
for value in (
|
||||||
|
principal.account_id,
|
||||||
|
principal.identity_id,
|
||||||
|
principal.membership_id,
|
||||||
|
)
|
||||||
|
if value
|
||||||
|
}
|
||||||
|
for item in eligible:
|
||||||
|
identity = identities.get(item.reference.resource_id)
|
||||||
|
decisions[item.reference.key] = bool(
|
||||||
|
identity
|
||||||
|
and (may_read_all or identity.created_by in actors)
|
||||||
|
)
|
||||||
|
return decisions
|
||||||
|
|
||||||
|
def index_changes_for_event(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
event: PlatformEvent,
|
||||||
|
delivery_key: str,
|
||||||
|
) -> Sequence[SearchIndexChange]:
|
||||||
|
if (
|
||||||
|
event.module_id != "forms_runtime"
|
||||||
|
or event.tenant is None
|
||||||
|
or event.resource is None
|
||||||
|
or event.resource.type != RESOURCE_TYPE
|
||||||
|
or event.resource.id is None
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
db = _session(session)
|
||||||
|
result = db.execute(
|
||||||
|
select(FormInstanceRevision, FormInstanceIdentity)
|
||||||
|
.join(
|
||||||
|
FormInstanceIdentity,
|
||||||
|
FormInstanceIdentity.id == FormInstanceRevision.identity_id,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
FormInstanceRevision.tenant_id == event.tenant.id,
|
||||||
|
FormInstanceRevision.instance_id == event.resource.id,
|
||||||
|
FormInstanceRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
).one_or_none()
|
||||||
|
cursor = event.event_id
|
||||||
|
document = (
|
||||||
|
_document(result[0], identity=result[1], change_cursor=cursor)
|
||||||
|
if result is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
reference = SearchResourceReference(
|
||||||
|
tenant_id=event.tenant.id,
|
||||||
|
module_id="forms_runtime",
|
||||||
|
resource_type=RESOURCE_TYPE,
|
||||||
|
resource_id=event.resource.id,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
SearchIndexChange(
|
||||||
|
change_id=f"{delivery_key}:{PROVIDER_ID}",
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
kind="upsert" if document is not None else "delete",
|
||||||
|
reference=reference,
|
||||||
|
source_revision=(document.source_revision if document else cursor),
|
||||||
|
cursor=cursor,
|
||||||
|
document=document,
|
||||||
|
occurred_at=event.occurred_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_forms_runtime_search_source(
|
||||||
|
_context: ModuleContext,
|
||||||
|
) -> FormsRuntimeSearchSource:
|
||||||
|
return FormsRuntimeSearchSource()
|
||||||
|
|
||||||
|
|
||||||
|
def _document(
|
||||||
|
row: FormInstanceRevision,
|
||||||
|
*,
|
||||||
|
identity: FormInstanceIdentity,
|
||||||
|
change_cursor: str | None = None,
|
||||||
|
) -> SearchDocument:
|
||||||
|
snapshot = dict(row.snapshot or {})
|
||||||
|
definition = _mapping(snapshot.get("definition_ref"))
|
||||||
|
definition_label = _text(definition.get("label"))
|
||||||
|
receipt_id = _text(snapshot.get("receipt_id"))
|
||||||
|
service = _mapping(snapshot.get("service_ref"))
|
||||||
|
service_id = _text(service.get("object_id"))
|
||||||
|
definition_id = identity.definition_id
|
||||||
|
title = definition_label or f"Form {definition_id}"
|
||||||
|
body = " ".join(
|
||||||
|
value
|
||||||
|
for value in (
|
||||||
|
definition_id,
|
||||||
|
identity.definition_revision,
|
||||||
|
receipt_id,
|
||||||
|
service_id,
|
||||||
|
row.status,
|
||||||
|
)
|
||||||
|
if value
|
||||||
|
)
|
||||||
|
tokens = [f"scope:{READ_SCOPE}", f"scope:{ADMIN_SCOPE}"]
|
||||||
|
if identity.created_by:
|
||||||
|
tokens.extend(
|
||||||
|
(
|
||||||
|
f"account:{identity.created_by}",
|
||||||
|
f"identity:{identity.created_by}",
|
||||||
|
f"membership:{identity.created_by}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return SearchDocument(
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
module_id="forms_runtime",
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
resource_type=RESOURCE_TYPE,
|
||||||
|
resource_id=row.instance_id,
|
||||||
|
title=title[:500],
|
||||||
|
url=f"/forms-runtime/{quote(row.instance_id, safe='')}",
|
||||||
|
summary=(f"{row.status} - receipt {receipt_id}" if receipt_id else row.status),
|
||||||
|
body=body[:200_000],
|
||||||
|
keywords=tuple(
|
||||||
|
value[:200]
|
||||||
|
for value in (definition_id, identity.definition_revision, row.status)
|
||||||
|
if value
|
||||||
|
),
|
||||||
|
visibility="restricted",
|
||||||
|
acl_tokens=tuple(dict.fromkeys(tokens)),
|
||||||
|
metadata={
|
||||||
|
"definition_id": definition_id,
|
||||||
|
"definition_revision": identity.definition_revision,
|
||||||
|
"status": row.status,
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
"protected_values_indexed": False,
|
||||||
|
},
|
||||||
|
source_revision=str(row.revision),
|
||||||
|
change_cursor=change_cursor,
|
||||||
|
source_updated_at=row.recorded_at,
|
||||||
|
requires_authorization_recheck=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: object) -> dict[str, object]:
|
||||||
|
return dict(value) if isinstance(value, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: object) -> str | None:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_source(provider_id: str, resource_type: str) -> None:
|
||||||
|
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
|
||||||
|
raise ValueError("Unsupported Forms Runtime search source.")
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Forms Runtime search requires a SQLAlchemy session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FormsRuntimeSearchSource",
|
||||||
|
"PROVIDER_ID",
|
||||||
|
"RESOURCE_TYPE",
|
||||||
|
"create_forms_runtime_search_source",
|
||||||
|
]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import replace
|
||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -33,11 +34,15 @@ from govoplan_core.core.institutional import (
|
|||||||
ServiceLaunchResult,
|
ServiceLaunchResult,
|
||||||
)
|
)
|
||||||
from govoplan_forms_runtime.backend.db.models import (
|
from govoplan_forms_runtime.backend.db.models import (
|
||||||
|
FormAssistedConfirmation,
|
||||||
FormInstanceEvent,
|
FormInstanceEvent,
|
||||||
FormInstanceIdentity,
|
FormInstanceIdentity,
|
||||||
FormInstanceRevision,
|
FormInstanceRevision,
|
||||||
|
FormIntakeSession,
|
||||||
)
|
)
|
||||||
|
from govoplan_forms_runtime.backend.assisted import assisted_submission_payload_sha256
|
||||||
from govoplan_forms_runtime.backend.domain import FormInstance
|
from govoplan_forms_runtime.backend.domain import FormInstance
|
||||||
|
from govoplan_forms_runtime.backend.evidence import FormEvidenceCoordinator
|
||||||
|
|
||||||
|
|
||||||
CAPABILITY_FORMS_RUNTIME_REGISTRY = "forms_runtime.registry"
|
CAPABILITY_FORMS_RUNTIME_REGISTRY = "forms_runtime.registry"
|
||||||
@@ -82,6 +87,7 @@ class FormRuntimePolicyEvaluator(Protocol):
|
|||||||
class FormRuntimeService:
|
class FormRuntimeService:
|
||||||
def __init__(self, registry: object | None) -> None:
|
def __init__(self, registry: object | None) -> None:
|
||||||
self._registry = registry
|
self._registry = registry
|
||||||
|
self._evidence = FormEvidenceCoordinator(registry)
|
||||||
|
|
||||||
def create_instance(
|
def create_instance(
|
||||||
self,
|
self,
|
||||||
@@ -200,6 +206,27 @@ class FormRuntimeService:
|
|||||||
changed_by=actor_id,
|
changed_by=actor_id,
|
||||||
metadata=dict(metadata or {}),
|
metadata=dict(metadata or {}),
|
||||||
)
|
)
|
||||||
|
evidence_diagnostics, evidence_snapshots = self._inspect_evidence(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance=instance,
|
||||||
|
definition=definition,
|
||||||
|
values=clean_values,
|
||||||
|
attachment_refs=attachments,
|
||||||
|
signature_refs=signatures,
|
||||||
|
purpose="start Form instance",
|
||||||
|
final=False,
|
||||||
|
observed_at=recorded_at,
|
||||||
|
)
|
||||||
|
if evidence_diagnostics or evidence_snapshots:
|
||||||
|
instance = replace(
|
||||||
|
instance,
|
||||||
|
validation_results=(*diagnostics, *evidence_diagnostics),
|
||||||
|
metadata={
|
||||||
|
**dict(instance.metadata),
|
||||||
|
"evidence_verification": list(evidence_snapshots),
|
||||||
|
},
|
||||||
|
)
|
||||||
return _record_instance(
|
return _record_instance(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
@@ -305,7 +332,7 @@ class FormRuntimeService:
|
|||||||
action="submit",
|
action="submit",
|
||||||
instance=current,
|
instance=current,
|
||||||
)
|
)
|
||||||
return self._revise(
|
submitted = self._revise(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
current=current,
|
current=current,
|
||||||
@@ -324,6 +351,31 @@ class FormRuntimeService:
|
|||||||
definition=definition,
|
definition=definition,
|
||||||
allowed_current_statuses=("started", "draft"),
|
allowed_current_statuses=("started", "draft"),
|
||||||
)
|
)
|
||||||
|
if not submitted.replayed:
|
||||||
|
from govoplan_forms_runtime.backend.status_access import (
|
||||||
|
FormStatusAccessService,
|
||||||
|
)
|
||||||
|
|
||||||
|
FormStatusAccessService(self._registry).ensure_for_submission(
|
||||||
|
session,
|
||||||
|
instance=submitted,
|
||||||
|
issued_at=recorded_at,
|
||||||
|
)
|
||||||
|
assisted_session = (
|
||||||
|
session.query(FormIntakeSession)
|
||||||
|
.filter(
|
||||||
|
FormIntakeSession.tenant_id == submitted.tenant_id,
|
||||||
|
FormIntakeSession.instance_id == submitted.instance_id,
|
||||||
|
FormIntakeSession.mode == "assisted",
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if assisted_session is not None and assisted_session.status != "submitted":
|
||||||
|
assisted_session.status = "submitted"
|
||||||
|
assisted_session.submitted_at = recorded_at
|
||||||
|
session.add(assisted_session)
|
||||||
|
session.flush()
|
||||||
|
return submitted
|
||||||
|
|
||||||
def transition_instance(
|
def transition_instance(
|
||||||
self,
|
self,
|
||||||
@@ -619,6 +671,111 @@ class FormRuntimeService:
|
|||||||
for row in rows
|
for row in rows
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def create_evidence_grant(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
instance_id: str,
|
||||||
|
expected_revision: int,
|
||||||
|
provider_id: str,
|
||||||
|
custodian_ref: str,
|
||||||
|
purpose: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
expires_at: datetime,
|
||||||
|
max_size_bytes: int | None = None,
|
||||||
|
allowed_content_types: Sequence[str] = (),
|
||||||
|
attachment_refs: Sequence[EvidenceReference] = (),
|
||||||
|
allow_all: bool = False,
|
||||||
|
):
|
||||||
|
current, _identity = _current_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
lock=True,
|
||||||
|
allow_all=allow_all,
|
||||||
|
)
|
||||||
|
if current.revision != expected_revision:
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"Form instance revision conflict: the expected revision is stale."
|
||||||
|
)
|
||||||
|
definition = self._definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
reference=current.definition_ref,
|
||||||
|
effective_at=current.recorded_at,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return self._evidence.create_upload_grant(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance=current,
|
||||||
|
definition=definition,
|
||||||
|
provider_id=provider_id,
|
||||||
|
custodian_ref=custodian_ref,
|
||||||
|
purpose=purpose,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
expires_at=expires_at,
|
||||||
|
max_size_bytes=max_size_bytes,
|
||||||
|
allowed_content_types=allowed_content_types,
|
||||||
|
attachment_refs=attachment_refs,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise FormRuntimeError(str(exc)) from exc
|
||||||
|
|
||||||
|
def acknowledge(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
instance_id: str,
|
||||||
|
expected_revision: int,
|
||||||
|
statement_id: str,
|
||||||
|
statement_version: str,
|
||||||
|
values: Mapping[str, object],
|
||||||
|
attachment_refs: Sequence[EvidenceReference],
|
||||||
|
accepted_at: datetime,
|
||||||
|
idempotency_key: str,
|
||||||
|
allow_all: bool = False,
|
||||||
|
) -> EvidenceReference:
|
||||||
|
current, _identity = _current_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
lock=True,
|
||||||
|
allow_all=allow_all,
|
||||||
|
)
|
||||||
|
definition = self._definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
reference=current.definition_ref,
|
||||||
|
effective_at=accepted_at,
|
||||||
|
)
|
||||||
|
clean_values = normalize_form_values(
|
||||||
|
definition,
|
||||||
|
_mapping_copy(values, "Form acknowledgement values"),
|
||||||
|
)
|
||||||
|
attachments = tuple(attachment_refs)
|
||||||
|
if any(item.tenant_id != current.tenant_id for item in attachments):
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"Form acknowledgement attachments cannot cross tenants."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return self._evidence.create_acknowledgement(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance=current,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
statement_id=statement_id,
|
||||||
|
statement_version=statement_version,
|
||||||
|
values=clean_values,
|
||||||
|
attachment_refs=attachments,
|
||||||
|
accepted_at=accepted_at,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise FormRuntimeError(str(exc)) from exc
|
||||||
|
|
||||||
def _revise(
|
def _revise(
|
||||||
self,
|
self,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -657,6 +814,19 @@ class FormRuntimeService:
|
|||||||
signature_refs=signatures,
|
signature_refs=signatures,
|
||||||
final=final_validation,
|
final=final_validation,
|
||||||
)
|
)
|
||||||
|
evidence_diagnostics, evidence_snapshots = self._inspect_evidence(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance=current,
|
||||||
|
definition=definition,
|
||||||
|
values=clean_values,
|
||||||
|
attachment_refs=attachments,
|
||||||
|
signature_refs=signatures,
|
||||||
|
purpose=operation.replace("_", " "),
|
||||||
|
final=final_validation,
|
||||||
|
observed_at=recorded_at,
|
||||||
|
)
|
||||||
|
diagnostics = (*diagnostics, *evidence_diagnostics)
|
||||||
request = {
|
request = {
|
||||||
"operation": operation,
|
"operation": operation,
|
||||||
"instance_id": current.instance_id,
|
"instance_id": current.instance_id,
|
||||||
@@ -679,6 +849,46 @@ class FormRuntimeService:
|
|||||||
)
|
)
|
||||||
if replay is not None:
|
if replay is not None:
|
||||||
return replay
|
return replay
|
||||||
|
if operation == "submitted" and _is_assisted_instance(current):
|
||||||
|
assisted_session = (
|
||||||
|
session.query(FormIntakeSession)
|
||||||
|
.filter(
|
||||||
|
FormIntakeSession.tenant_id == current.tenant_id,
|
||||||
|
FormIntakeSession.instance_id == current.instance_id,
|
||||||
|
FormIntakeSession.mode == "assisted",
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
assisted_session is None
|
||||||
|
or assisted_session.status != "active"
|
||||||
|
or _aware(assisted_session.expires_at) <= recorded_at
|
||||||
|
):
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"Assisted intake submission requires an active, unexpired session."
|
||||||
|
)
|
||||||
|
expected_confirmation_digest = assisted_submission_payload_sha256(
|
||||||
|
current,
|
||||||
|
values=clean_values,
|
||||||
|
attachment_refs=attachments,
|
||||||
|
signature_refs=signatures,
|
||||||
|
)
|
||||||
|
confirmation = (
|
||||||
|
session.query(FormAssistedConfirmation)
|
||||||
|
.filter(
|
||||||
|
FormAssistedConfirmation.tenant_id == current.tenant_id,
|
||||||
|
FormAssistedConfirmation.instance_id == current.instance_id,
|
||||||
|
FormAssistedConfirmation.instance_revision == current.revision,
|
||||||
|
FormAssistedConfirmation.payload_sha256
|
||||||
|
== expected_confirmation_digest,
|
||||||
|
)
|
||||||
|
.order_by(FormAssistedConfirmation.confirmed_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if confirmation is None:
|
||||||
|
raise FormRuntimeError(
|
||||||
|
"Assisted intake submission requires a current read-back confirmation for the exact values and evidence."
|
||||||
|
)
|
||||||
if allowed_current_statuses is not None and current.status not in set(
|
if allowed_current_statuses is not None and current.status not in set(
|
||||||
allowed_current_statuses
|
allowed_current_statuses
|
||||||
):
|
):
|
||||||
@@ -716,7 +926,14 @@ class FormRuntimeService:
|
|||||||
change_reason=clean_reason,
|
change_reason=clean_reason,
|
||||||
created_by=current.created_by,
|
created_by=current.created_by,
|
||||||
changed_by=actor_id,
|
changed_by=actor_id,
|
||||||
metadata=current.metadata,
|
metadata=(
|
||||||
|
{
|
||||||
|
**dict(current.metadata),
|
||||||
|
"evidence_verification": list(evidence_snapshots),
|
||||||
|
}
|
||||||
|
if evidence_snapshots
|
||||||
|
else current.metadata
|
||||||
|
),
|
||||||
)
|
)
|
||||||
current_row = (
|
current_row = (
|
||||||
session.query(FormInstanceRevision)
|
session.query(FormInstanceRevision)
|
||||||
@@ -744,6 +961,38 @@ class FormRuntimeService:
|
|||||||
operation=operation,
|
operation=operation,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _inspect_evidence(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
instance: FormInstance,
|
||||||
|
definition: FormDefinition,
|
||||||
|
values: Mapping[str, object],
|
||||||
|
attachment_refs: Sequence[EvidenceReference],
|
||||||
|
signature_refs: Sequence[EvidenceReference],
|
||||||
|
purpose: str,
|
||||||
|
final: bool,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> tuple[tuple[Mapping[str, object], ...], tuple[Mapping[str, object], ...]]:
|
||||||
|
if not attachment_refs and not signature_refs:
|
||||||
|
return (), ()
|
||||||
|
try:
|
||||||
|
return self._evidence.inspect(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance=instance,
|
||||||
|
definition=definition,
|
||||||
|
values=values,
|
||||||
|
attachment_refs=attachment_refs,
|
||||||
|
signature_refs=signature_refs,
|
||||||
|
purpose=purpose,
|
||||||
|
final=final,
|
||||||
|
observed_at=observed_at,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise FormRuntimeError(str(exc)) from exc
|
||||||
|
|
||||||
def _definition(
|
def _definition(
|
||||||
self,
|
self,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -942,6 +1191,11 @@ def validate_form_values(
|
|||||||
return tuple(diagnostics)
|
return tuple(diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_assisted_instance(instance: FormInstance) -> bool:
|
||||||
|
intake = instance.metadata.get("intake")
|
||||||
|
return isinstance(intake, Mapping) and intake.get("mode") == "assisted"
|
||||||
|
|
||||||
|
|
||||||
def normalize_form_values(
|
def normalize_form_values(
|
||||||
definition: FormDefinition,
|
definition: FormDefinition,
|
||||||
values: Mapping[str, object],
|
values: Mapping[str, object],
|
||||||
|
|||||||
@@ -0,0 +1,683 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_FORM_DEFINITIONS,
|
||||||
|
FormDefinition,
|
||||||
|
FormDefinitionProvider,
|
||||||
|
InstitutionalReference,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.notifications import (
|
||||||
|
NotificationDispatchRequest,
|
||||||
|
notification_dispatch_provider,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.db.models import (
|
||||||
|
FormInstanceEvent,
|
||||||
|
FormInstanceRevision,
|
||||||
|
FormStatusAccessGrant,
|
||||||
|
FormStatusAccessPolicy,
|
||||||
|
FormStatusAccessToken,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.domain import FormInstance
|
||||||
|
from govoplan_forms_runtime.backend.email_normalization import normalize_status_email as _normalize_email
|
||||||
|
|
||||||
|
|
||||||
|
STATUS_ACCESS_MODES = frozenset(
|
||||||
|
{"authenticated", "email_link", "permanent_link"}
|
||||||
|
)
|
||||||
|
PUBLIC_TIMELINE_STATUSES = frozenset(
|
||||||
|
{
|
||||||
|
"submitted",
|
||||||
|
"validated",
|
||||||
|
"needs_review",
|
||||||
|
"accepted",
|
||||||
|
"rejected",
|
||||||
|
"handed_off",
|
||||||
|
"archived",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DEFAULT_STATUS_TOKEN_TTL_SECONDS = 60 * 60
|
||||||
|
DEFAULT_STATUS_REQUEST_LIMIT_PER_HOUR = 5
|
||||||
|
|
||||||
|
|
||||||
|
class FormStatusAccessError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FormStatusUnavailable(FormStatusAccessError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FormStatusAccessService:
|
||||||
|
def __init__(self, registry: object | None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def list_policies(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
) -> tuple[FormStatusAccessPolicy, ...]:
|
||||||
|
return tuple(
|
||||||
|
session.query(FormStatusAccessPolicy)
|
||||||
|
.filter(
|
||||||
|
FormStatusAccessPolicy.tenant_id == _principal_tenant(principal)
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
FormStatusAccessPolicy.definition_id.asc(),
|
||||||
|
FormStatusAccessPolicy.definition_revision.asc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
def upsert_policy(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
definition_ref: InstitutionalReference,
|
||||||
|
mode: str,
|
||||||
|
enabled: bool,
|
||||||
|
email_field_key: str | None,
|
||||||
|
token_ttl_seconds: int = DEFAULT_STATUS_TOKEN_TTL_SECONDS,
|
||||||
|
request_limit_per_hour: int = DEFAULT_STATUS_REQUEST_LIMIT_PER_HOUR,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
metadata: Mapping[str, object] | None = None,
|
||||||
|
recorded_at: datetime,
|
||||||
|
) -> FormStatusAccessPolicy:
|
||||||
|
tenant_id = _principal_tenant(principal)
|
||||||
|
actor_id = _principal_actor(principal)
|
||||||
|
_require_aware(recorded_at, "Status access policy recorded_at")
|
||||||
|
if mode not in STATUS_ACCESS_MODES:
|
||||||
|
raise FormStatusAccessError(
|
||||||
|
f"Unsupported Form status access mode: {mode!r}."
|
||||||
|
)
|
||||||
|
if definition_ref.tenant_id != tenant_id:
|
||||||
|
raise FormStatusAccessError(
|
||||||
|
"Status access policy cannot reference another tenant."
|
||||||
|
)
|
||||||
|
if not 300 <= token_ttl_seconds <= 7 * 24 * 60 * 60:
|
||||||
|
raise FormStatusAccessError(
|
||||||
|
"Short-lived status links must expire between five minutes and seven days."
|
||||||
|
)
|
||||||
|
if not 1 <= request_limit_per_hour <= 60:
|
||||||
|
raise FormStatusAccessError(
|
||||||
|
"Status link requests must be limited to between 1 and 60 per hour."
|
||||||
|
)
|
||||||
|
definition = self._definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
reference=definition_ref,
|
||||||
|
effective_at=recorded_at,
|
||||||
|
)
|
||||||
|
if definition.publication_state != "published":
|
||||||
|
raise FormStatusAccessError(
|
||||||
|
"Only a published Form can expose applicant status."
|
||||||
|
)
|
||||||
|
clean_email_field = str(email_field_key or "").strip() or None
|
||||||
|
if mode == "email_link":
|
||||||
|
if clean_email_field is None:
|
||||||
|
raise FormStatusAccessError(
|
||||||
|
"Short-lived email status links require an exact Form email field."
|
||||||
|
)
|
||||||
|
field = next(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in definition.fields
|
||||||
|
if candidate.key == clean_email_field
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if field is None:
|
||||||
|
raise FormStatusAccessError(
|
||||||
|
"The configured status email field does not exist on the exact Form revision."
|
||||||
|
)
|
||||||
|
if field.value_type != "email":
|
||||||
|
raise FormStatusAccessError(
|
||||||
|
"Short-lived status links require a Form field with the email value type."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
clean_email_field = None
|
||||||
|
|
||||||
|
policy = (
|
||||||
|
session.query(FormStatusAccessPolicy)
|
||||||
|
.filter(
|
||||||
|
FormStatusAccessPolicy.tenant_id == tenant_id,
|
||||||
|
FormStatusAccessPolicy.definition_id
|
||||||
|
== definition.reference.object_id,
|
||||||
|
FormStatusAccessPolicy.definition_revision
|
||||||
|
== str(definition.reference.version),
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if policy is None:
|
||||||
|
if expected_revision is not None:
|
||||||
|
raise FormStatusAccessError(
|
||||||
|
"Status access policy revision conflict: the policy does not exist."
|
||||||
|
)
|
||||||
|
policy = FormStatusAccessPolicy(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
policy_id=str(uuid.uuid4()),
|
||||||
|
definition_id=definition.reference.object_id,
|
||||||
|
definition_revision=str(definition.reference.version),
|
||||||
|
mode=mode,
|
||||||
|
enabled=bool(enabled),
|
||||||
|
revision=1,
|
||||||
|
email_field_key=clean_email_field,
|
||||||
|
token_ttl_seconds=token_ttl_seconds,
|
||||||
|
request_limit_per_hour=request_limit_per_hour,
|
||||||
|
created_by=actor_id,
|
||||||
|
updated_by=actor_id,
|
||||||
|
details={
|
||||||
|
**dict(metadata or {}),
|
||||||
|
"definition_title": definition.title,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if expected_revision is None or policy.revision != expected_revision:
|
||||||
|
raise FormStatusAccessError(
|
||||||
|
"Status access policy revision conflict: reload before saving."
|
||||||
|
)
|
||||||
|
policy.mode = mode
|
||||||
|
policy.enabled = bool(enabled)
|
||||||
|
policy.revision += 1
|
||||||
|
policy.email_field_key = clean_email_field
|
||||||
|
policy.token_ttl_seconds = token_ttl_seconds
|
||||||
|
policy.request_limit_per_hour = request_limit_per_hour
|
||||||
|
policy.updated_by = actor_id
|
||||||
|
policy.details = {
|
||||||
|
**dict(metadata or {}),
|
||||||
|
"definition_title": definition.title,
|
||||||
|
}
|
||||||
|
session.add(policy)
|
||||||
|
session.flush()
|
||||||
|
return policy
|
||||||
|
|
||||||
|
def ensure_for_submission(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
instance: FormInstance,
|
||||||
|
issued_at: datetime,
|
||||||
|
) -> FormStatusAccessGrant | None:
|
||||||
|
_require_aware(issued_at, "Status access issuance time")
|
||||||
|
existing = (
|
||||||
|
session.query(FormStatusAccessGrant)
|
||||||
|
.filter(
|
||||||
|
FormStatusAccessGrant.tenant_id == instance.tenant_id,
|
||||||
|
FormStatusAccessGrant.instance_id == instance.instance_id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
policy = (
|
||||||
|
session.query(FormStatusAccessPolicy)
|
||||||
|
.filter(
|
||||||
|
FormStatusAccessPolicy.tenant_id == instance.tenant_id,
|
||||||
|
FormStatusAccessPolicy.definition_id
|
||||||
|
== instance.definition_ref.object_id,
|
||||||
|
FormStatusAccessPolicy.definition_revision
|
||||||
|
== str(instance.definition_ref.version),
|
||||||
|
FormStatusAccessPolicy.enabled.is_(True),
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if policy is None:
|
||||||
|
return None
|
||||||
|
grant_id = str(uuid.uuid4())
|
||||||
|
tracking_id = secrets.token_urlsafe(24)
|
||||||
|
applicant_actor_id = _applicant_actor(instance)
|
||||||
|
clean_email = None
|
||||||
|
if policy.mode == "email_link" and policy.email_field_key:
|
||||||
|
clean_email = _normalize_email(instance.values.get(policy.email_field_key))
|
||||||
|
if policy.mode == "authenticated" and applicant_actor_id is None:
|
||||||
|
return None
|
||||||
|
if policy.mode == "email_link" and clean_email is None:
|
||||||
|
return None
|
||||||
|
grant = FormStatusAccessGrant(
|
||||||
|
tenant_id=instance.tenant_id,
|
||||||
|
grant_id=grant_id,
|
||||||
|
policy_id=policy.id,
|
||||||
|
instance_id=instance.instance_id,
|
||||||
|
tracking_id=tracking_id,
|
||||||
|
mode=policy.mode,
|
||||||
|
applicant_actor_id=applicant_actor_id,
|
||||||
|
recipient_email_sha256=(
|
||||||
|
_email_digest(grant_id, clean_email) if clean_email else None
|
||||||
|
),
|
||||||
|
token_ttl_seconds=policy.token_ttl_seconds,
|
||||||
|
request_limit_per_hour=policy.request_limit_per_hour,
|
||||||
|
request_window_started_at=None,
|
||||||
|
request_window_count=0,
|
||||||
|
issued_at=issued_at,
|
||||||
|
details={
|
||||||
|
"definition_id": instance.definition_ref.object_id,
|
||||||
|
"definition_revision": str(instance.definition_ref.version),
|
||||||
|
"definition_title": policy.details.get("definition_title")
|
||||||
|
or instance.definition_ref.label
|
||||||
|
or "Application",
|
||||||
|
"email_field_key": policy.email_field_key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.add(grant)
|
||||||
|
session.flush()
|
||||||
|
return grant
|
||||||
|
|
||||||
|
def access_summary_for_instance(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
instance_id: str,
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
grant = (
|
||||||
|
session.query(FormStatusAccessGrant)
|
||||||
|
.filter(
|
||||||
|
FormStatusAccessGrant.tenant_id == tenant_id,
|
||||||
|
FormStatusAccessGrant.instance_id == instance_id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if grant is None:
|
||||||
|
return None
|
||||||
|
policy = session.get(FormStatusAccessPolicy, grant.policy_id)
|
||||||
|
return {
|
||||||
|
"tracking_id": grant.tracking_id,
|
||||||
|
"mode": grant.mode,
|
||||||
|
"href": f"/portal/status/{grant.tracking_id}",
|
||||||
|
"enabled": bool(
|
||||||
|
policy is not None
|
||||||
|
and policy.enabled
|
||||||
|
and grant.revoked_at is None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def tenant_id_for_tracking_id(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tracking_id: str,
|
||||||
|
) -> str | None:
|
||||||
|
grant = (
|
||||||
|
session.query(FormStatusAccessGrant)
|
||||||
|
.filter(
|
||||||
|
FormStatusAccessGrant.tracking_id
|
||||||
|
== str(tracking_id or "").strip()
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
return grant.tenant_id if grant is not None else None
|
||||||
|
|
||||||
|
def public_access_challenge(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tracking_id: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
grant, policy = self._active_grant(session, tracking_id=tracking_id)
|
||||||
|
return {
|
||||||
|
"tracking_id": grant.tracking_id,
|
||||||
|
"mode": grant.mode,
|
||||||
|
"authenticated_available": grant.applicant_actor_id is not None,
|
||||||
|
"email_link_available": (
|
||||||
|
grant.mode == "email_link"
|
||||||
|
and grant.recipient_email_sha256 is not None
|
||||||
|
),
|
||||||
|
"token_ttl_seconds": (
|
||||||
|
grant.token_ttl_seconds if grant.mode == "email_link" else None
|
||||||
|
),
|
||||||
|
"policy_revision": policy.revision,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_authenticated_projection(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tracking_id: str,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
grant, _policy = self._active_grant(session, tracking_id=tracking_id)
|
||||||
|
if (
|
||||||
|
grant.applicant_actor_id is None
|
||||||
|
or grant.applicant_actor_id not in _principal_actor_ids(principal)
|
||||||
|
):
|
||||||
|
raise FormStatusUnavailable("Application status is unavailable.")
|
||||||
|
grant.last_accessed_at = observed_at
|
||||||
|
session.add(grant)
|
||||||
|
return self._projection(session, grant=grant)
|
||||||
|
|
||||||
|
def get_public_projection(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tracking_id: str,
|
||||||
|
token: str | None,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require_aware(observed_at, "Status observation time")
|
||||||
|
grant, _policy = self._active_grant(session, tracking_id=tracking_id)
|
||||||
|
if grant.mode == "permanent_link":
|
||||||
|
pass
|
||||||
|
elif grant.mode == "email_link" and token:
|
||||||
|
token_row = (
|
||||||
|
session.query(FormStatusAccessToken)
|
||||||
|
.filter(
|
||||||
|
FormStatusAccessToken.tenant_id == grant.tenant_id,
|
||||||
|
FormStatusAccessToken.grant_id == grant.id,
|
||||||
|
FormStatusAccessToken.token_sha256 == _token_digest(token),
|
||||||
|
FormStatusAccessToken.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if token_row is None or _aware(token_row.expires_at) <= observed_at:
|
||||||
|
raise FormStatusUnavailable("Application status is unavailable.")
|
||||||
|
token_row.last_used_at = observed_at
|
||||||
|
session.add(token_row)
|
||||||
|
else:
|
||||||
|
raise FormStatusUnavailable("Application status is unavailable.")
|
||||||
|
grant.last_accessed_at = observed_at
|
||||||
|
session.add(grant)
|
||||||
|
return self._projection(session, grant=grant)
|
||||||
|
|
||||||
|
def request_email_link(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tracking_id: str,
|
||||||
|
email: str,
|
||||||
|
requested_at: datetime,
|
||||||
|
) -> bool:
|
||||||
|
"""Issue a link when eligible; callers must always return a generic response."""
|
||||||
|
_require_aware(requested_at, "Status link request time")
|
||||||
|
try:
|
||||||
|
grant, _policy = self._active_grant(
|
||||||
|
session, tracking_id=tracking_id, lock=True
|
||||||
|
)
|
||||||
|
except FormStatusUnavailable:
|
||||||
|
return False
|
||||||
|
if grant.mode != "email_link" or grant.recipient_email_sha256 is None:
|
||||||
|
return False
|
||||||
|
if not _consume_request_limit(grant, now=requested_at):
|
||||||
|
session.add(grant)
|
||||||
|
return False
|
||||||
|
session.add(grant)
|
||||||
|
clean_email = _normalize_email(email)
|
||||||
|
if clean_email is None or not hmac.compare_digest(
|
||||||
|
grant.recipient_email_sha256,
|
||||||
|
_email_digest(grant.grant_id, clean_email),
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
provider = notification_dispatch_provider(self._registry)
|
||||||
|
if provider is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
session.query(FormStatusAccessToken).filter(
|
||||||
|
FormStatusAccessToken.tenant_id == grant.tenant_id,
|
||||||
|
FormStatusAccessToken.grant_id == grant.id,
|
||||||
|
FormStatusAccessToken.revoked_at.is_(None),
|
||||||
|
).update(
|
||||||
|
{FormStatusAccessToken.revoked_at: requested_at},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
secret = secrets.token_urlsafe(32)
|
||||||
|
token_row = FormStatusAccessToken(
|
||||||
|
tenant_id=grant.tenant_id,
|
||||||
|
token_id=str(uuid.uuid4()),
|
||||||
|
grant_id=grant.id,
|
||||||
|
token_sha256=_token_digest(secret),
|
||||||
|
issued_at=requested_at,
|
||||||
|
expires_at=requested_at
|
||||||
|
+ timedelta(seconds=grant.token_ttl_seconds),
|
||||||
|
details={"delivery": "notifications"},
|
||||||
|
)
|
||||||
|
session.add(token_row)
|
||||||
|
session.flush()
|
||||||
|
action_url = f"/portal/status/{grant.tracking_id}?token={secret}"
|
||||||
|
result = provider.enqueue_notification(
|
||||||
|
session,
|
||||||
|
NotificationDispatchRequest(
|
||||||
|
tenant_id=grant.tenant_id,
|
||||||
|
source_module="forms_runtime",
|
||||||
|
source_resource_type="form_status_access",
|
||||||
|
source_resource_id=grant.grant_id,
|
||||||
|
event_kind="forms_runtime.status_link.requested",
|
||||||
|
channel="mail",
|
||||||
|
recipient=clean_email,
|
||||||
|
recipient_type="email",
|
||||||
|
subject="Your application status link",
|
||||||
|
body_text=(
|
||||||
|
"Use the secure link to view the current status of your application. "
|
||||||
|
"The link expires automatically and replaces any earlier status link."
|
||||||
|
),
|
||||||
|
action_url=action_url,
|
||||||
|
payload={
|
||||||
|
"tracking_id": grant.tracking_id,
|
||||||
|
"expires_at": token_row.expires_at.isoformat(),
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"purpose": "application_status_access",
|
||||||
|
"token_persisted_as_digest": True,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
enqueue_delivery=True,
|
||||||
|
)
|
||||||
|
notification_id = str(result.get("id") or "").strip()
|
||||||
|
token_row.notification_id = notification_id or None
|
||||||
|
session.add(token_row)
|
||||||
|
session.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _projection(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
grant: FormStatusAccessGrant,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
current = (
|
||||||
|
session.query(FormInstanceRevision)
|
||||||
|
.filter(
|
||||||
|
FormInstanceRevision.tenant_id == grant.tenant_id,
|
||||||
|
FormInstanceRevision.instance_id == grant.instance_id,
|
||||||
|
FormInstanceRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if current is None:
|
||||||
|
raise FormStatusUnavailable("Application status is unavailable.")
|
||||||
|
snapshot = dict(current.snapshot or {})
|
||||||
|
events = (
|
||||||
|
session.query(FormInstanceEvent)
|
||||||
|
.filter(
|
||||||
|
FormInstanceEvent.tenant_id == grant.tenant_id,
|
||||||
|
FormInstanceEvent.instance_id == grant.instance_id,
|
||||||
|
FormInstanceEvent.status.in_(tuple(PUBLIC_TIMELINE_STATUSES)),
|
||||||
|
)
|
||||||
|
.order_by(FormInstanceEvent.occurred_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
timeline: list[dict[str, object]] = []
|
||||||
|
previous_status = ""
|
||||||
|
for event in events:
|
||||||
|
if event.status == previous_status:
|
||||||
|
continue
|
||||||
|
timeline.append(
|
||||||
|
{
|
||||||
|
"status": event.status,
|
||||||
|
"occurred_at": _aware(event.occurred_at).isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
previous_status = event.status
|
||||||
|
return {
|
||||||
|
"tracking_id": grant.tracking_id,
|
||||||
|
"title": str(grant.details.get("definition_title") or "Application"),
|
||||||
|
"status": current.status,
|
||||||
|
"updated_at": _aware(current.recorded_at).isoformat(),
|
||||||
|
"receipt_id": snapshot.get("receipt_id"),
|
||||||
|
"timeline": timeline,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _active_grant(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tracking_id: str,
|
||||||
|
lock: bool = False,
|
||||||
|
) -> tuple[FormStatusAccessGrant, FormStatusAccessPolicy]:
|
||||||
|
query = session.query(FormStatusAccessGrant).filter(
|
||||||
|
FormStatusAccessGrant.tracking_id == str(tracking_id or "").strip()
|
||||||
|
)
|
||||||
|
if lock:
|
||||||
|
query = query.with_for_update()
|
||||||
|
grant = query.one_or_none()
|
||||||
|
if grant is None or grant.revoked_at is not None:
|
||||||
|
raise FormStatusUnavailable("Application status is unavailable.")
|
||||||
|
policy = session.get(FormStatusAccessPolicy, grant.policy_id)
|
||||||
|
if policy is None or not policy.enabled:
|
||||||
|
raise FormStatusUnavailable("Application status is unavailable.")
|
||||||
|
return grant, policy
|
||||||
|
|
||||||
|
def _definition(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
reference: InstitutionalReference,
|
||||||
|
effective_at: datetime,
|
||||||
|
) -> FormDefinition:
|
||||||
|
provider = _capability(self._registry, CAPABILITY_FORM_DEFINITIONS)
|
||||||
|
if not isinstance(provider, FormDefinitionProvider):
|
||||||
|
raise FormStatusAccessError("The Forms definition provider is unavailable.")
|
||||||
|
definition = provider.get_form_definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
reference=reference,
|
||||||
|
effective_at=effective_at,
|
||||||
|
)
|
||||||
|
if definition is None or definition.reference != reference:
|
||||||
|
raise FormStatusAccessError("The exact Form definition is unavailable.")
|
||||||
|
return definition
|
||||||
|
|
||||||
|
|
||||||
|
def status_policy_payload(policy: FormStatusAccessPolicy) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"policy_id": policy.policy_id,
|
||||||
|
"definition_ref": {
|
||||||
|
"kind": "form",
|
||||||
|
"owner_module": "forms",
|
||||||
|
"object_id": policy.definition_id,
|
||||||
|
"tenant_id": policy.tenant_id,
|
||||||
|
"version": policy.definition_revision,
|
||||||
|
},
|
||||||
|
"mode": policy.mode,
|
||||||
|
"enabled": policy.enabled,
|
||||||
|
"revision": policy.revision,
|
||||||
|
"email_field_key": policy.email_field_key,
|
||||||
|
"token_ttl_seconds": policy.token_ttl_seconds,
|
||||||
|
"request_limit_per_hour": policy.request_limit_per_hour,
|
||||||
|
"metadata": dict(policy.details),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _applicant_actor(instance: FormInstance) -> str | None:
|
||||||
|
intake = instance.metadata.get("intake")
|
||||||
|
if isinstance(intake, Mapping) and intake.get("mode") == "assisted":
|
||||||
|
affected = str(intake.get("affected_party_ref") or "").strip()
|
||||||
|
if affected.startswith("account:") and len(affected) > len("account:"):
|
||||||
|
return affected[len("account:") :]
|
||||||
|
return None
|
||||||
|
actor_id = str(instance.created_by or "").strip()
|
||||||
|
if not actor_id or actor_id.startswith("form-public:"):
|
||||||
|
return None
|
||||||
|
return actor_id
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_request_limit(grant: FormStatusAccessGrant, *, now: datetime) -> bool:
|
||||||
|
window = (
|
||||||
|
_aware(grant.request_window_started_at)
|
||||||
|
if grant.request_window_started_at is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if window is None or now - window >= timedelta(hours=1):
|
||||||
|
grant.request_window_started_at = now
|
||||||
|
grant.request_window_count = 1
|
||||||
|
return True
|
||||||
|
if grant.request_window_count >= grant.request_limit_per_hour:
|
||||||
|
return False
|
||||||
|
grant.request_window_count += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _email_digest(grant_id: str, email: str) -> str:
|
||||||
|
return hashlib.sha256(f"{grant_id}\0{email}".encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _token_digest(token: str) -> str:
|
||||||
|
clean = str(token or "").strip()
|
||||||
|
if len(clean) < 32:
|
||||||
|
raise FormStatusUnavailable("Application status is unavailable.")
|
||||||
|
return hashlib.sha256(clean.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_tenant(principal: object) -> str:
|
||||||
|
value = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||||
|
if not value:
|
||||||
|
raise FormStatusAccessError("Status access requires a tenant-bound principal.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_actor(principal: object) -> str:
|
||||||
|
values = _principal_actor_ids(principal)
|
||||||
|
if not values:
|
||||||
|
raise FormStatusAccessError("Status access requires an acting identity.")
|
||||||
|
return values[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_actor_ids(principal: object) -> tuple[str, ...]:
|
||||||
|
values: list[str] = []
|
||||||
|
for name in ("account_id", "identity_id"):
|
||||||
|
value = str(getattr(principal, name, "") or "").strip()
|
||||||
|
if value and value not in values:
|
||||||
|
values.append(value)
|
||||||
|
return tuple(values)
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(registry: object | None, name: str) -> object | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(name):
|
||||||
|
return None
|
||||||
|
if hasattr(registry, "require_capability"):
|
||||||
|
return registry.require_capability(name)
|
||||||
|
if hasattr(registry, "capability"):
|
||||||
|
return registry.capability(name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime) -> datetime:
|
||||||
|
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_aware(value: datetime, label: str) -> None:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
raise FormStatusAccessError(f"{label} must include a timezone.")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_STATUS_REQUEST_LIMIT_PER_HOUR",
|
||||||
|
"DEFAULT_STATUS_TOKEN_TTL_SECONDS",
|
||||||
|
"FormStatusAccessError",
|
||||||
|
"FormStatusAccessService",
|
||||||
|
"FormStatusUnavailable",
|
||||||
|
"STATUS_ACCESS_MODES",
|
||||||
|
"status_policy_payload",
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
documentation_structured_translation_issues,
|
||||||
|
localizable_documentation_metadata_keys,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class FormsRuntimeDocumentationTests(unittest.TestCase):
|
||||||
|
def test_german_reference_documentation_is_complete(self) -> None:
|
||||||
|
topics = manifest.documentation
|
||||||
|
self.assertEqual(3, len(topics))
|
||||||
|
for topic in topics:
|
||||||
|
translation = topic.translations.get("de", {})
|
||||||
|
self.assertTrue(translation.get("title"), topic.id)
|
||||||
|
self.assertTrue(translation.get("summary"), topic.id)
|
||||||
|
self.assertTrue(translation.get("body"), topic.id)
|
||||||
|
if localizable_documentation_metadata_keys(topic):
|
||||||
|
self.assertEqual("1", topic.structured_translation_version, topic.id)
|
||||||
|
self.assertIn("de", topic.structured_translations, topic.id)
|
||||||
|
self.assertEqual((), documentation_structured_translation_issues(topic))
|
||||||
|
|
||||||
|
kinds = {topic.metadata.get("kind") for topic in topics}
|
||||||
|
self.assertIn("workflow", kinds)
|
||||||
|
self.assertIn("reference", kinds)
|
||||||
|
workflow = next(topic for topic in topics if topic.metadata.get("kind") == "workflow")
|
||||||
|
self.assertTrue(workflow.conditions)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,701 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarProvider,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
create_data_subject_request,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.db.models import (
|
||||||
|
FormAcknowledgement,
|
||||||
|
FormAssistedConfirmation,
|
||||||
|
FormHandoffEffect,
|
||||||
|
FormInstanceEvent,
|
||||||
|
FormInstanceIdentity,
|
||||||
|
FormInstanceRevision,
|
||||||
|
FormIntakeProfile,
|
||||||
|
FormIntakeSession,
|
||||||
|
FormStatusAccessGrant,
|
||||||
|
FormStatusAccessPolicy,
|
||||||
|
FormStatusAccessToken,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.dsar_provider import (
|
||||||
|
FORMS_RUNTIME_DSAR_CAPABILITY,
|
||||||
|
FormsRuntimeDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 21, 14, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider: FormsRuntimeDsarProvider,
|
||||||
|
*,
|
||||||
|
active: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.active = active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (FORMS_RUNTIME_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
self._assert_capability(name)
|
||||||
|
return "forms_runtime"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
active = self.active
|
||||||
|
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State",
|
||||||
|
(),
|
||||||
|
{"effective_modules": ("forms_runtime",) if active else ()},
|
||||||
|
)()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
self._assert_capability(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "forms_runtime"})(),)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_capability(name: str) -> None:
|
||||||
|
if name != FORMS_RUNTIME_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot(instance_id: str, *, name: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"instance_id": instance_id,
|
||||||
|
"definition_ref": {
|
||||||
|
"kind": "form",
|
||||||
|
"owner_module": "forms",
|
||||||
|
"object_id": "permit-form",
|
||||||
|
"version": "3",
|
||||||
|
},
|
||||||
|
"service_ref": {
|
||||||
|
"kind": "service",
|
||||||
|
"owner_module": "services",
|
||||||
|
"object_id": "permit-service",
|
||||||
|
"version": "2",
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"name": name,
|
||||||
|
"email": "subject@example.org",
|
||||||
|
"password_token": "credential-value-do-not-export",
|
||||||
|
"nested": {"address": "Example Street 1"},
|
||||||
|
},
|
||||||
|
"validation_results": [{"message": "private-validation-do-not-export"}],
|
||||||
|
"attachment_refs": [{"evidence_id": "private-attachment-do-not-export"}],
|
||||||
|
"signature_refs": [{"evidence_id": "private-signature-do-not-export"}],
|
||||||
|
"handoff_refs": [{"object_id": "case-1"}],
|
||||||
|
"receipt_id": "receipt-1",
|
||||||
|
"change_reason": "private-change-reason-do-not-export",
|
||||||
|
"metadata": {"secret": "private-metadata-do-not-export"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FormsRuntimeDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.provider = FormsRuntimeDsarProvider()
|
||||||
|
self.assertIsInstance(self.provider, DsarProvider)
|
||||||
|
self._seed_subject_instance()
|
||||||
|
self._seed_assisted_instance()
|
||||||
|
self._seed_unrelated_instances()
|
||||||
|
self._seed_operator_configuration()
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _seed_subject_instance(self) -> None:
|
||||||
|
identity = FormInstanceIdentity(
|
||||||
|
id="identity-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id="instance-1",
|
||||||
|
definition_id="permit-form",
|
||||||
|
definition_revision="3",
|
||||||
|
created_by="applicant-1",
|
||||||
|
)
|
||||||
|
self.session.add(identity)
|
||||||
|
self.session.flush()
|
||||||
|
first = FormInstanceRevision(
|
||||||
|
id="revision-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id="instance-1",
|
||||||
|
identity_id=identity.id,
|
||||||
|
revision=1,
|
||||||
|
status="draft",
|
||||||
|
recorded_at=NOW,
|
||||||
|
superseded_at=NOW + timedelta(minutes=1),
|
||||||
|
snapshot=_snapshot("instance-1", name="Ada Example draft"),
|
||||||
|
changed_by="applicant-1",
|
||||||
|
)
|
||||||
|
second = FormInstanceRevision(
|
||||||
|
id="revision-row-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id="instance-1",
|
||||||
|
identity_id=identity.id,
|
||||||
|
revision=2,
|
||||||
|
previous_revision_id=first.id,
|
||||||
|
status="submitted",
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
snapshot=_snapshot("instance-1", name="Ada Example"),
|
||||||
|
changed_by="applicant-1",
|
||||||
|
)
|
||||||
|
events = (
|
||||||
|
FormInstanceEvent(
|
||||||
|
id="event-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id="instance-1",
|
||||||
|
instance_revision=1,
|
||||||
|
event_id="event-public-1",
|
||||||
|
event_type="forms_runtime.instance.started",
|
||||||
|
status="draft",
|
||||||
|
occurred_at=NOW,
|
||||||
|
actor_id="applicant-1",
|
||||||
|
idempotency_key="private-event-key-do-not-export",
|
||||||
|
request_sha256="a" * 64,
|
||||||
|
payload={"secret": "private-event-payload-do-not-export"},
|
||||||
|
),
|
||||||
|
FormInstanceEvent(
|
||||||
|
id="event-row-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id="instance-1",
|
||||||
|
instance_revision=2,
|
||||||
|
event_id="event-public-2",
|
||||||
|
event_type="forms_runtime.instance.submitted",
|
||||||
|
status="submitted",
|
||||||
|
occurred_at=NOW + timedelta(minutes=1),
|
||||||
|
actor_id="applicant-1",
|
||||||
|
idempotency_key="private-submit-key-do-not-export",
|
||||||
|
request_sha256="b" * 64,
|
||||||
|
payload={"secret": "private-submit-payload-do-not-export"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
handoff = FormHandoffEffect(
|
||||||
|
id="handoff-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id="instance-1",
|
||||||
|
effect_id="handoff-public-1",
|
||||||
|
instance_revision=2,
|
||||||
|
idempotency_key="private-handoff-key-do-not-export",
|
||||||
|
provider_key="private-provider-key-do-not-export",
|
||||||
|
request_sha256="c" * 64,
|
||||||
|
binding_kind="case",
|
||||||
|
binding_reference="permit-case",
|
||||||
|
provider_capability="cases.service_launcher",
|
||||||
|
state="succeeded",
|
||||||
|
attempt_count=1,
|
||||||
|
requested_at=NOW + timedelta(minutes=1),
|
||||||
|
resolved_at=NOW + timedelta(minutes=2),
|
||||||
|
target_ref={
|
||||||
|
"kind": "case",
|
||||||
|
"owner_module": "cases",
|
||||||
|
"object_id": "case-1",
|
||||||
|
"version": "1",
|
||||||
|
},
|
||||||
|
evidence=[{"secret": "private-handoff-evidence-do-not-export"}],
|
||||||
|
last_error="private-error-do-not-export",
|
||||||
|
details={"secret": "private-handoff-details-do-not-export"},
|
||||||
|
)
|
||||||
|
intake = FormIntakeSession(
|
||||||
|
id="intake-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
session_id="intake-public-1",
|
||||||
|
profile_id="profile-row-1",
|
||||||
|
token_sha256="d" * 64,
|
||||||
|
mode="authenticated",
|
||||||
|
status="submitted",
|
||||||
|
instance_id="instance-1",
|
||||||
|
actor_id="applicant-1",
|
||||||
|
idempotency_key="private-intake-key-do-not-export",
|
||||||
|
request_sha256="e" * 64,
|
||||||
|
expires_at=NOW + timedelta(hours=1),
|
||||||
|
started_at=NOW,
|
||||||
|
submitted_at=NOW + timedelta(minutes=1),
|
||||||
|
created_by="applicant-1",
|
||||||
|
details={"secret": "private-intake-details-do-not-export"},
|
||||||
|
)
|
||||||
|
grant_id = "status-grant-public-1"
|
||||||
|
grant = FormStatusAccessGrant(
|
||||||
|
id="grant-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
grant_id=grant_id,
|
||||||
|
policy_id="policy-row-1",
|
||||||
|
instance_id="instance-1",
|
||||||
|
tracking_id="private-tracking-id-do-not-export",
|
||||||
|
mode="email_link",
|
||||||
|
applicant_actor_id="applicant-1",
|
||||||
|
recipient_email_sha256=hashlib.sha256(
|
||||||
|
f"{grant_id}\0subject@example.org".encode()
|
||||||
|
).hexdigest(),
|
||||||
|
token_ttl_seconds=900,
|
||||||
|
request_limit_per_hour=3,
|
||||||
|
issued_at=NOW + timedelta(minutes=1),
|
||||||
|
details={"secret": "private-grant-details-do-not-export"},
|
||||||
|
)
|
||||||
|
token = FormStatusAccessToken(
|
||||||
|
id="token-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
token_id="private-token-id-do-not-export",
|
||||||
|
grant_id=grant.id,
|
||||||
|
token_sha256="f" * 64,
|
||||||
|
issued_at=NOW + timedelta(minutes=1),
|
||||||
|
expires_at=NOW + timedelta(minutes=16),
|
||||||
|
notification_id="private-notification-id-do-not-export",
|
||||||
|
details={"secret": "private-token-details-do-not-export"},
|
||||||
|
)
|
||||||
|
confirmation = FormAssistedConfirmation(
|
||||||
|
id="confirmation-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
confirmation_id="confirmation-public-1",
|
||||||
|
intake_session_id=intake.id,
|
||||||
|
instance_id="instance-1",
|
||||||
|
instance_revision=2,
|
||||||
|
outcome="confirmed",
|
||||||
|
method="read_back",
|
||||||
|
confirmed_by_ref="applicant-1",
|
||||||
|
operator_actor_id="operator-other",
|
||||||
|
confirmed_at=NOW + timedelta(minutes=1),
|
||||||
|
payload_sha256="1" * 64,
|
||||||
|
idempotency_key="private-confirmation-key-do-not-export",
|
||||||
|
request_sha256="2" * 64,
|
||||||
|
correction_note="private-correction-note-do-not-export",
|
||||||
|
details={"secret": "private-confirmation-details-do-not-export"},
|
||||||
|
)
|
||||||
|
acknowledgement = FormAcknowledgement(
|
||||||
|
id="ack-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
acknowledgement_id="ack-public-1",
|
||||||
|
instance_id="instance-1",
|
||||||
|
instance_revision=2,
|
||||||
|
statement_id="truthful-submission",
|
||||||
|
statement_version="1",
|
||||||
|
actor_id="applicant-1",
|
||||||
|
accepted_at=NOW + timedelta(minutes=1),
|
||||||
|
payload_sha256="3" * 64,
|
||||||
|
idempotency_key="private-ack-key-do-not-export",
|
||||||
|
request_sha256="4" * 64,
|
||||||
|
details={"secret": "private-ack-details-do-not-export"},
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
first,
|
||||||
|
second,
|
||||||
|
*events,
|
||||||
|
handoff,
|
||||||
|
intake,
|
||||||
|
grant,
|
||||||
|
token,
|
||||||
|
confirmation,
|
||||||
|
acknowledgement,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _seed_assisted_instance(self) -> None:
|
||||||
|
identity = FormInstanceIdentity(
|
||||||
|
id="identity-assisted",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id="instance-assisted",
|
||||||
|
definition_id="permit-form",
|
||||||
|
definition_revision="3",
|
||||||
|
created_by="operator-1",
|
||||||
|
)
|
||||||
|
self.session.add(identity)
|
||||||
|
self.session.flush()
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
FormInstanceRevision(
|
||||||
|
id="revision-assisted",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id="instance-assisted",
|
||||||
|
identity_id=identity.id,
|
||||||
|
revision=1,
|
||||||
|
status="draft",
|
||||||
|
recorded_at=NOW,
|
||||||
|
snapshot=_snapshot(
|
||||||
|
"instance-assisted",
|
||||||
|
name="Assisted Applicant Private Name",
|
||||||
|
),
|
||||||
|
changed_by="operator-1",
|
||||||
|
),
|
||||||
|
FormInstanceEvent(
|
||||||
|
id="event-assisted",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id="instance-assisted",
|
||||||
|
instance_revision=1,
|
||||||
|
event_id="event-assisted-public",
|
||||||
|
event_type="forms_runtime.instance.started",
|
||||||
|
status="draft",
|
||||||
|
occurred_at=NOW,
|
||||||
|
actor_id="operator-1",
|
||||||
|
idempotency_key="assisted-private-key",
|
||||||
|
request_sha256="5" * 64,
|
||||||
|
payload={"secret": "assisted-private-payload"},
|
||||||
|
),
|
||||||
|
FormIntakeSession(
|
||||||
|
id="intake-assisted",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
session_id="intake-assisted-public",
|
||||||
|
profile_id="profile-row-1",
|
||||||
|
token_sha256="6" * 64,
|
||||||
|
mode="assisted",
|
||||||
|
status="started",
|
||||||
|
instance_id="instance-assisted",
|
||||||
|
actor_id="applicant-assisted",
|
||||||
|
idempotency_key="assisted-intake-private-key",
|
||||||
|
request_sha256="7" * 64,
|
||||||
|
expires_at=NOW + timedelta(hours=1),
|
||||||
|
started_at=NOW,
|
||||||
|
created_by="operator-1",
|
||||||
|
details={"secret": "assisted-details-private"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _seed_unrelated_instances(self) -> None:
|
||||||
|
for tenant_id, suffix, actor in (
|
||||||
|
("tenant-1", "unrelated", "unrelated-actor"),
|
||||||
|
("tenant-2", "other-tenant", "applicant-1"),
|
||||||
|
):
|
||||||
|
identity = FormInstanceIdentity(
|
||||||
|
id=f"identity-{suffix}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
instance_id=f"instance-{suffix}",
|
||||||
|
definition_id="permit-form",
|
||||||
|
definition_revision="3",
|
||||||
|
created_by=actor,
|
||||||
|
)
|
||||||
|
self.session.add(identity)
|
||||||
|
self.session.flush()
|
||||||
|
self.session.add(
|
||||||
|
FormInstanceRevision(
|
||||||
|
id=f"revision-{suffix}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
instance_id=identity.instance_id,
|
||||||
|
identity_id=identity.id,
|
||||||
|
revision=1,
|
||||||
|
status="submitted",
|
||||||
|
recorded_at=NOW,
|
||||||
|
snapshot=_snapshot(
|
||||||
|
identity.instance_id,
|
||||||
|
name=f"private-{suffix}-name-do-not-export",
|
||||||
|
),
|
||||||
|
changed_by=actor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _seed_operator_configuration(self) -> None:
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
FormIntakeProfile(
|
||||||
|
id="profile-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id="profile-public-1",
|
||||||
|
public_id="public-profile-id",
|
||||||
|
definition_id="permit-form",
|
||||||
|
definition_revision="3",
|
||||||
|
mode="assisted",
|
||||||
|
enabled=True,
|
||||||
|
revision=1,
|
||||||
|
custodian_ref="unit-1",
|
||||||
|
draft_ttl_seconds=3600,
|
||||||
|
invitation_ttl_seconds=3600,
|
||||||
|
rate_limit_per_minute=10,
|
||||||
|
created_by="operator-1",
|
||||||
|
updated_by="operator-1",
|
||||||
|
details={"secret": "private-profile-details-do-not-export"},
|
||||||
|
),
|
||||||
|
FormStatusAccessPolicy(
|
||||||
|
id="policy-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
policy_id="policy-public-1",
|
||||||
|
definition_id="permit-form",
|
||||||
|
definition_revision="3",
|
||||||
|
mode="email_link",
|
||||||
|
enabled=True,
|
||||||
|
revision=1,
|
||||||
|
email_field_key="email",
|
||||||
|
token_ttl_seconds=900,
|
||||||
|
request_limit_per_hour=3,
|
||||||
|
created_by="operator-1",
|
||||||
|
updated_by="operator-1",
|
||||||
|
details={"secret": "private-policy-details-do-not-export"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_actor_search_exports_subject_instance_and_minimizes_internals(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="applicant-1"),
|
||||||
|
)
|
||||||
|
types = {item.resource_type for item in records}
|
||||||
|
self.assertIn("forms_runtime_instance_revision", types)
|
||||||
|
self.assertIn("forms_runtime_handoff_effect", types)
|
||||||
|
self.assertIn("forms_runtime_status_access_grant", types)
|
||||||
|
self.assertIn("forms_runtime_status_token_lifecycle", types)
|
||||||
|
self.assertIn("forms_runtime_assisted_confirmation", types)
|
||||||
|
self.assertIn("forms_runtime_acknowledgement", types)
|
||||||
|
|
||||||
|
exported = json.dumps([item.to_dict() for item in records], sort_keys=True)
|
||||||
|
self.assertIn("Ada Example", exported)
|
||||||
|
self.assertIn("Example Street 1", exported)
|
||||||
|
self.assertNotIn("credential-value-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-attachment-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-signature-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-validation-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-event-payload-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-token-id-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-tracking-id-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-notification-id-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-provider-key-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-error-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-correction-note-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-unrelated-name-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-other-tenant-name-do-not-export", exported)
|
||||||
|
|
||||||
|
def test_email_selector_matches_grant_specific_digest(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(email=" Subject@Example.org "),
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
item.resource_type == "forms_runtime_status_access_grant"
|
||||||
|
and item.data["email_selector_matched"] is True
|
||||||
|
for item in records
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
item.resource_type == "forms_runtime_instance_revision"
|
||||||
|
for item in records
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_assisted_operator_is_attributed_but_not_treated_as_applicant(self) -> None:
|
||||||
|
operator_records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="operator-1"),
|
||||||
|
)
|
||||||
|
exported = json.dumps(
|
||||||
|
[item.to_dict() for item in operator_records], sort_keys=True
|
||||||
|
)
|
||||||
|
self.assertIn("forms_runtime_operator_attribution", exported)
|
||||||
|
self.assertIn("created_form_instance", exported)
|
||||||
|
self.assertIn("intake_profile_configuration", exported)
|
||||||
|
self.assertNotIn("Assisted Applicant Private Name", exported)
|
||||||
|
self.assertNotIn("assisted-private-payload", exported)
|
||||||
|
self.assertNotIn("private-profile-details-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-policy-details-do-not-export", exported)
|
||||||
|
|
||||||
|
applicant_records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="applicant-assisted"),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"Assisted Applicant Private Name",
|
||||||
|
json.dumps([item.to_dict() for item in applicant_records]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_direct_and_canonical_conflicts_fail_closed(self) -> None:
|
||||||
|
direct = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={"forms_runtime.instance": "instance-1"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
item.resource_type == "forms_runtime_instance_revision"
|
||||||
|
for item in direct
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="applicant-1",
|
||||||
|
external_references={"forms_runtime.instance": "instance-unrelated"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual((), conflict)
|
||||||
|
alias_conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="applicant-1",
|
||||||
|
external_references={"forms_runtime.account": "other-account"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual((), alias_conflict)
|
||||||
|
|
||||||
|
def test_planning_retains_evidence_and_reviews_active_status_access(self) -> None:
|
||||||
|
subject = DsarSubjectRef(account_id="applicant-1")
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
self.assertGreater(sum(item.kind == "retain" for item in actions), 5)
|
||||||
|
self.assertEqual(1, sum(item.kind == "manual_review" for item in actions))
|
||||||
|
self.assertTrue(all(not item.executable for item in actions))
|
||||||
|
results = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-forms-1",
|
||||||
|
)
|
||||||
|
self.assertTrue(all(item.status == "blocked" for item in results))
|
||||||
|
|
||||||
|
def test_foreign_records_and_actions_are_rejected(self) -> None:
|
||||||
|
subject = DsarSubjectRef(account_id="applicant-1")
|
||||||
|
foreign_record = DsarRecordRef(
|
||||||
|
provider_id="foreign",
|
||||||
|
module_id="foreign",
|
||||||
|
resource_type="foreign",
|
||||||
|
resource_id="foreign-1",
|
||||||
|
category="foreign",
|
||||||
|
title="Foreign record",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider record"):
|
||||||
|
self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=(foreign_record,),
|
||||||
|
)
|
||||||
|
foreign_action = DsarErasureActionRef(
|
||||||
|
action_id="foreign:delete:1",
|
||||||
|
provider_id="foreign",
|
||||||
|
module_id="foreign",
|
||||||
|
kind="delete",
|
||||||
|
resource_type="foreign",
|
||||||
|
resource_id="foreign-1",
|
||||||
|
title="Delete foreign",
|
||||||
|
rationale="No",
|
||||||
|
executable=True,
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider action"):
|
||||||
|
self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=(foreign_action,),
|
||||||
|
request_id="dsar-forms-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_workflow_discovers_only_the_active_tenant_capability(self) -> None:
|
||||||
|
active = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-FORMS-1",
|
||||||
|
request_kind="access",
|
||||||
|
subject=DsarSubjectRef(account_id="applicant-1"),
|
||||||
|
purpose="Subject access request",
|
||||||
|
legal_basis=None,
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-operator",
|
||||||
|
)
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=active,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual("searched", active.status)
|
||||||
|
self.assertEqual(
|
||||||
|
[FORMS_RUNTIME_DSAR_CAPABILITY],
|
||||||
|
active.coverage["provider_capabilities"],
|
||||||
|
)
|
||||||
|
self.assertEqual(["forms_runtime"], active.coverage["covered_modules"])
|
||||||
|
|
||||||
|
inactive = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-FORMS-2",
|
||||||
|
request_kind="access",
|
||||||
|
subject=DsarSubjectRef(account_id="applicant-1"),
|
||||||
|
purpose="Inactive module coverage",
|
||||||
|
legal_basis=None,
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-operator",
|
||||||
|
)
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, active=False),
|
||||||
|
row=inactive,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual([], inactive.coverage["provider_capabilities"])
|
||||||
|
self.assertEqual(
|
||||||
|
[FORMS_RUNTIME_DSAR_CAPABILITY],
|
||||||
|
inactive.coverage["inactive_provider_capabilities"],
|
||||||
|
)
|
||||||
|
self.assertEqual(0, inactive.search_result["record_count"])
|
||||||
|
|
||||||
|
def test_manifest_registers_and_documents_the_capability(self) -> None:
|
||||||
|
self.assertIn(FORMS_RUNTIME_DSAR_CAPABILITY, manifest.capability_factories)
|
||||||
|
self.assertIn(
|
||||||
|
FORMS_RUNTIME_DSAR_CAPABILITY,
|
||||||
|
manifest.capability_documentation,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
FORMS_RUNTIME_DSAR_CAPABILITY,
|
||||||
|
{item.name for item in manifest.provides_interfaces},
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
topic.id == "forms_runtime.data-subject-requests"
|
||||||
|
and {"admin", "user"}.issubset(topic.documentation_types)
|
||||||
|
for topic in manifest.documentation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_forms_runtime.backend.dsar_provider import _normalized_email
|
||||||
|
from govoplan_forms_runtime.backend.email_normalization import normalize_status_email
|
||||||
|
from govoplan_forms_runtime.backend.status_access import _normalize_email
|
||||||
|
|
||||||
|
|
||||||
|
class EmailNormalizationTests(unittest.TestCase):
|
||||||
|
def test_status_access_and_dsar_share_the_same_selector_policy(self) -> None:
|
||||||
|
self.assertIs(normalize_status_email, _normalize_email)
|
||||||
|
self.assertIs(normalize_status_email, _normalized_email)
|
||||||
|
|
||||||
|
def test_existing_selector_results_remain_exact_at_policy_boundaries(self) -> None:
|
||||||
|
boundary = "a" * 308 + "@example.org"
|
||||||
|
values = (
|
||||||
|
(None, None), (False, None), (0, None), ("", None), (" ", None),
|
||||||
|
(" Subject@Example.ORG ", "subject@example.org"),
|
||||||
|
("\u00a0Straße@BÜRO.Example\u00a0", "strasse@büro.example"),
|
||||||
|
("a+b@sub.example.org", "a+b@sub.example.org"),
|
||||||
|
("a..b@example..org", "a..b@example..org"), # Existing policy, not a new RFC validator.
|
||||||
|
("a b@example.org", None), ("a@exa\tmple.org", None), ("a@exa\u00a0mple.org", None),
|
||||||
|
("a@@example.org", None), ("@example.org", None), ("a@example", None),
|
||||||
|
("a@.example.org", None), ("a@example.org.", None),
|
||||||
|
(boundary, boundary), ("a" + boundary, None),
|
||||||
|
)
|
||||||
|
for value, expected in values:
|
||||||
|
with self.subTest(value=value):
|
||||||
|
self.assertEqual(expected, normalize_status_email(value))
|
||||||
|
|
||||||
|
def test_selector_canonicalization_does_not_mutate_submitted_values(self) -> None:
|
||||||
|
values = {"email": " Subject@Example.ORG ", "other": ["001", " ", None]}
|
||||||
|
self.assertEqual("subject@example.org", normalize_status_email(values["email"]))
|
||||||
|
self.assertEqual({"email": " Subject@Example.ORG ", "other": ["001", " ", None]}, values)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
import unittest
|
import unittest
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -20,16 +21,24 @@ from govoplan_core.core.institutional import (
|
|||||||
ServiceLaunchResult,
|
ServiceLaunchResult,
|
||||||
TemporalRevision,
|
TemporalRevision,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||||
from govoplan_forms.backend.db.models import FormDefinitionRevision
|
from govoplan_forms.backend.db.models import FormDefinitionRevision
|
||||||
from govoplan_forms.backend.service import (
|
from govoplan_forms.backend.service import (
|
||||||
SqlFormDefinitionProvider,
|
SqlFormDefinitionProvider,
|
||||||
record_form_definition,
|
record_form_definition,
|
||||||
)
|
)
|
||||||
from govoplan_forms_runtime.backend.db.models import (
|
from govoplan_forms_runtime.backend.db.models import (
|
||||||
|
FormAcknowledgement,
|
||||||
|
FormAssistedConfirmation,
|
||||||
FormHandoffEffect,
|
FormHandoffEffect,
|
||||||
FormInstanceEvent,
|
FormInstanceEvent,
|
||||||
FormInstanceIdentity,
|
FormInstanceIdentity,
|
||||||
FormInstanceRevision,
|
FormInstanceRevision,
|
||||||
|
FormIntakeProfile,
|
||||||
|
FormIntakeSession,
|
||||||
|
FormStatusAccessGrant,
|
||||||
|
FormStatusAccessPolicy,
|
||||||
|
FormStatusAccessToken,
|
||||||
)
|
)
|
||||||
from govoplan_forms_runtime.backend.service import (
|
from govoplan_forms_runtime.backend.service import (
|
||||||
FormRuntimeError,
|
FormRuntimeError,
|
||||||
@@ -37,6 +46,12 @@ from govoplan_forms_runtime.backend.service import (
|
|||||||
FormsServiceLauncher,
|
FormsServiceLauncher,
|
||||||
)
|
)
|
||||||
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
|
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
|
||||||
|
from govoplan_forms_runtime.backend.intake import FormIntakeError, FormIntakeService
|
||||||
|
from govoplan_forms_runtime.backend.status_access import (
|
||||||
|
FormStatusAccessError,
|
||||||
|
FormStatusAccessService,
|
||||||
|
FormStatusUnavailable,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
|
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
|
||||||
@@ -61,6 +76,53 @@ class Registry:
|
|||||||
def require_capability(self, name: str) -> object:
|
def require_capability(self, name: str) -> object:
|
||||||
return self.capabilities[name]
|
return self.capabilities[name]
|
||||||
|
|
||||||
|
def capability(self, name: str) -> object:
|
||||||
|
return self.capabilities[name]
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationProvider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.requests: list[object] = []
|
||||||
|
|
||||||
|
def tenant_id_for_notification(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
notification_id: str,
|
||||||
|
) -> str | None:
|
||||||
|
del session, notification_id
|
||||||
|
return "tenant-1"
|
||||||
|
|
||||||
|
def enqueue_notification(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
request: object,
|
||||||
|
*,
|
||||||
|
enqueue_delivery: bool = True,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
del session, enqueue_delivery
|
||||||
|
self.requests.append(request)
|
||||||
|
return {"id": f"notification-{len(self.requests)}"}
|
||||||
|
|
||||||
|
def deliver_notification(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
notification_id: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
del session, notification_id
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def deliver_pending(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
del session, tenant_id, limit
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def form_definition(
|
def form_definition(
|
||||||
*,
|
*,
|
||||||
@@ -96,6 +158,12 @@ def form_definition(
|
|||||||
value_type="choice",
|
value_type="choice",
|
||||||
options=("portal", "mail"),
|
options=("portal", "mail"),
|
||||||
),
|
),
|
||||||
|
FormFieldDefinition(
|
||||||
|
key="email",
|
||||||
|
label="Email",
|
||||||
|
value_type="email",
|
||||||
|
constraints={"max_length": 320},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
publication_state="published",
|
publication_state="published",
|
||||||
allow_drafts=True,
|
allow_drafts=True,
|
||||||
@@ -113,6 +181,13 @@ class FormsRuntimeTests(unittest.TestCase):
|
|||||||
FormInstanceRevision.__table__,
|
FormInstanceRevision.__table__,
|
||||||
FormInstanceEvent.__table__,
|
FormInstanceEvent.__table__,
|
||||||
FormHandoffEffect.__table__,
|
FormHandoffEffect.__table__,
|
||||||
|
FormIntakeProfile.__table__,
|
||||||
|
FormIntakeSession.__table__,
|
||||||
|
FormAssistedConfirmation.__table__,
|
||||||
|
FormStatusAccessPolicy.__table__,
|
||||||
|
FormStatusAccessGrant.__table__,
|
||||||
|
FormStatusAccessToken.__table__,
|
||||||
|
FormAcknowledgement.__table__,
|
||||||
):
|
):
|
||||||
table.create(self.engine)
|
table.create(self.engine)
|
||||||
self.session = Session(self.engine)
|
self.session = Session(self.engine)
|
||||||
@@ -683,6 +758,589 @@ class FormsRuntimeTests(unittest.TestCase):
|
|||||||
self.assertEqual("reconciled", reconciled.state)
|
self.assertEqual("reconciled", reconciled.state)
|
||||||
self.assertEqual("handed_off", revised.status)
|
self.assertEqual("handed_off", revised.status)
|
||||||
|
|
||||||
|
def test_anonymous_and_invitation_intake_are_hashed_expiring_and_replay_safe(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
intake = FormIntakeService(self.registry)
|
||||||
|
available = intake.list_available_definitions(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
(self.definition.reference,), tuple(item.reference for item in available)
|
||||||
|
)
|
||||||
|
anonymous = intake.create_profile(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
mode="anonymous",
|
||||||
|
custodian_ref="user:account-1",
|
||||||
|
rate_limit_per_minute=10,
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
started = intake.start_anonymous(
|
||||||
|
self.session,
|
||||||
|
public_id=anonymous.public_id,
|
||||||
|
values={"name": "Ada"},
|
||||||
|
idempotency_key="anonymous-start",
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(started.token)
|
||||||
|
stored_session = self.session.query(FormIntakeSession).one()
|
||||||
|
self.assertNotEqual(started.token, stored_session.token_sha256)
|
||||||
|
current, definition = intake.get_public_instance(
|
||||||
|
self.session,
|
||||||
|
token=str(started.token),
|
||||||
|
now=NOW + timedelta(minutes=2),
|
||||||
|
)
|
||||||
|
self.assertEqual("permit-form", definition.reference.object_id)
|
||||||
|
submitted = intake.submit_public(
|
||||||
|
self.session,
|
||||||
|
token=str(started.token),
|
||||||
|
expected_revision=current.revision,
|
||||||
|
values=current.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="anonymous-submit",
|
||||||
|
recorded_at=NOW + timedelta(minutes=3),
|
||||||
|
)
|
||||||
|
self.assertEqual("submitted", submitted.status)
|
||||||
|
|
||||||
|
replay = intake.start_anonymous(
|
||||||
|
self.session,
|
||||||
|
public_id=anonymous.public_id,
|
||||||
|
values={"name": "Ada"},
|
||||||
|
idempotency_key="anonymous-start",
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
self.assertTrue(replay.replayed)
|
||||||
|
self.assertIsNone(replay.token)
|
||||||
|
self.assertEqual(started.instance.instance_id, replay.instance.instance_id)
|
||||||
|
|
||||||
|
invitation = intake.create_profile(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
mode="invitation",
|
||||||
|
custodian_ref="user:account-1",
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
issued = intake.issue_invitation(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
profile_id=invitation.profile_id,
|
||||||
|
idempotency_key="invite-1",
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
invited = intake.start_invitation(
|
||||||
|
self.session,
|
||||||
|
token=str(issued.token),
|
||||||
|
values={"name": "Grace"},
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
self.assertEqual("active", invited.status)
|
||||||
|
self.assertEqual("invitation", invited.mode)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(FormIntakeError, "unavailable"):
|
||||||
|
intake.get_public_instance(
|
||||||
|
self.session,
|
||||||
|
token="x" * 40,
|
||||||
|
now=NOW + timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_assisted_intake_is_resumable_and_requires_exact_readback_evidence(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
intake = FormIntakeService(self.registry)
|
||||||
|
profile = intake.create_profile(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
mode="assisted",
|
||||||
|
custodian_ref="function:parking-permits",
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
started = intake.start_assisted(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
profile_id=profile.profile_id,
|
||||||
|
values={"name": "Ada", "delivery": "mail"},
|
||||||
|
channel="counter",
|
||||||
|
affected_party_ref="party:resident-1",
|
||||||
|
represented_party_ref=None,
|
||||||
|
authority_basis="self",
|
||||||
|
purpose="Apply for a resident parking permit.",
|
||||||
|
legal_basis_ref="law:resident-parking",
|
||||||
|
consent_basis="verbal-consent",
|
||||||
|
notice_given=True,
|
||||||
|
responsible_function_ref="function:parking-permits",
|
||||||
|
language="de",
|
||||||
|
accessibility_needs=("plain-language",),
|
||||||
|
field_sources={
|
||||||
|
"name": {
|
||||||
|
"source": "person_statement",
|
||||||
|
"confidence": "stated",
|
||||||
|
"declared_by_ref": "party:resident-1",
|
||||||
|
},
|
||||||
|
"delivery": {
|
||||||
|
"source": "person_statement",
|
||||||
|
"confidence": "stated",
|
||||||
|
"declared_by_ref": "party:resident-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
idempotency_key="assisted-start-1",
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
self.assertIsNone(started.token)
|
||||||
|
self.assertEqual("assisted", started.mode)
|
||||||
|
self.assertEqual("counter", started.instance.metadata["intake"]["channel"])
|
||||||
|
replay = intake.start_assisted(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
profile_id=profile.profile_id,
|
||||||
|
values={"name": "Ada", "delivery": "mail"},
|
||||||
|
channel="counter",
|
||||||
|
affected_party_ref="party:resident-1",
|
||||||
|
represented_party_ref=None,
|
||||||
|
authority_basis="self",
|
||||||
|
purpose="Apply for a resident parking permit.",
|
||||||
|
legal_basis_ref="law:resident-parking",
|
||||||
|
consent_basis="verbal-consent",
|
||||||
|
notice_given=True,
|
||||||
|
responsible_function_ref="function:parking-permits",
|
||||||
|
language="de",
|
||||||
|
accessibility_needs=("plain-language",),
|
||||||
|
field_sources={
|
||||||
|
"name": {
|
||||||
|
"source": "person_statement",
|
||||||
|
"confidence": "stated",
|
||||||
|
"declared_by_ref": "party:resident-1",
|
||||||
|
},
|
||||||
|
"delivery": {
|
||||||
|
"source": "person_statement",
|
||||||
|
"confidence": "stated",
|
||||||
|
"declared_by_ref": "party:resident-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
idempotency_key="assisted-start-1",
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
self.assertTrue(replay.replayed)
|
||||||
|
self.assertEqual(started.instance.instance_id, replay.instance.instance_id)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(FormRuntimeError, "read-back confirmation"):
|
||||||
|
self.runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=started.instance.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values=started.instance.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="assisted-submit-unconfirmed",
|
||||||
|
recorded_at=NOW + timedelta(minutes=2),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(FormIntakeError, "Save assisted corrections"):
|
||||||
|
intake.record_assisted_confirmation(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=started.instance.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values={"name": "Ada Lovelace", "delivery": "mail"},
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
outcome="corrected",
|
||||||
|
method="spoken_readback",
|
||||||
|
confirmed_by_ref="party:resident-1",
|
||||||
|
confirmed_at=NOW + timedelta(minutes=2),
|
||||||
|
idempotency_key="readback-unsaved",
|
||||||
|
field_sources={
|
||||||
|
key: {"source": "person_statement", "confidence": "stated"}
|
||||||
|
for key in ("name", "delivery")
|
||||||
|
},
|
||||||
|
correction_note="Applicant corrected the full name.",
|
||||||
|
)
|
||||||
|
confirmed = intake.record_assisted_confirmation(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=started.instance.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values=started.instance.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
outcome="confirmed",
|
||||||
|
method="spoken_readback",
|
||||||
|
confirmed_by_ref="party:resident-1",
|
||||||
|
confirmed_at=NOW + timedelta(minutes=3),
|
||||||
|
idempotency_key="readback-1",
|
||||||
|
field_sources={
|
||||||
|
key: {"source": "person_statement", "confidence": "stated"}
|
||||||
|
for key in ("name", "delivery")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual("confirmed", confirmed.outcome)
|
||||||
|
|
||||||
|
corrected_draft = self.runtime.update_draft(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=started.instance.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values={"name": "Ada Lovelace", "delivery": "mail"},
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="assisted-correction-save",
|
||||||
|
recorded_at=NOW + timedelta(minutes=4),
|
||||||
|
change_reason="Correct the applicant name after read-back.",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(FormRuntimeError, "read-back confirmation"):
|
||||||
|
self.runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=corrected_draft.instance_id,
|
||||||
|
expected_revision=2,
|
||||||
|
values=corrected_draft.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="assisted-submit-stale-readback",
|
||||||
|
recorded_at=NOW + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
corrected = intake.record_assisted_confirmation(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=corrected_draft.instance_id,
|
||||||
|
expected_revision=2,
|
||||||
|
values=corrected_draft.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
outcome="corrected",
|
||||||
|
method="written_preview",
|
||||||
|
confirmed_by_ref="party:resident-1",
|
||||||
|
confirmed_at=NOW + timedelta(minutes=6),
|
||||||
|
idempotency_key="readback-2",
|
||||||
|
field_sources={
|
||||||
|
key: {"source": "person_statement", "confidence": "stated"}
|
||||||
|
for key in ("name", "delivery")
|
||||||
|
},
|
||||||
|
correction_note="Applicant corrected the full name.",
|
||||||
|
)
|
||||||
|
self.assertNotEqual(confirmed.payload_sha256, corrected.payload_sha256)
|
||||||
|
submitted = self.runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=corrected_draft.instance_id,
|
||||||
|
expected_revision=2,
|
||||||
|
values=corrected_draft.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="assisted-submit-confirmed",
|
||||||
|
recorded_at=NOW + timedelta(minutes=7),
|
||||||
|
)
|
||||||
|
self.assertEqual("submitted", submitted.status)
|
||||||
|
stored_session = (
|
||||||
|
self.session.query(FormIntakeSession)
|
||||||
|
.filter(FormIntakeSession.instance_id == submitted.instance_id)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
self.assertEqual("submitted", stored_session.status)
|
||||||
|
self.assertEqual(
|
||||||
|
2,
|
||||||
|
len(
|
||||||
|
intake.list_assisted_confirmations(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=submitted.instance_id,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status_access_modes_are_configurable_and_public_projection_is_bounded(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
notifications = NotificationProvider()
|
||||||
|
registry = Registry(
|
||||||
|
SqlFormDefinitionProvider(),
|
||||||
|
**{CAPABILITY_NOTIFICATIONS_DISPATCH: notifications},
|
||||||
|
)
|
||||||
|
runtime = FormRuntimeService(registry)
|
||||||
|
status_access = FormStatusAccessService(registry)
|
||||||
|
|
||||||
|
policy = status_access.upsert_policy(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
mode="authenticated",
|
||||||
|
enabled=True,
|
||||||
|
email_field_key=None,
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
authenticated_draft = runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
values={"name": "Ada", "delivery": "portal"},
|
||||||
|
idempotency_key="status-auth-create",
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
authenticated_submission = runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=authenticated_draft.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values=authenticated_draft.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="status-auth-submit",
|
||||||
|
recorded_at=NOW + timedelta(minutes=2),
|
||||||
|
)
|
||||||
|
authenticated_summary = status_access.access_summary_for_instance(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id=authenticated_submission.instance_id,
|
||||||
|
)
|
||||||
|
self.assertEqual("authenticated", authenticated_summary["mode"])
|
||||||
|
authenticated_projection = status_access.get_authenticated_projection(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
tracking_id=authenticated_summary["tracking_id"],
|
||||||
|
observed_at=NOW + timedelta(minutes=3),
|
||||||
|
)
|
||||||
|
self.assertEqual("submitted", authenticated_projection["status"])
|
||||||
|
self.assertNotIn("values", authenticated_projection)
|
||||||
|
self.assertNotIn("actor_id", authenticated_projection)
|
||||||
|
with self.assertRaises(FormStatusUnavailable):
|
||||||
|
status_access.get_authenticated_projection(
|
||||||
|
self.session,
|
||||||
|
Principal(account_id="another-account"),
|
||||||
|
tracking_id=authenticated_summary["tracking_id"],
|
||||||
|
observed_at=NOW + timedelta(minutes=3),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(FormStatusAccessError, "email value type"):
|
||||||
|
status_access.upsert_policy(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
mode="email_link",
|
||||||
|
enabled=True,
|
||||||
|
email_field_key="name",
|
||||||
|
expected_revision=policy.revision,
|
||||||
|
recorded_at=NOW + timedelta(minutes=4),
|
||||||
|
)
|
||||||
|
|
||||||
|
policy = status_access.upsert_policy(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
mode="email_link",
|
||||||
|
enabled=True,
|
||||||
|
email_field_key="email",
|
||||||
|
token_ttl_seconds=900,
|
||||||
|
request_limit_per_hour=3,
|
||||||
|
expected_revision=policy.revision,
|
||||||
|
recorded_at=NOW + timedelta(minutes=4),
|
||||||
|
)
|
||||||
|
email_draft = runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
values={
|
||||||
|
"name": "Ada",
|
||||||
|
"delivery": "mail",
|
||||||
|
"email": "Ada@example.test",
|
||||||
|
},
|
||||||
|
idempotency_key="status-email-create",
|
||||||
|
recorded_at=NOW + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
email_submission = runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=email_draft.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values=email_draft.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="status-email-submit",
|
||||||
|
recorded_at=NOW + timedelta(minutes=6),
|
||||||
|
)
|
||||||
|
email_summary = status_access.access_summary_for_instance(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id=email_submission.instance_id,
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
status_access.request_email_link(
|
||||||
|
self.session,
|
||||||
|
tracking_id=email_summary["tracking_id"],
|
||||||
|
email="wrong@example.test",
|
||||||
|
requested_at=NOW + timedelta(minutes=7),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual([], notifications.requests)
|
||||||
|
self.assertTrue(
|
||||||
|
status_access.request_email_link(
|
||||||
|
self.session,
|
||||||
|
tracking_id=email_summary["tracking_id"],
|
||||||
|
email="ada@EXAMPLE.test",
|
||||||
|
requested_at=NOW + timedelta(minutes=8),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
first_url = notifications.requests[-1].action_url
|
||||||
|
first_token = parse_qs(urlsplit(first_url).query)["token"][0]
|
||||||
|
email_projection = status_access.get_public_projection(
|
||||||
|
self.session,
|
||||||
|
tracking_id=email_summary["tracking_id"],
|
||||||
|
token=first_token,
|
||||||
|
observed_at=NOW + timedelta(minutes=9),
|
||||||
|
)
|
||||||
|
self.assertEqual(email_submission.receipt_id, email_projection["receipt_id"])
|
||||||
|
self.assertTrue(
|
||||||
|
status_access.request_email_link(
|
||||||
|
self.session,
|
||||||
|
tracking_id=email_summary["tracking_id"],
|
||||||
|
email="ada@example.test",
|
||||||
|
requested_at=NOW + timedelta(minutes=10),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.assertRaises(FormStatusUnavailable):
|
||||||
|
status_access.get_public_projection(
|
||||||
|
self.session,
|
||||||
|
tracking_id=email_summary["tracking_id"],
|
||||||
|
token=first_token,
|
||||||
|
observed_at=NOW + timedelta(minutes=11),
|
||||||
|
)
|
||||||
|
second_url = notifications.requests[-1].action_url
|
||||||
|
second_token = parse_qs(urlsplit(second_url).query)["token"][0]
|
||||||
|
self.assertNotEqual(first_token, second_token)
|
||||||
|
|
||||||
|
policy = status_access.upsert_policy(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
mode="permanent_link",
|
||||||
|
enabled=True,
|
||||||
|
email_field_key=None,
|
||||||
|
expected_revision=policy.revision,
|
||||||
|
recorded_at=NOW + timedelta(minutes=12),
|
||||||
|
)
|
||||||
|
permanent_draft = runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
values={"name": "Ada", "delivery": "portal"},
|
||||||
|
idempotency_key="status-permanent-create",
|
||||||
|
recorded_at=NOW + timedelta(minutes=13),
|
||||||
|
)
|
||||||
|
permanent_submission = runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=permanent_draft.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values=permanent_draft.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="status-permanent-submit",
|
||||||
|
recorded_at=NOW + timedelta(minutes=14),
|
||||||
|
)
|
||||||
|
permanent_summary = status_access.access_summary_for_instance(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id=permanent_submission.instance_id,
|
||||||
|
)
|
||||||
|
permanent_projection = status_access.get_public_projection(
|
||||||
|
self.session,
|
||||||
|
tracking_id=permanent_summary["tracking_id"],
|
||||||
|
token=None,
|
||||||
|
observed_at=NOW + timedelta(minutes=15),
|
||||||
|
)
|
||||||
|
self.assertEqual("submitted", permanent_projection["status"])
|
||||||
|
policy = status_access.upsert_policy(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
mode="permanent_link",
|
||||||
|
enabled=False,
|
||||||
|
email_field_key=None,
|
||||||
|
expected_revision=policy.revision,
|
||||||
|
recorded_at=NOW + timedelta(minutes=16),
|
||||||
|
)
|
||||||
|
self.assertFalse(policy.enabled)
|
||||||
|
with self.assertRaises(FormStatusUnavailable):
|
||||||
|
status_access.get_public_projection(
|
||||||
|
self.session,
|
||||||
|
tracking_id=permanent_summary["tracking_id"],
|
||||||
|
token=None,
|
||||||
|
observed_at=NOW + timedelta(minutes=17),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_native_acknowledgement_is_bound_to_actor_and_exact_payload(self) -> None:
|
||||||
|
draft = self.runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
values={"name": "Ada"},
|
||||||
|
idempotency_key="ack-create",
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
signature = self.runtime.acknowledge(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=draft.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
statement_id="submission-correct",
|
||||||
|
statement_version="1",
|
||||||
|
values=draft.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
accepted_at=NOW + timedelta(minutes=1),
|
||||||
|
idempotency_key="ack-1",
|
||||||
|
)
|
||||||
|
submitted = self.runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=draft.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values=draft.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(signature,),
|
||||||
|
idempotency_key="ack-submit",
|
||||||
|
recorded_at=NOW + timedelta(minutes=2),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"accepted", submitted.metadata["evidence_verification"][0]["state"]
|
||||||
|
)
|
||||||
|
|
||||||
|
second = self.runtime.create_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition_ref=self.definition.reference,
|
||||||
|
values={"name": "Ada"},
|
||||||
|
idempotency_key="ack-create-2",
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
stale_signature = self.runtime.acknowledge(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=second.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
statement_id="submission-correct",
|
||||||
|
statement_version="1",
|
||||||
|
values=second.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
accepted_at=NOW + timedelta(minutes=1),
|
||||||
|
idempotency_key="ack-2",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(FormRuntimeError, "failed final verification"):
|
||||||
|
self.runtime.submit_instance(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
instance_id=second.instance_id,
|
||||||
|
expected_revision=1,
|
||||||
|
values={"name": "Changed after acknowledgement"},
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(stale_signature,),
|
||||||
|
idempotency_key="ack-stale-submit",
|
||||||
|
recorded_at=NOW + timedelta(minutes=2),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -14,7 +14,15 @@ class FormsRuntimeInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
{item.path for item in frontend.routes}, # type: ignore[union-attr]
|
{item.path for item in frontend.routes}, # type: ignore[union-attr]
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{"forms_runtime.navigation", "forms_runtime.workspace", "forms_runtime.instance"},
|
{"/forms/public/:publicId", "/forms/intake/:token"},
|
||||||
|
{item.path for item in frontend.public_routes}, # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"forms_runtime.navigation",
|
||||||
|
"forms_runtime.workspace",
|
||||||
|
"forms_runtime.instance",
|
||||||
|
},
|
||||||
{item.id for item in frontend.view_surfaces}, # type: ignore[union-attr]
|
{item.id for item in frontend.view_surfaces}, # type: ignore[union-attr]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,10 +31,18 @@ class FormsRuntimeInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
guide = topics["forms_runtime.submissions"]
|
guide = topics["forms_runtime.submissions"]
|
||||||
reference = topics["forms_runtime.reference.fields-and-consequences"]
|
reference = topics["forms_runtime.reference.fields-and-consequences"]
|
||||||
self.assertIn("forms_runtime.instance", guide.metadata["help_contexts"])
|
self.assertIn("forms_runtime.instance", guide.metadata["help_contexts"])
|
||||||
|
self.assertIn("forms_runtime.public-intake", guide.metadata["help_contexts"])
|
||||||
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
|
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
|
||||||
self.assertIn("forms_runtime.action.submit", reference.metadata["help_contexts"])
|
self.assertIn(
|
||||||
|
"forms_runtime.action.submit", reference.metadata["help_contexts"]
|
||||||
|
)
|
||||||
self.assertIn("submit", reference.metadata["consequence_classes"])
|
self.assertIn("submit", reference.metadata["consequence_classes"])
|
||||||
self.assertIn("compensate", reference.metadata["consequence_classes"])
|
self.assertIn("compensate", reference.metadata["consequence_classes"])
|
||||||
|
self.assertIn("every populated value independently", reference.body)
|
||||||
|
self.assertFalse(any(
|
||||||
|
"mixed-source field editing remains" in limit
|
||||||
|
for limit in manifest.architecture.known_limits
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import unittest
|
|||||||
|
|
||||||
from govoplan_forms_runtime.backend.manifest import (
|
from govoplan_forms_runtime.backend.manifest import (
|
||||||
ADMIN_SCOPE,
|
ADMIN_SCOPE,
|
||||||
|
ASSIST_SCOPE,
|
||||||
PARTICIPATE_SCOPE,
|
PARTICIPATE_SCOPE,
|
||||||
READ_SCOPE,
|
READ_SCOPE,
|
||||||
WRITE_SCOPE,
|
WRITE_SCOPE,
|
||||||
@@ -19,7 +20,7 @@ class ManifestTests(unittest.TestCase):
|
|||||||
self.assertEqual(manifest.dependencies, ("access", "forms"))
|
self.assertEqual(manifest.dependencies, ("access", "forms"))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{permission.scope for permission in manifest.permissions},
|
{permission.scope for permission in manifest.permissions},
|
||||||
{PARTICIPATE_SCOPE, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE},
|
{PARTICIPATE_SCOPE, ASSIST_SCOPE, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE},
|
||||||
)
|
)
|
||||||
participant = next(
|
participant = next(
|
||||||
item
|
item
|
||||||
|
|||||||
@@ -32,11 +32,18 @@ class FormsRuntimeMigrationTests(unittest.TestCase):
|
|||||||
"form_instance_revisions",
|
"form_instance_revisions",
|
||||||
"form_instance_events",
|
"form_instance_events",
|
||||||
"form_handoff_effects",
|
"form_handoff_effects",
|
||||||
|
"form_intake_profiles",
|
||||||
|
"form_intake_sessions",
|
||||||
|
"form_assisted_confirmations",
|
||||||
|
"form_status_access_policies",
|
||||||
|
"form_status_access_grants",
|
||||||
|
"form_status_access_tokens",
|
||||||
|
"form_acknowledgements",
|
||||||
}.issubset(inspect(engine).get_table_names())
|
}.issubset(inspect(engine).get_table_names())
|
||||||
)
|
)
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"a3d5f7b9c1e2",
|
"d6a8b0c2e4f6",
|
||||||
set(MigrationContext.configure(connection).get_current_heads()),
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import InstitutionalReference
|
||||||
|
from govoplan_core.core.records import RecordContractError, RecordSourceLocator
|
||||||
|
from govoplan_forms_runtime.backend.db.models import (
|
||||||
|
FormInstanceIdentity,
|
||||||
|
FormInstanceRevision,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.domain import FormInstance
|
||||||
|
from govoplan_forms_runtime.backend.record_source import FormsRuntimeRecordSource
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 6, 10, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Principal:
|
||||||
|
account_id: str = "account-1"
|
||||||
|
tenant_id: str = "tenant-1"
|
||||||
|
scopes: tuple[str, ...] = ("forms_runtime:submission:participate",)
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
return scope in self.scopes
|
||||||
|
|
||||||
|
|
||||||
|
class FormsRuntimeRecordSourceTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
FormInstanceIdentity.__table__.create(self.engine)
|
||||||
|
FormInstanceRevision.__table__.create(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
instance = FormInstance(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id="submission-1",
|
||||||
|
revision=2,
|
||||||
|
status="submitted",
|
||||||
|
definition_ref=InstitutionalReference(
|
||||||
|
kind="form",
|
||||||
|
owner_module="forms",
|
||||||
|
object_id="permit-form",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="3",
|
||||||
|
),
|
||||||
|
values={"name": "Ada"},
|
||||||
|
validation_results=(),
|
||||||
|
receipt_id="receipt-1",
|
||||||
|
recorded_at=NOW,
|
||||||
|
change_reason="Submitted.",
|
||||||
|
created_by="account-1",
|
||||||
|
changed_by="account-1",
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
FormInstanceIdentity(
|
||||||
|
id="identity-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id="submission-1",
|
||||||
|
definition_id="permit-form",
|
||||||
|
definition_revision="3",
|
||||||
|
created_by="account-1",
|
||||||
|
),
|
||||||
|
FormInstanceRevision(
|
||||||
|
id="revision-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id="submission-1",
|
||||||
|
identity_id="identity-1",
|
||||||
|
revision=2,
|
||||||
|
status="submitted",
|
||||||
|
recorded_at=NOW,
|
||||||
|
snapshot=instance.to_dict(),
|
||||||
|
changed_by="account-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def locator(self) -> RecordSourceLocator:
|
||||||
|
return RecordSourceLocator(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
source_module="forms_runtime",
|
||||||
|
resource_type="form_submission_revision",
|
||||||
|
resource_id="submission-1",
|
||||||
|
source_revision="2",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolves_exact_authorized_immutable_submission(self) -> None:
|
||||||
|
result = FormsRuntimeRecordSource().resolve(
|
||||||
|
self.session,
|
||||||
|
Principal(),
|
||||||
|
locator=self.locator(),
|
||||||
|
purpose="file permit request",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("Form submission receipt-1", result.label)
|
||||||
|
self.assertEqual(64, len(result.content_sha256 or ""))
|
||||||
|
self.assertEqual("submitted", result.metadata["status"])
|
||||||
|
self.assertNotIn("values", result.metadata)
|
||||||
|
|
||||||
|
def test_current_access_is_rechecked(self) -> None:
|
||||||
|
with self.assertRaisesRegex(RecordContractError, "cannot read"):
|
||||||
|
FormsRuntimeRecordSource().resolve(
|
||||||
|
self.session,
|
||||||
|
Principal(account_id="account-2"),
|
||||||
|
locator=self.locator(),
|
||||||
|
purpose="file permit request",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = FormsRuntimeRecordSource().resolve(
|
||||||
|
self.session,
|
||||||
|
Principal(
|
||||||
|
account_id="account-2",
|
||||||
|
scopes=("forms_runtime:workspace:read",),
|
||||||
|
),
|
||||||
|
locator=self.locator(),
|
||||||
|
purpose="records administration",
|
||||||
|
)
|
||||||
|
self.assertEqual("receipt-1", result.metadata["receipt_id"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent
|
||||||
|
from govoplan_core.core.search import (
|
||||||
|
SearchAuthorizationRequest,
|
||||||
|
SearchBackfillRequest,
|
||||||
|
SearchResourceReference,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.db.models import (
|
||||||
|
FormInstanceIdentity,
|
||||||
|
FormInstanceRevision,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.search_source import (
|
||||||
|
FormsRuntimeSearchSource,
|
||||||
|
PROVIDER_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 6, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class FormsRuntimeSearchSourceTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
FormInstanceIdentity.__table__.create(self.engine)
|
||||||
|
FormInstanceRevision.__table__.create(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
identity = FormInstanceIdentity(
|
||||||
|
id="identity-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id="instance-1",
|
||||||
|
definition_id="permit-form",
|
||||||
|
definition_revision="4",
|
||||||
|
created_by="account-1",
|
||||||
|
)
|
||||||
|
revision = FormInstanceRevision(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id="instance-1",
|
||||||
|
identity_id="identity-row-1",
|
||||||
|
revision=3,
|
||||||
|
status="submitted",
|
||||||
|
recorded_at=NOW,
|
||||||
|
changed_by="account-1",
|
||||||
|
snapshot={
|
||||||
|
"definition_ref": {
|
||||||
|
"object_id": "permit-form",
|
||||||
|
"version": "4",
|
||||||
|
"label": "Permit application",
|
||||||
|
},
|
||||||
|
"values": {"protected_field": "PROTECTED-VALUE"},
|
||||||
|
"receipt_id": "receipt-1",
|
||||||
|
"service_ref": {"object_id": "permit", "version": "2"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.session.add_all((identity, revision))
|
||||||
|
self.session.commit()
|
||||||
|
self.source = FormsRuntimeSearchSource()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_search_projection_excludes_submitted_values_and_rechecks_access(self) -> None:
|
||||||
|
page = self.source.backfill(
|
||||||
|
self.session,
|
||||||
|
request=SearchBackfillRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
resource_type="form_submission",
|
||||||
|
rebuild_id="rebuild-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
document = page.documents[0]
|
||||||
|
self.assertNotIn("PROTECTED-VALUE", repr(document))
|
||||||
|
self.assertFalse(document.metadata["protected_values_indexed"])
|
||||||
|
self.assertEqual("Permit application", document.title)
|
||||||
|
|
||||||
|
reference = SearchResourceReference(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
module_id="forms_runtime",
|
||||||
|
resource_type="form_submission",
|
||||||
|
resource_id="instance-1",
|
||||||
|
)
|
||||||
|
request = SearchAuthorizationRequest(reference=reference, source_revision="3")
|
||||||
|
self.assertTrue(
|
||||||
|
self.source.authorize(
|
||||||
|
self.session,
|
||||||
|
_principal({"forms_runtime:submission:participate"}),
|
||||||
|
requests=(request,),
|
||||||
|
)[reference.key]
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
self.source.authorize(
|
||||||
|
self.session,
|
||||||
|
_principal({"forms_runtime:workspace:read"}, account_id="other"),
|
||||||
|
requests=(request,),
|
||||||
|
)[reference.key]
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
self.source.authorize(
|
||||||
|
self.session,
|
||||||
|
_principal({"forms_runtime:submission:participate"}, account_id="other"),
|
||||||
|
requests=(request,),
|
||||||
|
)[reference.key]
|
||||||
|
)
|
||||||
|
|
||||||
|
changes = self.source.index_changes_for_event(
|
||||||
|
self.session,
|
||||||
|
event=PlatformEvent(
|
||||||
|
type="forms_runtime.instance.submitted",
|
||||||
|
module_id="forms_runtime",
|
||||||
|
tenant=EventTenantRef(id="tenant-1"),
|
||||||
|
resource=EventObjectRef(type="form_submission", id="instance-1"),
|
||||||
|
),
|
||||||
|
delivery_key="delivery-1",
|
||||||
|
)
|
||||||
|
self.assertEqual("upsert", changes[0].kind)
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(scopes: set[str], *, account_id: str = "account-1") -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id=account_id,
|
||||||
|
membership_id=f"membership-{account_id}",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset(scopes),
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id=account_id),
|
||||||
|
user=SimpleNamespace(id=f"membership-{account_id}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/forms-runtime-webui",
|
"name": "@govoplan/forms-runtime-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -17,6 +17,104 @@ export type EvidenceReference = {
|
|||||||
evidence_id: string;
|
evidence_id: string;
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
version?: string | null;
|
version?: string | null;
|
||||||
|
checksum?: string | null;
|
||||||
|
source_ref?: string | null;
|
||||||
|
derived_from?: string[];
|
||||||
|
responsible_actor_ref?: string | null;
|
||||||
|
captured_at?: string | null;
|
||||||
|
inspection_url?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormEvidenceGrant = {
|
||||||
|
provider_id: string;
|
||||||
|
grant_id: string;
|
||||||
|
upload_token?: string | null;
|
||||||
|
upload_url: string;
|
||||||
|
expires_at: string;
|
||||||
|
max_size_bytes: number;
|
||||||
|
allowed_content_types: string[];
|
||||||
|
replayed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormIntakeProfile = {
|
||||||
|
profile_id: string;
|
||||||
|
public_id: string;
|
||||||
|
definition_ref: InstitutionalReference;
|
||||||
|
mode: "anonymous" | "invitation" | "assisted";
|
||||||
|
enabled: boolean;
|
||||||
|
revision: number;
|
||||||
|
draft_ttl_seconds: number;
|
||||||
|
invitation_ttl_seconds: number;
|
||||||
|
rate_limit_per_minute: number;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormStatusAccessMode = "authenticated" | "email_link" | "permanent_link";
|
||||||
|
|
||||||
|
export type FormStatusAccessPolicy = {
|
||||||
|
policy_id: string;
|
||||||
|
definition_ref: InstitutionalReference;
|
||||||
|
mode: FormStatusAccessMode;
|
||||||
|
enabled: boolean;
|
||||||
|
revision: number;
|
||||||
|
email_field_key?: string | null;
|
||||||
|
token_ttl_seconds: number;
|
||||||
|
request_limit_per_hour: number;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormStatusAccessSummary = {
|
||||||
|
tracking_id: string;
|
||||||
|
mode: FormStatusAccessMode;
|
||||||
|
href: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicIntakeResult = {
|
||||||
|
session_id: string;
|
||||||
|
mode: "anonymous" | "invitation" | "assisted";
|
||||||
|
status: string;
|
||||||
|
expires_at: string;
|
||||||
|
instance?: FormInstance | null;
|
||||||
|
token?: string | null;
|
||||||
|
replayed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssistedIntakeContext = {
|
||||||
|
session_id: string;
|
||||||
|
profile_id: string;
|
||||||
|
mode: "assisted";
|
||||||
|
channel: "counter" | "telephone" | "paper" | "email" | "mobile" | "representative" | "offline_import";
|
||||||
|
affected_party_ref: string;
|
||||||
|
represented_party_ref?: string | null;
|
||||||
|
authority_basis: string;
|
||||||
|
purpose: string;
|
||||||
|
legal_basis_ref?: string | null;
|
||||||
|
consent_basis?: string | null;
|
||||||
|
notice_given: boolean;
|
||||||
|
responsible_function_ref: string;
|
||||||
|
language: string;
|
||||||
|
accessibility_needs: string[];
|
||||||
|
field_sources: Record<string, {
|
||||||
|
source: "person_statement" | "representative_statement" | "document" | "system" | "derived";
|
||||||
|
confidence: "stated" | "verified" | "uncertain";
|
||||||
|
declared_by_ref?: string | null;
|
||||||
|
}>;
|
||||||
|
operator: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssistedConfirmation = {
|
||||||
|
confirmation_id: string;
|
||||||
|
instance_id: string;
|
||||||
|
instance_revision: number;
|
||||||
|
outcome: "confirmed" | "corrected" | "confirmation_unavailable";
|
||||||
|
method: "spoken_readback" | "written_preview" | "accessible_copy" | "unavailable";
|
||||||
|
confirmed_by_ref: string;
|
||||||
|
operator_actor_id: string;
|
||||||
|
confirmed_at: string;
|
||||||
|
payload_sha256: string;
|
||||||
|
correction_note?: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FormCondition =
|
export type FormCondition =
|
||||||
@@ -99,6 +197,8 @@ export type FormInstance = {
|
|||||||
change_reason: string;
|
change_reason: string;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
changed_by: string;
|
changed_by: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
status_access?: FormStatusAccessSummary | null;
|
||||||
replayed: boolean;
|
replayed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,15 +283,17 @@ export function saveFormDraft(
|
|||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
instance: FormInstance,
|
instance: FormInstance,
|
||||||
values: Record<string, unknown>,
|
values: Record<string, unknown>,
|
||||||
changeReason: string
|
changeReason: string,
|
||||||
|
attachmentRefs: EvidenceReference[] = instance.attachment_refs,
|
||||||
|
signatureRefs: EvidenceReference[] = instance.signature_refs
|
||||||
): Promise<FormInstance> {
|
): Promise<FormInstance> {
|
||||||
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}`, {
|
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
expected_revision: instance.revision,
|
expected_revision: instance.revision,
|
||||||
values,
|
values,
|
||||||
attachment_refs: instance.attachment_refs,
|
attachment_refs: attachmentRefs,
|
||||||
signature_refs: instance.signature_refs,
|
signature_refs: signatureRefs,
|
||||||
idempotency_key: crypto.randomUUID(),
|
idempotency_key: crypto.randomUUID(),
|
||||||
recorded_at: new Date().toISOString(),
|
recorded_at: new Date().toISOString(),
|
||||||
change_reason: changeReason
|
change_reason: changeReason
|
||||||
@@ -202,14 +304,374 @@ export function saveFormDraft(
|
|||||||
export function submitFormInstance(
|
export function submitFormInstance(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
instance: FormInstance,
|
instance: FormInstance,
|
||||||
values: Record<string, unknown>
|
values: Record<string, unknown>,
|
||||||
|
attachmentRefs: EvidenceReference[] = instance.attachment_refs,
|
||||||
|
signatureRefs: EvidenceReference[] = instance.signature_refs
|
||||||
): Promise<FormInstance> {
|
): Promise<FormInstance> {
|
||||||
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/submit`, {
|
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/submit`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
expected_revision: instance.revision,
|
expected_revision: instance.revision,
|
||||||
values,
|
values,
|
||||||
attachment_refs: instance.attachment_refs,
|
attachment_refs: attachmentRefs,
|
||||||
|
signature_refs: signatureRefs,
|
||||||
|
idempotency_key: crypto.randomUUID(),
|
||||||
|
recorded_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFormEvidenceGrant(
|
||||||
|
settings: ApiSettings,
|
||||||
|
instance: FormInstance,
|
||||||
|
options: {
|
||||||
|
idempotencyKey: string;
|
||||||
|
providerId?: string;
|
||||||
|
purpose?: string;
|
||||||
|
allowedContentTypes?: string[];
|
||||||
|
attachmentRefs?: EvidenceReference[];
|
||||||
|
}
|
||||||
|
): Promise<FormEvidenceGrant> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/evidence-grants`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_revision: instance.revision,
|
||||||
|
provider_id: options.providerId ?? "files",
|
||||||
|
purpose: options.purpose ?? "Form attachment",
|
||||||
|
idempotency_key: options.idempotencyKey,
|
||||||
|
expires_at: new Date(Date.now() + 10 * 60_000).toISOString(),
|
||||||
|
allowed_content_types: options.allowedContentTypes ?? [],
|
||||||
|
attachment_refs: options.attachmentRefs ?? instance.attachment_refs
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadFormEvidence(
|
||||||
|
settings: ApiSettings,
|
||||||
|
grant: FormEvidenceGrant,
|
||||||
|
file: File
|
||||||
|
): Promise<{ grant_id: string; evidence: EvidenceReference }> {
|
||||||
|
if (!grant.upload_token) {
|
||||||
|
return Promise.reject(new Error("The upload grant secret is no longer available. Request a new grant."));
|
||||||
|
}
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", file);
|
||||||
|
return apiFetch(settings, grant.upload_url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-Form-Evidence-Token": grant.upload_token },
|
||||||
|
body
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acknowledgeFormInstance(
|
||||||
|
settings: ApiSettings,
|
||||||
|
instance: FormInstance,
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
attachmentRefs: EvidenceReference[],
|
||||||
|
options: { statementId: string; statementVersion: string; idempotencyKey: string }
|
||||||
|
): Promise<{ evidence: EvidenceReference }> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/acknowledgements`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_revision: instance.revision,
|
||||||
|
statement_id: options.statementId,
|
||||||
|
statement_version: options.statementVersion,
|
||||||
|
values,
|
||||||
|
attachment_refs: attachmentRefs,
|
||||||
|
accepted_at: new Date().toISOString(),
|
||||||
|
idempotency_key: options.idempotencyKey
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFormIntakeProfiles(
|
||||||
|
settings: ApiSettings,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ profiles: FormIntakeProfile[] }> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms-runtime/intake-profiles", { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFormIntakeDefinitions(
|
||||||
|
settings: ApiSettings,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ definitions: FormDefinition[] }> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms-runtime/intake-profile-definitions?limit=200", { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFormIntakeProfile(
|
||||||
|
settings: ApiSettings,
|
||||||
|
definitionRef: InstitutionalReference,
|
||||||
|
mode: "anonymous" | "invitation" | "assisted",
|
||||||
|
options: {
|
||||||
|
draftTtlSeconds?: number;
|
||||||
|
invitationTtlSeconds?: number;
|
||||||
|
rateLimitPerMinute?: number;
|
||||||
|
} = {}
|
||||||
|
): Promise<FormIntakeProfile> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms-runtime/intake-profiles", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
definition_ref: definitionRef,
|
||||||
|
mode,
|
||||||
|
draft_ttl_seconds: options.draftTtlSeconds ?? 2_592_000,
|
||||||
|
invitation_ttl_seconds: options.invitationTtlSeconds ?? 1_209_600,
|
||||||
|
rate_limit_per_minute: options.rateLimitPerMinute ?? 60,
|
||||||
|
recorded_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFormStatusAccessPolicies(
|
||||||
|
settings: ApiSettings,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ policies: FormStatusAccessPolicy[] }> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms-runtime/status-access/policies", { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveFormStatusAccessPolicy(
|
||||||
|
settings: ApiSettings,
|
||||||
|
options: {
|
||||||
|
definitionRef: InstitutionalReference;
|
||||||
|
mode: FormStatusAccessMode;
|
||||||
|
enabled: boolean;
|
||||||
|
emailFieldKey?: string;
|
||||||
|
tokenTtlSeconds: number;
|
||||||
|
requestLimitPerHour: number;
|
||||||
|
expectedRevision?: number;
|
||||||
|
}
|
||||||
|
): Promise<FormStatusAccessPolicy> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms-runtime/status-access/policies", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
definition_ref: options.definitionRef,
|
||||||
|
mode: options.mode,
|
||||||
|
enabled: options.enabled,
|
||||||
|
email_field_key: options.mode === "email_link" ? options.emailFieldKey?.trim() || null : null,
|
||||||
|
token_ttl_seconds: options.tokenTtlSeconds,
|
||||||
|
request_limit_per_hour: options.requestLimitPerHour,
|
||||||
|
expected_revision: options.expectedRevision ?? null,
|
||||||
|
recorded_at: new Date().toISOString(),
|
||||||
|
metadata: {}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAssistedIntakeProfiles(
|
||||||
|
settings: ApiSettings,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ profiles: FormIntakeProfile[] }> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms-runtime/assisted-intake/profiles", { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startAssistedFormIntake(
|
||||||
|
settings: ApiSettings,
|
||||||
|
options: {
|
||||||
|
profileId: string;
|
||||||
|
channel: AssistedIntakeContext["channel"];
|
||||||
|
affectedPartyRef: string;
|
||||||
|
representedPartyRef?: string;
|
||||||
|
authorityBasis: string;
|
||||||
|
purpose: string;
|
||||||
|
legalBasisRef?: string;
|
||||||
|
consentBasis?: string;
|
||||||
|
noticeGiven: boolean;
|
||||||
|
responsibleFunctionRef: string;
|
||||||
|
language: string;
|
||||||
|
accessibilityNeeds: string[];
|
||||||
|
}
|
||||||
|
): Promise<PublicIntakeResult> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms-runtime/assisted-intake/start", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
profile_id: options.profileId,
|
||||||
|
values: {},
|
||||||
|
channel: options.channel,
|
||||||
|
affected_party_ref: options.affectedPartyRef,
|
||||||
|
represented_party_ref: options.representedPartyRef?.trim() || null,
|
||||||
|
authority_basis: options.authorityBasis,
|
||||||
|
purpose: options.purpose,
|
||||||
|
legal_basis_ref: options.legalBasisRef?.trim() || null,
|
||||||
|
consent_basis: options.consentBasis?.trim() || null,
|
||||||
|
notice_given: options.noticeGiven,
|
||||||
|
responsible_function_ref: options.responsibleFunctionRef,
|
||||||
|
language: options.language,
|
||||||
|
accessibility_needs: options.accessibilityNeeds,
|
||||||
|
field_sources: {},
|
||||||
|
idempotency_key: crypto.randomUUID(),
|
||||||
|
recorded_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAssistedConfirmations(
|
||||||
|
settings: ApiSettings,
|
||||||
|
instanceId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ confirmations: AssistedConfirmation[] }> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/assisted-confirmations`,
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordAssistedConfirmation(
|
||||||
|
settings: ApiSettings,
|
||||||
|
instance: FormInstance,
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
attachmentRefs: EvidenceReference[],
|
||||||
|
signatureRefs: EvidenceReference[],
|
||||||
|
options: {
|
||||||
|
outcome: AssistedConfirmation["outcome"];
|
||||||
|
method: AssistedConfirmation["method"];
|
||||||
|
confirmedByRef: string;
|
||||||
|
fieldSources: AssistedIntakeContext["field_sources"];
|
||||||
|
correctionNote?: string;
|
||||||
|
}
|
||||||
|
): Promise<AssistedConfirmation> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/assisted-confirmations`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_revision: instance.revision,
|
||||||
|
values,
|
||||||
|
attachment_refs: attachmentRefs,
|
||||||
|
signature_refs: signatureRefs,
|
||||||
|
outcome: options.outcome,
|
||||||
|
method: options.method,
|
||||||
|
confirmed_by_ref: options.confirmedByRef,
|
||||||
|
confirmed_at: new Date().toISOString(),
|
||||||
|
idempotency_key: crypto.randomUUID(),
|
||||||
|
field_sources: options.fieldSources,
|
||||||
|
correction_note: options.correctionNote?.trim() || null,
|
||||||
|
metadata: {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFormIntakeProfileEnabled(
|
||||||
|
settings: ApiSettings,
|
||||||
|
profile: FormIntakeProfile,
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<FormIntakeProfile> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/forms-runtime/intake-profiles/${encodeURIComponent(profile.profile_id)}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ expected_revision: profile.revision, enabled })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function issueFormIntakeInvitation(
|
||||||
|
settings: ApiSettings,
|
||||||
|
profile: FormIntakeProfile,
|
||||||
|
idempotencyKey: string
|
||||||
|
): Promise<PublicIntakeResult> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/forms-runtime/intake-profiles/${encodeURIComponent(profile.profile_id)}/invitations`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
recorded_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startAnonymousFormIntake(
|
||||||
|
settings: ApiSettings,
|
||||||
|
publicId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
recordedAt: string
|
||||||
|
): Promise<PublicIntakeResult> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/forms-runtime/public/profiles/${encodeURIComponent(publicId)}/start`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
values: {},
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
recorded_at: recordedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startInvitationFormIntake(
|
||||||
|
settings: ApiSettings,
|
||||||
|
token: string
|
||||||
|
): Promise<PublicIntakeResult> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms-runtime/public/intake/start", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-Form-Intake-Token": token },
|
||||||
|
body: JSON.stringify({ values: {}, recorded_at: new Date().toISOString() })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublicFormIntake(
|
||||||
|
settings: ApiSettings,
|
||||||
|
token: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ instance: FormInstance; definition: FormDefinition }> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms-runtime/public/intake", {
|
||||||
|
headers: { "X-Form-Intake-Token": token },
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePublicFormDraft(
|
||||||
|
settings: ApiSettings,
|
||||||
|
token: string,
|
||||||
|
instance: FormInstance,
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
attachmentRefs: EvidenceReference[],
|
||||||
|
changeReason: string
|
||||||
|
): Promise<FormInstance> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms-runtime/public/intake", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "X-Form-Intake-Token": token },
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_revision: instance.revision,
|
||||||
|
values,
|
||||||
|
attachment_refs: attachmentRefs,
|
||||||
|
signature_refs: instance.signature_refs,
|
||||||
|
idempotency_key: crypto.randomUUID(),
|
||||||
|
recorded_at: new Date().toISOString(),
|
||||||
|
change_reason: changeReason
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitPublicFormIntake(
|
||||||
|
settings: ApiSettings,
|
||||||
|
token: string,
|
||||||
|
instance: FormInstance,
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
attachmentRefs: EvidenceReference[]
|
||||||
|
): Promise<FormInstance> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms-runtime/public/intake/submit", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-Form-Intake-Token": token },
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_revision: instance.revision,
|
||||||
|
values,
|
||||||
|
attachment_refs: attachmentRefs,
|
||||||
signature_refs: instance.signature_refs,
|
signature_refs: instance.signature_refs,
|
||||||
idempotency_key: crypto.randomUUID(),
|
idempotency_key: crypto.randomUUID(),
|
||||||
recorded_at: new Date().toISOString()
|
recorded_at: new Date().toISOString()
|
||||||
@@ -217,6 +679,27 @@ export function submitFormInstance(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createPublicFormEvidenceGrant(
|
||||||
|
settings: ApiSettings,
|
||||||
|
token: string,
|
||||||
|
instance: FormInstance,
|
||||||
|
idempotencyKey: string,
|
||||||
|
attachmentRefs: EvidenceReference[] = instance.attachment_refs
|
||||||
|
): Promise<FormEvidenceGrant> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms-runtime/public/intake/evidence-grants", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-Form-Intake-Token": token },
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_revision: instance.revision,
|
||||||
|
provider_id: "files",
|
||||||
|
purpose: "Public Form attachment",
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
expires_at: new Date(Date.now() + 10 * 60_000).toISOString(),
|
||||||
|
attachment_refs: attachmentRefs
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function listFormHandoffs(
|
export function listFormHandoffs(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { Play } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogForm,
|
||||||
|
DialogSection,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
FormGrid,
|
||||||
|
LoadingIndicator,
|
||||||
|
ToggleSwitch,
|
||||||
|
type PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
listAssistedIntakeProfiles,
|
||||||
|
startAssistedFormIntake,
|
||||||
|
type AssistedIntakeContext,
|
||||||
|
type FormIntakeProfile
|
||||||
|
} from "../../api/formsRuntime";
|
||||||
|
|
||||||
|
|
||||||
|
type AssistedIntakeDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
settings: PlatformRouteContext["settings"];
|
||||||
|
language: string;
|
||||||
|
onStarted: (instanceId: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AssistedIntakeDialog({
|
||||||
|
open,
|
||||||
|
settings,
|
||||||
|
language,
|
||||||
|
onStarted,
|
||||||
|
onClose
|
||||||
|
}: AssistedIntakeDialogProps) {
|
||||||
|
const [profiles, setProfiles] = useState<FormIntakeProfile[]>([]);
|
||||||
|
const [profileId, setProfileId] = useState("");
|
||||||
|
const [channel, setChannel] = useState<AssistedIntakeContext["channel"]>("counter");
|
||||||
|
const [affectedPartyRef, setAffectedPartyRef] = useState("");
|
||||||
|
const [representedPartyRef, setRepresentedPartyRef] = useState("");
|
||||||
|
const [authorityBasis, setAuthorityBasis] = useState("self");
|
||||||
|
const [purpose, setPurpose] = useState("");
|
||||||
|
const [legalBasisRef, setLegalBasisRef] = useState("");
|
||||||
|
const [consentBasis, setConsentBasis] = useState("");
|
||||||
|
const [noticeGiven, setNoticeGiven] = useState(false);
|
||||||
|
const [responsibleFunctionRef, setResponsibleFunctionRef] = useState("");
|
||||||
|
const [accessibilityNeeds, setAccessibilityNeeds] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async (signal?: AbortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await listAssistedIntakeProfiles(settings, signal);
|
||||||
|
setProfiles(result.profiles);
|
||||||
|
setProfileId((current) => current || result.profiles[0]?.profile_id || "");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const controller = new AbortController();
|
||||||
|
void load(controller.signal).catch((reason) => {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Assisted intake profiles could not be loaded.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [load, open]);
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (!valid()) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await startAssistedFormIntake(settings, {
|
||||||
|
profileId,
|
||||||
|
channel,
|
||||||
|
affectedPartyRef: affectedPartyRef.trim(),
|
||||||
|
representedPartyRef: representedPartyRef.trim() || undefined,
|
||||||
|
authorityBasis,
|
||||||
|
purpose: purpose.trim(),
|
||||||
|
legalBasisRef: legalBasisRef.trim() || undefined,
|
||||||
|
consentBasis: consentBasis.trim() || undefined,
|
||||||
|
noticeGiven,
|
||||||
|
responsibleFunctionRef: responsibleFunctionRef.trim(),
|
||||||
|
language: language || "de",
|
||||||
|
accessibilityNeeds: accessibilityNeeds.split(/[,\n]/).map((item) => item.trim()).filter(Boolean)
|
||||||
|
});
|
||||||
|
if (!result.instance) throw new Error("The assisted session was created without a Form instance.");
|
||||||
|
onStarted(result.instance.instance_id);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The assisted intake could not be started.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function valid() {
|
||||||
|
return Boolean(
|
||||||
|
profileId
|
||||||
|
&& affectedPartyRef.trim()
|
||||||
|
&& authorityBasis.trim()
|
||||||
|
&& purpose.trim()
|
||||||
|
&& responsibleFunctionRef.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title="Start assisted intake"
|
||||||
|
description="Capture who is acting, for whom, through which channel, and for what purpose before entering Form values."
|
||||||
|
size="large"
|
||||||
|
closeDisabled={busy}
|
||||||
|
onClose={onClose}
|
||||||
|
helpContextId="forms_runtime.assisted-intake"
|
||||||
|
helpTopicId="forms_runtime.submissions"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={onClose} disabled={busy}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={() => void start()} disabled={busy || loading || !valid()}>
|
||||||
|
<Play size={16} aria-hidden="true" />
|
||||||
|
Start session
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{loading && <LoadingIndicator label="Loading assisted intake profiles" />}
|
||||||
|
{!loading && profiles.length === 0 &&
|
||||||
|
<DismissibleAlert tone="info">
|
||||||
|
No assisted intake profile is enabled. Ask a Forms Runtime administrator to add one for the published Form.
|
||||||
|
</DismissibleAlert>
|
||||||
|
}
|
||||||
|
{!loading && profiles.length > 0 &&
|
||||||
|
<DialogForm onSubmit={(event) => { event.preventDefault(); void start(); }}>
|
||||||
|
<DialogSection>
|
||||||
|
<FormGrid columns={2} gap="small" collapseAt="workspace">
|
||||||
|
<FormField label="Published Form">
|
||||||
|
<select value={profileId} onChange={(event) => setProfileId(event.target.value)} disabled={busy}>
|
||||||
|
{profiles.map((profile) =>
|
||||||
|
<option key={profile.profile_id} value={profile.profile_id}>
|
||||||
|
{profileTitle(profile)} · revision {profile.definition_ref.version}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Intake channel">
|
||||||
|
<select value={channel} onChange={(event) => setChannel(event.target.value as AssistedIntakeContext["channel"])} disabled={busy}>
|
||||||
|
<option value="counter">Service counter</option>
|
||||||
|
<option value="telephone">Telephone</option>
|
||||||
|
<option value="paper">Paper</option>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
<option value="mobile">Mobile service</option>
|
||||||
|
<option value="representative">Representative</option>
|
||||||
|
<option value="offline_import">Offline import</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Affected party reference" help="Use the governed person or organization reference; do not enter a display name only.">
|
||||||
|
<input value={affectedPartyRef} onChange={(event) => setAffectedPartyRef(event.target.value)} disabled={busy} required />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Represented party reference" help="Optional when the affected party is acting directly.">
|
||||||
|
<input value={representedPartyRef} onChange={(event) => setRepresentedPartyRef(event.target.value)} disabled={busy} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Authority basis">
|
||||||
|
<select value={authorityBasis} onChange={(event) => setAuthorityBasis(event.target.value)} disabled={busy}>
|
||||||
|
<option value="self">Acting for self</option>
|
||||||
|
<option value="documented_representation">Documented representation</option>
|
||||||
|
<option value="legal_guardianship">Legal guardianship</option>
|
||||||
|
<option value="statutory_authority">Statutory authority</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Responsible function reference">
|
||||||
|
<input value={responsibleFunctionRef} onChange={(event) => setResponsibleFunctionRef(event.target.value)} disabled={busy} required />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Purpose">
|
||||||
|
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} disabled={busy} required />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Legal basis reference">
|
||||||
|
<input value={legalBasisRef} onChange={(event) => setLegalBasisRef(event.target.value)} disabled={busy} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Consent basis">
|
||||||
|
<input value={consentBasis} onChange={(event) => setConsentBasis(event.target.value)} disabled={busy} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Accessibility or communication support" help="Separate multiple needs with commas.">
|
||||||
|
<input value={accessibilityNeeds} onChange={(event) => setAccessibilityNeeds(event.target.value)} disabled={busy} />
|
||||||
|
</FormField>
|
||||||
|
</FormGrid>
|
||||||
|
</DialogSection>
|
||||||
|
<DialogSection variant="inset">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Privacy and procedural notice was provided"
|
||||||
|
checked={noticeGiven}
|
||||||
|
onChange={setNoticeGiven}
|
||||||
|
disabled={busy}
|
||||||
|
help="Record the fact of notice here; retain any separately required evidence through its owning module."
|
||||||
|
/>
|
||||||
|
</DialogSection>
|
||||||
|
</DialogForm>
|
||||||
|
}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileTitle(profile: FormIntakeProfile): string {
|
||||||
|
const title = profile.metadata.definition_title;
|
||||||
|
return typeof title === "string" && title.trim()
|
||||||
|
? title
|
||||||
|
: profile.definition_ref.label ?? profile.definition_ref.object_id;
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import { ArrowLeft, ExternalLink, RefreshCw, Save, Send } from "lucide-react";
|
import { Archive, ArrowLeft, BadgeCheck, ClipboardCheck, ExternalLink, RefreshCw, Save, Send, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
import {
|
import { ActionBlockerHint,
|
||||||
ActionBlockerHint,
|
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
|
DescriptionItem,
|
||||||
|
DescriptionList,
|
||||||
|
Dialog,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
|
TextWithHelp,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
FileDropZone,
|
||||||
|
FormField as CoreFormField,
|
||||||
|
FormGrid,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@@ -14,8 +20,11 @@ import {
|
|||||||
hasScope,
|
hasScope,
|
||||||
i18nMessage,
|
i18nMessage,
|
||||||
useGuardedNavigate,
|
useGuardedNavigate,
|
||||||
|
usePlatformModuleInstalled,
|
||||||
usePlatformLanguage,
|
usePlatformLanguage,
|
||||||
useUnsavedDraftGuard,
|
useUnsavedDraftGuard,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
type PlatformRouteContext
|
type PlatformRouteContext
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
@@ -24,11 +33,19 @@ import {
|
|||||||
getFormInstanceEvents,
|
getFormInstanceEvents,
|
||||||
getFormInstanceHistory,
|
getFormInstanceHistory,
|
||||||
listFormHandoffs,
|
listFormHandoffs,
|
||||||
|
listAssistedConfirmations,
|
||||||
|
recordAssistedConfirmation,
|
||||||
startFormHandoff,
|
startFormHandoff,
|
||||||
actOnFormHandoff,
|
actOnFormHandoff,
|
||||||
|
acknowledgeFormInstance,
|
||||||
compensateFormHandoff,
|
compensateFormHandoff,
|
||||||
|
createFormEvidenceGrant,
|
||||||
saveFormDraft,
|
saveFormDraft,
|
||||||
submitFormInstance,
|
submitFormInstance,
|
||||||
|
uploadFormEvidence,
|
||||||
|
type EvidenceReference,
|
||||||
|
type AssistedConfirmation,
|
||||||
|
type AssistedIntakeContext,
|
||||||
type FormDefinition,
|
type FormDefinition,
|
||||||
type FormFieldDefinition,
|
type FormFieldDefinition,
|
||||||
type FormInstance,
|
type FormInstance,
|
||||||
@@ -47,12 +64,16 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
const { instanceId = "" } = useParams();
|
const { instanceId = "" } = useParams();
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
const { language } = usePlatformLanguage();
|
const { language } = usePlatformLanguage();
|
||||||
|
const recordsAvailable = usePlatformModuleInstalled("records");
|
||||||
const [instance, setInstance] = useState<FormInstance | null>(null);
|
const [instance, setInstance] = useState<FormInstance | null>(null);
|
||||||
const [definition, setDefinition] = useState<FormDefinition | null>(null);
|
const [definition, setDefinition] = useState<FormDefinition | null>(null);
|
||||||
const [history, setHistory] = useState<FormInstance[]>([]);
|
const [history, setHistory] = useState<FormInstance[]>([]);
|
||||||
const [events, setEvents] = useState<FormInstanceEvent[]>([]);
|
const [events, setEvents] = useState<FormInstanceEvent[]>([]);
|
||||||
const [handoffs, setHandoffs] = useState<FormHandoff[]>([]);
|
const [handoffs, setHandoffs] = useState<FormHandoff[]>([]);
|
||||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||||
|
const [attachmentRefs, setAttachmentRefs] = useState<EvidenceReference[]>([]);
|
||||||
|
const [attachmentNames, setAttachmentNames] = useState<Record<string, string>>({});
|
||||||
|
const [signatureRefs, setSignatureRefs] = useState<EvidenceReference[]>([]);
|
||||||
const [changeReason, setChangeReason] = useState("");
|
const [changeReason, setChangeReason] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -63,24 +84,45 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
const [compensating, setCompensating] = useState<FormHandoff | null>(null);
|
const [compensating, setCompensating] = useState<FormHandoff | null>(null);
|
||||||
const [confirmingSubmit, setConfirmingSubmit] = useState(false);
|
const [confirmingSubmit, setConfirmingSubmit] = useState(false);
|
||||||
const [confirmingHandoff, setConfirmingHandoff] = useState(false);
|
const [confirmingHandoff, setConfirmingHandoff] = useState(false);
|
||||||
|
const [confirmingAcknowledgement, setConfirmingAcknowledgement] = useState(false);
|
||||||
|
const [confirmingAssisted, setConfirmingAssisted] = useState(false);
|
||||||
|
const [assistedConfirmations, setAssistedConfirmations] = useState<AssistedConfirmation[]>([]);
|
||||||
|
const [assistedOutcome, setAssistedOutcome] = useState<AssistedConfirmation["outcome"]>("confirmed");
|
||||||
|
const [assistedMethod, setAssistedMethod] = useState<AssistedConfirmation["method"]>("spoken_readback");
|
||||||
|
const [assistedConfirmedBy, setAssistedConfirmedBy] = useState("");
|
||||||
|
const [assistedFieldSources, setAssistedFieldSources] = useState<AssistedIntakeContext["field_sources"]>({});
|
||||||
|
const [assistedNote, setAssistedNote] = useState("");
|
||||||
|
|
||||||
const load = useCallback(async (signal?: AbortSignal) => {
|
const load = useCallback(async (signal?: AbortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const nextInstance = await getFormInstance(settings, instanceId, signal);
|
const nextInstance = await getFormInstance(settings, instanceId, signal);
|
||||||
const [nextDefinition, nextHistory, nextEvents, nextHandoffs] = await Promise.all([
|
const assisted = intakeContext(nextInstance);
|
||||||
|
const [nextDefinition, nextHistory, nextEvents, nextHandoffs, nextConfirmations] = await Promise.all([
|
||||||
getFormDefinition(settings, instanceId, signal),
|
getFormDefinition(settings, instanceId, signal),
|
||||||
getFormInstanceHistory(settings, instanceId, signal),
|
getFormInstanceHistory(settings, instanceId, signal),
|
||||||
getFormInstanceEvents(settings, instanceId, signal),
|
getFormInstanceEvents(settings, instanceId, signal),
|
||||||
listFormHandoffs(settings, instanceId, signal)
|
listFormHandoffs(settings, instanceId, signal),
|
||||||
|
assisted
|
||||||
|
? listAssistedConfirmations(settings, instanceId, signal)
|
||||||
|
: Promise.resolve({ confirmations: [] })
|
||||||
]);
|
]);
|
||||||
setInstance(nextInstance);
|
setInstance(nextInstance);
|
||||||
setDefinition(nextDefinition);
|
setDefinition(nextDefinition);
|
||||||
setHistory(nextHistory.revisions);
|
setHistory(nextHistory.revisions);
|
||||||
setEvents(nextEvents.events);
|
setEvents(nextEvents.events);
|
||||||
setHandoffs(nextHandoffs.handoffs);
|
setHandoffs(nextHandoffs.handoffs);
|
||||||
|
setAssistedConfirmations(nextConfirmations.confirmations);
|
||||||
|
setAssistedConfirmedBy((current) => current || assisted?.affected_party_ref || "");
|
||||||
|
setAssistedFieldSources(assistedSourcesForRevision(
|
||||||
|
nextInstance,
|
||||||
|
assisted,
|
||||||
|
nextConfirmations.confirmations
|
||||||
|
));
|
||||||
setValues(nextInstance.values);
|
setValues(nextInstance.values);
|
||||||
|
setAttachmentRefs(nextInstance.attachment_refs);
|
||||||
|
setSignatureRefs(nextInstance.signature_refs);
|
||||||
setChangeReason("");
|
setChangeReason("");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -110,10 +152,14 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
const canAdmin = hasScope(auth, "forms_runtime:workspace:admin");
|
const canAdmin = hasScope(auth, "forms_runtime:workspace:admin");
|
||||||
const canEditPermission = canParticipate || canWrite;
|
const canEditPermission = canParticipate || canWrite;
|
||||||
const editable = Boolean(editableLifecycle && canEditPermission);
|
const editable = Boolean(editableLifecycle && canEditPermission);
|
||||||
const canSave = Boolean(instance?.status === "draft" && definition?.allow_drafts && canEditPermission);
|
const canSave = Boolean(editableLifecycle && definition?.allow_drafts && canEditPermission);
|
||||||
const changed = useMemo(
|
const changed = useMemo(
|
||||||
() => Boolean(instance && JSON.stringify(values) !== JSON.stringify(instance.values)),
|
() => Boolean(instance && (
|
||||||
[instance, values]
|
JSON.stringify(values) !== JSON.stringify(instance.values)
|
||||||
|
|| JSON.stringify(attachmentRefs) !== JSON.stringify(instance.attachment_refs)
|
||||||
|
|| JSON.stringify(signatureRefs) !== JSON.stringify(instance.signature_refs)
|
||||||
|
)),
|
||||||
|
[attachmentRefs, instance, signatureRefs, values]
|
||||||
);
|
);
|
||||||
const diagnostics = useMemo(() => {
|
const diagnostics = useMemo(() => {
|
||||||
const grouped = new Map<string, ValidationResult[]>();
|
const grouped = new Map<string, ValidationResult[]>();
|
||||||
@@ -133,13 +179,25 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
);
|
);
|
||||||
const mayHandoff = Boolean(instance && ["submitted", "validated", "needs_review", "accepted"].includes(instance.status) && instance.service_ref);
|
const mayHandoff = Boolean(instance && ["submitted", "validated", "needs_review", "accepted"].includes(instance.status) && instance.service_ref);
|
||||||
const canHandoff = canWrite || canAdmin;
|
const canHandoff = canWrite || canAdmin;
|
||||||
|
const signatureRequired = definition?.signature_requirement === "required";
|
||||||
|
const signatureBlocked = Boolean(signatureRequired && signatureRefs.length === 0);
|
||||||
|
const attachmentLimitReached = Boolean(
|
||||||
|
definition && attachmentRefs.length >= definition.max_attachments
|
||||||
|
);
|
||||||
|
const assisted = intakeContext(instance);
|
||||||
|
const assistedConfirmationCurrent = Boolean(
|
||||||
|
assisted
|
||||||
|
&& !changed
|
||||||
|
&& assistedConfirmations.some((item) => item.instance_revision === instance?.revision)
|
||||||
|
);
|
||||||
|
const assistedSaveBlocked = Boolean(assisted && changed);
|
||||||
|
|
||||||
async function save(): Promise<boolean> {
|
async function save(): Promise<boolean> {
|
||||||
if (!instance || !canSave || !changed || !changeReason.trim()) return false;
|
if (!instance || !canSave || !changed || !changeReason.trim()) return false;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await saveFormDraft(settings, instance, values, changeReason.trim());
|
await saveFormDraft(settings, instance, values, changeReason.trim(), attachmentRefs, signatureRefs);
|
||||||
await load();
|
await load();
|
||||||
return true;
|
return true;
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
@@ -155,6 +213,8 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
onSave: save,
|
onSave: save,
|
||||||
onDiscard: () => {
|
onDiscard: () => {
|
||||||
setValues(instance?.values ?? {});
|
setValues(instance?.values ?? {});
|
||||||
|
setAttachmentRefs(instance?.attachment_refs ?? []);
|
||||||
|
setSignatureRefs(instance?.signature_refs ?? []);
|
||||||
setChangeReason("");
|
setChangeReason("");
|
||||||
},
|
},
|
||||||
title: "i18n:govoplan-forms-runtime.unsaved_title",
|
title: "i18n:govoplan-forms-runtime.unsaved_title",
|
||||||
@@ -166,7 +226,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await submitFormInstance(settings, instance, values);
|
await submitFormInstance(settings, instance, values, attachmentRefs, signatureRefs);
|
||||||
await load();
|
await load();
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : "The Form could not be submitted.");
|
setError(reason instanceof Error ? reason.message : "The Form could not be submitted.");
|
||||||
@@ -175,6 +235,123 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmAssistedReadback() {
|
||||||
|
if (!instance || !assisted || !assistedConfirmedBy.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const confirmation = await recordAssistedConfirmation(
|
||||||
|
settings,
|
||||||
|
instance,
|
||||||
|
values,
|
||||||
|
attachmentRefs,
|
||||||
|
signatureRefs,
|
||||||
|
{
|
||||||
|
outcome: assistedOutcome,
|
||||||
|
method: assistedMethod,
|
||||||
|
confirmedByRef: assistedConfirmedBy.trim(),
|
||||||
|
fieldSources: Object.fromEntries(
|
||||||
|
Object.entries(values)
|
||||||
|
.filter(([, value]) => value !== null && value !== undefined && value !== "")
|
||||||
|
.map(([key]) => [key, assistedFieldSources[key] ?? defaultAssistedFieldSource(
|
||||||
|
assistedConfirmedBy.trim() || assisted.affected_party_ref
|
||||||
|
)])
|
||||||
|
),
|
||||||
|
correctionNote: assistedNote
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setAssistedConfirmations((current) => [confirmation, ...current]);
|
||||||
|
setConfirmingAssisted(false);
|
||||||
|
setConfirmingSubmit(true);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The assisted read-back confirmation could not be recorded.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeValue(fieldKey: string, value: unknown) {
|
||||||
|
setValues((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
if (value === undefined || value === "") delete next[fieldKey];
|
||||||
|
else next[fieldKey] = value;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setSignatureRefs([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeAssistedFieldSource(
|
||||||
|
fieldKey: string,
|
||||||
|
patch: Partial<AssistedIntakeContext["field_sources"][string]>
|
||||||
|
) {
|
||||||
|
setAssistedFieldSources((current) => ({
|
||||||
|
...current,
|
||||||
|
[fieldKey]: {
|
||||||
|
...(current[fieldKey] ?? defaultAssistedFieldSource(
|
||||||
|
assistedConfirmedBy.trim() || assisted?.affected_party_ref || ""
|
||||||
|
)),
|
||||||
|
...patch
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAttachments(files: File[]) {
|
||||||
|
if (!instance || !definition) return;
|
||||||
|
const available = Math.max(0, definition.max_attachments - attachmentRefs.length);
|
||||||
|
const selected = files.slice(0, available);
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const uploaded: EvidenceReference[] = [];
|
||||||
|
const names: Record<string, string> = {};
|
||||||
|
for (const file of selected) {
|
||||||
|
const grant = await createFormEvidenceGrant(settings, instance, {
|
||||||
|
idempotencyKey: crypto.randomUUID(),
|
||||||
|
purpose: "Form attachment",
|
||||||
|
attachmentRefs: [...attachmentRefs, ...uploaded]
|
||||||
|
});
|
||||||
|
const result = await uploadFormEvidence(settings, grant, file);
|
||||||
|
uploaded.push(result.evidence);
|
||||||
|
names[result.evidence.evidence_id] = file.name;
|
||||||
|
}
|
||||||
|
setAttachmentRefs((current) => [...current, ...uploaded]);
|
||||||
|
setAttachmentNames((current) => ({ ...current, ...names }));
|
||||||
|
setSignatureRefs([]);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The attachment could not be uploaded.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acknowledge() {
|
||||||
|
if (!instance || !editable) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await acknowledgeFormInstance(
|
||||||
|
settings,
|
||||||
|
instance,
|
||||||
|
values,
|
||||||
|
attachmentRefs,
|
||||||
|
{
|
||||||
|
statementId: "forms_runtime.submission.correct_and_complete",
|
||||||
|
statementVersion: "1",
|
||||||
|
idempotencyKey: crypto.randomUUID()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setSignatureRefs((current) => [
|
||||||
|
...current.filter((item) => item.owner_module !== "forms_runtime"),
|
||||||
|
result.evidence
|
||||||
|
]);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The acknowledgement could not be recorded.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function startHandoff() {
|
async function startHandoff() {
|
||||||
if (!instance || !mayHandoff) return;
|
if (!instance || !mayHandoff) return;
|
||||||
setHandoffBusy(true);
|
setHandoffBusy(true);
|
||||||
@@ -219,16 +396,30 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="forms-runtime-page">
|
<main className="forms-runtime-page">
|
||||||
<div className="form-instance-shell">
|
<WorkspaceFrame className="form-instance-shell" label="Form instance" interfaceId="forms-runtime.instance" helpContextId="forms-runtime.page.instance" helpModuleId="forms-runtime">
|
||||||
<div className="form-instance-toolbar">
|
<WorkspaceActionBar
|
||||||
|
title="Form instance"
|
||||||
|
titleHelp={<DocumentationHelpLink reference={FORMS_RUNTIME_DOCUMENTATION} />}
|
||||||
|
scope="workspace"
|
||||||
|
variant="detail"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void load(), loading }}
|
||||||
|
className="form-instance-toolbar"
|
||||||
|
contextActions={<span className="form-instance-toolbar-context">
|
||||||
<Button onClick={() => navigate("/forms-runtime")}>
|
<Button onClick={() => navigate("/forms-runtime")}>
|
||||||
<ArrowLeft size={16} aria-hidden="true" />
|
<ArrowLeft size={16} aria-hidden="true" />
|
||||||
Forms
|
Forms
|
||||||
</Button>
|
</Button>
|
||||||
{definition && <strong>{localized.title}</strong>}
|
{definition && <strong>{localized.title}</strong>}
|
||||||
{instance && <StatusBadge status={editableLifecycle ? "active" : "inactive"} label={stateLabel(instance.status)} />}
|
{instance && <StatusBadge status={editableLifecycle ? "active" : "inactive"} label={stateLabel(instance.status)} />}
|
||||||
<DocumentationHelpLink reference={FORMS_RUNTIME_DOCUMENTATION} />
|
</span>}
|
||||||
</div>
|
primaryActions={instance && recordsAvailable && !editableLifecycle ?
|
||||||
|
<Button onClick={() => navigate(recordFilingPath(instance))}>
|
||||||
|
<Archive size={16} aria-hidden="true" />
|
||||||
|
File in eAkte
|
||||||
|
</Button>
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
<PageScrollViewport className="form-instance-viewport">
|
<PageScrollViewport className="form-instance-viewport">
|
||||||
{error &&
|
{error &&
|
||||||
<DismissibleAlert tone="danger" resetKey={error}>
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
@@ -257,9 +448,38 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
<div className="form-instance-content">
|
<div className="form-instance-content">
|
||||||
<section className="form-instance-main">
|
<section className="form-instance-main">
|
||||||
<header>
|
<header>
|
||||||
<h1>{localized.title}</h1>
|
<TextWithHelp as="div" help={editable ? <DocumentationHelpLink reference={FORMS_RUNTIME_FIELD_DOCUMENTATION} /> : undefined}><h1>{localized.title}</h1></TextWithHelp>
|
||||||
{localized.description && <p>{localized.description}</p>}
|
{localized.description && <p>{localized.description}</p>}
|
||||||
</header>
|
</header>
|
||||||
|
{assisted &&
|
||||||
|
<section className="form-assisted-context">
|
||||||
|
<div className="form-evidence-heading">
|
||||||
|
<h2>Assisted intake context</h2>
|
||||||
|
<StatusBadge
|
||||||
|
status={assistedConfirmationCurrent || (!editableLifecycle && assistedConfirmations.length > 0) ? "active" : "inactive"}
|
||||||
|
label={editableLifecycle
|
||||||
|
? assistedConfirmationCurrent ? "Read-back current" : "Read-back required"
|
||||||
|
: assistedConfirmations.length > 0 ? "Read-back recorded" : "No read-back evidence"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DescriptionList columns={3} collapseAt="workspace" density="compact">
|
||||||
|
<DescriptionItem term="Channel">{humanize(assisted.channel)}</DescriptionItem>
|
||||||
|
<DescriptionItem term="Affected party">{assisted.affected_party_ref}</DescriptionItem>
|
||||||
|
{assisted.represented_party_ref && <DescriptionItem term="Represented party">{assisted.represented_party_ref}</DescriptionItem>}
|
||||||
|
<DescriptionItem term="Authority">{humanize(assisted.authority_basis)}</DescriptionItem>
|
||||||
|
<DescriptionItem term="Responsible function">{assisted.responsible_function_ref}</DescriptionItem>
|
||||||
|
<DescriptionItem term="Purpose">{assisted.purpose}</DescriptionItem>
|
||||||
|
<DescriptionItem term="Notice">{assisted.notice_given ? "Provided" : "Not recorded"}</DescriptionItem>
|
||||||
|
<DescriptionItem term="Language">{assisted.language}</DescriptionItem>
|
||||||
|
{assisted.accessibility_needs.length > 0 &&
|
||||||
|
<DescriptionItem term="Communication support">{assisted.accessibility_needs.join(", ")}</DescriptionItem>
|
||||||
|
}
|
||||||
|
</DescriptionList>
|
||||||
|
<p className="form-assisted-context-note">
|
||||||
|
This context records provenance and does not bypass the published Form rules. Any saved correction invalidates the earlier read-back for submission.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
{groups.map((group) =>
|
{groups.map((group) =>
|
||||||
<section className="form-runtime-section" key={`${group.pageKey}:${group.sectionKey}`}>
|
<section className="form-runtime-section" key={`${group.pageKey}:${group.sectionKey}`}>
|
||||||
{(groups.length > 1 || definition.pages?.length) && <header><h2>{localized.sectionTitles[group.sectionKey] ?? group.sectionTitle}</h2>{group.description && <p>{group.description}</p>}</header>}
|
{(groups.length > 1 || definition.pages?.length) && <header><h2>{localized.sectionTitles[group.sectionKey] ?? group.sectionTitle}</h2>{group.description && <p>{group.description}</p>}</header>}
|
||||||
@@ -277,26 +497,64 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
value={values[field.key]}
|
value={values[field.key]}
|
||||||
disabled={!editable || saving}
|
disabled={!editable || saving}
|
||||||
diagnostics={diagnostics.get(field.key) ?? []}
|
diagnostics={diagnostics.get(field.key) ?? []}
|
||||||
onChange={(value) => setValues((current) => {
|
onChange={(value) => changeValue(field.key, value)}
|
||||||
const next = { ...current };
|
|
||||||
if (value === undefined || value === "") delete next[field.key];
|
|
||||||
else next[field.key] = value;
|
|
||||||
return next;
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{(instance.attachment_refs.length > 0 || instance.signature_refs.length > 0) &&
|
{(definition.max_attachments > 0 || definition.signature_requirement !== "none") &&
|
||||||
<div className="form-evidence-summary">
|
<section className="form-instance-evidence">
|
||||||
<span>{instance.attachment_refs.length} attachments</span>
|
<div className="form-evidence-heading">
|
||||||
<span>{instance.signature_refs.length} signatures</span>
|
<h2>Evidence and acknowledgement</h2>
|
||||||
|
<span>{attachmentRefs.length} attachments · {signatureRefs.length} acknowledgements</span>
|
||||||
</div>
|
</div>
|
||||||
|
{editable && definition.max_attachments > 0 &&
|
||||||
|
<FileDropZone
|
||||||
|
multiple
|
||||||
|
busy={saving}
|
||||||
|
disabled={attachmentLimitReached}
|
||||||
|
note={`${attachmentRefs.length} of ${definition.max_attachments} attachments`}
|
||||||
|
onFiles={uploadAttachments}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{attachmentRefs.length > 0 &&
|
||||||
|
<div className="form-public-attachment-list">
|
||||||
|
{attachmentRefs.map((reference) =>
|
||||||
|
<div key={`${reference.evidence_id}:${reference.version ?? ""}`}>
|
||||||
|
<span>{attachmentNames[reference.evidence_id] ?? `Managed attachment ${reference.evidence_id.slice(0, 8)}`}</span>
|
||||||
|
{editable &&
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
aria-label="Remove attachment"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setAttachmentRefs((current) => current.filter((item) => item !== reference));
|
||||||
|
setSignatureRefs([]);
|
||||||
|
}}>
|
||||||
|
<Trash2 size={15} aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{editable && definition.signature_requirement !== "none" &&
|
||||||
|
<div className="form-acknowledgement-row">
|
||||||
|
<span>
|
||||||
|
<strong>Authenticated acknowledgement</strong>
|
||||||
|
<small>Records your account, the exact values, and the exact managed attachments. It is not a qualified electronic signature.</small>
|
||||||
|
</span>
|
||||||
|
<Button disabled={saving} onClick={() => setConfirmingAcknowledgement(true)}>
|
||||||
|
<BadgeCheck size={16} aria-hidden="true" />
|
||||||
|
{signatureRefs.some((item) => item.owner_module === "forms_runtime") ? "Renew acknowledgement" : "Acknowledge"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
}
|
}
|
||||||
{editable &&
|
{editable &&
|
||||||
<div className="form-instance-actions">
|
<div className="form-instance-actions">
|
||||||
<DocumentationHelpLink reference={FORMS_RUNTIME_FIELD_DOCUMENTATION} />
|
|
||||||
{canSave &&
|
{canSave &&
|
||||||
<label className="form-change-reason">
|
<label className="form-change-reason">
|
||||||
<span>Change reason</span>
|
<span>Change reason</span>
|
||||||
@@ -316,9 +574,19 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
Save draft
|
Save draft
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
<Button variant="primary" onClick={() => setConfirmingSubmit(true)} disabled={saving} disabledReason={saving ? FORMS_RUNTIME_I18N.saving : undefined}>
|
<Button
|
||||||
<Send size={16} aria-hidden="true" />
|
variant="primary"
|
||||||
Submit
|
onClick={() => assisted && !assistedConfirmationCurrent ? setConfirmingAssisted(true) : setConfirmingSubmit(true)}
|
||||||
|
disabled={saving || signatureBlocked || assistedSaveBlocked}
|
||||||
|
disabledReason={saving
|
||||||
|
? FORMS_RUNTIME_I18N.saving
|
||||||
|
: signatureBlocked
|
||||||
|
? "Record and save the required acknowledgement before submitting."
|
||||||
|
: assistedSaveBlocked
|
||||||
|
? "Save assisted corrections and evidence before read-back."
|
||||||
|
: undefined}>
|
||||||
|
{assisted && !assistedConfirmationCurrent ? <ClipboardCheck size={16} aria-hidden="true" /> : <Send size={16} aria-hidden="true" />}
|
||||||
|
{assisted && !assistedConfirmationCurrent ? "Read back and submit" : "Submit"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -328,6 +596,16 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
<code>{instance.receipt_id}</code>
|
<code>{instance.receipt_id}</code>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
{!editable && instance.status_access &&
|
||||||
|
<section className="form-status-access-receipt">
|
||||||
|
<div>
|
||||||
|
<strong>Applicant status access</strong>
|
||||||
|
<span>{statusAccessMessage(instance.status_access.mode)}</span>
|
||||||
|
</div>
|
||||||
|
<code>{instance.status_access.tracking_id}</code>
|
||||||
|
<a className="btn" href={instance.status_access.href}>Open status page</a>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
{!editable && definition.handoff_kinds.length > 0 &&
|
{!editable && definition.handoff_kinds.length > 0 &&
|
||||||
<section className="form-handoffs">
|
<section className="form-handoffs">
|
||||||
<div className="form-handoff-heading">
|
<div className="form-handoff-heading">
|
||||||
@@ -391,7 +669,131 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</PageScrollViewport>
|
</PageScrollViewport>
|
||||||
|
</WorkspaceFrame>
|
||||||
|
<Dialog
|
||||||
|
open={confirmingAssisted}
|
||||||
|
title="Record assisted read-back"
|
||||||
|
description="Make the exact values and managed evidence available to the confirming party. Record corrections before continuing; the server binds this evidence to the current revision and submission payload."
|
||||||
|
closeDisabled={saving}
|
||||||
|
onClose={() => setConfirmingAssisted(false)}
|
||||||
|
helpContextId="forms_runtime.assisted-confirmation"
|
||||||
|
helpTopicId="forms_runtime.submissions"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setConfirmingAssisted(false)} disabled={saving}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={saving || !assistedConfirmedBy.trim() || (assistedOutcome !== "confirmed" && !assistedNote.trim())}
|
||||||
|
onClick={() => void confirmAssistedReadback()}>
|
||||||
|
<ClipboardCheck size={16} aria-hidden="true" />
|
||||||
|
Record and continue
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
<FormGrid columns={2} gap="small" collapseAt="workspace">
|
||||||
|
<CoreFormField label="Outcome">
|
||||||
|
<select
|
||||||
|
value={assistedOutcome}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(event) => {
|
||||||
|
const outcome = event.target.value as AssistedConfirmation["outcome"];
|
||||||
|
setAssistedOutcome(outcome);
|
||||||
|
if (outcome === "confirmation_unavailable") setAssistedMethod("unavailable");
|
||||||
|
else if (assistedMethod === "unavailable") setAssistedMethod("spoken_readback");
|
||||||
|
}}>
|
||||||
|
<option value="confirmed">Confirmed without correction</option>
|
||||||
|
<option value="corrected">Corrected and confirmed</option>
|
||||||
|
<option value="confirmation_unavailable">Confirmation unavailable</option>
|
||||||
|
</select>
|
||||||
|
</CoreFormField>
|
||||||
|
<CoreFormField label="Confirmation method">
|
||||||
|
<select value={assistedMethod} onChange={(event) => setAssistedMethod(event.target.value as AssistedConfirmation["method"])} disabled={saving || assistedOutcome === "confirmation_unavailable"}>
|
||||||
|
<option value="spoken_readback">Spoken read-back</option>
|
||||||
|
<option value="written_preview">Written preview</option>
|
||||||
|
<option value="accessible_copy">Accessible copy</option>
|
||||||
|
{assistedOutcome === "confirmation_unavailable" && <option value="unavailable">Unavailable</option>}
|
||||||
|
</select>
|
||||||
|
</CoreFormField>
|
||||||
|
<CoreFormField label="Confirming party reference">
|
||||||
|
<input value={assistedConfirmedBy} onChange={(event) => setAssistedConfirmedBy(event.target.value)} disabled={saving} required />
|
||||||
|
</CoreFormField>
|
||||||
|
<CoreFormField label={assistedOutcome === "confirmed" ? "Note (optional)" : "Correction or exception note"}>
|
||||||
|
<textarea rows={3} value={assistedNote} onChange={(event) => setAssistedNote(event.target.value)} disabled={saving} required={assistedOutcome !== "confirmed"} />
|
||||||
|
</CoreFormField>
|
||||||
|
</FormGrid>
|
||||||
|
<section className="form-assisted-field-sources" aria-labelledby="assisted-field-sources-heading">
|
||||||
|
<header>
|
||||||
|
<h3 id="assisted-field-sources-heading">Source of each populated value</h3>
|
||||||
|
<p>Classify every value independently. Use a governed party, document, or system reference instead of repeating sensitive evidence in free text.</p>
|
||||||
|
</header>
|
||||||
|
<div className="form-assisted-field-source-list">
|
||||||
|
{Object.entries(values)
|
||||||
|
.filter(([, value]) => value !== null && value !== undefined && value !== "")
|
||||||
|
.map(([fieldKey]) => {
|
||||||
|
const source = assistedFieldSources[fieldKey] ?? defaultAssistedFieldSource(
|
||||||
|
assistedConfirmedBy.trim() || assisted?.affected_party_ref || ""
|
||||||
|
);
|
||||||
|
const fieldLabel = localized.fieldLabels[fieldKey]
|
||||||
|
?? definition?.fields.find((field) => field.key === fieldKey)?.label
|
||||||
|
?? humanize(fieldKey);
|
||||||
|
return (
|
||||||
|
<fieldset key={fieldKey} className="form-assisted-field-source">
|
||||||
|
<legend>{fieldLabel}</legend>
|
||||||
|
<FormGrid columns={3} gap="small" collapseAt="workspace">
|
||||||
|
<CoreFormField label="Value source">
|
||||||
|
<select
|
||||||
|
value={source.source}
|
||||||
|
onChange={(event) => changeAssistedFieldSource(fieldKey, {
|
||||||
|
source: event.target.value as typeof source.source
|
||||||
|
})}
|
||||||
|
disabled={saving}>
|
||||||
|
<option value="person_statement">Person statement</option>
|
||||||
|
<option value="representative_statement">Representative statement</option>
|
||||||
|
<option value="document">Document</option>
|
||||||
|
<option value="system">Existing system</option>
|
||||||
|
<option value="derived">Derived by operator</option>
|
||||||
|
</select>
|
||||||
|
</CoreFormField>
|
||||||
|
<CoreFormField label="Source confidence">
|
||||||
|
<select
|
||||||
|
value={source.confidence}
|
||||||
|
onChange={(event) => changeAssistedFieldSource(fieldKey, {
|
||||||
|
confidence: event.target.value as typeof source.confidence
|
||||||
|
})}
|
||||||
|
disabled={saving}>
|
||||||
|
<option value="stated">Stated</option>
|
||||||
|
<option value="verified">Verified</option>
|
||||||
|
<option value="uncertain">Uncertain</option>
|
||||||
|
</select>
|
||||||
|
</CoreFormField>
|
||||||
|
<CoreFormField label="Declared by or source reference" help="Optional for derived values; otherwise identify the governed party, document, or source system.">
|
||||||
|
<input
|
||||||
|
value={source.declared_by_ref ?? ""}
|
||||||
|
onChange={(event) => changeAssistedFieldSource(fieldKey, {
|
||||||
|
declared_by_ref: event.target.value || null
|
||||||
|
})}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</CoreFormField>
|
||||||
|
</FormGrid>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
</Dialog>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmingAcknowledgement}
|
||||||
|
title="Record acknowledgement"
|
||||||
|
message="Confirm that the displayed values and managed attachments are correct and complete. This records a payload-bound authenticated acknowledgement; it is not a qualified electronic signature."
|
||||||
|
confirmLabel="Acknowledge"
|
||||||
|
busy={saving}
|
||||||
|
onCancel={() => setConfirmingAcknowledgement(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmingAcknowledgement(false);
|
||||||
|
void acknowledge();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={confirmingSubmit}
|
open={confirmingSubmit}
|
||||||
title="i18n:govoplan-forms-runtime.submit_title"
|
title="i18n:govoplan-forms-runtime.submit_title"
|
||||||
@@ -430,7 +832,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormField({
|
export function FormField({
|
||||||
field,
|
field,
|
||||||
value,
|
value,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -580,7 +982,114 @@ function numericConstraint(value: unknown): number | undefined {
|
|||||||
return typeof value === "number" ? value : undefined;
|
return typeof value === "number" ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleGroups(definition: FormDefinition, values: Record<string, unknown>) {
|
function intakeContext(instance: FormInstance | null): AssistedIntakeContext | null {
|
||||||
|
const value = instance?.metadata?.intake;
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
|
const context = value as Record<string, unknown>;
|
||||||
|
if (context.mode !== "assisted"
|
||||||
|
|| typeof context.session_id !== "string"
|
||||||
|
|| typeof context.profile_id !== "string"
|
||||||
|
|| typeof context.channel !== "string"
|
||||||
|
|| typeof context.affected_party_ref !== "string"
|
||||||
|
|| typeof context.authority_basis !== "string"
|
||||||
|
|| typeof context.purpose !== "string"
|
||||||
|
|| typeof context.responsible_function_ref !== "string"
|
||||||
|
|| typeof context.language !== "string") return null;
|
||||||
|
return {
|
||||||
|
session_id: context.session_id,
|
||||||
|
profile_id: context.profile_id,
|
||||||
|
mode: "assisted",
|
||||||
|
channel: context.channel as AssistedIntakeContext["channel"],
|
||||||
|
affected_party_ref: context.affected_party_ref,
|
||||||
|
represented_party_ref: typeof context.represented_party_ref === "string" ? context.represented_party_ref : null,
|
||||||
|
authority_basis: context.authority_basis,
|
||||||
|
purpose: context.purpose,
|
||||||
|
legal_basis_ref: typeof context.legal_basis_ref === "string" ? context.legal_basis_ref : null,
|
||||||
|
consent_basis: typeof context.consent_basis === "string" ? context.consent_basis : null,
|
||||||
|
notice_given: context.notice_given === true,
|
||||||
|
responsible_function_ref: context.responsible_function_ref,
|
||||||
|
language: context.language,
|
||||||
|
accessibility_needs: Array.isArray(context.accessibility_needs) ? context.accessibility_needs.map(String) : [],
|
||||||
|
field_sources: typeof context.field_sources === "object" && context.field_sources && !Array.isArray(context.field_sources)
|
||||||
|
? context.field_sources as AssistedIntakeContext["field_sources"]
|
||||||
|
: {},
|
||||||
|
operator: typeof context.operator === "object" && context.operator && !Array.isArray(context.operator)
|
||||||
|
? context.operator as Record<string, unknown>
|
||||||
|
: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultAssistedFieldSource(
|
||||||
|
declaredByRef: string
|
||||||
|
): AssistedIntakeContext["field_sources"][string] {
|
||||||
|
return {
|
||||||
|
source: "person_statement",
|
||||||
|
confidence: "stated",
|
||||||
|
declared_by_ref: declaredByRef || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistedSourcesForRevision(
|
||||||
|
instance: FormInstance,
|
||||||
|
assisted: AssistedIntakeContext | null,
|
||||||
|
confirmations: AssistedConfirmation[]
|
||||||
|
): AssistedIntakeContext["field_sources"] {
|
||||||
|
const currentConfirmation = confirmations.find(
|
||||||
|
(confirmation) => confirmation.instance_revision === instance.revision
|
||||||
|
);
|
||||||
|
const confirmedSources = currentConfirmation?.metadata?.field_sources;
|
||||||
|
return normalizeAssistedFieldSources(confirmedSources)
|
||||||
|
?? assisted?.field_sources
|
||||||
|
?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAssistedFieldSources(
|
||||||
|
value: unknown
|
||||||
|
): AssistedIntakeContext["field_sources"] | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
|
const result: AssistedIntakeContext["field_sources"] = {};
|
||||||
|
for (const [fieldKey, candidate] of Object.entries(value)) {
|
||||||
|
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
|
||||||
|
const source = (candidate as Record<string, unknown>).source;
|
||||||
|
const confidence = (candidate as Record<string, unknown>).confidence;
|
||||||
|
const declaredByRef = (candidate as Record<string, unknown>).declared_by_ref;
|
||||||
|
if (!isAssistedSource(source) || !isAssistedConfidence(confidence)) continue;
|
||||||
|
result[fieldKey] = {
|
||||||
|
source,
|
||||||
|
confidence,
|
||||||
|
declared_by_ref: typeof declaredByRef === "string" && declaredByRef.trim()
|
||||||
|
? declaredByRef
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAssistedSource(
|
||||||
|
value: unknown
|
||||||
|
): value is AssistedIntakeContext["field_sources"][string]["source"] {
|
||||||
|
return [
|
||||||
|
"person_statement",
|
||||||
|
"representative_statement",
|
||||||
|
"document",
|
||||||
|
"system",
|
||||||
|
"derived"
|
||||||
|
].includes(String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAssistedConfidence(
|
||||||
|
value: unknown
|
||||||
|
): value is AssistedIntakeContext["field_sources"][string]["confidence"] {
|
||||||
|
return ["stated", "verified", "uncertain"].includes(String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusAccessMessage(mode: "authenticated" | "email_link" | "permanent_link"): string {
|
||||||
|
if (mode === "authenticated") return "Only the bound applicant account can open this status page.";
|
||||||
|
if (mode === "email_link") return "The applicant uses this tracking ID and linked email address to request an expiring link.";
|
||||||
|
return "This non-expiring bearer link can be opened by anyone who holds it.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleGroups(definition: FormDefinition, values: Record<string, unknown>) {
|
||||||
const fields = new Map(definition.fields.map((field) => [field.key, field]));
|
const fields = new Map(definition.fields.map((field) => [field.key, field]));
|
||||||
const fieldVisible = (field: FormFieldDefinition) => !field.visibility_condition || evaluateCondition(field.visibility_condition, values);
|
const fieldVisible = (field: FormFieldDefinition) => !field.visibility_condition || evaluateCondition(field.visibility_condition, values);
|
||||||
if (!definition.pages?.length) {
|
if (!definition.pages?.length) {
|
||||||
@@ -608,9 +1117,11 @@ function visibleGroups(definition: FormDefinition, values: Record<string, unknow
|
|||||||
}
|
}
|
||||||
|
|
||||||
function evaluateCondition(condition: NonNullable<FormFieldDefinition["visibility_condition"]>, values: Record<string, unknown>): boolean {
|
function evaluateCondition(condition: NonNullable<FormFieldDefinition["visibility_condition"]>, values: Record<string, unknown>): boolean {
|
||||||
|
if ("conditions" in condition) {
|
||||||
if (condition.kind === "all") return condition.conditions.every((item) => evaluateCondition(item, values));
|
if (condition.kind === "all") return condition.conditions.every((item) => evaluateCondition(item, values));
|
||||||
if (condition.kind === "any") return condition.conditions.some((item) => evaluateCondition(item, values));
|
if (condition.kind === "any") return condition.conditions.some((item) => evaluateCondition(item, values));
|
||||||
if (condition.kind === "not") return !evaluateCondition(condition.conditions[0], values);
|
return !evaluateCondition(condition.conditions[0], values);
|
||||||
|
}
|
||||||
const actual = values[condition.field_key];
|
const actual = values[condition.field_key];
|
||||||
const expected = condition.value;
|
const expected = condition.value;
|
||||||
switch (condition.operator) {
|
switch (condition.operator) {
|
||||||
@@ -640,7 +1151,7 @@ function comparable(left: unknown, right: unknown, compare: (left: number | stri
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function localizeDefinition(definition: FormDefinition | null, language: string) {
|
export function localizeDefinition(definition: FormDefinition | null, language: string) {
|
||||||
const canonical = {
|
const canonical = {
|
||||||
title: definition?.title ?? "",
|
title: definition?.title ?? "",
|
||||||
description: definition?.description ?? null,
|
description: definition?.description ?? null,
|
||||||
@@ -676,6 +1187,17 @@ function stateLabel(value: string): string {
|
|||||||
return `i18n:govoplan-forms-runtime.state_${value}`;
|
return `i18n:govoplan-forms-runtime.state_${value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordFilingPath(instance: FormInstance): string {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
sourceModule: "forms_runtime",
|
||||||
|
resourceType: "form_submission_revision",
|
||||||
|
resourceId: instance.instance_id,
|
||||||
|
sourceRevision: String(instance.revision),
|
||||||
|
sourceLabel: instance.receipt_id ? `Form submission ${instance.receipt_id}` : "Form submission"
|
||||||
|
});
|
||||||
|
return `/records?${query.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
function domainLabel(value: string): string {
|
function domainLabel(value: string): string {
|
||||||
return `i18n:govoplan-forms-runtime.domain_${value}`;
|
return `i18n:govoplan-forms-runtime.domain_${value}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
import { RefreshCw } from "lucide-react";
|
import { Link2, ShieldCheck, UserRoundPlus } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import { Button,
|
||||||
Button,
|
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
|
SelectionListItemContent,
|
||||||
|
StatePanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
hasScope,
|
||||||
i18nMessage,
|
i18nMessage,
|
||||||
useGuardedNavigate,
|
useGuardedNavigate,
|
||||||
usePlatformLanguage,
|
usePlatformLanguage,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
type PlatformRouteContext
|
type PlatformRouteContext
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { listFormInstances, type FormInstance } from "../../api/formsRuntime";
|
import { listFormInstances, type FormInstance } from "../../api/formsRuntime";
|
||||||
import { FORMS_RUNTIME_DOCUMENTATION, FORMS_RUNTIME_I18N } from "./interfacePatterns";
|
import { FORMS_RUNTIME_DOCUMENTATION } from "./interfacePatterns";
|
||||||
|
import IntakeProfilesDialog from "./IntakeProfilesDialog";
|
||||||
|
import AssistedIntakeDialog from "./AssistedIntakeDialog";
|
||||||
|
import StatusAccessPoliciesDialog from "./StatusAccessPoliciesDialog";
|
||||||
|
|
||||||
|
|
||||||
const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"];
|
const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"];
|
||||||
|
|
||||||
export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
|
export default function FormsRuntimePage({ settings, auth }: PlatformRouteContext) {
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
const { language } = usePlatformLanguage();
|
const { language } = usePlatformLanguage();
|
||||||
const [items, setItems] = useState<FormInstance[]>([]);
|
const [items, setItems] = useState<FormInstance[]>([]);
|
||||||
@@ -26,6 +35,12 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
|
|||||||
const [status, setStatus] = useState("open");
|
const [status, setStatus] = useState("open");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [intakeOpen, setIntakeOpen] = useState(false);
|
||||||
|
const [assistedOpen, setAssistedOpen] = useState(false);
|
||||||
|
const [statusAccessOpen, setStatusAccessOpen] = useState(false);
|
||||||
|
const canAdmin = hasScope(auth, "forms_runtime:workspace:admin");
|
||||||
|
const canAssist = hasScope(auth, "forms_runtime:submission:assist")
|
||||||
|
|| hasScope(auth, "forms_runtime:workspace:write");
|
||||||
|
|
||||||
const load = useCallback((signal?: AbortSignal) => {
|
const load = useCallback((signal?: AbortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -53,12 +68,28 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="forms-runtime-page">
|
<main className="forms-runtime-page">
|
||||||
<div className="forms-runtime-shell">
|
<WorkspaceFrame className="forms-runtime-shell" label="Forms runtime" interfaceId="forms-runtime.workspace" helpContextId="forms-runtime.page.workspace" helpModuleId="forms-runtime">
|
||||||
<div className="forms-runtime-toolbar">
|
<WorkspaceActionBar
|
||||||
<Button onClick={() => void load()} disabled={loading} disabledReason={loading ? FORMS_RUNTIME_I18N.loading : undefined}>
|
title="Forms runtime"
|
||||||
<RefreshCw size={16} aria-hidden="true" />
|
titleHelp={<DocumentationHelpLink reference={FORMS_RUNTIME_DOCUMENTATION} />}
|
||||||
Refresh
|
scope="workspace"
|
||||||
|
variant="collection"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void load(), loading }}
|
||||||
|
className="forms-runtime-toolbar"
|
||||||
|
contextActions={<>
|
||||||
|
{canAdmin &&
|
||||||
|
<Button onClick={() => setIntakeOpen(true)}>
|
||||||
|
<Link2 size={16} aria-hidden="true" />
|
||||||
|
Public intake
|
||||||
</Button>
|
</Button>
|
||||||
|
}
|
||||||
|
{canAdmin &&
|
||||||
|
<Button onClick={() => setStatusAccessOpen(true)}>
|
||||||
|
<ShieldCheck size={16} aria-hidden="true" />
|
||||||
|
Status access
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
<label>
|
<label>
|
||||||
<span>Status</span>
|
<span>Status</span>
|
||||||
<select value={status} onChange={(event) => setStatus(event.target.value)}>
|
<select value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||||
@@ -74,8 +105,14 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<span className="forms-runtime-count">{i18nMessage("i18n:govoplan-forms-runtime.form_count", { total })}</span>
|
<span className="forms-runtime-count">{i18nMessage("i18n:govoplan-forms-runtime.form_count", { total })}</span>
|
||||||
<DocumentationHelpLink reference={FORMS_RUNTIME_DOCUMENTATION} />
|
</>}
|
||||||
</div>
|
createAction={canAssist ?
|
||||||
|
<Button onClick={() => setAssistedOpen(true)}>
|
||||||
|
<UserRoundPlus size={16} aria-hidden="true" />
|
||||||
|
Assisted intake
|
||||||
|
</Button>
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
<PageScrollViewport className="forms-runtime-list-viewport">
|
<PageScrollViewport className="forms-runtime-list-viewport">
|
||||||
{error &&
|
{error &&
|
||||||
<DismissibleAlert tone="danger" resetKey={error}>
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
@@ -84,29 +121,35 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
|
|||||||
}
|
}
|
||||||
{loading && <LoadingIndicator label="Loading forms" />}
|
{loading && <LoadingIndicator label="Loading forms" />}
|
||||||
{!loading && !error && items.length === 0 &&
|
{!loading && !error && items.length === 0 &&
|
||||||
<div className="forms-runtime-empty">No matching Forms.</div>
|
<StatePanel size="compact" description="No matching Forms." />
|
||||||
}
|
}
|
||||||
{!loading && items.length > 0 &&
|
{!loading && items.length > 0 &&
|
||||||
<div className="forms-runtime-list" role="list">
|
<SelectionList variant="navigation" label="Forms">
|
||||||
{items.map((item) =>
|
{items.map((item) =>
|
||||||
<button
|
<SelectionListItem
|
||||||
type="button"
|
|
||||||
role="listitem"
|
|
||||||
className="forms-runtime-row"
|
|
||||||
key={item.instance_id}
|
key={item.instance_id}
|
||||||
|
selected={false}
|
||||||
onClick={() => navigate(`/forms-runtime/${encodeURIComponent(item.instance_id)}`)}>
|
onClick={() => navigate(`/forms-runtime/${encodeURIComponent(item.instance_id)}`)}>
|
||||||
<span className="forms-runtime-row-main">
|
<SelectionListItemContent title={item.definition_ref.label ?? humanize(item.definition_ref.object_id)} description={`Revision ${item.definition_ref.version ?? "-"} · ${formatDateTime(item.recorded_at, language)}`} />
|
||||||
<strong>{item.definition_ref.label ?? humanize(item.definition_ref.object_id)}</strong>
|
|
||||||
<span>Revision {item.definition_ref.version ?? "-"}</span>
|
|
||||||
</span>
|
|
||||||
<span>{formatDateTime(item.recorded_at, language)}</span>
|
|
||||||
<StatusBadge status={isOpen(item.status) ? "active" : "inactive"} label={stateLabel(item.status)} />
|
<StatusBadge status={isOpen(item.status) ? "active" : "inactive"} label={stateLabel(item.status)} />
|
||||||
</button>
|
</SelectionListItem>
|
||||||
)}
|
)}
|
||||||
</div>
|
</SelectionList>
|
||||||
}
|
}
|
||||||
</PageScrollViewport>
|
</PageScrollViewport>
|
||||||
</div>
|
</WorkspaceFrame>
|
||||||
|
<IntakeProfilesDialog open={intakeOpen} settings={settings} onClose={() => setIntakeOpen(false)} />
|
||||||
|
<StatusAccessPoliciesDialog open={statusAccessOpen} settings={settings} onClose={() => setStatusAccessOpen(false)} />
|
||||||
|
<AssistedIntakeDialog
|
||||||
|
open={assistedOpen}
|
||||||
|
settings={settings}
|
||||||
|
language={language}
|
||||||
|
onClose={() => setAssistedOpen(false)}
|
||||||
|
onStarted={(instanceId) => {
|
||||||
|
setAssistedOpen(false);
|
||||||
|
navigate(`/forms-runtime/${encodeURIComponent(instanceId)}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { Copy, Link, Plus, Send } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { FormGrid,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DismissibleAlert,
|
||||||
|
LoadingIndicator,
|
||||||
|
StatusBadge,
|
||||||
|
ToggleSwitch,
|
||||||
|
type PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
createFormIntakeProfile,
|
||||||
|
issueFormIntakeInvitation,
|
||||||
|
listFormIntakeDefinitions,
|
||||||
|
listFormIntakeProfiles,
|
||||||
|
setFormIntakeProfileEnabled,
|
||||||
|
type FormDefinition,
|
||||||
|
type FormIntakeProfile
|
||||||
|
} from "../../api/formsRuntime";
|
||||||
|
|
||||||
|
|
||||||
|
type IntakeProfilesDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
settings: PlatformRouteContext["settings"];
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function IntakeProfilesDialog({ open, settings, onClose }: IntakeProfilesDialogProps) {
|
||||||
|
const [profiles, setProfiles] = useState<FormIntakeProfile[]>([]);
|
||||||
|
const [definitions, setDefinitions] = useState<FormDefinition[]>([]);
|
||||||
|
const [definitionId, setDefinitionId] = useState("");
|
||||||
|
const [mode, setMode] = useState<FormIntakeProfile["mode"]>("invitation");
|
||||||
|
const [draftDays, setDraftDays] = useState(30);
|
||||||
|
const [invitationDays, setInvitationDays] = useState(14);
|
||||||
|
const [rateLimit, setRateLimit] = useState(60);
|
||||||
|
const [invitationLinks, setInvitationLinks] = useState<Record<string, string>>({});
|
||||||
|
const [busyKey, setBusyKey] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
|
||||||
|
const definitionsByKey = useMemo(
|
||||||
|
() => new Map(definitions.map((item) => [definitionKey(item), item])),
|
||||||
|
[definitions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = useCallback(async (signal?: AbortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [profileResult, definitionResult] = await Promise.all([
|
||||||
|
listFormIntakeProfiles(settings, signal),
|
||||||
|
listFormIntakeDefinitions(settings, signal)
|
||||||
|
]);
|
||||||
|
setProfiles(profileResult.profiles);
|
||||||
|
setDefinitions(definitionResult.definitions);
|
||||||
|
setDefinitionId((current) => current || definitionKey(definitionResult.definitions[0]));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const controller = new AbortController();
|
||||||
|
void load(controller.signal).catch((reason) => {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Public intake profiles could not be loaded.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [load, open]);
|
||||||
|
|
||||||
|
async function createProfile() {
|
||||||
|
const definition = definitionsByKey.get(definitionId);
|
||||||
|
if (!definition) return;
|
||||||
|
setBusyKey("create");
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
await createFormIntakeProfile(settings, definition.reference, mode, {
|
||||||
|
draftTtlSeconds: Math.round(draftDays * 86_400),
|
||||||
|
invitationTtlSeconds: Math.round(invitationDays * 86_400),
|
||||||
|
rateLimitPerMinute: rateLimit
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
setNotice("The Form intake profile was created.");
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The public intake profile could not be created.");
|
||||||
|
} finally {
|
||||||
|
setBusyKey("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setEnabled(profile: FormIntakeProfile, enabled: boolean) {
|
||||||
|
setBusyKey(`state:${profile.profile_id}`);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const updated = await setFormIntakeProfileEnabled(settings, profile, enabled);
|
||||||
|
setProfiles((current) => current.map((item) => item.profile_id === updated.profile_id ? updated : item));
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The public intake profile could not be updated.");
|
||||||
|
} finally {
|
||||||
|
setBusyKey("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function issueInvitation(profile: FormIntakeProfile) {
|
||||||
|
setBusyKey(`invite:${profile.profile_id}`);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
const result = await issueFormIntakeInvitation(settings, profile, crypto.randomUUID());
|
||||||
|
if (!result.token) throw new Error("The invitation was recorded, but its one-time secret is no longer available.");
|
||||||
|
setInvitationLinks((current) => ({
|
||||||
|
...current,
|
||||||
|
[profile.profile_id]: absolutePath(`/forms/intake/${encodeURIComponent(result.token!)}`)
|
||||||
|
}));
|
||||||
|
setNotice("The invitation was issued. Copy its link now; the token is not stored in readable form.");
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The invitation could not be issued.");
|
||||||
|
} finally {
|
||||||
|
setBusyKey("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy(value: string) {
|
||||||
|
if (!navigator.clipboard?.writeText) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setNotice("The intake link was copied.");
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The intake link could not be copied.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title="Form intake profiles"
|
||||||
|
className="form-intake-dialog"
|
||||||
|
closeDisabled={Boolean(busyKey)}
|
||||||
|
onClose={onClose}
|
||||||
|
helpContextId="forms_runtime.public-intake"
|
||||||
|
helpTopicId="forms_runtime.submissions"
|
||||||
|
footer={<Button onClick={onClose} disabled={Boolean(busyKey)}>Close</Button>}>
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{notice && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
||||||
|
{loading && <LoadingIndicator label="Loading public intake profiles" />}
|
||||||
|
{!loading &&
|
||||||
|
<>
|
||||||
|
<section className="form-intake-create">
|
||||||
|
<h3>Add intake profile</h3>
|
||||||
|
<FormGrid columns={3} gap="small" collapseAt="workspace" className="form-intake-create-grid">
|
||||||
|
<label>
|
||||||
|
<span>Published Form</span>
|
||||||
|
<select value={definitionId} onChange={(event) => setDefinitionId(event.target.value)} disabled={Boolean(busyKey)}>
|
||||||
|
{definitions.map((item) =>
|
||||||
|
<option key={definitionKey(item)} value={definitionKey(item)}>
|
||||||
|
{item.title} (revision {item.reference.version})
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Access mode</span>
|
||||||
|
<select value={mode} onChange={(event) => setMode(event.target.value as FormIntakeProfile["mode"])} disabled={Boolean(busyKey)}>
|
||||||
|
<option value="invitation">Invitation link</option>
|
||||||
|
<option value="anonymous">Open anonymous link</option>
|
||||||
|
<option value="assisted">Authenticated assisted session</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Draft retention (days)</span>
|
||||||
|
<input type="number" min={1} max={365} value={draftDays} onChange={(event) => setDraftDays(Number(event.target.value))} disabled={Boolean(busyKey)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Invitation validity (days)</span>
|
||||||
|
<input type="number" min={1} max={90} value={invitationDays} onChange={(event) => setInvitationDays(Number(event.target.value))} disabled={Boolean(busyKey) || mode !== "invitation"} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Starts per minute</span>
|
||||||
|
<input type="number" min={1} max={10_000} value={rateLimit} onChange={(event) => setRateLimit(Number(event.target.value))} disabled={Boolean(busyKey) || mode === "assisted"} />
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="form-intake-create-button"
|
||||||
|
disabled={Boolean(busyKey) || !definitionId || !validSettings(draftDays, invitationDays, rateLimit)}
|
||||||
|
onClick={() => void createProfile()}>
|
||||||
|
<Plus size={16} aria-hidden="true" />
|
||||||
|
Add profile
|
||||||
|
</Button>
|
||||||
|
</FormGrid>
|
||||||
|
</section>
|
||||||
|
<section className="form-intake-profiles">
|
||||||
|
<h3>Configured profiles</h3>
|
||||||
|
{profiles.length === 0 && <p className="form-intake-empty">No Form intake profile has been configured.</p>}
|
||||||
|
{profiles.map((profile) => {
|
||||||
|
const definition = definitionsByKey.get(referenceKey(profile));
|
||||||
|
const link = profile.mode === "anonymous"
|
||||||
|
? absolutePath(`/forms/public/${encodeURIComponent(profile.public_id)}`)
|
||||||
|
: invitationLinks[profile.profile_id];
|
||||||
|
const busy = busyKey.endsWith(profile.profile_id);
|
||||||
|
return (
|
||||||
|
<div className="form-intake-profile-row" key={profile.profile_id}>
|
||||||
|
<span className="form-intake-profile-main">
|
||||||
|
<strong>{definition?.title ?? profile.definition_ref.label ?? profile.definition_ref.object_id}</strong>
|
||||||
|
<small>Revision {profile.definition_ref.version} · {modeLabel(profile.mode)}</small>
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={profile.enabled ? "active" : "inactive"} label={profile.enabled ? "Active" : "Inactive"} />
|
||||||
|
<ToggleSwitch label="Profile active" checked={profile.enabled} disabled={Boolean(busyKey)} onChange={(enabled) => void setEnabled(profile, enabled)} />
|
||||||
|
<span className="form-intake-profile-actions">
|
||||||
|
{profile.mode === "invitation" &&
|
||||||
|
<Button disabled={!profile.enabled || Boolean(busyKey)} onClick={() => void issueInvitation(profile)}>
|
||||||
|
<Send size={15} aria-hidden="true" />
|
||||||
|
Issue invitation
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
{link &&
|
||||||
|
<Button disabled={busy || typeof navigator === "undefined" || !navigator.clipboard?.writeText} onClick={() => void copy(link)}>
|
||||||
|
<Copy size={15} aria-hidden="true" />
|
||||||
|
Copy link
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
{profile.mode === "anonymous" && <Link size={16} aria-label="Reusable public link" />}
|
||||||
|
</span>
|
||||||
|
{link && <code className="form-intake-link">{link}</code>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function definitionKey(definition?: FormDefinition): string {
|
||||||
|
return definition ? `${definition.reference.object_id}:${definition.reference.version ?? ""}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function referenceKey(profile: FormIntakeProfile): string {
|
||||||
|
return `${profile.definition_ref.object_id}:${profile.definition_ref.version ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeLabel(mode: FormIntakeProfile["mode"]): string {
|
||||||
|
if (mode === "anonymous") return "Anonymous link";
|
||||||
|
if (mode === "invitation") return "Invitation links";
|
||||||
|
return "Authenticated assisted sessions";
|
||||||
|
}
|
||||||
|
|
||||||
|
function absolutePath(path: string): string {
|
||||||
|
return typeof window === "undefined" ? path : new URL(path, window.location.origin).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validSettings(draftDays: number, invitationDays: number, rateLimit: number): boolean {
|
||||||
|
return Number.isFinite(draftDays) && draftDays >= 1 && draftDays <= 365
|
||||||
|
&& Number.isFinite(invitationDays) && invitationDays >= 1 && invitationDays <= 90
|
||||||
|
&& Number.isInteger(rateLimit) && rateLimit >= 1 && rateLimit <= 10_000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import { Save, Send, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import { ActionToolbar,
|
||||||
|
ActionBlockerHint,
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
DismissibleAlert,
|
||||||
|
FileDropZone,
|
||||||
|
LoadingIndicator,
|
||||||
|
PageScrollViewport,
|
||||||
|
StatusBadge,
|
||||||
|
usePlatformLanguage,
|
||||||
|
type PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
createPublicFormEvidenceGrant,
|
||||||
|
getPublicFormIntake,
|
||||||
|
savePublicFormDraft,
|
||||||
|
startAnonymousFormIntake,
|
||||||
|
startInvitationFormIntake,
|
||||||
|
submitPublicFormIntake,
|
||||||
|
uploadFormEvidence,
|
||||||
|
type EvidenceReference,
|
||||||
|
type FormDefinition,
|
||||||
|
type FormInstance,
|
||||||
|
type ValidationResult
|
||||||
|
} from "../../api/formsRuntime";
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
localizeDefinition,
|
||||||
|
visibleGroups
|
||||||
|
} from "./FormInstancePage";
|
||||||
|
|
||||||
|
|
||||||
|
type AnonymousStartAttempt = {
|
||||||
|
idempotencyKey: string;
|
||||||
|
recordedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PublicFormPage({ settings }: PlatformRouteContext) {
|
||||||
|
const { publicId = "", token: invitationToken = "" } = useParams();
|
||||||
|
const { language } = usePlatformLanguage();
|
||||||
|
const [token, setToken] = useState(invitationToken);
|
||||||
|
const [instance, setInstance] = useState<FormInstance | null>(null);
|
||||||
|
const [definition, setDefinition] = useState<FormDefinition | null>(null);
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||||
|
const [attachmentRefs, setAttachmentRefs] = useState<EvidenceReference[]>([]);
|
||||||
|
const [attachmentNames, setAttachmentNames] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [confirmingSubmit, setConfirmingSubmit] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let active = true;
|
||||||
|
async function initialize() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
let activeToken = invitationToken;
|
||||||
|
if (activeToken) {
|
||||||
|
await startInvitationFormIntake(settings, activeToken);
|
||||||
|
} else if (publicId) {
|
||||||
|
activeToken = readSessionToken(publicId);
|
||||||
|
if (!activeToken) {
|
||||||
|
const attempt = anonymousStartAttempt(publicId);
|
||||||
|
const started = await startAnonymousFormIntake(
|
||||||
|
settings,
|
||||||
|
publicId,
|
||||||
|
attempt.idempotencyKey,
|
||||||
|
attempt.recordedAt
|
||||||
|
);
|
||||||
|
activeToken = started.token ?? "";
|
||||||
|
if (!activeToken) {
|
||||||
|
throw new Error("This Form was already opened in another browser session. Use the original session or request a new link.");
|
||||||
|
}
|
||||||
|
rememberSessionToken(publicId, activeToken);
|
||||||
|
clearAnonymousStartAttempt(publicId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!activeToken) throw new Error("This Form link is incomplete.");
|
||||||
|
const loaded = await getPublicFormIntake(settings, activeToken, controller.signal);
|
||||||
|
if (!active) return;
|
||||||
|
setToken(activeToken);
|
||||||
|
applyLoaded(loaded.instance, loaded.definition);
|
||||||
|
} catch (reason) {
|
||||||
|
if (active && (reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The public Form could not be loaded.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (active) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function applyLoaded(nextInstance: FormInstance, nextDefinition: FormDefinition) {
|
||||||
|
setInstance(nextInstance);
|
||||||
|
setDefinition(nextDefinition);
|
||||||
|
setValues(nextInstance.values);
|
||||||
|
setAttachmentRefs(nextInstance.attachment_refs);
|
||||||
|
}
|
||||||
|
void initialize();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [invitationToken, publicId, settings]);
|
||||||
|
|
||||||
|
const editable = Boolean(instance && ["started", "draft"].includes(instance.status));
|
||||||
|
const changed = Boolean(
|
||||||
|
instance && (
|
||||||
|
JSON.stringify(values) !== JSON.stringify(instance.values)
|
||||||
|
|| JSON.stringify(attachmentRefs) !== JSON.stringify(instance.attachment_refs)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const localized = useMemo(
|
||||||
|
() => localizeDefinition(definition, language),
|
||||||
|
[definition, language]
|
||||||
|
);
|
||||||
|
const groups = useMemo(
|
||||||
|
() => definition ? visibleGroups(definition, values) : [],
|
||||||
|
[definition, values]
|
||||||
|
);
|
||||||
|
const diagnostics = useMemo(() => {
|
||||||
|
const grouped = new Map<string, ValidationResult[]>();
|
||||||
|
for (const item of instance?.validation_results ?? []) {
|
||||||
|
const key = item.field ?? "";
|
||||||
|
grouped.set(key, [...(grouped.get(key) ?? []), item]);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}, [instance]);
|
||||||
|
const signatureBlocked = Boolean(
|
||||||
|
definition?.signature_requirement === "required"
|
||||||
|
&& (instance?.signature_refs.length ?? 0) === 0
|
||||||
|
);
|
||||||
|
const attachmentLimitReached = Boolean(
|
||||||
|
definition && attachmentRefs.length >= definition.max_attachments
|
||||||
|
);
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
if (!token) return;
|
||||||
|
const loaded = await getPublicFormIntake(settings, token);
|
||||||
|
setInstance(loaded.instance);
|
||||||
|
setDefinition(loaded.definition);
|
||||||
|
setValues(loaded.instance.values);
|
||||||
|
setAttachmentRefs(loaded.instance.attachment_refs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!instance || !token || !definition?.allow_drafts || !changed) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await savePublicFormDraft(
|
||||||
|
settings,
|
||||||
|
token,
|
||||||
|
instance,
|
||||||
|
values,
|
||||||
|
attachmentRefs,
|
||||||
|
"Public participant saved the draft."
|
||||||
|
);
|
||||||
|
await reload();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The draft could not be saved.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!instance || !token || !editable || signatureBlocked) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await submitPublicFormIntake(settings, token, instance, values, attachmentRefs);
|
||||||
|
await reload();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The Form could not be submitted.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload(files: File[]) {
|
||||||
|
if (!instance || !token || !definition) return;
|
||||||
|
const available = Math.max(0, definition.max_attachments - attachmentRefs.length);
|
||||||
|
const selected = files.slice(0, available);
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const uploaded: EvidenceReference[] = [];
|
||||||
|
const names: Record<string, string> = {};
|
||||||
|
for (const file of selected) {
|
||||||
|
const grant = await createPublicFormEvidenceGrant(
|
||||||
|
settings,
|
||||||
|
token,
|
||||||
|
instance,
|
||||||
|
crypto.randomUUID(),
|
||||||
|
[...attachmentRefs, ...uploaded]
|
||||||
|
);
|
||||||
|
const result = await uploadFormEvidence(settings, grant, file);
|
||||||
|
uploaded.push(result.evidence);
|
||||||
|
names[result.evidence.evidence_id] = file.name;
|
||||||
|
}
|
||||||
|
setAttachmentRefs((current) => [...current, ...uploaded]);
|
||||||
|
setAttachmentNames((current) => ({ ...current, ...names }));
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The attachment could not be uploaded.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="forms-runtime-page forms-public-page">
|
||||||
|
<div className="form-instance-shell">
|
||||||
|
<ActionToolbar className="form-instance-toolbar">
|
||||||
|
<strong>{localized.title || "Form"}</strong>
|
||||||
|
{instance && <StatusBadge status={editable ? "active" : "inactive"} label={stateLabel(instance.status)} />}
|
||||||
|
</ActionToolbar>
|
||||||
|
<PageScrollViewport className="form-instance-viewport">
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{loading && <LoadingIndicator label="Loading Form" />}
|
||||||
|
{!loading && instance && definition &&
|
||||||
|
<div className="form-public-content">
|
||||||
|
<header>
|
||||||
|
<h1>{localized.title}</h1>
|
||||||
|
{localized.description && <p>{localized.description}</p>}
|
||||||
|
</header>
|
||||||
|
{groups.map((group) =>
|
||||||
|
<section className="form-runtime-section" key={`${group.pageKey}:${group.sectionKey}`}>
|
||||||
|
{(groups.length > 1 || definition.pages?.length) &&
|
||||||
|
<header>
|
||||||
|
<h2>{localized.sectionTitles[group.sectionKey] ?? group.sectionTitle}</h2>
|
||||||
|
{group.description && <p>{group.description}</p>}
|
||||||
|
</header>
|
||||||
|
}
|
||||||
|
<div className="form-fields">
|
||||||
|
{group.fields.map((field) =>
|
||||||
|
<FormField
|
||||||
|
key={field.key}
|
||||||
|
field={{
|
||||||
|
...field,
|
||||||
|
label: localized.fieldLabels[field.key] ?? field.label,
|
||||||
|
help_text: localized.fieldHelpTexts[field.key] ?? field.help_text
|
||||||
|
}}
|
||||||
|
optionLabels={localized.optionLabels[field.key] ?? {}}
|
||||||
|
value={values[field.key]}
|
||||||
|
disabled={!editable || busy}
|
||||||
|
diagnostics={diagnostics.get(field.key) ?? []}
|
||||||
|
onChange={(value) => setValues((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
if (value === undefined || value === "") delete next[field.key];
|
||||||
|
else next[field.key] = value;
|
||||||
|
return next;
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{definition.max_attachments > 0 && editable &&
|
||||||
|
<section className="form-public-attachments">
|
||||||
|
<h2>Attachments</h2>
|
||||||
|
<FileDropZone
|
||||||
|
multiple
|
||||||
|
busy={busy}
|
||||||
|
disabled={attachmentLimitReached}
|
||||||
|
note={`${attachmentRefs.length} of ${definition.max_attachments} attachments`}
|
||||||
|
onFiles={upload}
|
||||||
|
/>
|
||||||
|
<div className="form-public-attachment-list">
|
||||||
|
{attachmentRefs.map((reference) =>
|
||||||
|
<div key={`${reference.evidence_id}:${reference.version ?? ""}`}>
|
||||||
|
<span>{attachmentNames[reference.evidence_id] ?? `Managed attachment ${reference.evidence_id.slice(0, 8)}`}</span>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
aria-label="Remove attachment"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setAttachmentRefs((current) => current.filter((item) => item !== reference))}>
|
||||||
|
<Trash2 size={15} aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
{signatureBlocked &&
|
||||||
|
<ActionBlockerHint
|
||||||
|
tone="warning"
|
||||||
|
reason={{
|
||||||
|
summary: "This Form requires an authenticated or external signature.",
|
||||||
|
details: "The anonymous/invitation profile cannot silently substitute a lower-assurance acknowledgement.",
|
||||||
|
requiredAction: "Use an authenticated service entry or a configured signature provider.",
|
||||||
|
actor: "Service owner",
|
||||||
|
target: "Form intake and signature policy"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{editable &&
|
||||||
|
<div className="form-instance-actions">
|
||||||
|
{definition.allow_drafts &&
|
||||||
|
<Button disabled={busy || !changed} onClick={() => void save()}>
|
||||||
|
<Save size={16} aria-hidden="true" />
|
||||||
|
Save draft
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={busy || signatureBlocked}
|
||||||
|
disabledReason={signatureBlocked ? "The required signature profile is unavailable for this public link." : undefined}
|
||||||
|
onClick={() => setConfirmingSubmit(true)}>
|
||||||
|
<Send size={16} aria-hidden="true" />
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{!editable && instance.receipt_id &&
|
||||||
|
<div className="form-receipt">
|
||||||
|
<strong>Submission received</strong>
|
||||||
|
<span>Receipt</span>
|
||||||
|
<code>{instance.receipt_id}</code>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{!editable && instance.status_access &&
|
||||||
|
<section className="form-status-access-receipt">
|
||||||
|
<div>
|
||||||
|
<strong>Track this application</strong>
|
||||||
|
<span>{statusAccessMessage(instance.status_access.mode)}</span>
|
||||||
|
</div>
|
||||||
|
<code>{instance.status_access.tracking_id}</code>
|
||||||
|
<a className="btn" href={instance.status_access.href}>Open status page</a>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</PageScrollViewport>
|
||||||
|
</div>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmingSubmit}
|
||||||
|
title="Submit Form"
|
||||||
|
message="Submit this Form? The values and managed attachment references become an immutable submission revision."
|
||||||
|
confirmLabel="Submit"
|
||||||
|
busy={busy}
|
||||||
|
onCancel={() => setConfirmingSubmit(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmingSubmit(false);
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function anonymousStartAttempt(publicId: string): AnonymousStartAttempt {
|
||||||
|
const key = `govoplan.forms-runtime.start.${publicId}`;
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem(key);
|
||||||
|
if (stored) return JSON.parse(stored) as AnonymousStartAttempt;
|
||||||
|
const attempt = {
|
||||||
|
idempotencyKey: crypto.randomUUID(),
|
||||||
|
recordedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
sessionStorage.setItem(key, JSON.stringify(attempt));
|
||||||
|
return attempt;
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
idempotencyKey: crypto.randomUUID(),
|
||||||
|
recordedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAnonymousStartAttempt(publicId: string) {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(`govoplan.forms-runtime.start.${publicId}`);
|
||||||
|
} catch {
|
||||||
|
// Private browsing may deny session storage; the active token still works.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionToken(publicId: string): string {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(`govoplan.forms-runtime.token.${publicId}`) ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberSessionToken(publicId: string, token: string) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(`govoplan.forms-runtime.token.${publicId}`, token);
|
||||||
|
} catch {
|
||||||
|
// Keep the token in component memory when session storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateLabel(value: string): string {
|
||||||
|
return `i18n:govoplan-forms-runtime.state_${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusAccessMessage(mode: "authenticated" | "email_link" | "permanent_link"): string {
|
||||||
|
if (mode === "authenticated") return "Sign in with the linked applicant account to view status.";
|
||||||
|
if (mode === "email_link") return "Use this tracking ID and the linked email address to request a short-lived status link.";
|
||||||
|
return "This permanent bearer link does not require sign-in. Store and share it carefully.";
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { Save } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogForm,
|
||||||
|
DialogSection,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
FormGrid,
|
||||||
|
LoadingIndicator,
|
||||||
|
StatusBadge,
|
||||||
|
ToggleSwitch,
|
||||||
|
type PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
listFormIntakeDefinitions,
|
||||||
|
listFormStatusAccessPolicies,
|
||||||
|
saveFormStatusAccessPolicy,
|
||||||
|
type FormDefinition,
|
||||||
|
type FormStatusAccessMode,
|
||||||
|
type FormStatusAccessPolicy
|
||||||
|
} from "../../api/formsRuntime";
|
||||||
|
|
||||||
|
|
||||||
|
type StatusAccessPoliciesDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
settings: PlatformRouteContext["settings"];
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatusAccessPoliciesDialog({
|
||||||
|
open,
|
||||||
|
settings,
|
||||||
|
onClose
|
||||||
|
}: StatusAccessPoliciesDialogProps) {
|
||||||
|
const [definitions, setDefinitions] = useState<FormDefinition[]>([]);
|
||||||
|
const [policies, setPolicies] = useState<FormStatusAccessPolicy[]>([]);
|
||||||
|
const [definitionKey, setDefinitionKey] = useState("");
|
||||||
|
const [mode, setMode] = useState<FormStatusAccessMode>("authenticated");
|
||||||
|
const [emailFieldKey, setEmailFieldKey] = useState("");
|
||||||
|
const [tokenMinutes, setTokenMinutes] = useState(60);
|
||||||
|
const [requestLimit, setRequestLimit] = useState(5);
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busyKey, setBusyKey] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async (signal?: AbortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [definitionResult, policyResult] = await Promise.all([
|
||||||
|
listFormIntakeDefinitions(settings, signal),
|
||||||
|
listFormStatusAccessPolicies(settings, signal)
|
||||||
|
]);
|
||||||
|
setDefinitions(definitionResult.definitions);
|
||||||
|
setPolicies(policyResult.policies);
|
||||||
|
setDefinitionKey((current) => current || referenceKey(definitionResult.definitions[0]));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const controller = new AbortController();
|
||||||
|
void load(controller.signal).catch((reason) => {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Status access policies could not be loaded.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [load, open]);
|
||||||
|
|
||||||
|
const definition = useMemo(
|
||||||
|
() => definitions.find((item) => referenceKey(item) === definitionKey),
|
||||||
|
[definitionKey, definitions]
|
||||||
|
);
|
||||||
|
const policy = useMemo(
|
||||||
|
() => policies.find((item) => referenceKey(item) === definitionKey),
|
||||||
|
[definitionKey, policies]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!definitionKey) return;
|
||||||
|
if (policy) {
|
||||||
|
setMode(policy.mode);
|
||||||
|
setEmailFieldKey(policy.email_field_key ?? "");
|
||||||
|
setTokenMinutes(Math.max(5, Math.round(policy.token_ttl_seconds / 60)));
|
||||||
|
setRequestLimit(policy.request_limit_per_hour);
|
||||||
|
setEnabled(policy.enabled);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMode("authenticated");
|
||||||
|
setEmailFieldKey("");
|
||||||
|
setTokenMinutes(60);
|
||||||
|
setRequestLimit(5);
|
||||||
|
setEnabled(true);
|
||||||
|
}, [definitionKey, policy]);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!definition || (mode === "email_link" && !emailFieldKey)) return;
|
||||||
|
setBusyKey(definitionKey);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
await saveFormStatusAccessPolicy(settings, {
|
||||||
|
definitionRef: definition.reference,
|
||||||
|
mode,
|
||||||
|
enabled,
|
||||||
|
emailFieldKey,
|
||||||
|
tokenTtlSeconds: tokenMinutes * 60,
|
||||||
|
requestLimitPerHour: requestLimit,
|
||||||
|
expectedRevision: policy?.revision
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
setNotice("The applicant status access policy was saved.");
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The status access policy could not be saved.");
|
||||||
|
} finally {
|
||||||
|
setBusyKey("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(item: FormStatusAccessPolicy, nextEnabled: boolean) {
|
||||||
|
const exactDefinition = definitions.find((candidate) => referenceKey(candidate) === referenceKey(item));
|
||||||
|
if (!exactDefinition) return;
|
||||||
|
setBusyKey(item.policy_id);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
await saveFormStatusAccessPolicy(settings, {
|
||||||
|
definitionRef: exactDefinition.reference,
|
||||||
|
mode: item.mode,
|
||||||
|
enabled: nextEnabled,
|
||||||
|
emailFieldKey: item.email_field_key ?? undefined,
|
||||||
|
tokenTtlSeconds: item.token_ttl_seconds,
|
||||||
|
requestLimitPerHour: item.request_limit_per_hour,
|
||||||
|
expectedRevision: item.revision
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
setNotice(nextEnabled
|
||||||
|
? "Applicant status access was enabled for future submissions and existing grants."
|
||||||
|
: "Applicant status access and all existing grants for this exact Form revision were suspended."
|
||||||
|
);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The status access policy could not be changed.");
|
||||||
|
} finally {
|
||||||
|
setBusyKey("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title="Applicant status access"
|
||||||
|
description="Choose the access and disclosure profile independently for each exact published Form revision."
|
||||||
|
size="large"
|
||||||
|
closeDisabled={Boolean(busyKey)}
|
||||||
|
onClose={onClose}
|
||||||
|
helpContextId="forms_runtime.status-policy"
|
||||||
|
helpTopicId="forms_runtime.submissions"
|
||||||
|
footer={<Button onClick={onClose} disabled={Boolean(busyKey)}>Close</Button>}>
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{notice && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
||||||
|
{loading && <LoadingIndicator label="Loading applicant status policies" />}
|
||||||
|
{!loading && definitions.length === 0 &&
|
||||||
|
<DismissibleAlert tone="info">Publish a Form revision before configuring applicant status.</DismissibleAlert>
|
||||||
|
}
|
||||||
|
{!loading && definitions.length > 0 &&
|
||||||
|
<DialogForm onSubmit={(event) => { event.preventDefault(); void save(); }}>
|
||||||
|
<DialogSection title={policy ? "Edit policy" : "Add policy"}>
|
||||||
|
<FormGrid columns={2} gap="small" collapseAt="workspace">
|
||||||
|
<FormField label="Published Form">
|
||||||
|
<select value={definitionKey} onChange={(event) => setDefinitionKey(event.target.value)} disabled={Boolean(busyKey)}>
|
||||||
|
{definitions.map((item) =>
|
||||||
|
<option key={referenceKey(item)} value={referenceKey(item)}>
|
||||||
|
{item.title} · revision {item.reference.version}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Access profile">
|
||||||
|
<select value={mode} onChange={(event) => setMode(event.target.value as FormStatusAccessMode)} disabled={Boolean(busyKey)}>
|
||||||
|
<option value="authenticated">Authenticated applicant only</option>
|
||||||
|
<option value="email_link">Short-lived link to linked email</option>
|
||||||
|
<option value="permanent_link">Permanent public bearer link</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{mode === "email_link" &&
|
||||||
|
<FormField label="Linked email field" help="The final submitted value is compared without disclosing whether a request matched.">
|
||||||
|
<select value={emailFieldKey} onChange={(event) => setEmailFieldKey(event.target.value)} disabled={Boolean(busyKey)} required>
|
||||||
|
<option value="">Select a field</option>
|
||||||
|
{definition?.fields.filter((field) => field.value_type === "email").map((field) =>
|
||||||
|
<option key={field.key} value={field.key}>{field.label} ({field.key})</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
}
|
||||||
|
{mode === "email_link" &&
|
||||||
|
<FormField label="Link validity (minutes)">
|
||||||
|
<input type="number" min={5} max={10_080} value={tokenMinutes} onChange={(event) => setTokenMinutes(Number(event.target.value))} disabled={Boolean(busyKey)} />
|
||||||
|
</FormField>
|
||||||
|
}
|
||||||
|
{mode === "email_link" &&
|
||||||
|
<FormField label="Requests per hour">
|
||||||
|
<input type="number" min={1} max={60} value={requestLimit} onChange={(event) => setRequestLimit(Number(event.target.value))} disabled={Boolean(busyKey)} />
|
||||||
|
</FormField>
|
||||||
|
}
|
||||||
|
<FormField label="Policy state">
|
||||||
|
<ToggleSwitch label="Applicant status enabled" checked={enabled} onChange={setEnabled} disabled={Boolean(busyKey)} />
|
||||||
|
</FormField>
|
||||||
|
</FormGrid>
|
||||||
|
<AccessConsequence mode={mode} />
|
||||||
|
<div className="form-status-policy-save">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={Boolean(busyKey) || !definition || (mode === "email_link" && !emailFieldKey)}>
|
||||||
|
<Save size={16} aria-hidden="true" />
|
||||||
|
Save policy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogSection>
|
||||||
|
<DialogSection title="Configured policies" variant="inset">
|
||||||
|
{policies.length === 0 && <p className="form-intake-empty">No applicant status policy has been configured.</p>}
|
||||||
|
<div className="form-status-policy-list">
|
||||||
|
{policies.map((item) =>
|
||||||
|
<div className="form-status-policy-row" key={item.policy_id}>
|
||||||
|
<span>
|
||||||
|
<strong>{policyTitle(item)}</strong>
|
||||||
|
<small>Revision {item.definition_ref.version} · {modeLabel(item.mode)}</small>
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={item.enabled ? "active" : "inactive"} label={item.enabled ? "Enabled" : "Suspended"} />
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Policy enabled"
|
||||||
|
checked={item.enabled}
|
||||||
|
disabled={Boolean(busyKey)}
|
||||||
|
onChange={(value) => void toggle(item, value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogSection>
|
||||||
|
</DialogForm>
|
||||||
|
}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccessConsequence({ mode }: { mode: FormStatusAccessMode }) {
|
||||||
|
if (mode === "authenticated") {
|
||||||
|
return <DismissibleAlert tone="info">Only the bound applicant account can view status. Assisted intake needs an explicit <code>account:</code> party reference for this profile.</DismissibleAlert>;
|
||||||
|
}
|
||||||
|
if (mode === "email_link") {
|
||||||
|
return <DismissibleAlert tone="warning">A matching identifier and email request creates a new expiring secret and revokes the previous one. Notifications and Mail must be configured for delivery.</DismissibleAlert>;
|
||||||
|
}
|
||||||
|
return <DismissibleAlert tone="warning">The link does not expire or require sign-in. Anyone holding it can see the bounded status timeline until the policy is suspended.</DismissibleAlert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function referenceKey(value?: FormDefinition | FormStatusAccessPolicy): string {
|
||||||
|
if (!value) return "";
|
||||||
|
const reference = "definition_ref" in value ? value.definition_ref : value.reference;
|
||||||
|
return `${reference.object_id}:${reference.version ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function policyTitle(policy: FormStatusAccessPolicy): string {
|
||||||
|
const title = policy.metadata.definition_title;
|
||||||
|
return typeof title === "string" && title.trim()
|
||||||
|
? title
|
||||||
|
: policy.definition_ref.label ?? policy.definition_ref.object_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeLabel(mode: FormStatusAccessMode): string {
|
||||||
|
if (mode === "authenticated") return "Authenticated applicant";
|
||||||
|
if (mode === "email_link") return "Short-lived email link";
|
||||||
|
return "Permanent public link";
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
const en = {
|
const en = {
|
||||||
|
"Form instance": "Form instance",
|
||||||
|
"Forms runtime": "Forms runtime",
|
||||||
"i18n:govoplan-forms-runtime.forms": "Forms",
|
"i18n:govoplan-forms-runtime.forms": "Forms",
|
||||||
"i18n:govoplan-forms-runtime.loading_reason": "Form data is still loading.",
|
"i18n:govoplan-forms-runtime.loading_reason": "Form data is still loading.",
|
||||||
"i18n:govoplan-forms-runtime.saving_reason": "A Form operation is still running.",
|
"i18n:govoplan-forms-runtime.saving_reason": "A Form operation is still running.",
|
||||||
@@ -73,10 +75,137 @@ const en = {
|
|||||||
"Select": "Select",
|
"Select": "Select",
|
||||||
"Compensate handoff": "Compensate handoff",
|
"Compensate handoff": "Compensate handoff",
|
||||||
"Confirm absent and compensate": "Confirm absent and compensate",
|
"Confirm absent and compensate": "Confirm absent and compensate",
|
||||||
"Read-only Form": "Read-only Form"
|
"Read-only Form": "Read-only Form",
|
||||||
|
"Public intake": "Public intake",
|
||||||
|
"Public Form intake": "Public Form intake",
|
||||||
|
"Loading public intake profiles": "Loading public intake profiles",
|
||||||
|
"Add intake profile": "Add intake profile",
|
||||||
|
"Published Form": "Published Form",
|
||||||
|
"Access mode": "Access mode",
|
||||||
|
"Invitation link": "Invitation link",
|
||||||
|
"Open anonymous link": "Open anonymous link",
|
||||||
|
"Draft retention (days)": "Draft retention (days)",
|
||||||
|
"Invitation validity (days)": "Invitation validity (days)",
|
||||||
|
"Starts per minute": "Starts per minute",
|
||||||
|
"Add profile": "Add profile",
|
||||||
|
"Configured profiles": "Configured profiles",
|
||||||
|
"No public intake profile has been configured.": "No public intake profile has been configured.",
|
||||||
|
"Anonymous link": "Anonymous link",
|
||||||
|
"Invitation links": "Invitation links",
|
||||||
|
"Active": "Active",
|
||||||
|
"Inactive": "Inactive",
|
||||||
|
"Profile active": "Profile active",
|
||||||
|
"Issue invitation": "Issue invitation",
|
||||||
|
"Copy link": "Copy link",
|
||||||
|
"Reusable public link": "Reusable public link",
|
||||||
|
"The public intake profile was created.": "The public intake profile was created.",
|
||||||
|
"The invitation was issued. Copy its link now; the token is not stored in readable form.": "The invitation was issued. Copy its link now; the token is not stored in readable form.",
|
||||||
|
"The intake link was copied.": "The intake link was copied.",
|
||||||
|
"Evidence and acknowledgement": "Evidence and acknowledgement",
|
||||||
|
"Attachments": "Attachments",
|
||||||
|
"Remove attachment": "Remove attachment",
|
||||||
|
"Authenticated acknowledgement": "Authenticated acknowledgement",
|
||||||
|
"Records your account, the exact values, and the exact managed attachments. It is not a qualified electronic signature.": "Records your account, the exact values, and the exact managed attachments. It is not a qualified electronic signature.",
|
||||||
|
"Renew acknowledgement": "Renew acknowledgement",
|
||||||
|
"Acknowledge": "Acknowledge",
|
||||||
|
"Record acknowledgement": "Record acknowledgement",
|
||||||
|
"File in eAkte": "File in eAkte",
|
||||||
|
"Record the required acknowledgement before submitting.": "Record the required acknowledgement before submitting.",
|
||||||
|
"Confirm that the displayed values and managed attachments are correct and complete. This records a payload-bound authenticated acknowledgement; it is not a qualified electronic signature.": "Confirm that the displayed values and managed attachments are correct and complete. This records a payload-bound authenticated acknowledgement; it is not a qualified electronic signature.",
|
||||||
|
"Form": "Form",
|
||||||
|
"This Form requires an authenticated or external signature.": "This Form requires an authenticated or external signature.",
|
||||||
|
"The anonymous/invitation profile cannot silently substitute a lower-assurance acknowledgement.": "The anonymous/invitation profile cannot silently substitute a lower-assurance acknowledgement.",
|
||||||
|
"Use an authenticated service entry or a configured signature provider.": "Use an authenticated service entry or a configured signature provider.",
|
||||||
|
"Service owner": "Service owner",
|
||||||
|
"Form intake and signature policy": "Form intake and signature policy",
|
||||||
|
"The required signature profile is unavailable for this public link.": "The required signature profile is unavailable for this public link.",
|
||||||
|
"Submission received": "Submission received",
|
||||||
|
"Receipt": "Receipt",
|
||||||
|
"Submit Form": "Submit Form",
|
||||||
|
"Submit this Form? The values and managed attachment references become an immutable submission revision.": "Submit this Form? The values and managed attachment references become an immutable submission revision.",
|
||||||
|
"Assisted intake": "Assisted intake",
|
||||||
|
"Start assisted intake": "Start assisted intake",
|
||||||
|
"Capture who is acting, for whom, through which channel, and for what purpose before entering Form values.": "Capture who is acting, for whom, through which channel, and for what purpose before entering Form values.",
|
||||||
|
"Cancel": "Cancel",
|
||||||
|
"Start session": "Start session",
|
||||||
|
"Loading assisted intake profiles": "Loading assisted intake profiles",
|
||||||
|
"No assisted intake profile is enabled. Ask a Forms Runtime administrator to add one for the published Form.": "No assisted intake profile is enabled. Ask a Forms Runtime administrator to add one for the published Form.",
|
||||||
|
"Intake channel": "Intake channel",
|
||||||
|
"Service counter": "Service counter",
|
||||||
|
"Telephone": "Telephone",
|
||||||
|
"Paper": "Paper",
|
||||||
|
"Email": "Email",
|
||||||
|
"Mobile service": "Mobile service",
|
||||||
|
"Representative": "Representative",
|
||||||
|
"Offline import": "Offline import",
|
||||||
|
"Affected party reference": "Affected party reference",
|
||||||
|
"Use the governed person or organization reference; do not enter a display name only.": "Use the governed person or organization reference; do not enter a display name only.",
|
||||||
|
"Represented party reference": "Represented party reference",
|
||||||
|
"Optional when the affected party is acting directly.": "Optional when the affected party is acting directly.",
|
||||||
|
"Authority basis": "Authority basis",
|
||||||
|
"Acting for self": "Acting for self",
|
||||||
|
"Documented representation": "Documented representation",
|
||||||
|
"Legal guardianship": "Legal guardianship",
|
||||||
|
"Statutory authority": "Statutory authority",
|
||||||
|
"Responsible function reference": "Responsible function reference",
|
||||||
|
"Purpose": "Purpose",
|
||||||
|
"Legal basis reference": "Legal basis reference",
|
||||||
|
"Consent basis": "Consent basis",
|
||||||
|
"Accessibility or communication support": "Accessibility or communication support",
|
||||||
|
"Separate multiple needs with commas.": "Separate multiple needs with commas.",
|
||||||
|
"Privacy and procedural notice was provided": "Privacy and procedural notice was provided",
|
||||||
|
"Record the fact of notice here; retain any separately required evidence through its owning module.": "Record the fact of notice here; retain any separately required evidence through its owning module.",
|
||||||
|
"Assisted intake context": "Assisted intake context",
|
||||||
|
"Read-back current": "Read-back current",
|
||||||
|
"Read-back required": "Read-back required",
|
||||||
|
"Read-back recorded": "Read-back recorded",
|
||||||
|
"No read-back evidence": "No read-back evidence",
|
||||||
|
"Channel": "Channel",
|
||||||
|
"Affected party": "Affected party",
|
||||||
|
"Represented party": "Represented party",
|
||||||
|
"Authority": "Authority",
|
||||||
|
"Responsible function": "Responsible function",
|
||||||
|
"Notice": "Notice",
|
||||||
|
"Provided": "Provided",
|
||||||
|
"Not recorded": "Not recorded",
|
||||||
|
"Language": "Language",
|
||||||
|
"Communication support": "Communication support",
|
||||||
|
"This context records provenance and does not bypass the published Form rules. Any saved correction invalidates the earlier read-back for submission.": "This context records provenance and does not bypass the published Form rules. Any saved correction invalidates the earlier read-back for submission.",
|
||||||
|
"Read back and submit": "Read back and submit",
|
||||||
|
"Record assisted read-back": "Record assisted read-back",
|
||||||
|
"Make the exact values and managed evidence available to the confirming party. Record corrections before continuing; the server binds this evidence to the current revision and submission payload.": "Make the exact values and managed evidence available to the confirming party. Record corrections before continuing; the server binds this evidence to the current revision and submission payload.",
|
||||||
|
"Record and continue": "Record and continue",
|
||||||
|
"Outcome": "Outcome",
|
||||||
|
"Confirmed without correction": "Confirmed without correction",
|
||||||
|
"Corrected and confirmed": "Corrected and confirmed",
|
||||||
|
"Confirmation unavailable": "Confirmation unavailable",
|
||||||
|
"Confirmation method": "Confirmation method",
|
||||||
|
"Spoken read-back": "Spoken read-back",
|
||||||
|
"Written preview": "Written preview",
|
||||||
|
"Accessible copy": "Accessible copy",
|
||||||
|
"Unavailable": "Unavailable",
|
||||||
|
"Confirming party reference": "Confirming party reference",
|
||||||
|
"Note (optional)": "Note (optional)",
|
||||||
|
"Correction or exception note": "Correction or exception note",
|
||||||
|
"Source of each populated value": "Source of each populated value",
|
||||||
|
"Classify every value independently. Use a governed party, document, or system reference instead of repeating sensitive evidence in free text.": "Classify every value independently. Use a governed party, document, or system reference instead of repeating sensitive evidence in free text.",
|
||||||
|
"Value source": "Value source",
|
||||||
|
"Person statement": "Person statement",
|
||||||
|
"Representative statement": "Representative statement",
|
||||||
|
"Document": "Document",
|
||||||
|
"Existing system": "Existing system",
|
||||||
|
"Derived by operator": "Derived by operator",
|
||||||
|
"Source confidence": "Source confidence",
|
||||||
|
"Stated": "Stated",
|
||||||
|
"Verified": "Verified",
|
||||||
|
"Uncertain": "Uncertain",
|
||||||
|
"Declared by or source reference": "Declared by or source reference",
|
||||||
|
"Optional for derived values; otherwise identify the governed party, document, or source system.": "Optional for derived values; otherwise identify the governed party, document, or source system."
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const de: Record<keyof typeof en, string> = {
|
const de: Record<keyof typeof en, string> = {
|
||||||
|
"Form instance": "Formularinstanz",
|
||||||
|
"Forms runtime": "Formularlaufzeit",
|
||||||
"i18n:govoplan-forms-runtime.forms": "Formulare",
|
"i18n:govoplan-forms-runtime.forms": "Formulare",
|
||||||
"i18n:govoplan-forms-runtime.loading_reason": "Formulardaten werden noch geladen.",
|
"i18n:govoplan-forms-runtime.loading_reason": "Formulardaten werden noch geladen.",
|
||||||
"i18n:govoplan-forms-runtime.saving_reason": "Eine Formularaktion läuft noch.",
|
"i18n:govoplan-forms-runtime.saving_reason": "Eine Formularaktion läuft noch.",
|
||||||
@@ -149,7 +278,132 @@ const de: Record<keyof typeof en, string> = {
|
|||||||
"Select": "Auswählen",
|
"Select": "Auswählen",
|
||||||
"Compensate handoff": "Übergabe kompensieren",
|
"Compensate handoff": "Übergabe kompensieren",
|
||||||
"Confirm absent and compensate": "Fehlen bestätigen und kompensieren",
|
"Confirm absent and compensate": "Fehlen bestätigen und kompensieren",
|
||||||
"Read-only Form": "Schreibgeschütztes Formular"
|
"Read-only Form": "Schreibgeschütztes Formular",
|
||||||
|
"Public intake": "Öffentlicher Formulareingang",
|
||||||
|
"Public Form intake": "Öffentlicher Formulareingang",
|
||||||
|
"Loading public intake profiles": "Profile für den öffentlichen Formulareingang werden geladen",
|
||||||
|
"Add intake profile": "Eingangsprofil hinzufügen",
|
||||||
|
"Published Form": "Veröffentlichtes Formular",
|
||||||
|
"Access mode": "Zugangsart",
|
||||||
|
"Invitation link": "Einladungslink",
|
||||||
|
"Open anonymous link": "Offener anonymer Link",
|
||||||
|
"Draft retention (days)": "Entwurfsaufbewahrung (Tage)",
|
||||||
|
"Invitation validity (days)": "Gültigkeit der Einladung (Tage)",
|
||||||
|
"Starts per minute": "Starts pro Minute",
|
||||||
|
"Add profile": "Profil hinzufügen",
|
||||||
|
"Configured profiles": "Konfigurierte Profile",
|
||||||
|
"No public intake profile has been configured.": "Es ist kein Profil für den öffentlichen Formulareingang konfiguriert.",
|
||||||
|
"Anonymous link": "Anonymer Link",
|
||||||
|
"Invitation links": "Einladungslinks",
|
||||||
|
"Active": "Aktiv",
|
||||||
|
"Inactive": "Inaktiv",
|
||||||
|
"Profile active": "Profil aktiv",
|
||||||
|
"Issue invitation": "Einladung ausstellen",
|
||||||
|
"Copy link": "Link kopieren",
|
||||||
|
"Reusable public link": "Wiederverwendbarer öffentlicher Link",
|
||||||
|
"The public intake profile was created.": "Das Profil für den öffentlichen Formulareingang wurde erstellt.",
|
||||||
|
"The invitation was issued. Copy its link now; the token is not stored in readable form.": "Die Einladung wurde ausgestellt. Kopieren Sie den Link jetzt; das Token wird nicht lesbar gespeichert.",
|
||||||
|
"The intake link was copied.": "Der Eingangslink wurde kopiert.",
|
||||||
|
"Evidence and acknowledgement": "Nachweise und Bestätigung",
|
||||||
|
"Attachments": "Anhänge",
|
||||||
|
"Remove attachment": "Anhang entfernen",
|
||||||
|
"Authenticated acknowledgement": "Authentifizierte Bestätigung",
|
||||||
|
"Records your account, the exact values, and the exact managed attachments. It is not a qualified electronic signature.": "Erfasst Ihr Konto, die exakten Werte und die exakten verwalteten Anhänge. Dies ist keine qualifizierte elektronische Signatur.",
|
||||||
|
"Renew acknowledgement": "Bestätigung erneuern",
|
||||||
|
"Acknowledge": "Bestätigen",
|
||||||
|
"Record acknowledgement": "Bestätigung erfassen",
|
||||||
|
"File in eAkte": "In eAkte verakten",
|
||||||
|
"Record the required acknowledgement before submitting.": "Erfassen Sie vor dem Absenden die erforderliche Bestätigung.",
|
||||||
|
"Confirm that the displayed values and managed attachments are correct and complete. This records a payload-bound authenticated acknowledgement; it is not a qualified electronic signature.": "Bestätigen Sie, dass die angezeigten Werte und verwalteten Anhänge richtig und vollständig sind. Dies erfasst eine an die Nutzdaten gebundene authentifizierte Bestätigung; es ist keine qualifizierte elektronische Signatur.",
|
||||||
|
"Form": "Formular",
|
||||||
|
"This Form requires an authenticated or external signature.": "Dieses Formular erfordert eine authentifizierte oder externe Signatur.",
|
||||||
|
"The anonymous/invitation profile cannot silently substitute a lower-assurance acknowledgement.": "Das anonyme beziehungsweise Einladungsprofil darf nicht stillschweigend eine Bestätigung mit geringerem Vertrauensniveau einsetzen.",
|
||||||
|
"Use an authenticated service entry or a configured signature provider.": "Verwenden Sie einen authentifizierten Diensteinstieg oder einen konfigurierten Signaturanbieter.",
|
||||||
|
"Service owner": "Dienstverantwortliche Stelle",
|
||||||
|
"Form intake and signature policy": "Richtlinie für Formulareingang und Signaturen",
|
||||||
|
"The required signature profile is unavailable for this public link.": "Das erforderliche Signaturprofil ist für diesen öffentlichen Link nicht verfügbar.",
|
||||||
|
"Submission received": "Übermittlung eingegangen",
|
||||||
|
"Receipt": "Beleg",
|
||||||
|
"Submit Form": "Formular absenden",
|
||||||
|
"Submit this Form? The values and managed attachment references become an immutable submission revision.": "Dieses Formular absenden? Die Werte und Referenzen auf verwaltete Anhänge werden zu einer unveränderlichen Übermittlungsrevision.",
|
||||||
|
"Assisted intake": "Assistierte Erfassung",
|
||||||
|
"Start assisted intake": "Assistierte Erfassung starten",
|
||||||
|
"Capture who is acting, for whom, through which channel, and for what purpose before entering Form values.": "Erfassen Sie vor den Formularwerten, wer für wen, über welchen Kanal und zu welchem Zweck handelt.",
|
||||||
|
"Cancel": "Abbrechen",
|
||||||
|
"Start session": "Sitzung starten",
|
||||||
|
"Loading assisted intake profiles": "Profile für die assistierte Erfassung werden geladen",
|
||||||
|
"No assisted intake profile is enabled. Ask a Forms Runtime administrator to add one for the published Form.": "Es ist kein Profil für die assistierte Erfassung aktiviert. Bitten Sie die Formularadministration, eines für das veröffentlichte Formular einzurichten.",
|
||||||
|
"Intake channel": "Erfassungskanal",
|
||||||
|
"Service counter": "Serviceschalter",
|
||||||
|
"Telephone": "Telefon",
|
||||||
|
"Paper": "Papier",
|
||||||
|
"Email": "E-Mail",
|
||||||
|
"Mobile service": "Mobiler Dienst",
|
||||||
|
"Representative": "Vertretung",
|
||||||
|
"Offline import": "Offline-Import",
|
||||||
|
"Affected party reference": "Referenz der betroffenen Partei",
|
||||||
|
"Use the governed person or organization reference; do not enter a display name only.": "Verwenden Sie die geregelte Personen- oder Organisationsreferenz, nicht nur einen Anzeigenamen.",
|
||||||
|
"Represented party reference": "Referenz der vertretenen Partei",
|
||||||
|
"Optional when the affected party is acting directly.": "Optional, wenn die betroffene Partei selbst handelt.",
|
||||||
|
"Authority basis": "Befugnisgrundlage",
|
||||||
|
"Acting for self": "Handelt für sich selbst",
|
||||||
|
"Documented representation": "Dokumentierte Vertretung",
|
||||||
|
"Legal guardianship": "Gesetzliche Betreuung",
|
||||||
|
"Statutory authority": "Gesetzliche Befugnis",
|
||||||
|
"Responsible function reference": "Referenz der zuständigen Funktion",
|
||||||
|
"Purpose": "Zweck",
|
||||||
|
"Legal basis reference": "Referenz der Rechtsgrundlage",
|
||||||
|
"Consent basis": "Einwilligungsgrundlage",
|
||||||
|
"Accessibility or communication support": "Barrierefreiheits- oder Kommunikationsunterstützung",
|
||||||
|
"Separate multiple needs with commas.": "Trennen Sie mehrere Bedarfe durch Kommas.",
|
||||||
|
"Privacy and procedural notice was provided": "Datenschutz- und Verfahrenshinweis wurde erteilt",
|
||||||
|
"Record the fact of notice here; retain any separately required evidence through its owning module.": "Erfassen Sie hier die Erteilung; gesondert erforderliche Nachweise verbleiben im zuständigen Modul.",
|
||||||
|
"Assisted intake context": "Kontext der assistierten Erfassung",
|
||||||
|
"Read-back current": "Rücklesen aktuell",
|
||||||
|
"Read-back required": "Rücklesen erforderlich",
|
||||||
|
"Read-back recorded": "Rücklesen erfasst",
|
||||||
|
"No read-back evidence": "Kein Rücklesenachweis",
|
||||||
|
"Channel": "Kanal",
|
||||||
|
"Affected party": "Betroffene Partei",
|
||||||
|
"Represented party": "Vertretene Partei",
|
||||||
|
"Authority": "Befugnis",
|
||||||
|
"Responsible function": "Zuständige Funktion",
|
||||||
|
"Notice": "Hinweis",
|
||||||
|
"Provided": "Erteilt",
|
||||||
|
"Not recorded": "Nicht erfasst",
|
||||||
|
"Language": "Sprache",
|
||||||
|
"Communication support": "Kommunikationsunterstützung",
|
||||||
|
"This context records provenance and does not bypass the published Form rules. Any saved correction invalidates the earlier read-back for submission.": "Dieser Kontext erfasst die Herkunft und umgeht die Regeln des veröffentlichten Formulars nicht. Jede gespeicherte Korrektur macht das frühere Rücklesen für die Einreichung ungültig.",
|
||||||
|
"Read back and submit": "Rücklesen und absenden",
|
||||||
|
"Record assisted read-back": "Assistiertes Rücklesen erfassen",
|
||||||
|
"Make the exact values and managed evidence available to the confirming party. Record corrections before continuing; the server binds this evidence to the current revision and submission payload.": "Stellen Sie der bestätigenden Partei die exakten Werte und verwalteten Nachweise bereit. Erfassen Sie Korrekturen vor dem Fortfahren; der Server bindet diesen Nachweis an die aktuelle Revision und die Einreichungsnutzdaten.",
|
||||||
|
"Record and continue": "Erfassen und fortfahren",
|
||||||
|
"Outcome": "Ergebnis",
|
||||||
|
"Confirmed without correction": "Ohne Korrektur bestätigt",
|
||||||
|
"Corrected and confirmed": "Korrigiert und bestätigt",
|
||||||
|
"Confirmation unavailable": "Bestätigung nicht verfügbar",
|
||||||
|
"Confirmation method": "Bestätigungsmethode",
|
||||||
|
"Spoken read-back": "Mündliches Rücklesen",
|
||||||
|
"Written preview": "Schriftliche Vorschau",
|
||||||
|
"Accessible copy": "Barrierefreie Kopie",
|
||||||
|
"Unavailable": "Nicht verfügbar",
|
||||||
|
"Confirming party reference": "Referenz der bestätigenden Partei",
|
||||||
|
"Note (optional)": "Notiz (optional)",
|
||||||
|
"Correction or exception note": "Korrektur- oder Ausnahmenotiz",
|
||||||
|
"Source of each populated value": "Quelle jedes ausgefüllten Werts",
|
||||||
|
"Classify every value independently. Use a governed party, document, or system reference instead of repeating sensitive evidence in free text.": "Klassifizieren Sie jeden Wert einzeln. Verwenden Sie eine geregelte Partei-, Dokument- oder Systemreferenz, statt sensible Nachweise im Freitext zu wiederholen.",
|
||||||
|
"Value source": "Wertquelle",
|
||||||
|
"Person statement": "Angabe der Person",
|
||||||
|
"Representative statement": "Angabe der Vertretung",
|
||||||
|
"Document": "Dokument",
|
||||||
|
"Existing system": "Bestehendes System",
|
||||||
|
"Derived by operator": "Durch Bearbeitung abgeleitet",
|
||||||
|
"Source confidence": "Quellenvertrauen",
|
||||||
|
"Stated": "Angegeben",
|
||||||
|
"Verified": "Geprüft",
|
||||||
|
"Uncertain": "Unsicher",
|
||||||
|
"Declared by or source reference": "Erklärende Partei oder Quellenreferenz",
|
||||||
|
"Optional for derived values; otherwise identify the governed party, document, or source system.": "Bei abgeleiteten Werten optional; sonst die geregelte Partei, das Dokument oder Quellsystem angeben."
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generatedTranslations: PlatformTranslations = { en, de };
|
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||||
|
|||||||
+15
-2
@@ -6,6 +6,7 @@ import "./styles/forms-runtime.css";
|
|||||||
|
|
||||||
const FormsRuntimePage = lazy(() => import("./features/forms/FormsRuntimePage"));
|
const FormsRuntimePage = lazy(() => import("./features/forms/FormsRuntimePage"));
|
||||||
const FormInstancePage = lazy(() => import("./features/forms/FormInstancePage"));
|
const FormInstancePage = lazy(() => import("./features/forms/FormInstancePage"));
|
||||||
|
const PublicFormPage = lazy(() => import("./features/forms/PublicFormPage"));
|
||||||
const routeScopes = [
|
const routeScopes = [
|
||||||
"forms_runtime:submission:participate",
|
"forms_runtime:submission:participate",
|
||||||
"forms_runtime:workspace:read"
|
"forms_runtime:workspace:read"
|
||||||
@@ -14,9 +15,9 @@ const routeScopes = [
|
|||||||
export const formsRuntimeModule: PlatformWebModule = {
|
export const formsRuntimeModule: PlatformWebModule = {
|
||||||
id: "forms_runtime",
|
id: "forms_runtime",
|
||||||
label: "i18n:govoplan-forms-runtime.forms",
|
label: "i18n:govoplan-forms-runtime.forms",
|
||||||
version: "0.1.14",
|
version: "0.1.20",
|
||||||
dependencies: ["access", "forms"],
|
dependencies: ["access", "forms"],
|
||||||
optionalDependencies: ["files", "approvals", "workflow_engine", "portal", "cases", "policy", "audit"],
|
optionalDependencies: ["files", "approvals", "workflow_engine", "portal", "cases", "records", "policy", "audit"],
|
||||||
translations: generatedTranslations,
|
translations: generatedTranslations,
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
@@ -34,6 +35,18 @@ export const formsRuntimeModule: PlatformWebModule = {
|
|||||||
render: (context) => createElement(FormInstancePage, context)
|
render: (context) => createElement(FormInstancePage, context)
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
publicRoutes: [
|
||||||
|
{
|
||||||
|
path: "/forms/public/:publicId",
|
||||||
|
order: 10,
|
||||||
|
render: (context) => createElement(PublicFormPage, context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/forms/intake/:token",
|
||||||
|
order: 11,
|
||||||
|
render: (context) => createElement(PublicFormPage, context)
|
||||||
|
}
|
||||||
|
],
|
||||||
navItems: [
|
navItems: [
|
||||||
{
|
{
|
||||||
to: "/forms-runtime",
|
to: "/forms-runtime",
|
||||||
|
|||||||
@@ -1,29 +1,9 @@
|
|||||||
.forms-runtime-page,
|
.forms-runtime-page {
|
||||||
.forms-runtime-shell,
|
|
||||||
.form-instance-shell {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forms-runtime-shell,
|
|
||||||
.form-instance-shell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.forms-runtime-toolbar,
|
|
||||||
.form-instance-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
min-height: 58px;
|
|
||||||
padding: 10px 18px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--surface-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.forms-runtime-toolbar label {
|
.forms-runtime-toolbar label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -41,6 +21,21 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-instance-toolbar-context {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-toolbar-context > strong {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 180px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.forms-runtime-list-viewport,
|
.forms-runtime-list-viewport,
|
||||||
.form-instance-viewport {
|
.form-instance-viewport {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -53,64 +48,6 @@
|
|||||||
margin: 0 auto 16px;
|
margin: 0 auto 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forms-runtime-list {
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--surface-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.forms-runtime-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(260px, 1fr) minmax(170px, auto) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 64px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border: 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forms-runtime-row:last-child {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forms-runtime-row:hover,
|
|
||||||
.forms-runtime-row:focus-visible {
|
|
||||||
background: var(--hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.forms-runtime-row-main {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forms-runtime-row-main strong,
|
|
||||||
.forms-runtime-row-main span {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forms-runtime-row-main span,
|
|
||||||
.forms-runtime-row > span:not(.status-badge) {
|
|
||||||
color: var(--text-soft);
|
|
||||||
font-size: 0.84rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forms-runtime-empty {
|
|
||||||
padding: 36px 0;
|
|
||||||
color: var(--text-soft);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-instance-content {
|
.form-instance-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||||
@@ -124,6 +61,66 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-public-content {
|
||||||
|
width: min(100%, 900px);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-public-content > header {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-public-content > header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-public-content > header p {
|
||||||
|
max-width: 70ch;
|
||||||
|
margin: 7px 0 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-public-attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-public-attachments h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-public-attachments .file-drop-zone {
|
||||||
|
min-height: 118px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-public-attachment-list {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-public-attachment-list > div {
|
||||||
|
display: flex;
|
||||||
|
min-height: 42px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-public-attachment-list > div > span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.form-instance-main > header {
|
.form-instance-main > header {
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
@@ -230,6 +227,262 @@
|
|||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-instance-evidence {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-evidence-heading,
|
||||||
|
.form-acknowledgement-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-evidence-heading h2,
|
||||||
|
.form-intake-dialog h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-evidence-heading > span,
|
||||||
|
.form-acknowledgement-row small {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-evidence .file-drop-zone {
|
||||||
|
min-height: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-acknowledgement-row {
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-acknowledgement-row > span {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-dialog {
|
||||||
|
width: min(980px, calc(100vw - 40px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-assisted-context {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 16px 0 4px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-assisted-context-note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-assisted-field-sources {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-assisted-field-sources > header h3,
|
||||||
|
.form-assisted-field-sources > header p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-assisted-field-sources > header p {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-assisted-field-source-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-assisted-field-source {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-assisted-field-source legend {
|
||||||
|
padding-inline: 5px;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status-policy-save {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status-access-receipt {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(180px, auto) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status-access-receipt > div {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status-access-receipt span {
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status-access-receipt code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status-policy-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status-policy-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto minmax(150px, auto);
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status-policy-row > span:first-child {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status-policy-row small {
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.form-status-access-receipt {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status-policy-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status-policy-row .toggle-switch {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-create,
|
||||||
|
.form-intake-profiles {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-profiles {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-create-grid {
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-create-grid label {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-create-grid label > span {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-create-button {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-profile-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1fr) auto auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px 14px;
|
||||||
|
min-height: 62px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-profile-main {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-profile-main strong,
|
||||||
|
.form-intake-profile-main small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-profile-main small,
|
||||||
|
.form-intake-empty {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-profile-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-link {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.form-receipt {
|
.form-receipt {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -358,13 +611,18 @@
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 900px) {
|
||||||
.forms-runtime-row {
|
.form-instance-toolbar .page-action-slot-context {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
max-width: 100%;
|
||||||
|
flex: 1 1 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forms-runtime-row > span:not(.forms-runtime-row-main, .status-badge) {
|
.form-instance-toolbar-context {
|
||||||
display: none;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-instance-toolbar-context .status-badge {
|
||||||
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-instance-content {
|
.form-instance-content {
|
||||||
@@ -385,6 +643,24 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-evidence-heading,
|
||||||
|
.form-acknowledgement-row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-profile-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-profile-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-link {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.form-handoff-create,
|
.form-handoff-create,
|
||||||
.form-handoff-row {
|
.form-handoff-row {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
Reference in New Issue
Block a user