Files
shahikitchen-prod/infrastructure/menu/menu-versioning.ts
T

281 lines
7.7 KiB
TypeScript

import { mkdir, readFile, writeFile, unlink } from 'fs/promises';
import path from 'path';
import type { MenuCategory } from '@/domain/menu/entities';
import {
MENU_BASELINE_VERSION_ID,
type MenuVersionListItem,
type MenuVersionMeta,
type MenuVersionSnapshot,
type MenuVersionTrigger,
} from '@/domain/menu/versioning';
import { menuCategories as defaultMenuCategories } from './static-menu-data';
const VERSIONS_DIR = path.join(process.cwd(), 'data', 'menu-versions');
const INDEX_FILE = path.join(VERSIONS_DIR, 'index.json');
const BASELINE_FILE = path.join(VERSIONS_DIR, 'baseline.json');
interface VersionIndex {
versions: MenuVersionMeta[];
}
function versionFilePath(id: string): string {
return id === MENU_BASELINE_VERSION_ID
? BASELINE_FILE
: path.join(VERSIONS_DIR, `${id}.json`);
}
function isMenuCategoryArray(value: unknown): value is MenuCategory[] {
return (
Array.isArray(value) &&
value.every(
(category) =>
typeof category === 'object' &&
category !== null &&
typeof (category as MenuCategory).id === 'string' &&
Array.isArray((category as MenuCategory).items),
)
);
}
function countMenuStats(categories: MenuCategory[]): { categoryCount: number; dishCount: number } {
return {
categoryCount: categories.length,
dishCount: categories.reduce((sum, category) => sum + category.items.length, 0),
};
}
function menuFingerprint(categories: MenuCategory[]): string {
return JSON.stringify(categories);
}
function cloneCategories(categories: MenuCategory[]): MenuCategory[] {
return JSON.parse(JSON.stringify(categories)) as MenuCategory[];
}
async function ensureVersionsDir(): Promise<void> {
await mkdir(VERSIONS_DIR, { recursive: true });
}
async function readIndex(): Promise<VersionIndex> {
await ensureVersionsDir();
try {
const raw = await readFile(INDEX_FILE, 'utf8');
const parsed = JSON.parse(raw) as VersionIndex;
if (!Array.isArray(parsed.versions)) return { versions: [] };
return parsed;
} catch {
return { versions: [] };
}
}
async function writeIndex(index: VersionIndex): Promise<void> {
await ensureVersionsDir();
await writeFile(INDEX_FILE, JSON.stringify(index, null, 2), 'utf8');
}
async function writeSnapshotFile(snapshot: MenuVersionSnapshot): Promise<void> {
await ensureVersionsDir();
await writeFile(versionFilePath(snapshot.id), JSON.stringify(snapshot, null, 2), 'utf8');
}
async function readSnapshotFile(id: string): Promise<MenuVersionSnapshot | null> {
try {
const raw = await readFile(versionFilePath(id), 'utf8');
const parsed = JSON.parse(raw) as MenuVersionSnapshot;
if (!isMenuCategoryArray(parsed.categories)) return null;
return parsed;
} catch {
return null;
}
}
function createVersionId(): string {
return `v-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export async function ensureMenuBaseline(): Promise<MenuVersionMeta> {
await ensureVersionsDir();
const index = await readIndex();
const existing = index.versions.find((version) => version.id === MENU_BASELINE_VERSION_ID);
if (existing) return existing;
const categories = cloneCategories(defaultMenuCategories);
const stats = countMenuStats(categories);
const createdAt = new Date().toISOString();
const baseline: MenuVersionSnapshot = {
id: MENU_BASELINE_VERSION_ID,
label: 'Original menu (baseline)',
createdAt,
trigger: 'auto',
categories,
isBaseline: true,
};
await writeSnapshotFile(baseline);
const meta: MenuVersionMeta = {
id: MENU_BASELINE_VERSION_ID,
label: baseline.label,
createdAt,
trigger: 'auto',
isBaseline: true,
...stats,
};
index.versions.push(meta);
await writeIndex(index);
return meta;
}
export async function createMenuSnapshot(
categories: MenuCategory[],
label: string,
trigger: MenuVersionTrigger = 'auto',
): Promise<MenuVersionMeta> {
await ensureMenuBaseline();
const index = await readIndex();
const id = createVersionId();
const createdAt = new Date().toISOString();
const stats = countMenuStats(categories);
const snapshot: MenuVersionSnapshot = {
id,
label: label.trim() || 'Menu updated',
createdAt,
trigger,
categories: cloneCategories(categories),
isBaseline: false,
};
await writeSnapshotFile(snapshot);
const meta: MenuVersionMeta = {
id,
label: snapshot.label,
createdAt,
trigger,
isBaseline: false,
...stats,
};
index.versions.unshift(meta);
await writeIndex(index);
return meta;
}
export async function listMenuVersions(liveCategories: MenuCategory[]): Promise<MenuVersionListItem[]> {
await ensureMenuBaseline();
const index = await readIndex();
const liveHash = menuFingerprint(liveCategories);
const items: MenuVersionListItem[] = [];
for (const meta of index.versions) {
const snapshot = await readSnapshotFile(meta.id);
const matchesLive = snapshot ? menuFingerprint(snapshot.categories) === liveHash : false;
items.push({ ...meta, matchesLive });
}
const baseline = items.find((item) => item.isBaseline);
const nonBaseline = items.filter((item) => !item.isBaseline);
nonBaseline.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return baseline ? [...nonBaseline, baseline] : nonBaseline;
}
export async function getMenuVersionCategories(versionId: string): Promise<MenuCategory[] | null> {
const snapshot = await readSnapshotFile(versionId);
return snapshot ? cloneCategories(snapshot.categories) : null;
}
export async function restoreMenuVersion(
versionId: string,
persist: (categories: MenuCategory[]) => Promise<void>,
readLive: () => Promise<MenuCategory[]>,
): Promise<MenuVersionMeta> {
const snapshot = await readSnapshotFile(versionId);
if (!snapshot) {
throw new Error('Version not found.');
}
const current = await readLive();
await createMenuSnapshot(
current,
`Before rollback to "${snapshot.label}"`,
'restore',
);
await persist(snapshot.categories);
return {
id: snapshot.id,
label: snapshot.label,
createdAt: snapshot.createdAt,
trigger: snapshot.trigger,
isBaseline: snapshot.isBaseline,
...countMenuStats(snapshot.categories),
};
}
export async function resetMenuVersionHistory(categories: MenuCategory[]): Promise<MenuVersionMeta> {
await ensureVersionsDir();
const index = await readIndex();
for (const version of index.versions) {
if (version.id === MENU_BASELINE_VERSION_ID) continue;
try {
await unlink(versionFilePath(version.id));
} catch {
// File may already be missing.
}
}
const stats = countMenuStats(categories);
const createdAt = new Date().toISOString();
const baseline: MenuVersionSnapshot = {
id: MENU_BASELINE_VERSION_ID,
label: 'Current menu (baseline)',
createdAt,
trigger: 'manual',
categories: cloneCategories(categories),
isBaseline: true,
};
await writeSnapshotFile(baseline);
const meta: MenuVersionMeta = {
id: MENU_BASELINE_VERSION_ID,
label: baseline.label,
createdAt,
trigger: 'manual',
isBaseline: true,
...stats,
};
index.versions = [meta];
await writeIndex(index);
return meta;
}
export async function deleteMenuVersion(versionId: string): Promise<boolean> {
if (versionId === MENU_BASELINE_VERSION_ID) {
throw new Error('The baseline version cannot be deleted.');
}
const index = await readIndex();
const nextVersions = index.versions.filter((version) => version.id !== versionId);
if (nextVersions.length === index.versions.length) return false;
index.versions = nextVersions;
await writeIndex(index);
try {
await unlink(versionFilePath(versionId));
} catch {
// File may already be missing; index update is sufficient.
}
return true;
}