Initial commit: Converge portfolio rationalization app
Next.js app for mapping a customer's competitive tool landscape onto our portfolio and building the consolidation business case: assessment workbench, customer report and Word export, product catalog, competitive knowledge base, dossier import, shareable landscape questionnaires with a review inbox, tool-footprint and business-unit value analysis, and an implementation timeline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
238
src/lib/store.ts
Normal file
238
src/lib/store.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Assessment, AssessmentSummary, Catalog, KnowledgeBase, Survey } from "./types";
|
||||
import { DEFAULT_WEIGHTS, rollup } from "./scoring";
|
||||
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");
|
||||
|
||||
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,
|
||||
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 });
|
||||
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user