248 lines
7.2 KiB
TypeScript
248 lines
7.2 KiB
TypeScript
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<void> {
|
|
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<MenuCategory[]> {
|
|
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<void> {
|
|
await ensureMenuFile();
|
|
await writeFile(MENU_DATA_FILE, JSON.stringify(categories, null, 2), 'utf8');
|
|
memoryCache = categories;
|
|
}
|
|
|
|
export async function writeMenuCategories(
|
|
categories: MenuCategory[],
|
|
options?: WriteMenuOptions,
|
|
): Promise<void> {
|
|
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<MenuItem>,
|
|
): Promise<MenuItem | null> {
|
|
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<MenuItem | null> {
|
|
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<boolean> {
|
|
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<MenuCategory | null> {
|
|
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<boolean> {
|
|
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<Pick<MenuCategory, 'name'>>,
|
|
): Promise<MenuCategory | null> {
|
|
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;
|
|
} |