Files
govoplan-campaign/webui/src/features/campaigns/utils/campaignView.ts

233 lines
9.0 KiB
TypeScript

import type { CampaignListItem } from "../../../types";
import type { CampaignSummary, CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
export type CampaignWorkspaceData = {
campaign: CampaignListItem | null;
versions: CampaignVersionListItem[];
currentVersion: CampaignVersionDetail | null;
summary: CampaignSummary | null;
};
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asRecord(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
export function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
export function getCampaignJson(version: CampaignVersionDetail | null): Record<string, unknown> {
return version?.raw_json ?? version?.campaign_json ?? {};
}
export function getCampaignSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).campaign);
}
export function getRecipientsSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).recipients);
}
export function getTemplateSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).template);
}
export function getDeliverySection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).delivery);
}
export function getEntriesSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).entries);
}
export function getAttachmentsSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).attachments);
}
export function getFields(version: CampaignVersionDetail | null): unknown[] {
return asArray(getCampaignJson(version).fields);
}
export function userLockState(
version: CampaignVersionDetail | CampaignVersionListItem | null
): "temporary" | "permanent" | null {
if (!version) return null;
if (version.user_lock_state === "temporary" || version.user_lock_state === "permanent") {
return version.user_lock_state;
}
// Backwards compatibility for snapshots created before explicit user locks.
return version.published_at ? "permanent" : null;
}
export function isTemporaryUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
return userLockState(version) === "temporary";
}
export function isPermanentUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
return userLockState(version) === "permanent";
}
export function isUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
return userLockState(version) !== null;
}
export function isFinalLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
if (!version) return false;
return [
"queued",
"sending",
"sent",
"completed",
"partially_completed",
"outcome_unknown",
"failed",
"failed_partial",
"partially_sent",
"archived",
"cancelled"
].includes((version.workflow_state ?? "").toLowerCase());
}
export function isVersionReadyForDelivery(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
if (!version?.locked_at || isUserLockedVersion(version)) return false;
const validation = asRecord(version.validation_summary);
return validation.ok === true;
}
export function canUnlockValidationVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
if (!version) return false;
if (isUserLockedVersion(version)) return false;
if (!isVersionReadyForDelivery(version)) return false;
if (isFinalLockedVersion(version)) return false;
return ["approved", "built"].includes(version.workflow_state ?? "");
}
export function isHistoricalCampaignVersion(
version: CampaignVersionDetail | CampaignVersionListItem | null,
currentVersionId?: string | null,
): boolean {
return Boolean(version && currentVersionId && version.id !== currentVersionId);
}
export function isAuditLockedVersion(
version: CampaignVersionDetail | CampaignVersionListItem | null,
currentVersionId?: string | null,
): boolean {
if (!version) return false;
if (isHistoricalCampaignVersion(version, currentVersionId)) return true;
if (version.locked_at || isUserLockedVersion(version)) return true;
return isFinalLockedVersion(version);
}
export function versionLockReason(
version: CampaignVersionDetail | CampaignVersionListItem | null,
currentVersionId?: string | null,
): string {
if (!version) return "No campaign version is loaded.";
if (isHistoricalCampaignVersion(version, currentVersionId)) {
return "Historical campaign versions are review-only. Continue work in the current version or create a new working copy after the current version becomes immutable.";
}
if (isTemporaryUserLockedVersion(version)) {
return `Temporarily user-locked at ${formatDateTime(version.user_locked_at)}. Authorized users may unlock it or make the lock permanent.`;
}
if (isPermanentUserLockedVersion(version)) {
return `Permanently user-locked at ${formatDateTime(version.user_locked_at ?? version.published_at)}. It cannot be unlocked; create an editable copy.`;
}
if (canUnlockValidationVersion(version)) {
return `Temporarily locked by validation at ${formatDateTime(version.locked_at)}. Unlocking invalidates validation, build and queue state.`;
}
if (isFinalLockedVersion(version)) return `Permanently locked by delivery/final state: ${humanize(version.workflow_state ?? "locked")}.`;
if (version.locked_at) return `Temporarily locked at ${formatDateTime(version.locked_at)}.`;
return "Editable working version.";
}
export function currentStepLabel(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
if (!version) return "—";
const flow = version.current_flow || "manual";
const step = version.current_step || "not set";
return `${humanize(flow)} / ${humanize(step)}`;
}
export function formatDateTime(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
export function humanize(value?: string | null): string {
if (!value) return "—";
return value.replace(/_/g, " ").replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
export function summaryValue(summary: Record<string, unknown> | null | undefined, keys: string[]): string | number {
if (!summary) return "—";
for (const key of keys) {
const value = summary[key];
if (typeof value === "number" || typeof value === "string") return value;
}
return "—";
}
export function getString(record: Record<string, unknown>, key: string, fallback = "—"): string {
const value = record[key];
if (typeof value === "string" && value.trim()) return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return fallback;
}
export function getNestedString(record: Record<string, unknown>, path: string[], fallback = "—"): string {
let current: unknown = record;
for (const part of path) {
if (!isRecord(current)) return fallback;
current = current[part];
}
if (typeof current === "string" && current.trim()) return current;
if (typeof current === "number" || typeof current === "boolean") return String(current);
if (Array.isArray(current)) return current.length ? current.join(", ") : fallback;
if (isRecord(current)) return stringifyPreview(current, 120);
return fallback;
}
export function stringifyPreview(value: unknown, maxLength = 220): string {
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? "";
return text.length > maxLength ? `${text.slice(0, maxLength)}` : text;
}
export function cloneCampaignJsonForCopy(
source: Record<string, unknown>,
campaign: CampaignListItem | null,
stamp: string
): { externalId: string; name: string; description: string; rawJson: Record<string, unknown> } {
const rawJson = JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
const campaignSection = asRecord(rawJson.campaign);
const baseId = String(campaignSection.id || campaign?.external_id || campaign?.id || "campaign");
const baseName = String(campaignSection.name || campaign?.name || "Campaign");
const description = String(campaignSection.description || campaign?.description || "");
const externalId = `${baseId}-copy-${stamp}`.replace(/[^a-zA-Z0-9_.-]+/g, "-").toLowerCase();
const name = `${baseName} (copy)`;
rawJson.campaign = {
...campaignSection,
id: externalId,
name,
description
};
return { externalId, name, description, rawJson };
}
export function timestampSlug(date = new Date()): string {
return date.toISOString().slice(0, 19).replace(/[-:T]/g, "");
}