Add governed public form intake
This commit is contained in:
@@ -51,6 +51,23 @@ 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
|
||||||
|
- 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
|
||||||
|
|
||||||
@@ -67,10 +84,18 @@ Policy-referenced definitions fail closed when no compatible
|
|||||||
`forms_runtime.policy_evaluator` is active.
|
`forms_runtime.policy_evaluator` is active.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
## 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 +125,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 +148,11 @@ 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
|
Public intake, Files-backed attachment evidence, and authenticated
|
||||||
implementation depth. Conditional multi-page definitions are resolved from Forms, and
|
acknowledgements implement the approved first profiles. Conditional multi-page
|
||||||
native Case/Workflow handoffs execute automatically when the exact owner
|
definitions are resolved from Forms, and native Case/Workflow handoffs execute
|
||||||
capability is installed. Additional target kinds remain adapter depth; the
|
automatically when the exact owner capability is installed. CAPTCHA,
|
||||||
owner and security boundaries no longer depend on those additions.
|
pseudonymous intake, advanced or qualified electronic signatures, richer
|
||||||
|
attachment classification/retention controls, and additional target kinds
|
||||||
|
remain provider or product depth; none may weaken the implemented owner and
|
||||||
|
security boundaries.
|
||||||
|
|||||||
@@ -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,155 @@ 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 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",
|
||||||
"FormInstanceEvent",
|
"FormInstanceEvent",
|
||||||
"FormHandoffEffect",
|
"FormHandoffEffect",
|
||||||
|
"FormIntakeProfile",
|
||||||
|
"FormIntakeSession",
|
||||||
"FormInstanceIdentity",
|
"FormInstanceIdentity",
|
||||||
"FormInstanceRevision",
|
"FormInstanceRevision",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,808 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from typing import Literal
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_FORM_DEFINITIONS,
|
||||||
|
EvidenceReference,
|
||||||
|
FormDefinition,
|
||||||
|
FormDefinitionProvider,
|
||||||
|
InstitutionalReference,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.db.models import (
|
||||||
|
FormIntakeProfile,
|
||||||
|
FormIntakeSession,
|
||||||
|
)
|
||||||
|
from govoplan_forms_runtime.backend.domain import FormInstance
|
||||||
|
from govoplan_forms_runtime.backend.service import FormRuntimeError, FormRuntimeService
|
||||||
|
|
||||||
|
|
||||||
|
IntakeMode = Literal["anonymous", "invitation"]
|
||||||
|
INTAKE_MODES = frozenset({"anonymous", "invitation"})
|
||||||
|
DEFAULT_DRAFT_TTL_SECONDS = 30 * 24 * 60 * 60
|
||||||
|
DEFAULT_INVITATION_TTL_SECONDS = 14 * 24 * 60 * 60
|
||||||
|
DEFAULT_RATE_LIMIT_PER_MINUTE = 60
|
||||||
|
|
||||||
|
|
||||||
|
class FormIntakeError(FormRuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PublicIntakePrincipal:
|
||||||
|
tenant_id: str
|
||||||
|
account_id: str
|
||||||
|
auth_method: str
|
||||||
|
profile_id: str
|
||||||
|
identity_id: str | None = None
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
del scope
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IntakeSessionResult:
|
||||||
|
session_id: str
|
||||||
|
mode: str
|
||||||
|
status: str
|
||||||
|
expires_at: datetime
|
||||||
|
instance: FormInstance | None
|
||||||
|
token: str | None = None
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"mode": self.mode,
|
||||||
|
"status": self.status,
|
||||||
|
"expires_at": self.expires_at.isoformat(),
|
||||||
|
"instance": self.instance.to_dict() if self.instance else None,
|
||||||
|
"token": self.token,
|
||||||
|
"replayed": self.replayed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FormIntakeService:
|
||||||
|
def __init__(self, registry: object | None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
self._runtime = FormRuntimeService(registry)
|
||||||
|
|
||||||
|
def create_profile(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
definition_ref: InstitutionalReference,
|
||||||
|
mode: IntakeMode,
|
||||||
|
custodian_ref: str,
|
||||||
|
draft_ttl_seconds: int = DEFAULT_DRAFT_TTL_SECONDS,
|
||||||
|
invitation_ttl_seconds: int = DEFAULT_INVITATION_TTL_SECONDS,
|
||||||
|
rate_limit_per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE,
|
||||||
|
metadata: Mapping[str, object] | None = None,
|
||||||
|
recorded_at: datetime,
|
||||||
|
) -> FormIntakeProfile:
|
||||||
|
tenant_id = _principal_tenant(principal)
|
||||||
|
actor_id = _principal_actor(principal)
|
||||||
|
_require_aware(recorded_at, "Intake profile recorded_at")
|
||||||
|
if mode not in INTAKE_MODES:
|
||||||
|
raise FormIntakeError(f"Unsupported public intake mode: {mode!r}.")
|
||||||
|
if not custodian_ref.strip():
|
||||||
|
raise FormIntakeError("A public intake profile requires a custodian.")
|
||||||
|
if not 60 <= draft_ttl_seconds <= 365 * 24 * 60 * 60:
|
||||||
|
raise FormIntakeError(
|
||||||
|
"Draft expiry must be between one minute and one year."
|
||||||
|
)
|
||||||
|
if not 60 <= invitation_ttl_seconds <= 90 * 24 * 60 * 60:
|
||||||
|
raise FormIntakeError(
|
||||||
|
"Invitation expiry must be between one minute and ninety days."
|
||||||
|
)
|
||||||
|
if not 1 <= rate_limit_per_minute <= 10_000:
|
||||||
|
raise FormIntakeError(
|
||||||
|
"Public intake rate limit must be between 1 and 10000."
|
||||||
|
)
|
||||||
|
definition = self._definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
reference=definition_ref,
|
||||||
|
effective_at=recorded_at,
|
||||||
|
)
|
||||||
|
if definition.publication_state != "published":
|
||||||
|
raise FormIntakeError("Only a published Form can receive public intake.")
|
||||||
|
profile = FormIntakeProfile(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
profile_id=str(uuid.uuid4()),
|
||||||
|
public_id=secrets.token_urlsafe(24),
|
||||||
|
definition_id=definition.reference.object_id,
|
||||||
|
definition_revision=str(definition.reference.version),
|
||||||
|
mode=mode,
|
||||||
|
enabled=True,
|
||||||
|
revision=1,
|
||||||
|
custodian_ref=custodian_ref.strip(),
|
||||||
|
draft_ttl_seconds=draft_ttl_seconds,
|
||||||
|
invitation_ttl_seconds=invitation_ttl_seconds,
|
||||||
|
rate_limit_per_minute=rate_limit_per_minute,
|
||||||
|
rate_window_started_at=None,
|
||||||
|
rate_window_count=0,
|
||||||
|
created_by=actor_id,
|
||||||
|
updated_by=actor_id,
|
||||||
|
details={
|
||||||
|
"identity_claim_allowed": False,
|
||||||
|
"pseudonymous": False,
|
||||||
|
**dict(metadata or {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.add(profile)
|
||||||
|
session.flush()
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def list_profiles(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
) -> tuple[FormIntakeProfile, ...]:
|
||||||
|
return tuple(
|
||||||
|
session.query(FormIntakeProfile)
|
||||||
|
.filter(FormIntakeProfile.tenant_id == _principal_tenant(principal))
|
||||||
|
.order_by(
|
||||||
|
FormIntakeProfile.definition_id.asc(),
|
||||||
|
FormIntakeProfile.mode.asc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_available_definitions(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 200,
|
||||||
|
) -> tuple[FormDefinition, ...]:
|
||||||
|
provider = _capability(self._registry, CAPABILITY_FORM_DEFINITIONS)
|
||||||
|
if not isinstance(provider, FormDefinitionProvider):
|
||||||
|
raise FormIntakeError("The Forms definition provider is unavailable.")
|
||||||
|
definitions = provider.list_form_definitions(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=_principal_tenant(principal),
|
||||||
|
query=query.strip(),
|
||||||
|
limit=min(max(limit, 1), 200),
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
item
|
||||||
|
for item in definitions
|
||||||
|
if item.publication_state == "published" and item.reference.version
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_profile_enabled(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
profile_id: str,
|
||||||
|
expected_revision: int,
|
||||||
|
enabled: bool,
|
||||||
|
) -> FormIntakeProfile:
|
||||||
|
profile = self._profile_for_admin(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
profile_id=profile_id,
|
||||||
|
lock=True,
|
||||||
|
)
|
||||||
|
if profile.revision != expected_revision:
|
||||||
|
raise FormIntakeError(
|
||||||
|
"Intake profile revision conflict: the expected revision is stale."
|
||||||
|
)
|
||||||
|
profile.enabled = bool(enabled)
|
||||||
|
profile.revision += 1
|
||||||
|
profile.updated_by = _principal_actor(principal)
|
||||||
|
session.add(profile)
|
||||||
|
session.flush()
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def issue_invitation(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
profile_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
recorded_at: datetime,
|
||||||
|
expires_at: datetime | None = None,
|
||||||
|
metadata: Mapping[str, object] | None = None,
|
||||||
|
) -> IntakeSessionResult:
|
||||||
|
profile = self._profile_for_admin(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
profile_id=profile_id,
|
||||||
|
lock=True,
|
||||||
|
)
|
||||||
|
if profile.mode != "invitation" or not profile.enabled:
|
||||||
|
raise FormIntakeError("This intake profile cannot issue invitations.")
|
||||||
|
_require_aware(recorded_at, "Invitation recorded_at")
|
||||||
|
effective_expiry = expires_at or (
|
||||||
|
recorded_at + timedelta(seconds=profile.invitation_ttl_seconds)
|
||||||
|
)
|
||||||
|
_require_aware(effective_expiry, "Invitation expires_at")
|
||||||
|
if effective_expiry <= recorded_at:
|
||||||
|
raise FormIntakeError("Invitation expiry must be in the future.")
|
||||||
|
request = {
|
||||||
|
"operation": "issue_invitation",
|
||||||
|
"profile_id": profile.profile_id,
|
||||||
|
"expires_at": effective_expiry.isoformat(),
|
||||||
|
"metadata": dict(metadata or {}),
|
||||||
|
}
|
||||||
|
replay = self._session_replay(
|
||||||
|
session,
|
||||||
|
tenant_id=profile.tenant_id,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
request_sha256=_hash(request),
|
||||||
|
)
|
||||||
|
if replay is not None:
|
||||||
|
return self._result(session, replay, token=None, replayed=True)
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
intake_session = self._new_session(
|
||||||
|
profile,
|
||||||
|
token=token,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
request_sha256=_hash(request),
|
||||||
|
expires_at=effective_expiry,
|
||||||
|
created_by=_principal_actor(principal),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
session.add(intake_session)
|
||||||
|
session.flush()
|
||||||
|
return self._result(session, intake_session, token=token)
|
||||||
|
|
||||||
|
def start_anonymous(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
public_id: str,
|
||||||
|
values: Mapping[str, object],
|
||||||
|
idempotency_key: str,
|
||||||
|
recorded_at: datetime,
|
||||||
|
) -> IntakeSessionResult:
|
||||||
|
profile = self._public_profile(
|
||||||
|
session,
|
||||||
|
public_id=public_id,
|
||||||
|
expected_mode="anonymous",
|
||||||
|
now=recorded_at,
|
||||||
|
)
|
||||||
|
request = {
|
||||||
|
"operation": "anonymous_start",
|
||||||
|
"profile_id": profile.profile_id,
|
||||||
|
"values": dict(values),
|
||||||
|
"recorded_at": recorded_at.isoformat(),
|
||||||
|
}
|
||||||
|
request_sha256 = _hash(request)
|
||||||
|
replay = self._session_replay(
|
||||||
|
session,
|
||||||
|
tenant_id=profile.tenant_id,
|
||||||
|
idempotency_key=f"anonymous:{profile.profile_id}:{idempotency_key}",
|
||||||
|
request_sha256=request_sha256,
|
||||||
|
)
|
||||||
|
if replay is not None:
|
||||||
|
return self._result(session, replay, token=None, replayed=True)
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
intake_session = self._new_session(
|
||||||
|
profile,
|
||||||
|
token=token,
|
||||||
|
idempotency_key=f"anonymous:{profile.profile_id}:{idempotency_key}",
|
||||||
|
request_sha256=request_sha256,
|
||||||
|
expires_at=recorded_at + timedelta(seconds=profile.draft_ttl_seconds),
|
||||||
|
created_by="public",
|
||||||
|
)
|
||||||
|
session.add(intake_session)
|
||||||
|
session.flush()
|
||||||
|
instance = self._start_instance(
|
||||||
|
session,
|
||||||
|
profile,
|
||||||
|
intake_session,
|
||||||
|
values=values,
|
||||||
|
recorded_at=recorded_at,
|
||||||
|
)
|
||||||
|
intake_session.instance_id = instance.instance_id
|
||||||
|
intake_session.status = "active"
|
||||||
|
intake_session.started_at = recorded_at
|
||||||
|
session.add(intake_session)
|
||||||
|
session.flush()
|
||||||
|
return self._result(session, intake_session, token=token, instance=instance)
|
||||||
|
|
||||||
|
def start_invitation(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
values: Mapping[str, object],
|
||||||
|
recorded_at: datetime,
|
||||||
|
) -> IntakeSessionResult:
|
||||||
|
intake_session, profile = self._public_session(
|
||||||
|
session,
|
||||||
|
token=token,
|
||||||
|
now=recorded_at,
|
||||||
|
lock=True,
|
||||||
|
)
|
||||||
|
if intake_session.mode != "invitation":
|
||||||
|
raise _public_unavailable()
|
||||||
|
if intake_session.instance_id:
|
||||||
|
return self._result(session, intake_session, replayed=True)
|
||||||
|
instance = self._start_instance(
|
||||||
|
session,
|
||||||
|
profile,
|
||||||
|
intake_session,
|
||||||
|
values=values,
|
||||||
|
recorded_at=recorded_at,
|
||||||
|
)
|
||||||
|
intake_session.instance_id = instance.instance_id
|
||||||
|
intake_session.status = "active"
|
||||||
|
intake_session.started_at = recorded_at
|
||||||
|
session.add(intake_session)
|
||||||
|
session.flush()
|
||||||
|
return self._result(session, intake_session, instance=instance)
|
||||||
|
|
||||||
|
def get_public_instance(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
now: datetime,
|
||||||
|
) -> tuple[FormInstance, FormDefinition]:
|
||||||
|
intake_session, profile = self._public_session(
|
||||||
|
session,
|
||||||
|
token=token,
|
||||||
|
now=now,
|
||||||
|
lock=True,
|
||||||
|
)
|
||||||
|
instance = self._instance(session, intake_session)
|
||||||
|
definition = self._definition(
|
||||||
|
session,
|
||||||
|
_public_principal(intake_session, profile),
|
||||||
|
reference=_profile_definition_ref(profile),
|
||||||
|
effective_at=instance.recorded_at,
|
||||||
|
)
|
||||||
|
return instance, definition
|
||||||
|
|
||||||
|
def update_public_draft(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
expected_revision: int,
|
||||||
|
values: Mapping[str, object],
|
||||||
|
attachment_refs: Sequence[EvidenceReference],
|
||||||
|
signature_refs: Sequence[EvidenceReference],
|
||||||
|
idempotency_key: str,
|
||||||
|
recorded_at: datetime,
|
||||||
|
change_reason: str,
|
||||||
|
) -> FormInstance:
|
||||||
|
intake_session, profile = self._public_session(
|
||||||
|
session,
|
||||||
|
token=token,
|
||||||
|
now=recorded_at,
|
||||||
|
lock=True,
|
||||||
|
)
|
||||||
|
principal = _public_principal(intake_session, profile)
|
||||||
|
return self._runtime.update_draft(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=_session_instance_id(intake_session),
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
values=values,
|
||||||
|
attachment_refs=attachment_refs,
|
||||||
|
signature_refs=signature_refs,
|
||||||
|
idempotency_key=f"public:{intake_session.session_id}:{idempotency_key}",
|
||||||
|
recorded_at=recorded_at,
|
||||||
|
change_reason=change_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
def submit_public(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
expected_revision: int,
|
||||||
|
values: Mapping[str, object],
|
||||||
|
attachment_refs: Sequence[EvidenceReference],
|
||||||
|
signature_refs: Sequence[EvidenceReference],
|
||||||
|
idempotency_key: str,
|
||||||
|
recorded_at: datetime,
|
||||||
|
) -> FormInstance:
|
||||||
|
intake_session, profile = self._public_session(
|
||||||
|
session,
|
||||||
|
token=token,
|
||||||
|
now=recorded_at,
|
||||||
|
lock=True,
|
||||||
|
allow_submitted=True,
|
||||||
|
)
|
||||||
|
principal = _public_principal(intake_session, profile)
|
||||||
|
instance = self._runtime.submit_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
instance_id=_session_instance_id(intake_session),
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
values=values,
|
||||||
|
attachment_refs=attachment_refs,
|
||||||
|
signature_refs=signature_refs,
|
||||||
|
idempotency_key=f"public:{intake_session.session_id}:{idempotency_key}",
|
||||||
|
recorded_at=recorded_at,
|
||||||
|
)
|
||||||
|
intake_session.status = "submitted"
|
||||||
|
intake_session.submitted_at = recorded_at
|
||||||
|
session.add(intake_session)
|
||||||
|
session.flush()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def session_context(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
now: datetime,
|
||||||
|
) -> tuple[FormIntakeSession, FormIntakeProfile, PublicIntakePrincipal]:
|
||||||
|
intake_session, profile = self._public_session(
|
||||||
|
session,
|
||||||
|
token=token,
|
||||||
|
now=now,
|
||||||
|
lock=True,
|
||||||
|
)
|
||||||
|
return intake_session, profile, _public_principal(intake_session, profile)
|
||||||
|
|
||||||
|
def _start_instance(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
profile: FormIntakeProfile,
|
||||||
|
intake_session: FormIntakeSession,
|
||||||
|
*,
|
||||||
|
values: Mapping[str, object],
|
||||||
|
recorded_at: datetime,
|
||||||
|
) -> FormInstance:
|
||||||
|
principal = _public_principal(intake_session, profile)
|
||||||
|
return self._runtime.create_instance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition_ref=_profile_definition_ref(profile),
|
||||||
|
values=values,
|
||||||
|
idempotency_key=f"public-start:{intake_session.session_id}",
|
||||||
|
recorded_at=recorded_at,
|
||||||
|
metadata={
|
||||||
|
"intake": {
|
||||||
|
"profile_id": profile.profile_id,
|
||||||
|
"mode": profile.mode,
|
||||||
|
"channel": "public_web",
|
||||||
|
"authenticated_identity_claimed": False,
|
||||||
|
"authority_basis": "invitation"
|
||||||
|
if profile.mode == "invitation"
|
||||||
|
else "self_submission",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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 FormIntakeError("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 FormIntakeError("The exact Form definition is unavailable.")
|
||||||
|
return definition
|
||||||
|
|
||||||
|
def _profile_for_admin(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
profile_id: str,
|
||||||
|
lock: bool,
|
||||||
|
) -> FormIntakeProfile:
|
||||||
|
query = session.query(FormIntakeProfile).filter(
|
||||||
|
FormIntakeProfile.tenant_id == _principal_tenant(principal),
|
||||||
|
FormIntakeProfile.profile_id == profile_id,
|
||||||
|
)
|
||||||
|
if lock:
|
||||||
|
query = query.with_for_update()
|
||||||
|
profile = query.one_or_none()
|
||||||
|
if profile is None:
|
||||||
|
raise LookupError("Form intake profile not found.")
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def _public_profile(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
public_id: str,
|
||||||
|
expected_mode: str,
|
||||||
|
now: datetime,
|
||||||
|
) -> FormIntakeProfile:
|
||||||
|
_require_aware(now, "Public intake time")
|
||||||
|
profile = (
|
||||||
|
session.query(FormIntakeProfile)
|
||||||
|
.filter(FormIntakeProfile.public_id == public_id)
|
||||||
|
.with_for_update()
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if profile is None or not profile.enabled or profile.mode != expected_mode:
|
||||||
|
raise _public_unavailable()
|
||||||
|
_consume_rate_limit(profile, now=now)
|
||||||
|
session.add(profile)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def _public_session(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
now: datetime,
|
||||||
|
lock: bool,
|
||||||
|
allow_submitted: bool = False,
|
||||||
|
) -> tuple[FormIntakeSession, FormIntakeProfile]:
|
||||||
|
_require_aware(now, "Public intake time")
|
||||||
|
query = session.query(FormIntakeSession).filter(
|
||||||
|
FormIntakeSession.token_sha256 == _token_hash(token)
|
||||||
|
)
|
||||||
|
if lock:
|
||||||
|
query = query.with_for_update()
|
||||||
|
intake_session = query.one_or_none()
|
||||||
|
if intake_session is None:
|
||||||
|
raise _public_unavailable()
|
||||||
|
profile_query = session.query(FormIntakeProfile).filter(
|
||||||
|
FormIntakeProfile.id == intake_session.profile_id
|
||||||
|
)
|
||||||
|
if lock:
|
||||||
|
profile_query = profile_query.with_for_update()
|
||||||
|
profile = profile_query.one_or_none()
|
||||||
|
allowed_states = {"issued", "active"}
|
||||||
|
if allow_submitted:
|
||||||
|
allowed_states.add("submitted")
|
||||||
|
if (
|
||||||
|
profile is None
|
||||||
|
or not profile.enabled
|
||||||
|
or intake_session.status not in allowed_states
|
||||||
|
or intake_session.revoked_at is not None
|
||||||
|
or _aware(intake_session.expires_at) <= now
|
||||||
|
):
|
||||||
|
raise _public_unavailable()
|
||||||
|
_consume_rate_limit(profile, now=now)
|
||||||
|
session.add(profile)
|
||||||
|
return intake_session, profile
|
||||||
|
|
||||||
|
def _session_replay(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
request_sha256: str,
|
||||||
|
) -> FormIntakeSession | None:
|
||||||
|
existing = (
|
||||||
|
session.query(FormIntakeSession)
|
||||||
|
.filter(
|
||||||
|
FormIntakeSession.tenant_id == tenant_id,
|
||||||
|
FormIntakeSession.idempotency_key == idempotency_key,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing is not None and existing.request_sha256 != request_sha256:
|
||||||
|
raise FormIntakeError(
|
||||||
|
"Public intake idempotency conflict: this key was used for another request."
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
def _new_session(
|
||||||
|
self,
|
||||||
|
profile: FormIntakeProfile,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
request_sha256: str,
|
||||||
|
expires_at: datetime,
|
||||||
|
created_by: str,
|
||||||
|
metadata: Mapping[str, object] | None = None,
|
||||||
|
) -> FormIntakeSession:
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
return FormIntakeSession(
|
||||||
|
tenant_id=profile.tenant_id,
|
||||||
|
session_id=session_id,
|
||||||
|
profile_id=profile.id,
|
||||||
|
token_sha256=_token_hash(token),
|
||||||
|
mode=profile.mode,
|
||||||
|
status="issued",
|
||||||
|
instance_id=None,
|
||||||
|
actor_id=f"form-public:{session_id}",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
request_sha256=request_sha256,
|
||||||
|
expires_at=expires_at,
|
||||||
|
created_by=created_by,
|
||||||
|
details=dict(metadata or {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _instance(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
intake_session: FormIntakeSession,
|
||||||
|
) -> FormInstance:
|
||||||
|
profile = session.get(FormIntakeProfile, intake_session.profile_id)
|
||||||
|
if profile is None:
|
||||||
|
raise _public_unavailable()
|
||||||
|
instance = self._runtime.get_instance(
|
||||||
|
session,
|
||||||
|
_public_principal(intake_session, profile),
|
||||||
|
instance_id=_session_instance_id(intake_session),
|
||||||
|
)
|
||||||
|
if instance is None:
|
||||||
|
raise _public_unavailable()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def _result(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
intake_session: FormIntakeSession,
|
||||||
|
*,
|
||||||
|
token: str | None = None,
|
||||||
|
instance: FormInstance | None = None,
|
||||||
|
replayed: bool = False,
|
||||||
|
) -> IntakeSessionResult:
|
||||||
|
if instance is None and intake_session.instance_id:
|
||||||
|
instance = self._instance(session, intake_session)
|
||||||
|
return IntakeSessionResult(
|
||||||
|
session_id=intake_session.session_id,
|
||||||
|
mode=intake_session.mode,
|
||||||
|
status=intake_session.status,
|
||||||
|
expires_at=_aware(intake_session.expires_at),
|
||||||
|
instance=instance,
|
||||||
|
token=token,
|
||||||
|
replayed=replayed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_payload(profile: FormIntakeProfile) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"profile_id": profile.profile_id,
|
||||||
|
"public_id": profile.public_id,
|
||||||
|
"definition_ref": {
|
||||||
|
"kind": "form",
|
||||||
|
"owner_module": "forms",
|
||||||
|
"object_id": profile.definition_id,
|
||||||
|
"tenant_id": profile.tenant_id,
|
||||||
|
"version": profile.definition_revision,
|
||||||
|
},
|
||||||
|
"mode": profile.mode,
|
||||||
|
"enabled": profile.enabled,
|
||||||
|
"revision": profile.revision,
|
||||||
|
"draft_ttl_seconds": profile.draft_ttl_seconds,
|
||||||
|
"invitation_ttl_seconds": profile.invitation_ttl_seconds,
|
||||||
|
"rate_limit_per_minute": profile.rate_limit_per_minute,
|
||||||
|
"metadata": dict(profile.details),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_rate_limit(profile: FormIntakeProfile, *, now: datetime) -> None:
|
||||||
|
window = (
|
||||||
|
_aware(profile.rate_window_started_at)
|
||||||
|
if profile.rate_window_started_at is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if window is None or now - window >= timedelta(minutes=1):
|
||||||
|
profile.rate_window_started_at = now
|
||||||
|
profile.rate_window_count = 1
|
||||||
|
return
|
||||||
|
if profile.rate_window_count >= profile.rate_limit_per_minute:
|
||||||
|
raise FormIntakeError("Public Form intake is temporarily rate limited.")
|
||||||
|
profile.rate_window_count += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _public_principal(
|
||||||
|
intake_session: FormIntakeSession,
|
||||||
|
profile: FormIntakeProfile,
|
||||||
|
) -> PublicIntakePrincipal:
|
||||||
|
return PublicIntakePrincipal(
|
||||||
|
tenant_id=intake_session.tenant_id,
|
||||||
|
account_id=intake_session.actor_id,
|
||||||
|
auth_method=f"form_public_{intake_session.mode}",
|
||||||
|
profile_id=profile.profile_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_definition_ref(profile: FormIntakeProfile) -> InstitutionalReference:
|
||||||
|
return InstitutionalReference(
|
||||||
|
kind="form",
|
||||||
|
owner_module="forms",
|
||||||
|
object_id=profile.definition_id,
|
||||||
|
tenant_id=profile.tenant_id,
|
||||||
|
version=profile.definition_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_instance_id(intake_session: FormIntakeSession) -> str:
|
||||||
|
if not intake_session.instance_id:
|
||||||
|
raise _public_unavailable()
|
||||||
|
return intake_session.instance_id
|
||||||
|
|
||||||
|
|
||||||
|
def _public_unavailable() -> FormIntakeError:
|
||||||
|
return FormIntakeError("Public Form intake is unavailable.")
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_tenant(principal: object) -> str:
|
||||||
|
value = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||||
|
if not value:
|
||||||
|
raise FormIntakeError("Forms Runtime requires a tenant-bound principal.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
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 FormIntakeError("Forms Runtime requires an acting identity.")
|
||||||
|
|
||||||
|
|
||||||
|
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 _token_hash(token: str) -> str:
|
||||||
|
clean = str(token or "").strip()
|
||||||
|
if len(clean) < 32:
|
||||||
|
raise _public_unavailable()
|
||||||
|
return hashlib.sha256(clean.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(value: Mapping[str, object]) -> str:
|
||||||
|
return hashlib.sha256(
|
||||||
|
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_aware(value: datetime, label: str) -> None:
|
||||||
|
if value.tzinfo is None or value.utcoffset() is None:
|
||||||
|
raise FormIntakeError(f"{label} must include a timezone.")
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime) -> datetime:
|
||||||
|
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_DRAFT_TTL_SECONDS",
|
||||||
|
"DEFAULT_INVITATION_TTL_SECONDS",
|
||||||
|
"DEFAULT_RATE_LIMIT_PER_MINUTE",
|
||||||
|
"FormIntakeError",
|
||||||
|
"FormIntakeService",
|
||||||
|
"IntakeSessionResult",
|
||||||
|
"PublicIntakePrincipal",
|
||||||
|
"profile_payload",
|
||||||
|
]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
@@ -27,12 +28,21 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
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.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,
|
||||||
@@ -57,6 +67,7 @@ OPTIONAL_DEPENDENCIES = (
|
|||||||
"cases",
|
"cases",
|
||||||
"policy",
|
"policy",
|
||||||
"audit",
|
"audit",
|
||||||
|
"records",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -93,7 +104,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.",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,6 +137,38 @@ def _router(context: ModuleContext):
|
|||||||
return create_router(context.registry)
|
return create_router(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/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)
|
||||||
|
|
||||||
@@ -146,6 +189,17 @@ 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)
|
||||||
|
.count(),
|
||||||
|
"public_intake_sessions": session.query(runtime_models.FormIntakeSession)
|
||||||
|
.filter(runtime_models.FormIntakeSession.tenant_id == tenant_id)
|
||||||
|
.count(),
|
||||||
|
"authenticated_acknowledgements": session.query(
|
||||||
|
runtime_models.FormAcknowledgement
|
||||||
|
)
|
||||||
|
.filter(runtime_models.FormAcknowledgement.tenant_id == tenant_id)
|
||||||
|
.count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -169,6 +223,7 @@ manifest = ModuleManifest(
|
|||||||
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 +250,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",
|
||||||
@@ -231,6 +298,15 @@ 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.authenticated_acknowledgement",
|
||||||
|
version="1.0.0",
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_RECORD_SOURCE_FORMS_RUNTIME,
|
||||||
|
version="1.0.0",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -260,6 +336,7 @@ manifest = ModuleManifest(
|
|||||||
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_documentation={
|
capability_documentation={
|
||||||
CAPABILITY_FORMS_RUNTIME_REGISTRY: CapabilityDocumentation(
|
CAPABILITY_FORMS_RUNTIME_REGISTRY: CapabilityDocumentation(
|
||||||
@@ -272,6 +349,11 @@ 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",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
@@ -280,6 +362,9 @@ 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.FormIntakeSession,
|
||||||
|
runtime_models.FormIntakeProfile,
|
||||||
runtime_models.FormHandoffEffect,
|
runtime_models.FormHandoffEffect,
|
||||||
runtime_models.FormInstanceEvent,
|
runtime_models.FormInstanceEvent,
|
||||||
runtime_models.FormInstanceRevision,
|
runtime_models.FormInstanceRevision,
|
||||||
@@ -290,6 +375,9 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
uninstall_guard_providers=(
|
uninstall_guard_providers=(
|
||||||
persistent_table_uninstall_guard(
|
persistent_table_uninstall_guard(
|
||||||
|
runtime_models.FormAcknowledgement,
|
||||||
|
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,6 +385,12 @@ 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(
|
DocumentationTopic(
|
||||||
@@ -306,6 +400,7 @@ manifest = ModuleManifest(
|
|||||||
body=(
|
body=(
|
||||||
"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. 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"),
|
||||||
@@ -323,6 +418,8 @@ manifest = ModuleManifest(
|
|||||||
"forms_runtime.navigation",
|
"forms_runtime.navigation",
|
||||||
"forms_runtime.workspace",
|
"forms_runtime.workspace",
|
||||||
"forms_runtime.instance",
|
"forms_runtime.instance",
|
||||||
|
"forms_runtime.public-intake",
|
||||||
|
"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,6 +427,7 @@ 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.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -343,7 +441,8 @@ manifest = ModuleManifest(
|
|||||||
"Submitting validates values, attachments, signatures, and policy requirements and records an immutable receipt; it is "
|
"Submitting validates values, attachments, signatures, and policy requirements and records an immutable receipt; it is "
|
||||||
"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."
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
@@ -368,6 +467,11 @@ 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",
|
||||||
|
"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,6 +479,10 @@ 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.",
|
||||||
|
"file_submission": "Resolves the exact immutable submission revision under current access and preserves only a digest-bound reference in Records.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -386,7 +494,7 @@ 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.",
|
||||||
),
|
),
|
||||||
supported_authority_modes=("native_authoritative",),
|
supported_authority_modes=("native_authoritative",),
|
||||||
owned_concepts=(
|
owned_concepts=(
|
||||||
|
|||||||
+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")
|
||||||
+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
|
||||||
@@ -17,7 +19,9 @@ from govoplan_forms_runtime.backend.manifest import (
|
|||||||
WRITE_SCOPE,
|
WRITE_SCOPE,
|
||||||
)
|
)
|
||||||
from govoplan_forms_runtime.backend.schemas import (
|
from govoplan_forms_runtime.backend.schemas import (
|
||||||
|
FormAcknowledgementRequest,
|
||||||
FormDraftUpdateRequest,
|
FormDraftUpdateRequest,
|
||||||
|
FormEvidenceGrantCreateRequest,
|
||||||
FormHandoffRequest,
|
FormHandoffRequest,
|
||||||
FormHandoffActionRequest,
|
FormHandoffActionRequest,
|
||||||
FormHandoffCompensateRequest,
|
FormHandoffCompensateRequest,
|
||||||
@@ -25,11 +29,20 @@ from govoplan_forms_runtime.backend.schemas import (
|
|||||||
FormInstanceEventsResponse,
|
FormInstanceEventsResponse,
|
||||||
FormInstanceHistoryResponse,
|
FormInstanceHistoryResponse,
|
||||||
FormInstanceListResponse,
|
FormInstanceListResponse,
|
||||||
|
FormIntakeInvitationRequest,
|
||||||
|
FormIntakeProfileCreateRequest,
|
||||||
|
FormIntakeProfileStateRequest,
|
||||||
|
PublicFormStartRequest,
|
||||||
FormSubmitRequest,
|
FormSubmitRequest,
|
||||||
FormTransitionRequest,
|
FormTransitionRequest,
|
||||||
FormNativeHandoffRequest,
|
FormNativeHandoffRequest,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +50,232 @@ 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)
|
||||||
|
|
||||||
|
@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("/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.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.to_dict(), "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.to_dict()
|
||||||
|
|
||||||
|
@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.to_dict()
|
||||||
|
|
||||||
@router.get("/instances", response_model=FormInstanceListResponse)
|
@router.get("/instances", response_model=FormInstanceListResponse)
|
||||||
def api_list_instances(
|
def api_list_instances(
|
||||||
@@ -212,6 +451,110 @@ def create_router(registry: object | None) -> APIRouter:
|
|||||||
raise _error(exc) from exc
|
raise _error(exc) from exc
|
||||||
return item.to_dict()
|
return item.to_dict()
|
||||||
|
|
||||||
|
@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",
|
||||||
response_model=dict[str, object],
|
response_model=dict[str, object],
|
||||||
@@ -542,4 +885,46 @@ def _error(exc: Exception) -> HTTPException:
|
|||||||
return HTTPException(status_code=code, detail=message)
|
return HTTPException(status_code=code, detail=message)
|
||||||
|
|
||||||
|
|
||||||
|
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,67 @@ 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"]
|
||||||
|
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 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 +166,9 @@ class FormInstanceEventsResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"FormAcknowledgementRequest",
|
||||||
"FormDraftUpdateRequest",
|
"FormDraftUpdateRequest",
|
||||||
|
"FormEvidenceGrantCreateRequest",
|
||||||
"FormHandoffRequest",
|
"FormHandoffRequest",
|
||||||
"FormHandoffActionRequest",
|
"FormHandoffActionRequest",
|
||||||
"FormHandoffCompensateRequest",
|
"FormHandoffCompensateRequest",
|
||||||
@@ -114,6 +177,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
|
||||||
@@ -38,6 +39,7 @@ from govoplan_forms_runtime.backend.db.models import (
|
|||||||
FormInstanceRevision,
|
FormInstanceRevision,
|
||||||
)
|
)
|
||||||
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 +84,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 +203,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,
|
||||||
@@ -619,6 +643,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 +786,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,
|
||||||
@@ -716,7 +858,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 +893,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,
|
||||||
|
|||||||
@@ -26,10 +26,13 @@ from govoplan_forms.backend.service import (
|
|||||||
record_form_definition,
|
record_form_definition,
|
||||||
)
|
)
|
||||||
from govoplan_forms_runtime.backend.db.models import (
|
from govoplan_forms_runtime.backend.db.models import (
|
||||||
|
FormAcknowledgement,
|
||||||
FormHandoffEffect,
|
FormHandoffEffect,
|
||||||
FormInstanceEvent,
|
FormInstanceEvent,
|
||||||
FormInstanceIdentity,
|
FormInstanceIdentity,
|
||||||
FormInstanceRevision,
|
FormInstanceRevision,
|
||||||
|
FormIntakeProfile,
|
||||||
|
FormIntakeSession,
|
||||||
)
|
)
|
||||||
from govoplan_forms_runtime.backend.service import (
|
from govoplan_forms_runtime.backend.service import (
|
||||||
FormRuntimeError,
|
FormRuntimeError,
|
||||||
@@ -37,6 +40,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
|
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
|
||||||
@@ -113,6 +117,9 @@ class FormsRuntimeTests(unittest.TestCase):
|
|||||||
FormInstanceRevision.__table__,
|
FormInstanceRevision.__table__,
|
||||||
FormInstanceEvent.__table__,
|
FormInstanceEvent.__table__,
|
||||||
FormHandoffEffect.__table__,
|
FormHandoffEffect.__table__,
|
||||||
|
FormIntakeProfile.__table__,
|
||||||
|
FormIntakeSession.__table__,
|
||||||
|
FormAcknowledgement.__table__,
|
||||||
):
|
):
|
||||||
table.create(self.engine)
|
table.create(self.engine)
|
||||||
self.session = Session(self.engine)
|
self.session = Session(self.engine)
|
||||||
@@ -683,6 +690,165 @@ 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_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,8 +31,11 @@ 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"])
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,14 @@ 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_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",
|
"b4e6f8a0c2d3",
|
||||||
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()
|
||||||
@@ -17,6 +17,46 @@ 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";
|
||||||
|
enabled: boolean;
|
||||||
|
revision: number;
|
||||||
|
draft_ttl_seconds: number;
|
||||||
|
invitation_ttl_seconds: number;
|
||||||
|
rate_limit_per_minute: number;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicIntakeResult = {
|
||||||
|
session_id: string;
|
||||||
|
mode: "anonymous" | "invitation";
|
||||||
|
status: string;
|
||||||
|
expires_at: string;
|
||||||
|
instance?: FormInstance | null;
|
||||||
|
token?: string | null;
|
||||||
|
replayed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FormCondition =
|
export type FormCondition =
|
||||||
@@ -183,15 +223,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 +244,243 @@ 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",
|
||||||
|
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 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 +488,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,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowLeft, ExternalLink, RefreshCw, Save, Send } from "lucide-react";
|
import { Archive, ArrowLeft, BadgeCheck, 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 {
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
FileDropZone,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
hasScope,
|
hasScope,
|
||||||
i18nMessage,
|
i18nMessage,
|
||||||
useGuardedNavigate,
|
useGuardedNavigate,
|
||||||
|
usePlatformModuleInstalled,
|
||||||
usePlatformLanguage,
|
usePlatformLanguage,
|
||||||
useUnsavedDraftGuard,
|
useUnsavedDraftGuard,
|
||||||
type PlatformRouteContext
|
type PlatformRouteContext
|
||||||
@@ -26,9 +28,13 @@ import {
|
|||||||
listFormHandoffs,
|
listFormHandoffs,
|
||||||
startFormHandoff,
|
startFormHandoff,
|
||||||
actOnFormHandoff,
|
actOnFormHandoff,
|
||||||
|
acknowledgeFormInstance,
|
||||||
compensateFormHandoff,
|
compensateFormHandoff,
|
||||||
|
createFormEvidenceGrant,
|
||||||
saveFormDraft,
|
saveFormDraft,
|
||||||
submitFormInstance,
|
submitFormInstance,
|
||||||
|
uploadFormEvidence,
|
||||||
|
type EvidenceReference,
|
||||||
type FormDefinition,
|
type FormDefinition,
|
||||||
type FormFieldDefinition,
|
type FormFieldDefinition,
|
||||||
type FormInstance,
|
type FormInstance,
|
||||||
@@ -47,12 +53,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,6 +73,7 @@ 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 load = useCallback(async (signal?: AbortSignal) => {
|
const load = useCallback(async (signal?: AbortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -81,6 +92,8 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
setEvents(nextEvents.events);
|
setEvents(nextEvents.events);
|
||||||
setHandoffs(nextHandoffs.handoffs);
|
setHandoffs(nextHandoffs.handoffs);
|
||||||
setValues(nextInstance.values);
|
setValues(nextInstance.values);
|
||||||
|
setAttachmentRefs(nextInstance.attachment_refs);
|
||||||
|
setSignatureRefs(nextInstance.signature_refs);
|
||||||
setChangeReason("");
|
setChangeReason("");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -112,8 +125,12 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
const editable = Boolean(editableLifecycle && canEditPermission);
|
const editable = Boolean(editableLifecycle && canEditPermission);
|
||||||
const canSave = Boolean(instance?.status === "draft" && definition?.allow_drafts && canEditPermission);
|
const canSave = Boolean(instance?.status === "draft" && 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 +150,18 @@ 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
|
||||||
|
);
|
||||||
|
|
||||||
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 +177,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 +190,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 +199,73 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -227,6 +318,12 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
</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)} />}
|
||||||
|
{instance && recordsAvailable && !editableLifecycle &&
|
||||||
|
<Button onClick={() => navigate(recordFilingPath(instance))}>
|
||||||
|
<Archive size={16} aria-hidden="true" />
|
||||||
|
File in eAkte
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
<DocumentationHelpLink reference={FORMS_RUNTIME_DOCUMENTATION} />
|
<DocumentationHelpLink reference={FORMS_RUNTIME_DOCUMENTATION} />
|
||||||
</div>
|
</div>
|
||||||
<PageScrollViewport className="form-instance-viewport">
|
<PageScrollViewport className="form-instance-viewport">
|
||||||
@@ -277,22 +374,61 @@ 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>
|
||||||
</div>
|
<span>{attachmentRefs.length} attachments · {signatureRefs.length} acknowledgements</span>
|
||||||
|
</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">
|
||||||
@@ -316,7 +452,7 @@ 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 variant="primary" onClick={() => setConfirmingSubmit(true)} disabled={saving || signatureBlocked} disabledReason={saving ? FORMS_RUNTIME_I18N.saving : signatureBlocked ? "Record the required acknowledgement before submitting." : undefined}>
|
||||||
<Send size={16} aria-hidden="true" />
|
<Send size={16} aria-hidden="true" />
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -392,6 +528,18 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
}
|
}
|
||||||
</PageScrollViewport>
|
</PageScrollViewport>
|
||||||
</div>
|
</div>
|
||||||
|
<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 +578,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormField({
|
export function FormField({
|
||||||
field,
|
field,
|
||||||
value,
|
value,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -580,7 +728,7 @@ 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>) {
|
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) {
|
||||||
@@ -640,7 +788,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 +824,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,4 +1,4 @@
|
|||||||
import { RefreshCw } from "lucide-react";
|
import { Link2, RefreshCw } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
hasScope,
|
||||||
i18nMessage,
|
i18nMessage,
|
||||||
useGuardedNavigate,
|
useGuardedNavigate,
|
||||||
usePlatformLanguage,
|
usePlatformLanguage,
|
||||||
@@ -14,11 +15,12 @@ import {
|
|||||||
} 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, FORMS_RUNTIME_I18N } from "./interfacePatterns";
|
||||||
|
import IntakeProfilesDialog from "./IntakeProfilesDialog";
|
||||||
|
|
||||||
|
|
||||||
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 +28,8 @@ 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 canAdmin = hasScope(auth, "forms_runtime:workspace:admin");
|
||||||
|
|
||||||
const load = useCallback((signal?: AbortSignal) => {
|
const load = useCallback((signal?: AbortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -59,6 +63,12 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
|
|||||||
<RefreshCw size={16} aria-hidden="true" />
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
{canAdmin &&
|
||||||
|
<Button onClick={() => setIntakeOpen(true)}>
|
||||||
|
<Link2 size={16} aria-hidden="true" />
|
||||||
|
Public intake
|
||||||
|
</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)}>
|
||||||
@@ -107,6 +117,7 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
|
|||||||
}
|
}
|
||||||
</PageScrollViewport>
|
</PageScrollViewport>
|
||||||
</div>
|
</div>
|
||||||
|
<IntakeProfilesDialog open={intakeOpen} settings={settings} onClose={() => setIntakeOpen(false)} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { Copy, Link, Plus, Send } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
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<"anonymous" | "invitation">("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 public 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="Public Form intake"
|
||||||
|
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>
|
||||||
|
<div 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 "anonymous" | "invitation")} disabled={Boolean(busyKey)}>
|
||||||
|
<option value="invitation">Invitation link</option>
|
||||||
|
<option value="anonymous">Open anonymous link</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 === "anonymous"} />
|
||||||
|
</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)} />
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="form-intake-profiles">
|
||||||
|
<h3>Configured profiles</h3>
|
||||||
|
{profiles.length === 0 && <p className="form-intake-empty">No public 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} · {profile.mode === "anonymous" ? "Anonymous link" : "Invitation links"}</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 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,391 @@
|
|||||||
|
import { Save, Send, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import {
|
||||||
|
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">
|
||||||
|
<div className="form-instance-toolbar">
|
||||||
|
<strong>{localized.title || "Form"}</strong>
|
||||||
|
{instance && <StatusBadge status={editable ? "active" : "inactive"} label={stateLabel(instance.status)} />}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</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}`;
|
||||||
|
}
|
||||||
@@ -73,7 +73,54 @@ 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."
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const de: Record<keyof typeof en, string> = {
|
const de: Record<keyof typeof en, string> = {
|
||||||
@@ -149,7 +196,54 @@ 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."
|
||||||
};
|
};
|
||||||
|
|
||||||
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.18",
|
||||||
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",
|
||||||
|
|||||||
@@ -124,6 +124,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 +290,135 @@
|
|||||||
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-intake-create,
|
||||||
|
.form-intake-profiles {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-profiles {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-create-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
align-items: end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
@@ -385,6 +574,25 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-evidence-heading,
|
||||||
|
.form-acknowledgement-row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-intake-create-grid,
|
||||||
|
.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