67abbf19b5
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 20s
CI / Build server (push) Successful in 26s
CI / Build client (push) Successful in 35s
CI / Playwright E2E tests (push) Failing after 1m56s
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 1s
- Socket.io Redis adapter pro sdílený stav přes repliky - graceful shutdown serveru - WATCH/MULTI v updateData pro race-condition-safe aktualizace - lease mechanismus pro push reminder (zabrání duplicitnímu odesílání) - k8s/ manifesty pro testovací kind cluster - Dockerfile: opraven EXPOSE port na 3001 - .gitignore: ignorovány Claude pracovní soubory
217 lines
7.9 KiB
TypeScript
217 lines
7.9 KiB
TypeScript
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<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;
|
|
}
|
|
|
|
/**
|
|
* Pokusí se získat nebo obnovit leader lease pro scheduler připomínek.
|
|
* Vrátí true pokud tato instance smí spustit připomínky.
|
|
* Při non-Redis storage vždy vrací true (single-process, leader election není potřeba).
|
|
*/
|
|
async function tryAcquireOrRenewLease(): Promise<boolean> {
|
|
if (process.env.STORAGE?.toLowerCase() !== 'redis') return true;
|
|
try {
|
|
const c = getRedisClient();
|
|
if (!c) return true;
|
|
|
|
// Zkusíme získat lease atomicky (SET NX EX)
|
|
const acquired = await c.set(LEADER_LEASE_KEY, POD_ID, { NX: true, EX: LEASE_TTL_SECONDS });
|
|
if (acquired !== null) return true; // lease čerstvě získána
|
|
|
|
// Pokud jsme ji nedostali, ověříme zda ji držíme my
|
|
const currentHolder = await c.get(LEADER_LEASE_KEY);
|
|
if (currentHolder === POD_ID) {
|
|
// Naše lease — obnovíme TTL
|
|
await c.set(LEADER_LEASE_KEY, POD_ID, { EX: LEASE_TTL_SECONDS });
|
|
return true;
|
|
}
|
|
return false; // lease drží jiná instance
|
|
} catch (e) {
|
|
console.error('Push reminder: chyba při získávání lease, připomínky budou odeslány', e);
|
|
return true; // při chybě raději spustíme, než vynecháme
|
|
}
|
|
}
|
|
|
|
/** Uvolní leader lease při graceful shutdown. */
|
|
export async function releaseReminderLease(): Promise<void> {
|
|
if (process.env.STORAGE?.toLowerCase() !== 'redis') return;
|
|
try {
|
|
const c = getRedisClient();
|
|
if (!c) return;
|
|
const currentHolder = await c.get(LEADER_LEASE_KEY);
|
|
if (currentHolder === POD_ID) {
|
|
await c.del(LEADER_LEASE_KEY);
|
|
console.log('Push reminder: lease uvolněna');
|
|
}
|
|
} catch (e) {
|
|
console.error('Push reminder: chyba při uvolňování lease', e);
|
|
}
|
|
}
|
|
|
|
/** 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<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 připomínkám v ${reminderTime}`);
|
|
}
|
|
|
|
/** 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'));
|
|
}
|
|
|
|
/** 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 tryAcquireOrRenewLease();
|
|
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) {
|
|
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>(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})`);
|
|
}
|