From d366ac39d423139eaea3ac42efeef5345ce4226c Mon Sep 17 00:00:00 2001 From: batmanisko Date: Wed, 4 Feb 2026 17:08:23 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20podpora=20per-user=20notifikac=C3=AD=20?= =?UTF-8?q?s=20Discord,=20ntfy=20a=20Teams=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uživatelé mohou v nastavení konfigurovat vlastní webhook URL/topic pro Discord, MS Teams a ntfy, a zvolit události k odběru. Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou. --- .../src/components/modals/SettingsModal.tsx | 119 ++++++++++++++++- server/src/index.ts | 2 + server/src/notifikace.ts | 126 ++++++++++++++++-- server/src/routes/notificationRoutes.ts | 32 +++++ 4 files changed, 267 insertions(+), 12 deletions(-) create mode 100644 server/src/routes/notificationRoutes.ts diff --git a/client/src/components/modals/SettingsModal.tsx b/client/src/components/modals/SettingsModal.tsx index f0bdc4b..a7efc92 100644 --- a/client/src/components/modals/SettingsModal.tsx +++ b/client/src/components/modals/SettingsModal.tsx @@ -1,6 +1,8 @@ -import { useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Modal, Button, Form } from "react-bootstrap" import { useSettings, ThemePreference } from "../../context/settings"; +import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types"; +import { useAuth } from "../../context/auth"; type Props = { isOpen: boolean, @@ -10,12 +12,56 @@ type Props = { /** Modální dialog pro uživatelská nastavení. */ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly) { + const auth = useAuth(); const settings = useSettings(); const bankAccountRef = useRef(null); const nameRef = useRef(null); const hideSoupsRef = useRef(null); const themeRef = useRef(null); + const ntfyTopicRef = useRef(null); + const discordWebhookRef = useRef(null); + const teamsWebhookRef = useRef(null); + const [notifSettings, setNotifSettings] = useState({}); + const [enabledEvents, setEnabledEvents] = useState([]); + + useEffect(() => { + if (isOpen && auth?.login) { + getNotificationSettings().then(response => { + if (response.data) { + setNotifSettings(response.data); + setEnabledEvents(response.data.enabledEvents ?? []); + } + }).catch(() => {}); + } + }, [isOpen, auth?.login]); + + const toggleEvent = (event: UdalostEnum) => { + setEnabledEvents(prev => + prev.includes(event) ? prev.filter(e => e !== event) : [...prev, event] + ); + }; + + const handleSave = async () => { + // Uložení notifikačních nastavení na server + await updateNotificationSettings({ + body: { + ntfyTopic: ntfyTopicRef.current?.value || undefined, + discordWebhookUrl: discordWebhookRef.current?.value || undefined, + teamsWebhookUrl: teamsWebhookRef.current?.value || undefined, + enabledEvents, + } + }).catch(() => {}); + + // Uložení ostatních nastavení (localStorage) + onSave( + bankAccountRef.current?.value, + nameRef.current?.value, + hideSoupsRef.current?.checked, + themeRef.current?.value as ThemePreference, + ); + }; + return ( @@ -51,6 +97,75 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly +

Notifikace

+

+ Nastavením notifikací budete dostávat upozornění o událostech (např. "Jdeme na oběd") přímo do vámi zvoleného komunikačního kanálu. +

+ + + ntfy téma (topic) + e.stopPropagation()} + /> + + Téma pro ntfy push notifikace. Nechte prázdné pro vypnutí. + + + + + Discord webhook URL + e.stopPropagation()} + /> + + URL webhooku Discord kanálu. Nechte prázdné pro vypnutí. + + + + + MS Teams webhook URL + e.stopPropagation()} + /> + + URL webhooku MS Teams kanálu. Nechte prázdné pro vypnutí. + + + + + Události k odběru + {Object.values(UdalostEnum).map(event => ( + toggleEvent(event)} + /> + ))} + + Zvolte události, o kterých chcete být notifikováni. Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou. + + + +
+

Bankovní účet

Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day. @@ -88,7 +203,7 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly Storno - diff --git a/server/src/index.ts b/server/src/index.ts index 6b75441..4936a6b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -13,6 +13,7 @@ import foodRoutes, { refreshMetoda } from "./routes/foodRoutes"; import votingRoutes from "./routes/votingRoutes"; import easterEggRoutes from "./routes/easterEggRoutes"; import statsRoutes from "./routes/statsRoutes"; +import notificationRoutes from "./routes/notificationRoutes"; const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); @@ -146,6 +147,7 @@ app.use("/api/food", foodRoutes); app.use("/api/voting", votingRoutes); app.use("/api/easterEggs", easterEggRoutes); app.use("/api/stats", statsRoutes); +app.use("/api/notifications", notificationRoutes); app.use('/stats', express.static('public')); app.use(express.static('public')); diff --git a/server/src/notifikace.ts b/server/src/notifikace.ts index 9663181..232c3eb 100644 --- a/server/src/notifikace.ts +++ b/server/src/notifikace.ts @@ -3,11 +3,56 @@ import dotenv from 'dotenv'; import path from 'path'; import { getClientData, getToday } from "./service"; import { getUsersByLocation, getHumanTime } from "./utils"; -import { NotifikaceData, NotifikaceInput } from '../../types'; +import { NotifikaceData, NotifikaceInput, NotificationSettings } from '../../types'; +import getStorage from "./storage"; const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); +const storage = getStorage(); +const NOTIFICATION_SETTINGS_PREFIX = 'notif'; + +/** Vrátí klíč pro uložení notifikačních nastavení uživatele. */ +function getNotificationSettingsKey(login: string): string { + return `${NOTIFICATION_SETTINGS_PREFIX}_${login}`; +} + +/** Vrátí nastavení notifikací pro daného uživatele. */ +export async function getNotificationSettings(login: string): Promise { + return await storage.getData(getNotificationSettingsKey(login)) ?? {}; +} + +/** Uloží nastavení notifikací pro daného uživatele. */ +export async function saveNotificationSettings(login: string, settings: NotificationSettings): Promise { + await storage.setData(getNotificationSettingsKey(login), settings); + return settings; +} + +/** Odešle ntfy notifikaci na dané téma. */ +async function ntfyCallToTopic(topic: string, message: string) { + const url = process.env.NTFY_HOST; + const username = process.env.NTFY_USERNAME; + const password = process.env.NTFY_PASSWD; + if (!url || !username || !password) { + return; + } + const token = Buffer.from(`${username}:${password}`, 'utf8').toString('base64'); + try { + const response = await axios({ + url: `${url}/${topic}`, + method: 'POST', + data: message, + headers: { + 'Authorization': `Basic ${token}`, + 'Tag': 'meat_on_bone' + } + }); + console.log(response.data); + } catch (error) { + console.error(`Chyba při odesílání ntfy notifikace na topic ${topic}:`, error); + } +} + export const ntfyCall = async (data: NotifikaceInput) => { const url = process.env.NTFY_HOST const username = process.env.NTFY_USERNAME; @@ -87,10 +132,58 @@ export const teamsCall = async (data: NotifikaceInput) => { } } +/** Odešle Teams notifikaci na daný webhook URL. */ +async function teamsCallToUrl(webhookUrl: string, data: NotifikaceInput) { + const title = data.udalost; + let time = new Date(); + time.setTime(time.getTime() + 1000 * 60); + const message = 'Odcházíme v ' + getHumanTime(time) + ', ' + data.user; + const card = { + '@type': 'MessageCard', + '@context': 'http://schema.org/extensions', + 'themeColor': "0072C6", + summary: 'Summary description', + sections: [ + { + activityTitle: title, + text: message, + }, + ], + }; + try { + await axios.post(webhookUrl, card, { + headers: { + 'content-type': 'application/vnd.microsoft.teams.card.o365connector' + }, + }); + } catch (error) { + console.error(`Chyba při odesílání Teams notifikace:`, error); + } +} + +/** Odešle Discord notifikaci na daný webhook URL. */ +async function discordCall(webhookUrl: string, data: NotifikaceInput) { + let time = new Date(); + time.setTime(time.getTime() + 1000 * 60); + const message = `🍖 **${data.udalost}** — ${data.user} (odchod v ${getHumanTime(time)})`; + try { + await axios.post(webhookUrl, { + content: message, + }, { + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + console.error(`Chyba při odesílání Discord notifikace:`, error); + } +} + /** Zavolá notifikace na všechny konfigurované způsoby notifikace, přetížení proměných na false pro jednotlivé způsoby je vypne*/ export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy = true }: NotifikaceData) => { - const notifications = []; + const notifications: Promise[] = []; + // Globální notifikace (zpětně kompatibilní) if (ntfy) { const ntfyPromises = await ntfyCall(input); if (ntfyPromises) { @@ -100,20 +193,33 @@ export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy if (teams) { const teamsPromises = await teamsCall(input); if (teamsPromises) { - notifications.push(teamsPromises); + notifications.push(Promise.resolve(teamsPromises)); + } + } + + // Per-user notifikace: najdeme uživatele se stejnou lokací a odešleme dle jejich nastavení + const clientData = await getClientData(getToday()); + const usersToNotify = getUsersByLocation(clientData.choices, input.user); + for (const user of usersToNotify) { + if (user === input.user) continue; // Neposíláme notifikaci spouštějícímu uživateli + const userSettings = await getNotificationSettings(user); + if (!userSettings.enabledEvents?.includes(input.udalost)) continue; + + if (userSettings.ntfyTopic) { + notifications.push(ntfyCallToTopic(userSettings.ntfyTopic, `${input.udalost} - spustil: ${input.user}`)); + } + if (userSettings.discordWebhookUrl) { + notifications.push(discordCall(userSettings.discordWebhookUrl, input)); + } + if (userSettings.teamsWebhookUrl) { + notifications.push(teamsCallToUrl(userSettings.teamsWebhookUrl, input)); } } - // gotify bych řekl, že už je deprecated - // if (gotify) { - // const gotifyPromises = await gotifyCall(input, gotifyData); - // notifications.push(...gotifyPromises); - // } try { const results = await Promise.all(notifications); return results; } catch (error) { console.error("Error in callNotifikace: ", error); - // Handle the error as needed } -}; \ No newline at end of file +}; diff --git a/server/src/routes/notificationRoutes.ts b/server/src/routes/notificationRoutes.ts new file mode 100644 index 0000000..1772fb9 --- /dev/null +++ b/server/src/routes/notificationRoutes.ts @@ -0,0 +1,32 @@ +import express, { Request } from "express"; +import { getLogin } from "../auth"; +import { parseToken } from "../utils"; +import { getNotificationSettings, saveNotificationSettings } from "../notifikace"; +import { UpdateNotificationSettingsData } from "../../../types"; + +const router = express.Router(); + +/** Vrátí nastavení notifikací pro přihlášeného uživatele. */ +router.get("/settings", async (req, res, next) => { + const login = getLogin(parseToken(req)); + try { + const settings = await getNotificationSettings(login); + res.status(200).json(settings); + } catch (e: any) { next(e) } +}); + +/** Uloží nastavení notifikací pro přihlášeného uživatele. */ +router.post("/settings", async (req: Request<{}, any, UpdateNotificationSettingsData["body"]>, res, next) => { + const login = getLogin(parseToken(req)); + try { + const settings = await saveNotificationSettings(login, { + ntfyTopic: req.body.ntfyTopic, + discordWebhookUrl: req.body.discordWebhookUrl, + teamsWebhookUrl: req.body.teamsWebhookUrl, + enabledEvents: req.body.enabledEvents, + }); + res.status(200).json(settings); + } catch (e: any) { next(e) } +}); + +export default router;