312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
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<T>(file: string): Promise<T> {
|
|
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<Catalog> {
|
|
return readJson<Catalog>(CATALOG_PATH);
|
|
}
|
|
|
|
export async function saveCatalog(c: Catalog): Promise<Catalog> {
|
|
c.updatedAt = new Date().toISOString().slice(0, 10);
|
|
await writeJson(CATALOG_PATH, c);
|
|
return c;
|
|
}
|
|
|
|
export async function getKnowledgeBase(): Promise<KnowledgeBase> {
|
|
try {
|
|
return await readJson<KnowledgeBase>(KB_PATH);
|
|
} catch {
|
|
return { version: 1, updatedAt: new Date().toISOString(), entries: [] };
|
|
}
|
|
}
|
|
|
|
export async function saveKnowledgeBase(kb: KnowledgeBase): Promise<KnowledgeBase> {
|
|
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<AssessmentSummary[]> {
|
|
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<Assessment>(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<Assessment | null> {
|
|
try {
|
|
return await readJson<Assessment>(assessmentPath(id));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function saveAssessment(a: Assessment): Promise<Assessment> {
|
|
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<Assessment>): Promise<Assessment> {
|
|
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<Survey | null> {
|
|
if (!isSurveyToken(id)) return null;
|
|
try {
|
|
return await readJson<Survey>(surveyPath(id));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function listSurveys(assessmentId: string): Promise<Survey[]> {
|
|
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<Survey>(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<Survey> {
|
|
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<string, Promise<unknown>>();
|
|
|
|
export async function mutateSurvey(id: string, fn: (s: Survey) => void | string): Promise<Survey | string | null> {
|
|
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<Branding> {
|
|
try {
|
|
return { ...DEFAULT_BRANDING, ...(await readJson<Partial<Branding>>(SETTINGS_PATH)) };
|
|
} catch {
|
|
return { ...DEFAULT_BRANDING };
|
|
}
|
|
}
|
|
|
|
export async function saveBranding(b: Branding): Promise<Branding> {
|
|
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<SnapshotMeta[]> {
|
|
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<Snapshot>(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<Snapshot | null> {
|
|
if (!/^[a-z0-9-]{1,40}$/.test(id)) return null;
|
|
try {
|
|
return await readJson<Snapshot>(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<SnapshotMeta> {
|
|
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;
|
|
}
|