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 '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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 { 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 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) ?? {};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 { 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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:
|
||||||
|
|||||||
Reference in New Issue
Block a user