import webpush from 'web-push'; import crypto from 'crypto'; import getStorage from './storage'; import { getRedisClient } from './storage/redis'; 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 POD_ID = process.env.POD_ID ?? `local-${process.pid}`; interface RegistryEntry { time: string; subscription: webpush.PushSubscription; } type Registry = Record; /** Mapa login → timestamp (ms) posledního odeslání připomínky. */ const lastReminded = new Map(); const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami let reminderInterval: ReturnType | 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; } /** * 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 { 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 { 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); } } /** 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. */ export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime: string): Promise { await storage.updateData(REGISTRY_KEY, (current) => { const registry = current ?? {}; registry[login] = { time: reminderTime, subscription }; return registry; }); console.log(`Push reminder: uživatel ${login} přihlášen k připomínkám v ${reminderTime}`); } /** Odebere push subscription pro uživatele. */ export async function unsubscribePush(login: string): Promise { await storage.updateData(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')); } /** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */ async function checkAndSendReminders(): Promise { if (getIsWeekend(getToday())) return; // Leader election — pouze jeden pod spouští připomínky const isLeader = await tryAcquireOrRenewLease(); if (!isLeader) return; const registry = await storage.getData(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) { if (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); } } } if (expiredLogins.length > 0) { await storage.updateData(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. */ 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})`); }