Files
Luncher/server/src/routes/devRoutes.ts
T
mates 49508dacd0
CI / Generate TypeScript types (push) Successful in 11s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 40s
CI / Playwright E2E tests (push) Failing after 2m44s
CI / Build and push Docker image (push) Skipped
CI / Notify (push) Successful in 2s
fix: dočasné skrytí podniku Šenk Šeříková
2026-10-09 14:45:00 +02:00

464 lines
18 KiB
TypeScript

import express, { Request } from "express";
import { getDateForWeekIndex, getData, isRestaurantDisabled, getActiveRestaurants, getRestaurantMenu, getToday, initIfNeeded } from "../service";
import { formatDate, getDayOfWeekIndex } from "../utils";
import getStorage from "../storage";
import { getWebsocket } from "../websocket";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import webpush from 'web-push';
import { ClientData, GroupState, MealSlot, OrderGroupMember, Restaurant, TrackingProvider } from "../../../types/gen/types.gen";
import { getStores } from "../stores";
import { createGroup } from "../groups";
import {
startBoltSimulation, advanceBoltSimulation, setBoltSimulationStep,
stopBoltSimulationByGroup, getBoltSimulation,
} from "../boltSimulator";
import { checkOrderTracking } from "../orderTracking";
const router = express.Router();
const storage = getStorage();
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
// Seznam náhodných jmen pro generování mock dat
const MOCK_NAMES = [
'Alice', 'Bob', 'Charlie', 'David', 'Eva', 'Filip', 'Gita', 'Honza',
'Ivana', 'Jakub', 'Kamila', 'Lukáš', 'Markéta', 'Nikola', 'Ondřej',
'Petra', 'Quido', 'Radek', 'Simona', 'Tomáš', 'Ursula', 'Viktor',
'Wanda', 'Xaver', 'Yvona', 'Zdeněk', 'Aneta', 'Boris', 'Cecílie', 'Daniel'
];
// Volby stravování pro mock data
const LUNCH_CHOICES = [
'SLADOVNICKA',
'TECHTOWER',
'ZASTAVKAUMICHALA',
'SENKSERIKOVA',
'OBJEDNAVAM',
'NEOBEDVAM',
'ROZHODUJI',
].filter(choice => !isRestaurantDisabled(choice as Restaurant));
// Náhodné názvy jídel do poznámek mock objednávek
const MOCK_ORDER_FOODS = [
'Malá kuřecí tortilla',
'Jalapenos burger',
'Chicken Palak',
'Pad Thai s kuřecím',
'Bún bò nam bo',
'Trhané hovězí v bulce',
'Caesar salát s kuřetem',
'Kuřecí vindaloo',
'Smažený sýr s hranolkami',
'Pizza Diavola',
'Burrito s trhaným masem',
'Ramen s vepřovým bůčkem',
'Falafel wrap v pitě',
'Krůtí steak s bramborovou kaší',
'Gyros se zeleninou',
'Losos s grilovanou zeleninou',
'Svíčková na smetaně s knedlíkem',
'Kuřecí kung pao',
'Poke bowl s tuňákem',
'Vepřový řízek s bramborovým salátem',
];
/** Výchozí hodnoty dialogu pro generování mock objednávek. */
const DEFAULT_ORDER_MEMBER_COUNT = 3;
const DEFAULT_MIN_AMOUNT = 100;
const DEFAULT_MAX_AMOUNT = 1000;
/** Nejvyšší povolený počet osob doplněných do jedné skupiny. */
const MAX_ORDER_MEMBER_COUNT = 50;
/** Nejvyšší povolená částka (v Kč) — musí odpovídat OpenAPI definici. */
const MAX_ORDER_AMOUNT = 100000;
// Restaurace s menu
const RESTAURANTS_WITH_MENU = getActiveRestaurants();
/**
* Middleware pro kontrolu DEV režimu
*/
function requireDevMode(req: any, res: any, next: any) {
if (ENVIRONMENT !== 'development' && ENVIRONMENT !== 'test') {
return res.status(403).json({ error: 'Tento endpoint je dostupný pouze ve vývojovém režimu' });
}
next();
}
router.use(requireDevMode);
/** Indexy všech pracovních dnů týdne (pondělí až pátek). */
const WEEK_DAY_INDEXES = [0, 1, 2, 3, 4];
/**
* Vygeneruje mock data pro jeden den týdne.
* @returns počet skutečně vygenerovaných záznamů
*/
async function generateMockDataForDay(dayIndex: number, requestedCount?: number): Promise<number> {
const count = requestedCount ?? Math.floor(Math.random() * 16) + 5; // 5-20
const date = getDateForWeekIndex(dayIndex);
await initIfNeeded(date);
const dateKey = formatDate(date);
const data = await storage.getData<any>(dateKey);
// Získání menu restaurací pro vybraný den
const menus: { [key: string]: any } = {};
for (const restaurant of RESTAURANTS_WITH_MENU) {
const menu = await getRestaurantMenu(restaurant as any, date);
if (menu?.food?.length) {
menus[restaurant] = menu.food;
}
}
// Vygenerování náhodných uživatelů
const usedNames = new Set<string>();
for (let i = 0; i < count && usedNames.size < MOCK_NAMES.length; i++) {
// Vybereme náhodné jméno, které ještě nebylo použito
let name: string;
do {
name = MOCK_NAMES[Math.floor(Math.random() * MOCK_NAMES.length)];
} while (usedNames.has(name));
usedNames.add(name);
// Vybereme náhodnou volbu stravování
const choice = LUNCH_CHOICES[Math.floor(Math.random() * LUNCH_CHOICES.length)];
// Inicializace struktury pro volbu
data.choices[choice] ??= {};
const userChoice: any = {
trusted: false,
selectedFoods: [],
};
// Pokud má restaurace menu, vybereme náhodné jídlo
if (RESTAURANTS_WITH_MENU.includes(choice as Restaurant) && menus[choice]?.length) {
const foods = menus[choice];
// Vybereme náhodné jídlo (ne polévku)
const mainFoods = foods.filter((f: any) => !f.isSoup);
if (mainFoods.length > 0) {
const randomFoodIndex = foods.indexOf(mainFoods[Math.floor(Math.random() * mainFoods.length)]);
userChoice.selectedFoods = [randomFoodIndex];
}
}
data.choices[choice][name] = userChoice;
}
await storage.setData(dateKey, data);
return usedNames.size;
}
/** Smaže všechny volby (a pizza day) pro jeden den týdne. */
async function clearMockDataForDay(dayIndex: number): Promise<void> {
const date = getDateForWeekIndex(dayIndex);
await initIfNeeded(date);
const dateKey = formatDate(date);
const data = await storage.getData<any>(dateKey);
// Vymažeme všechny volby i aktivní pizza day
data.choices = {};
delete data.pizzaDay;
await storage.setData(dateKey, data);
}
/** Rozešle klientům aktuální data pro dané datum. */
async function broadcastDate(date: Date) {
const clientData = await getData(date);
getWebsocket().emit("message", clientData);
}
/**
* Vygeneruje mock data pro testování - pro jeden den, nebo pro celý týden.
*/
router.post("/generate", async (req: Request<{}, any, any>, res, next) => {
try {
const wholeWeek = req.body?.wholeWeek === true;
const count: number | undefined = req.body?.count;
if (wholeWeek) {
// Pro každý den generujeme zvlášť - pokud není zadán počet, bude pro každý den náhodný
const days: { dayIndex: number, count: number }[] = [];
for (const dayIndex of WEEK_DAY_INDEXES) {
days.push({ dayIndex, count: await generateMockDataForDay(dayIndex, count) });
}
// Klientům stačí poslat data dnešního dne, ostatní si dotáhnou při přepnutí
await broadcastDate(getToday());
const total = days.reduce((sum, day) => sum + day.count, 0);
return res.status(200).json({ success: true, count: total, days });
}
const dayIndex = req.body?.dayIndex ?? getDayOfWeekIndex(getToday());
if (dayIndex < 0 || dayIndex > 4) {
return res.status(400).json({ error: 'Neplatný index dne (0-4)' });
}
const generated = await generateMockDataForDay(dayIndex, count);
await broadcastDate(getDateForWeekIndex(dayIndex));
res.status(200).json({ success: true, count: generated, dayIndex });
} catch (e: any) {
next(e);
}
});
/**
* Smaže všechny volby pro daný den, nebo pro celý týden.
*/
router.post("/clear", async (req: Request<{}, any, any>, res, next) => {
try {
const wholeWeek = req.body?.wholeWeek === true;
if (wholeWeek) {
for (const dayIndex of WEEK_DAY_INDEXES) {
await clearMockDataForDay(dayIndex);
}
await broadcastDate(getToday());
return res.status(200).json({ success: true, days: WEEK_DAY_INDEXES.map(dayIndex => ({ dayIndex })) });
}
const dayIndex = req.body?.dayIndex ?? getDayOfWeekIndex(getToday());
if (dayIndex < 0 || dayIndex > 4) {
return res.status(400).json({ error: 'Neplatný index dne (0-4)' });
}
await clearMockDataForDay(dayIndex);
await broadcastDate(getDateForWeekIndex(dayIndex));
res.status(200).json({ success: true, dayIndex });
} catch (e: any) {
next(e);
}
});
/** Vrátí obsah push reminder registry (pro ladění). */
router.get("/pushRegistry", async (_req, res, next) => {
try {
const registry = await storage.getData<any>('push_reminder_registry') ?? {};
const sanitized = Object.fromEntries(
Object.entries(registry).map(([login, entry]: [string, any]) => [
login,
{ time: entry.time, endpoint: entry.subscription?.endpoint?.slice(0, 60) + '…' }
])
);
res.status(200).json(sanitized);
} catch (e: any) { next(e) }
});
/** Okamžitě odešle test push notifikaci přihlášenému uživateli (pro ladění). */
router.post("/testPush", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
const registry = await storage.getData<any>('push_reminder_registry') ?? {};
const entry = registry[login];
if (!entry) {
return res.status(404).json({ error: `Uživatel ${login} nemá uloženou push subscription. Nastav připomínku v nastavení.` });
}
const publicKey = process.env.VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
const subject = process.env.VAPID_SUBJECT;
if (!publicKey || !privateKey || !subject) {
return res.status(503).json({ error: 'VAPID klíče nejsou nastaveny' });
}
webpush.setVapidDetails(subject, publicKey, privateKey);
await webpush.sendNotification(
entry.subscription,
JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!', login })
);
res.status(200).json({ ok: true });
} catch (e: any) { next(e) }
});
// --- DEV mock data pro sekci Objednání ---
/** Vrátí náhodné celé číslo z intervalu <min, max>. */
function randomInt(min: number, max: number): number {
return min + Math.floor(Math.random() * (max - min + 1));
}
/** Vrátí náhodný prvek neprázdného pole. */
function randomItem<Type>(items: Type[]): Type {
return items[randomInt(0, items.length - 1)];
}
/**
* Vybere jméno, které ve skupině ještě není použité.
* Po vyčerpání seznamu jmen přidává číselný sufix (Alice2, Alice3, …).
*/
function pickMemberName(members: Record<string, OrderGroupMember>): string {
const free = MOCK_NAMES.filter(name => !members[name]);
if (free.length > 0) {
return randomItem(free);
}
const base = randomItem(MOCK_NAMES);
let suffix = 2;
while (members[`${base}${suffix}`]) {
suffix++;
}
return `${base}${suffix}`;
}
/** Rozešle klientům aktuální data sekce Objednání (dnešní den). */
async function broadcastExtra() {
getWebsocket().emit("message", await getData(getToday(), MealSlot.EXTRA));
}
/**
* Vygeneruje mock data pro sekci Objednání — vždy jen pro aktuální den.
* Do každé existující skupiny doplní zadaný počet osob s náhodnou částkou
* a názvem náhodného jídla v poznámce. Pokud žádná skupina neexistuje,
* založí jednu z náhodně vybraného obchodu.
*/
router.post("/generateOrders", async (req: Request<{}, any, any>, res, next) => {
try {
const login = getLogin(parseToken(req));
const count: number = req.body?.count ?? DEFAULT_ORDER_MEMBER_COUNT;
const minAmount: number = req.body?.minAmount ?? DEFAULT_MIN_AMOUNT;
const maxAmount: number = req.body?.maxAmount ?? DEFAULT_MAX_AMOUNT;
if (!Number.isInteger(count) || count < 1 || count > MAX_ORDER_MEMBER_COUNT) {
return res.status(400).json({ error: `Počet osob musí být celé číslo mezi 1 a ${MAX_ORDER_MEMBER_COUNT}` });
}
if (!Number.isInteger(minAmount) || !Number.isInteger(maxAmount)
|| minAmount < 0 || maxAmount > MAX_ORDER_AMOUNT || minAmount > maxAmount) {
return res.status(400).json({ error: 'Neplatný rozsah částek' });
}
const stores = await getStores();
if (stores.length === 0) {
return res.status(400).json({ error: 'Není nadefinován žádný obchod — mock data pro objednávání nelze vygenerovat' });
}
const key = `${formatDate(getToday())}_extra`;
const existing = await storage.getData<ClientData>(key);
let createdGroupId: string | undefined;
// Bez skupiny není kam objednávky doplnit — jednu založíme z náhodně vybraného obchodu
if (!existing?.groups?.length) {
const store = randomItem(stores);
const urls = store.urls ?? [];
const created = await createGroup(login, store.name, undefined, urls.length > 0 ? randomItem(urls) : undefined);
createdGroupId = created.groups?.[created.groups.length - 1]?.id;
}
const results: { id: string, name: string, added: number, created?: boolean }[] = [];
await storage.updateData<ClientData>(key, current => {
const data = current!;
// Mutátor může být (u Redis) zavolán opakovaně — výsledky proto vždy sestavíme znovu
results.length = 0;
for (const group of data.groups ?? []) {
for (let i = 0; i < count; i++) {
group.members[pickMemberName(group.members)] = {
amount: randomInt(minAmount, maxAmount) * 100,
note: randomItem(MOCK_ORDER_FOODS),
};
}
results.push({
id: group.id,
name: group.name,
added: count,
...(group.id === createdGroupId ? { created: true } : {}),
});
}
return data;
});
await broadcastExtra();
res.status(200).json({
success: true,
count: results.reduce((sum, group) => sum + group.added, 0),
groups: results,
});
} catch (e: any) {
next(e);
}
});
// --- DEV simulace sledování Bolt Food (Wolt simulaci zatím nemá) ---
/** Nastaví/zruší sledovací token skupiny a zajistí stav ORDERED. Vrátí aktualizovaná data. */
async function applyBoltToken(groupId: string, token: string | undefined): Promise<ClientData> {
const key = `${formatDate(getToday())}_extra`;
return storage.updateData<ClientData>(key, current => {
const d = current;
const group = d?.groups?.find(g => g.id === groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
group.trackingProvider = token ? TrackingProvider.BOLT : undefined;
group.trackingCode = token;
if (token) {
group.state = GroupState.ORDERED;
} else {
group.trackingOrderState = undefined;
group.trackingCourierState = undefined;
}
return d!;
});
}
/** Spustí simulaci sledování Bolt pro skupinu a provede první poll. */
router.post("/bolt/simulate", async (req: Request<{}, any, any>, res, next) => {
try {
const groupId = req.body?.groupId;
if (!groupId) return res.status(400).json({ error: 'Chybí groupId' });
const token = startBoltSimulation(groupId);
await applyBoltToken(groupId, token);
await checkOrderTracking(); // okamžitý první poll → stav "accepted" + websocket
res.status(200).json({ success: true, token, simulation: getBoltSimulation(groupId) });
} catch (e: any) { next(e); }
});
/** Posune simulaci na další krok a přepošle aktualizovaný stav klientům. */
router.post("/bolt/advance", async (req: Request<{}, any, any>, res, next) => {
try {
const groupId = req.body?.groupId;
if (!groupId) return res.status(400).json({ error: 'Chybí groupId' });
advanceBoltSimulation(groupId);
await checkOrderTracking();
res.status(200).json({ success: true, simulation: getBoltSimulation(groupId) });
} catch (e: any) { next(e); }
});
/** Nastaví konkrétní stav simulace (ruční override, např. pro stav "cancelled"). */
router.post("/bolt/state", async (req: Request<{}, any, any>, res, next) => {
try {
const { groupId, order_state, courier_state, etaSeconds } = req.body ?? {};
if (!groupId || !order_state) return res.status(400).json({ error: 'Chybí groupId nebo order_state' });
setBoltSimulationStep(groupId, { order_state, courier_state, etaSeconds });
await checkOrderTracking();
res.status(200).json({ success: true, simulation: getBoltSimulation(groupId) });
} catch (e: any) { next(e); }
});
/** Spustí jeden tik scheduleru okamžitě (bez čekání na interval). */
router.post("/bolt/poll", async (_req, res, next) => {
try {
await checkOrderTracking();
res.status(200).json({ success: true });
} catch (e: any) { next(e); }
});
/** Ukončí simulaci skupiny a odebere sledovací token. */
router.delete("/bolt/simulate", async (req: Request<{}, any, any>, res, next) => {
try {
const groupId = req.body?.groupId;
if (!groupId) return res.status(400).json({ error: 'Chybí groupId' });
stopBoltSimulationByGroup(groupId);
const updated = await applyBoltToken(groupId, undefined);
getWebsocket()?.emit('message', updated);
res.status(200).json({ success: true });
} catch (e: any) { next(e); }
});
export default router;