diff --git a/client/public/sw.js b/client/public/sw.js new file mode 100644 index 0000000..46b2e56 --- /dev/null +++ b/client/public/sw.js @@ -0,0 +1,28 @@ +// Service Worker pro Web Push notifikace (připomínka výběru oběda) + +self.addEventListener('push', (event) => { + const data = event.data?.json() ?? { title: 'Luncher', body: 'Ještě nemáte zvolený oběd!' }; + event.waitUntil( + self.registration.showNotification(data.title, { + body: data.body, + icon: '/favicon.ico', + tag: 'lunch-reminder', + }) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + event.waitUntil( + self.clients.matchAll({ type: 'window' }).then((clientList) => { + // Pokud je již otevřené okno, zaostříme na něj + for (const client of clientList) { + if (client.url.includes(self.location.origin) && 'focus' in client) { + return client.focus(); + } + } + // Jinak otevřeme nové + return self.clients.openWindow('/'); + }) + ); +}); diff --git a/client/src/components/modals/SettingsModal.tsx b/client/src/components/modals/SettingsModal.tsx index a7efc92..7f2bbf1 100644 --- a/client/src/components/modals/SettingsModal.tsx +++ b/client/src/components/modals/SettingsModal.tsx @@ -3,6 +3,7 @@ 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"; +import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder"; type Props = { isOpen: boolean, @@ -19,6 +20,7 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly(null); const themeRef = useRef(null); + const reminderTimeRef = useRef(null); const ntfyTopicRef = useRef(null); const discordWebhookRef = useRef(null); const teamsWebhookRef = useRef(null); @@ -43,6 +45,9 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly { + const newReminderTime = reminderTimeRef.current?.value || undefined; + const oldReminderTime = notifSettings.reminderTime; + // Uložení notifikačních nastavení na server await updateNotificationSettings({ body: { @@ -50,9 +55,17 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly {}); + // Správa push subscription pro připomínky + if (newReminderTime && newReminderTime !== oldReminderTime) { + subscribeToPush(newReminderTime); + } else if (!newReminderTime && oldReminderTime) { + unsubscribeFromPush(); + } + // Uložení ostatních nastavení (localStorage) onSave( bankAccountRef.current?.value, @@ -102,6 +115,29 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly + + Připomínka výběru oběda + + + + + + + + + + + + + + V zadaný čas vám přijde push notifikace, pokud nemáte zvolenou možnost stravování. + + + ntfy téma (topic) { + const token = getToken(); + return fetch(`/api/notifications/push${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }, + }); +} + +/** + * Zaregistruje service worker, přihlásí se k push notifikacím + * a odešle subscription na server. + */ +export async function subscribeToPush(reminderTime: string): Promise { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + console.warn('Push notifikace nejsou v tomto prohlížeči podporovány'); + return false; + } + + try { + // Registrace service workeru + const registration = await navigator.serviceWorker.register('/sw.js'); + await navigator.serviceWorker.ready; + + // Vyžádání oprávnění + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + console.warn('Push notifikace: oprávnění zamítnuto'); + return false; + } + + // Získání VAPID veřejného klíče ze serveru + const vapidResponse = await pushApiFetch('/vapidKey'); + if (!vapidResponse.ok) { + console.error('Push notifikace: nepodařilo se získat VAPID klíč'); + return false; + } + const { key: vapidPublicKey } = await vapidResponse.json(); + + // Přihlášení k push + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource, + }); + + // Odeslání subscription na server + const response = await pushApiFetch('/subscribe', { + method: 'POST', + body: JSON.stringify({ + subscription: subscription.toJSON(), + reminderTime, + }), + }); + + if (!response.ok) { + console.error('Push notifikace: nepodařilo se odeslat subscription na server'); + return false; + } + + console.log('Push notifikace: úspěšně přihlášeno k připomínkám v', reminderTime); + return true; + } catch (error) { + console.error('Push notifikace: chyba při registraci', error); + return false; + } +} + +/** + * Odhlásí se z push notifikací a informuje server. + */ +export async function unsubscribeFromPush(): Promise { + if (!('serviceWorker' in navigator)) { + return; + } + + try { + const registration = await navigator.serviceWorker.getRegistration('/sw.js'); + if (registration) { + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + await subscription.unsubscribe(); + } + } + + await pushApiFetch('/unsubscribe', { method: 'POST' }); + console.log('Push notifikace: úspěšně odhlášeno z připomínek'); + } catch (error) { + console.error('Push notifikace: chyba při odhlášení', error); + } +} diff --git a/server/.env.template b/server/.env.template index ca2f9d8..f78a16d 100644 --- a/server/.env.template +++ b/server/.env.template @@ -37,4 +37,10 @@ # HTTP_REMOTE_TRUSTED_IPS=127.0.0.1,192.168.1.0/24 # Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'. -# HTTP_REMOTE_USER_HEADER_NAME=remote-user \ No newline at end of file +# HTTP_REMOTE_USER_HEADER_NAME=remote-user + +# VAPID klíče pro Web Push notifikace (připomínka výběru oběda). +# Vygenerovat pomocí: npx web-push generate-vapid-keys +# VAPID_PUBLIC_KEY= +# VAPID_PRIVATE_KEY= +# VAPID_SUBJECT=mailto:admin@example.com \ No newline at end of file diff --git a/server/package.json b/server/package.json index 0b90a38..0b0ccfb 100644 --- a/server/package.json +++ b/server/package.json @@ -19,6 +19,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.10.0", "@types/request-promise": "^4.1.48", + "@types/web-push": "^3.6.4", "babel-jest": "^30.2.0", "jest": "^30.2.0", "nodemon": "^3.1.10", @@ -34,6 +35,7 @@ "jsonwebtoken": "^9.0.0", "redis": "^5.9.0", "simple-json-db": "^2.0.0", - "socket.io": "^4.6.1" + "socket.io": "^4.6.1", + "web-push": "^3.6.7" } } diff --git a/server/src/index.ts b/server/src/index.ts index 7aec7a7..384eb5f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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í diff --git a/server/src/pushReminder.ts b/server/src/pushReminder.ts new file mode 100644 index 0000000..d3e499d --- /dev/null +++ b/server/src/pushReminder.ts @@ -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; + +/** Mapa login → datum (YYYY-MM-DD), kdy byl uživatel naposledy upozorněn. */ +const remindedToday = new Map(); + +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 { + return await storage.getData(REGISTRY_KEY) ?? {}; +} + +async function saveRegistry(registry: Registry): Promise { + 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 { + 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 { + 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 { + // 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'); +} diff --git a/server/src/routes/notificationRoutes.ts b/server/src/routes/notificationRoutes.ts index 1772fb9..c377829 100644 --- a/server/src/routes/notificationRoutes.ts +++ b/server/src/routes/notificationRoutes.ts @@ -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; diff --git a/server/yarn.lock b/server/yarn.lock index 7163393..d4e1fad 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -1732,6 +1732,13 @@ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" integrity sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA== +"@types/web-push@^3.6.4": + version "3.6.4" + resolved "https://registry.yarnpkg.com/@types/web-push/-/web-push-3.6.4.tgz#4c6e10d3963ba51e7b4b8fff185f43612c0d1346" + integrity sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ== + dependencies: + "@types/node" "*" + "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -1874,6 +1881,11 @@ acorn@^8.11.0, acorn@^8.4.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0" integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== +agent-base@^7.1.2: + version "7.1.4" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== + ansi-escapes@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" @@ -1928,6 +1940,16 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +asn1.js@^5.3.0: + version "5.4.1" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" + integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + safer-buffer "^2.1.0" + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -2046,6 +2068,11 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== +bn.js@^4.0.0: + version "4.12.3" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.3.tgz#2cc2c679188eb35b006f2d0d4710bed8437a769e" + integrity sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g== + body-parser@^2.2.1: version "2.2.2" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.2.tgz#1a32cdb966beaf68de50a9dfbe5b58f83cb8890c" @@ -2342,7 +2369,7 @@ css-what@^6.1.0: resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== -debug@^4, debug@^4.1.0, debug@^4.3.1, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1: +debug@4, debug@^4, debug@^4.1.0, debug@^4.3.1, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -2892,6 +2919,19 @@ http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.1: statuses "~2.0.2" toidentifier "~1.0.1" +http_ece@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http_ece/-/http_ece-1.2.0.tgz#84d5885f052eae8c9b075eee4d2eb5105f114479" + integrity sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA== + +https-proxy-agent@^7.0.0: + version "7.0.6" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== + dependencies: + agent-base "^7.1.2" + debug "4" + human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" @@ -2937,7 +2977,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@~2.0.4: +inherits@2, inherits@^2.0.1, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -3468,7 +3508,7 @@ jwa@^2.0.1: ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" -jws@^4.0.1: +jws@^4.0.0, jws@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== @@ -3621,6 +3661,11 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +minimalistic-assert@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -3635,6 +3680,11 @@ minimatch@^9.0.4: dependencies: brace-expansion "^2.0.1" +minimist@^1.2.5: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + "minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" @@ -4031,7 +4081,7 @@ safe-buffer@^5.0.1, safe-buffer@^5.2.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -"safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -4503,6 +4553,17 @@ walker@^1.0.8: dependencies: makeerror "1.0.12" +web-push@^3.6.7: + version "3.6.7" + resolved "https://registry.yarnpkg.com/web-push/-/web-push-3.6.7.tgz#5f5e645951153e37ef90a6ddea5c150ea0f709e1" + integrity sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A== + dependencies: + asn1.js "^5.3.0" + http_ece "1.2.0" + https-proxy-agent "^7.0.0" + jws "^4.0.0" + minimist "^1.2.5" + whatwg-encoding@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5" diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index f19e175..75915d4 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -550,6 +550,9 @@ NotificationSettings: type: array items: $ref: "#/UdalostEnum" + reminderTime: + description: Čas, ve který má být uživatel upozorněn na nezvolený oběd (HH:MM). Prázdné = vypnuto. + type: string GotifyServer: type: object required: