diff --git a/client/src/components/BoltOrderProgress.tsx b/client/src/components/BoltOrderProgress.tsx deleted file mode 100644 index a29d0be..0000000 --- a/client/src/components/BoltOrderProgress.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { OverlayTrigger, Tooltip } from 'react-bootstrap'; -import './BoltOrderProgress.scss'; - -const STEPS = ['Přijato', 'Příprava', 'Vyzvedávání', 'Na cestě', 'Doručeno']; - -/** - * Známé stavy objednávky z Bolt API → index kroku ve stepperu. - * Pozor: waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí", - * nikoli "na cestě" — tu signalizuje až stav kurýra (picked_up apod.). - * Krok „Vyzvedávání" je vyhrazen pro kombinaci „kurýr u podniku + jídlo hotové" - * (viz logika níže), samotný order_state ho neudělí — jinak bychom hlásili - * vyzvedávání i když se ještě peče (chování, na které si Bolt sám občas stěžuje). - */ -const ORDER_STATE_TO_STEP: Record = { - created: 0, - pending: 0, - waiting_acceptance: 0, - accepted: 0, - waiting_preparation: 0, - preparing: 1, - waiting_delivery: 1, - ready_for_pickup: 1, - waiting_courier: 1, - waiting_pickup: 1, - picked_up: 3, - in_delivery: 3, - delivering: 3, - heading_to_client: 3, - delivered: 4, - finished: 4, -}; - -/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */ -const COURIER_STATE_TO_STEP: Record = { - matched: 0, - accepted: 0, - heading_to_provider: 1, - arrived_to_provider: 1, - picked_up: 3, - heading_to_client: 3, - delivering: 3, - arrived_to_client: 3, - delivered: 4, -}; - -/** Order states ve kterých už jídlo čeká připravené na kurýra. */ -const PICKUP_READY_ORDER_STATES = new Set(['waiting_delivery', 'ready_for_pickup', 'waiting_courier', 'waiting_pickup']); - -/** Neznámé stavy se mapují heuristicky podle klíčových slov. */ -function stepForOrderState(state: string): number | 'cancelled' { - const s = state.toLowerCase(); - if (s in ORDER_STATE_TO_STEP) return ORDER_STATE_TO_STEP[s]; - if (/cancel|reject|fail/.test(s)) return 'cancelled'; - if (/delivered|finished/.test(s)) return 4; - if (/accept|pending|created/.test(s)) return 0; - if (/^waiting|prepar|ready|cook/.test(s)) return 1; - if (/picked|delivering|heading_to_client|transport/.test(s)) return 3; - return 0; -} - -function stepForCourierState(state?: string): number { - if (!state) return 0; - const s = state.toLowerCase(); - if (s in COURIER_STATE_TO_STEP) return COURIER_STATE_TO_STEP[s]; - if (/picked|client|delivering|transport/.test(s)) return 3; - return 0; -} - -interface Props { - /** Raw order_state z Bolt API (např. waiting_preparation) */ - state: string; - /** Raw courier.state z Bolt API (např. arrived_to_provider) */ - courierState?: string; - /** Zda sledování stále běží (skupina má boltTrackingToken) */ - tracking: boolean; -} - -/** Mini progress stepper se stavem objednávky Bolt Food. */ -export default function BoltOrderProgress({ state, courierState, tracking }: Props) { - const orderStep = stepForOrderState(state); - if (orderStep === 'cancelled') { - return Objednávka Bolt byla zrušena; - } - // Stav kurýra může krok jen zpřesnit dopředu (např. waiting_delivery + picked_up → Na cestě) - let step = Math.max(orderStep, stepForCourierState(courierState)); - // Vyzvedávání = kurýr u podniku a jídlo skutečně hotové. Sám arrived_to_provider - // (bez toho, že by jídlo bylo hotové) nestačí — viz komentář u ORDER_STATE_TO_STEP. - const courierAtProvider = courierState?.toLowerCase() === 'arrived_to_provider'; - const foodReadyForPickup = PICKUP_READY_ORDER_STATES.has(state.toLowerCase()); - if (step < 3 && courierAtProvider && foodReadyForPickup) step = 2; - const rawInfo = courierState ? `${state} / kurýr: ${courierState}` : state; - return ( - Stav z Bolt Food: {rawInfo}}> -
- {STEPS.map((label, i) => ( -
-
-
{label}
-
- ))} -
- - ); -} diff --git a/client/src/components/BoltOrderProgress.scss b/client/src/components/OrderProgress.scss similarity index 84% rename from client/src/components/BoltOrderProgress.scss rename to client/src/components/OrderProgress.scss index 3825a5f..cf21eac 100644 --- a/client/src/components/BoltOrderProgress.scss +++ b/client/src/components/OrderProgress.scss @@ -1,9 +1,9 @@ -.bolt-progress { +.tracking-progress { display: flex; align-items: flex-start; max-width: 400px; - .bolt-step { + .tracking-step { position: relative; display: flex; flex-direction: column; @@ -11,7 +11,7 @@ flex: 1; min-width: 64px; - .bolt-dot { + .tracking-dot { position: relative; z-index: 1; width: 12px; @@ -20,7 +20,7 @@ background: var(--luncher-border, #ced4da); } - .bolt-label { + .tracking-label { margin-top: 2px; font-size: 0.7em; color: var(--luncher-text-muted, #6c757d); @@ -40,7 +40,7 @@ } &.done { - .bolt-dot { + .tracking-dot { background: var(--bs-success, #198754); } @@ -49,19 +49,19 @@ } } - &.active .bolt-label { + &.active .tracking-label { font-weight: 600; color: inherit; } } // Pulzování aktivního kroku, dokud sledování běží - &.live .bolt-step.active .bolt-dot { - animation: bolt-pulse 2s ease-in-out infinite; + &.live .tracking-step.active .tracking-dot { + animation: tracking-pulse 2s ease-in-out infinite; } } -@keyframes bolt-pulse { +@keyframes tracking-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5); } diff --git a/client/src/components/OrderProgress.tsx b/client/src/components/OrderProgress.tsx new file mode 100644 index 0000000..e0d2e56 --- /dev/null +++ b/client/src/components/OrderProgress.tsx @@ -0,0 +1,145 @@ +import { OverlayTrigger, Tooltip } from 'react-bootstrap'; +import { TrackingProvider } from '../../../types'; +import './OrderProgress.scss'; + +const STEPS = ['Přijato', 'Příprava', 'Vyzvedávání', 'Na cestě', 'Doručeno']; + +/** Lidský název rozvozové služby do tooltipu. */ +export const PROVIDER_LABEL: Record = { + bolt: 'Bolt Food', + wolt: 'Wolt', +}; + +/** + * Známé stavy objednávky z Bolt API → index kroku ve stepperu. + * Pozor: waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí", + * nikoli "na cestě" — tu signalizuje až stav kurýra (picked_up apod.). + * Krok „Vyzvedávání" je vyhrazen pro kombinaci „kurýr u podniku + jídlo hotové" + * (viz logika níže), samotný order_state ho neudělí — jinak bychom hlásili + * vyzvedávání i když se ještě peče (chování, na které si Bolt sám občas stěžuje). + */ +const BOLT_STATE_TO_STEP: Record = { + created: 0, + pending: 0, + waiting_acceptance: 0, + accepted: 0, + waiting_preparation: 0, + preparing: 1, + waiting_delivery: 1, + ready_for_pickup: 1, + waiting_courier: 1, + waiting_pickup: 1, + picked_up: 3, + in_delivery: 3, + delivering: 3, + heading_to_client: 3, + delivered: 4, + finished: 4, +}; + +/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */ +const BOLT_COURIER_STATE_TO_STEP: Record = { + matched: 0, + accepted: 0, + heading_to_provider: 1, + arrived_to_provider: 1, + picked_up: 3, + heading_to_client: 3, + delivering: 3, + arrived_to_client: 3, + delivered: 4, +}; + +/** + * Známé stavy objednávky z Wolt tracking API → index kroku ve stepperu. + * Stav "ready" znamená hotové jídlo čekající na kurýra, proto zůstává v „Přípravě" + * a na „Vyzvedávání" se posune, až když je kurýr přiřazený. Že objednávku kurýr + * skutečně veze, se pozná až ze stavu kurýra 'delivering' (viz trackingProviders.ts). + */ +const WOLT_STATE_TO_STEP: Record = { + received: 0, + acknowledged: 0, + confirmed: 0, + waiting: 0, + production: 1, + preparing: 1, + ready: 1, + picked_up: 3, + transport: 3, + delivering: 3, + delivered: 4, +}; + +/** Stavy, ve kterých už jídlo čeká připravené na kurýra. */ +const PICKUP_READY_STATES: Record> = { + bolt: new Set(['waiting_delivery', 'ready_for_pickup', 'waiting_courier', 'waiting_pickup']), + wolt: new Set(['ready']), +}; + +/** Neznámé stavy se mapují heuristicky podle klíčových slov. */ +function stepForOrderState(provider: TrackingProvider, state: string): number | 'cancelled' { + const s = state.toLowerCase(); + const known = provider === 'wolt' ? WOLT_STATE_TO_STEP : BOLT_STATE_TO_STEP; + if (s in known) return known[s]; + if (/cancel|reject|fail/.test(s)) return 'cancelled'; + if (/delivered|finished/.test(s)) return 4; + if (/accept|pending|created|received/.test(s)) return 0; + if (/^waiting|prepar|ready|cook|production/.test(s)) return 1; + if (/picked|delivering|heading_to_client|transport/.test(s)) return 3; + return 0; +} + +function stepForCourierState(provider: TrackingProvider, state?: string): number { + if (!state) return 0; + const s = state.toLowerCase(); + // Wolt má jen odvozené stavy: 'assigned' o kroku nic neříká, 'delivering' = veze naši objednávku + if (provider === 'wolt') return s === 'delivering' ? 3 : 0; + if (s in BOLT_COURIER_STATE_TO_STEP) return BOLT_COURIER_STATE_TO_STEP[s]; + if (/picked|client|delivering|transport/.test(s)) return 3; + return 0; +} + +interface Props { + /** Rozvozová služba, ze které stav pochází */ + provider: TrackingProvider; + /** Raw stav objednávky (Bolt order_state, Wolt status) */ + state: string; + /** Raw stav kurýra (Bolt courier.state, Wolt 'assigned') */ + courierState?: string; + /** Zda sledování stále běží (skupina má trackingCode) */ + tracking: boolean; +} + +/** Mini progress stepper se stavem objednávky u rozvozové služby. */ +export default function OrderProgress({ provider, state, courierState, tracking }: Props) { + const label = PROVIDER_LABEL[provider] ?? provider; + const orderStep = stepForOrderState(provider, state); + if (orderStep === 'cancelled') { + return Objednávka {label} byla zrušena; + } + // Stav kurýra může krok jen zpřesnit dopředu (např. waiting_delivery + picked_up → Na cestě) + let step = Math.max(orderStep, stepForCourierState(provider, courierState)); + // Vyzvedávání = kurýr u podniku a jídlo skutečně hotové. Sám arrived_to_provider + // (bez toho, že by jídlo bylo hotové) nestačí — viz komentář u BOLT_STATE_TO_STEP. + const courierAtProvider = provider === 'wolt' + ? !!courierState + : courierState?.toLowerCase() === 'arrived_to_provider'; + const foodReadyForPickup = PICKUP_READY_STATES[provider]?.has(state.toLowerCase()); + if (step < 3 && courierAtProvider && foodReadyForPickup) step = 2; + const courierInfo = provider === 'wolt' + ? (courierState === 'delivering' ? 'kurýr veze objednávku' : 'kurýr přiřazen') + : `kurýr: ${courierState}`; + const rawInfo = courierState ? `${state} / ${courierInfo}` : state; + return ( + Stav z {label}: {rawInfo}}> +
+ {STEPS.map((stepLabel, i) => ( +
+
+
{stepLabel}
+
+ ))} +
+ + ); +} diff --git a/client/src/components/modals/BoltSimulationModal.tsx b/client/src/components/modals/BoltSimulationModal.tsx index fe32837..f328b76 100644 --- a/client/src/components/modals/BoltSimulationModal.tsx +++ b/client/src/components/modals/BoltSimulationModal.tsx @@ -12,7 +12,7 @@ type Props = { group: OrderGroup; }; -/** Nabídka stavů pro ruční nastavení (odpovídá mapování v BoltOrderProgress). */ +/** Nabídka stavů pro ruční nastavení (odpovídá mapování v OrderProgress). */ const STATE_OPTIONS: { key: string; label: string; order_state: string; courier_state?: string }[] = [ { key: 'accepted', label: 'Přijato', order_state: 'accepted' }, { key: 'preparing', label: 'Příprava', order_state: 'preparing' }, @@ -30,7 +30,7 @@ export default function BoltSimulationModal({ isOpen, onClose, group }: Readonly const [info, setInfo] = useState(null); const [manualKey, setManualKey] = useState('preparing'); - const running = !!group.boltTrackingToken; + const running = !!group.trackingCode; /** Obecný runner — spustí akci, ošetří chybu a krátce zobrazí výsledek. */ const run = async (action: () => Promise<{ error?: unknown }>, okMsg: string) => { @@ -113,7 +113,7 @@ export default function BoltSimulationModal({ isOpen, onClose, group }: Readonly

Stav simulace:{' '} {running - ? běží{group.boltOrderState ? ` — ${group.boltOrderState}` : ''} + ? běží{group.trackingOrderState ? ` — ${group.trackingOrderState}` : ''} : neběží}

diff --git a/client/src/components/modals/SettingsModal.tsx b/client/src/components/modals/SettingsModal.tsx index f976347..af0be15 100644 --- a/client/src/components/modals/SettingsModal.tsx +++ b/client/src/components/modals/SettingsModal.tsx @@ -141,13 +141,13 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly - Až bude skupinová objednávka sledovaná přes Bolt Food doručena, přijde vám push notifikace. - Zakladateli skupiny se neposílá — ten dostane upozornění přímo z aplikace Bolt. + Až bude sledovaná skupinová objednávka doručena, přijde vám push notifikace. + Zakladateli skupiny se neposílá — ten dostane upozornění přímo z aplikace rozvozové služby. diff --git a/client/src/pages/OrderGroupsPage.tsx b/client/src/pages/OrderGroupsPage.tsx index 68ad9cc..b9b3d5e 100644 --- a/client/src/pages/OrderGroupsPage.tsx +++ b/client/src/pages/OrderGroupsPage.tsx @@ -7,8 +7,8 @@ import DatePicker, { registerLocale } from 'react-datepicker'; 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, setBoltTracking, getOrderDates, + ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr, TrackingProvider, + getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setTracking, getOrderDates, } from '../../../types'; import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees'; import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket'; @@ -24,26 +24,57 @@ import PayForGroupModal from '../components/modals/PayForGroupModal'; import EditGroupFeesModal from '../components/modals/EditGroupFeesModal'; import BoltSimulationModal from '../components/modals/BoltSimulationModal'; import PendingPayments from '../components/PendingPayments'; -import BoltOrderProgress from '../components/BoltOrderProgress'; +import OrderProgress, { PROVIDER_LABEL } from '../components/OrderProgress'; 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; const IS_DEV = process.env.NODE_ENV === 'development'; -/** Vytáhne sledovací token ze sdílecí URL Bolt Food, nebo přijme samotný token. Null = neplatný vstup. */ -function extractBoltToken(input: string): string | null { +/** Předpony sdílecích odkazů jednotlivých služeb (pro sestavení odkazu z uloženého kódu). */ +const TRACKING_URL_PREFIX: Record = { + bolt: 'https://food.bolt.eu/sharedActiveOrder/', + wolt: 'https://track.wolt.com/', +}; +/** Tvar kódu sledování u jednotlivých služeb — musí odpovídat serveru (trackingProviders.ts). */ +const TRACKING_CODE_REGEX: Record = { + bolt: /^[0-9a-f]{64}$/i, + wolt: /^[A-Za-z0-9_-]{20,32}$/, +}; +const TRACKING_HOST: Record = { + bolt: 'bolt.eu', + wolt: 'wolt.com', +}; + +/** Sestaví odkaz na sledování objednávky ze služby a kódu. */ +function trackingUrlFor(provider: TrackingProvider, code: string): string { + return `${TRACKING_URL_PREFIX[provider] ?? ''}${code}`; +} + +/** + * Rozpozná rozvozovou službu podle odkazu na sledování (nebo podle samotného kódu) + * a vytáhne z něj kód sledování. Null = vstup nepatří žádné podporované službě. + */ +function extractTracking(input: string): { provider: TrackingProvider; code: string } | null { const trimmed = input.trim(); if (!trimmed) return null; - if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed; + let hostname = ''; + let lastSegment = ''; 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; + const url = new URL(trimmed); + hostname = url.hostname.toLowerCase(); + const segments = url.pathname.split('/').filter(Boolean); + lastSegment = segments[segments.length - 1] ?? ''; } catch { - return null; + // Vstup není URL — zkusíme ho vzít jako samotný kód } + for (const provider of Object.values(TrackingProvider)) { + const regex = TRACKING_CODE_REGEX[provider]; + if (!hostname && regex.test(trimmed)) return { provider, code: trimmed }; + const host = TRACKING_HOST[provider]; + const hostMatches = hostname === host || hostname.endsWith(`.${host}`); + if (hostMatches && regex.test(lastSegment)) return { provider, code: lastSegment }; + } + return null; } /** Zkrátí dlouhý odkaz pro zobrazení v řádku (zachová začátek i konec). */ @@ -99,7 +130,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 [boltSimModal, setBoltSimModal] = useState(null); @@ -276,7 +307,7 @@ export default function OrderGroupsPage() { const handleSaveTimes = async (group: OrderGroup) => { const times = editTimes[group.id]; if (!times) return; - const { orderedAt, deliveryAt, boltUrl } = times; + const { orderedAt, deliveryAt, trackingUrl } = times; if (orderedAt && !TIME_REGEX.test(orderedAt)) { setPageError('Čas objednání musí být ve formátu HH:MM'); return; @@ -285,16 +316,16 @@ export default function OrderGroupsPage() { setPageError('Čas doručení musí být ve formátu HH:MM'); return; } - // 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)'); + // Odkaz na sledování se odesílá jen při změně oproti aktuálnímu kódu skupiny + const tracking = trackingUrl.trim() ? extractTracking(trackingUrl) : null; + if (trackingUrl.trim() && !tracking) { + setPageError('Neplatný odkaz pro sledování (očekáván odkaz Bolt Food nebo Wolt)'); return; } - const boltChanged = (boltToken ?? undefined) !== group.boltTrackingToken; + const trackingChanged = tracking?.code !== group.trackingCode || tracking?.provider !== group.trackingProvider; 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 && trackingChanged) { + ok = await refresh(() => setTracking({ body: { id: group.id, shareUrl: trackingUrl.trim() } })); } if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; }); }; @@ -731,13 +762,13 @@ export default function OrderGroupsPage() { />
- Bolt odkaz pro sledování: + Odkaz pro sledování (Bolt Food / Wolt): setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], boltUrl: e.target.value } }))} + placeholder={`${TRACKING_URL_PREFIX.wolt}…`} + value={editTimes[group.id]?.trackingUrl ?? ''} + onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], trackingUrl: e.target.value } }))} onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }} style={{ width: 260 }} /> @@ -750,8 +781,10 @@ export default function OrderGroupsPage() { {(() => { const canEdit = !isReadOnly && isCreator; // Aktivace editačního režimu – stejné chování jako tlačítko s tužkou - const startEdit = () => canEdit && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', boltUrl: group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : '' } })); - const trackingUrl = group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : null; + const trackingUrl = group.trackingProvider && group.trackingCode + ? trackingUrlFor(group.trackingProvider, group.trackingCode) + : null; + const startEdit = () => canEdit && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', trackingUrl: trackingUrl ?? '' } })); return ( <> {canEdit && ( @@ -775,9 +808,9 @@ export default function OrderGroupsPage() { onClick={startEdit} > Doručení v: {group.deliveryAt ?? '—'} - {group.boltTrackingToken && ( - Čas doručení se aktualizuje automaticky z Bolt Food}> - Bolt + {group.trackingCode && group.trackingProvider && ( + Čas doručení se aktualizuje automaticky z {PROVIDER_LABEL[group.trackingProvider]}}> + {PROVIDER_LABEL[group.trackingProvider]} )} @@ -802,9 +835,14 @@ export default function OrderGroupsPage() { })()}
)} - {group.boltOrderState && ( + {group.trackingOrderState && group.trackingProvider && (
- +
)} {IS_DEV && ( diff --git a/e2e/tests/notification-settings.spec.ts b/e2e/tests/notification-settings.spec.ts index aaa62e1..a467751 100644 --- a/e2e/tests/notification-settings.spec.ts +++ b/e2e/tests/notification-settings.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import { loginViaApi } from './helpers'; -const BOLT_LABEL = 'Upozornění na doručení objednávky (Bolt Food)'; +const DELIVERY_LABEL = 'Upozornění na doručení objednávky (Bolt Food / Wolt)'; test.beforeEach(async ({ page }) => { await loginViaApi(page, 'e2e-user'); @@ -15,24 +15,24 @@ async function openSettings(page: import('@playwright/test').Page) { await expect(page.locator('.modal-title')).toContainText('Nastavení', { timeout: 5_000 }); } -test('Přepínač doručení Bolt je pod výběrem času připomínky', async ({ page }) => { +test('Přepínač doručení objednávky je pod výběrem času připomínky', async ({ page }) => { await openSettings(page); const reminderGroup = page.locator('.modal-body .mb-3').filter({ hasText: 'Připomínka výběru oběda' }); - const boltGroup = page.locator('.modal-body .mb-3').filter({ hasText: BOLT_LABEL }); + const deliveryGroup = page.locator('.modal-body .mb-3').filter({ hasText: DELIVERY_LABEL }); await expect(reminderGroup).toBeVisible(); - await expect(boltGroup).toBeVisible(); + await expect(deliveryGroup).toBeVisible(); // Přepínač musí v DOM následovat až za skupinou s časem připomínky const order = await page.evaluate((label) => { const groups = Array.from(document.querySelectorAll('.modal-body .mb-3')); return { reminder: groups.findIndex(g => g.textContent?.includes('Připomínka výběru oběda')), - bolt: groups.findIndex(g => g.textContent?.includes(label)), + delivery: groups.findIndex(g => g.textContent?.includes(label)), }; - }, BOLT_LABEL); + }, DELIVERY_LABEL); expect(order.reminder).toBeGreaterThanOrEqual(0); - expect(order.bolt).toBe(order.reminder + 1); + expect(order.delivery).toBe(order.reminder + 1); // Výchozí stav je vypnuto await expect(page.locator('#boltDeliveredCheckbox')).not.toBeChecked(); diff --git a/server/.env.template b/server/.env.template index 4526ff7..677cc93 100644 --- a/server/.env.template +++ b/server/.env.template @@ -53,7 +53,7 @@ # Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403). # ADMIN_PASSWORD= -# Interval (ms) scheduleru sledování objednávek Bolt Food. Výchozí 60000 (60 s). +# Interval (ms) scheduleru sledování objednávek (Bolt Food, Wolt). Výchozí 60000 (60 s). # Pro vývoj se simulací lze zkrátit (min. 1000), aby se změny stavu projevily rychleji. # BOLT_POLL_INTERVAL_MS=3000 diff --git a/server/changelogs/2026-08-24.json b/server/changelogs/2026-08-24.json new file mode 100644 index 0000000..c5f39ff --- /dev/null +++ b/server/changelogs/2026-08-24.json @@ -0,0 +1,4 @@ +[ + "Sledování objednávky nově i přes Wolt — stačí u objednané skupiny vložit odkaz z track.wolt.com", + "Upozornění na doručení objednávky nyní chodí i u objednávek sledovaných přes Wolt" +] diff --git a/server/src/boltSimulator.ts b/server/src/boltSimulator.ts index bef7d09..53dd930 100644 --- a/server/src/boltSimulator.ts +++ b/server/src/boltSimulator.ts @@ -4,7 +4,7 @@ import crypto from 'crypto'; * Vývojový simulátor sledování objednávek Bolt Food. * * Drží in-memory registr „simulovaných" objednávek klíčovaný tokenem. Funkce - * pollBoltOrder v boltTracking.ts se na začátku podívá, zda je token simulovaný, + * pollBoltOrder v trackingProviders.ts se na začátku podívá, zda je token simulovaný, * a pokud ano, vrátí vyfabrikovaný stav místo dotazu na reálné Bolt API. * * Registr se plní výhradně přes dev endpointy (gated requireDevMode), takže @@ -13,7 +13,7 @@ import crypto from 'crypto'; * Postup stavů je řízen ručně (krokováním), bez časové osy — viz advance/setState. */ -/** Jeden krok simulace — odpovídá tomu, co vrací Bolt API a co čte BoltOrderProgress. */ +/** Jeden krok simulace — odpovídá tomu, co vrací Bolt API a co čte OrderProgress. */ export interface SimStep { order_state: string; courier_state?: string; @@ -40,7 +40,7 @@ interface Simulation { /** * Výchozí scénář „happy path". Stavy a stavy kurýra odpovídají mapování ve - * client/src/components/BoltOrderProgress.tsx + * client/src/components/OrderProgress.tsx * (Přijato → Příprava → Vyzvedávání → Na cestě → Doručeno). */ export const DEFAULT_SCENARIO: SimStep[] = [ diff --git a/server/src/boltTracking.ts b/server/src/boltTracking.ts deleted file mode 100644 index f8b90bc..0000000 --- a/server/src/boltTracking.ts +++ /dev/null @@ -1,217 +0,0 @@ -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'; -import { isBoltSimulated, getSimulatedBoltOrder } from './boltSimulator'; -import { notifyBoltDelivered } from './notifikace'; - -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 DELIVERED_STATE_REGEX = /delivered|finished/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; - courier?: { state?: string } | null; -} - -/** 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 { - // DEV simulace: simulované tokeny obsluhuje boltSimulator místo reálného Bolt API. - // V produkci je registr vždy prázdný, takže se sem nikdy nedostane. - if (isBoltSimulated(token)) { - return getSimulatedBoltOrder(token); - } - const res = await axios.post(BOLT_POLLING_URL, { token }, { - params: { - version: 'FW.1.113', - language: 'cs-CZ', - country: 'cz', - device_name: 'web', - device_os_version: 'web', - deviceType: 'web', - session_id: DEVICE_ID, - distinct_id: `$device:${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 orderState: string | undefined; - let courierState: string | undefined; - let clearToken = false; - - try { - const order = await pollBoltOrder(group.boltTrackingToken!); - consecutiveFailures.delete(group.id); - if (!order) { - // Objednávka z API zmizela — považujeme ji za doručenou - orderState = 'delivered'; - clearToken = true; - } else { - orderState = order.order_state || undefined; - courierState = order.courier?.state || undefined; - if (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; - } - - const timeChanged = deliveryAt !== undefined && deliveryAt !== group.deliveryAt; - const stateChanged = orderState !== undefined && orderState !== group.boltOrderState; - const courierChanged = courierState !== group.boltCourierState && !clearToken; - if (!clearToken && !timeChanged && !stateChanged && !courierChanged) continue; - - // Notifikujeme jen skutečný přechod do doručeného stavu. Porovnání s předchozím - // stavem hlídá duplicity jak u přechodu delivered → finished, tak u tiku, kdy - // objednávka zmizí z API poté, co už byla označena za doručenou. - const deliveredNow = stateChanged - && DELIVERED_STATE_REGEX.test(orderState ?? '') - && !DELIVERED_STATE_REGEX.test(group.boltOrderState ?? ''); - - // Log každého přechodu stavu — Bolt API není dokumentované, takže si takhle - // průběžně mapujeme jeho stavový automat (viz mapování v BoltOrderProgress.tsx). - if (stateChanged || courierChanged) { - console.log( - `Bolt tracking: skupina "${group.name}" stav ${group.boltOrderState ?? '(žádný)'} → ${orderState ?? '(žádný)'}` + - `, kurýr ${group.boltCourierState ?? '(žádný)'} → ${courierState ?? '(žádný)'}` - ); - } - - updated = await storage.updateData(key, current => { - const d = current ?? data!; - const g = d.groups?.find(x => x.id === group.id); - if (g?.boltTrackingToken) { - if (timeChanged) g.deliveryAt = deliveryAt; - if (stateChanged) g.boltOrderState = orderState; - if (courierChanged) g.boltCourierState = courierState; - if (clearToken) g.boltTrackingToken = undefined; - } - return d; - }); - if (clearToken) { - console.log(`Bolt tracking: sledování skupiny "${group.name}" ukončeno`); - } - - // Až po uložení stavu, aby pád neposlal notifikaci podruhé. - // Selhání push nesmí shodit celý tik sledování. - if (deliveredNow) { - try { - await notifyBoltDelivered(group.name, Object.keys(group.members ?? {}), group.creatorLogin); - } catch (e) { - console.error(`Bolt tracking: chyba při odesílání notifikace o doručení skupiny "${group.name}"`, e); - } - } - } - - if (updated) { - getWebsocket()?.emit('message', updated); - } -} - -/** - * Spustí scheduler pro sledování Bolt objednávek. Interval je 60 s, lze ho ale - * zkrátit přes env BOLT_POLL_INTERVAL_MS (užitečné při vývoji se simulací). - */ -export function startBoltTrackingScheduler(): void { - const parsed = Number(process.env.BOLT_POLL_INTERVAL_MS); - const intervalMs = Number.isFinite(parsed) && parsed >= 1000 ? parsed : 60_000; - boltInterval = setInterval(checkBoltTracking, intervalMs); - console.log(`Bolt tracking: scheduler spuštěn (interval ${intervalMs} ms)`); -} - -/** 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 cec1067..6d713c5 100644 --- a/server/src/groups.ts +++ b/server/src/groups.ts @@ -3,7 +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 { extractTracking } from "./trackingProviders"; import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen"; import { formatDate } from "./utils"; @@ -151,9 +151,10 @@ export async function setGroupState(login: string, groupId: string, newState: Gr group.orderedAt = undefined; group.deliveryAt = undefined; group.qrGenerated = undefined; - group.boltTrackingToken = undefined; - group.boltOrderState = undefined; - group.boltCourierState = undefined; + group.trackingProvider = undefined; + group.trackingCode = undefined; + group.trackingOrderState = undefined; + group.trackingCourierState = undefined; for (const ml of memberLogins) { group.members[ml] = { ...group.members[ml], paid: undefined }; } @@ -204,24 +205,26 @@ export async function updateGroupTimes(login: string, groupId: string, orderedAt return saveExtraData(data, date); } -export async function setGroupBoltTracking(login: string, groupId: string, shareUrl?: string, date?: Date): Promise { +export async function setGroupTracking(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 (group.creatorLogin !== login) throw new Error('Sledování objednávky může nastavit pouze zakladatel'); if (!shareUrl) { - group.boltTrackingToken = undefined; - group.boltOrderState = undefined; - group.boltCourierState = undefined; + group.trackingProvider = undefined; + group.trackingCode = undefined; + group.trackingOrderState = undefined; + group.trackingCourierState = 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'); - if (token !== group.boltTrackingToken) { - group.boltTrackingToken = token; + if (group.state !== GroupState.ORDERED) throw new Error('Sledování objednávky lze nastavit pouze ve stavu "objednáno"'); + const tracking = extractTracking(shareUrl); + if (!tracking) throw new Error('Neplatný odkaz na sledování objednávky'); + if (tracking.code !== group.trackingCode || tracking.provider !== group.trackingProvider) { + group.trackingProvider = tracking.provider; + group.trackingCode = tracking.code; // Stav patří k předchozí objednávce — vyčistíme, doplní ho první poll - group.boltOrderState = undefined; - group.boltCourierState = undefined; + group.trackingOrderState = undefined; + group.trackingCourierState = undefined; } } return saveExtraData(data, date); diff --git a/server/src/index.ts b/server/src/index.ts index 3b557e1..667a5dc 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -13,7 +13,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 { startOrderTrackingScheduler, stopOrderTrackingScheduler, releaseOrderTrackingLease } from "./orderTracking"; import { storageReady } from "./storage"; import getStorage from "./storage"; import { shutdownRedisStorage } from "./storage/redis"; @@ -89,9 +89,9 @@ async function shutdown(signal: string) { stopReminderScheduler(); await releaseReminderLease(); - // Stop Bolt tracking scheduler and release leader lease - stopBoltTrackingScheduler(); - await releaseBoltTrackingLease(); + // Stop order tracking scheduler and release leader lease + stopOrderTrackingScheduler(); + await releaseOrderTrackingLease(); // Shut down Redis pub/sub clients (Socket.io adapter) await shutdownWebsocketClients(); @@ -310,6 +310,6 @@ storageReady.then(async () => { server.listen(PORT, () => { console.log(`Server listening on ${HOST}, port ${PORT}`); startReminderScheduler(); - startBoltTrackingScheduler(); + startOrderTrackingScheduler(); }); }); diff --git a/server/src/notifikace.ts b/server/src/notifikace.ts index ad1fb32..da366d8 100644 --- a/server/src/notifikace.ts +++ b/server/src/notifikace.ts @@ -231,9 +231,12 @@ const OBJEDNANI_PATH = '/objednani'; /** * Odešle push notifikaci o doručení skupinové objednávky členům skupiny, * kteří si to zapnuli v nastavení. Zakladatel skupiny notifikaci nedostává — - * ten je o doručení informován přímo aplikací Bolt. + * ten je o doručení informován přímo aplikací rozvozové služby. + * + * Klíč nastavení zůstal boltDeliveredPush z doby, kdy sledování uměl jen Bolt Food — + * přejmenování by uživatelům zahodilo už uložené zapnutí. */ -export async function notifyBoltDelivered(groupName: string, memberLogins: string[], creatorLogin: string): Promise { +export async function notifyOrderDelivered(groupName: string, memberLogins: string[], creatorLogin: string): Promise { const recipients: string[] = []; for (const login of memberLogins) { if (login === creatorLogin) continue; @@ -245,7 +248,7 @@ export async function notifyBoltDelivered(groupName: string, memberLogins: strin await sendPushToLogins(recipients, { title: 'Luncher', body: `Objednávka „${groupName}" byla doručena!`, - tag: `bolt-delivered-${groupName}`, + tag: `order-delivered-${groupName}`, url: OBJEDNANI_PATH, }); } diff --git a/server/src/orderTracking.ts b/server/src/orderTracking.ts new file mode 100644 index 0000000..6312298 --- /dev/null +++ b/server/src/orderTracking.ts @@ -0,0 +1,159 @@ +import getStorage from './storage'; +import { createLeaderLease } from './leaderLease'; +import { getToday } from './service'; +import { formatDate } from './utils'; +import { getWebsocket } from './websocket'; +import { ClientData, GroupState, OrderGroup, TrackingProvider } from '../../types/gen/types.gen'; +import { TRACKERS } from './trackingProviders'; +import { notifyOrderDelivered } from './notifikace'; + +const storage = getStorage(); +// Klíč lease zůstal z doby, kdy sledování uměl jen Bolt — přejmenování by při +// rolling deployi na chvíli pustilo dva „leadery" (starý a nový klíč) najednou. +const lease = createLeaderLease('luncher:bolt:leader'); + +const TERMINAL_STATE_REGEX = /delivered|finished|cancelled|canceled|rejected|failed/i; +const DELIVERED_STATE_REGEX = /delivered|finished/i; +const MAX_CONSECUTIVE_FAILURES = 10; + +let trackingInterval: ReturnType | undefined; + +/** Mapa groupId → počet po sobě jdoucích selhání dotazu na API rozvozové služby. */ +const consecutiveFailures = new Map(); + +/** Skupina se sledováním — trackingCode i trackingProvider jsou zaručeně vyplněné. */ +type TrackedGroup = OrderGroup & { trackingProvider: TrackingProvider; trackingCode: string }; + +function isTracked(group: OrderGroup): group is TrackedGroup { + return !!group.trackingCode && !!group.trackingProvider && !!TRACKERS[group.trackingProvider]; +} + +/** + * Jeden tik scheduleru: pro dnešní objednané skupiny se sledovacím kódem + * zjistí očekávaný čas doručení z API rozvozové služby a aktualizuje deliveryAt. + * Sledování se automaticky ukončí (kód se smaže), když objednávka skončí + * nebo dotazy opakovaně selhávají. + */ +export async function checkOrderTracking(): 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 => isTracked(g) && g.state === GroupState.ORDERED) as TrackedGroup[]; + + // Ú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) { + const tracker = TRACKERS[group.trackingProvider]; + let deliveryAt: string | undefined; + let orderState: string | undefined; + let courierState: string | undefined; + let clearTracking = false; + + try { + const order = await tracker.poll(group.trackingCode); + consecutiveFailures.delete(group.id); + if (!order) { + // Objednávka z API zmizela — považujeme ji za doručenou + orderState = 'delivered'; + clearTracking = true; + } else { + orderState = order.orderState || undefined; + courierState = order.courierState; + if (TERMINAL_STATE_REGEX.test(order.orderState)) { + clearTracking = true; + } else { + deliveryAt = order.deliveryAt; + } + } + } catch (e) { + const failures = (consecutiveFailures.get(group.id) ?? 0) + 1; + consecutiveFailures.set(group.id, failures); + console.error(`${tracker.label} tracking: chyba dotazu pro skupinu "${group.name}" (${failures}/${MAX_CONSECUTIVE_FAILURES})`, e); + if (failures < MAX_CONSECUTIVE_FAILURES) continue; + consecutiveFailures.delete(group.id); + clearTracking = true; + } + + const timeChanged = deliveryAt !== undefined && deliveryAt !== group.deliveryAt; + const stateChanged = orderState !== undefined && orderState !== group.trackingOrderState; + const courierChanged = courierState !== group.trackingCourierState && !clearTracking; + if (!clearTracking && !timeChanged && !stateChanged && !courierChanged) continue; + + // Notifikujeme jen skutečný přechod do doručeného stavu. Porovnání s předchozím + // stavem hlídá duplicity jak u přechodu delivered → finished, tak u tiku, kdy + // objednávka zmizí z API poté, co už byla označena za doručenou. + const deliveredNow = stateChanged + && DELIVERED_STATE_REGEX.test(orderState ?? '') + && !DELIVERED_STATE_REGEX.test(group.trackingOrderState ?? ''); + + // Log každého přechodu stavu — API rozvozových služeb nejsou dokumentovaná, takže + // si takhle průběžně mapujeme jejich stavové automaty (viz mapování v OrderProgress.tsx). + if (stateChanged || courierChanged) { + console.log( + `${tracker.label} tracking: skupina "${group.name}" stav ${group.trackingOrderState ?? '(žádný)'} → ${orderState ?? '(žádný)'}` + + `, kurýr ${group.trackingCourierState ?? '(žádný)'} → ${courierState ?? '(žádný)'}` + ); + } + + updated = await storage.updateData(key, current => { + const d = current ?? data!; + const g = d.groups?.find(x => x.id === group.id); + if (g?.trackingCode) { + if (timeChanged) g.deliveryAt = deliveryAt; + if (stateChanged) g.trackingOrderState = orderState; + if (courierChanged) g.trackingCourierState = courierState; + if (clearTracking) g.trackingCode = undefined; + } + return d; + }); + if (clearTracking) { + console.log(`${tracker.label} tracking: sledování skupiny "${group.name}" ukončeno`); + } + + // Až po uložení stavu, aby pád neposlal notifikaci podruhé. + // Selhání push nesmí shodit celý tik sledování. + if (deliveredNow) { + try { + await notifyOrderDelivered(group.name, Object.keys(group.members ?? {}), group.creatorLogin); + } catch (e) { + console.error(`${tracker.label} tracking: chyba při odesílání notifikace o doručení skupiny "${group.name}"`, e); + } + } + } + + if (updated) { + getWebsocket()?.emit('message', updated); + } +} + +/** + * Spustí scheduler pro sledování objednávek. Interval je 60 s, lze ho ale + * zkrátit přes env BOLT_POLL_INTERVAL_MS (užitečné při vývoji se simulací). + */ +export function startOrderTrackingScheduler(): void { + const parsed = Number(process.env.BOLT_POLL_INTERVAL_MS); + const intervalMs = Number.isFinite(parsed) && parsed >= 1000 ? parsed : 60_000; + trackingInterval = setInterval(checkOrderTracking, intervalMs); + console.log(`Sledování objednávek: scheduler spuštěn (interval ${intervalMs} ms)`); +} + +/** Stopne scheduler sledování. Volá se při graceful shutdown. */ +export function stopOrderTrackingScheduler(): void { + if (trackingInterval) { + clearInterval(trackingInterval); + trackingInterval = undefined; + } +} + +/** Uvolní leader lease při graceful shutdown. */ +export async function releaseOrderTrackingLease(): Promise { + await lease.release(); +} diff --git a/server/src/routes/devRoutes.ts b/server/src/routes/devRoutes.ts index 792a784..a33a131 100644 --- a/server/src/routes/devRoutes.ts +++ b/server/src/routes/devRoutes.ts @@ -6,12 +6,12 @@ import { getWebsocket } from "../websocket"; import { getLogin } from "../auth"; import { parseToken } from "../utils"; import webpush from 'web-push'; -import { ClientData, GroupState } from "../../../types/gen/types.gen"; +import { ClientData, GroupState, TrackingProvider } from "../../../types/gen/types.gen"; import { startBoltSimulation, advanceBoltSimulation, setBoltSimulationStep, stopBoltSimulationByGroup, getBoltSimulation, } from "../boltSimulator"; -import { checkBoltTracking } from "../boltTracking"; +import { checkOrderTracking } from "../orderTracking"; const router = express.Router(); const storage = getStorage(); @@ -201,7 +201,7 @@ router.post("/testPush", async (req, res, next) => { } catch (e: any) { next(e) } }); -// --- DEV simulace sledování Bolt Food --- +// --- 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 { @@ -210,12 +210,13 @@ async function applyBoltToken(groupId: string, token: string | undefined): Promi const d = current; const group = d?.groups?.find(g => g.id === groupId); if (!group) throw new Error('Skupina nebyla nalezena'); - group.boltTrackingToken = token; + group.trackingProvider = token ? TrackingProvider.BOLT : undefined; + group.trackingCode = token; if (token) { group.state = GroupState.ORDERED; } else { - group.boltOrderState = undefined; - group.boltCourierState = undefined; + group.trackingOrderState = undefined; + group.trackingCourierState = undefined; } return d!; }); @@ -228,7 +229,7 @@ router.post("/bolt/simulate", async (req: Request<{}, any, any>, res, next) => { if (!groupId) return res.status(400).json({ error: 'Chybí groupId' }); const token = startBoltSimulation(groupId); await applyBoltToken(groupId, token); - await checkBoltTracking(); // okamžitý první poll → stav "accepted" + websocket + await checkOrderTracking(); // okamžitý první poll → stav "accepted" + websocket res.status(200).json({ success: true, token, simulation: getBoltSimulation(groupId) }); } catch (e: any) { next(e); } }); @@ -239,7 +240,7 @@ router.post("/bolt/advance", async (req: Request<{}, any, any>, res, next) => { const groupId = req.body?.groupId; if (!groupId) return res.status(400).json({ error: 'Chybí groupId' }); advanceBoltSimulation(groupId); - await checkBoltTracking(); + await checkOrderTracking(); res.status(200).json({ success: true, simulation: getBoltSimulation(groupId) }); } catch (e: any) { next(e); } }); @@ -250,7 +251,7 @@ router.post("/bolt/state", async (req: Request<{}, any, any>, res, next) => { 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 checkBoltTracking(); + await checkOrderTracking(); res.status(200).json({ success: true, simulation: getBoltSimulation(groupId) }); } catch (e: any) { next(e); } }); @@ -258,7 +259,7 @@ router.post("/bolt/state", async (req: Request<{}, any, any>, res, next) => { /** Spustí jeden tik scheduleru okamžitě (bez čekání na interval). */ router.post("/bolt/poll", async (_req, res, next) => { try { - await checkBoltTracking(); + await checkOrderTracking(); res.status(200).json({ success: true }); } catch (e: any) { next(e); } }); diff --git a/server/src/routes/groupRoutes.ts b/server/src/routes/groupRoutes.ts index c173660..8038680 100644 --- a/server/src/routes/groupRoutes.ts +++ b/server/src/routes/groupRoutes.ts @@ -2,9 +2,9 @@ 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, setGroupBoltTracking, getOrderDates } from "../groups"; +import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupTracking, getOrderDates } from "../groups"; import { GroupState } from "../../../types/gen/types.gen"; -import { checkBoltTracking } from "../boltTracking"; +import { checkOrderTracking } from "../orderTracking"; const router = express.Router(); @@ -161,20 +161,20 @@ router.post("/updateTimes", async (req: Request, res, next) => { } catch (e: any) { next(e); } }); -router.post("/setBoltTracking", async (req: Request, res, next) => { +router.post("/setTracking", 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' }); + return res.status(400).json({ error: 'Neplatný odkaz na sledování objednávky' }); } try { - const data = await setGroupBoltTracking(login, id, shareUrl); + const data = await setGroupTracking(login, id, shareUrl); broadcastExtra(data); res.status(200).json(data); // Okamžitý poll, ať uživatel nečeká na další tik scheduleru if (shareUrl) { - checkBoltTracking().catch(e => console.error('Bolt tracking: okamžitý poll selhal', e)); + checkOrderTracking().catch(e => console.error('Sledování objednávek: okamžitý poll selhal', e)); } } catch (e: any) { next(e); } }); diff --git a/server/src/tests/boltTracking.test.ts b/server/src/tests/boltTracking.test.ts deleted file mode 100644 index 0ba9329..0000000 --- a/server/src/tests/boltTracking.test.ts +++ /dev/null @@ -1,416 +0,0 @@ -import axios from 'axios'; -import { resetMemoryStorage } from '../storage/memory'; -import getStorage from '../storage'; -import { addStore } from '../stores'; -import { createGroup, setGroupState, setGroupBoltTracking, addGroupMember } from '../groups'; -import { saveNotificationSettings } from '../notifikace'; -import { sendPushToLogins } from '../pushReminder'; -import { extractBoltToken, computeDeliveryHHMM, checkBoltTracking } from '../boltTracking'; -import { startBoltSimulation, advanceBoltSimulation, setBoltSimulationStep, stopBoltSimulationByGroup } from '../boltSimulator'; -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 }), -})); - -jest.mock('../pushReminder', () => ({ sendPushToLogins: jest.fn() })); -const mockedSendPush = sendPushToLogins as jest.MockedFunction; - -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ší včetně stavu', async () => { - await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY); - const d = await setGroupBoltTracking(CREATOR, groupId, '', TODAY); - expect(d.groups![0].boltTrackingToken).toBeUndefined(); - expect(d.groups![0].boltOrderState).toBeUndefined(); - }); - - test('nový token vynuluje stav předchozí objednávky', async () => { - await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY); - await storage.updateData(`2025-01-10_extra`, (current) => { - current!.groups![0].boltOrderState = 'preparing'; - return current!; - }); - // Stejný token stav nemění - let d = await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY); - expect(d.groups![0].boltOrderState).toBe('preparing'); - // Jiný token stav vynuluje - const otherUrl = `https://food.bolt.eu/sharedActiveOrder/${'b'.repeat(64)}`; - d = await setGroupBoltTracking(CREATOR, groupId, otherUrl, TODAY); - expect(d.groups![0].boltOrderState).toBeUndefined(); - expect(d.groups![0].boltTrackingToken).toBe('b'.repeat(64)); - }); - - 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.boltOrderState).toBe('waiting_preparation'); - 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.boltOrderState).toBe('delivered'); - 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(); - expect(group.boltOrderState).toBe('delivered'); - }); - - test('ukládá stav kurýra (reálná odpověď s waiting_delivery)', async () => { - mockedAxios.post.mockResolvedValue(boltResponse({ - order_id: 312222357, - order_state: 'waiting_delivery', - expected_time_to_client_in_seconds: 911, - provider: { provider_id: 82859, state: 'waiting_pickup' }, - courier: { courier_id: 1958424, state: 'arrived_to_provider', lat: 49.7, lng: 13.3 }, - })); - await checkBoltTracking(); - let group = await getGroup(); - expect(group.boltOrderState).toBe('waiting_delivery'); - expect(group.boltCourierState).toBe('arrived_to_provider'); - - // Kurýr vyzvedl — změní se jen courier state - mockedAxios.post.mockResolvedValue(boltResponse({ - order_state: 'waiting_delivery', - expected_time_to_client_in_seconds: 911, - courier: { state: 'picked_up' }, - })); - await checkBoltTracking(); - group = await getGroup(); - expect(group.boltCourierState).toBe('picked_up'); - }); - - test('aktualizuje boltOrderState při změně stavu beze změny času', async () => { - mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'waiting_preparation', expected_time_to_client_in_seconds: 1800 })); - await checkBoltTracking(); - mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 })); - await checkBoltTracking(); - const group = await getGroup(); - expect(group.boltOrderState).toBe('preparing'); - expect(group.boltTrackingToken).toBe(TOKEN); - }); - - 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(); - }); -}); - -describe('DEV simulace (boltSimulator + checkBoltTracking)', () => { - 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); - // Simulátor vygeneruje validní 64-hex token a přiřadíme ho skupině jako reálný dev endpoint - const token = startBoltSimulation(groupId); - await setGroupBoltTracking(CREATOR, groupId, `https://food.bolt.eu/sharedActiveOrder/${token}`); - }); - - afterEach(() => stopBoltSimulationByGroup(groupId)); - - async function getGroup() { - const data = await storage.getData(extraKey()); - return data!.groups!.find(g => g.id === groupId)!; - } - - test('simulovaný token nevolá reálné Bolt API', async () => { - await checkBoltTracking(); - expect(mockedAxios.post).not.toHaveBeenCalled(); - }); - - test('první poll nastaví stav waiting_acceptance a ETA', async () => { - await checkBoltTracking(); - const g = await getGroup(); - expect(g.boltOrderState).toBe('waiting_acceptance'); - expect(g.deliveryAt).toBe(computeDeliveryHHMM(2100)); - }); - - test('advance posune sekvenci na accepted', async () => { - await checkBoltTracking(); - advanceBoltSimulation(groupId); - await checkBoltTracking(); - expect((await getGroup()).boltOrderState).toBe('accepted'); - }); - - test('ruční nastavení stavu (override) se projeví při pollu', async () => { - setBoltSimulationStep(groupId, { order_state: 'in_delivery', courier_state: 'heading_to_client', etaSeconds: 300 }); - await checkBoltTracking(); - const g = await getGroup(); - expect(g.boltOrderState).toBe('in_delivery'); - expect(g.boltCourierState).toBe('heading_to_client'); - }); - - test('terminální stav delivered ukončí sledování (smaže token)', async () => { - setBoltSimulationStep(groupId, { order_state: 'delivered' }); - await checkBoltTracking(); - const g = await getGroup(); - expect(g.boltOrderState).toBe('delivered'); - expect(g.boltTrackingToken).toBeUndefined(); - }); -}); - -describe('notifikace o doručení objednávky', () => { - const MEMBER = 'petr'; - const OTHER = 'jana'; - let groupId: string; - - beforeEach(async () => { - const d = await createGroup(CREATOR, STORE); - groupId = d.groups![0].id; - await addGroupMember(CREATOR, groupId, MEMBER); - await addGroupMember(CREATOR, groupId, OTHER); - await setGroupState(CREATOR, groupId, GroupState.LOCKED); - await setGroupState(CREATOR, groupId, GroupState.ORDERED); - await setGroupBoltTracking(CREATOR, groupId, SHARE_URL); - }); - - /** Posune objednávku do stavu "na cestě", aby další poll byl skutečný přechod. */ - async function tickInDelivery() { - mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'in_delivery', expected_time_to_client_in_seconds: 600 })); - await checkBoltTracking(); - } - - test('doručení notifikuje členy, kteří to mají zapnuté', async () => { - await saveNotificationSettings(MEMBER, { boltDeliveredPush: true }); - await saveNotificationSettings(OTHER, { boltDeliveredPush: false }); - await tickInDelivery(); - - mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' })); - await checkBoltTracking(); - - expect(mockedSendPush).toHaveBeenCalledTimes(1); - expect(mockedSendPush).toHaveBeenCalledWith( - [MEMBER], - expect.objectContaining({ body: expect.stringContaining(STORE), url: '/objednani' }), - ); - }); - - test('zakladatel notifikaci nedostane, ani když ji má zapnutou', async () => { - await saveNotificationSettings(CREATOR, { boltDeliveredPush: true }); - await saveNotificationSettings(MEMBER, { boltDeliveredPush: true }); - await tickInDelivery(); - - mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' })); - await checkBoltTracking(); - - expect(mockedSendPush).toHaveBeenCalledWith([MEMBER], expect.anything()); - }); - - test('bez zapnutého nastavení se neposílá nic', async () => { - await tickInDelivery(); - mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' })); - await checkBoltTracking(); - expect(mockedSendPush).not.toHaveBeenCalled(); - }); - - test('notifikace se neposílá opakovaně (delivered → finished → objednávka zmizí)', async () => { - await saveNotificationSettings(MEMBER, { boltDeliveredPush: true }); - await tickInDelivery(); - - mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' })); - await checkBoltTracking(); - expect(mockedSendPush).toHaveBeenCalledTimes(1); - - mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'finished' })); - await checkBoltTracking(); - mockedAxios.post.mockResolvedValue(boltResponse(null)); - await checkBoltTracking(); - expect(mockedSendPush).toHaveBeenCalledTimes(1); - }); - - test('zmizelá objednávka bez předchozího doručení notifikuje', async () => { - await saveNotificationSettings(MEMBER, { boltDeliveredPush: true }); - await tickInDelivery(); - - mockedAxios.post.mockResolvedValue(boltResponse(null)); - await checkBoltTracking(); - expect(mockedSendPush).toHaveBeenCalledTimes(1); - }); - - test('zrušená objednávka notifikaci neposílá', async () => { - await saveNotificationSettings(MEMBER, { boltDeliveredPush: true }); - await tickInDelivery(); - - mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'cancelled' })); - await checkBoltTracking(); - expect(mockedSendPush).not.toHaveBeenCalled(); - }); -}); diff --git a/server/src/tests/groups.test.ts b/server/src/tests/groups.test.ts index ed02def..4335e81 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, setGroupBoltTracking, markGroupMemberPaid } from '../groups'; +import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, setGroupTracking, markGroupMemberPaid } from '../groups'; import { GroupState } from '../../../types/gen/types.gen'; const CREATOR = 'tomas'; @@ -193,13 +193,14 @@ describe('setGroupState', () => { await expect(setGroupState(USER, groupId, GroupState.LOCKED, TODAY)).rejects.toThrow('zakladatel'); }); - test('ordered → locked smaže boltTrackingToken', async () => { + test('ordered → locked smaže sledování objednávky', 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); + await setGroupTracking(CREATOR, groupId, `https://food.bolt.eu/sharedActiveOrder/${token}`, TODAY); const d = await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY); - expect(d.groups![0].boltTrackingToken).toBeUndefined(); + expect(d.groups![0].trackingCode).toBeUndefined(); + expect(d.groups![0].trackingProvider).toBeUndefined(); }); }); diff --git a/server/src/tests/orderTracking.test.ts b/server/src/tests/orderTracking.test.ts new file mode 100644 index 0000000..67a6bba --- /dev/null +++ b/server/src/tests/orderTracking.test.ts @@ -0,0 +1,585 @@ +import axios from 'axios'; +import { resetMemoryStorage } from '../storage/memory'; +import getStorage from '../storage'; +import { addStore } from '../stores'; +import { createGroup, setGroupState, setGroupTracking, addGroupMember } from '../groups'; +import { saveNotificationSettings } from '../notifikace'; +import { sendPushToLogins } from '../pushReminder'; +import { checkOrderTracking } from '../orderTracking'; +import { extractTracking, computeDeliveryHHMM, formatEtaHHMM } from '../trackingProviders'; +import { startBoltSimulation, advanceBoltSimulation, setBoltSimulationStep, stopBoltSimulationByGroup } from '../boltSimulator'; +import { ClientData, GroupState, TrackingProvider } 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 }), +})); + +jest.mock('../pushReminder', () => ({ sendPushToLogins: jest.fn() })); +const mockedSendPush = sendPushToLogins as jest.MockedFunction; + +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}`; +const WOLT_CODE = 'ZfKWLEtm1JyB-nI0zk6S7g'; +const WOLT_URL = `https://track.wolt.com/${WOLT_CODE}`; + +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('extractTracking', () => { + const bolt = (code: string) => ({ provider: TrackingProvider.BOLT, code }); + const wolt = (code: string) => ({ provider: TrackingProvider.WOLT, code }); + + test('přijme plnou share URL Bolt Food', () => { + expect(extractTracking(SHARE_URL)).toEqual(bolt(TOKEN)); + }); + + test('toleruje lomítko, query a hash na konci', () => { + expect(extractTracking(`${SHARE_URL}/`)).toEqual(bolt(TOKEN)); + expect(extractTracking(`${SHARE_URL}?utm=x`)).toEqual(bolt(TOKEN)); + expect(extractTracking(`${SHARE_URL}#sekce`)).toEqual(bolt(TOKEN)); + expect(extractTracking(` ${SHARE_URL} `)).toEqual(bolt(TOKEN)); + }); + + test('přijme samotný token Bolt včetně velkých písmen', () => { + expect(extractTracking(TOKEN)).toEqual(bolt(TOKEN)); + expect(extractTracking(TOKEN.toUpperCase())).toEqual(bolt(TOKEN.toUpperCase())); + }); + + test('přijme odkaz na sledování Wolt i s jazykem v cestě', () => { + expect(extractTracking(WOLT_URL)).toEqual(wolt(WOLT_CODE)); + expect(extractTracking(`https://track.wolt.com/en/${WOLT_CODE}`)).toEqual(wolt(WOLT_CODE)); + expect(extractTracking(` ${WOLT_URL}?utm=x `)).toEqual(wolt(WOLT_CODE)); + }); + + test('přijme samotný tracking code Wolt', () => { + expect(extractTracking(WOLT_CODE)).toEqual(wolt(WOLT_CODE)); + }); + + test('odmítne neplatný vstup', () => { + expect(extractTracking('')).toBeNull(); + expect(extractTracking('nesmysl')).toBeNull(); + expect(extractTracking('https://food.bolt.eu/sharedActiveOrder/abc123')).toBeNull(); + expect(extractTracking(`https://food.bolt.eu/sharedActiveOrder/${'z'.repeat(64)}`)).toBeNull(); + expect(extractTracking(TOKEN.slice(0, 63))).toBeNull(); + expect(extractTracking('https://track.wolt.com/cs')).toBeNull(); + // Odkaz cizí služby se stejně tvarovaným kódem nesmí projít jako Wolt + expect(extractTracking(`https://track.example.com/${WOLT_CODE}`)).toBeNull(); + }); +}); + +describe('formatEtaHHMM', () => { + test('převede ISO čas do zóny objednávky', () => { + expect(formatEtaHHMM('2026-08-24T09:35:33.066000+00:00', 'Europe/Prague')).toBe('11:35'); + expect(formatEtaHHMM('2026-08-24T09:35:33.066000+00:00', 'UTC')).toBe('09:35'); + }); + + test('půlnoc se zobrazí jako 00:00', () => { + expect(formatEtaHHMM('2026-08-24T22:00:00Z', 'Europe/Prague')).toBe('00:00'); + }); + + test('neznámá zóna spadne na čas serveru, nesmyslný čas vrátí undefined', () => { + expect(formatEtaHHMM('2026-08-24T09:35:00Z', 'Neznama/Zona')).toMatch(/^\d{2}:\d{2}$/); + expect(formatEtaHHMM('nesmysl')).toBeUndefined(); + }); +}); + +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('setGroupTracking (Bolt)', () => { + 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 setGroupTracking(CREATOR, groupId, SHARE_URL, TODAY); + expect(d.groups![0].trackingCode).toBe(TOKEN); + }); + + test('prázdná hodnota sledování zruší včetně stavu', async () => { + await setGroupTracking(CREATOR, groupId, SHARE_URL, TODAY); + const d = await setGroupTracking(CREATOR, groupId, '', TODAY); + expect(d.groups![0].trackingCode).toBeUndefined(); + expect(d.groups![0].trackingOrderState).toBeUndefined(); + }); + + test('nový token vynuluje stav předchozí objednávky', async () => { + await setGroupTracking(CREATOR, groupId, SHARE_URL, TODAY); + await storage.updateData(`2025-01-10_extra`, (current) => { + current!.groups![0].trackingOrderState = 'preparing'; + return current!; + }); + // Stejný token stav nemění + let d = await setGroupTracking(CREATOR, groupId, SHARE_URL, TODAY); + expect(d.groups![0].trackingOrderState).toBe('preparing'); + // Jiný token stav vynuluje + const otherUrl = `https://food.bolt.eu/sharedActiveOrder/${'b'.repeat(64)}`; + d = await setGroupTracking(CREATOR, groupId, otherUrl, TODAY); + expect(d.groups![0].trackingOrderState).toBeUndefined(); + expect(d.groups![0].trackingCode).toBe('b'.repeat(64)); + }); + + test('odmítne neplatný odkaz', async () => { + await expect(setGroupTracking(CREATOR, groupId, 'nesmysl', TODAY)).rejects.toThrow('Neplatný odkaz'); + }); + + test('nezakladatel nemůže sledování nastavit', async () => { + await expect(setGroupTracking(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(setGroupTracking(CREATOR, openGroupId, SHARE_URL, TODAY)).rejects.toThrow('objednáno'); + }); +}); + +describe('checkOrderTracking (Bolt)', () => { + // 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 setGroupTracking(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 checkOrderTracking(); + const after = computeDeliveryHHMM(1800); + const group = await getGroup(); + expect([before, after]).toContain(group.deliveryAt); + expect(group.trackingOrderState).toBe('waiting_preparation'); + expect(group.trackingCode).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 checkOrderTracking(); + expect(mockEmit).toHaveBeenCalledTimes(1); + await checkOrderTracking(); + 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 checkOrderTracking(); + mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered', expected_time_to_client_in_seconds: 0 })); + await checkOrderTracking(); + const group = await getGroup(); + expect(group.trackingCode).toBeUndefined(); + expect(group.trackingOrderState).toBe('delivered'); + expect(group.deliveryAt).toMatch(/^\d{2}:\d{2}$/); + }); + + test('ukončí sledování, když objednávka už neexistuje', async () => { + mockedAxios.post.mockResolvedValue(boltResponse(null)); + await checkOrderTracking(); + const group = await getGroup(); + expect(group.trackingCode).toBeUndefined(); + expect(group.trackingOrderState).toBe('delivered'); + }); + + test('ukládá stav kurýra (reálná odpověď s waiting_delivery)', async () => { + mockedAxios.post.mockResolvedValue(boltResponse({ + order_id: 312222357, + order_state: 'waiting_delivery', + expected_time_to_client_in_seconds: 911, + provider: { provider_id: 82859, state: 'waiting_pickup' }, + courier: { courier_id: 1958424, state: 'arrived_to_provider', lat: 49.7, lng: 13.3 }, + })); + await checkOrderTracking(); + let group = await getGroup(); + expect(group.trackingOrderState).toBe('waiting_delivery'); + expect(group.trackingCourierState).toBe('arrived_to_provider'); + + // Kurýr vyzvedl — změní se jen courier state + mockedAxios.post.mockResolvedValue(boltResponse({ + order_state: 'waiting_delivery', + expected_time_to_client_in_seconds: 911, + courier: { state: 'picked_up' }, + })); + await checkOrderTracking(); + group = await getGroup(); + expect(group.trackingCourierState).toBe('picked_up'); + }); + + test('aktualizuje trackingOrderState při změně stavu beze změny času', async () => { + mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'waiting_preparation', expected_time_to_client_in_seconds: 1800 })); + await checkOrderTracking(); + mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 })); + await checkOrderTracking(); + const group = await getGroup(); + expect(group.trackingOrderState).toBe('preparing'); + expect(group.trackingCode).toBe(TOKEN); + }); + + 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 checkOrderTracking(); + const group = await getGroup(); + expect(group.trackingCode).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 checkOrderTracking(); + } + expect((await getGroup()).trackingCode).toBe(TOKEN); + await checkOrderTracking(); + expect((await getGroup()).trackingCode).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 checkOrderTracking(); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); +}); + +describe('checkOrderTracking (Wolt)', () => { + const extraKey = () => `${formatDate(new Date())}_extra`; + let groupId: string; + + /** Odpověď Wolt tracking API — tvarem odpovídá reálnému /order-tracking-api/v1/details. */ + function woltResponse(details: object) { + return { data: { refresh_in_seconds: 30, timezone: 'Europe/Prague', ...details } }; + } + + /** 404 z Wolt API (neznámý nebo expirovaný tracking code). */ + function woltNotFound() { + (mockedAxios.isAxiosError as unknown as jest.Mock).mockImplementation((e: any) => !!e?.isAxiosError); + return Object.assign(new Error('Request failed with status code 404'), { + isAxiosError: true, + response: { status: 404, data: { detail: 'Not Found' } }, + }); + } + + beforeEach(async () => { + const d = await createGroup(CREATOR, STORE); + groupId = d.groups![0].id; + await addGroupMember(CREATOR, groupId, USER); + await setGroupState(CREATOR, groupId, GroupState.LOCKED); + await setGroupState(CREATOR, groupId, GroupState.ORDERED); + await setGroupTracking(CREATOR, groupId, WOLT_URL); + }); + + async function getGroup() { + const data = await storage.getData(extraKey()); + return data!.groups!.find(g => g.id === groupId)!; + } + + test('odkaz uloží službu i tracking code', async () => { + const group = await getGroup(); + expect(group.trackingProvider).toBe(TrackingProvider.WOLT); + expect(group.trackingCode).toBe(WOLT_CODE); + }); + + test('nastaví deliveryAt z delivery_eta v zóně objednávky', async () => { + mockedAxios.get.mockResolvedValue(woltResponse({ + status: 'production', + delivery_eta: '2026-08-24T09:35:33.066000+00:00', + pickup_eta: '2026-08-24T09:12:45.481000+00:00', + couriers: [], + })); + await checkOrderTracking(); + const group = await getGroup(); + expect(group.deliveryAt).toBe('11:35'); + expect(group.trackingOrderState).toBe('production'); + expect(group.trackingCourierState).toBeUndefined(); + expect(mockEmit).toHaveBeenCalledWith('message', expect.anything()); + // Wolt se dotazuje GETem, Bolt API se nesmí volat vůbec + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining(`/track/${WOLT_CODE}`), + expect.objectContaining({ headers: expect.objectContaining({ Origin: 'https://track.wolt.com' }) }), + ); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + test('kurýr doručující cizí objednávku je jen assigned', async () => { + // Reálná odpověď: jídlo hotové, kurýr u podniku, ale veze ještě cizí objednávku + mockedAxios.get.mockResolvedValue(woltResponse({ + status: 'ready', + delivery_eta: '2026-08-24T09:35:33.066000+00:00', + couriers: [{ + id: '31ce2f6e139bec6c', + coordinates: { lat: 49.72954, lon: 13.34632 }, + vehicle_type: 'car', + is_delivering: true, + is_delivering_other_order: true, + }], + })); + await checkOrderTracking(); + const group = await getGroup(); + expect(group.trackingOrderState).toBe('ready'); + expect(group.trackingCourierState).toBe('assigned'); + }); + + test('kurýr vezoucí naši objednávku se uloží jako delivering', async () => { + mockedAxios.get.mockResolvedValue(woltResponse({ + status: 'ready', + delivery_eta: '2026-08-24T09:35:33.066000+00:00', + couriers: [{ is_delivering: true, is_delivering_other_order: false }], + })); + await checkOrderTracking(); + expect((await getGroup()).trackingCourierState).toBe('delivering'); + }); + + test('kurýr bez příznaků doručování je assigned', async () => { + mockedAxios.get.mockResolvedValue(woltResponse({ + status: 'ready', + delivery_eta: '2026-08-24T09:35:33.066000+00:00', + couriers: [{ coordinates: { lat: 49.72, lon: 13.34 } }], + })); + await checkOrderTracking(); + expect((await getGroup()).trackingCourierState).toBe('assigned'); + }); + + test('doručení ukončí sledování a upozorní členy', async () => { + await saveNotificationSettings(USER, { boltDeliveredPush: true }); + mockedAxios.get.mockResolvedValue(woltResponse({ + status: 'production', + delivery_eta: '2026-08-24T09:35:33.066000+00:00', + })); + await checkOrderTracking(); + + mockedAxios.get.mockResolvedValue(woltResponse({ status: 'delivered' })); + await checkOrderTracking(); + + const group = await getGroup(); + expect(group.trackingCode).toBeUndefined(); + expect(group.trackingOrderState).toBe('delivered'); + expect(group.deliveryAt).toBe('11:35'); + expect(mockedSendPush).toHaveBeenCalledTimes(1); + }); + + test('neznámý tracking code (404) ukončí sledování', async () => { + mockedAxios.get.mockRejectedValue(woltNotFound()); + await checkOrderTracking(); + const group = await getGroup(); + expect(group.trackingCode).toBeUndefined(); + }); + + test('odpověď bez stavu se počítá jako selhání, sledování pokračuje', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockedAxios.get.mockResolvedValue(woltResponse({ delivery_eta: null })); + await checkOrderTracking(); + const group = await getGroup(); + expect(group.trackingCode).toBe(WOLT_CODE); + expect(group.trackingOrderState).toBeUndefined(); + errorSpy.mockRestore(); + }); +}); + +describe('DEV simulace (boltSimulator + checkOrderTracking)', () => { + 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); + // Simulátor vygeneruje validní 64-hex token a přiřadíme ho skupině jako reálný dev endpoint + const token = startBoltSimulation(groupId); + await setGroupTracking(CREATOR, groupId, `https://food.bolt.eu/sharedActiveOrder/${token}`); + }); + + afterEach(() => stopBoltSimulationByGroup(groupId)); + + async function getGroup() { + const data = await storage.getData(extraKey()); + return data!.groups!.find(g => g.id === groupId)!; + } + + test('simulovaný token nevolá reálné Bolt API', async () => { + await checkOrderTracking(); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + test('první poll nastaví stav waiting_acceptance a ETA', async () => { + await checkOrderTracking(); + const g = await getGroup(); + expect(g.trackingOrderState).toBe('waiting_acceptance'); + expect(g.deliveryAt).toBe(computeDeliveryHHMM(2100)); + }); + + test('advance posune sekvenci na accepted', async () => { + await checkOrderTracking(); + advanceBoltSimulation(groupId); + await checkOrderTracking(); + expect((await getGroup()).trackingOrderState).toBe('accepted'); + }); + + test('ruční nastavení stavu (override) se projeví při pollu', async () => { + setBoltSimulationStep(groupId, { order_state: 'in_delivery', courier_state: 'heading_to_client', etaSeconds: 300 }); + await checkOrderTracking(); + const g = await getGroup(); + expect(g.trackingOrderState).toBe('in_delivery'); + expect(g.trackingCourierState).toBe('heading_to_client'); + }); + + test('terminální stav delivered ukončí sledování (smaže token)', async () => { + setBoltSimulationStep(groupId, { order_state: 'delivered' }); + await checkOrderTracking(); + const g = await getGroup(); + expect(g.trackingOrderState).toBe('delivered'); + expect(g.trackingCode).toBeUndefined(); + }); +}); + +describe('notifikace o doručení objednávky', () => { + const MEMBER = 'petr'; + const OTHER = 'jana'; + let groupId: string; + + beforeEach(async () => { + const d = await createGroup(CREATOR, STORE); + groupId = d.groups![0].id; + await addGroupMember(CREATOR, groupId, MEMBER); + await addGroupMember(CREATOR, groupId, OTHER); + await setGroupState(CREATOR, groupId, GroupState.LOCKED); + await setGroupState(CREATOR, groupId, GroupState.ORDERED); + await setGroupTracking(CREATOR, groupId, SHARE_URL); + }); + + /** Posune objednávku do stavu "na cestě", aby další poll byl skutečný přechod. */ + async function tickInDelivery() { + mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'in_delivery', expected_time_to_client_in_seconds: 600 })); + await checkOrderTracking(); + } + + test('doručení notifikuje členy, kteří to mají zapnuté', async () => { + await saveNotificationSettings(MEMBER, { boltDeliveredPush: true }); + await saveNotificationSettings(OTHER, { boltDeliveredPush: false }); + await tickInDelivery(); + + mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' })); + await checkOrderTracking(); + + expect(mockedSendPush).toHaveBeenCalledTimes(1); + expect(mockedSendPush).toHaveBeenCalledWith( + [MEMBER], + expect.objectContaining({ body: expect.stringContaining(STORE), url: '/objednani' }), + ); + }); + + test('zakladatel notifikaci nedostane, ani když ji má zapnutou', async () => { + await saveNotificationSettings(CREATOR, { boltDeliveredPush: true }); + await saveNotificationSettings(MEMBER, { boltDeliveredPush: true }); + await tickInDelivery(); + + mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' })); + await checkOrderTracking(); + + expect(mockedSendPush).toHaveBeenCalledWith([MEMBER], expect.anything()); + }); + + test('bez zapnutého nastavení se neposílá nic', async () => { + await tickInDelivery(); + mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' })); + await checkOrderTracking(); + expect(mockedSendPush).not.toHaveBeenCalled(); + }); + + test('notifikace se neposílá opakovaně (delivered → finished → objednávka zmizí)', async () => { + await saveNotificationSettings(MEMBER, { boltDeliveredPush: true }); + await tickInDelivery(); + + mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' })); + await checkOrderTracking(); + expect(mockedSendPush).toHaveBeenCalledTimes(1); + + mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'finished' })); + await checkOrderTracking(); + mockedAxios.post.mockResolvedValue(boltResponse(null)); + await checkOrderTracking(); + expect(mockedSendPush).toHaveBeenCalledTimes(1); + }); + + test('zmizelá objednávka bez předchozího doručení notifikuje', async () => { + await saveNotificationSettings(MEMBER, { boltDeliveredPush: true }); + await tickInDelivery(); + + mockedAxios.post.mockResolvedValue(boltResponse(null)); + await checkOrderTracking(); + expect(mockedSendPush).toHaveBeenCalledTimes(1); + }); + + test('zrušená objednávka notifikaci neposílá', async () => { + await saveNotificationSettings(MEMBER, { boltDeliveredPush: true }); + await tickInDelivery(); + + mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'cancelled' })); + await checkOrderTracking(); + expect(mockedSendPush).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/trackingProviders.ts b/server/src/trackingProviders.ts new file mode 100644 index 0000000..f839df8 --- /dev/null +++ b/server/src/trackingProviders.ts @@ -0,0 +1,240 @@ +import axios from 'axios'; +import crypto from 'crypto'; +import { TrackingProvider } from '../../types/gen/types.gen'; +import { isBoltSimulated, getSimulatedBoltOrder } from './boltSimulator'; + +/** + * Adaptéry rozvozových služeb pro sledování objednávek. + * + * Každá služba umí ze sdílecího odkazu vytáhnout svůj kód a dotázat se svého + * (nedokumentovaného) veřejného API. Výsledek normalizují do tvaru TrackedOrder, + * se kterým už dál pracuje jen scheduler v orderTracking.ts. + */ + +/** Znormalizovaný stav sledované objednávky — společný jmenovatel všech služeb. */ +export interface TrackedOrder { + /** Raw stav objednávky ze služby (Bolt order_state, Wolt status). */ + orderState: string; + /** Raw stav kurýra, pokud ho služba poskytuje. */ + courierState?: string; + /** Očekávaný čas doručení ve formátu HH:MM, pokud ho lze určit. */ + deliveryAt?: string; +} + +export interface DeliveryTracker { + provider: TrackingProvider; + /** Lidský název služby (do UI hlášek a logů). */ + label: string; + /** Vytáhne kód sledování ze vstupu, nebo null, pokud vstup službě nepatří. */ + extractCode(input: string): string | null; + /** Dotáže se API služby. Vrátí null, pokud objednávka už neexistuje. */ + poll(code: string): Promise; +} + +const BOLT_POLLING_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getOrderPolling'; +const BOLT_SHARE_HOST = 'bolt.eu'; +const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i; + +const WOLT_TRACKING_URL = 'https://consumer-api.wolt.com/order-tracking-api/v1/details/tracking-code/track/'; +const WOLT_SHARE_HOST = 'wolt.com'; +/** Wolt tracking code je base64url (v praxi 22 znaků); délku bereme s rezervou. */ +const WOLT_CODE_REGEX = /^[A-Za-z0-9_-]{20,32}$/; + +/** Identifikátor zařízení pro Bolt API — generuje se jednou na proces. */ +const DEVICE_ID = crypto.randomUUID(); + +/** Vrátí poslední neprázdný segment cesty URL (u odkazu bez URL tvaru vrátí null). */ +function lastPathSegment(input: string): string | null { + let url: URL; + try { + url = new URL(input); + } catch { + return null; + } + const segments = url.pathname.split('/').filter(Boolean); + return segments[segments.length - 1] ?? null; +} + +/** Patří hostitel odkazu dané službě (včetně subdomén)? */ +function isHost(input: string, host: string): boolean { + try { + const hostname = new URL(input).hostname.toLowerCase(); + return hostname === host || hostname.endsWith(`.${host}`); + } catch { + return false; + } +} + +/** 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')}`; +} + +/** + * Zformátuje absolutní ISO čas na HH:MM. Wolt v odpovědi posílá i časovou zónu + * objednávky, takže se čas zobrazí správně i kdyby server běžel v jiné zóně. + */ +export function formatEtaHHMM(iso: string, timeZone?: string): string | undefined { + const eta = new Date(iso); + if (Number.isNaN(eta.getTime())) return undefined; + const options: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }; + try { + return new Intl.DateTimeFormat('cs-CZ', { ...options, timeZone }).format(eta); + } catch { + // Neznámá zóna z API — spadneme na lokální čas serveru (TZ=Europe/Prague) + return new Intl.DateTimeFormat('cs-CZ', options).format(eta); + } +} + +interface BoltOrder { + order_id?: number; + order_state: string; + expected_time_to_client_in_seconds?: number; + courier?: { state?: string } | null; +} + +/** Dotáže se veřejného Bolt API na stav sdílené objednávky. Vrátí null, pokud objednávka už neexistuje. */ +async function pollBoltOrder(token: string): Promise { + // DEV simulace: simulované tokeny obsluhuje boltSimulator místo reálného Bolt API. + // V produkci je registr vždy prázdný, takže se sem nikdy nedostane. + if (isBoltSimulated(token)) { + return getSimulatedBoltOrder(token); + } + const res = await axios.post(BOLT_POLLING_URL, { token }, { + params: { + version: 'FW.1.113', + language: 'cs-CZ', + country: 'cz', + device_name: 'web', + device_os_version: 'web', + deviceType: 'web', + session_id: DEVICE_ID, + distinct_id: `$device:${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; +} + +const boltTracker: DeliveryTracker = { + provider: TrackingProvider.BOLT, + label: 'Bolt Food', + + /** + * Přijme sdílecí URL Bolt Food (https://food.bolt.eu/sharedActiveOrder/) + * nebo samotný token (64 hex znaků). + */ + extractCode(input) { + if (BOLT_TOKEN_REGEX.test(input)) return input; + if (!isHost(input, BOLT_SHARE_HOST)) return null; + const last = lastPathSegment(input); + return last && BOLT_TOKEN_REGEX.test(last) ? last : null; + }, + + async poll(code) { + const order = await pollBoltOrder(code); + if (!order) return null; + const seconds = order.expected_time_to_client_in_seconds; + return { + orderState: order.order_state || '', + courierState: order.courier?.state || undefined, + deliveryAt: typeof seconds === 'number' ? computeDeliveryHHMM(seconds) : undefined, + }; + }, +}; + +/** Odpověď Wolt tracking API (jen pole, která používáme). */ +interface WoltTrackingDetails { + status?: string; + delivery_eta?: string | null; + couriers?: WoltCourier[]; + timezone?: string; +} + +interface WoltCourier { + /** Kurýr veze nějakou objednávku (nemusí to být ta naše — viz is_delivering_other_order). */ + is_delivering?: boolean; + /** Kurýr právě doručuje cizí objednávku, tu naši teprve vyzvedne nebo veze až po ní. */ + is_delivering_other_order?: boolean; +} + +/** + * Wolt neposílá stav kurýra, jen jeho pozici a dva příznaky. Odvodíme z nich + * dvojí stav: 'delivering' (veze naši objednávku → krok „Na cestě") a 'assigned' + * (kurýr přiřazen, ale naše jídlo ještě nemá — typicky doručuje cizí objednávku). + */ +function woltCourierState(couriers?: WoltCourier[]): string | undefined { + const courier = couriers?.[0]; + if (!courier) return undefined; + return courier.is_delivering && !courier.is_delivering_other_order ? 'delivering' : 'assigned'; +} + +const woltTracker: DeliveryTracker = { + provider: TrackingProvider.WOLT, + label: 'Wolt', + + /** + * Přijme odkaz na sledování Wolt (https://track.wolt.com/, případně + * s jazykovým segmentem /en/) nebo samotný tracking code. + */ + extractCode(input) { + if (WOLT_CODE_REGEX.test(input)) return input; + if (!isHost(input, WOLT_SHARE_HOST)) return null; + const last = lastPathSegment(input); + return last && WOLT_CODE_REGEX.test(last) ? last : null; + }, + + async poll(code) { + let details: WoltTrackingDetails; + try { + const res = await axios.get(`${WOLT_TRACKING_URL}${encodeURIComponent(code)}`, { + headers: { + Accept: 'application/json', + 'Accept-Language': 'cs', + // Wolt API odpovídá jen na dotazy z jeho tracking stránky + Referer: 'https://track.wolt.com/', + Origin: 'https://track.wolt.com', + }, + timeout: 10_000, + }); + details = res.data ?? {}; + } catch (e) { + // Neznámý/expirovaný kód → objednávka už neexistuje (stejně jako u Boltu) + if (axios.isAxiosError(e) && e.response?.status === 404) return null; + throw e; + } + if (!details.status) { + throw new Error('Wolt API vrátilo odpověď bez stavu objednávky'); + } + return { + orderState: details.status, + courierState: woltCourierState(details.couriers), + deliveryAt: details.delivery_eta ? formatEtaHHMM(details.delivery_eta, details.timezone) : undefined, + }; + }, +}; + +export const TRACKERS: Record = { + [TrackingProvider.BOLT]: boltTracker, + [TrackingProvider.WOLT]: woltTracker, +}; + +/** + * Rozpozná službu podle sdílecího odkazu (nebo samotného kódu) a vytáhne z něj + * kód sledování. Vrátí null, pokud vstup nepatří žádné podporované službě. + */ +export function extractTracking(input: string): { provider: TrackingProvider; code: string } | null { + const trimmed = input.trim(); + if (!trimmed) return null; + for (const tracker of Object.values(TRACKERS)) { + const code = tracker.extractCode(trimmed); + if (code) return { provider: tracker.provider, code }; + } + return null; +} diff --git a/types/api.yml b/types/api.yml index 0065d49..dfb63bf 100644 --- a/types/api.yml +++ b/types/api.yml @@ -142,8 +142,8 @@ paths: $ref: "./paths/groups/setState.yml" /groups/updateTimes: $ref: "./paths/groups/updateTimes.yml" - /groups/setBoltTracking: - $ref: "./paths/groups/setBoltTracking.yml" + /groups/setTracking: + $ref: "./paths/groups/setTracking.yml" /groups/updateFees: $ref: "./paths/groups/updateFees.yml" diff --git a/types/paths/groups/setBoltTracking.yml b/types/paths/groups/setBoltTracking.yml deleted file mode 100644 index 55f8405..0000000 --- a/types/paths/groups/setBoltTracking.yml +++ /dev/null @@ -1,21 +0,0 @@ -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/paths/groups/setTracking.yml b/types/paths/groups/setTracking.yml new file mode 100644 index 0000000..84526c7 --- /dev/null +++ b/types/paths/groups/setTracking.yml @@ -0,0 +1,24 @@ +post: + operationId: setTracking + summary: Nastaví nebo zruší sledování objednávky u rozvozové služby (pouze zakladatel). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + description: ID skupiny + type: string + shareUrl: + description: | + Odkaz na sledování objednávky — Bolt Food (https://food.bolt.eu/sharedActiveOrder/...) + nebo Wolt (https://track.wolt.com/...). Služba se pozná z odkazu. + 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 86e3e1a..008b4a6 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -694,7 +694,7 @@ NotificationSettings: description: Čas, ve který má být uživatel upozorněn na nezvolený oběd (HH:MM). Prázdné = vypnuto. type: string boltDeliveredPush: - description: Zapnuté push upozornění na doručení skupinové objednávky sledované přes Bolt Food. + description: Zapnuté push upozornění na doručení sledované skupinové objednávky (Bolt Food i Wolt). Název klíče zůstal kvůli zpětné kompatibilitě uloženého nastavení uživatelů. type: boolean GotifyServer: type: object @@ -871,6 +871,16 @@ Store: description: Volitelná URL na nabídku podniku (např. Bolt Food/Wolt/Foodora) type: string +TrackingProvider: + description: Rozvozová služba, přes kterou lze sledovat stav objednávky + type: string + enum: + - bolt + - wolt + x-enum-varnames: + - BOLT + - WOLT + GroupState: description: Stav skupiny objednávky type: string @@ -956,14 +966,16 @@ 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. + trackingProvider: + $ref: "#/TrackingProvider" + trackingCode: + description: Kód sledování objednávky (poslední segment share URL — u Bolt Food 64-hex token, u Wolt tracking code). Pokud je vyplněn, server automaticky aktualizuje deliveryAt. type: string - boltOrderState: - description: Poslední známý stav objednávky z Bolt API (raw order_state, např. waiting_preparation, waiting_delivery, delivered). Zůstává vyplněn i po ukončení sledování. + trackingOrderState: + description: Poslední známý stav objednávky ze sledovací služby (raw hodnota — Bolt order_state, např. waiting_preparation; Wolt status, např. production). Zůstává vyplněn i po ukončení sledování. type: string - boltCourierState: - description: Poslední známý stav kurýra z Bolt API (raw courier.state, např. arrived_to_provider, picked_up). Zpřesňuje krok "Na cestě" ve stepperu. + trackingCourierState: + description: Poslední známý stav kurýra ze sledovací služby (Bolt raw courier.state, např. arrived_to_provider; Wolt odvozené "assigned"/"delivering"). Zpřesňuje krok "Na cestě" ve stepperu. type: string # --- NEVYŘÍZENÉ QR KÓDY ---