237 lines
9.0 KiB
TypeScript
237 lines
9.0 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import type { CreatePlatformLeadSourceInput, PlatformLeadSourceAdminDto, UpdatePlatformLeadSourceInput } from "@hivegrid/db/platform-leads-admin.server";
|
|
|
|
export type PlatformLeadSourceBootstrapMode = "dry-run" | "apply";
|
|
|
|
export const PLATFORM_LEAD_INITIAL_SOURCE_CONFIGURATION = Object.freeze([
|
|
{ key: "hivegrid-website", displayName: "HiveGrid Website", currentWebsiteSource: true },
|
|
{ key: "hivegrid-legacy-cloud", displayName: "HiveGrid Cloud Legacy", currentWebsiteSource: false },
|
|
{ key: "lead-inbox-legacy", displayName: "Lead Inbox Legacy", currentWebsiteSource: false },
|
|
] as const);
|
|
|
|
type CreateAction = {
|
|
kind: "create";
|
|
key: string;
|
|
input: Omit<CreatePlatformLeadSourceInput, "actorAdminUserId" | "reason">;
|
|
};
|
|
|
|
type UpdateAction = {
|
|
kind: "update";
|
|
key: string;
|
|
input: Omit<UpdatePlatformLeadSourceInput, "actorAdminUserId" | "reason">;
|
|
};
|
|
|
|
type UnchangedAction = { kind: "unchanged"; key: string };
|
|
|
|
export type PlatformLeadSourceBootstrapAction = CreateAction | UpdateAction | UnchangedAction;
|
|
|
|
export type PlatformLeadSourceBootstrapPlan = {
|
|
hash: string;
|
|
actions: PlatformLeadSourceBootstrapAction[];
|
|
};
|
|
|
|
export type PlatformLeadSourceBootstrapService = {
|
|
createSource(input: CreatePlatformLeadSourceInput): Promise<PlatformLeadSourceAdminDto>;
|
|
updateSource(input: UpdatePlatformLeadSourceInput): Promise<PlatformLeadSourceAdminDto>;
|
|
};
|
|
|
|
export type PlatformLeadSourceBootstrapManifest = {
|
|
schemaVersion: 1;
|
|
runId: string;
|
|
mode: PlatformLeadSourceBootstrapMode;
|
|
startedAt: string;
|
|
finishedAt: string;
|
|
operatorBindingSha256: string;
|
|
planSha256: string;
|
|
expectedPlanSha256: string | null;
|
|
planVerified: boolean | null;
|
|
actions: Array<{ key: string; action: PlatformLeadSourceBootstrapAction["kind"] }>;
|
|
counts: { createPlanned: number; updatePlanned: number; unchanged: number; created: number; updated: number; failed: number };
|
|
outcome: "validated" | "applied" | "blocked" | "failed";
|
|
credentialsIssued: 0;
|
|
notificationsTriggered: 0;
|
|
};
|
|
|
|
export class PlatformLeadSourceBootstrapError extends Error {
|
|
public override readonly name = "PlatformLeadSourceBootstrapError";
|
|
|
|
public constructor(public readonly code: "INVALID_CONFIGURATION") {
|
|
super(code);
|
|
}
|
|
}
|
|
|
|
export function planPlatformLeadSourceBootstrap(
|
|
existingSources: readonly PlatformLeadSourceAdminDto[],
|
|
routingAdminUserId: string
|
|
): PlatformLeadSourceBootstrapPlan {
|
|
const routingUserId = requireIdentifier(routingAdminUserId);
|
|
const byKey = new Map<string, PlatformLeadSourceAdminDto>();
|
|
for (const source of existingSources) {
|
|
if (byKey.has(source.key)) throw new PlatformLeadSourceBootstrapError("INVALID_CONFIGURATION");
|
|
byKey.set(source.key, source);
|
|
}
|
|
|
|
const actions = PLATFORM_LEAD_INITIAL_SOURCE_CONFIGURATION.map<PlatformLeadSourceBootstrapAction>((desired) => {
|
|
const existing = byKey.get(desired.key);
|
|
if (!existing) {
|
|
return {
|
|
kind: "create",
|
|
key: desired.key,
|
|
input: {
|
|
key: desired.key,
|
|
displayName: desired.displayName,
|
|
enabled: desired.currentWebsiteSource,
|
|
defaultAssigneeAdminUserId: desired.currentWebsiteSource ? routingUserId : null,
|
|
notifyAdminUserIds: desired.currentWebsiteSource ? [routingUserId] : [],
|
|
rateLimitPerMinute: 60,
|
|
metadata: { managedBy: "R-LEADS" },
|
|
},
|
|
};
|
|
}
|
|
if (!desired.currentWebsiteSource) {
|
|
return existing.enabled
|
|
? { kind: "update", key: desired.key, input: { sourceId: existing.id, enabled: false } }
|
|
: { kind: "unchanged", key: desired.key };
|
|
}
|
|
|
|
const notifyAdminUserIds = [...new Set([...existing.notifyAdminUserIds, routingUserId])].sort();
|
|
const input: UpdateAction["input"] = { sourceId: existing.id };
|
|
if (!existing.enabled) input.enabled = true;
|
|
if (existing.defaultAssigneeAdminUserId !== routingUserId) input.defaultAssigneeAdminUserId = routingUserId;
|
|
if (!sameStringList(existing.notifyAdminUserIds, notifyAdminUserIds)) input.notifyAdminUserIds = notifyAdminUserIds;
|
|
if (Object.keys(input).length === 1) return { kind: "unchanged", key: desired.key };
|
|
return { kind: "update", key: desired.key, input };
|
|
});
|
|
|
|
return { hash: hashPlan(actions), actions };
|
|
}
|
|
|
|
export async function runPlatformLeadSourceBootstrap(input: {
|
|
mode: PlatformLeadSourceBootstrapMode;
|
|
runId: string;
|
|
actorAdminUserId: string;
|
|
routingAdminUserId: string;
|
|
existingSources: readonly PlatformLeadSourceAdminDto[];
|
|
expectedPlanSha256?: string;
|
|
service?: PlatformLeadSourceBootstrapService;
|
|
clock?: () => Date;
|
|
}): Promise<PlatformLeadSourceBootstrapManifest> {
|
|
const clock = input.clock ?? (() => new Date());
|
|
const startedAt = validDate(clock());
|
|
const actorAdminUserId = requireIdentifier(input.actorAdminUserId);
|
|
const routingAdminUserId = requireIdentifier(input.routingAdminUserId);
|
|
const runId = requireRunId(input.runId);
|
|
const plan = planPlatformLeadSourceBootstrap(input.existingSources, routingAdminUserId);
|
|
const expectedPlanSha256 = input.expectedPlanSha256?.trim().toLowerCase() ?? null;
|
|
const planVerified = expectedPlanSha256 === null ? null : plan.hash === expectedPlanSha256;
|
|
if (input.mode === "apply" && (!expectedPlanSha256 || !/^[a-f0-9]{64}$/u.test(expectedPlanSha256) || !input.service)) {
|
|
throw new PlatformLeadSourceBootstrapError("INVALID_CONFIGURATION");
|
|
}
|
|
|
|
let created = 0;
|
|
let updated = 0;
|
|
let failed = 0;
|
|
if (input.mode === "apply" && planVerified) {
|
|
for (const action of plan.actions) {
|
|
try {
|
|
if (action.kind === "create") {
|
|
await input.service!.createSource({
|
|
...action.input,
|
|
actorAdminUserId,
|
|
reason: "R-LEADS initial platform lead source bootstrap",
|
|
requestId: runId,
|
|
});
|
|
created += 1;
|
|
} else if (action.kind === "update") {
|
|
await input.service!.updateSource({
|
|
...action.input,
|
|
actorAdminUserId,
|
|
reason: "R-LEADS platform lead source routing bootstrap",
|
|
requestId: runId,
|
|
});
|
|
updated += 1;
|
|
}
|
|
} catch {
|
|
failed += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
const createPlanned = plan.actions.filter(({ kind }) => kind === "create").length;
|
|
const updatePlanned = plan.actions.filter(({ kind }) => kind === "update").length;
|
|
const unchanged = plan.actions.filter(({ kind }) => kind === "unchanged").length;
|
|
const outcome = input.mode === "dry-run" ? "validated" : planVerified === false ? "blocked" : failed > 0 ? "failed" : "applied";
|
|
return {
|
|
schemaVersion: 1,
|
|
runId,
|
|
mode: input.mode,
|
|
startedAt: startedAt.toISOString(),
|
|
finishedAt: validDate(clock()).toISOString(),
|
|
operatorBindingSha256: sha256(routingAdminUserId),
|
|
planSha256: plan.hash,
|
|
expectedPlanSha256,
|
|
planVerified,
|
|
actions: plan.actions.map(({ key, kind }) => ({ key, action: kind })),
|
|
counts: { createPlanned, updatePlanned, unchanged, created, updated, failed },
|
|
outcome,
|
|
credentialsIssued: 0,
|
|
notificationsTriggered: 0,
|
|
};
|
|
}
|
|
|
|
export function isSuccessfulPlatformLeadSourceBootstrapManifest(manifest: PlatformLeadSourceBootstrapManifest): boolean {
|
|
return manifest.outcome === "validated" || manifest.outcome === "applied";
|
|
}
|
|
|
|
function hashPlan(actions: readonly PlatformLeadSourceBootstrapAction[]): string {
|
|
return sha256(
|
|
JSON.stringify(
|
|
actions.map((action) =>
|
|
action.kind === "unchanged" ? action : { kind: action.kind, key: action.key, input: sortRecord(action.input as Record<string, unknown>) }
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
function sortRecord(record: Record<string, unknown>): Record<string, unknown> {
|
|
return Object.fromEntries(
|
|
Object.entries(record)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, value]) => [key, Array.isArray(value) ? [...value].sort() : value])
|
|
);
|
|
}
|
|
|
|
function sameStringList(left: readonly string[], right: readonly string[]): boolean {
|
|
if (left.length !== right.length) return false;
|
|
const sortedLeft = [...left].sort();
|
|
return sortedLeft.every((value, index) => value === right[index]);
|
|
}
|
|
|
|
function requireIdentifier(value: string): string {
|
|
if (typeof value !== "string") throw new PlatformLeadSourceBootstrapError("INVALID_CONFIGURATION");
|
|
const normalized = value.trim();
|
|
if (!normalized || normalized.length > 191 || !/^[A-Za-z0-9_-]+$/u.test(normalized)) {
|
|
throw new PlatformLeadSourceBootstrapError("INVALID_CONFIGURATION");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function requireRunId(value: string): string {
|
|
if (typeof value !== "string") throw new PlatformLeadSourceBootstrapError("INVALID_CONFIGURATION");
|
|
const normalized = value.trim();
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,190}$/u.test(normalized)) {
|
|
throw new PlatformLeadSourceBootstrapError("INVALID_CONFIGURATION");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function validDate(value: Date): Date {
|
|
if (!(value instanceof Date) || !Number.isFinite(value.getTime())) throw new PlatformLeadSourceBootstrapError("INVALID_CONFIGURATION");
|
|
return value;
|
|
}
|
|
|
|
function sha256(value: string): string {
|
|
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
}
|