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
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:
@@ -8,7 +8,7 @@ import { cs } from 'date-fns/locale';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import {
|
||||
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr,
|
||||
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, getOrderDates,
|
||||
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setBoltTracking, getOrderDates,
|
||||
} from '../../../types';
|
||||
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
||||
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
||||
@@ -26,6 +26,22 @@ import PendingPayments from '../components/PendingPayments';
|
||||
|
||||
const SLOT = MealSlot.EXTRA;
|
||||
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
const BOLT_SHARE_URL_PREFIX = 'https://food.bolt.eu/sharedActiveOrder/';
|
||||
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
|
||||
|
||||
/** Vytáhne sledovací token ze sdílecí URL Bolt Food, nebo přijme samotný token. Null = neplatný vstup. */
|
||||
function extractBoltToken(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed;
|
||||
try {
|
||||
const segments = new URL(trimmed).pathname.split('/').filter(Boolean);
|
||||
const last = segments[segments.length - 1];
|
||||
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Český lokál pro date picker (názvy měsíců/dnů, pondělí jako první den)
|
||||
registerLocale('cs', cs);
|
||||
@@ -72,7 +88,7 @@ export default function OrderGroupsPage() {
|
||||
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
|
||||
const [editNotes, setEditNotes] = useState<Record<string, 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 [feesModal, setFeesModal] = 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 times = editTimes[group.id];
|
||||
if (!times) return;
|
||||
const { orderedAt, deliveryAt } = times;
|
||||
const { orderedAt, deliveryAt, boltUrl } = times;
|
||||
if (orderedAt && !TIME_REGEX.test(orderedAt)) {
|
||||
setPageError('Čas objednání musí být ve formátu HH:MM');
|
||||
return;
|
||||
@@ -245,7 +261,17 @@ export default function OrderGroupsPage() {
|
||||
setPageError('Čas doručení musí být ve formátu HH:MM');
|
||||
return;
|
||||
}
|
||||
const ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
|
||||
// Bolt odkaz se odesílá jen při změně oproti aktuálnímu tokenu skupiny
|
||||
const boltToken = boltUrl.trim() ? extractBoltToken(boltUrl) : null;
|
||||
if (boltUrl.trim() && !boltToken) {
|
||||
setPageError('Neplatný odkaz Bolt (očekávána URL sdílení objednávky)');
|
||||
return;
|
||||
}
|
||||
const boltChanged = (boltToken ?? undefined) !== group.boltTrackingToken;
|
||||
let ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
|
||||
if (ok && boltChanged) {
|
||||
ok = await refresh(() => setBoltTracking({ body: { id: group.id, shareUrl: boltUrl.trim() } }));
|
||||
}
|
||||
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
|
||||
};
|
||||
|
||||
@@ -667,6 +693,18 @@ export default function OrderGroupsPage() {
|
||||
style={{ width: 75 }}
|
||||
/>
|
||||
</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-secondary" onClick={() => setEditTimes(prev => { const n = { ...prev }; delete n[group.id]; return n; })}>Zrušit</Button>
|
||||
</div>
|
||||
@@ -674,7 +712,7 @@ export default function OrderGroupsPage() {
|
||||
<div
|
||||
className="d-flex align-items-center gap-3 flex-wrap"
|
||||
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}
|
||||
>
|
||||
<small className="text-muted">
|
||||
@@ -682,6 +720,11 @@ export default function OrderGroupsPage() {
|
||||
</small>
|
||||
<small className="text-muted">
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import getStorage from "./storage";
|
||||
import { getClientData, getToday, initIfNeeded } from "./service";
|
||||
import { getStores } from "./stores";
|
||||
import { removePendingQrsByGroupId } from "./pizza";
|
||||
import { extractBoltToken } from "./boltTracking";
|
||||
import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen";
|
||||
import { formatDate } from "./utils";
|
||||
|
||||
@@ -150,6 +151,7 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
|
||||
group.orderedAt = undefined;
|
||||
group.deliveryAt = undefined;
|
||||
group.qrGenerated = undefined;
|
||||
group.boltTrackingToken = undefined;
|
||||
for (const ml of memberLogins) {
|
||||
group.members[ml] = { ...group.members[ml], paid: undefined };
|
||||
}
|
||||
@@ -199,3 +201,19 @@ export async function updateGroupTimes(login: string, groupId: string, orderedAt
|
||||
if (deliveryAt !== undefined) group.deliveryAt = deliveryAt || undefined;
|
||||
return saveExtraData(data, date);
|
||||
}
|
||||
|
||||
export async function setGroupBoltTracking(login: string, groupId: string, shareUrl?: string, date?: Date): Promise<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);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToke
|
||||
import { getPendingQrs } from "./pizza";
|
||||
import { initWebsocket, initRedisAdapter, shutdownWebsocketClients, getWebsocket } from "./websocket";
|
||||
import { startReminderScheduler, stopReminderScheduler, releaseReminderLease, verifyQuickChoiceToken } from "./pushReminder";
|
||||
import { startBoltTrackingScheduler, stopBoltTrackingScheduler, releaseBoltTrackingLease } from "./boltTracking";
|
||||
import { storageReady } from "./storage";
|
||||
import getStorage from "./storage";
|
||||
import { shutdownRedisStorage } from "./storage/redis";
|
||||
@@ -85,6 +86,10 @@ async function shutdown(signal: string) {
|
||||
stopReminderScheduler();
|
||||
await releaseReminderLease();
|
||||
|
||||
// Stop Bolt tracking scheduler and release leader lease
|
||||
stopBoltTrackingScheduler();
|
||||
await releaseBoltTrackingLease();
|
||||
|
||||
// Shut down Redis pub/sub clients (Socket.io adapter)
|
||||
await shutdownWebsocketClients();
|
||||
|
||||
@@ -284,5 +289,6 @@ storageReady.then(async () => {
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server listening on ${HOST}, port ${PORT}`);
|
||||
startReminderScheduler();
|
||||
startBoltTrackingScheduler();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import webpush from 'web-push';
|
||||
import crypto from 'crypto';
|
||||
import getStorage from './storage';
|
||||
import { getRedisClient } from './storage/redis';
|
||||
import { createLeaderLease } from './leaderLease';
|
||||
import { getClientData, getToday } from './service';
|
||||
import { getIsWeekend } from './utils';
|
||||
import { LunchChoices } from '../../types';
|
||||
|
||||
const storage = getStorage();
|
||||
const REGISTRY_KEY = 'push_reminder_registry';
|
||||
const LEADER_LEASE_KEY = 'luncher:reminder:leader';
|
||||
const LEASE_TTL_SECONDS = 90;
|
||||
const lease = createLeaderLease('luncher:reminder:leader');
|
||||
|
||||
const POD_ID = process.env.POD_ID ?? `local-${process.pid}`;
|
||||
|
||||
@@ -43,49 +42,9 @@ function userHasChoice(choices: LunchChoices, login: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pokusí se získat nebo obnovit leader lease pro scheduler připomínek.
|
||||
* Vrátí true pokud tato instance smí spustit připomínky.
|
||||
* Při non-Redis storage vždy vrací true (single-process, leader election není potřeba).
|
||||
*/
|
||||
async function tryAcquireOrRenewLease(): Promise<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. */
|
||||
export async function releaseReminderLease(): Promise<void> {
|
||||
if (process.env.STORAGE?.toLowerCase() !== 'redis') return;
|
||||
try {
|
||||
const c = getRedisClient();
|
||||
if (!c) return;
|
||||
const currentHolder = await c.get(LEADER_LEASE_KEY);
|
||||
if (currentHolder === POD_ID) {
|
||||
await c.del(LEADER_LEASE_KEY);
|
||||
console.log('Push reminder: lease uvolněna');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Push reminder: chyba při uvolňování lease', e);
|
||||
}
|
||||
await lease.release();
|
||||
}
|
||||
|
||||
/** Stopne scheduler připomínek. Volá se při graceful shutdown. */
|
||||
@@ -140,7 +99,7 @@ async function checkAndSendReminders(): Promise<void> {
|
||||
if (getIsWeekend(getToday())) return;
|
||||
|
||||
// Leader election — pouze jeden pod spouští připomínky
|
||||
const isLeader = await tryAcquireOrRenewLease();
|
||||
const isLeader = await lease.tryAcquireOrRenew();
|
||||
if (!isLeader) return;
|
||||
|
||||
const registry = await storage.getData<Registry>(REGISTRY_KEY) ?? {};
|
||||
|
||||
@@ -2,7 +2,7 @@ import express, { Request } from "express";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
import { getWebsocket } from "../websocket";
|
||||
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, getOrderDates } from "../groups";
|
||||
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupBoltTracking, getOrderDates } from "../groups";
|
||||
import { GroupState } from "../../../types/gen/types.gen";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -160,4 +160,18 @@ router.post("/updateTimes", async (req: Request, res, next) => {
|
||||
} catch (e: any) { next(e); }
|
||||
});
|
||||
|
||||
router.post("/setBoltTracking", async (req: Request, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, shareUrl } = req.body ?? {};
|
||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||
if (shareUrl !== undefined && typeof shareUrl !== 'string') {
|
||||
return res.status(400).json({ error: 'Neplatný odkaz na sledování objednávky Bolt' });
|
||||
}
|
||||
try {
|
||||
const data = await setGroupBoltTracking(login, id, shareUrl);
|
||||
broadcastExtra(data);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { resetMemoryStorage } from '../storage/memory';
|
||||
import { getStores, addStore } from '../stores';
|
||||
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState } from '../groups';
|
||||
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, setGroupBoltTracking } from '../groups';
|
||||
import { GroupState } from '../../../types/gen/types.gen';
|
||||
|
||||
const CREATOR = 'tomas';
|
||||
@@ -192,4 +192,13 @@ describe('setGroupState', () => {
|
||||
test('nečlen nemůže měnit stav', async () => {
|
||||
await expect(setGroupState(USER, groupId, GroupState.LOCKED, TODAY)).rejects.toThrow('zakladatel');
|
||||
});
|
||||
|
||||
test('ordered → locked smaže boltTrackingToken', async () => {
|
||||
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
|
||||
const token = 'a'.repeat(64);
|
||||
await setGroupBoltTracking(CREATOR, groupId, `https://food.bolt.eu/sharedActiveOrder/${token}`, TODAY);
|
||||
const d = await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||
expect(d.groups![0].boltTrackingToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,6 +100,8 @@ paths:
|
||||
$ref: "./paths/groups/setState.yml"
|
||||
/groups/updateTimes:
|
||||
$ref: "./paths/groups/updateTimes.yml"
|
||||
/groups/setBoltTracking:
|
||||
$ref: "./paths/groups/setBoltTracking.yml"
|
||||
/groups/updateFees:
|
||||
$ref: "./paths/groups/updateFees.yml"
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -786,6 +786,9 @@ OrderGroup:
|
||||
qrGenerated:
|
||||
description: Příznak, zda byly pro skupinu vygenerovány QR kódy (blokuje opakované generování)
|
||||
type: boolean
|
||||
boltTrackingToken:
|
||||
description: Token sdíleného sledování objednávky Bolt Food (poslední segment share URL). Pokud je vyplněn, server automaticky aktualizuje deliveryAt.
|
||||
type: string
|
||||
|
||||
# --- NEVYŘÍZENÉ QR KÓDY ---
|
||||
PendingQr:
|
||||
|
||||
Reference in New Issue
Block a user