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
210 lines
8.2 KiB
TypeScript
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;
|
|
}
|