feat: automatické sledování doručení objednávky přes Bolt Food
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 36s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 43s
CI / Notify (push) Successful in 2s

Zakladatel skupiny může na stránce objednání vložit sdílecí odkaz
Bolt Food. Server pak každou minutu dotazuje veřejné Bolt API
a automaticky aktualizuje čas doručení skupiny (deliveryAt).
Sledování se samo ukončí po doručení, zrušení objednávky nebo
opakovaných chybách. Leader lease vytažena do znovupoužitelného
modulu leaderLease.ts.

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