feat: integrate files and portal surfaces

This commit is contained in:
2026-08-07 14:53:46 +02:00
parent 60f50f7906
commit 11f175cbb3
13 changed files with 512 additions and 13 deletions
+49
View File
@@ -2,6 +2,8 @@ import {
apiFetch,
apiPath,
apiPostJson,
apiUrl,
authHeaders,
type ApiSettings
} from "@govoplan/core-webui";
@@ -62,6 +64,15 @@ export type PostboxAttachment = {
metadata: Record<string, unknown>;
};
export type PostboxAttachmentResolution = PostboxAttachment & {
available: boolean;
reason_code: string;
file_asset_id?: string | null;
file_version_id?: string | null;
download_path?: string | null;
provenance: Record<string, unknown>;
};
export type PostboxMessage = {
id: string;
tenant_id: string;
@@ -200,6 +211,7 @@ export type PostboxTemplateRevision = {
address_pattern: string;
classification: string;
allow_vacant_delivery: boolean;
portal_visible: boolean;
encryption_profile: string;
history_policy: Record<string, unknown>;
routing_policy: PostboxRoutingPolicy;
@@ -235,6 +247,7 @@ export type PostboxTemplateRevisionPayload = Pick<
| "address_pattern"
| "classification"
| "allow_vacant_delivery"
| "portal_visible"
| "routing_policy"
>;
@@ -276,6 +289,7 @@ export type PostboxExactCreatePayload = {
function_id: string;
address_key?: string | null;
classification: string;
portal_visible: boolean;
};
export type PostboxMessageAuthoringPayload = {
@@ -328,6 +342,41 @@ export function getPostboxMessage(
return apiFetch(settings, `/api/v1/postbox/messages/${encodeURIComponent(messageId)}`);
}
export async function resolvePostboxAttachments(
settings: ApiSettings,
messageId: string
): Promise<PostboxAttachmentResolution[]> {
const response = await apiFetch<{ attachments: PostboxAttachmentResolution[] }>(
settings,
`/api/v1/postbox/messages/${encodeURIComponent(messageId)}/attachment-resolutions`
);
return response.attachments;
}
export async function downloadPostboxAttachment(
settings: ApiSettings,
attachment: PostboxAttachmentResolution
): Promise<void> {
if (!attachment.available || !attachment.download_path) {
throw new Error("This attachment payload is not available.");
}
const response = await fetch(apiUrl(settings, attachment.download_path), {
headers: authHeaders(settings),
credentials: "include"
});
if (!response.ok) {
throw new Error(`Attachment download failed (${response.status}).`);
}
const objectUrl = URL.createObjectURL(await response.blob());
const link = document.createElement("a");
link.href = objectUrl;
link.download = attachment.name || attachment.reference_id;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(objectUrl);
}
export function markPostboxMessage(
settings: ApiSettings,
messageId: string,
@@ -112,6 +112,7 @@ const templateDefaults = (): TemplateDraft => ({
address_pattern: "{template_slug}.{unit_slug}.{function_slug}",
classification: "internal",
allow_vacant_delivery: true,
portal_visible: false,
routing_policy: routingDefaults()
});
@@ -121,7 +122,8 @@ const exactDefaults = (): ExactDraft => ({
organization_unit_id: "",
function_id: "",
address_key: "",
classification: "internal"
classification: "internal",
portal_visible: false
});
export default function PostboxAdminPanel({
@@ -270,6 +272,7 @@ export default function PostboxAdminPanel({
address_pattern: revision.address_pattern,
classification: revision.classification,
allow_vacant_delivery: revision.allow_vacant_delivery,
portal_visible: revision.portal_visible,
routing_policy: revision.routing_policy ?? routingDefaults()
};
setTemplatePreview(null);
@@ -1126,6 +1129,14 @@ function TemplateDialog({
onChange={(checked) => onChange({ ...draft, allow_vacant_delivery: checked })}
/>
</div>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Show in Portal"
help="Portal lists this Postbox only for users whose current function assignment already grants Postbox access."
checked={draft.portal_visible}
onChange={(checked) => onChange({ ...draft, portal_visible: checked })}
/>
</div>
<div className="postbox-routing-section">
<div className="postbox-routing-heading">
<div>
@@ -1472,6 +1483,14 @@ function ExactPostboxDialog({
<FormField label="Description" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input value={draft.description || ""} onChange={(event) => onChange({ ...draft, description: event.target.value })} />
</FormField>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Show in Portal"
help="Portal lists this Postbox only when the current user's function assignment grants access."
checked={draft.portal_visible}
onChange={(checked) => onChange({ ...draft, portal_visible: checked })}
/>
</div>
</div>
</Dialog>
);
@@ -1578,6 +1597,7 @@ function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload {
address_pattern: draft.address_pattern,
classification: draft.classification,
allow_vacant_delivery: draft.allow_vacant_delivery,
portal_visible: draft.portal_visible,
routing_policy: draft.routing_policy
};
}
+57 -5
View File
@@ -3,6 +3,7 @@ import {
Archive,
Building2,
CheckCheck,
Download,
Inbox,
Layers3,
MailOpen,
@@ -45,15 +46,18 @@ import {
createPostboxGrouping,
createPostboxMessage,
deletePostboxGrouping,
downloadPostboxAttachment,
getPostboxMessage,
listPostboxGroupings,
listPostboxMessages,
listPostboxes,
markPostboxMessage,
resolvePostboxAttachments,
replyToPostboxMessage,
updatePostboxGrouping,
type PostboxDirectoryItem,
type PostboxGrouping,
type PostboxAttachmentResolution,
type PostboxMessage
} from "../../api/postbox";
import {
@@ -106,6 +110,9 @@ export default function PostboxPage({
const requestedMessageId = useRef(
new URLSearchParams(window.location.search).get("message") ?? ""
);
const requestedPostboxId = useRef(
new URLSearchParams(window.location.search).get("postbox") ?? ""
);
const requestedMessageLoaded = useRef(false);
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
const [groupings, setGroupings] = useState<PostboxGrouping[]>([]);
@@ -114,6 +121,7 @@ export default function PostboxPage({
const [messages, setMessages] = useState<PostboxMessage[]>([]);
const [selectedMessageId, setSelectedMessageId] = useState("");
const [selectedMessage, setSelectedMessage] = useState<PostboxMessage | null>(null);
const [attachmentResolutions, setAttachmentResolutions] = useState<PostboxAttachmentResolution[]>([]);
const [unavailableSelection, setUnavailableSelection] = useState("");
const [total, setTotal] = useState(0);
const [messageState, setMessageState] = useState<MessageStateFilter>("all");
@@ -204,7 +212,9 @@ export default function PostboxPage({
setSelectedPostboxId((current) =>
current && nextPostboxes.some((postbox) => postbox.id === current)
? current
: ""
: nextPostboxes.some((postbox) => postbox.id === requestedPostboxId.current)
? requestedPostboxId.current
: ""
);
} catch (loadError) {
setError(errorMessage(loadError));
@@ -316,14 +326,19 @@ export default function PostboxPage({
if (message.availability === "available" && !message.read_at) {
message = await markPostboxMessage(settings, selectedMessageId, "read");
}
const resolutions = message.attachments.length
? await resolvePostboxAttachments(settings, message.id)
: [];
if (cancelled) return;
setSelectedMessage(message);
setAttachmentResolutions(resolutions);
setMessages((items) =>
items.map((item) => (item.id === message.id ? message : item))
);
} catch (loadError) {
if (!cancelled && isApiError(loadError, 403, 404)) {
setSelectedMessage(null);
setAttachmentResolutions([]);
setUnavailableSelection(
"This message is no longer available or is outside your current Postbox assignments."
);
@@ -866,6 +881,12 @@ export default function PostboxPage({
<MessageDetail
message={selectedMessage}
postbox={postboxes.find((item) => item.id === selectedMessage.postbox_id)}
attachmentResolutions={attachmentResolutions}
onDownload={(attachment) => {
void downloadPostboxAttachment(settings, attachment).catch((downloadError) => {
setError(errorMessage(downloadError));
});
}}
/>
) : unavailableSelection ? (
<div className="postbox-empty postbox-unavailable-message">
@@ -1099,10 +1120,14 @@ export default function PostboxPage({
function MessageDetail({
message,
postbox
postbox,
attachmentResolutions,
onDownload
}: {
message: PostboxMessage;
postbox?: PostboxDirectoryItem;
attachmentResolutions: PostboxAttachmentResolution[];
onDownload: (attachment: PostboxAttachmentResolution) => void;
}) {
const { language } = usePlatformLanguage();
return (
@@ -1166,15 +1191,30 @@ function MessageDetail({
<section className="postbox-attachments">
<h2>Evidence and attachments</h2>
{!message.attachments.length ? <p>No attachment references.</p> : null}
{message.attachments.map((attachment) => (
{message.attachments.map((attachment) => {
const resolution = attachmentResolutions.find(
(item) => item.reference_type === attachment.reference_type
&& item.reference_id === attachment.reference_id
);
return (
<div key={`${attachment.reference_type}:${attachment.reference_id}`}>
<Paperclip size={15} />
<span>
<strong>{attachment.name || attachment.reference_id}</strong>
<strong>{resolution?.name || attachment.name || attachment.reference_id}</strong>
<small>{attachment.reference_type}{attachment.media_type ? ` · ${attachment.media_type}` : ""}</small>
{resolution && !resolution.available ? (
<small>{attachmentResolutionExplanation(resolution.reason_code)}</small>
) : null}
</span>
{resolution?.available ? (
<IconButton
icon={<Download size={15} />}
label={`Download ${resolution.name || attachment.name || "attachment"}`}
onClick={() => onDownload(resolution)}
/>
) : null}
</div>
))}
)})}
</section>
{postbox?.access ? (
<section className="postbox-access-explanation">
@@ -1189,6 +1229,18 @@ function MessageDetail({
);
}
function attachmentResolutionExplanation(reasonCode: string): string {
const explanations: Record<string, string> = {
download_permission_missing: "Files download permission is required.",
file_access_denied: "The referenced file is outside your current Files access.",
file_not_found: "The referenced file or version no longer exists.",
file_payload_missing: "The referenced file payload is unavailable.",
files_provider_unavailable: "Files is not available in this installation.",
reference_provider_unavailable: "No provider can open this evidence type."
};
return explanations[reasonCode] || "The referenced payload cannot currently be opened.";
}
function sourceName(
postboxes: PostboxDirectoryItem[],
postboxId: string