import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import type { Assessment, AssessmentSummary, Branding, Catalog, KnowledgeBase, Snapshot, SnapshotMeta, Survey } from "./types"; import { DEFAULT_WEIGHTS, rollup } from "./scoring"; import { evaluate } from "./finance"; import { forgetAssessment, learnFromAssessment } from "./kb"; import { isSurveyToken } from "./survey"; const DATA_DIR = path.join(process.cwd(), "data"); const CATALOG_PATH = path.join(DATA_DIR, "catalog.json"); const KB_PATH = path.join(DATA_DIR, "knowledge-base.json"); const ASSESSMENT_DIR = path.join(DATA_DIR, "assessments"); const SURVEY_DIR = path.join(DATA_DIR, "surveys"); const SNAPSHOT_DIR = path.join(DATA_DIR, "snapshots"); const SETTINGS_PATH = path.join(DATA_DIR, "settings.json"); async function readJson(file: string): Promise { return JSON.parse(await fs.readFile(file, "utf8")) as T; } async function writeJson(file: string, value: unknown) { await fs.mkdir(path.dirname(file), { recursive: true }); // Write-then-rename so a crash mid-write can never truncate a saved assessment. const tmp = `${file}.${process.pid}.tmp`; await fs.writeFile(tmp, JSON.stringify(value, null, 2) + "\n", "utf8"); await fs.rename(tmp, file); } export async function getCatalog(): Promise { return readJson(CATALOG_PATH); } export async function saveCatalog(c: Catalog): Promise { c.updatedAt = new Date().toISOString().slice(0, 10); await writeJson(CATALOG_PATH, c); return c; } export async function getKnowledgeBase(): Promise { try { return await readJson(KB_PATH); } catch { return { version: 1, updatedAt: new Date().toISOString(), entries: [] }; } } export async function saveKnowledgeBase(kb: KnowledgeBase): Promise { kb.updatedAt = new Date().toISOString(); await writeJson(KB_PATH, kb); return kb; } /** Assessment ids become filenames, so they must not escape the assessments dir. */ export function isSafeId(id: string) { return /^[a-z0-9][a-z0-9-]{0,63}$/.test(id); } function assessmentPath(id: string) { if (!isSafeId(id)) throw new Error(`Invalid assessment id: ${id}`); return path.join(ASSESSMENT_DIR, `${id}.json`); } export async function listAssessments(): Promise { await fs.mkdir(ASSESSMENT_DIR, { recursive: true }); const files = (await fs.readdir(ASSESSMENT_DIR)).filter((f) => f.endsWith(".json")); const catalog = await getCatalog(); const out: AssessmentSummary[] = []; for (const f of files) { try { const a = await readJson(path.join(ASSESSMENT_DIR, f)); const r = rollup(a, catalog); out.push({ id: a.id, customerName: a.customerName, preparedBy: a.preparedBy, createdAt: a.createdAt, updatedAt: a.updatedAt, status: a.status ?? "open", candidateCount: a.candidates.length, annualSpend: r.addressableSpend, annualSavings: r.totalAnnualSavings, }); } catch { // Skip unreadable or hand-edited files rather than failing the whole list. } } return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } export async function getAssessment(id: string): Promise { try { return await readJson(assessmentPath(id)); } catch { return null; } } export async function saveAssessment(a: Assessment): Promise { a.updatedAt = new Date().toISOString(); await writeJson(assessmentPath(a.id), a); // Every save folds what the seller entered back into the knowledge base, so // the next assessment starts from real numbers. A failure here must never // lose the assessment itself, which is already on disk. try { const kb = await getKnowledgeBase(); const next = learnFromAssessment(kb, a); if (next) await saveKnowledgeBase(next); } catch (err) { console.error("Knowledge-base learning failed for", a.id, err); } return a; } export async function deleteAssessment(id: string) { await fs.rm(assessmentPath(id), { force: true }); for (const sv of await listSurveys(id)) await fs.rm(surveyPath(sv.id), { force: true }); await fs.rm(path.join(SNAPSHOT_DIR, id), { recursive: true, force: true }); try { const kb = await getKnowledgeBase(); const next = forgetAssessment(kb, id); if (next) await saveKnowledgeBase(next); } catch (err) { console.error("Knowledge-base cleanup failed for", id, err); } } export function slugify(s: string) { const base = s .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 40); return base || "assessment"; } export async function createAssessment(input: Partial): Promise { await fs.mkdir(ASSESSMENT_DIR, { recursive: true }); const customerName = (input.customerName || "New Customer").trim(); const base = slugify(customerName); let id = base; let n = 2; while (await getAssessment(id)) id = `${base}-${n++}`; const now = new Date().toISOString(); const a: Assessment = { id, version: 1, customerName, industry: input.industry ?? "", preparedBy: input.preparedBy ?? "", preparedFor: input.preparedFor ?? "", opportunityRef: input.opportunityRef ?? "", executiveSummary: input.executiveSummary ?? "", horizonYears: input.horizonYears ?? 3, weights: input.weights ?? { ...DEFAULT_WEIGHTS }, candidates: input.candidates ?? [], createdAt: now, updatedAt: now, }; await saveAssessment(a); return a; } /* ------------------------------------------------------------ questionnaires */ function surveyPath(id: string) { if (!isSurveyToken(id)) throw new Error("Invalid questionnaire id"); return path.join(SURVEY_DIR, `${id}.json`); } export async function getSurvey(id: string): Promise { if (!isSurveyToken(id)) return null; try { return await readJson(surveyPath(id)); } catch { return null; } } export async function listSurveys(assessmentId: string): Promise { await fs.mkdir(SURVEY_DIR, { recursive: true }); const out: Survey[] = []; for (const f of await fs.readdir(SURVEY_DIR)) { if (!f.endsWith(".json")) continue; try { const s = await readJson(path.join(SURVEY_DIR, f)); if (s.assessmentId === assessmentId) out.push(s); } catch { // A half-written or hand-edited file should not hide the others. } } return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } export async function createSurvey(assessmentId: string, respondentName: string, domains: string[]): Promise { const now = new Date().toISOString(); const s: Survey = { id: crypto.randomBytes(16).toString("hex"), assessmentId, respondentName, domains, answers: {}, status: "open", review: {}, createdAt: now, updatedAt: now, submittedAt: "", }; await writeJson(surveyPath(s.id), s); return s; } // The respondent and the seller both write the same file (answers vs. review // marks), so every change is a read-modify-write serialised per questionnaire. const surveyLocks = new Map>(); export async function mutateSurvey(id: string, fn: (s: Survey) => void | string): Promise { const prev = surveyLocks.get(id) ?? Promise.resolve(); const run = prev.catch(() => {}).then(async () => { const s = await getSurvey(id); if (!s) return null; const refusal = fn(s); if (typeof refusal === "string") return refusal; s.updatedAt = new Date().toISOString(); await writeJson(surveyPath(id), s); return s; }); surveyLocks.set(id, run); try { return await run; } finally { if (surveyLocks.get(id) === run) surveyLocks.delete(id); } } export async function deleteSurvey(id: string) { await fs.rm(surveyPath(id), { force: true }); } /* ------------------------------------------------------------ branding */ export const DEFAULT_BRANDING: Branding = { companyName: "", accent: "#005C8A", logo: "" }; export async function getBranding(): Promise { try { return { ...DEFAULT_BRANDING, ...(await readJson>(SETTINGS_PATH)) }; } catch { return { ...DEFAULT_BRANDING }; } } export async function saveBranding(b: Branding): Promise { await writeJson(SETTINGS_PATH, b); return b; } /* ------------------------------------------------------------ snapshots */ const snapshotDir = (assessmentId: string) => { if (!isSafeId(assessmentId)) throw new Error(`Invalid assessment id: ${assessmentId}`); return path.join(SNAPSHOT_DIR, assessmentId); }; export async function listSnapshots(assessmentId: string): Promise { const dir = snapshotDir(assessmentId); await fs.mkdir(dir, { recursive: true }); const out: SnapshotMeta[] = []; for (const f of await fs.readdir(dir)) { if (!f.endsWith(".json")) continue; try { const { assessment: _a, ...meta } = await readJson(path.join(dir, f)); out.push(meta); } catch { // Skip a damaged snapshot rather than hiding the rest. } } return out.sort((x, y) => y.createdAt.localeCompare(x.createdAt)); } export async function getSnapshot(assessmentId: string, id: string): Promise { if (!/^[a-z0-9-]{1,40}$/.test(id)) return null; try { return await readJson(path.join(snapshotDir(assessmentId), `${id}.json`)); } catch { return null; } } /** Freezes the assessment as it stands, with its headline, under a label. */ export async function createSnapshot(a: Assessment, label: string, trigger: SnapshotMeta["trigger"]): Promise { const catalog = await getCatalog(); const e = evaluate(a, catalog); const createdAt = new Date().toISOString(); const snap: Snapshot = { id: `${createdAt.replace(/[^0-9]/g, "").slice(0, 14)}-${crypto.randomBytes(3).toString("hex")}`, assessmentId: a.id, label: label.trim().slice(0, 120) || "Snapshot", trigger, createdAt, headline: { netAnnual: e.cash.runRateNet, horizonNet: e.cash.horizonNet, tools: e.r.toolsRetired }, assessment: structuredClone(a), }; await writeJson(path.join(snapshotDir(a.id), `${snap.id}.json`), snap); const { assessment: _a, ...meta } = snap; return meta; }