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 { await mkdir(VERSIONS_DIR, { recursive: true }); } async function readIndex(): Promise { 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 { await ensureVersionsDir(); await writeFile(INDEX_FILE, JSON.stringify(index, null, 2), 'utf8'); } async function writeSnapshotFile(snapshot: MenuVersionSnapshot): Promise { await ensureVersionsDir(); await writeFile(versionFilePath(snapshot.id), JSON.stringify(snapshot, null, 2), 'utf8'); } async function readSnapshotFile(id: string): Promise { 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 { 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 { 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 { 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 { const snapshot = await readSnapshotFile(versionId); return snapshot ? cloneCategories(snapshot.categories) : null; } export async function restoreMenuVersion( versionId: string, persist: (categories: MenuCategory[]) => Promise, readLive: () => Promise, ): Promise { 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 { 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 { 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; }