Replace entire repo content with code from /root/shahikitchen-google/

This commit is contained in:
root
2026-07-03 02:51:15 +00:00
parent c8b7dc95e4
commit 8ccf033329
79 changed files with 7611 additions and 439 deletions
+51
View File
@@ -0,0 +1,51 @@
import { createHmac, timingSafeEqual } from 'crypto';
import { cookies } from 'next/headers';
export const ADMIN_SESSION_COOKIE = 'shahi_admin_session';
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
function getSessionSecret(): string {
return process.env.ADMIN_SESSION_SECRET ?? 'shahi-kitchen-admin-dev-secret';
}
export function createAdminSessionToken(): string {
const issuedAt = Date.now().toString();
const payload = `admin:${issuedAt}`;
const signature = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
return `${payload}.${signature}`;
}
export function verifyAdminSessionToken(token: string | undefined | null): boolean {
if (!token) return false;
const [payload, signature] = token.split('.');
if (!payload || !signature) return false;
const expected = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
const sigBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expected);
if (sigBuffer.length !== expectedBuffer.length) return false;
if (!timingSafeEqual(sigBuffer, expectedBuffer)) return false;
const [, issuedAtRaw] = payload.split(':');
const issuedAt = Number(issuedAtRaw);
if (!Number.isFinite(issuedAt)) return false;
return Date.now() - issuedAt <= SESSION_MAX_AGE_MS;
}
export async function isAdminAuthenticated(): Promise<boolean> {
const cookieStore = await cookies();
const token = cookieStore.get(ADMIN_SESSION_COOKIE)?.value;
return verifyAdminSessionToken(token);
}
export function getAdminSessionCookieOptions() {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_MAX_AGE_MS / 1000,
};
}
+87
View File
@@ -0,0 +1,87 @@
import { createHmac, timingSafeEqual } from 'crypto';
import { cookies } from 'next/headers';
export const CUSTOMER_SESSION_COOKIE = 'shahi_customer_session';
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
export interface CustomerSession {
email: string;
name?: string;
}
function getSessionSecret(): string {
return process.env.CUSTOMER_SESSION_SECRET ?? 'shahi-kitchen-customer-dev-secret';
}
function encodeEmail(email: string): string {
return Buffer.from(email, 'utf8').toString('base64url');
}
function decodeEmail(encoded: string): string | null {
try {
return Buffer.from(encoded, 'base64url').toString('utf8');
} catch {
return null;
}
}
export function createCustomerSessionToken(session: CustomerSession): string {
const issuedAt = Date.now().toString();
const namePart = session.name ? `:${encodeEmail(session.name)}` : '';
const payload = `customer:${encodeEmail(session.email)}${namePart}:${issuedAt}`;
const signature = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
return `${payload}.${signature}`;
}
export function verifyCustomerSessionToken(token: string | undefined | null): CustomerSession | null {
if (!token) return null;
const [payload, signature] = token.split('.');
if (!payload || !signature) return null;
const expected = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
const sigBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expected);
if (sigBuffer.length !== expectedBuffer.length) return null;
if (!timingSafeEqual(sigBuffer, expectedBuffer)) return null;
const parts = payload.split(':');
if (parts[0] !== 'customer' || parts.length < 3) return null;
const issuedAt = Number(parts[parts.length - 1]);
if (!Number.isFinite(issuedAt) || Date.now() - issuedAt > SESSION_MAX_AGE_MS) return null;
const email = decodeEmail(parts[1]);
if (!email) return null;
let name: string | undefined;
if (parts.length === 4) {
name = decodeEmail(parts[2]) ?? undefined;
}
return { email, name };
}
export async function getCustomerSession(): Promise<CustomerSession | null> {
const cookieStore = await cookies();
const token = cookieStore.get(CUSTOMER_SESSION_COOKIE)?.value;
return verifyCustomerSessionToken(token);
}
export function getCustomerSessionCookieOptions() {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_MAX_AGE_MS / 1000,
};
}
export function getClearedCustomerSessionCookieOptions() {
return {
...getCustomerSessionCookieOptions(),
maxAge: 0,
expires: new Date(0),
};
}
+95
View File
@@ -0,0 +1,95 @@
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo';
export function isGoogleOAuthConfigured(): boolean {
return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
}
export function getSiteOrigin(): string {
return process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
}
export function getGoogleRedirectUri(): string {
return `${getSiteOrigin()}/api/auth/callback/google`;
}
function getOAuthStateSecret(): string {
return process.env.CUSTOMER_SESSION_SECRET ?? 'shahi-kitchen-customer-dev-secret';
}
export function createOAuthState(): string {
const nonce = randomBytes(16).toString('hex');
const signature = createHmac('sha256', getOAuthStateSecret()).update(nonce).digest('hex');
return `${nonce}.${signature}`;
}
export function verifyOAuthState(state: string | null | undefined): boolean {
if (!state) return false;
const [nonce, signature] = state.split('.');
if (!nonce || !signature) return false;
const expected = createHmac('sha256', getOAuthStateSecret()).update(nonce).digest('hex');
const sigBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expected);
if (sigBuffer.length !== expectedBuffer.length) return false;
return timingSafeEqual(sigBuffer, expectedBuffer);
}
export function buildGoogleAuthUrl(state: string): string {
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
redirect_uri: getGoogleRedirectUri(),
response_type: 'code',
scope: 'openid email profile',
access_type: 'online',
prompt: 'select_account',
state,
});
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
}
interface GoogleTokenResponse {
access_token?: string;
error?: string;
}
interface GoogleUserInfo {
email?: string;
email_verified?: boolean;
name?: string;
}
export async function fetchGoogleUserFromCode(
code: string,
): Promise<{ email: string; name?: string } | null> {
const tokenResponse = await fetch(GOOGLE_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
redirect_uri: getGoogleRedirectUri(),
grant_type: 'authorization_code',
}),
});
const tokenData = (await tokenResponse.json()) as GoogleTokenResponse;
if (!tokenResponse.ok || !tokenData.access_token) return null;
const userResponse = await fetch(GOOGLE_USERINFO_URL, {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
const userData = (await userResponse.json()) as GoogleUserInfo;
if (!userResponse.ok || !userData.email || userData.email_verified === false) return null;
return {
email: userData.email,
name: userData.name,
};
}
+28
View File
@@ -0,0 +1,28 @@
const OAUTH_RETURN_COOKIE = 'shahi_google_oauth_return_to';
const DEFAULT_RETURN_PATH = '/account';
const SAFE_PATH_PATTERN = /^\/[a-zA-Z0-9/_-]*$/;
export function getOAuthReturnCookieName(): string {
return OAUTH_RETURN_COOKIE;
}
/** Only same-origin relative paths (optionally with query) are allowed after OAuth. */
export function sanitizeOAuthReturnPath(value: string | null | undefined): string {
if (!value) return DEFAULT_RETURN_PATH;
const trimmed = value.trim();
if (!trimmed.startsWith('/') || trimmed.startsWith('//') || trimmed.includes('://')) {
return DEFAULT_RETURN_PATH;
}
const withoutHash = trimmed.split('#')[0] ?? trimmed;
const queryIndex = withoutHash.indexOf('?');
const pathname = queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex);
const search = queryIndex === -1 ? '' : withoutHash.slice(queryIndex);
if (!pathname || !SAFE_PATH_PATTERN.test(pathname)) {
return DEFAULT_RETURN_PATH;
}
return `${pathname}${search}`;
}
+6
View File
@@ -0,0 +1,6 @@
import { requireMenuManager } from './require-menu-manager';
/** Menu Management routes — requires an allowlisted Google customer session. */
export async function requireAdmin(): Promise<import('next/server').NextResponse | null> {
return requireMenuManager();
}
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { isMenuManagerEmail } from '@/domain/auth/menu-managers';
import { getCustomerSession } from './customer-session';
export async function getMenuManagerSession() {
const session = await getCustomerSession();
if (!session || !isMenuManagerEmail(session.email)) return null;
return session;
}
export async function requireMenuManager(): Promise<NextResponse | null> {
const session = await getMenuManagerSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return null;
}
@@ -0,0 +1,121 @@
import { isGothenburgDeliveryAddress } from '@/domain/delivery/gothenburg-address';
import {
disableGeoapifyAutocomplete,
isGeoapifyApiKeyConfigured,
isGeoapifyAutocompleteDisabled,
mapGeoapifyHttpStatus,
type GeoapifyUnavailableReason,
} from './geoapify-availability';
import {
getCachedGeoapifyFeatures,
getInFlightGeoapifyRequest,
setCachedGeoapifyFeatures,
trackInFlightGeoapifyRequest,
waitForGeoapifyRateLimit,
} from './geoapify-request-cache';
import type { GeoapifyAutocompleteResponse, GeoapifyFeature } from './geoapify-types';
/** Backaplan branch — bias autocomplete toward Gothenburg. */
const GOTHENBURG_BIAS = 'proximity:11.944,57.699';
/** Approximate Göteborg municipality bounding box (minLon,minLat,maxLon,maxLat). */
const GOTHENBURG_RECT_FILTER = 'rect:11.74,57.60,12.15,57.82';
export type GeoapifyAutocompleteResult =
| { status: 'ok'; features: GeoapifyFeature[] }
| { status: 'unavailable'; reason: GeoapifyUnavailableReason; features: [] };
export function isDeliverableGeoapifyFeature(feature: GeoapifyFeature): boolean {
const { properties } = feature;
return isGothenburgDeliveryAddress({
city: properties.city,
municipality: properties.municipality,
postcode: properties.postcode,
countryCode: properties.country_code,
formatted: properties.formatted,
});
}
export function shouldUseGeoapifyAutocomplete(): boolean {
return isGeoapifyApiKeyConfigured() && !isGeoapifyAutocompleteDisabled();
}
function unavailable(reason: GeoapifyUnavailableReason): GeoapifyAutocompleteResult {
disableGeoapifyAutocomplete();
return { status: 'unavailable', reason, features: [] };
}
async function requestGeoapifyAddressSuggestions(
query: string,
lang: 'sv' | 'en',
): Promise<GeoapifyFeature[]> {
const cached = getCachedGeoapifyFeatures(query, lang);
if (cached) return cached;
const inFlight = getInFlightGeoapifyRequest(query, lang);
if (inFlight) return inFlight;
const request = (async () => {
await waitForGeoapifyRateLimit();
const apiKey = process.env.NEXT_PUBLIC_GEOAPIFY_API_KEY!.trim();
const params = new URLSearchParams({
text: query.trim(),
format: 'geojson',
lang,
limit: '8',
filter: `countrycode:se|${GOTHENBURG_RECT_FILTER}`,
bias: GOTHENBURG_BIAS,
apiKey,
});
const response = await fetch(
`https://api.geoapify.com/v1/geocode/autocomplete?${params.toString()}`,
);
if (!response.ok) {
throw new GeoapifyRequestError(mapGeoapifyHttpStatus(response.status));
}
const data = (await response.json()) as GeoapifyAutocompleteResponse;
const features = (data.features ?? []).filter(isDeliverableGeoapifyFeature);
setCachedGeoapifyFeatures(query, lang, features);
return features;
})();
return trackInFlightGeoapifyRequest(query, lang, request);
}
class GeoapifyRequestError extends Error {
constructor(public readonly reason: GeoapifyUnavailableReason) {
super(reason);
this.name = 'GeoapifyRequestError';
}
}
export async function fetchGeoapifyAddressSuggestions(
query: string,
lang: 'sv' | 'en' = 'sv',
): Promise<GeoapifyAutocompleteResult> {
if (!isGeoapifyApiKeyConfigured()) {
return unavailable('missing_key');
}
if (isGeoapifyAutocompleteDisabled()) {
return unavailable('quota_exceeded');
}
if (query.trim().length < 3) {
return { status: 'ok', features: [] };
}
try {
const features = await requestGeoapifyAddressSuggestions(query, lang);
return { status: 'ok', features };
} catch (error) {
if (error instanceof GeoapifyRequestError) {
return unavailable(error.reason);
}
return unavailable('network');
}
}
@@ -0,0 +1,50 @@
const SESSION_STORAGE_KEY = 'shahi-geoapify-disabled';
export type GeoapifyUnavailableReason =
| 'missing_key'
| 'quota_exceeded'
| 'unauthorized'
| 'forbidden'
| 'rate_limited'
| 'payment_required'
| 'network'
| 'server_error';
export function isGeoapifyApiKeyConfigured(): boolean {
const key = process.env.NEXT_PUBLIC_GEOAPIFY_API_KEY;
return typeof key === 'string' && key.trim().length > 0;
}
export function isGeoapifyAutocompleteDisabled(): boolean {
if (typeof window === 'undefined') return false;
try {
return sessionStorage.getItem(SESSION_STORAGE_KEY) === '1';
} catch {
return false;
}
}
export function disableGeoapifyAutocomplete(): void {
if (typeof window === 'undefined') return;
try {
sessionStorage.setItem(SESSION_STORAGE_KEY, '1');
} catch {
// sessionStorage may be unavailable in private mode
}
}
export function mapGeoapifyHttpStatus(status: number): GeoapifyUnavailableReason {
switch (status) {
case 401:
return 'unauthorized';
case 402:
return 'payment_required';
case 403:
return 'forbidden';
case 429:
return 'rate_limited';
default:
if (status >= 500) return 'server_error';
return 'quota_exceeded';
}
}
@@ -0,0 +1,130 @@
import type { GeoapifyFeature } from './geoapify-types';
const CACHE_TTL_MS = 30 * 60 * 1000;
const MAX_MEMORY_ENTRIES = 80;
const MAX_SESSION_ENTRIES = 40;
const MIN_REQUEST_INTERVAL_MS = 750;
const SESSION_CACHE_KEY = 'shahi-geoapify-cache';
interface CacheEntry {
features: GeoapifyFeature[];
cachedAt: number;
}
const memoryCache = new Map<string, CacheEntry>();
const inFlight = new Map<string, Promise<GeoapifyFeature[]>>();
let lastNetworkRequestAt = 0;
let sessionHydrated = false;
export function buildGeoapifyCacheKey(query: string, lang: string): string {
return `${lang}:${query.trim().toLowerCase()}`;
}
function isFresh(entry: CacheEntry): boolean {
return Date.now() - entry.cachedAt < CACHE_TTL_MS;
}
function trimMemoryCache(): void {
while (memoryCache.size > MAX_MEMORY_ENTRIES) {
const oldestKey = memoryCache.keys().next().value;
if (!oldestKey) break;
memoryCache.delete(oldestKey);
}
}
function hydrateFromSessionStorage(): void {
if (sessionHydrated || typeof window === 'undefined') return;
sessionHydrated = true;
try {
const raw = sessionStorage.getItem(SESSION_CACHE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, CacheEntry>;
for (const [key, entry] of Object.entries(parsed)) {
if (isFresh(entry)) {
memoryCache.set(key, entry);
}
}
} catch {
// Ignore corrupt cache payloads
}
}
function persistToSessionStorage(): void {
if (typeof window === 'undefined') return;
try {
const payload: Record<string, CacheEntry> = {};
const entries = [...memoryCache.entries()]
.filter(([, entry]) => isFresh(entry))
.slice(-MAX_SESSION_ENTRIES);
for (const [key, entry] of entries) {
payload[key] = entry;
}
sessionStorage.setItem(SESSION_CACHE_KEY, JSON.stringify(payload));
} catch {
// sessionStorage may be full or unavailable
}
}
export function getCachedGeoapifyFeatures(
query: string,
lang: string,
): GeoapifyFeature[] | null {
hydrateFromSessionStorage();
const key = buildGeoapifyCacheKey(query, lang);
const entry = memoryCache.get(key);
if (!entry || !isFresh(entry)) {
if (entry) memoryCache.delete(key);
return null;
}
return entry.features;
}
export function setCachedGeoapifyFeatures(
query: string,
lang: string,
features: GeoapifyFeature[],
): void {
hydrateFromSessionStorage();
const key = buildGeoapifyCacheKey(query, lang);
memoryCache.set(key, { features, cachedAt: Date.now() });
trimMemoryCache();
persistToSessionStorage();
}
export function getInFlightGeoapifyRequest(
query: string,
lang: string,
): Promise<GeoapifyFeature[]> | null {
return inFlight.get(buildGeoapifyCacheKey(query, lang)) ?? null;
}
export function trackInFlightGeoapifyRequest(
query: string,
lang: string,
request: Promise<GeoapifyFeature[]>,
): Promise<GeoapifyFeature[]> {
const key = buildGeoapifyCacheKey(query, lang);
inFlight.set(key, request);
return request.finally(() => {
if (inFlight.get(key) === request) {
inFlight.delete(key);
}
});
}
export async function waitForGeoapifyRateLimit(): Promise<void> {
const elapsed = Date.now() - lastNetworkRequestAt;
const waitMs = MIN_REQUEST_INTERVAL_MS - elapsed;
if (waitMs > 0) {
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
lastNetworkRequestAt = Date.now();
}
@@ -0,0 +1,22 @@
export interface GeoapifyAddressProperties {
formatted?: string;
address_line1?: string;
address_line2?: string;
city?: string;
municipality?: string;
postcode?: string;
country?: string;
country_code?: string;
lat?: number;
lon?: number;
}
export interface GeoapifyFeature {
type: 'Feature';
properties: GeoapifyAddressProperties;
}
export interface GeoapifyAutocompleteResponse {
type: string;
features: GeoapifyFeature[];
}
+248
View File
@@ -0,0 +1,248 @@
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;
}
+281
View File
@@ -0,0 +1,281 @@
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;
}
+6 -6
View File
@@ -133,7 +133,7 @@ export const menuCategories: MenuCategory[] = [
{ id: "chicken-karahi", name: "Chicken Karahi", description: "Wok-tossed chicken in a robust tomato, chili and ginger gravy.", price: 149, image: "chicken-karahi.jpg", video: "chicken-karahi.mp4" },
{ id: "lahore-sizzler", name: "Lahore Sizzler", description: "Sizzling platter of marinated chicken with vegetables and spicy sauces.", price: 169, image: "lahore-sizzler.jpg", video: "lahore-sizzler.mp4" },
{ id: "butter-chicken", name: "Butter Chicken", description: "Tender chicken in a creamy tomato and butter gravy with mild spices.", price: 149, image: "butter-chicken.jpg" },
{ id: "chicken-haleem", name: "Chicken Haleem", description: "Slow-cooked shredded chicken with lentils, wheat and aromatic spices.", price: 149, image: "chicken-haleem.jpg", video: "chicken-haleem.mp4" },
{ id: "chicken-haleem", name: "Chicken Haleem", description: "Slow-cooked shredded chicken with lentils, wheat and aromatic spices.", price: 139, image: "chicken-haleem.jpg", video: "chicken-haleem.mp4" },
],
},
{
@@ -171,7 +171,7 @@ export const menuCategories: MenuCategory[] = [
{ id: "lambay-gulab-jaman", name: "Lambay Gulab Jamun", description: "Elongated gulab jamun with extra syrup — a Shahi Sweets favourite.", image: "lambay-gulab-jaman.jpg", ...WEIGHT_SWEET_PRICING },
{ id: "cream-gulab-jaman", name: "Cream Gulab Jamun", description: "Gulab jamun filled with creamy centre, finished in fragrant syrup.", image: "cream-gulab-jaman.jpg", ...WEIGHT_SWEET_PRICING },
{ id: "ras-gulay", name: "Ras Gulay", description: "Spongy cottage-cheese balls in light sugar syrup — chilled and refreshing.", image: "ras-gulay.jpg", video: "ras-gulay.mp4", ...WEIGHT_SWEET_PRICING },
{ id: "rasmalai", name: "Rasmalai", description: "Soft cheese dumplings soaked in chilled sweetened milk with cardamom and saffron.", image: "rasmalai.jpg", video: "rasmalai.mp4", ...WEIGHT_SWEET_PRICING },
{ id: "rasmalai", name: "Rasmalai", description: "Soft cheese dumplings soaked in chilled sweetened milk with cardamom and saffron.", price: 45, image: "rasmalai.jpg", video: "rasmalai.mp4" },
{ id: "cham-cham", name: "Cham Cham", description: "Oval Bengali sweet coated in coconut or pistachio — soft and milky.", image: "cham-cham.jpg", video: "cham-cham.mp4", ...WEIGHT_SWEET_PRICING },
{ id: "paira", name: "Paira", description: "Traditional milk fudge sweet with a smooth, grainy texture.", image: "paira.jpg", video: "paira.mp4", ...WEIGHT_SWEET_PRICING },
{ id: "laddu", name: "Laddu", description: "Round gram-flour and ghee sweet balls — festive and aromatic.", image: "laddu.jpg", video: "laddu.mp4", ...WEIGHT_SWEET_PRICING },
@@ -197,10 +197,10 @@ export const menuCategories: MenuCategory[] = [
items: [
{ id: "masala-chai", name: "Masala Chai", description: "Traditional spiced tea brewed with milk, cardamom, ginger and aromatic spices.", price: 39, image: "masala-chai.jpg" },
{ id: "mango-lassi", name: "Mango Lassi", description: "Refreshing sweet yogurt drink blended with ripe mango and cardamom.", price: 45, image: "mango-lassi.jpg", video: "mango-lassi.mp4" },
{ id: "coca-cola", name: "Coca-Cola", description: "Classic chilled cola soft drink.", price: 29, video: "coca-cola.mp4" },
{ id: "pepsi-fanta", name: "Pepsi / Fanta", description: "Refreshing cola or orange flavored carbonated beverage.", price: 29, video: "pepsi-fanta.mp4" },
{ id: "sprite-ramlosa", name: "Sprite / Ramlösa", description: "Crisp lemon-lime soda or sparkling mineral water.", price: 29, video: "sprite-ramlosa.mp4" },
{ id: "energy-drink", name: "Energy Drink", description: "Caffeinated beverage for an instant energy boost.", price: 39, video: "energy-drink.mp4" },
{ id: "coca-cola", name: "Coca-Cola", description: "Classic chilled cola soft drink.", price: 25, video: "coca-cola.mp4" },
{ id: "pepsi-fanta", name: "Pepsi / Fanta", description: "Refreshing cola or orange flavored carbonated beverage.", price: 25, video: "pepsi-fanta.mp4" },
{ id: "sprite-ramlosa", name: "Sprite / Ramlösa", description: "Crisp lemon-lime soda or sparkling mineral water.", price: 25, video: "sprite-ramlosa.mp4" },
{ id: "energy-drink", name: "Energy Drink", description: "Caffeinated beverage for an instant energy boost.", price: 29, video: "energy-drink.mp4" },
{ id: "juice", name: "Juice", description: "Fresh fruit juice, typically mango or other seasonal flavors.", price: 20, image: "mango-juice.jpg" },
{ id: "coffee", name: "Coffee", description: "Freshly brewed hot coffee.", price: 39, image: "black-coffee.jpg" },
{ id: "latte", name: "Latte", description: "Espresso coffee with steamed milk and a light layer of foam.", price: 49, image: "latte.jpg", video: "latte.mp4" },