diff --git a/client/src/pages/OrderGroupsPage.tsx b/client/src/pages/OrderGroupsPage.tsx index 96de429..bcbe0a7 100644 --- a/client/src/pages/OrderGroupsPage.tsx +++ b/client/src/pages/OrderGroupsPage.tsx @@ -8,7 +8,7 @@ import { cs } from 'date-fns/locale'; import 'react-datepicker/dist/react-datepicker.css'; import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr, - getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, getOrderDates, + getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setBoltTracking, getOrderDates, } from '../../../types'; import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees'; import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket'; @@ -26,6 +26,22 @@ import PendingPayments from '../components/PendingPayments'; const SLOT = MealSlot.EXTRA; const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/; +const BOLT_SHARE_URL_PREFIX = 'https://food.bolt.eu/sharedActiveOrder/'; +const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i; + +/** Vytáhne sledovací token ze sdílecí URL Bolt Food, nebo přijme samotný token. Null = neplatný vstup. */ +function extractBoltToken(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed; + try { + const segments = new URL(trimmed).pathname.split('/').filter(Boolean); + const last = segments[segments.length - 1]; + return last && BOLT_TOKEN_REGEX.test(last) ? last : null; + } catch { + return null; + } +} // Český lokál pro date picker (názvy měsíců/dnů, pondělí jako první den) registerLocale('cs', cs); @@ -72,7 +88,7 @@ export default function OrderGroupsPage() { const [editAmounts, setEditAmounts] = useState>({}); const [editNotes, setEditNotes] = useState>({}); const [editSurcharges, setEditSurcharges] = useState>({}); - const [editTimes, setEditTimes] = useState>({}); + const [editTimes, setEditTimes] = useState>({}); const [payModal, setPayModal] = useState(null); const [feesModal, setFeesModal] = useState(null); const [confirmOrderGroup, setConfirmOrderGroup] = useState(null); @@ -236,7 +252,7 @@ export default function OrderGroupsPage() { const handleSaveTimes = async (group: OrderGroup) => { const times = editTimes[group.id]; if (!times) return; - const { orderedAt, deliveryAt } = times; + const { orderedAt, deliveryAt, boltUrl } = times; if (orderedAt && !TIME_REGEX.test(orderedAt)) { setPageError('Čas objednání musí být ve formátu HH:MM'); return; @@ -245,7 +261,17 @@ export default function OrderGroupsPage() { setPageError('Čas doručení musí být ve formátu HH:MM'); return; } - const ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } })); + // Bolt odkaz se odesílá jen při změně oproti aktuálnímu tokenu skupiny + const boltToken = boltUrl.trim() ? extractBoltToken(boltUrl) : null; + if (boltUrl.trim() && !boltToken) { + setPageError('Neplatný odkaz Bolt (očekávána URL sdílení objednávky)'); + return; + } + const boltChanged = (boltToken ?? undefined) !== group.boltTrackingToken; + let ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } })); + if (ok && boltChanged) { + ok = await refresh(() => setBoltTracking({ body: { id: group.id, shareUrl: boltUrl.trim() } })); + } if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; }); }; @@ -667,6 +693,18 @@ export default function OrderGroupsPage() { style={{ width: 75 }} /> +
+ Bolt odkaz: + setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], boltUrl: e.target.value } }))} + onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }} + style={{ width: 260 }} + /> +
@@ -674,7 +712,7 @@ export default function OrderGroupsPage() {
!isReadOnly && isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '' } }))} + onClick={() => !isReadOnly && isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', boltUrl: group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : '' } }))} title={!isReadOnly && isCreator ? 'Klikněte pro úpravu časů' : undefined} > @@ -682,6 +720,11 @@ export default function OrderGroupsPage() { Doručení v: {group.deliveryAt ?? '—'} + {group.boltTrackingToken && ( + Čas doručení se aktualizuje automaticky z Bolt Food}> + Bolt + + )}
)} diff --git a/server/src/boltTracking.ts b/server/src/boltTracking.ts new file mode 100644 index 0000000..6b309f5 --- /dev/null +++ b/server/src/boltTracking.ts @@ -0,0 +1,165 @@ +import axios from 'axios'; +import crypto from 'crypto'; +import getStorage from './storage'; +import { createLeaderLease } from './leaderLease'; +import { getToday } from './service'; +import { formatDate } from './utils'; +import { getWebsocket } from './websocket'; +import { ClientData, GroupState } from '../../types/gen/types.gen'; + +const storage = getStorage(); +const lease = createLeaderLease('luncher:bolt:leader'); + +const BOLT_POLLING_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getOrderPolling'; +const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i; +const TERMINAL_STATE_REGEX = /delivered|finished|cancelled|rejected|failed/i; +const MAX_CONSECUTIVE_FAILURES = 10; + +/** Identifikátor zařízení pro Bolt API — generuje se jednou na proces. */ +const DEVICE_ID = crypto.randomUUID(); + +let boltInterval: ReturnType | undefined; + +/** Mapa groupId → počet po sobě jdoucích selhání dotazu na Bolt API. */ +const consecutiveFailures = new Map(); + +/** + * Vytáhne sledovací token ze sdílecí URL Bolt Food + * (https://food.bolt.eu/sharedActiveOrder/) nebo přijme samotný token. + * Vrátí null, pokud vstup neobsahuje platný token (64 hex znaků). + */ +export function extractBoltToken(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed; + let pathname: string; + try { + pathname = new URL(trimmed).pathname; + } catch { + return null; + } + const segments = pathname.split('/').filter(Boolean); + const last = segments[segments.length - 1]; + return last && BOLT_TOKEN_REGEX.test(last) ? last : null; +} + +/** Spočítá očekávaný čas doručení (teď + sekundy) ve formátu HH:MM. */ +export function computeDeliveryHHMM(seconds: number, now: Date = new Date()): string { + const eta = new Date(now.getTime() + seconds * 1000); + return `${String(eta.getHours()).padStart(2, '0')}:${String(eta.getMinutes()).padStart(2, '0')}`; +} + +interface BoltOrder { + order_id: number; + order_state: string; + expected_time_to_client_in_seconds?: number; +} + +/** Dotáže se veřejného Bolt API na stav sdílené objednávky. Vrátí null, pokud objednávka už neexistuje. */ +export async function pollBoltOrder(token: string): Promise { + const res = await axios.post(BOLT_POLLING_URL, { token }, { + params: { + version: 'FW.1.111', + language: 'cs-CZ', + country: 'cz', + device_name: 'web', + device_os_version: 'web', + deviceType: 'web', + session_id: DEVICE_ID, + distinct_id: DEVICE_ID, + deviceId: DEVICE_ID, + }, + headers: { 'Content-Type': 'application/json' }, + timeout: 10_000, + }); + if (res.data?.code !== 0) { + throw new Error(`Bolt API vrátilo kód ${res.data?.code}: ${res.data?.message}`); + } + return res.data?.data?.orders?.[0] ?? null; +} + +/** + * Jeden tik scheduleru: pro dnešní objednané skupiny se sledovacím tokenem + * zjistí očekávaný čas doručení z Bolt API a aktualizuje deliveryAt. + * Sledování se automaticky ukončí (token se smaže), když objednávka skončí + * nebo dotazy opakovaně selhávají. + */ +export async function checkBoltTracking(): Promise { + const isLeader = await lease.tryAcquireOrRenew(); + if (!isLeader) return; + + const key = `${formatDate(getToday())}_extra`; + const data = await storage.getData(key); + const candidates = (data?.groups ?? []).filter(g => g.boltTrackingToken && g.state === GroupState.ORDERED); + + // Úklid čítačů selhání pro skupiny, které už nesledujeme + for (const groupId of consecutiveFailures.keys()) { + if (!candidates.some(g => g.id === groupId)) consecutiveFailures.delete(groupId); + } + if (candidates.length === 0) return; + + let updated: ClientData | undefined; + + for (const group of candidates) { + let deliveryAt: string | undefined; + let clearToken = false; + + try { + const order = await pollBoltOrder(group.boltTrackingToken!); + consecutiveFailures.delete(group.id); + if (!order || TERMINAL_STATE_REGEX.test(order.order_state ?? '')) { + clearToken = true; + } else if (typeof order.expected_time_to_client_in_seconds === 'number') { + deliveryAt = computeDeliveryHHMM(order.expected_time_to_client_in_seconds); + } + } catch (e) { + const failures = (consecutiveFailures.get(group.id) ?? 0) + 1; + consecutiveFailures.set(group.id, failures); + console.error(`Bolt tracking: chyba dotazu pro skupinu "${group.name}" (${failures}/${MAX_CONSECUTIVE_FAILURES})`, e); + if (failures < MAX_CONSECUTIVE_FAILURES) continue; + consecutiveFailures.delete(group.id); + clearToken = true; + } + + if (!clearToken && (deliveryAt === undefined || deliveryAt === group.deliveryAt)) continue; + + updated = await storage.updateData(key, current => { + const d = current ?? data!; + const g = d.groups?.find(x => x.id === group.id); + if (g?.boltTrackingToken) { + if (clearToken) { + g.boltTrackingToken = undefined; + } else { + g.deliveryAt = deliveryAt; + } + } + return d; + }); + if (clearToken) { + console.log(`Bolt tracking: sledování skupiny "${group.name}" ukončeno`); + } + } + + if (updated) { + getWebsocket()?.emit('message', updated); + } +} + +/** Spustí scheduler pro sledování Bolt objednávek (každou minutu). */ +export function startBoltTrackingScheduler(): void { + boltInterval = setInterval(checkBoltTracking, 60_000); + console.log('Bolt tracking: scheduler spuštěn'); +} + +/** Stopne scheduler sledování. Volá se při graceful shutdown. */ +export function stopBoltTrackingScheduler(): void { + if (boltInterval) { + clearInterval(boltInterval); + boltInterval = undefined; + } +} + +/** Uvolní leader lease při graceful shutdown. */ +export async function releaseBoltTrackingLease(): Promise { + await lease.release(); +} diff --git a/server/src/groups.ts b/server/src/groups.ts index d0c88e1..60ad641 100644 --- a/server/src/groups.ts +++ b/server/src/groups.ts @@ -3,6 +3,7 @@ import getStorage from "./storage"; import { getClientData, getToday, initIfNeeded } from "./service"; import { getStores } from "./stores"; import { removePendingQrsByGroupId } from "./pizza"; +import { extractBoltToken } from "./boltTracking"; import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen"; import { formatDate } from "./utils"; @@ -150,6 +151,7 @@ export async function setGroupState(login: string, groupId: string, newState: Gr group.orderedAt = undefined; group.deliveryAt = undefined; group.qrGenerated = undefined; + group.boltTrackingToken = undefined; for (const ml of memberLogins) { group.members[ml] = { ...group.members[ml], paid: undefined }; } @@ -199,3 +201,19 @@ export async function updateGroupTimes(login: string, groupId: string, orderedAt if (deliveryAt !== undefined) group.deliveryAt = deliveryAt || undefined; return saveExtraData(data, date); } + +export async function setGroupBoltTracking(login: string, groupId: string, shareUrl?: string, date?: Date): Promise { + const data = await getExtraData(date); + const group = findGroup(data, groupId); + if (!group) throw new Error('Skupina nebyla nalezena'); + if (group.creatorLogin !== login) throw new Error('Sledování Bolt může nastavit pouze zakladatel'); + if (!shareUrl) { + group.boltTrackingToken = undefined; + } else { + if (group.state !== GroupState.ORDERED) throw new Error('Sledování Bolt lze nastavit pouze ve stavu "objednáno"'); + const token = extractBoltToken(shareUrl); + if (!token) throw new Error('Neplatný odkaz na sledování objednávky Bolt'); + group.boltTrackingToken = token; + } + return saveExtraData(data, date); +} diff --git a/server/src/index.ts b/server/src/index.ts index 15afc4c..2d20e95 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -11,6 +11,7 @@ import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToke import { getPendingQrs } from "./pizza"; import { initWebsocket, initRedisAdapter, shutdownWebsocketClients, getWebsocket } from "./websocket"; import { startReminderScheduler, stopReminderScheduler, releaseReminderLease, verifyQuickChoiceToken } from "./pushReminder"; +import { startBoltTrackingScheduler, stopBoltTrackingScheduler, releaseBoltTrackingLease } from "./boltTracking"; import { storageReady } from "./storage"; import getStorage from "./storage"; import { shutdownRedisStorage } from "./storage/redis"; @@ -85,6 +86,10 @@ async function shutdown(signal: string) { stopReminderScheduler(); await releaseReminderLease(); + // Stop Bolt tracking scheduler and release leader lease + stopBoltTrackingScheduler(); + await releaseBoltTrackingLease(); + // Shut down Redis pub/sub clients (Socket.io adapter) await shutdownWebsocketClients(); @@ -284,5 +289,6 @@ storageReady.then(async () => { server.listen(PORT, () => { console.log(`Server listening on ${HOST}, port ${PORT}`); startReminderScheduler(); + startBoltTrackingScheduler(); }); }); diff --git a/server/src/leaderLease.ts b/server/src/leaderLease.ts new file mode 100644 index 0000000..9a2157f --- /dev/null +++ b/server/src/leaderLease.ts @@ -0,0 +1,56 @@ +import { getRedisClient } from './storage/redis'; + +const POD_ID = process.env.POD_ID ?? `local-${process.pid}`; + +export interface LeaderLease { + tryAcquireOrRenew(): Promise; + release(): Promise; +} + +/** + * Vytvoří leader lease pro daný klíč — zajišťuje, že periodickou úlohu + * spouští v multi-replica nasazení pouze jedna instance. + * Při non-Redis storage vždy vrací true (single-process, leader election není potřeba). + */ +export function createLeaderLease(leaseKey: string, ttlSeconds = 90): LeaderLease { + return { + async tryAcquireOrRenew(): Promise { + if (process.env.STORAGE?.toLowerCase() !== 'redis') return true; + try { + const c = getRedisClient(); + if (!c) return true; + + // Zkusíme získat lease atomicky (SET NX EX) + const acquired = await c.set(leaseKey, POD_ID, { NX: true, EX: ttlSeconds }); + if (acquired !== null) return true; // lease čerstvě získána + + // Pokud jsme ji nedostali, ověříme zda ji držíme my + const currentHolder = await c.get(leaseKey); + if (currentHolder === POD_ID) { + // Naše lease — obnovíme TTL + await c.set(leaseKey, POD_ID, { EX: ttlSeconds }); + return true; + } + return false; // lease drží jiná instance + } catch (e) { + console.error(`Leader lease (${leaseKey}): chyba při získávání, úloha bude spuštěna`, e); + return true; // při chybě raději spustíme, než vynecháme + } + }, + + async release(): Promise { + if (process.env.STORAGE?.toLowerCase() !== 'redis') return; + try { + const c = getRedisClient(); + if (!c) return; + const currentHolder = await c.get(leaseKey); + if (currentHolder === POD_ID) { + await c.del(leaseKey); + console.log(`Leader lease (${leaseKey}): uvolněna`); + } + } catch (e) { + console.error(`Leader lease (${leaseKey}): chyba při uvolňování`, e); + } + }, + }; +} diff --git a/server/src/pushReminder.ts b/server/src/pushReminder.ts index bb0f3ee..89354a0 100644 --- a/server/src/pushReminder.ts +++ b/server/src/pushReminder.ts @@ -1,15 +1,14 @@ import webpush from 'web-push'; import crypto from 'crypto'; import getStorage from './storage'; -import { getRedisClient } from './storage/redis'; +import { createLeaderLease } from './leaderLease'; import { getClientData, getToday } from './service'; import { getIsWeekend } from './utils'; import { LunchChoices } from '../../types'; const storage = getStorage(); const REGISTRY_KEY = 'push_reminder_registry'; -const LEADER_LEASE_KEY = 'luncher:reminder:leader'; -const LEASE_TTL_SECONDS = 90; +const lease = createLeaderLease('luncher:reminder:leader'); const POD_ID = process.env.POD_ID ?? `local-${process.pid}`; @@ -43,49 +42,9 @@ function userHasChoice(choices: LunchChoices, login: string): boolean { return false; } -/** - * Pokusí se získat nebo obnovit leader lease pro scheduler připomínek. - * Vrátí true pokud tato instance smí spustit připomínky. - * Při non-Redis storage vždy vrací true (single-process, leader election není potřeba). - */ -async function tryAcquireOrRenewLease(): Promise { - if (process.env.STORAGE?.toLowerCase() !== 'redis') return true; - try { - const c = getRedisClient(); - if (!c) return true; - - // Zkusíme získat lease atomicky (SET NX EX) - const acquired = await c.set(LEADER_LEASE_KEY, POD_ID, { NX: true, EX: LEASE_TTL_SECONDS }); - if (acquired !== null) return true; // lease čerstvě získána - - // Pokud jsme ji nedostali, ověříme zda ji držíme my - const currentHolder = await c.get(LEADER_LEASE_KEY); - if (currentHolder === POD_ID) { - // Naše lease — obnovíme TTL - await c.set(LEADER_LEASE_KEY, POD_ID, { EX: LEASE_TTL_SECONDS }); - return true; - } - return false; // lease drží jiná instance - } catch (e) { - console.error('Push reminder: chyba při získávání lease, připomínky budou odeslány', e); - return true; // při chybě raději spustíme, než vynecháme - } -} - /** Uvolní leader lease při graceful shutdown. */ export async function releaseReminderLease(): Promise { - if (process.env.STORAGE?.toLowerCase() !== 'redis') return; - try { - const c = getRedisClient(); - if (!c) return; - const currentHolder = await c.get(LEADER_LEASE_KEY); - if (currentHolder === POD_ID) { - await c.del(LEADER_LEASE_KEY); - console.log('Push reminder: lease uvolněna'); - } - } catch (e) { - console.error('Push reminder: chyba při uvolňování lease', e); - } + await lease.release(); } /** Stopne scheduler připomínek. Volá se při graceful shutdown. */ @@ -140,7 +99,7 @@ async function checkAndSendReminders(): Promise { if (getIsWeekend(getToday())) return; // Leader election — pouze jeden pod spouští připomínky - const isLeader = await tryAcquireOrRenewLease(); + const isLeader = await lease.tryAcquireOrRenew(); if (!isLeader) return; const registry = await storage.getData(REGISTRY_KEY) ?? {}; diff --git a/server/src/routes/groupRoutes.ts b/server/src/routes/groupRoutes.ts index d0cec5c..2a1a961 100644 --- a/server/src/routes/groupRoutes.ts +++ b/server/src/routes/groupRoutes.ts @@ -2,7 +2,7 @@ import express, { Request } from "express"; import { getLogin } from "../auth"; import { parseToken } from "../utils"; import { getWebsocket } from "../websocket"; -import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, getOrderDates } from "../groups"; +import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupBoltTracking, getOrderDates } from "../groups"; import { GroupState } from "../../../types/gen/types.gen"; const router = express.Router(); @@ -160,4 +160,18 @@ router.post("/updateTimes", async (req: Request, res, next) => { } catch (e: any) { next(e); } }); +router.post("/setBoltTracking", async (req: Request, res, next) => { + const login = getLogin(parseToken(req)); + const { id, shareUrl } = req.body ?? {}; + if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' }); + if (shareUrl !== undefined && typeof shareUrl !== 'string') { + return res.status(400).json({ error: 'Neplatný odkaz na sledování objednávky Bolt' }); + } + try { + const data = await setGroupBoltTracking(login, id, shareUrl); + broadcastExtra(data); + res.status(200).json(data); + } catch (e: any) { next(e); } +}); + export default router; diff --git a/server/src/tests/boltTracking.test.ts b/server/src/tests/boltTracking.test.ts new file mode 100644 index 0000000..dbd1346 --- /dev/null +++ b/server/src/tests/boltTracking.test.ts @@ -0,0 +1,211 @@ +import axios from 'axios'; +import { resetMemoryStorage } from '../storage/memory'; +import getStorage from '../storage'; +import { addStore } from '../stores'; +import { createGroup, setGroupState, setGroupBoltTracking } from '../groups'; +import { extractBoltToken, computeDeliveryHHMM, checkBoltTracking } from '../boltTracking'; +import { ClientData, GroupState } from '../../../types/gen/types.gen'; +import { formatDate } from '../utils'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const mockEmit = jest.fn(); +jest.mock('../websocket', () => ({ + getWebsocket: () => ({ emit: mockEmit }), +})); + +const storage = getStorage(); + +const CREATOR = 'tomas'; +const USER = 'petr'; +const ADMIN_PW = 'testadmin'; +const STORE = 'McDonald\'s'; +const TOKEN = '0d521a8be3c4acebb26d8bd5716d91eac67050fb152a899a55fa19bd5ed65f15'; +const SHARE_URL = `https://food.bolt.eu/sharedActiveOrder/${TOKEN}`; + +function boltResponse(order: object | null) { + return { + data: { + code: 0, + message: 'OK', + data: { orders: order ? [order] : [], baskets: [] }, + }, + }; +} + +beforeEach(async () => { + resetMemoryStorage(); + jest.clearAllMocks(); + process.env.ADMIN_PASSWORD = ADMIN_PW; + await addStore(STORE, ADMIN_PW); +}); + +afterEach(() => { + delete process.env.ADMIN_PASSWORD; +}); + +describe('extractBoltToken', () => { + test('přijme plnou share URL', () => { + expect(extractBoltToken(SHARE_URL)).toBe(TOKEN); + }); + + test('toleruje lomítko, query a hash na konci', () => { + expect(extractBoltToken(`${SHARE_URL}/`)).toBe(TOKEN); + expect(extractBoltToken(`${SHARE_URL}?utm=x`)).toBe(TOKEN); + expect(extractBoltToken(`${SHARE_URL}#sekce`)).toBe(TOKEN); + expect(extractBoltToken(` ${SHARE_URL} `)).toBe(TOKEN); + }); + + test('přijme samotný token včetně velkých písmen', () => { + expect(extractBoltToken(TOKEN)).toBe(TOKEN); + expect(extractBoltToken(TOKEN.toUpperCase())).toBe(TOKEN.toUpperCase()); + }); + + test('odmítne neplatný vstup', () => { + expect(extractBoltToken('')).toBeNull(); + expect(extractBoltToken('nesmysl')).toBeNull(); + expect(extractBoltToken('https://food.bolt.eu/sharedActiveOrder/abc123')).toBeNull(); + expect(extractBoltToken(`https://food.bolt.eu/sharedActiveOrder/${'z'.repeat(64)}`)).toBeNull(); + expect(extractBoltToken(TOKEN.slice(0, 63))).toBeNull(); + }); +}); + +describe('computeDeliveryHHMM', () => { + test('přičte sekundy k aktuálnímu času', () => { + expect(computeDeliveryHHMM(1800, new Date('2025-01-10T11:00:00'))).toBe('11:30'); + }); + + test('přechod přes půlnoc', () => { + expect(computeDeliveryHHMM(1200, new Date('2025-01-10T23:50:00'))).toBe('00:10'); + }); +}); + +describe('setGroupBoltTracking', () => { + const TODAY = new Date('2025-01-10'); + let groupId: string; + + beforeEach(async () => { + const d = await createGroup(CREATOR, STORE, TODAY); + groupId = d.groups![0].id; + await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY); + await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY); + }); + + test('uloží token ze share URL', async () => { + const d = await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY); + expect(d.groups![0].boltTrackingToken).toBe(TOKEN); + }); + + test('prázdná hodnota sledování zruší', async () => { + await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY); + const d = await setGroupBoltTracking(CREATOR, groupId, '', TODAY); + expect(d.groups![0].boltTrackingToken).toBeUndefined(); + }); + + test('odmítne neplatný odkaz', async () => { + await expect(setGroupBoltTracking(CREATOR, groupId, 'nesmysl', TODAY)).rejects.toThrow('Neplatný odkaz'); + }); + + test('nezakladatel nemůže sledování nastavit', async () => { + await expect(setGroupBoltTracking(USER, groupId, SHARE_URL, TODAY)).rejects.toThrow('zakladatel'); + }); + + test('nelze nastavit mimo stav objednáno', async () => { + const d = await createGroup(CREATOR, STORE, TODAY); + const openGroupId = d.groups![1].id; + await expect(setGroupBoltTracking(CREATOR, openGroupId, SHARE_URL, TODAY)).rejects.toThrow('objednáno'); + }); +}); + +describe('checkBoltTracking', () => { + // Scheduler čte vždy dnešní data (getToday), proto se skupiny zakládají bez explicitního data + const extraKey = () => `${formatDate(new Date())}_extra`; + let groupId: string; + + beforeEach(async () => { + const d = await createGroup(CREATOR, STORE); + groupId = d.groups![0].id; + await setGroupState(CREATOR, groupId, GroupState.LOCKED); + await setGroupState(CREATOR, groupId, GroupState.ORDERED); + await setGroupBoltTracking(CREATOR, groupId, SHARE_URL); + }); + + async function getGroup() { + const data = await storage.getData(extraKey()); + return data!.groups!.find(g => g.id === groupId)!; + } + + test('aktualizuje deliveryAt podle expected_time_to_client_in_seconds', async () => { + mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'waiting_preparation', expected_time_to_client_in_seconds: 1800 })); + const before = computeDeliveryHHMM(1800); + await checkBoltTracking(); + const after = computeDeliveryHHMM(1800); + const group = await getGroup(); + expect([before, after]).toContain(group.deliveryAt); + expect(group.boltTrackingToken).toBe(TOKEN); + expect(mockEmit).toHaveBeenCalledWith('message', expect.anything()); + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('getOrderPolling'), + { token: TOKEN }, + expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }), + ); + }); + + test('nezapisuje, pokud se čas nezměnil', async () => { + mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 })); + await checkBoltTracking(); + expect(mockEmit).toHaveBeenCalledTimes(1); + await checkBoltTracking(); + expect(mockEmit).toHaveBeenCalledTimes(1); + }); + + test('ukončí sledování po doručení (token smazán, deliveryAt zůstává)', async () => { + mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 })); + await checkBoltTracking(); + mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered', expected_time_to_client_in_seconds: 0 })); + await checkBoltTracking(); + const group = await getGroup(); + expect(group.boltTrackingToken).toBeUndefined(); + expect(group.deliveryAt).toMatch(/^\d{2}:\d{2}$/); + }); + + test('ukončí sledování, když objednávka už neexistuje', async () => { + mockedAxios.post.mockResolvedValue(boltResponse(null)); + await checkBoltTracking(); + const group = await getGroup(); + expect(group.boltTrackingToken).toBeUndefined(); + }); + + test('chybová odpověď Bolt API (code != 0) se počítá jako selhání', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockedAxios.post.mockResolvedValue({ data: { code: 42, message: 'FAIL' } }); + await checkBoltTracking(); + const group = await getGroup(); + expect(group.boltTrackingToken).toBe(TOKEN); + errorSpy.mockRestore(); + }); + + test('po 10 po sobě jdoucích selháních sledování ukončí', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockedAxios.post.mockRejectedValue(new Error('network down')); + for (let i = 0; i < 9; i++) { + await checkBoltTracking(); + } + expect((await getGroup()).boltTrackingToken).toBe(TOKEN); + await checkBoltTracking(); + expect((await getGroup()).boltTrackingToken).toBeUndefined(); + errorSpy.mockRestore(); + }); + + test('ignoruje skupiny mimo stav objednáno', async () => { + await storage.updateData(extraKey(), (current) => { + const d = current!; + const g = d.groups!.find(x => x.id === groupId)!; + g.state = GroupState.LOCKED; + return d; + }); + await checkBoltTracking(); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/tests/groups.test.ts b/server/src/tests/groups.test.ts index 5f9d86d..fe68e1e 100644 --- a/server/src/tests/groups.test.ts +++ b/server/src/tests/groups.test.ts @@ -1,6 +1,6 @@ import { resetMemoryStorage } from '../storage/memory'; import { getStores, addStore } from '../stores'; -import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState } from '../groups'; +import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, setGroupBoltTracking } from '../groups'; import { GroupState } from '../../../types/gen/types.gen'; const CREATOR = 'tomas'; @@ -192,4 +192,13 @@ describe('setGroupState', () => { test('nečlen nemůže měnit stav', async () => { await expect(setGroupState(USER, groupId, GroupState.LOCKED, TODAY)).rejects.toThrow('zakladatel'); }); + + test('ordered → locked smaže boltTrackingToken', async () => { + await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY); + await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY); + const token = 'a'.repeat(64); + await setGroupBoltTracking(CREATOR, groupId, `https://food.bolt.eu/sharedActiveOrder/${token}`, TODAY); + const d = await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY); + expect(d.groups![0].boltTrackingToken).toBeUndefined(); + }); }); diff --git a/types/api.yml b/types/api.yml index c2e29a8..0c62ed8 100644 --- a/types/api.yml +++ b/types/api.yml @@ -100,6 +100,8 @@ paths: $ref: "./paths/groups/setState.yml" /groups/updateTimes: $ref: "./paths/groups/updateTimes.yml" + /groups/setBoltTracking: + $ref: "./paths/groups/setBoltTracking.yml" /groups/updateFees: $ref: "./paths/groups/updateFees.yml" diff --git a/types/paths/groups/setBoltTracking.yml b/types/paths/groups/setBoltTracking.yml new file mode 100644 index 0000000..55f8405 --- /dev/null +++ b/types/paths/groups/setBoltTracking.yml @@ -0,0 +1,21 @@ +post: + operationId: setBoltTracking + summary: Nastaví nebo zruší sledování objednávky Bolt Food (pouze zakladatel). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + description: ID skupiny + type: string + shareUrl: + description: Sdílecí URL objednávky Bolt Food (https://food.bolt.eu/sharedActiveOrder/...). Prázdná hodnota sledování zruší. + type: string + responses: + "200": + $ref: "../../api.yml#/components/responses/ClientDataResponse" diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 29796a5..518e76f 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -786,6 +786,9 @@ OrderGroup: qrGenerated: description: Příznak, zda byly pro skupinu vygenerovány QR kódy (blokuje opakované generování) type: boolean + boltTrackingToken: + description: Token sdíleného sledování objednávky Bolt Food (poslední segment share URL). Pokud je vyplněn, server automaticky aktualizuje deliveryAt. + type: string # --- NEVYŘÍZENÉ QR KÓDY --- PendingQr: