import { mkdir, readFile, writeFile } from 'fs/promises'; import path from 'path'; import type { MenuCategory, MenuItem } from '@/domain/menu/entities'; import type { MenuVersionTrigger } from '@/domain/menu/versioning'; import { menuCategories as defaultMenuCategories } from './static-menu-data'; const MENU_DATA_DIR = path.join(process.cwd(), 'data'); const MENU_DATA_FILE = path.join(MENU_DATA_DIR, 'menu.json'); let memoryCache: MenuCategory[] | null = null; async function ensureMenuFile(): Promise { await mkdir(MENU_DATA_DIR, { recursive: true }); try { await readFile(MENU_DATA_FILE, 'utf8'); } catch { await writeFile(MENU_DATA_FILE, JSON.stringify(defaultMenuCategories, null, 2), 'utf8'); } } 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), ) ); } export async function readMenuCategories(): Promise { if (memoryCache) return memoryCache; await ensureMenuFile(); const raw = await readFile(MENU_DATA_FILE, 'utf8'); const parsed = JSON.parse(raw) as unknown; if (!isMenuCategoryArray(parsed)) { memoryCache = defaultMenuCategories; return memoryCache; } memoryCache = parsed; return memoryCache; } export interface WriteMenuOptions { snapshotLabel?: string; snapshotTrigger?: MenuVersionTrigger; skipSnapshot?: boolean; } export async function persistMenuCategories(categories: MenuCategory[]): Promise { await ensureMenuFile(); await writeFile(MENU_DATA_FILE, JSON.stringify(categories, null, 2), 'utf8'); memoryCache = categories; } export async function writeMenuCategories( categories: MenuCategory[], options?: WriteMenuOptions, ): Promise { await persistMenuCategories(categories); if (!options?.skipSnapshot) { const { createMenuSnapshot, ensureMenuBaseline } = await import('./menu-versioning'); await ensureMenuBaseline(); await createMenuSnapshot( categories, options?.snapshotLabel ?? 'Menu updated', options?.snapshotTrigger ?? 'auto', ); } } export async function updateMenuItem( categoryId: string, itemId: string, updates: Partial, ): Promise { const categories = await readMenuCategories(); let updatedItem: MenuItem | null = null; const nextCategories = categories.map((category) => { if (category.id !== categoryId) return category; return { ...category, items: category.items.map((item) => { if (item.id !== itemId) return item; updatedItem = { ...item, ...updates, id: item.id }; return updatedItem; }), }; }); if (!updatedItem) return null; const dishName = nextCategories .find((category) => category.id === categoryId) ?.items.find((item) => item.id === itemId)?.name ?? itemId; await writeMenuCategories(nextCategories, { snapshotLabel: `Updated dish: ${dishName}`, }); return updatedItem; } export function slugifyMenuId(text: string): string { return text .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } function allItems(categories: MenuCategory[]): MenuItem[] { return categories.flatMap((category) => category.items); } function isItemIdTaken(categories: MenuCategory[], itemId: string): boolean { return allItems(categories).some((item) => item.id === itemId); } function uniqueItemId(categories: MenuCategory[], baseId: string): string { if (!isItemIdTaken(categories, baseId)) return baseId; let suffix = 2; while (isItemIdTaken(categories, `${baseId}-${suffix}`)) suffix += 1; return `${baseId}-${suffix}`; } export async function addMenuItem(categoryId: string, item: MenuItem): Promise { const categories = await readMenuCategories(); const categoryExists = categories.some((category) => category.id === categoryId); if (!categoryExists) return null; const id = uniqueItemId(categories, slugifyMenuId(item.id || item.name)); if (!id) return null; const newItem: MenuItem = { ...item, id }; const nextCategories = categories.map((category) => category.id === categoryId ? { ...category, items: [...category.items, newItem] } : category, ); await writeMenuCategories(nextCategories, { snapshotLabel: `Added dish: ${newItem.name}`, }); return newItem; } export async function removeMenuItem(categoryId: string, itemId: string): Promise { const categories = await readMenuCategories(); let removed = false; const nextCategories = categories.map((category) => { if (category.id !== categoryId) return category; const nextItems = category.items.filter((item) => { if (item.id === itemId) { removed = true; return false; } return true; }); return { ...category, items: nextItems }; }); if (!removed) return false; const removedName = categories .find((category) => category.id === categoryId) ?.items.find((item) => item.id === itemId)?.name ?? itemId; await writeMenuCategories(nextCategories, { snapshotLabel: `Removed dish: ${removedName}`, }); return true; } export async function addMenuCategory(category: MenuCategory): Promise { const categories = await readMenuCategories(); const id = slugifyMenuId(category.id || category.name); if (!id) return null; if (categories.some((existing) => existing.id === id)) return null; const newCategory: MenuCategory = { id, name: category.name.trim(), items: category.items ?? [], }; await writeMenuCategories([...categories, newCategory], { snapshotLabel: `Added category: ${newCategory.name}`, }); return newCategory; } export async function removeMenuCategory(categoryId: string): Promise { const categories = await readMenuCategories(); if (!categories.some((category) => category.id === categoryId)) return false; const removedCategory = categories.find((category) => category.id === categoryId); const nextCategories = categories.filter((category) => category.id !== categoryId); await writeMenuCategories(nextCategories, { snapshotLabel: `Removed category: ${removedCategory?.name ?? categoryId}`, }); return true; } export async function updateMenuCategory( categoryId: string, updates: Partial>, ): Promise { const categories = await readMenuCategories(); let updatedCategory: MenuCategory | null = null; const nextCategories = categories.map((category) => { if (category.id !== categoryId) return category; updatedCategory = { ...category, ...(typeof updates.name === 'string' ? { name: updates.name.trim() } : {}), }; return updatedCategory; }); if (!updatedCategory) return null; const categoryName = nextCategories.find((category) => category.id === categoryId)?.name ?? categoryId; await writeMenuCategories(nextCategories, { snapshotLabel: `Updated category: ${categoryName}`, }); return updatedCategory; } export function clearMenuCache(): void { memoryCache = null; }