///
import {
type Cell,
cell,
Default,
handler,
lift,
recipe,
str,
} from "commontools";
type SignatureStatus = "pending" | "signed" | "declined";
interface SignerSeed {
id?: string;
name?: string;
role?: string;
email?: string;
status?: string;
signedAt?: string;
order?: number;
}
interface SignerEntry extends SignerSeed {
id: string;
name: string;
role: string;
email: string;
status: SignatureStatus;
order: number;
signedAt?: string;
}
interface DocumentSignatureWorkflowArgs {
documentTitle: Default;
signers: Default;
}
interface MarkSignedEvent {
id?: string;
signedAt?: string;
}
interface DeclineSignerEvent {
id?: string;
reason?: string;
}
interface ResetSignerEvent {
id?: string;
}
interface DocumentSignatureContext {
signers: Cell;
log: Cell;
}
const defaultDocumentTitle = "Master Services Agreement";
const defaultSigners: SignerSeed[] = [
{
id: "signer-legal",
name: "Amelia Edwards",
role: "Legal Counsel",
email: "amelia@firm.example",
status: "signed",
signedAt: "2024-07-01",
order: 1,
},
{
id: "signer-sales",
name: "Noah Chen",
role: "Account Executive",
email: "noah@commontools.example",
status: "pending",
order: 2,
},
{
id: "signer-client",
name: "Ravi Patel",
role: "Client CFO",
email: "ravi@client-co.example",
status: "pending",
order: 3,
},
];
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
const sanitizeIdentifier = (value: unknown, fallback: string): string => {
if (typeof value === "string") {
const trimmed = value.trim().toLowerCase();
const normalized = trimmed
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
if (normalized.length > 0) return normalized;
}
return fallback;
};
const sanitizeWhitespace = (value: unknown, fallback: string): string => {
if (typeof value !== "string") return fallback;
const trimmed = value.trim().replace(/\s+/g, " ");
return trimmed.length > 0 ? trimmed : fallback;
};
const sanitizeName = (value: unknown, fallback: string): string => {
const cleaned = sanitizeWhitespace(value, fallback);
return cleaned
.split(" ")
.map((segment) =>
segment.length > 0
? segment[0].toUpperCase() + segment.slice(1).toLowerCase()
: segment
)
.join(" ");
};
const sanitizeRole = (value: unknown, fallback: string): string => {
const cleaned = sanitizeWhitespace(value, fallback);
return cleaned.length > 0 ? cleaned : fallback;
};
const sanitizeEmail = (value: unknown, fallback: string): string => {
if (typeof value === "string") {
const trimmed = value.trim().toLowerCase();
if (trimmed.includes("@") && trimmed.split("@").length === 2) {
return trimmed;
}
}
return fallback;
};
const sanitizeStatus = (
value: unknown,
fallback: SignatureStatus,
): SignatureStatus => {
if (value === "pending" || value === "signed" || value === "declined") {
return value;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (
normalized === "pending" ||
normalized === "signed" ||
normalized === "declined"
) {
return normalized as SignatureStatus;
}
}
return fallback;
};
const sanitizeOrder = (value: unknown, fallback: number): number => {
if (typeof value === "number" && Number.isFinite(value)) {
const integer = Math.trunc(value);
return integer < 1 ? 1 : integer;
}
return fallback < 1 ? 1 : Math.trunc(fallback);
};
const sanitizeDate = (value: unknown, fallback: string): string => {
if (typeof value === "string") {
const normalized = value.trim().replace(/\//g, "-");
if (datePattern.test(normalized)) return normalized;
}
return fallback;
};
const sanitizeReason = (value: unknown): string => {
if (typeof value !== "string") return "";
const trimmed = value.trim().replace(/\s+/g, " ");
return trimmed.length > 0 ? trimmed : "";
};
const sanitizeTitle = (
value: unknown,
fallback: string = defaultDocumentTitle,
): string => {
const cleaned = sanitizeWhitespace(value, fallback);
return cleaned.length > 0 ? cleaned : fallback;
};
const ensureUniqueId = (candidate: string, used: Set): string => {
if (!used.has(candidate)) {
used.add(candidate);
return candidate;
}
let index = 2;
let id = `${candidate}-${index}`;
while (used.has(id)) {
index += 1;
id = `${candidate}-${index}`;
}
used.add(id);
return id;
};
const defaultSignedDate = (order: number): string => {
const day = ((order - 1) % 27) + 1;
return `2024-07-${String(day).padStart(2, "0")}`;
};
const sanitizeSigner = (
seed: SignerSeed | undefined,
fallback: SignerSeed,
index: number,
used: Set,
): SignerEntry => {
const fallbackId = typeof fallback.id === "string" && fallback.id.length > 0
? fallback.id
: `signer-${index + 1}`;
const id = ensureUniqueId(
sanitizeIdentifier(seed?.id, fallbackId),
used,
);
const fallbackName = sanitizeName(
fallback.name,
`Signer ${index + 1}`,
);
const name = sanitizeName(seed?.name, fallbackName);
const fallbackRole = sanitizeRole(fallback.role, "Signer");
const role = sanitizeRole(seed?.role, fallbackRole);
const fallbackEmail = sanitizeEmail(
fallback.email,
`${id}@signature.local`,
);
const email = sanitizeEmail(seed?.email, fallbackEmail);
const fallbackOrder = sanitizeOrder(fallback.order, index + 1);
const order = sanitizeOrder(seed?.order, fallbackOrder);
const fallbackStatus = sanitizeStatus(fallback.status, "pending");
const status = sanitizeStatus(seed?.status, fallbackStatus);
const fallbackSignedAt = sanitizeDate(
fallback.signedAt,
defaultSignedDate(order),
);
const signedAt = status === "signed"
? sanitizeDate(seed?.signedAt, fallbackSignedAt)
: undefined;
return {
id,
name,
role,
email,
status,
order,
signedAt,
};
};
const sortSigners = (entries: readonly SignerEntry[]): SignerEntry[] => {
return entries.slice().sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
return a.name.localeCompare(b.name);
});
};
const sanitizeSignerList = (
value: readonly SignerSeed[] | undefined,
): SignerEntry[] => {
const seeds = Array.isArray(value) && value.length > 0
? value
: defaultSigners;
const used = new Set();
const entries: SignerEntry[] = [];
for (let index = 0; index < seeds.length; index += 1) {
const fallback = defaultSigners[index % defaultSigners.length];
const entry = sanitizeSigner(seeds[index], fallback, index, used);
entries.push(entry);
}
return sortSigners(entries);
};
const appendHistory = (
history: readonly string[],
entry: string,
): string[] => {
const next = [...history, entry];
return next.length > 6 ? next.slice(next.length - 6) : next;
};
const getSanitizedSigners = (
context: DocumentSignatureContext,
): SignerEntry[] => {
return sanitizeSignerList(context.signers.get());
};
const storeSigners = (
context: DocumentSignatureContext,
entries: readonly SignerEntry[],
) => {
context.signers.set(entries.map((entry) => ({ ...entry })));
};
const markSignerSigned = handler(
(event: MarkSignedEvent | undefined, context: DocumentSignatureContext) => {
const id = sanitizeIdentifier(event?.id, "");
if (id.length === 0) return;
const current = getSanitizedSigners(context);
const index = current.findIndex((entry) => entry.id === id);
if (index === -1) return;
const entry = current[index];
if (entry.status === "signed") return;
const signedAt = sanitizeDate(
event?.signedAt,
defaultSignedDate(entry.order),
);
const next = current.slice();
next[index] = { ...entry, status: "signed", signedAt };
storeSigners(context, sortSigners(next));
const message = `${entry.name} (${entry.role}) signed on ${signedAt}`;
context.log.set(appendHistory(context.log.get() ?? [], message));
},
);
const markSignerDeclined = handler(
(
event: DeclineSignerEvent | undefined,
context: DocumentSignatureContext,
) => {
const id = sanitizeIdentifier(event?.id, "");
if (id.length === 0) return;
const current = getSanitizedSigners(context);
const index = current.findIndex((entry) => entry.id === id);
if (index === -1) return;
const entry = current[index];
if (entry.status === "declined") return;
const reason = sanitizeReason(event?.reason);
const next = current.slice();
next[index] = { ...entry, status: "declined", signedAt: undefined };
storeSigners(context, sortSigners(next));
const suffix = reason.length > 0 ? ` (${reason})` : "";
const message = `${entry.name} (${entry.role}) declined${suffix}`;
context.log.set(appendHistory(context.log.get() ?? [], message));
},
);
const resetSigner = handler(
(event: ResetSignerEvent | undefined, context: DocumentSignatureContext) => {
const id = sanitizeIdentifier(event?.id, "");
if (id.length === 0) return;
const current = getSanitizedSigners(context);
const index = current.findIndex((entry) => entry.id === id);
if (index === -1) return;
const entry = current[index];
if (entry.status === "pending" && entry.signedAt === undefined) {
return;
}
const next = current.slice();
next[index] = { ...entry, status: "pending", signedAt: undefined };
storeSigners(context, sortSigners(next));
const message = `${entry.name} reset to pending`;
context.log.set(appendHistory(context.log.get() ?? [], message));
},
);
export const documentSignatureWorkflow = recipe(
"Document Signature Workflow",
({ documentTitle, signers }) => {
const logEntries = cell([]);
const titleView = lift((raw: string | undefined) => sanitizeTitle(raw))(
documentTitle,
);
const orderedSigners = lift(sanitizeSignerList)(signers);
const orderedSignersView = lift((entries: SignerEntry[]) =>
entries.map((entry) => ({
id: entry.id,
name: entry.name,
role: entry.role,
email: entry.email,
status: entry.status,
order: entry.order,
signedAt: entry.signedAt ?? null,
}))
)(orderedSigners);
const outstandingEntries = lift((entries: SignerEntry[]) =>
entries.filter((entry) => entry.status !== "signed")
)(orderedSigners);
const outstandingSignersView = lift((entries: SignerEntry[]) =>
entries.map((entry) => ({
id: entry.id,
name: entry.name,
role: entry.role,
status: entry.status,
order: entry.order,
}))
)(outstandingEntries);
const outstandingSummary = lift((entries: SignerEntry[]) => {
if (entries.length === 0) return "All signers completed";
return entries
.map((entry) =>
`${entry.order}. ${entry.name} (${entry.role}) - ${entry.status}`
)
.join(" | ");
})(outstandingEntries);
const totalCount = lift((entries: SignerEntry[]) => entries.length)(
orderedSigners,
);
const completedCount = lift((entries: SignerEntry[]) =>
entries.filter((entry) => entry.status === "signed").length
)(orderedSigners);
const outstandingCount = lift((entries: SignerEntry[]) =>
entries.filter((entry) => entry.status !== "signed").length
)(orderedSigners);
const nextSigner = lift((entries: SignerEntry[]) => {
for (const entry of entries) {
if (entry.status === "pending") return entry;
}
return null;
})(orderedSigners);
const nextSignerView = lift((entry: SignerEntry | null) => {
if (!entry) return null;
return {
id: entry.id,
name: entry.name,
role: entry.role,
email: entry.email,
order: entry.order,
};
})(nextSigner);
const statusLine = lift((input: {
title: string;
next: SignerEntry | null;
outstanding: number;
}) => {
if (input.outstanding === 0) {
return `${input.title}: all signatures collected`;
}
if (input.next) {
return `${input.title}: next ${input.next.name} (${input.next.role}); ` +
`${input.outstanding} outstanding`;
}
return `${input.title}: ${input.outstanding} outstanding signatures`;
})({
title: titleView,
next: nextSigner,
outstanding: outstandingCount,
});
const counts = lift((input: {
total: number;
completed: number;
outstanding: number;
}) => input)({
total: totalCount,
completed: completedCount,
outstanding: outstandingCount,
});
const completionPercent = lift((input: {
total: number;
completed: number;
}) => {
if (input.total === 0) return 0;
return Math.round((input.completed / input.total) * 100);
})({ total: totalCount, completed: completedCount });
const progressLabel = str`${completionPercent}% complete for ${titleView}`;
const activityLog = lift((input: {
title: string;
entries: string[];
}) => {
const initial = `Signature packet prepared for ${input.title}`;
const combined = [initial, ...input.entries];
return combined.length > 6
? combined.slice(combined.length - 6)
: combined;
})({ title: titleView, entries: logEntries });
const context: DocumentSignatureContext = {
signers: signers as unknown as Cell,
log: logEntries as unknown as Cell,
};
return {
title: titleView,
orderedSigners: orderedSignersView,
outstandingSigners: outstandingSignersView,
outstandingSummary,
nextSigner: nextSignerView,
counts,
completionPercent,
statusLine,
progressLabel,
activityLog,
markSigned: markSignerSigned(context as never),
markDeclined: markSignerDeclined(context as never),
resetSigner: resetSigner(context as never),
};
},
);