feat: implement governed service directory

This commit is contained in:
2026-08-01 17:48:38 +02:00
parent cadac23162
commit ff44ed5f4a
17 changed files with 1948 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type PortalServiceBinding = {
kind: string;
reference: string;
required: boolean;
};
export type PortalServiceDefinition = {
reference: {
object_id: string;
version?: string | null;
};
key: string;
title: string;
audience: string[];
prerequisites: string[];
required_evidence_types: string[];
fee_refs: string[];
deadline_refs: string[];
channels: string[];
publication_state: string;
};
export type PortalServiceEntry = {
definition: PortalServiceDefinition;
state: "available" | "unavailable";
reason_codes: string[];
entry_binding?: PortalServiceBinding | null;
availability_evidence: Array<Record<string, unknown>>;
};
export type PortalServiceListResponse = {
services: PortalServiceEntry[];
};
export type PortalServiceLaunchResult = {
service_ref: PortalServiceDefinition["reference"];
binding: PortalServiceBinding;
state: "started" | "redirect";
target_ref?: Record<string, unknown> | null;
href?: string | null;
replayed: boolean;
evidence: Array<Record<string, unknown>>;
metadata: Record<string, unknown>;
};
export function listPortalServices(
settings: ApiSettings,
options: {
query?: string;
includeUnavailable?: boolean;
limit?: number;
},
signal?: AbortSignal
): Promise<PortalServiceListResponse> {
return apiFetch<PortalServiceListResponse>(
settings,
apiPath("/api/v1/portal/services", {
q: options.query,
include_unavailable: options.includeUnavailable,
limit: options.limit
}),
{ signal }
);
}
export function launchPortalService(
settings: ApiSettings,
serviceId: string,
payload: {
service_version: string;
idempotency_key: string;
requested_at: string;
parameters?: Record<string, unknown>;
}
): Promise<PortalServiceLaunchResult> {
return apiFetch<PortalServiceLaunchResult>(
settings,
`/api/v1/portal/services/${encodeURIComponent(serviceId)}/launch`,
{
method: "POST",
body: JSON.stringify(payload)
}
);
}
+231
View File
@@ -0,0 +1,231 @@
import { ArrowUpRight, Search } from "lucide-react";
import {
useEffect,
useMemo,
useRef,
useState,
type FormEvent
} from "react";
import {
DismissibleAlert,
Button,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
ToggleSwitch,
useGuardedNavigate,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
launchPortalService,
listPortalServices,
type PortalServiceEntry
} from "../../api/portal";
export default function PortalPage({ settings }: PlatformRouteContext) {
const navigate = useGuardedNavigate();
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [includeUnavailable, setIncludeUnavailable] = useState(true);
const [services, setServices] = useState<PortalServiceEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [launchingId, setLaunchingId] = useState("");
const launchAttempts = useRef(new Map<string, {
idempotencyKey: string;
requestedAt: string;
}>());
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError("");
listPortalServices(
settings,
{
query: submittedQuery,
includeUnavailable,
limit: 200
},
controller.signal
).
then((response) => setServices(response.services)).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Services could not be loaded.");
}
}).
finally(() => setLoading(false));
return () => controller.abort();
}, [includeUnavailable, settings, submittedQuery]);
const counts = useMemo(() => ({
available: services.filter((entry) => entry.state === "available").length,
unavailable: services.filter((entry) => entry.state === "unavailable").length
}), [services]);
function submit(event: FormEvent) {
event.preventDefault();
setSubmittedQuery(query.trim());
}
async function launch(entry: PortalServiceEntry) {
const serviceId = entry.definition.reference.object_id;
const version = entry.definition.reference.version;
if (!version) {
setError("This service has no exact launchable revision.");
return;
}
const attemptKey = `${serviceId}:${version}`;
const attempt = launchAttempts.current.get(attemptKey) ?? {
idempotencyKey: crypto.randomUUID(),
requestedAt: new Date().toISOString()
};
launchAttempts.current.set(attemptKey, attempt);
setLaunchingId(attemptKey);
setError("");
try {
const result = await launchPortalService(settings, serviceId, {
service_version: version,
idempotency_key: attempt.idempotencyKey,
requested_at: attempt.requestedAt
});
launchAttempts.current.delete(attemptKey);
if (!result.href) {
throw new Error("The service started without returning a destination.");
}
if (result.href.startsWith("/") && !result.href.startsWith("//")) {
navigate(result.href);
} else {
window.location.assign(result.href);
}
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Service could not be started.");
} finally {
setLaunchingId("");
}
}
return (
<main className="portal-page">
<div className="portal-shell">
<div className="portal-toolbar">
<form className="portal-search" onSubmit={submit}>
<Search size={17} aria-hidden="true" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label="Search services"
placeholder="Search services"
/>
<button type="submit" className="btn btn-primary">Search</button>
</form>
<ToggleSwitch
label="Show unavailable services"
checked={includeUnavailable}
onChange={setIncludeUnavailable}
/>
</div>
<div className="portal-result-summary" aria-live="polite">
<strong>{counts.available}</strong> available
{includeUnavailable && <><span aria-hidden="true">/</span><strong>{counts.unavailable}</strong> unavailable</>}
</div>
<PageScrollViewport className="portal-results">
{error &&
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
}
{loading && <LoadingIndicator label="Loading services" />}
{!loading && !error && services.length === 0 &&
<div className="portal-empty">No matching services.</div>
}
{!loading && services.length > 0 &&
<div className="portal-service-list">
{services.map((entry) =>
<ServiceEntry
key={`${entry.definition.reference.object_id}:${entry.definition.reference.version ?? "current"}`}
entry={entry}
launching={launchingId === `${entry.definition.reference.object_id}:${entry.definition.reference.version ?? ""}`}
onLaunch={() => void launch(entry)}
/>
)}
</div>
}
</PageScrollViewport>
</div>
</main>
);
}
function ServiceEntry({
entry,
launching,
onLaunch
}: {
entry: PortalServiceEntry;
launching: boolean;
onLaunch: () => void;
}) {
const reasons = userFacingReasons(entry.reason_codes);
return (
<article className={`portal-service-entry is-${entry.state}`}>
<div className="portal-service-heading">
<div>
<h2>{entry.definition.title}</h2>
<span className="portal-service-key">{entry.definition.key}</span>
</div>
<StatusBadge
status={entry.state === "available" ? "active" : "warning"}
label={entry.state === "available" ? "Available" : "Unavailable"}
/>
</div>
<div className="portal-service-metadata">
{entry.definition.channels.map((channel) =>
<span key={channel}>{humanize(channel)}</span>
)}
{entry.definition.required_evidence_types.map((evidence) =>
<span key={evidence}>{humanize(evidence)}</span>
)}
</div>
{reasons.length > 0 &&
<ul className="portal-service-reasons">
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
</ul>
}
<div className="portal-service-actions">
{entry.entry_binding && entry.state === "available" ?
<Button variant="primary" disabled={launching} onClick={onLaunch}>
{launching ? "Starting" : "Open"}
<ArrowUpRight size={15} aria-hidden="true" />
</Button> :
entry.entry_binding &&
<span className="portal-entry-kind">{humanize(entry.entry_binding.kind)}</span>
}
</div>
</article>
);
}
function userFacingReasons(codes: string[]): string[] {
const values = codes.
filter((code) => !code.startsWith("service.explanation:")).
map((code) => {
if (code === "service.publication.suspended") return "This service is temporarily suspended.";
if (code.includes("required_module.missing") || code.includes("required_capability.missing")) {
return "A required system component is unavailable.";
}
if (code.includes("evaluator_failed")) return "Availability could not be confirmed.";
if (code.includes("requirement.failed")) return "An availability requirement is not met.";
if (code.includes("requirement.unknown")) return "An availability requirement could not be confirmed.";
return "This service is currently unavailable.";
});
return [...new Set(values)];
}
function humanize(value: string): string {
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
+2
View File
@@ -0,0 +1,2 @@
export { default, portalModule } from "./module";
export * from "./api/portal";
+50
View File
@@ -0,0 +1,50 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/portal.css";
const PortalPage = lazy(() => import("./features/portal/PortalPage"));
export const portalModule: PlatformWebModule = {
id: "portal",
label: "Services",
version: "0.1.8",
optionalDependencies: ["access", "services", "cases", "forms", "workflow_engine"],
routes: [
{
path: "/portal",
anyOf: ["portal:service:read"],
order: 25,
surfaceId: "portal.directory",
render: (context) => createElement(PortalPage, context)
}
],
navItems: [
{
to: "/portal",
label: "Services",
iconName: "landmark",
anyOf: ["portal:service:read"],
order: 25,
surfaceId: "portal.navigation"
}
],
viewSurfaces: [
{
id: "portal.navigation",
moduleId: "portal",
kind: "navigation",
label: "Services navigation",
order: 10
},
{
id: "portal.directory",
moduleId: "portal",
kind: "route",
label: "Service directory",
order: 20
}
]
};
export default portalModule;
+146
View File
@@ -0,0 +1,146 @@
.portal-page {
height: 100%;
min-height: 0;
overflow: hidden;
}
.portal-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--surface);
}
.portal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 58px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
background: var(--surface-raised);
}
.portal-search {
display: flex;
align-items: center;
gap: 8px;
width: min(620px, 100%);
}
.portal-search input {
min-width: 120px;
flex: 1;
}
.portal-result-summary {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
color: var(--text-soft);
border-bottom: 1px solid var(--border);
}
.portal-results {
flex: 1;
min-height: 0;
padding: 16px 18px 24px;
}
.portal-service-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(360px, 100%), 1fr));
gap: 12px;
}
.portal-service-entry {
display: flex;
flex-direction: column;
min-height: 190px;
padding: 16px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 6px;
background: var(--surface-raised);
}
.portal-service-entry.is-unavailable {
border-left-color: var(--warning);
}
.portal-service-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.portal-service-heading h2 {
margin: 0;
font-size: 1rem;
line-height: 1.35;
letter-spacing: 0;
}
.portal-service-key,
.portal-entry-kind {
color: var(--text-soft);
font-size: 0.78rem;
}
.portal-service-metadata {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 14px;
}
.portal-service-metadata span {
padding: 3px 7px;
border-radius: 4px;
background: var(--surface-muted);
color: var(--text-soft);
font-size: 0.78rem;
}
.portal-service-reasons {
margin: 12px 0 0;
padding-left: 18px;
color: var(--warning-text-strong);
font-size: 0.86rem;
}
.portal-service-actions {
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 34px;
margin-top: auto;
padding-top: 14px;
}
.portal-service-actions .btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
.portal-empty {
padding: 36px 0;
color: var(--text-soft);
text-align: center;
}
@media (max-width: 720px) {
.portal-toolbar {
align-items: stretch;
flex-direction: column;
}
.portal-search {
width: 100%;
}
}