import { ClientData, Store } from "../../types/gen/types.gen"; import getStorage from "./storage"; const storage = getStorage(); const STORES_KEY = 'stores'; /** Maximální počet URL na jeden obchod (ochrana proti nafouknutí dat). */ const MAX_URLS = 10; /** * Podoby, v jakých mohou být obchody uloženy ve storage: * - řetězec — nejstarší formát (jen název), * - objekt s `url` — formát s jednou URL na nabídku, * - objekt s `urls` — aktuální formát s více URL. */ type StoredStore = string | { name: string; url?: string; urls?: string[] }; /** * Vrátí seznam povolených obchodů. Zachovává zpětnou kompatibilitu se staršími * formáty uložených dat (pole řetězců, resp. jediná URL v poli `url`) — vše * převede na aktuální podobu se seznamem `urls`. */ export async function getStores(): Promise { const raw = await storage.getData(STORES_KEY); if (!raw) { return []; } return raw.map(s => { if (typeof s === 'string') { return { name: s }; } const urls = s.urls ?? (s.url ? [s.url] : []); return urls.length > 0 ? { name: s.name, urls } : { name: s.name }; }); } /** * Ověří a upraví seznam URL obchodu — ořeže mezery, zahodí prázdné hodnoty, * odstraní duplicity a zkontroluje, že jde o platné http(s) odkazy. */ function normalizeUrls(urls?: string[]): string[] { if (!urls) { return []; } const result: string[] = []; for (const raw of urls) { const trimmed = raw?.trim(); if (!trimmed) { continue; } // Povolíme pouze http(s), aby URL nemohla být zneužita (např. javascript: → XSS) let parsed: URL; try { parsed = new URL(trimmed); } catch { throw new Error('Neplatná URL obchodu'); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error('URL musí začínat http:// nebo https://'); } if (!result.some(u => u.toLowerCase() === trimmed.toLowerCase())) { result.push(trimmed); } } if (result.length > MAX_URLS) { throw new Error(`Obchod může mít nejvýše ${MAX_URLS} URL`); } return result; } /** * Přidá nový obchod do seznamu povolených. * * @param name název obchodu * @param heslo admin heslo * @param urls volitelný seznam URL na nabídku podniku (různé dovozové služby) */ export async function addStore(name: string, heslo: string, urls?: string[]): Promise { const adminPassword = process.env.ADMIN_PASSWORD; if (!adminPassword || heslo !== adminPassword) { throw new Error('UNAUTHORIZED'); } const trimmed = name.trim(); if (!trimmed) { throw new Error('Název obchodu nesmí být prázdný'); } const stores = await getStores(); if (stores.some(s => s.name.toLowerCase() === trimmed.toLowerCase())) { throw new Error('Obchod s tímto názvem již existuje'); } const normalizedUrls = normalizeUrls(urls); const store: Store = normalizedUrls.length > 0 ? { name: trimmed, urls: normalizedUrls } : { name: trimmed }; const updated = [...stores, store]; await storage.setData(STORES_KEY, updated); return updated; } /** * Upraví existující obchod — název a/nebo seznam URL na nabídku. * * @param name aktuální název obchodu (identifikuje upravovaný obchod) * @param heslo admin heslo * @param newName nový název; pokud není předán, název zůstane nezměněn * @param urls nový seznam URL (nahradí stávající); pokud není předán, URL zůstanou nezměněné */ export async function updateStore(name: string, heslo: string, newName?: string, urls?: string[]): Promise { const adminPassword = process.env.ADMIN_PASSWORD; if (!adminPassword || heslo !== adminPassword) { throw new Error('UNAUTHORIZED'); } const stores = await getStores(); const index = stores.findIndex(s => s.name.toLowerCase() === name.trim().toLowerCase()); if (index < 0) { throw new Error('Obchod nebyl nalezen'); } const current = stores[index]; let finalName = current.name; if (newName !== undefined) { const trimmedName = newName.trim(); if (!trimmedName) { throw new Error('Název obchodu nesmí být prázdný'); } // Nový název nesmí kolidovat s jiným obchodem (shoda se sebou samým je v pořádku) if (stores.some((s, i) => i !== index && s.name.toLowerCase() === trimmedName.toLowerCase())) { throw new Error('Obchod s tímto názvem již existuje'); } finalName = trimmedName; } const finalUrls = urls !== undefined ? normalizeUrls(urls) : (current.urls ?? []); const updatedStore: Store = finalUrls.length > 0 ? { name: finalName, urls: finalUrls } : { name: finalName }; const updated = stores.map((s, i) => (i === index ? updatedStore : s)); await storage.setData(STORES_KEY, updated); return updated; } /** * Odebere obchod ze seznamu povolených (dle názvu). * * @param name název obchodu * @param heslo admin heslo */ export async function removeStore(name: string, heslo: string): Promise { const adminPassword = process.env.ADMIN_PASSWORD; if (!adminPassword || heslo !== adminPassword) { throw new Error('UNAUTHORIZED'); } const stores = await getStores(); const updated = stores.filter(s => s.name.toLowerCase() !== name.trim().toLowerCase()); await storage.setData(STORES_KEY, updated); return updated; } /** Maximální počet jídel vrácených pro jeden obchod v {@link getStoreDishes}. */ const MAX_DISHES_PER_STORE = 40; /** Rozpoznání polévek v poznámkách objednávek — ty se do kola štěstí nezahrnují. */ /** Jídla historicky objednaná u daného obchodu. */ export type StoreDishes = { store: string, dishes: string[] }; const SOUP_REGEX = /pol[eé]vk|v[ýy]var|soup|\bpho\b|ramen|tom yum|kulajda|gul[aá][sš]ovka/i; /** * Vrátí pro každý povolený obchod seznam jídel, která si u něj uživatelé v minulosti * objednali (z poznámek členů skupinových objednávek). Slouží jako záloha pro druhé * kolo „kola štěstí", pokud se nabídku z Bolt Food/Wolt nepodaří stáhnout. * Jídla jsou seřazena dle četnosti, bez duplicit a bez polévek. */ export async function getStoreDishes(): Promise { const stores = await getStores(); const counts = new Map>(); for (const store of stores) { counts.set(store.name.toLowerCase(), new Map()); } const keys = await storage.listKeys('_extra'); for (const key of keys) { if (!key.endsWith('_extra')) continue; const data = await storage.getData(key); for (const group of data?.groups ?? []) { const dishes = counts.get(group.name?.toLowerCase()); if (!dishes) continue; for (const member of Object.values(group.members ?? {})) { const dish = normalizeDish(member?.note); if (!dish || SOUP_REGEX.test(dish)) continue; const dishKey = dish.toLowerCase(); const existing = dishes.get(dishKey); if (existing) existing.count++; else dishes.set(dishKey, { name: dish, count: 1 }); } } } return stores.map(store => ({ store: store.name, dishes: [...counts.get(store.name.toLowerCase())!.values()] .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) .slice(0, MAX_DISHES_PER_STORE) .map(d => d.name), })); } /** Očistí poznámku člena objednávky (mezery, číslo položky z menu typu „28. ") na název jídla; poznámky nezačínající písmenem/číslicí zahodí. */ function normalizeDish(note?: string): string | undefined { if (!note) return undefined; const cleaned = note.replace(/\s+/g, ' ').replace(/^\d+\s*[.)]\s*/, '').trim(); // Poznámky typu „⬇️ totéž" nebo „(bez cibule)" nejsou názvy jídel if (!/^[\p{L}\p{N}]/u.test(cleaned)) return undefined; return cleaned.length >= 2 && cleaned.length <= 120 ? cleaned : undefined; }