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:
28
client/public/sw.js
Normal file
28
client/public/sw.js
Normal 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('/');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { Modal, Button, Form } from "react-bootstrap"
|
|||||||
import { useSettings, ThemePreference } from "../../context/settings";
|
import { useSettings, ThemePreference } from "../../context/settings";
|
||||||
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
|
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
|
||||||
import { useAuth } from "../../context/auth";
|
import { useAuth } from "../../context/auth";
|
||||||
|
import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
@@ -19,6 +20,7 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
|||||||
const hideSoupsRef = useRef<HTMLInputElement>(null);
|
const hideSoupsRef = useRef<HTMLInputElement>(null);
|
||||||
const themeRef = useRef<HTMLSelectElement>(null);
|
const themeRef = useRef<HTMLSelectElement>(null);
|
||||||
|
|
||||||
|
const reminderTimeRef = useRef<HTMLSelectElement>(null);
|
||||||
const ntfyTopicRef = useRef<HTMLInputElement>(null);
|
const ntfyTopicRef = useRef<HTMLInputElement>(null);
|
||||||
const discordWebhookRef = useRef<HTMLInputElement>(null);
|
const discordWebhookRef = useRef<HTMLInputElement>(null);
|
||||||
const teamsWebhookRef = 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 handleSave = async () => {
|
||||||
|
const newReminderTime = reminderTimeRef.current?.value || undefined;
|
||||||
|
const oldReminderTime = notifSettings.reminderTime;
|
||||||
|
|
||||||
// Uložení notifikačních nastavení na server
|
// Uložení notifikačních nastavení na server
|
||||||
await updateNotificationSettings({
|
await updateNotificationSettings({
|
||||||
body: {
|
body: {
|
||||||
@@ -50,9 +55,17 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
|||||||
discordWebhookUrl: discordWebhookRef.current?.value || undefined,
|
discordWebhookUrl: discordWebhookRef.current?.value || undefined,
|
||||||
teamsWebhookUrl: teamsWebhookRef.current?.value || undefined,
|
teamsWebhookUrl: teamsWebhookRef.current?.value || undefined,
|
||||||
enabledEvents,
|
enabledEvents,
|
||||||
|
reminderTime: newReminderTime,
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).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)
|
// Uložení ostatních nastavení (localStorage)
|
||||||
onSave(
|
onSave(
|
||||||
bankAccountRef.current?.value,
|
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.
|
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>
|
</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.Group className="mb-3">
|
||||||
<Form.Label>ntfy téma (topic)</Form.Label>
|
<Form.Label>ntfy téma (topic)</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
|
|||||||
108
client/src/hooks/usePushReminder.ts
Normal file
108
client/src/hooks/usePushReminder.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,3 +38,9 @@
|
|||||||
|
|
||||||
# Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'.
|
# 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
|
# 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
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/request-promise": "^4.1.48",
|
"@types/request-promise": "^4.1.48",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"babel-jest": "^30.2.0",
|
"babel-jest": "^30.2.0",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"redis": "^5.9.0",
|
"redis": "^5.9.0",
|
||||||
"simple-json-db": "^2.0.0",
|
"simple-json-db": "^2.0.0",
|
||||||
"socket.io": "^4.6.1"
|
"socket.io": "^4.6.1",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { generateToken, getLogin, verify } from "./auth";
|
|||||||
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
|
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
|
||||||
import { getPendingQrs } from "./pizza";
|
import { getPendingQrs } from "./pizza";
|
||||||
import { initWebsocket } from "./websocket";
|
import { initWebsocket } from "./websocket";
|
||||||
|
import { startReminderScheduler } from "./pushReminder";
|
||||||
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
||||||
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
||||||
import votingRoutes from "./routes/votingRoutes";
|
import votingRoutes from "./routes/votingRoutes";
|
||||||
@@ -185,6 +186,7 @@ const HOST = process.env.HOST ?? '0.0.0.0';
|
|||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`Server listening on ${HOST}, port ${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í
|
// 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 { getLogin } from "../auth";
|
||||||
import { parseToken } from "../utils";
|
import { parseToken } from "../utils";
|
||||||
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
|
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
|
||||||
|
import { subscribePush, unsubscribePush, getVapidPublicKey } from "../pushReminder";
|
||||||
import { UpdateNotificationSettingsData } from "../../../types";
|
import { UpdateNotificationSettingsData } from "../../../types";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -24,9 +25,43 @@ router.post("/settings", async (req: Request<{}, any, UpdateNotificationSettings
|
|||||||
discordWebhookUrl: req.body.discordWebhookUrl,
|
discordWebhookUrl: req.body.discordWebhookUrl,
|
||||||
teamsWebhookUrl: req.body.teamsWebhookUrl,
|
teamsWebhookUrl: req.body.teamsWebhookUrl,
|
||||||
enabledEvents: req.body.enabledEvents,
|
enabledEvents: req.body.enabledEvents,
|
||||||
|
reminderTime: req.body.reminderTime,
|
||||||
});
|
});
|
||||||
res.status(200).json(settings);
|
res.status(200).json(settings);
|
||||||
} catch (e: any) { next(e) }
|
} 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;
|
export default router;
|
||||||
|
|||||||
@@ -1732,6 +1732,13 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304"
|
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304"
|
||||||
integrity sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==
|
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@*":
|
"@types/yargs-parser@*":
|
||||||
version "21.0.3"
|
version "21.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15"
|
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"
|
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0"
|
||||||
integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==
|
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:
|
ansi-escapes@^4.3.2:
|
||||||
version "4.3.2"
|
version "4.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
|
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
|
||||||
@@ -1928,6 +1940,16 @@ argparse@^1.0.7:
|
|||||||
dependencies:
|
dependencies:
|
||||||
sprintf-js "~1.0.2"
|
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:
|
asynckit@^0.4.0:
|
||||||
version "0.4.0"
|
version "0.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
|
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"
|
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522"
|
||||||
integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==
|
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:
|
body-parser@^2.2.1:
|
||||||
version "2.2.2"
|
version "2.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.2.tgz#1a32cdb966beaf68de50a9dfbe5b58f83cb8890c"
|
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"
|
resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
|
||||||
integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
|
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"
|
version "4.4.3"
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
|
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
|
||||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
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"
|
statuses "~2.0.2"
|
||||||
toidentifier "~1.0.1"
|
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:
|
human-signals@^2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
|
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"
|
once "^1.3.0"
|
||||||
wrappy "1"
|
wrappy "1"
|
||||||
|
|
||||||
inherits@2, inherits@~2.0.4:
|
inherits@2, inherits@^2.0.1, inherits@~2.0.4:
|
||||||
version "2.0.4"
|
version "2.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||||
@@ -3468,7 +3508,7 @@ jwa@^2.0.1:
|
|||||||
ecdsa-sig-formatter "1.0.11"
|
ecdsa-sig-formatter "1.0.11"
|
||||||
safe-buffer "^5.0.1"
|
safe-buffer "^5.0.1"
|
||||||
|
|
||||||
jws@^4.0.1:
|
jws@^4.0.0, jws@^4.0.1:
|
||||||
version "4.0.1"
|
version "4.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690"
|
resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690"
|
||||||
integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==
|
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"
|
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
|
||||||
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
|
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:
|
minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2:
|
||||||
version "3.1.2"
|
version "3.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
|
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
|
||||||
@@ -3635,6 +3680,11 @@ minimatch@^9.0.4:
|
|||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion "^2.0.1"
|
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:
|
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2:
|
||||||
version "7.1.2"
|
version "7.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707"
|
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"
|
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
||||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
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"
|
version "2.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
||||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||||
@@ -4503,6 +4553,17 @@ walker@^1.0.8:
|
|||||||
dependencies:
|
dependencies:
|
||||||
makeerror "1.0.12"
|
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:
|
whatwg-encoding@^3.1.1:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5"
|
resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5"
|
||||||
|
|||||||
@@ -550,6 +550,9 @@ NotificationSettings:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/UdalostEnum"
|
$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:
|
GotifyServer:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
|
|||||||
Reference in New Issue
Block a user