feat: push upozornění na doručení objednávky Bolt Food
CI / Generate TypeScript types (push) Successful in 12s
CI / Server unit tests (push) Successful in 22s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m48s
CI / Build and push Docker image (push) Successful in 53s
CI / Notify (push) Successful in 2s

Nové zaškrtávátko v Nastavení → Notifikace pod výběrem času připomínky.
Když skupinová objednávka sledovaná přes Bolt Food přejde do stavu
delivered/finished, dostanou push členové skupiny, kteří si to zapnuli.
Zakladatel skupiny notifikaci nedostává — ten je informován aplikací Bolt.

Push subscription byla dosud svázaná s časem připomínky (subscribe endpoint
bez reminderTime vracel 400), takže by nový přepínač samostatně nefungoval.
Čas připomínky je nově v registru volitelný a scheduler připomínek záznamy
bez času přeskakuje.

Service worker nemá natvrdo tag a akci "Mám vlastní/neobědvám" — obojí se
řídí payloadem, aby je doručovací notifikace nedědila.
This commit is contained in:
2026-08-10 12:36:07 +02:00
parent 377a350211
commit 065ccaf38a
12 changed files with 440 additions and 41 deletions
+10 -7
View File
@@ -1,4 +1,4 @@
// Service Worker pro Web Push notifikace (připomínka výběru oběda) // Service Worker pro Web Push notifikace (připomínka výběru oběda, doručení objednávky)
self.addEventListener('push', (event) => { self.addEventListener('push', (event) => {
const data = event.data?.json() ?? { title: 'Luncher', body: 'Ještě nemáte zvolený oběd!' }; const data = event.data?.json() ?? { title: 'Luncher', body: 'Ještě nemáte zvolený oběd!' };
@@ -6,11 +6,12 @@ self.addEventListener('push', (event) => {
self.registration.showNotification(data.title, { self.registration.showNotification(data.title, {
body: data.body, body: data.body,
icon: '/favicon.ico', icon: '/favicon.ico',
tag: 'lunch-reminder', tag: data.tag ?? 'lunch-reminder',
data: { login: data.login, token: data.token }, data: { login: data.login, token: data.token, url: data.url },
actions: [ // Token posílá jen připomínka oběda — ostatní notifikace tlačítko nemají.
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' }, actions: data.token
], ? [{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' }]
: [],
}) })
); );
}); });
@@ -32,14 +33,16 @@ self.addEventListener('notificationclick', (event) => {
return; return;
} }
const url = event.notification.data?.url ?? '/';
event.waitUntil( event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clientList) => { self.clients.matchAll({ type: 'window' }).then((clientList) => {
for (const client of clientList) { for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) { if (client.url.includes(self.location.origin) && 'focus' in client) {
if ('navigate' in client) client.navigate(url).catch(() => {});
return client.focus(); return client.focus();
} }
} }
return self.clients.openWindow('/'); return self.clients.openWindow(url);
}) })
); );
}); });
+30 -7
View File
@@ -21,6 +21,7 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
const themeRef = useRef<HTMLSelectElement>(null); const themeRef = useRef<HTMLSelectElement>(null);
const reminderTimeRef = useRef<HTMLInputElement>(null); const reminderTimeRef = useRef<HTMLInputElement>(null);
const boltDeliveredRef = useRef<HTMLInputElement>(null);
const ntfyTopicRef = useRef<HTMLInputElement>(null); const ntfyTopicRef = useRef<HTMLInputElement>(null);
const discordWebhookRef = useRef<HTMLInputElement>(null); const discordWebhookRef = useRef<HTMLInputElement>(null);
const teamsWebhookRef = useRef<HTMLInputElement>(null); const teamsWebhookRef = useRef<HTMLInputElement>(null);
@@ -47,22 +48,29 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
const handleSave = async () => { const handleSave = async () => {
const newReminderTime = reminderTimeRef.current?.value || undefined; const newReminderTime = reminderTimeRef.current?.value || undefined;
const oldReminderTime = notifSettings.reminderTime; const oldReminderTime = notifSettings.reminderTime;
const newBoltDelivered = boltDeliveredRef.current?.checked ?? false;
const oldBoltDelivered = notifSettings.boltDeliveredPush ?? false;
// Uložení notifikačních nastavení na server // Uložení notifikačních nastavení na server
await updateNotificationSettings({ const newSettings: NotificationSettings = {
body: {
ntfyTopic: ntfyTopicRef.current?.value || undefined, ntfyTopic: ntfyTopicRef.current?.value || undefined,
discordWebhookUrl: discordWebhookRef.current?.value || undefined, discordWebhookUrl: discordWebhookRef.current?.value || undefined,
teamsWebhookUrl: teamsWebhookRef.current?.value || undefined, teamsWebhookUrl: teamsWebhookRef.current?.value || undefined,
enabledEvents, enabledEvents,
reminderTime: newReminderTime, reminderTime: newReminderTime,
} boltDeliveredPush: newBoltDelivered,
}).catch(() => {}); };
await updateNotificationSettings({ body: newSettings }).catch(() => {});
setNotifSettings(newSettings);
// Správa push subscription pro připomínky // Správa push subscription — drží ji naživu kterákoli z push funkcí.
if (newReminderTime && newReminderTime !== oldReminderTime) { // Záměrně bez await: subscribeToPush si vyžádá oprávnění prohlížeče a modal
// by na tu dobu zůstal otevřený.
const wantsPush = !!newReminderTime || newBoltDelivered;
const hadPush = !!oldReminderTime || oldBoltDelivered;
if (wantsPush && (newReminderTime !== oldReminderTime || newBoltDelivered !== oldBoltDelivered)) {
subscribeToPush(newReminderTime); subscribeToPush(newReminderTime);
} else if (!newReminderTime && oldReminderTime) { } else if (!wantsPush && hadPush) {
unsubscribeFromPush(); unsubscribeFromPush();
} }
@@ -128,6 +136,21 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group className="mb-3">
<Form.Check
id="boltDeliveredCheckbox"
ref={boltDeliveredRef}
type="checkbox"
label="Upozornění na doručení objednávky (Bolt Food)"
defaultChecked={notifSettings.boltDeliveredPush ?? false}
key={`bolt-delivered-${notifSettings.boltDeliveredPush ?? false}`}
/>
<Form.Text className="text-muted">
bude skupinová objednávka sledovaná přes Bolt Food doručena, přijde vám push notifikace.
Zakladateli skupiny se neposílá ten dostane upozornění přímo z aplikace Bolt.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>ntfy téma (topic)</Form.Label> <Form.Label>ntfy téma (topic)</Form.Label>
<Form.Control <Form.Control
+5 -4
View File
@@ -27,9 +27,10 @@ async function pushApiFetch(path: string, options: RequestInit = {}): Promise<Re
/** /**
* Zaregistruje service worker, přihlásí se k push notifikacím * Zaregistruje service worker, přihlásí se k push notifikacím
* a odešle subscription na server. * a odešle subscription na server. `reminderTime` je volitelný — bez něj
* uživatel odebírá jen ostatní push notifikace (např. doručení objednávky).
*/ */
export async function subscribeToPush(reminderTime: string): Promise<boolean> { export async function subscribeToPush(reminderTime?: string): Promise<boolean> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) { if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.warn('Push notifikace nejsou v tomto prohlížeči podporovány'); console.warn('Push notifikace nejsou v tomto prohlížeči podporovány');
return false; return false;
@@ -75,7 +76,7 @@ export async function subscribeToPush(reminderTime: string): Promise<boolean> {
return false; return false;
} }
console.log('Push notifikace: úspěšně přihlášeno k připomínkám v', reminderTime); console.log('Push notifikace: úspěšně přihlášeno', reminderTime ? `(připomínka v ${reminderTime})` : '(bez připomínky oběda)');
return true; return true;
} catch (error) { } catch (error) {
console.error('Push notifikace: chyba při registraci', error); console.error('Push notifikace: chyba při registraci', error);
@@ -101,7 +102,7 @@ export async function unsubscribeFromPush(): Promise<void> {
} }
await pushApiFetch('/unsubscribe', { method: 'POST' }); await pushApiFetch('/unsubscribe', { method: 'POST' });
console.log('Push notifikace: úspěšně odhlášeno z připomínek'); console.log('Push notifikace: úspěšně odhlášeno');
} catch (error) { } catch (error) {
console.error('Push notifikace: chyba při odhlášení', error); console.error('Push notifikace: chyba při odhlášení', error);
} }
+77
View File
@@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test';
import { loginViaApi } from './helpers';
const BOLT_LABEL = 'Upozornění na doručení objednávky (Bolt Food)';
test.beforeEach(async ({ page }) => {
await loginViaApi(page, 'e2e-user');
await page.reload();
await page.waitForLoadState('networkidle');
});
async function openSettings(page: import('@playwright/test').Page) {
await page.locator('#basic-nav-dropdown').click();
await page.locator('text=Nastavení').click();
await expect(page.locator('.modal-title')).toContainText('Nastavení', { timeout: 5_000 });
}
test('Přepínač doručení Bolt je pod výběrem času připomínky', async ({ page }) => {
await openSettings(page);
const reminderGroup = page.locator('.modal-body .mb-3').filter({ hasText: 'Připomínka výběru oběda' });
const boltGroup = page.locator('.modal-body .mb-3').filter({ hasText: BOLT_LABEL });
await expect(reminderGroup).toBeVisible();
await expect(boltGroup).toBeVisible();
// Přepínač musí v DOM následovat až za skupinou s časem připomínky
const order = await page.evaluate((label) => {
const groups = Array.from(document.querySelectorAll('.modal-body .mb-3'));
return {
reminder: groups.findIndex(g => g.textContent?.includes('Připomínka výběru oběda')),
bolt: groups.findIndex(g => g.textContent?.includes(label)),
};
}, BOLT_LABEL);
expect(order.reminder).toBeGreaterThanOrEqual(0);
expect(order.bolt).toBe(order.reminder + 1);
// Výchozí stav je vypnuto
await expect(page.locator('#boltDeliveredCheckbox')).not.toBeChecked();
});
test('Zapnutí přepínače se uloží na server a přežije znovuotevření', async ({ page }) => {
await openSettings(page);
await page.locator('#boltDeliveredCheckbox').check();
await page.locator('.modal-footer button', { hasText: 'Uložit' }).click();
await expect(page.locator('.modal')).not.toBeVisible({ timeout: 5_000 });
// Server má nastavení uložené
const token = await page.evaluate(() => localStorage.getItem('token'));
const resp = await page.request.get('/api/notifications/settings', {
headers: { Authorization: `Bearer ${token}` },
});
expect(await resp.json()).toMatchObject({ boltDeliveredPush: true });
// Po znovunačtení stránky je přepínač stále zapnutý
await page.reload();
await page.waitForLoadState('networkidle');
await openSettings(page);
await expect(page.locator('#boltDeliveredCheckbox')).toBeChecked();
});
test('Vypnutí přepínače se uloží zpět', async ({ page }) => {
await openSettings(page);
await page.locator('#boltDeliveredCheckbox').check();
await page.locator('.modal-footer button', { hasText: 'Uložit' }).click();
await expect(page.locator('.modal')).not.toBeVisible({ timeout: 5_000 });
await openSettings(page);
await page.locator('#boltDeliveredCheckbox').uncheck();
await page.locator('.modal-footer button', { hasText: 'Uložit' }).click();
await expect(page.locator('.modal')).not.toBeVisible({ timeout: 5_000 });
const token = await page.evaluate(() => localStorage.getItem('token'));
const resp = await page.request.get('/api/notifications/settings', {
headers: { Authorization: `Bearer ${token}` },
});
expect(await resp.json()).toMatchObject({ boltDeliveredPush: false });
});
+3
View File
@@ -0,0 +1,3 @@
[
"Upozornění na doručení skupinové objednávky sledované přes Bolt Food (zapíná se v Nastavení → Notifikace)"
]
+19
View File
@@ -7,6 +7,7 @@ import { formatDate } from './utils';
import { getWebsocket } from './websocket'; import { getWebsocket } from './websocket';
import { ClientData, GroupState } from '../../types/gen/types.gen'; import { ClientData, GroupState } from '../../types/gen/types.gen';
import { isBoltSimulated, getSimulatedBoltOrder } from './boltSimulator'; import { isBoltSimulated, getSimulatedBoltOrder } from './boltSimulator';
import { notifyBoltDelivered } from './notifikace';
const storage = getStorage(); const storage = getStorage();
const lease = createLeaderLease('luncher:bolt:leader'); const lease = createLeaderLease('luncher:bolt:leader');
@@ -14,6 +15,7 @@ const lease = createLeaderLease('luncher:bolt:leader');
const BOLT_POLLING_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getOrderPolling'; const BOLT_POLLING_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getOrderPolling';
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i; const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
const TERMINAL_STATE_REGEX = /delivered|finished|cancelled|rejected|failed/i; const TERMINAL_STATE_REGEX = /delivered|finished|cancelled|rejected|failed/i;
const DELIVERED_STATE_REGEX = /delivered|finished/i;
const MAX_CONSECUTIVE_FAILURES = 10; const MAX_CONSECUTIVE_FAILURES = 10;
/** Identifikátor zařízení pro Bolt API — generuje se jednou na proces. */ /** Identifikátor zařízení pro Bolt API — generuje se jednou na proces. */
@@ -143,6 +145,13 @@ export async function checkBoltTracking(): Promise<void> {
const courierChanged = courierState !== group.boltCourierState && !clearToken; const courierChanged = courierState !== group.boltCourierState && !clearToken;
if (!clearToken && !timeChanged && !stateChanged && !courierChanged) continue; if (!clearToken && !timeChanged && !stateChanged && !courierChanged) continue;
// Notifikujeme jen skutečný přechod do doručeného stavu. Porovnání s předchozím
// stavem hlídá duplicity jak u přechodu delivered → finished, tak u tiku, kdy
// objednávka zmizí z API poté, co už byla označena za doručenou.
const deliveredNow = stateChanged
&& DELIVERED_STATE_REGEX.test(orderState ?? '')
&& !DELIVERED_STATE_REGEX.test(group.boltOrderState ?? '');
// Log každého přechodu stavu — Bolt API není dokumentované, takže si takhle // Log každého přechodu stavu — Bolt API není dokumentované, takže si takhle
// průběžně mapujeme jeho stavový automat (viz mapování v BoltOrderProgress.tsx). // průběžně mapujeme jeho stavový automat (viz mapování v BoltOrderProgress.tsx).
if (stateChanged || courierChanged) { if (stateChanged || courierChanged) {
@@ -166,6 +175,16 @@ export async function checkBoltTracking(): Promise<void> {
if (clearToken) { if (clearToken) {
console.log(`Bolt tracking: sledování skupiny "${group.name}" ukončeno`); console.log(`Bolt tracking: sledování skupiny "${group.name}" ukončeno`);
} }
// Až po uložení stavu, aby pád neposlal notifikaci podruhé.
// Selhání push nesmí shodit celý tik sledování.
if (deliveredNow) {
try {
await notifyBoltDelivered(group.name, Object.keys(group.members ?? {}), group.creatorLogin);
} catch (e) {
console.error(`Bolt tracking: chyba při odesílání notifikace o doručení skupiny "${group.name}"`, e);
}
}
} }
if (updated) { if (updated) {
+26
View File
@@ -5,6 +5,7 @@ import { getClientData, getToday } from "./service";
import { getUsersByLocation, getHumanTime } from "./utils"; import { getUsersByLocation, getHumanTime } from "./utils";
import { NotifikaceData, NotifikaceInput, NotificationSettings } from '../../types'; import { NotifikaceData, NotifikaceInput, NotificationSettings } from '../../types';
import getStorage from "./storage"; import getStorage from "./storage";
import { sendPushToLogins } from "./pushReminder";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
@@ -223,3 +224,28 @@ export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy
console.error("Error in callNotifikace: ", error); console.error("Error in callNotifikace: ", error);
} }
}; };
/** Cesta na stránku skupinových objednávek (viz OBJEDNANI_URL v klientovi). */
const OBJEDNANI_PATH = '/objednani';
/**
* Odešle push notifikaci o doručení skupinové objednávky členům skupiny,
* kteří si to zapnuli v nastavení. Zakladatel skupiny notifikaci nedostává —
* ten je o doručení informován přímo aplikací Bolt.
*/
export async function notifyBoltDelivered(groupName: string, memberLogins: string[], creatorLogin: string): Promise<void> {
const recipients: string[] = [];
for (const login of memberLogins) {
if (login === creatorLogin) continue;
const settings = await getNotificationSettings(login);
if (settings.boltDeliveredPush) recipients.push(login);
}
if (recipients.length === 0) return;
await sendPushToLogins(recipients, {
title: 'Luncher',
body: `Objednávka „${groupName}" byla doručena!`,
tag: `bolt-delivered-${groupName}`,
url: OBJEDNANI_PATH,
});
}
+61 -12
View File
@@ -13,10 +13,21 @@ const lease = createLeaderLease('luncher:reminder:leader');
const POD_ID = process.env.POD_ID ?? `local-${process.pid}`; const POD_ID = process.env.POD_ID ?? `local-${process.pid}`;
interface RegistryEntry { interface RegistryEntry {
time: string; /** Čas připomínky výběru oběda (HH:MM). Nevyplněno = uživatel odebírá jen ostatní push notifikace. */
time?: string;
subscription: webpush.PushSubscription; subscription: webpush.PushSubscription;
} }
/** Data odesílaná do service workeru. */
export interface PushPayload {
title: string;
body: string;
/** Seskupovací tag notifikace (výchozí v service workeru je připomínka oběda). */
tag?: string;
/** Cesta, která se otevře po kliknutí na notifikaci. */
url?: string;
}
type Registry = Record<string, RegistryEntry>; type Registry = Record<string, RegistryEntry>;
/** Mapa login → timestamp (ms) posledního odeslání připomínky. */ /** Mapa login → timestamp (ms) posledního odeslání připomínky. */
@@ -55,14 +66,18 @@ export function stopReminderScheduler(): void {
} }
} }
/** Přidá nebo aktualizuje push subscription pro uživatele. */ /**
export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime: string): Promise<void> { * Přidá nebo aktualizuje push subscription pro uživatele.
* `reminderTime` je volitelný — bez něj uživatel dostává jen ostatní push notifikace
* (např. doručení objednávky Bolt Food), ale ne připomínku výběru oběda.
*/
export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime?: string): Promise<void> {
await storage.updateData<Registry>(REGISTRY_KEY, (current) => { await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
const registry = current ?? {}; const registry = current ?? {};
registry[login] = { time: reminderTime, subscription }; registry[login] = { time: reminderTime, subscription };
return registry; return registry;
}); });
console.log(`Push reminder: uživatel ${login} přihlášen k připomínkám v ${reminderTime}`); console.log(`Push reminder: uživatel ${login} přihlášen k push notifikacím${reminderTime ? `, připomínka v ${reminderTime}` : ' (bez připomínky oběda)'}`);
} }
/** Odebere push subscription pro uživatele. */ /** Odebere push subscription pro uživatele. */
@@ -94,6 +109,45 @@ export function verifyQuickChoiceToken(login: string, token: string): boolean {
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex')); return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex'));
} }
/** Odebere z registru subscriptions, které push služba označila za neplatné (404/410). */
async function pruneExpiredSubscriptions(expiredLogins: string[]): Promise<void> {
if (expiredLogins.length === 0) return;
await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
const registry = current ?? {};
for (const login of expiredLogins) delete registry[login];
return registry;
});
}
/**
* Odešle push notifikaci daným uživatelům. Loginy bez registrované subscription
* se tiše přeskočí, expirované subscriptions se z registru odeberou.
*/
export async function sendPushToLogins(logins: string[], payload: PushPayload): Promise<void> {
if (logins.length === 0) return;
const registry = await storage.getData<Registry>(REGISTRY_KEY) ?? {};
const expiredLogins: string[] = [];
for (const login of logins) {
const entry = registry[login];
if (!entry) continue;
try {
await webpush.sendNotification(entry.subscription, JSON.stringify(payload));
console.log(`Push: odeslána notifikace "${payload.title}" uživateli ${login}`);
} catch (error: any) {
if (error.statusCode === 410 || error.statusCode === 404) {
console.log(`Push: subscription uživatele ${login} expirovala, odebírám`);
expiredLogins.push(login);
} else {
console.error(`Push: chyba při odesílání notifikace uživateli ${login}:`, error);
}
}
}
await pruneExpiredSubscriptions(expiredLogins);
}
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */ /** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
async function checkAndSendReminders(): Promise<void> { async function checkAndSendReminders(): Promise<void> {
if (getIsWeekend(getToday())) return; if (getIsWeekend(getToday())) return;
@@ -119,7 +173,8 @@ async function checkAndSendReminders(): Promise<void> {
const expiredLogins: string[] = []; const expiredLogins: string[] = [];
for (const [login, entry] of entries) { for (const [login, entry] of entries) {
if (currentTime < entry.time) continue; // Uživatelé bez nastaveného času připomínky odebírají jen ostatní push notifikace
if (!entry.time || currentTime < entry.time) continue;
const last = lastReminded.get(login) ?? 0; const last = lastReminded.get(login) ?? 0;
if (Date.now() - last < REMINDER_COOLDOWN_MS) continue; if (Date.now() - last < REMINDER_COOLDOWN_MS) continue;
@@ -148,13 +203,7 @@ async function checkAndSendReminders(): Promise<void> {
} }
} }
if (expiredLogins.length > 0) { await pruneExpiredSubscriptions(expiredLogins);
await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
const r = current ?? {};
for (const login of expiredLogins) delete r[login];
return r;
});
}
} }
/** Spustí scheduler pro kontrolu a odesílání připomínek každou minutu. */ /** Spustí scheduler pro kontrolu a odesílání připomínek každou minutu. */
+3 -5
View File
@@ -26,6 +26,7 @@ router.post("/settings", async (req: Request<{}, any, UpdateNotificationSettings
teamsWebhookUrl: req.body.teamsWebhookUrl, teamsWebhookUrl: req.body.teamsWebhookUrl,
enabledEvents: req.body.enabledEvents, enabledEvents: req.body.enabledEvents,
reminderTime: req.body.reminderTime, reminderTime: req.body.reminderTime,
boltDeliveredPush: req.body.boltDeliveredPush,
}); });
res.status(200).json(settings); res.status(200).json(settings);
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
@@ -40,22 +41,19 @@ router.get("/push/vapidKey", (req, res) => {
res.status(200).json({ key }); res.status(200).json({ key });
}); });
/** Přihlásí uživatele k push připomínkám. */ /** Přihlásí uživatele k push notifikacím. Čas připomínky výběru oběda je volitelný. */
router.post("/push/subscribe", async (req, res, next) => { router.post("/push/subscribe", async (req, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
try { try {
if (!req.body.subscription) { if (!req.body.subscription) {
return res.status(400).json({ error: "Nebyla předána push subscription" }); return res.status(400).json({ error: "Nebyla předána push subscription" });
} }
if (!req.body.reminderTime) {
return res.status(400).json({ error: "Nebyl předán čas připomínky" });
}
await subscribePush(login, req.body.subscription, req.body.reminderTime); await subscribePush(login, req.body.subscription, req.body.reminderTime);
res.status(200).json({}); res.status(200).json({});
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
/** Odhlásí uživatele z push připomínek. */ /** Odhlásí uživatele z push notifikací. */
router.post("/push/unsubscribe", async (req, res, next) => { router.post("/push/unsubscribe", async (req, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
try { try {
+94 -1
View File
@@ -2,7 +2,9 @@ import axios from 'axios';
import { resetMemoryStorage } from '../storage/memory'; import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage'; import getStorage from '../storage';
import { addStore } from '../stores'; import { addStore } from '../stores';
import { createGroup, setGroupState, setGroupBoltTracking } from '../groups'; import { createGroup, setGroupState, setGroupBoltTracking, addGroupMember } from '../groups';
import { saveNotificationSettings } from '../notifikace';
import { sendPushToLogins } from '../pushReminder';
import { extractBoltToken, computeDeliveryHHMM, checkBoltTracking } from '../boltTracking'; import { extractBoltToken, computeDeliveryHHMM, checkBoltTracking } from '../boltTracking';
import { startBoltSimulation, advanceBoltSimulation, setBoltSimulationStep, stopBoltSimulationByGroup } from '../boltSimulator'; import { startBoltSimulation, advanceBoltSimulation, setBoltSimulationStep, stopBoltSimulationByGroup } from '../boltSimulator';
import { ClientData, GroupState } from '../../../types/gen/types.gen'; import { ClientData, GroupState } from '../../../types/gen/types.gen';
@@ -16,6 +18,9 @@ jest.mock('../websocket', () => ({
getWebsocket: () => ({ emit: mockEmit }), getWebsocket: () => ({ emit: mockEmit }),
})); }));
jest.mock('../pushReminder', () => ({ sendPushToLogins: jest.fn() }));
const mockedSendPush = sendPushToLogins as jest.MockedFunction<typeof sendPushToLogins>;
const storage = getStorage(); const storage = getStorage();
const CREATOR = 'tomas'; const CREATOR = 'tomas';
@@ -321,3 +326,91 @@ describe('DEV simulace (boltSimulator + checkBoltTracking)', () => {
expect(g.boltTrackingToken).toBeUndefined(); expect(g.boltTrackingToken).toBeUndefined();
}); });
}); });
describe('notifikace o doručení objednávky', () => {
const MEMBER = 'petr';
const OTHER = 'jana';
let groupId: string;
beforeEach(async () => {
const d = await createGroup(CREATOR, STORE);
groupId = d.groups![0].id;
await addGroupMember(CREATOR, groupId, MEMBER);
await addGroupMember(CREATOR, groupId, OTHER);
await setGroupState(CREATOR, groupId, GroupState.LOCKED);
await setGroupState(CREATOR, groupId, GroupState.ORDERED);
await setGroupBoltTracking(CREATOR, groupId, SHARE_URL);
});
/** Posune objednávku do stavu "na cestě", aby další poll byl skutečný přechod. */
async function tickInDelivery() {
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'in_delivery', expected_time_to_client_in_seconds: 600 }));
await checkBoltTracking();
}
test('doručení notifikuje členy, kteří to mají zapnuté', async () => {
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
await saveNotificationSettings(OTHER, { boltDeliveredPush: false });
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
await checkBoltTracking();
expect(mockedSendPush).toHaveBeenCalledTimes(1);
expect(mockedSendPush).toHaveBeenCalledWith(
[MEMBER],
expect.objectContaining({ body: expect.stringContaining(STORE), url: '/objednani' }),
);
});
test('zakladatel notifikaci nedostane, ani když ji má zapnutou', async () => {
await saveNotificationSettings(CREATOR, { boltDeliveredPush: true });
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
await checkBoltTracking();
expect(mockedSendPush).toHaveBeenCalledWith([MEMBER], expect.anything());
});
test('bez zapnutého nastavení se neposílá nic', async () => {
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
await checkBoltTracking();
expect(mockedSendPush).not.toHaveBeenCalled();
});
test('notifikace se neposílá opakovaně (delivered → finished → objednávka zmizí)', async () => {
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered' }));
await checkBoltTracking();
expect(mockedSendPush).toHaveBeenCalledTimes(1);
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'finished' }));
await checkBoltTracking();
mockedAxios.post.mockResolvedValue(boltResponse(null));
await checkBoltTracking();
expect(mockedSendPush).toHaveBeenCalledTimes(1);
});
test('zmizelá objednávka bez předchozího doručení notifikuje', async () => {
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse(null));
await checkBoltTracking();
expect(mockedSendPush).toHaveBeenCalledTimes(1);
});
test('zrušená objednávka notifikaci neposílá', async () => {
await saveNotificationSettings(MEMBER, { boltDeliveredPush: true });
await tickInDelivery();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'cancelled' }));
await checkBoltTracking();
expect(mockedSendPush).not.toHaveBeenCalled();
});
});
+104
View File
@@ -0,0 +1,104 @@
import webpush from 'web-push';
import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage';
import { subscribePush, unsubscribePush, sendPushToLogins } from '../pushReminder';
jest.mock('web-push');
const mockedWebpush = webpush as jest.Mocked<typeof webpush>;
const storage = getStorage();
const REGISTRY_KEY = 'push_reminder_registry';
const SUB_A = { endpoint: 'https://push.example/a', keys: { p256dh: 'a', auth: 'a' } } as any;
const SUB_B = { endpoint: 'https://push.example/b', keys: { p256dh: 'b', auth: 'b' } } as any;
async function getRegistry(): Promise<Record<string, { time?: string; subscription: unknown }>> {
return await storage.getData(REGISTRY_KEY) ?? {};
}
beforeEach(() => {
resetMemoryStorage();
jest.clearAllMocks();
mockedWebpush.sendNotification.mockResolvedValue({} as any);
});
describe('subscribePush', () => {
test('uloží subscription i bez času připomínky', async () => {
await subscribePush('petr', SUB_A);
const entry = (await getRegistry())['petr'];
expect(entry.subscription).toEqual(SUB_A);
expect(entry.time).toBeUndefined();
});
test('uloží čas připomínky, když je zadán', async () => {
await subscribePush('petr', SUB_A, '10:30');
expect((await getRegistry())['petr'].time).toBe('10:30');
});
test('opakované přihlášení přepíše předchozí záznam', async () => {
await subscribePush('petr', SUB_A, '10:30');
await subscribePush('petr', SUB_B);
const entry = (await getRegistry())['petr'];
expect(entry.subscription).toEqual(SUB_B);
expect(entry.time).toBeUndefined();
});
test('unsubscribePush záznam odebere', async () => {
await subscribePush('petr', SUB_A);
await unsubscribePush('petr');
expect(await getRegistry()).toEqual({});
});
});
describe('sendPushToLogins', () => {
const PAYLOAD = { title: 'Luncher', body: 'Objednávka byla doručena!', url: '/objednani' };
test('odešle notifikaci registrovaným uživatelům', async () => {
await subscribePush('petr', SUB_A);
await subscribePush('jana', SUB_B, '11:00');
await sendPushToLogins(['petr', 'jana'], PAYLOAD);
expect(mockedWebpush.sendNotification).toHaveBeenCalledTimes(2);
expect(mockedWebpush.sendNotification).toHaveBeenCalledWith(SUB_A, JSON.stringify(PAYLOAD));
expect(mockedWebpush.sendNotification).toHaveBeenCalledWith(SUB_B, JSON.stringify(PAYLOAD));
});
test('přeskočí uživatele bez registrované subscription', async () => {
await subscribePush('petr', SUB_A);
await sendPushToLogins(['petr', 'nikdo'], PAYLOAD);
expect(mockedWebpush.sendNotification).toHaveBeenCalledTimes(1);
expect(mockedWebpush.sendNotification).toHaveBeenCalledWith(SUB_A, expect.any(String));
});
test('prázdný seznam neodesílá nic', async () => {
await subscribePush('petr', SUB_A);
await sendPushToLogins([], PAYLOAD);
expect(mockedWebpush.sendNotification).not.toHaveBeenCalled();
});
test('expirovanou subscription (410) odebere z registru', async () => {
await subscribePush('petr', SUB_A);
await subscribePush('jana', SUB_B);
mockedWebpush.sendNotification.mockImplementation(async (sub: any) => {
if (sub.endpoint === SUB_A.endpoint) throw Object.assign(new Error('Gone'), { statusCode: 410 });
return {} as any;
});
await sendPushToLogins(['petr', 'jana'], PAYLOAD);
const registry = await getRegistry();
expect(registry['petr']).toBeUndefined();
expect(registry['jana']).toBeDefined();
});
test('ostatní chyby subscription nemažou a nešíří se dál', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
await subscribePush('petr', SUB_A);
mockedWebpush.sendNotification.mockRejectedValue(Object.assign(new Error('Boom'), { statusCode: 500 }));
await expect(sendPushToLogins(['petr'], PAYLOAD)).resolves.toBeUndefined();
expect((await getRegistry())['petr']).toBeDefined();
errorSpy.mockRestore();
});
});
+3
View File
@@ -627,6 +627,9 @@ NotificationSettings:
reminderTime: reminderTime:
description: Čas, ve který má být uživatel upozorněn na nezvolený oběd (HH:MM). Prázdné = vypnuto. description: Čas, ve který má být uživatel upozorněn na nezvolený oběd (HH:MM). Prázdné = vypnuto.
type: string type: string
boltDeliveredPush:
description: Zapnuté push upozornění na doručení skupinové objednávky sledované přes Bolt Food.
type: boolean
GotifyServer: GotifyServer:
type: object type: object
required: required: