225 lines
8.9 KiB
TypeScript
225 lines
8.9 KiB
TypeScript
import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui";
|
|
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 {
|
|
return formatPlatformDateTime(value);
|
|
}
|
|
|
|
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, "");
|
|
}
|