feat: push notifikace pro připomínku výběru oběda
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
This commit is contained in:
@@ -9,6 +9,7 @@ import { generateToken, getLogin, verify } from "./auth";
|
||||
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
|
||||
import { getPendingQrs } from "./pizza";
|
||||
import { initWebsocket } from "./websocket";
|
||||
import { startReminderScheduler } from "./pushReminder";
|
||||
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
||||
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
||||
import votingRoutes from "./routes/votingRoutes";
|
||||
@@ -185,6 +186,7 @@ const HOST = process.env.HOST ?? '0.0.0.0';
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server listening on ${HOST}, port ${PORT}`);
|
||||
startReminderScheduler();
|
||||
});
|
||||
|
||||
// Umožníme vypnutí serveru přes SIGINT, jinak Docker čeká než ho sestřelí
|
||||
|
||||
152
server/src/pushReminder.ts
Normal file
152
server/src/pushReminder.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import webpush from 'web-push';
|
||||
import getStorage from './storage';
|
||||
import { getClientData, getToday } from './service';
|
||||
import { getIsWeekend } from './utils';
|
||||
import { LunchChoices } from '../../types';
|
||||
|
||||
const storage = getStorage();
|
||||
const REGISTRY_KEY = 'push_reminder_registry';
|
||||
|
||||
interface RegistryEntry {
|
||||
time: string;
|
||||
subscription: webpush.PushSubscription;
|
||||
}
|
||||
|
||||
type Registry = Record<string, RegistryEntry>;
|
||||
|
||||
/** Mapa login → datum (YYYY-MM-DD), kdy byl uživatel naposledy upozorněn. */
|
||||
const remindedToday = new Map<string, string>();
|
||||
|
||||
function getTodayDateString(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function getRegistry(): Promise<Registry> {
|
||||
return await storage.getData<Registry>(REGISTRY_KEY) ?? {};
|
||||
}
|
||||
|
||||
async function saveRegistry(registry: Registry): Promise<void> {
|
||||
await storage.setData(REGISTRY_KEY, registry);
|
||||
}
|
||||
|
||||
/** Přidá nebo aktualizuje push subscription pro uživatele. */
|
||||
export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime: string): Promise<void> {
|
||||
const registry = await getRegistry();
|
||||
registry[login] = { time: reminderTime, subscription };
|
||||
await saveRegistry(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> {
|
||||
const registry = await getRegistry();
|
||||
delete registry[login];
|
||||
await saveRegistry(registry);
|
||||
remindedToday.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;
|
||||
}
|
||||
|
||||
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
|
||||
async function checkAndSendReminders(): Promise<void> {
|
||||
// Přeskočit víkendy
|
||||
if (getIsWeekend(getToday())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registry = await getRegistry();
|
||||
const entries = Object.entries(registry);
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = getCurrentTimeHHMM();
|
||||
const todayStr = getTodayDateString();
|
||||
|
||||
// Získáme data pro dnešek jednou pro všechny uživatele
|
||||
let clientData;
|
||||
try {
|
||||
clientData = await getClientData(getToday());
|
||||
} catch (e) {
|
||||
console.error('Push reminder: chyba při získávání dat', e);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [login, entry] of entries) {
|
||||
// Ještě nedosáhl čas připomínky
|
||||
if (currentTime < entry.time) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Už jsme dnes připomenuli
|
||||
if (remindedToday.get(login) === todayStr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Uživatel už má zvolenou možnost
|
||||
if (clientData.choices && userHasChoice(clientData.choices, login)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Odešleme push notifikaci
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
entry.subscription,
|
||||
JSON.stringify({
|
||||
title: 'Luncher',
|
||||
body: 'Ještě nemáte zvolený oběd!',
|
||||
})
|
||||
);
|
||||
remindedToday.set(login, todayStr);
|
||||
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||
// Subscription expirovala nebo je neplatná — odebereme z registry
|
||||
console.log(`Push reminder: subscription uživatele ${login} expirovala, odebírám`);
|
||||
delete registry[login];
|
||||
await saveRegistry(registry);
|
||||
} else {
|
||||
console.error(`Push reminder: chyba při odesílání notifikace uživateli ${login}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
// Spustíme kontrolu každou minutu
|
||||
setInterval(checkAndSendReminders, 60_000);
|
||||
console.log('Push reminder: scheduler spuštěn');
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import express, { Request } from "express";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
|
||||
import { subscribePush, unsubscribePush, getVapidPublicKey } from "../pushReminder";
|
||||
import { UpdateNotificationSettingsData } from "../../../types";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -24,9 +25,43 @@ router.post("/settings", async (req: Request<{}, any, UpdateNotificationSettings
|
||||
discordWebhookUrl: req.body.discordWebhookUrl,
|
||||
teamsWebhookUrl: req.body.teamsWebhookUrl,
|
||||
enabledEvents: req.body.enabledEvents,
|
||||
reminderTime: req.body.reminderTime,
|
||||
});
|
||||
res.status(200).json(settings);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
/** Vrátí veřejný VAPID klíč pro registraci push notifikací. */
|
||||
router.get("/push/vapidKey", (req, res) => {
|
||||
const key = getVapidPublicKey();
|
||||
if (!key) {
|
||||
return res.status(503).json({ error: "Push notifikace nejsou nakonfigurovány" });
|
||||
}
|
||||
res.status(200).json({ key });
|
||||
});
|
||||
|
||||
/** Přihlásí uživatele k push připomínkám. */
|
||||
router.post("/push/subscribe", async (req, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
try {
|
||||
if (!req.body.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);
|
||||
res.status(200).json({});
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
/** Odhlásí uživatele z push připomínek. */
|
||||
router.post("/push/unsubscribe", async (req, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
try {
|
||||
await unsubscribePush(login);
|
||||
res.status(200).json({});
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user