feat: add resident application status portal

This commit is contained in:
2026-08-19 12:33:34 +02:00
parent a15f599756
commit 89a142ead1
8 changed files with 512 additions and 3 deletions
+5
View File
@@ -33,4 +33,9 @@ replay-safe. A missing owner launcher makes the entry explainably unavailable.
Form launch resolves an exact published `<form-id>/<revision>` and returns the
owner's Form-instance route rather than persisting values in Portal.
The public `/portal/status/:trackingId` surface presents the bounded
`application_status.projection` owned by Forms Runtime. It supports the
configured authenticated, short-lived email-link, and permanent-link modes,
while Portal owns only the accessible presentation and reload/request actions.
See [docs/SERVICE_DIRECTORY_CONCEPT.md](docs/SERVICE_DIRECTORY_CONCEPT.md).
+25
View File
@@ -124,3 +124,28 @@ and Forms Runtime are active. It resolves an exact published
`<form-id>/<revision>`, validates launch values, and retains Service/binding
provenance. Reduced installations still fail closed rather than simulating a
submission in Portal.
## Applicant Status Presentation
Portal also presents Forms Runtime's bounded applicant-status projection at
`/portal/status/:trackingId`. It does not persist a status, inspect a Form
submission, or decide the disclosure policy. Forms Runtime resolves the tenant
through the Core `application_status.projection` contract and remains
authoritative for all access decisions.
The page adapts to the configured grant:
- authenticated-only access offers sign-in and then uses the applicant-bound
status endpoint;
- email-link access accepts the linked email address and always reports the
same request outcome, whether or not it matched; a delivered link carries a
short-lived secret that can be resent and replaces its predecessor; and
- permanent-link access loads from the high-entropy tracking URL without
authentication.
All modes render only title, current lifecycle state, update time, receipt
identifier, and the bounded public timeline supplied by Forms Runtime. Portal
must not infer missing milestones or expose values, people, evidence, internal
notes, or handoff details. A reload action re-fetches the authoritative
projection. Missing, disabled, revoked, expired, or unauthorized grants share
a non-enumerating unavailable state.
+81 -2
View File
@@ -1,5 +1,9 @@
from __future__ import annotations
from govoplan_core.core.application_status import (
CAPABILITY_APPLICATION_STATUS_PROJECTION,
application_status_projection_provider,
)
from govoplan_core.core.institutional import (
CAPABILITY_SERVICE_AVAILABILITY,
CAPABILITY_SERVICE_DEFINITIONS,
@@ -19,6 +23,7 @@ from govoplan_core.core.modules import (
NavItem,
PermissionDefinition,
ProductAreaContribution,
PublicFrontendRoute,
RoleTemplate,
)
from govoplan_core.core.provider_governance import (
@@ -52,6 +57,27 @@ def _router(context: ModuleContext):
return router
def _public_tenant_resolver(request: object, session: object) -> str | None:
path = str(getattr(getattr(request, "url", None), "path", ""))
if "/portal/status/" not in path:
return None
path_params = getattr(request, "path_params", {})
tracking_id = str(
path_params.get("trackingId")
or path_params.get("tracking_id")
or path.rsplit("/", 1)[-1]
or ""
).strip()
if not tracking_id:
return None
app = getattr(request, "app", None)
registry = getattr(getattr(app, "state", None), "govoplan_registry", None)
provider = application_status_projection_provider(registry)
if provider is None:
return None
return provider.tenant_id_for_tracking_id(session, tracking_id=tracking_id)
manifest = ModuleManifest(
id=MODULE_ID,
name="Portal",
@@ -70,6 +96,7 @@ manifest = ModuleManifest(
CAPABILITY_SERVICE_AVAILABILITY,
*SERVICE_LAUNCH_CAPABILITIES,
CAPABILITY_POSTBOX_PORTAL,
CAPABILITY_APPLICATION_STATUS_PROJECTION,
),
permissions=(
PermissionDefinition(
@@ -113,11 +140,18 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_APPLICATION_STATUS_PROJECTION,
version_min="1.0.0",
version_max_exclusive="2.0.0",
optional=True,
),
),
capability_factories={
CAPABILITY_PORTAL_SERVICE_DIRECTORY: _service_directory,
},
route_factory=_router,
public_tenant_resolver=_public_tenant_resolver,
nav_items=(
NavItem(
path="/portal",
@@ -138,6 +172,13 @@ manifest = ModuleManifest(
order=25,
),
),
public_routes=(
PublicFrontendRoute(
path="/portal/status/:trackingId",
component="PortalStatusPage",
order=12,
),
),
nav_items=(
NavItem(
path="/portal",
@@ -173,6 +214,13 @@ manifest = ModuleManifest(
label="Service directory",
order=20,
),
ViewSurface(
id="portal.application-status",
module_id=MODULE_ID,
kind="route",
label="Applicant status",
order=30,
),
),
),
capability_documentation={
@@ -211,6 +259,36 @@ manifest = ModuleManifest(
metadata={"kind": "guide", "help_contexts": ["portal.postboxes"]},
order=20,
),
DocumentationTopic(
id="portal.application-status",
title="Track an application",
summary="View the bounded public lifecycle through the access profile configured for the exact submitted Form revision.",
body=(
"Portal presents the applicant status projection owned by Forms Runtime. The service administrator chooses authenticated-only access, a short-lived email link, or a permanent public bearer link for each exact published Form revision. "
"Authenticated access is checked against the applicant account. Email-link requests return the same response for matching and non-matching details, revoke the previous link when a new one is sent, and depend on configured Notifications and Mail delivery. A permanent link does not expire or require sign-in and must therefore be handled like a bearer secret. "
"The page exposes only lifecycle states, update times, a tracking identifier, and the submission receipt. It does not display Form values, evidence, internal notes, actors, decision reasoning, or module handoff details."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("public", "user", "operator", "module_admin"),
links=(
DocumentationLink(
label="Applicant status",
href="/portal/status/:trackingId",
kind="runtime",
),
),
related_modules=("forms_runtime", "notifications", "mail"),
metadata={
"kind": "guide",
"help_contexts": ["portal.application-status"],
"privacy_notes": [
"Possession of a permanent status link grants access to the bounded projection until its owner suspends the policy or revokes the grant.",
"The email request surface does not reveal whether the tracking identifier, email address, provider, or delivery attempt matched."
],
},
order=30,
),
DocumentationTopic(
id="portal.service-directory",
title="Service directory",
@@ -258,9 +336,10 @@ manifest = ModuleManifest(
known_limits=(
"Portal does not persist service definitions; the Services provider remains authoritative.",
"Case, Forms Runtime, and Workflow Engine own launch effects. Portal keeps entries unavailable whenever the selected owner capability is absent.",
"Forms Runtime owns applicant-status access, redaction, and timeline semantics; Portal only presents that projection. Payment and decision-document actions are not yet included in the first status surface.",
),
owned_concepts=("service discovery", "service presentation", "channel entry"),
non_owned_concepts=("institutional service definition", "case lifecycle"),
owned_concepts=("service discovery", "service presentation", "channel entry", "applicant status presentation"),
non_owned_concepts=("institutional service definition", "case lifecycle", "applicant status access decision"),
reference_packages=("product.service-to-decision",),
documentation=ModuleArchitectureDocumentation(
security=("docs/SERVICE_DIRECTORY_CONCEPT.md",),
+9
View File
@@ -431,6 +431,15 @@ class PortalServiceDirectoryTests(unittest.TestCase):
{item.scope for item in manifest.permissions},
)
self.assertIn("portal.service_directory", manifest.capability_factories)
self.assertIsNotNone(manifest.public_tenant_resolver)
self.assertIn(
"/portal/status/:trackingId",
{route.path for route in manifest.frontend.public_routes},
)
self.assertIn(
"application_status.projection",
{item.name for item in manifest.requires_interfaces},
)
if __name__ == "__main__":
+79
View File
@@ -1,6 +1,11 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
function publicSettings(settings: ApiSettings): ApiSettings {
return { ...settings, accessToken: "", apiKey: "" };
}
export type PortalServiceBinding = {
kind: string;
reference: string;
@@ -65,6 +70,26 @@ export type PortalServiceLaunchResult = {
metadata: Record<string, unknown>;
};
export type PortalApplicationStatusAccess = {
tracking_id: string;
mode: "authenticated" | "email_link" | "permanent_link";
authenticated_available: boolean;
email_link_available: boolean;
token_ttl_seconds?: number | null;
};
export type PortalApplicationStatus = {
tracking_id: string;
title: string;
status: string;
updated_at: string;
receipt_id?: string | null;
timeline: Array<{
status: string;
occurred_at: string;
}>;
};
export function listPortalServices(
settings: ApiSettings,
options: {
@@ -115,3 +140,57 @@ export function launchPortalService(
}
);
}
export function getApplicationStatusAccess(
settings: ApiSettings,
trackingId: string,
signal?: AbortSignal
): Promise<PortalApplicationStatusAccess> {
return apiFetch(
publicSettings(settings),
`/api/v1/forms-runtime/public/status/${encodeURIComponent(trackingId)}/access`,
{ signal }
);
}
export function getPublicApplicationStatus(
settings: ApiSettings,
trackingId: string,
token?: string,
signal?: AbortSignal
): Promise<PortalApplicationStatus> {
return apiFetch(
publicSettings(settings),
apiPath(`/api/v1/forms-runtime/public/status/${encodeURIComponent(trackingId)}`, { token }),
{ signal }
);
}
export function getAuthenticatedApplicationStatus(
settings: ApiSettings,
trackingId: string,
signal?: AbortSignal
): Promise<PortalApplicationStatus> {
return apiFetch(
settings,
`/api/v1/forms-runtime/status/${encodeURIComponent(trackingId)}`,
{ signal }
);
}
export function requestApplicationStatusEmailLink(
settings: ApiSettings,
trackingId: string,
email: string
): Promise<{ accepted: boolean; message: string }> {
return apiFetch(
publicSettings(settings),
`/api/v1/forms-runtime/public/status/${encodeURIComponent(trackingId)}/email-links`,
{
method: "POST",
body: JSON.stringify({
email
})
}
);
}
@@ -0,0 +1,207 @@
import { Clock3, LogIn, RefreshCw, Send } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import {
ActionToolbar,
Button,
Card,
DescriptionItem,
DescriptionList,
DismissibleAlert,
FormField,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
WorkspaceFrame,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
getApplicationStatusAccess,
getAuthenticatedApplicationStatus,
getPublicApplicationStatus,
requestApplicationStatusEmailLink,
type PortalApplicationStatus,
type PortalApplicationStatusAccess
} from "../../api/portal";
export default function PortalStatusPage({ settings }: PlatformRouteContext) {
const { trackingId = "" } = useParams();
const [searchParams] = useSearchParams();
const token = searchParams.get("token") ?? "";
const [access, setAccess] = useState<PortalApplicationStatusAccess | null>(null);
const [status, setStatus] = useState<PortalApplicationStatus | null>(null);
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const load = useCallback(async (signal?: AbortSignal) => {
if (!trackingId) return;
setLoading(true);
setError("");
try {
const nextAccess = await getApplicationStatusAccess(settings, trackingId, signal);
setAccess(nextAccess);
if (token) {
setStatus(await getPublicApplicationStatus(settings, trackingId, token, signal));
return;
}
if (nextAccess.mode === "permanent_link") {
setStatus(await getPublicApplicationStatus(settings, trackingId, undefined, signal));
return;
}
if (settings.accessToken || settings.apiKey) {
try {
setStatus(await getAuthenticatedApplicationStatus(settings, trackingId, signal));
return;
} catch {
setStatus(null);
}
} else {
setStatus(null);
}
} catch (reason) {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Application status could not be loaded.");
}
} finally {
setLoading(false);
}
}, [settings, token, trackingId]);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load]);
async function requestLink() {
if (!email.trim()) return;
setSending(true);
setError("");
setNotice("");
try {
const response = await requestApplicationStatusEmailLink(settings, trackingId, email.trim());
setNotice(response.message);
} catch {
setNotice("If the application and email address match, a new short-lived status link will be sent.");
} finally {
setSending(false);
}
}
return (
<main className="portal-status-page">
<WorkspaceFrame
height="container"
label="Application status"
interfaceId="portal.application-status"
helpModuleId="portal"
helpTopicId="portal.application-status"
helpContextId="portal.application-status">
<ActionToolbar surface="workspace" className="portal-status-toolbar">
<strong>Application status</strong>
<Button onClick={() => void load()} disabled={loading}>
<RefreshCw size={16} aria-hidden="true" />
Reload
</Button>
</ActionToolbar>
<PageScrollViewport className="portal-status-viewport">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && <DismissibleAlert tone="info" resetKey={notice}>{notice}</DismissibleAlert>}
{loading && <LoadingIndicator label="Loading application status" />}
{!loading && status && <StatusProjection status={status} />}
{!loading && !status && access?.mode === "authenticated" &&
<Card title="Sign in to view status" className="portal-status-access-card">
<p>This application is configured for authenticated access only. Sign in with the account linked to the submission.</p>
<a className="btn btn-primary" href={`/login?next=${encodeURIComponent(window.location.pathname)}`}>
<LogIn size={16} aria-hidden="true" />
Sign in
</a>
</Card>
}
{!loading && !status && access?.mode === "email_link" &&
<Card title="Request a short-lived status link" className="portal-status-access-card">
<p>Enter the email address linked to the application. The response is identical whether or not the details match.</p>
<form onSubmit={(event) => { event.preventDefault(); void requestLink(); }}>
<FormField label="Linked email address">
<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} disabled={sending} required autoComplete="email" />
</FormField>
<Button type="submit" variant="primary" disabled={sending || !email.trim()}>
<Send size={16} aria-hidden="true" />
Send new link
</Button>
</form>
{access.token_ttl_seconds &&
<p className="portal-status-expiry"><Clock3 size={15} aria-hidden="true" />The link remains valid for {durationLabel(access.token_ttl_seconds)} and replaces the previous link.</p>
}
</Card>
}
</PageScrollViewport>
</WorkspaceFrame>
</main>
);
}
function StatusProjection({ status }: { status: PortalApplicationStatus }) {
return (
<div className="portal-status-content">
<Card
title={status.title}
actions={<StatusBadge status={statusTone(status.status)} label={statusLabel(status.status)} />}>
<DescriptionList columns={3} collapseAt="workspace" density="compact">
<DescriptionItem term="Tracking ID"><code>{status.tracking_id}</code></DescriptionItem>
<DescriptionItem term="Last updated">{formatDate(status.updated_at)}</DescriptionItem>
{status.receipt_id && <DescriptionItem term="Submission receipt"><code>{status.receipt_id}</code></DescriptionItem>}
</DescriptionList>
</Card>
<Card title="Timeline">
{status.timeline.length === 0 && <p>No public status event is available yet.</p>}
<ol className="portal-status-timeline">
{status.timeline.map((item, index) =>
<li key={`${item.status}:${item.occurred_at}:${index}`}>
<span className="portal-status-marker" aria-hidden="true" />
<div>
<strong>{statusLabel(item.status)}</strong>
<time dateTime={item.occurred_at}>{formatDate(item.occurred_at)}</time>
</div>
</li>
)}
</ol>
</Card>
<DismissibleAlert tone="info">
This page intentionally shows only the public lifecycle, update times, and receipt reference. Form values, evidence, internal notes, actors, and handoff details remain private.
</DismissibleAlert>
</div>
);
}
function statusLabel(value: string): string {
const labels: Record<string, string> = {
submitted: "Application received",
validated: "Completeness checked",
needs_review: "Under review",
accepted: "Approved",
rejected: "Decision issued",
handed_off: "Further processing",
archived: "Procedure closed"
};
return labels[value] ?? value.replaceAll("_", " ");
}
function statusTone(value: string): string {
if (value === "accepted" || value === "archived") return "active";
if (value === "rejected") return "warning";
return "pending";
}
function formatDate(value: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
function durationLabel(seconds: number): string {
if (seconds % 3600 === 0) return `${seconds / 3600} hour${seconds === 3600 ? "" : "s"}`;
return `${Math.round(seconds / 60)} minutes`;
}
+16 -1
View File
@@ -4,12 +4,13 @@ import "./styles/portal.css";
const PortalPage = lazy(() => import("./features/portal/PortalPage"));
const PortalStatusPage = lazy(() => import("./features/portal/PortalStatusPage"));
export const portalModule: PlatformWebModule = {
id: "portal",
label: "Services",
version: "0.1.8",
optionalDependencies: ["access", "services", "cases", "forms", "workflow_engine"],
optionalDependencies: ["access", "services", "cases", "forms", "forms_runtime", "workflow_engine"],
routes: [
{
path: "/portal",
@@ -19,6 +20,13 @@ export const portalModule: PlatformWebModule = {
render: (context) => createElement(PortalPage, context)
}
],
publicRoutes: [
{
path: "/portal/status/:trackingId",
order: 12,
render: (context) => createElement(PortalStatusPage, context)
}
],
navItems: [
{
to: "/portal",
@@ -43,6 +51,13 @@ export const portalModule: PlatformWebModule = {
kind: "route",
label: "Service directory",
order: 20
},
{
id: "portal.application-status",
moduleId: "portal",
kind: "route",
label: "Applicant status",
order: 30
}
]
};
+90
View File
@@ -4,6 +4,96 @@
overflow: hidden;
}
.portal-status-page {
height: 100%;
min-height: 0;
}
.portal-status-toolbar {
justify-content: space-between;
}
.portal-status-viewport {
padding: 18px;
}
.portal-status-content {
display: grid;
width: min(920px, 100%);
margin: 0 auto;
gap: 14px;
}
.portal-status-access-card {
width: min(620px, 100%);
margin: 32px auto;
}
.portal-status-access-card form {
display: grid;
gap: 12px;
}
.portal-status-access-card .btn,
.portal-status-access-card .btn-primary {
width: fit-content;
}
.portal-status-expiry {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-soft);
}
.portal-status-timeline {
position: relative;
display: grid;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
}
.portal-status-timeline::before {
position: absolute;
top: 11px;
bottom: 11px;
left: 7px;
width: 2px;
background: var(--border-strong);
content: "";
}
.portal-status-timeline li {
position: relative;
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 12px;
padding: 8px 0;
}
.portal-status-marker {
z-index: 1;
width: 16px;
height: 16px;
margin-top: 2px;
border: 3px solid var(--accent);
border-radius: var(--radius-round);
background: var(--surface-raised);
}
.portal-status-timeline li > div {
display: flex;
flex-direction: column;
gap: 2px;
}
.portal-status-timeline time {
color: var(--text-soft);
font-size: 0.82rem;
}
.portal-search { flex: 1 1 620px; }
.portal-result-summary {