Files
govoplan-campaign/webui/src/features/campaigns/review/BuiltMessagePreview.tsx
T
zemion c51fc180fb
Module Package Release / publish-packages (push) Successful in 12s
Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
2026-09-08 01:32:26 +02:00

373 lines
13 KiB
TypeScript

import {
Button,
DismissibleAlert,
FormField,
i18nMessage
} from "@govoplan/core-webui";
import { useState } from "react";
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";
import {
messageNeedsExplicitReview,
messageRequiresAttachmentOverrideReason
} from "./builtMessageQuery";
export default function BuiltMessagePreview({
campaignJson,
entries,
rows,
index,
canStartSingleMessageSend,
singleMessageSendBusy,
reviewed,
reviewReason,
reviewSaving = false,
reviewDisabled = false,
reviewError = "",
onReviewReasonChange,
onAcceptReview,
onSelect,
onSendSingle,
onClose
}: {
campaignJson: Record<string, unknown>;
entries: Record<string, unknown>[];
rows: Record<string, unknown>[];
index: number;
canStartSingleMessageSend: boolean;
singleMessageSendBusy: boolean;
reviewed: boolean;
reviewReason: string;
reviewSaving?: boolean;
reviewDisabled?: boolean;
reviewError?: string;
onReviewReasonChange: (value: string) => void;
onAcceptReview: (reasonRequired: boolean, reason: string) => Promise<void>;
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
);
const explicitReview = messageNeedsExplicitReview(row);
const reasonRequired = messageRequiresAttachmentOverrideReason(row);
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={
<div className="built-message-review-actions">
{reviewError && <DismissibleAlert tone="danger" dismissible={false}>{reviewError}</DismissibleAlert>}
{explicitReview && !reviewed ? (
<ReviewDecisionForm key={String(row.id ?? row.review_key ?? index)} initialReason={reviewReason}
reasonRequired={reasonRequired} saving={reviewSaving} disabled={reviewDisabled}
onReasonChange={onReviewReasonChange} onAccept={(reason) => onAcceptReview(reasonRequired, reason)} />
) : null}
{reviewed && <p role="status">i18n:govoplan-campaign.review_decision_saved</p>}
<Button
variant={explicitReview && !reviewed ? undefined : "primary"}
disabled={reviewSaving || singleMessageSendBusy || Boolean(singleSendDisabledReason)}
title={singleSendDisabledReason || "Test, send, or resend only this built message"}
onClick={() => onSendSingle(index)}
>
{singleMessageSendBusy ? "Sending..." : "Message actions..."}
</Button>
</div>
}
closeDisabled={reviewSaving}
onClose={onClose}
/>
);
}
// Keystrokes rerender only this small form, not the campaign table, template
// rendering and message body. The parent retains a draft ref for navigation.
function ReviewDecisionForm({ initialReason, reasonRequired, saving, disabled, onReasonChange, onAccept }: {
initialReason: string; reasonRequired: boolean; saving: boolean; disabled: boolean;
onReasonChange: (value: string) => void; onAccept: (reason: string) => Promise<void>;
}) {
const [reason, setReason] = useState(initialReason);
return <>
<FormField label={reasonRequired ? "i18n:govoplan-campaign.review_reason_label" : "i18n:govoplan-campaign.review_note_label"}
help="i18n:govoplan-campaign.review_reason_help">
<input value={reason} maxLength={4000} disabled={saving || disabled} onChange={event => {
setReason(event.target.value); onReasonChange(event.target.value);
}} />
</FormField>
<Button variant="primary" disabled={saving || disabled || reasonRequired && !reason.trim()} onClick={() => void onAccept(reason)}>
{saving ? "i18n:govoplan-campaign.review_saving_decision" : "i18n:govoplan-campaign.review_accept_next"}
</Button>
</>;
}
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");
if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) {
return `This message is in delivery state ${humanize(sendStatus)}.`;
}
return "";
}
function builtMessageMetaItems(row: Record<string, unknown>) {
const recipients = asRecord(row.resolved_recipients);
const postboxTargets = asArray(row.resolved_postbox_targets).map(asRecord);
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: "Frozen Postbox targets",
value: postboxTargets.length
? postboxTargets
.map((target) => {
const label = String(
target.name ?? target.address ?? target.postbox_id ?? "Postbox"
);
const address = String(target.address ?? "");
const context = target.context_key
? ` [${String(target.context_key)}]`
: "";
return `${label}${address && address !== label ? ` <${address}>` : ""}${context}`;
})
.join(", ")
: null
},
{
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<CampaignMessagePreviewAttachment>((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";
}