feat: push notifikace pro připomínku výběru oběda
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful

This commit is contained in:
2026-03-04 13:33:58 +01:00
parent b77914404b
commit d62f6c1f5a
10 changed files with 439 additions and 6 deletions

28
client/public/sw.js Normal file
View File

@@ -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('/');
})
);
});

View File

@@ -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<Prop
const hideSoupsRef = useRef<HTMLInputElement>(null);
const themeRef = useRef<HTMLSelectElement>(null);
const reminderTimeRef = useRef<HTMLSelectElement>(null);
const ntfyTopicRef = useRef<HTMLInputElement>(null);
const discordWebhookRef = useRef<HTMLInputElement>(null);
const teamsWebhookRef = useRef<HTMLInputElement>(null);
@@ -43,6 +45,9 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
};
const handleSave = async () => {
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<Prop
discordWebhookUrl: discordWebhookRef.current?.value || undefined,
teamsWebhookUrl: teamsWebhookRef.current?.value || undefined,
enabledEvents,
reminderTime: newReminderTime,
}
}).catch(() => {});
// 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<Prop
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.
</p>
<Form.Group className="mb-3">
<Form.Label>Připomínka výběru oběda</Form.Label>
<Form.Select
ref={reminderTimeRef}
defaultValue={notifSettings.reminderTime ?? ''}
key={notifSettings.reminderTime ?? 'reminder-empty'}
>
<option value="">Vypnuto</option>
<option value="10:00">10:00</option>
<option value="10:30">10:30</option>
<option value="11:00">11:00</option>
<option value="11:30">11:30</option>
<option value="12:00">12:00</option>
<option value="12:30">12:30</option>
<option value="13:00">13:00</option>
<option value="13:30">13:30</option>
<option value="14:00">14:00</option>
</Form.Select>
<Form.Text className="text-muted">
V zadaný čas vám přijde push notifikace, pokud nemáte zvolenou možnost stravování.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>ntfy téma (topic)</Form.Label>
<Form.Control

View File

@@ -0,0 +1,108 @@
import { getToken } from '../Utils';
/** Převede base64url VAPID klíč na Uint8Array pro PushManager. */
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
/** Helper pro autorizované API volání na push endpointy. */
async function pushApiFetch(path: string, options: RequestInit = {}): Promise<Response> {
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<boolean> {
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<void> {
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);
}
}

View File

@@ -38,3 +38,9 @@
# 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
# 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

View File

@@ -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"
}
}

View File

@@ -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
View 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');
}

View File

@@ -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;

View File

@@ -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"

View File

@@ -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: