feat: govern attachment exceptions and ownership transfers

This commit is contained in:
2026-07-30 17:42:10 +02:00
parent cd223cbb95
commit 5f7503598c
28 changed files with 2229 additions and 180 deletions
@@ -1,4 +1,8 @@
import { Button, i18nMessage } from "@govoplan/core-webui";
import {
Button,
FormField,
i18nMessage
} from "@govoplan/core-webui";
import CampaignMessagePreviewOverlay, {
type CampaignMessagePreviewAttachment
@@ -16,6 +20,10 @@ import {
numberOrUndefined,
stringOrUndefined
} from "./reviewFormatters";
import {
messageNeedsExplicitReview,
messageRequiresAttachmentOverrideReason
} from "./builtMessageQuery";
export default function BuiltMessagePreview({
campaignJson,
@@ -24,6 +32,10 @@ export default function BuiltMessagePreview({
index,
canStartSingleMessageSend,
singleMessageSendBusy,
reviewed,
reviewReason,
onReviewReasonChange,
onAcceptReview,
onSelect,
onSendSingle,
onClose
@@ -34,6 +46,10 @@ export default function BuiltMessagePreview({
index: number;
canStartSingleMessageSend: boolean;
singleMessageSendBusy: boolean;
reviewed: boolean;
reviewReason: string;
onReviewReasonChange: (value: string) => void;
onAcceptReview: (reasonRequired: boolean) => void;
onSelect: (index: number) => void;
onSendSingle: (index: number) => void;
onClose: () => void;
@@ -73,6 +89,8 @@ export default function BuiltMessagePreview({
row,
canStartSingleMessageSend
);
const explicitReview = messageNeedsExplicitReview(row);
const reasonRequired = messageRequiresAttachmentOverrideReason(row);
return (
<CampaignMessagePreviewOverlay
@@ -109,14 +127,47 @@ export default function BuiltMessagePreview({
onLast: () => onSelect(rows.length - 1)
}}
actions={
<Button
variant="primary"
disabled={singleMessageSendBusy || Boolean(singleSendDisabledReason)}
title={singleSendDisabledReason || "Test, send, or resend only this built message"}
onClick={() => onSendSingle(index)}
>
{singleMessageSendBusy ? "Sending..." : "Message actions..."}
</Button>
<div className="built-message-review-actions">
{explicitReview && !reviewed ? (
<>
<FormField
label={
reasonRequired
? "Reason for accepting attachment exception"
: "Review note"
}
help={
reasonRequired
? "Required. This reason is stored with the frozen build evidence."
: "Optional note stored with the review decision."
}
>
<input
value={reviewReason}
maxLength={4000}
onChange={(event) =>
onReviewReasonChange(event.target.value)
}
/>
</FormField>
<Button
variant="primary"
disabled={reasonRequired && !reviewReason.trim()}
onClick={() => onAcceptReview(reasonRequired)}
>
Accept review conditions
</Button>
</>
) : null}
<Button
variant={explicitReview && !reviewed ? undefined : "primary"}
disabled={singleMessageSendBusy || Boolean(singleSendDisabledReason)}
title={singleSendDisabledReason || "Test, send, or resend only this built message"}
onClick={() => onSendSingle(index)}
>
{singleMessageSendBusy ? "Sending..." : "Message actions..."}
</Button>
</div>
}
onClose={onClose}
/>
@@ -61,22 +61,39 @@ export function storedMessageReviewState(version: CampaignVersionDetail | null):
buildToken: string;
inspectionComplete: boolean;
reviewedMessageKeys: string[];
issueDecisions: Array<Record<string, unknown>>;
} {
const build = asRecord(version?.build_summary);
const buildToken = String(build.build_token ?? build.built_at ?? "");
const review = asRecord(asRecord(version?.editor_state).review_send);
if (!buildToken || String(review.build_token ?? "") !== buildToken) {
return { buildToken, inspectionComplete: false, reviewedMessageKeys: [] };
return {
buildToken,
inspectionComplete: false,
reviewedMessageKeys: [],
issueDecisions: []
};
}
return {
buildToken,
inspectionComplete: review.inspection_complete === true,
reviewedMessageKeys: asArray(review.reviewed_message_keys).filter(
(value): value is string => typeof value === "string"
)
),
issueDecisions: asArray(review.issue_decisions).map(asRecord)
};
}
export function messageRequiresAttachmentOverrideReason(
row: Record<string, unknown>
): boolean {
return asArray(row.issues).some((value) => {
const issue = asRecord(value);
return String(issue.behavior ?? "").toLowerCase() === "ask"
&& String(issue.source ?? "").startsWith("attachments");
});
}
export function builtMessageKey(row: Record<string, unknown>, index: number): string {
return String(row.entry_id ?? row.entry_index ?? index);
}