refactor(campaign): split review and recipient boundaries

This commit is contained in:
2026-07-30 01:01:26 +02:00
parent 23b9a531d5
commit 961d5d1130
12 changed files with 1860 additions and 1179 deletions
@@ -0,0 +1,312 @@
import { Button, i18nMessage } from "@govoplan/core-webui";
import CampaignMessagePreviewOverlay, {
type CampaignMessagePreviewAttachment
} from "../components/MessagePreviewOverlay";
import { asArray, asRecord, humanize } from "../utils/campaignView";
import { getBool, getText } from "../utils/draftEditor";
import {
buildTemplatePreviewContext,
renderTemplatePreviewText
} from "../utils/templatePlaceholders";
import {
formatAddressList,
formatSingleAddress,
numberFrom,
numberOrUndefined,
stringOrUndefined
} from "./reviewFormatters";
export default function BuiltMessagePreview({
campaignJson,
entries,
rows,
index,
canStartSingleMessageSend,
singleMessageSendBusy,
onSelect,
onSendSingle,
onClose
}: {
campaignJson: Record<string, unknown>;
entries: Record<string, unknown>[];
rows: Record<string, unknown>[];
index: number;
canStartSingleMessageSend: boolean;
singleMessageSendBusy: boolean;
onSelect: (index: number) => void;
onSendSingle: (index: number) => void;
onClose: () => void;
}) {
const row = rows[index] ?? {};
const entryIndex = Math.max(0, numberFrom(row, ["entry_index"]) - 1);
const entry = entries[entryIndex] ?? {};
const template = asRecord(campaignJson.template);
const context = buildTemplatePreviewContext(campaignJson, entry);
const ignoreEmptyFields = getBool(
asRecord(campaignJson.validation_policy),
"ignore_empty_fields",
false
);
const html = renderTemplatePreviewText(
getText(template, "html"),
context,
ignoreEmptyFields
);
const text = renderTemplatePreviewText(
getText(template, "text"),
context,
ignoreEmptyFields
);
const subject = String(
row.subject ||
renderTemplatePreviewText(
getText(template, "subject"),
context,
ignoreEmptyFields
) ||
"i18n:govoplan-campaign.no_subject.7b4e8035"
);
const issues = asArray(row.issues).map(asRecord);
const resolvedRecipients = asRecord(row.resolved_recipients);
const singleSendDisabledReason = singleMessageSendDisabledReason(
row,
canStartSingleMessageSend
);
return (
<CampaignMessagePreviewOverlay
title="i18n:govoplan-campaign.built_message_review.c7a594f9"
subject={subject}
bodyMode={html.trim() ? "html" : "text"}
text={text}
html={html}
recipientLabel={
formatAddressList(resolvedRecipients.to) ||
String(row.recipient_email ?? `Message ${index + 1}`)
}
recipientNote={
issues.length > 0
? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues
.map((issue) =>
String(
issue.message ??
issue.code ??
"i18n:govoplan-campaign.issue.73781a12"
)
)
.join(" · ")}`
: "i18n:govoplan-campaign.built_without_reported_issues.99c1f1a6"
}
metaItems={builtMessageMetaItems(row)}
attachments={builtMessageAttachments(row)}
navigation={{
index,
total: rows.length,
onFirst: () => onSelect(0),
onPrevious: () => onSelect(Math.max(0, index - 1)),
onNext: () => onSelect(Math.min(rows.length - 1, index + 1)),
onLast: () => onSelect(rows.length - 1)
}}
actions={
<Button
variant="primary"
disabled={singleMessageSendBusy || Boolean(singleSendDisabledReason)}
title={singleSendDisabledReason || "Send only this built message"}
onClick={() => onSendSingle(index)}
>
{singleMessageSendBusy ? "Sending..." : "Send this message..."}
</Button>
}
onClose={onClose}
/>
);
}
function singleMessageSendDisabledReason(
row: Record<string, unknown>,
canStartSingleMessageSend: boolean
): string {
if (!canStartSingleMessageSend) {
return "Validate, build, and complete the required review/mock gate before sending individual messages.";
}
const jobId = String(row.id ?? "");
if (!jobId) return "This preview row has no delivery job id.";
const buildStatus = String(row.build_status ?? "");
if (buildStatus !== "built") return "This message has not been built.";
const validationStatus = String(row.validation_status ?? "");
if (["blocked", "excluded", "inactive"].includes(validationStatus)) {
return `This message is ${humanize(validationStatus)}.`;
}
const sendStatus = String(row.send_status ?? "not_queued");
const queueStatus = String(row.queue_status ?? "draft");
if (["smtp_accepted", "sent"].includes(sendStatus)) {
return "This message was already accepted by SMTP.";
}
if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) {
return `This message is in delivery state ${humanize(sendStatus)}.`;
}
if (["failed_temporary", "failed_permanent"].includes(sendStatus)) {
return "Use the explicit retry action for failed messages.";
}
if (sendStatus === "queued" && queueStatus === "queued") return "";
if (
["not_queued", "cancelled"].includes(sendStatus) &&
["draft", "cancelled"].includes(queueStatus)
) {
return "";
}
return `This message cannot be sent from queue ${humanize(queueStatus)} / send ${humanize(sendStatus)}.`;
}
function builtMessageMetaItems(row: Record<string, unknown>) {
const recipients = asRecord(row.resolved_recipients);
return [
{
label: "i18n:govoplan-campaign.from.3f66052a",
value: formatSingleAddress(recipients.from) || "—"
},
{
label: "i18n:govoplan-campaign.to.ae79ea1e",
value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—")
},
{
label: "i18n:govoplan-campaign.cc.c5a976de",
value: formatAddressList(recipients.cc) || null
},
{
label: "i18n:govoplan-campaign.bcc.4c0145a3",
value: formatAddressList(recipients.bcc) || null
},
{
label: "i18n:govoplan-campaign.validation.dd74d182",
value: String(row.validation_status ?? "—")
},
{
label: "i18n:govoplan-campaign.mime_size.c8b9d519",
value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—"
}
];
}
function builtMessageAttachments(
row: Record<string, unknown>
): CampaignMessagePreviewAttachment[] {
return asArray(row.attachments).flatMap((value, index) => {
const attachment = asRecord(value);
const zipProtection = zipProtectionFromBuiltAttachment(attachment);
const managedMatches = asArray(attachment.managed_matches).map(asRecord);
if (managedMatches.length > 0) {
return managedMatches.map((match, matchIndex) => ({
filename: String(
match.filename ??
match.display_path ??
`Attachment ${index + 1}.${matchIndex + 1}`
),
detail: String(
match.display_path ?? match.relative_path ?? attachment.label ?? ""
),
contentType: stringOrUndefined(match.content_type),
sizeBytes: numberOrUndefined(match.size_bytes),
archiveGroup: stringOrUndefined(attachment.zip_filename),
archiveLabel: stringOrUndefined(attachment.zip_filename),
protected: zipProtection.protected,
protectionNote: zipProtection.note
}));
}
const matches = asArray(attachment.matches).filter(
(match): match is string => typeof match === "string" && Boolean(match.trim())
);
if (matches.length > 0) {
return matches.map((match) => ({
filename:
match.split(/[\\/]/).pop() ||
String(attachment.label ?? `Attachment ${index + 1}`),
detail: match,
contentType: stringOrUndefined(attachment.content_type),
sizeBytes: numberOrUndefined(attachment.size_bytes),
archiveGroup: stringOrUndefined(attachment.zip_filename),
archiveLabel: stringOrUndefined(attachment.zip_filename),
protected: zipProtection.protected,
protectionNote: zipProtection.note
}));
}
return [{
filename: String(
attachment.filename ??
attachment.filename_used ??
attachment.display_path ??
attachment.label ??
`Attachment ${index + 1}`
),
detail: String(
attachment.display_path ??
attachment.source_path ??
attachment.file_filter ??
""
),
contentType: stringOrUndefined(attachment.content_type),
sizeBytes: numberOrUndefined(attachment.size_bytes),
archiveGroup: null,
archiveLabel: null,
protected: false,
protectionNote: null
}];
});
}
function zipProtectionFromBuiltAttachment(
attachment: Record<string, unknown>
): { protected: boolean; note: string | null } {
const zipFilename = stringOrUndefined(attachment.zip_filename);
if (!zipFilename) return { protected: false, note: null };
const legacyMode = String(
attachment.password_mode ?? attachment.zip_password_mode ?? ""
).trim();
const protectedArchive = getBool(
attachment,
"password_enabled",
getBool(
attachment,
"zip_password_protected",
getBool(
attachment,
"zip_protected",
["direct", "field", "template"].includes(legacyMode)
)
)
);
if (!protectedArchive) return { protected: false, note: null };
const field =
stringOrUndefined(attachment.password_field) ??
stringOrUndefined(attachment.zip_password_field);
const rawScope = String(
attachment.password_scope ?? attachment.zip_password_scope ?? "local"
);
const scope = rawScope === "global" ? "global" : "local";
const method =
String(attachment.method ?? attachment.zip_method ?? "aes") === "zip_standard"
? "i18n:govoplan-campaign.zipcrypto.03bf7fb4"
: "i18n:govoplan-campaign.aes.41f215a6";
const source = field
? i18nMessage("i18n:govoplan-campaign.scope_field_value", {
value0: humanizeScope(scope),
value1: field
})
: "";
return {
protected: true,
note: source
? i18nMessage("i18n:govoplan-campaign.value_encryption_value", {
value0: source,
value1: method
})
: i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method })
};
}
function humanizeScope(scope: string): string {
return scope === "global"
? "i18n:govoplan-campaign.global.5f1184f7"
: "i18n:govoplan-campaign.local.dc99d54d";
}