281 lines
7.7 KiB
TypeScript
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;
|
|
} |