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
+70 -11
View File
@@ -156,6 +156,9 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
const [showAllReviewJobs, setShowAllReviewJobs] = useState(false);
const [jobsLoadedKey, setJobsLoadedKey] = useState("");
const [reviewedMessageKeys, setReviewedMessageKeys] = useState<Set<string>>(() => new Set());
const [reviewIssueDecisions, setReviewIssueDecisions] = useState<
Record<string, string>
>({});
const [newlyReviewedRequiredKeys, setNewlyReviewedRequiredKeys] = useState<Set<string>>(() => new Set());
const [selectedBuiltIndex, setSelectedBuiltIndex] = useState<number | null>(null);
const [singleSendConfirmIndex, setSingleSendConfirmIndex] = useState<number | null>(null);
@@ -182,7 +185,7 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
const imapDiagnosticsRef = useRef<CampaignJobsResponse>(emptyCampaignJobsResponse());
const [selectedDeliveryJobDetail, setSelectedDeliveryJobDetail] = useState<Record<string, unknown> | null>(null);
const persistedReview = storedMessageReviewState(version);
const persistedReviewKey = `${persistedReview.buildToken}|${persistedReview.inspectionComplete ? "1" : "0"}|${persistedReview.reviewedMessageKeys.join(",")}`;
const persistedReviewKey = `${persistedReview.buildToken}|${persistedReview.inspectionComplete ? "1" : "0"}|${persistedReview.reviewedMessageKeys.join(",")}|${JSON.stringify(persistedReview.issueDecisions)}`;
useEffect(() => {
setBuiltReviewRows([]);
@@ -192,6 +195,7 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
setReviewQuery({ sort: null, filters: {} });
setJobsLoadedKey("");
setNewlyReviewedRequiredKeys(new Set());
setReviewIssueDecisions({});
setSelectedBuiltIndex(null);
setMockResult(null);
setSelectedMockMessage(null);
@@ -215,6 +219,12 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
useEffect(() => {
setMessageReviewComplete(persistedReview.inspectionComplete);
setReviewedMessageKeys(new Set(persistedReview.reviewedMessageKeys));
setReviewIssueDecisions(Object.fromEntries(
persistedReview.issueDecisions.flatMap((item) => {
const jobId = String(item.job_id ?? "").trim();
return jobId ? [[jobId, String(item.reason ?? "")]] : [];
})
));
}, [version?.id, persistedReviewKey]);
useEffect(() => {
@@ -1010,7 +1020,14 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
const reviewed = new Set<string>(reviewedMessageKeys);
await updateCampaignReviewState(settings, campaignId, version.id, {
inspection_complete: true,
reviewed_message_keys: [...reviewed]
reviewed_message_keys: [...reviewed],
issue_decisions: Object.entries(reviewIssueDecisions).map(
([jobId, reason]) => ({
job_id: jobId,
decision: "accept" as const,
reason: reason.trim() || null
})
)
});
setReviewedMessageKeys(reviewed);
setNewlyReviewedRequiredKeys(new Set());
@@ -1063,15 +1080,6 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
if (index < 0) return;
const reviewRow = filteredBuiltReviewRows[index] ?? row;
const jobId = String(reviewRow.id ?? "");
const reviewKey = String(reviewRow.review_key ?? builtMessageKey(reviewRow, index));
setReviewedMessageKeys((current) => {
const next = new Set(current);
next.add(reviewKey);
return next;
});
if (messageNeedsExplicitReview(reviewRow) && reviewRow.reviewed !== true) {
setNewlyReviewedRequiredKeys((current) => new Set(current).add(reviewKey));
}
if (!jobId || reviewRow.resolved_recipients || reviewRow.attachments || reviewRow.issues) {
setSelectedBuiltIndex(index);
return;
@@ -1091,6 +1099,35 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
}
}
function acceptBuiltMessageReview(
row: Record<string, unknown>,
index: number,
reasonRequired: boolean
) {
const jobId = String(row.id ?? "").trim();
const reviewKey = String(
row.review_key ?? builtMessageKey(row, index)
);
const reason = reviewIssueDecisions[jobId] ?? "";
if (!jobId) {
setError("The built message has no delivery job id.");
return;
}
if (reasonRequired && !reason.trim()) {
setError("Enter a reason for accepting the attachment exception.");
return;
}
setReviewedMessageKeys((current) => new Set(current).add(reviewKey));
setNewlyReviewedRequiredKeys((current) =>
new Set(current).add(reviewKey)
);
setReviewIssueDecisions((current) => ({
...current,
[jobId]: reason
}));
setError("");
}
async function sendSingleBuiltMessage() {
if (!version || busy || singleSendConfirmIndex === null) return;
const row = filteredBuiltReviewRows[singleSendConfirmIndex];
@@ -1667,6 +1704,28 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
index={selectedBuiltIndex}
canStartSingleMessageSend={canStartSingleMessageSend}
singleMessageSendBusy={busy === "send"}
reviewed={reviewedMessageKeys.has(String(
selectedBuiltMessage.review_key
?? builtMessageKey(selectedBuiltMessage, selectedBuiltIndex)
))}
reviewReason={reviewIssueDecisions[
String(selectedBuiltMessage.id ?? "")
] ?? ""}
onReviewReasonChange={(value) => {
const jobId = String(selectedBuiltMessage.id ?? "").trim();
if (!jobId) return;
setReviewIssueDecisions((current) => ({
...current,
[jobId]: value
}));
}}
onAcceptReview={(reasonRequired) =>
acceptBuiltMessageReview(
selectedBuiltMessage,
selectedBuiltIndex,
reasonRequired
)
}
onSelect={openBuiltMessageAtIndex}
onSendSingle={(targetIndex) => {
const target = filteredBuiltReviewRows[targetIndex];