Files
Luncher/server/src/stores.ts
T
batmanisko e281615cc3
CI / Generate TypeScript types (push) Successful in 11s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 39s
CI / Playwright E2E tests (push) Successful in 1m52s
CI / Build and push Docker image (push) Successful in 51s
CI / Notify (push) Successful in 2s
feat: spin the wheel, objednavani scrape menu, historie cen
2026-09-23 15:41:15 +02:00

210 lines
8.2 KiB
TypeScript

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<Store[]> {
const raw = await storage.getData<StoredStore[]>(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<Store[]> {
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<Store[]> {
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<Store[]> {
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<StoreDishes[]> {
const stores = await getStores();
const counts = new Map<string, Map<string, { name: string, count: number }>>();
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<ClientData>(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;
}