Compare commits
4 Commits
c2a2659bc4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f13cd4ffa9 | ||
| 086646fd1c | |||
| b8629afef2 | |||
| d366ac39d4 |
@@ -628,56 +628,77 @@ input[type="text"], input[type="email"], input[type="password"] {
|
|||||||
|
|
||||||
tbody tr {
|
tbody tr {
|
||||||
transition: var(--luncher-transition);
|
transition: var(--luncher-transition);
|
||||||
|
border-bottom: 1px solid var(--luncher-border-light);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--luncher-bg-hover);
|
background: var(--luncher-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child td {
|
&:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
|
||||||
|
td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-color: var(--luncher-border-light);
|
border: none;
|
||||||
color: var(--luncher-text);
|
color: var(--luncher-text);
|
||||||
white-space: nowrap;
|
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
.user-row {
|
||||||
padding: 0;
|
display: flex;
|
||||||
margin: 8px 0 0 20px;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
li {
|
.user-info {
|
||||||
color: var(--luncher-text-secondary);
|
display: flex;
|
||||||
font-size: 0.9rem;
|
align-items: center;
|
||||||
margin-bottom: 4px;
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.food-choices {
|
.food-choices {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.food-choice-item {
|
.food-choice-item {
|
||||||
background: var(--luncher-primary-light);
|
display: inline-flex;
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: var(--luncher-radius-sm);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 6px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
border-left: 3px solid var(--luncher-primary);
|
color: var(--luncher-text-secondary);
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
opacity: 0;
|
||||||
|
transition: var(--luncher-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .action-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.food-choice-name {
|
.food-choice-name {
|
||||||
color: var(--luncher-text);
|
color: var(--luncher-text-secondary);
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import Loader from './components/Loader';
|
|||||||
import { getHumanDateTime, isInTheFuture } from './Utils';
|
import { getHumanDateTime, isInTheFuture } from './Utils';
|
||||||
import NoteModal from './components/modals/NoteModal';
|
import NoteModal from './components/modals/NoteModal';
|
||||||
import { useEasterEgg } from './context/eggs';
|
import { useEasterEgg } from './context/eggs';
|
||||||
import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer } from '../../types';
|
import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr } from '../../types';
|
||||||
import { getLunchChoiceName } from './enums';
|
import { getLunchChoiceName } from './enums';
|
||||||
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
||||||
// import './FallingLeaves.scss';
|
// import './FallingLeaves.scss';
|
||||||
@@ -544,55 +544,61 @@ function App() {
|
|||||||
const isBuyer = userPayload?.isBuyer || false;
|
const isBuyer = userPayload?.isBuyer || false;
|
||||||
return <tr key={entry[0]}>
|
return <tr key={entry[0]}>
|
||||||
<td>
|
<td>
|
||||||
{trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'>
|
<div className="user-row">
|
||||||
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
|
<div className="user-info">
|
||||||
</span>}
|
{trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'>
|
||||||
<strong>{login}</strong>
|
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
|
||||||
{userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>}
|
</span>}
|
||||||
{userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>}
|
<strong>{login}</strong>
|
||||||
{login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'>
|
{userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>}
|
||||||
<FontAwesomeIcon onClick={() => {
|
{userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>}
|
||||||
markAsBuyer();
|
</div>
|
||||||
}} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{cursor: 'pointer'}} />
|
<div className="user-actions">
|
||||||
</span>}
|
{login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'>
|
||||||
{login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'>
|
<FontAwesomeIcon onClick={() => {
|
||||||
<FontAwesomeIcon onClick={() => {
|
markAsBuyer();
|
||||||
copyNote(userPayload.note!);
|
}} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{cursor: 'pointer'}} />
|
||||||
}} icon={faBasketShopping} className='buyer-icon' />
|
</span>}
|
||||||
</span>}
|
{login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'>
|
||||||
{login !== auth.login && canChangeChoice && userPayload?.note?.length && <span title='Převzít poznámku'>
|
<FontAwesomeIcon onClick={() => {
|
||||||
<FontAwesomeIcon onClick={() => {
|
copyNote(userPayload.note!);
|
||||||
copyNote(userPayload.note!);
|
}} icon={faBasketShopping} className='buyer-icon' />
|
||||||
}} className='action-icon' icon={faComment} />
|
</span>}
|
||||||
</span>}
|
{login !== auth.login && canChangeChoice && userPayload?.note?.length && <span title='Převzít poznámku'>
|
||||||
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
|
<FontAwesomeIcon onClick={() => {
|
||||||
<FontAwesomeIcon onClick={() => {
|
copyNote(userPayload.note!);
|
||||||
setNoteModalOpen(true);
|
}} className='action-icon' icon={faComment} />
|
||||||
}} className='action-icon' icon={faNoteSticky} />
|
</span>}
|
||||||
</span>}
|
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
|
||||||
{login === auth.login && canChangeChoice && <span title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`}>
|
<FontAwesomeIcon onClick={() => {
|
||||||
<FontAwesomeIcon onClick={() => {
|
setNoteModalOpen(true);
|
||||||
doRemoveChoices(key as LunchChoice);
|
}} className='action-icon' icon={faNoteSticky} />
|
||||||
}} className='action-icon' icon={faTrashCan} />
|
</span>}
|
||||||
</span>}
|
{login === auth.login && canChangeChoice && <span title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`}>
|
||||||
</td>
|
<FontAwesomeIcon onClick={() => {
|
||||||
{userChoices?.length && food ? <td>
|
doRemoveChoices(key as LunchChoice);
|
||||||
<div className="food-choices">
|
}} className='action-icon' icon={faTrashCan} />
|
||||||
{userChoices?.map(foodIndex => {
|
</span>}
|
||||||
const restaurantKey = key as Restaurant;
|
</div>
|
||||||
const foodName = food[restaurantKey]?.food?.[foodIndex].name;
|
|
||||||
return <div key={foodIndex} className="food-choice-item">
|
|
||||||
<span className="food-choice-name">{foodName}</span>
|
|
||||||
{login === auth.login && canChangeChoice &&
|
|
||||||
<span title={`Odstranit ${foodName}`}>
|
|
||||||
<FontAwesomeIcon onClick={() => {
|
|
||||||
doRemoveFoodChoice(restaurantKey, foodIndex);
|
|
||||||
}} className='action-icon' icon={faTrashCan} />
|
|
||||||
</span>}
|
|
||||||
</div>
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</td> : null}
|
{userChoices && userChoices.length > 0 && food && (
|
||||||
|
<div className="food-choices">
|
||||||
|
{userChoices.map(foodIndex => {
|
||||||
|
const restaurantKey = key as Restaurant;
|
||||||
|
const foodName = food[restaurantKey]?.food?.[foodIndex].name;
|
||||||
|
return <div key={foodIndex} className="food-choice-item">
|
||||||
|
<span className="food-choice-name">{foodName}</span>
|
||||||
|
{login === auth.login && canChangeChoice &&
|
||||||
|
<span title={`Odstranit ${foodName}`}>
|
||||||
|
<FontAwesomeIcon onClick={() => {
|
||||||
|
doRemoveFoodChoice(restaurantKey, foodIndex);
|
||||||
|
}} className='action-icon' icon={faTrashCan} />
|
||||||
|
</span>}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
@@ -729,6 +735,32 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
{data.pendingQrs && data.pendingQrs.length > 0 &&
|
||||||
|
<div className='pizza-section fade-in mt-4'>
|
||||||
|
<h3>Nevyřízené platby</h3>
|
||||||
|
<p>Máte neuhrazené QR kódy z předchozích Pizza day.</p>
|
||||||
|
{data.pendingQrs.map(qr => (
|
||||||
|
<div key={qr.date} className='qr-code mb-3'>
|
||||||
|
<p>
|
||||||
|
<strong>{qr.date}</strong> — {qr.creator} ({qr.totalPrice} Kč)
|
||||||
|
</p>
|
||||||
|
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' />
|
||||||
|
<div className='mt-2'>
|
||||||
|
<Button variant="success" onClick={async () => {
|
||||||
|
await dismissQr({ body: { date: qr.date } });
|
||||||
|
// Přenačteme data pro aktualizaci
|
||||||
|
const response = await getData({ query: { dayIndex } });
|
||||||
|
if (response.data) {
|
||||||
|
setData(response.data);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
Zaplatil jsem
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
{/* <FallingLeaves
|
{/* <FallingLeaves
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Modal, Button, Form } from "react-bootstrap"
|
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 { useAuth } from "../../context/auth";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
@@ -10,12 +12,56 @@ type Props = {
|
|||||||
|
|
||||||
/** Modální dialog pro uživatelská nastavení. */
|
/** Modální dialog pro uživatelská nastavení. */
|
||||||
export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Props>) {
|
export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Props>) {
|
||||||
|
const auth = useAuth();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const bankAccountRef = useRef<HTMLInputElement>(null);
|
const bankAccountRef = useRef<HTMLInputElement>(null);
|
||||||
const nameRef = useRef<HTMLInputElement>(null);
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
const hideSoupsRef = useRef<HTMLInputElement>(null);
|
const hideSoupsRef = useRef<HTMLInputElement>(null);
|
||||||
const themeRef = useRef<HTMLSelectElement>(null);
|
const themeRef = useRef<HTMLSelectElement>(null);
|
||||||
|
|
||||||
|
const ntfyTopicRef = useRef<HTMLInputElement>(null);
|
||||||
|
const discordWebhookRef = useRef<HTMLInputElement>(null);
|
||||||
|
const teamsWebhookRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [notifSettings, setNotifSettings] = useState<NotificationSettings>({});
|
||||||
|
const [enabledEvents, setEnabledEvents] = useState<UdalostEnum[]>([]);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Modal show={isOpen} onHide={onClose} size="lg">
|
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
@@ -51,6 +97,75 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
|||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
|
<h4>Notifikace</h4>
|
||||||
|
<p>
|
||||||
|
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>ntfy téma (topic)</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
ref={ntfyTopicRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="moje-tema"
|
||||||
|
defaultValue={notifSettings.ntfyTopic}
|
||||||
|
key={notifSettings.ntfyTopic ?? 'ntfy-empty'}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Téma pro ntfy push notifikace. Nechte prázdné pro vypnutí.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Discord webhook URL</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
ref={discordWebhookRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
defaultValue={notifSettings.discordWebhookUrl}
|
||||||
|
key={notifSettings.discordWebhookUrl ?? 'discord-empty'}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
URL webhooku Discord kanálu. Nechte prázdné pro vypnutí.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>MS Teams webhook URL</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
ref={teamsWebhookRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="https://outlook.office.com/webhook/..."
|
||||||
|
defaultValue={notifSettings.teamsWebhookUrl}
|
||||||
|
key={notifSettings.teamsWebhookUrl ?? 'teams-empty'}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
URL webhooku MS Teams kanálu. Nechte prázdné pro vypnutí.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Události k odběru</Form.Label>
|
||||||
|
{Object.values(UdalostEnum).map(event => (
|
||||||
|
<Form.Check
|
||||||
|
key={event}
|
||||||
|
id={`notif-event-${event}`}
|
||||||
|
type="checkbox"
|
||||||
|
label={event}
|
||||||
|
checked={enabledEvents.includes(event)}
|
||||||
|
onChange={() => toggleEvent(event)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Zvolte události, o kterých chcete být notifikováni. Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
<h4>Bankovní účet</h4>
|
<h4>Bankovní účet</h4>
|
||||||
<p>
|
<p>
|
||||||
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.
|
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<Prop
|
|||||||
<Button variant="secondary" onClick={onClose}>
|
<Button variant="secondary" onClick={onClose}>
|
||||||
Storno
|
Storno
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value, hideSoupsRef.current?.checked, themeRef.current?.value as ThemePreference)}>
|
<Button onClick={handleSave}>
|
||||||
Uložit
|
Uložit
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ import { getData, getDateForWeekIndex, getToday } from "./service";
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getQr } from "./qr";
|
import { getQr } from "./qr";
|
||||||
import { generateToken, verify } from "./auth";
|
import { generateToken, getLogin, verify } from "./auth";
|
||||||
import { getIsWeekend, InsufficientPermissions } from "./utils";
|
import { getIsWeekend, InsufficientPermissions, parseToken } from "./utils";
|
||||||
|
import { getPendingQrs } from "./pizza";
|
||||||
import { initWebsocket } from "./websocket";
|
import { initWebsocket } from "./websocket";
|
||||||
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";
|
||||||
import easterEggRoutes from "./routes/easterEggRoutes";
|
import easterEggRoutes from "./routes/easterEggRoutes";
|
||||||
import statsRoutes from "./routes/statsRoutes";
|
import statsRoutes from "./routes/statsRoutes";
|
||||||
|
import notificationRoutes from "./routes/notificationRoutes";
|
||||||
|
|
||||||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||||
@@ -137,7 +139,18 @@ app.get("/api/data", async (req, res) => {
|
|||||||
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
|
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
|
||||||
date = getDateForWeekIndex(4);
|
date = getDateForWeekIndex(4);
|
||||||
}
|
}
|
||||||
res.status(200).json(await getData(date));
|
const data = await getData(date);
|
||||||
|
// Připojíme nevyřízené QR kódy pro přihlášeného uživatele
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const pendingQrs = await getPendingQrs(login);
|
||||||
|
if (pendingQrs.length > 0) {
|
||||||
|
data.pendingQrs = pendingQrs;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Token nemusí být validní, ignorujeme
|
||||||
|
}
|
||||||
|
res.status(200).json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ostatní routes
|
// Ostatní routes
|
||||||
@@ -146,6 +159,7 @@ app.use("/api/food", foodRoutes);
|
|||||||
app.use("/api/voting", votingRoutes);
|
app.use("/api/voting", votingRoutes);
|
||||||
app.use("/api/easterEggs", easterEggRoutes);
|
app.use("/api/easterEggs", easterEggRoutes);
|
||||||
app.use("/api/stats", statsRoutes);
|
app.use("/api/stats", statsRoutes);
|
||||||
|
app.use("/api/notifications", notificationRoutes);
|
||||||
|
|
||||||
app.use('/stats', express.static('public'));
|
app.use('/stats', express.static('public'));
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
|
|||||||
@@ -3,11 +3,56 @@ import dotenv from 'dotenv';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getClientData, getToday } from "./service";
|
import { getClientData, getToday } from "./service";
|
||||||
import { getUsersByLocation, getHumanTime } from "./utils";
|
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';
|
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
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<NotificationSettings> {
|
||||||
|
return await storage.getData<NotificationSettings>(getNotificationSettingsKey(login)) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uloží nastavení notifikací pro daného uživatele. */
|
||||||
|
export async function saveNotificationSettings(login: string, settings: NotificationSettings): Promise<NotificationSettings> {
|
||||||
|
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) => {
|
export const ntfyCall = async (data: NotifikaceInput) => {
|
||||||
const url = process.env.NTFY_HOST
|
const url = process.env.NTFY_HOST
|
||||||
const username = process.env.NTFY_USERNAME;
|
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*/
|
/** 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) => {
|
export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy = true }: NotifikaceData) => {
|
||||||
const notifications = [];
|
const notifications: Promise<any>[] = [];
|
||||||
|
|
||||||
|
// Globální notifikace (zpětně kompatibilní)
|
||||||
if (ntfy) {
|
if (ntfy) {
|
||||||
const ntfyPromises = await ntfyCall(input);
|
const ntfyPromises = await ntfyCall(input);
|
||||||
if (ntfyPromises) {
|
if (ntfyPromises) {
|
||||||
@@ -100,20 +193,33 @@ export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy
|
|||||||
if (teams) {
|
if (teams) {
|
||||||
const teamsPromises = await teamsCall(input);
|
const teamsPromises = await teamsCall(input);
|
||||||
if (teamsPromises) {
|
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 {
|
try {
|
||||||
const results = await Promise.all(notifications);
|
const results = await Promise.all(notifications);
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in callNotifikace: ", error);
|
console.error("Error in callNotifikace: ", error);
|
||||||
// Handle the error as needed
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { generateQr } from "./qr";
|
|||||||
import getStorage from "./storage";
|
import getStorage from "./storage";
|
||||||
import { downloadPizzy } from "./chefie";
|
import { downloadPizzy } from "./chefie";
|
||||||
import { getClientData, getToday, initIfNeeded } from "./service";
|
import { getClientData, getToday, initIfNeeded } from "./service";
|
||||||
import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum } from "../../types/gen/types.gen";
|
import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen";
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
|
const PENDING_QR_PREFIX = 'pending_qr';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vrátí seznam dostupných pizz pro dnešní den.
|
* Vrátí seznam dostupných pizz pro dnešní den.
|
||||||
@@ -241,6 +242,12 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
|
|||||||
let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
|
let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
|
||||||
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message);
|
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message);
|
||||||
order.hasQr = true;
|
order.hasQr = true;
|
||||||
|
// Uložíme nevyřízený QR kód pro persistentní zobrazení
|
||||||
|
await addPendingQr(order.customer, {
|
||||||
|
date: today,
|
||||||
|
creator: login,
|
||||||
|
totalPrice: order.totalPrice,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,4 +314,41 @@ export async function updatePizzaFee(login: string, targetLogin: string, text?:
|
|||||||
targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price ?? 0);
|
targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price ?? 0);
|
||||||
await storage.setData(today, clientData);
|
await storage.setData(today, clientData);
|
||||||
return clientData;
|
return clientData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí klíč pro uložení nevyřízených QR kódů uživatele.
|
||||||
|
*/
|
||||||
|
function getPendingQrKey(login: string): string {
|
||||||
|
return `${PENDING_QR_PREFIX}_${login}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Přidá nevyřízený QR kód pro uživatele.
|
||||||
|
*/
|
||||||
|
async function addPendingQr(login: string, pendingQr: PendingQr): Promise<void> {
|
||||||
|
const key = getPendingQrKey(login);
|
||||||
|
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
||||||
|
// Nepřidáváme duplicity pro stejný den
|
||||||
|
if (!existing.some(qr => qr.date === pendingQr.date)) {
|
||||||
|
existing.push(pendingQr);
|
||||||
|
await storage.setData(key, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí nevyřízené QR kódy pro uživatele.
|
||||||
|
*/
|
||||||
|
export async function getPendingQrs(login: string): Promise<PendingQr[]> {
|
||||||
|
return await storage.getData<PendingQr[]>(getPendingQrKey(login)) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Označí QR kód pro daný den jako uhrazený (odstraní ho ze seznamu nevyřízených).
|
||||||
|
*/
|
||||||
|
export async function dismissPendingQr(login: string, date: string): Promise<void> {
|
||||||
|
const key = getPendingQrKey(login);
|
||||||
|
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
||||||
|
const filtered = existing.filter(qr => qr.date !== date);
|
||||||
|
await storage.setData(key, filtered);
|
||||||
}
|
}
|
||||||
32
server/src/routes/notificationRoutes.ts
Normal file
32
server/src/routes/notificationRoutes.ts
Normal file
@@ -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;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import express, { Request } from "express";
|
import express, { Request } from "express";
|
||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee } from "../pizza";
|
import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza";
|
||||||
import { parseToken } from "../utils";
|
import { parseToken } from "../utils";
|
||||||
import { getWebsocket } from "../websocket";
|
import { getWebsocket } from "../websocket";
|
||||||
import { AddPizzaData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types";
|
import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -109,4 +109,16 @@ router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeData["
|
|||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Označí QR kód jako uhrazený. */
|
||||||
|
router.post("/dismissQr", async (req: Request<{}, any, DismissQrData["body"]>, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
if (!req.body.date) {
|
||||||
|
return res.status(400).json({ error: "Nebyl předán datum" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await dismissPendingQr(login, req.body.date);
|
||||||
|
res.status(200).json({});
|
||||||
|
} catch (e: any) { next(e) }
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -50,6 +50,12 @@ paths:
|
|||||||
$ref: "./paths/pizzaDay/updatePizzaDayNote.yml"
|
$ref: "./paths/pizzaDay/updatePizzaDayNote.yml"
|
||||||
/pizzaDay/updatePizzaFee:
|
/pizzaDay/updatePizzaFee:
|
||||||
$ref: "./paths/pizzaDay/updatePizzaFee.yml"
|
$ref: "./paths/pizzaDay/updatePizzaFee.yml"
|
||||||
|
/pizzaDay/dismissQr:
|
||||||
|
$ref: "./paths/pizzaDay/dismissQr.yml"
|
||||||
|
|
||||||
|
# Notifikace (/api/notifications)
|
||||||
|
/notifications/settings:
|
||||||
|
$ref: "./paths/notifications/settings.yml"
|
||||||
|
|
||||||
# Easter eggy (/api/easterEggs)
|
# Easter eggy (/api/easterEggs)
|
||||||
/easterEggs:
|
/easterEggs:
|
||||||
|
|||||||
26
types/paths/notifications/settings.yml
Normal file
26
types/paths/notifications/settings.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
get:
|
||||||
|
operationId: getNotificationSettings
|
||||||
|
summary: Vrátí nastavení notifikací pro přihlášeného uživatele.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Nastavení notifikací
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/NotificationSettings"
|
||||||
|
post:
|
||||||
|
operationId: updateNotificationSettings
|
||||||
|
summary: Uloží nastavení notifikací pro přihlášeného uživatele.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/NotificationSettings"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Nastavení notifikací bylo uloženo.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/NotificationSettings"
|
||||||
17
types/paths/pizzaDay/dismissQr.yml
Normal file
17
types/paths/pizzaDay/dismissQr.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
post:
|
||||||
|
operationId: dismissQr
|
||||||
|
summary: Označí QR kód pro daný den jako uhrazený (odstraní ho ze seznamu nevyřízených).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
description: Datum Pizza day, ke kterému se QR kód vztahuje
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- date
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: QR kód byl označen jako uhrazený.
|
||||||
@@ -53,6 +53,11 @@ ClientData:
|
|||||||
description: Datum a čas poslední aktualizace pizz
|
description: Datum a čas poslední aktualizace pizz
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
pendingQrs:
|
||||||
|
description: Nevyřízené QR kódy pro platbu z předchozích pizza day
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/PendingQr"
|
||||||
|
|
||||||
# --- OBĚDY ---
|
# --- OBĚDY ---
|
||||||
UserLunchChoice:
|
UserLunchChoice:
|
||||||
@@ -527,6 +532,24 @@ NotifikaceData:
|
|||||||
type: boolean
|
type: boolean
|
||||||
ntfy:
|
ntfy:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
NotificationSettings:
|
||||||
|
description: Nastavení notifikací pro konkrétního uživatele
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
ntfyTopic:
|
||||||
|
description: Téma pro ntfy push notifikace
|
||||||
|
type: string
|
||||||
|
discordWebhookUrl:
|
||||||
|
description: URL webhooku Discord kanálu
|
||||||
|
type: string
|
||||||
|
teamsWebhookUrl:
|
||||||
|
description: URL webhooku MS Teams kanálu
|
||||||
|
type: string
|
||||||
|
enabledEvents:
|
||||||
|
description: Seznam událostí, o kterých chce být uživatel notifikován
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/UdalostEnum"
|
||||||
GotifyServer:
|
GotifyServer:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -539,3 +562,23 @@ GotifyServer:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
# --- NEVYŘÍZENÉ QR KÓDY ---
|
||||||
|
PendingQr:
|
||||||
|
description: Nevyřízený QR kód pro platbu z předchozího Pizza day
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- date
|
||||||
|
- creator
|
||||||
|
- totalPrice
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
description: Datum Pizza day, ke kterému se QR kód vztahuje
|
||||||
|
type: string
|
||||||
|
creator:
|
||||||
|
description: Jméno zakladatele Pizza day (objednávajícího)
|
||||||
|
type: string
|
||||||
|
totalPrice:
|
||||||
|
description: Celková cena objednávky v Kč
|
||||||
|
type: number
|
||||||
|
|||||||
Reference in New Issue
Block a user