Files
Luncher/server/src/pushReminder.ts
T
batmanisko 065ccaf38a
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
feat: push upozornění na doručení objednávky Bolt Food
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.
2026-08-10 12:36:07 +02:00

225 lines
8.4 KiB
TypeScript

import webpush from 'web-push';
import crypto from 'crypto';
import getStorage from './storage';
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 lease = createLeaderLease('luncher:reminder:leader');
const POD_ID = process.env.POD_ID ?? `local-${process.pid}`;
interface RegistryEntry {
/** Č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;
}
/** 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>;
/** Mapa login → timestamp (ms) posledního odeslání připomínky. */
const lastReminded = new Map<string, number>();
const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami
let reminderInterval: ReturnType<typeof setInterval> | undefined;
function getCurrentTimeHHMM(): string {
const now = new Date();
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
}
/** Zjistí, zda má uživatel zvolenou nějakou možnost stravování. */
function userHasChoice(choices: LunchChoices, login: string): boolean {
for (const locationKey of Object.keys(choices)) {
const locationChoices = choices[locationKey as keyof LunchChoices];
if (locationChoices && login in locationChoices) {
return true;
}
}
return false;
}
/** Uvolní leader lease při graceful shutdown. */
export async function releaseReminderLease(): Promise<void> {
await lease.release();
}
/** Stopne scheduler připomínek. Volá se při graceful shutdown. */
export function stopReminderScheduler(): void {
if (reminderInterval) {
clearInterval(reminderInterval);
reminderInterval = undefined;
}
}
/**
* 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) => {
const registry = current ?? {};
registry[login] = { time: reminderTime, subscription };
return registry;
});
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. */
export async function unsubscribePush(login: string): Promise<void> {
await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
const registry = current ?? {};
delete registry[login];
return registry;
});
lastReminded.delete(login);
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
}
/** Vrátí veřejný VAPID klíč. */
export function getVapidPublicKey(): string | undefined {
return process.env.VAPID_PUBLIC_KEY;
}
function generateQuickChoiceToken(login: string): string {
const today = new Date().toISOString().slice(0, 10);
const secret = process.env.JWT_SECRET ?? '';
return crypto.createHmac('sha256', secret).update(`${login}:${today}`).digest('hex');
}
/** Ověří jednorázový token z push notifikace. */
export function verifyQuickChoiceToken(login: string, token: string): boolean {
if (!login || !token || token.length !== 64) return false;
const expected = generateQuickChoiceToken(login);
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. */
async function checkAndSendReminders(): Promise<void> {
if (getIsWeekend(getToday())) return;
// Leader election — pouze jeden pod spouští připomínky
const isLeader = await lease.tryAcquireOrRenew();
if (!isLeader) return;
const registry = await storage.getData<Registry>(REGISTRY_KEY) ?? {};
const entries = Object.entries(registry);
if (entries.length === 0) return;
const currentTime = getCurrentTimeHHMM();
let clientData;
try {
clientData = await getClientData(getToday());
} catch (e) {
console.error('Push reminder: chyba při získávání dat', e);
return;
}
const expiredLogins: string[] = [];
for (const [login, entry] of entries) {
// 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;
if (Date.now() - last < REMINDER_COOLDOWN_MS) continue;
if (clientData.choices && userHasChoice(clientData.choices, login)) continue;
try {
await webpush.sendNotification(
entry.subscription,
JSON.stringify({
title: 'Luncher',
body: 'Ještě nemáte zvolený oběd!',
login,
token: generateQuickChoiceToken(login),
})
);
lastReminded.set(login, Date.now());
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
} catch (error: any) {
if (error.statusCode === 410 || error.statusCode === 404) {
console.log(`Push reminder: subscription uživatele ${login} expirovala, odebírám`);
expiredLogins.push(login);
} else {
console.error(`Push reminder: chyba při odesílání notifikace uživateli ${login}:`, error);
}
}
}
await pruneExpiredSubscriptions(expiredLogins);
}
/** Spustí scheduler pro kontrolu a odesílání připomínek každou minutu. */
export function startReminderScheduler(): void {
const publicKey = process.env.VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
const subject = process.env.VAPID_SUBJECT;
if (!publicKey || !privateKey || !subject) {
console.log('Push reminder: VAPID klíče nejsou nastaveny, scheduler nebude spuštěn');
return;
}
webpush.setVapidDetails(subject, publicKey, privateKey);
reminderInterval = setInterval(checkAndSendReminders, 60_000);
console.log(`Push reminder: scheduler spuštěn (POD_ID=${POD_ID})`);
}