Files
Converge/src/lib/store.ts

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;
}