refactor(campaign): split review and recipient boundaries
This commit is contained in:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user