CI / Generate TypeScript types (push) Successful in 13s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 53s
CI / Notify (push) Successful in 2s
Vedle Bolt Food jde nově sledovat i objednávka z Woltu — stačí u objednané skupiny vložit odkaz z track.wolt.com. Logika sledování je zobecněná do registru rozvozových služeb (trackingProviders.ts): každá služba umí vytáhnout kód ze sdílecího odkazu a dotázat se svého API, scheduler i stepper jsou společné. - pole skupiny bolt* přejmenována na tracking* + nové trackingProvider - endpoint /groups/setBoltTracking → /groups/setTracking - Wolt: čas doručení z delivery_eta v časové zóně objednávky, 404 ukončí sledování, stav kurýra odvozen z is_delivering/is_delivering_other_order - DEV simulace zůstává jen pro Bolt Food
280 lines
10 KiB
TypeScript
280 lines
10 KiB
TypeScript
import express, { Request } from "express";
|
|
import { getDateForWeekIndex, getData, 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, TrackingProvider } from "../../../types/gen/types.gen";
|
|
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',
|
|
];
|
|
|
|
// Restaurace s menu
|
|
const RESTAURANTS_WITH_MENU = [
|
|
'SLADOVNICKA',
|
|
'TECHTOWER',
|
|
'ZASTAVKAUMICHALA',
|
|
'SENKSERIKOVA',
|
|
];
|
|
|
|
/**
|
|
* 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);
|
|
|
|
/**
|
|
* Vygeneruje mock data pro testování.
|
|
*/
|
|
router.post("/generate", async (req: Request<{}, any, any>, res, next) => {
|
|
try {
|
|
const dayIndex = req.body?.dayIndex ?? getDayOfWeekIndex(getToday());
|
|
const count = req.body?.count ?? Math.floor(Math.random() * 16) + 5; // 5-20
|
|
|
|
if (dayIndex < 0 || dayIndex > 4) {
|
|
return res.status(400).json({ error: 'Neplatný index dne (0-4)' });
|
|
}
|
|
|
|
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) && 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);
|
|
|
|
// Odeslat aktualizovaná data přes WebSocket
|
|
const clientData = await getData(date);
|
|
getWebsocket().emit("message", clientData);
|
|
|
|
res.status(200).json({ success: true, count: usedNames.size, dayIndex });
|
|
} catch (e: any) {
|
|
next(e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Smaže všechny volby pro daný den.
|
|
*/
|
|
router.post("/clear", async (req: Request<{}, any, any>, res, next) => {
|
|
try {
|
|
const dayIndex = req.body?.dayIndex ?? getDayOfWeekIndex(getToday());
|
|
|
|
if (dayIndex < 0 || dayIndex > 4) {
|
|
return res.status(400).json({ error: 'Neplatný index dne (0-4)' });
|
|
}
|
|
|
|
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);
|
|
|
|
// Odeslat aktualizovaná data přes WebSocket
|
|
const clientData = await getData(date);
|
|
getWebsocket().emit("message", clientData);
|
|
|
|
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 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;
|