1 Commits

Author SHA1 Message Date
batmanisko aa7e2772a7 feat: večeře (extra meal slot)
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 36s
CI / Generate TypeScript types (pull_request) Successful in 1m18s
CI / Build client (push) Successful in 36s
CI / Build server (pull_request) Successful in 27s
CI / Build client (pull_request) Successful in 41s
CI / Server unit tests (pull_request) Successful in 3m14s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 2s
CI / Playwright E2E tests (pull_request) Successful in 10m26s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Notify (pull_request) Has been skipped
- Nová stránka /vecere pro evidenci extra jídla (večeře/pozdní oběd)
- MealSlot enum (obed/extra), oddělený storage namespace YYYY-MM-DD_extra
- slot parametr na všech food endpointech a GET /api/data
- Push reminder: přechod na 60min cooldown, login v payloadu místo endpointu
- server: slot?: string → slot?: MealSlot, enum konstanty místo literálů
- Jest testy izolace extra/obed storage namespace
- Aktualizace changelogů (saláty, SINGLE_PAYMENT, večeře)
2026-05-06 20:37:39 +02:00
65 changed files with 809 additions and 3564 deletions
-67
View File
@@ -1,67 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "types: openapi-ts",
"type": "shell",
"command": "yarn openapi-ts",
"options": {
"cwd": "${workspaceFolder}/types"
},
"presentation": {
"reveal": "silent",
"panel": "dedicated"
},
"problemMatcher": []
},
{
"label": "server: startReload",
"type": "shell",
"command": "yarn startReload",
"options": {
"cwd": "${workspaceFolder}/server",
"env": {
"NODE_ENV": "development"
}
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"group": "dev"
},
"problemMatcher": []
},
{
"label": "client: vite",
"type": "shell",
"command": "yarn start",
"options": {
"cwd": "${workspaceFolder}/client"
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"group": "dev"
},
"problemMatcher": []
},
{
"label": "dev: server+client",
"dependsOn": [
"server: startReload",
"client: vite"
]
},
{
"label": "dev: all",
"dependsOrder": "sequence",
"dependsOn": [
"types: openapi-ts",
"dev: server+client"
],
"problemMatcher": []
}
]
}
+73
View File
@@ -0,0 +1,73 @@
# TODO
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli
- [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
- [ ] Nemělo by se jít dostat na přihlašovací formulář (měla by tam být nanejvýš hláška nebo přesměrování)
- [ ] Možnost úhrady celé útraty jednou osobou
- Základní myšlenka: jedna osoba uhradí celou útratu (v zájmu rychlosti odbavení), ostatním se automaticky vygeneruje QR kód, kterým následně uhradí svoji část útraty
- Obecně to bude problém např. pokud si někdo objedná něco navíc (pití apod.)
- [ ] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul
- [ ] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu
- [ ] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům
- [ ] Generování a zobrazení QR kódů ostatním strávníkům
- [ ] Umožnit u každého strávníka připočíst vlastní částku (např. za pití)
- [ ] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť)
- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
- [ ] Umožnit zadat URL/tokeny uživatelem
- [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
- [ ] Zavést notifikace typu "Jdeme na oběd"
- [ ] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
- [ ] Vylepšit parsery restaurací
- [ ] Sladovnická
- [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru
- [ ] U Motlíků
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.')
- [ ] Jídelní lístek se stahuje jednou každý den, teoreticky by stačilo jednou týdně (za předpokladu, že se během týdne nemění)
- [ ] TechTower
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)')
- [ ] Jídelní lístek se stahuje v rámci prvního požadavku daný den, ale často se jídelní lístek na stránkách aktualizuje až v průběhu pondělního dopoledne a ten zobrazený je proto neaktuální
- Stránka neposílá hlavičku o času poslední modifikace, takže o to se nelze opřít
- Nevím aktuálně jak řešit jinak, než častějším scrapováním celé stránky
- [X] Někdy jsou v názvech jídel přebytečné mezery kolem čárek ( , )
- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru)
- [ ] Zavést složku /data
- [ ] Mazat z databáze data z minulosti, aktuálně je to k ničemu
- [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh)
- [ ] Implementovat Pizza day
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
- [ ] Pizzy se samy budou při naklikání přidávat do košíku
- [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch()
- [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní
- [X] Umožnit u Pizza day ručně připočíst cenu za přísady
- [X] Prvotní načtení pizz při založení Pizza Day trvá a nic se během toho nezobrazuje (např. loader)
- [X] Po doručení zobrazit komu zaplatit (kdo objednával)
- [x] Zbytečně nescrapovat každý den pizzy z Pizza Chefie, dokud není založen Pizza Day
- [x] Umožnit uzamčení objednávek zakladatelem
- [x] Možnost uložení čísla účtu
- [x] Automatické generování a zobrazení QR kódů
- [x] https://qr-platba.cz/pro-vyvojare/restful-api/
- [x] Zobrazovat celkovou cenu objednávky pod tabulkou objednávek
- [x] Umožnit přidat k objednávce poznámku (např. "bez oliv")
- [x] Negenerovat QR kód pro objednávajícího
- [X] Možnost náhledu na ostatní dny v týdnu (např. pomocí šipek)
- [X] Možnost výběru oběda na následující dny v týdnu
- [X] Umožnit vybrat libovolný čas odchodu
- [X] Validace zadání smysluplného času (ideálně i klientská)
- [x] Umožnit smazání aktuální volby "popelnicí", místo nutnosti vybrat prázdnou položku v selectu
- [x] Přívětivější možnost odhlašování
- [x] Vyřešit responzivní design pro použití na mobilu
- [x] Vyndat URL na Food API do .env
- [x] Neselhat při nedostupnosti nebo chybě z Food API
- [x] Dokončit docker-compose pro kompletní funkčnost
- [x] Vylepšit dokumentaci projektu
- [x] Popsat závislosti, co je nutné provést před vývojem a postup spuštění pro vývoj
- [x] Popsat dostupné env
- [x] Přesunout autentizaci na server (JWT?)
- [x] Zavést .env.template a přidat .env do .gitignore
- [x] Zkrášlit dialog pro vyplnění čísla účtu, vypadá mizerně
- [x] Zbavit se Food API, potřebnou funkcionalitu zahrnout do serveru
- [x] Vyřešit API mezi serverem a klientem, aby nebyl v obou projektech duplicitní kód (viz types.ts a Types.tsx)
- [X] Vybraná jídla strávníků zobrazovat v samostatném sloupci
- [X] Umožnit výběr/zadání preferovaného času odchodu na oběd
- Hodí se např. pokud má někdo schůzky
- [X] Ukládat dostupné pizzy do DB místo souborů
- [X] Ukládat jídla do DB místo souborů
+4 -4
View File
@@ -7,7 +7,7 @@ self.addEventListener('push', (event) => {
body: data.body, body: data.body,
icon: '/favicon.ico', icon: '/favicon.ico',
tag: 'lunch-reminder', tag: 'lunch-reminder',
data: { login: data.login, token: data.token }, data: { login: data.login },
actions: [ actions: [
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' }, { action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
], ],
@@ -19,13 +19,13 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close(); event.notification.close();
if (event.action === 'neobedvam') { if (event.action === 'neobedvam') {
const { login, token } = event.notification.data ?? {}; const login = event.notification.data?.login;
if (login && token) { if (login) {
event.waitUntil( event.waitUntil(
fetch('/api/notifications/push/quickChoice', { fetch('/api/notifications/push/quickChoice', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login, token }), body: JSON.stringify({ login }),
}) })
); );
} }
+11 -41
View File
@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react'; import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import { EVENT_DISCONNECT, EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from './context/socket'; import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket';
import { useAuth } from './context/auth'; import { useAuth } from './context/auth';
import Login from './Login'; import Login from './Login';
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap'; import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
@@ -13,15 +13,13 @@ import './App.scss';
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons'; import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
import { useSettings } from './context/settings'; import { useSettings } from './context/settings';
import Footer from './components/Footer'; import Footer from './components/Footer';
import { faArrowUpRightFromSquare, faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import { useNavigate } from 'react-router-dom';
import Loader from './components/Loader'; import Loader from './components/Loader';
import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils'; import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils';
import NoteModal from './components/modals/NoteModal'; import NoteModal from './components/modals/NoteModal';
import ConfirmModal from './components/modals/ConfirmModal';
import PayForAllModal from './components/modals/PayForAllModal'; import PayForAllModal from './components/modals/PayForAllModal';
import { useEasterEgg } from './context/eggs'; import { useEasterEgg } from './context/eggs';
import { ClientData, Food, MealSlot, PendingQr, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr, generateQr } from '../../types'; import { ClientData, Food, MealSlot, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr, generateQr } 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';
@@ -61,7 +59,6 @@ const EASTER_EGG_DEFAULT_DURATION = 0.75;
function App() { function App() {
const auth = useAuth(); const auth = useAuth();
const settings = useSettings(); const settings = useSettings();
const navigate = useNavigate();
const [easterEgg, _] = useEasterEgg(auth); const [easterEgg, _] = useEasterEgg(auth);
const [isConnected, setIsConnected] = useState<boolean>(false); const [isConnected, setIsConnected] = useState<boolean>(false);
const [data, setData] = useState<ClientData>(); const [data, setData] = useState<ClientData>();
@@ -78,7 +75,6 @@ function App() {
const [dayIndex, setDayIndex] = useState<number>(); const [dayIndex, setDayIndex] = useState<number>();
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false); const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false); const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
const [dismissQrId, setDismissQrId] = useState<string | null>(null);
const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null); const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null);
const [eggImage, setEggImage] = useState<Blob>(); const [eggImage, setEggImage] = useState<Blob>();
const eggRef = useRef<HTMLImageElement>(null); const eggRef = useRef<HTMLImageElement>(null);
@@ -136,25 +132,14 @@ function App() {
setData(newData); setData(newData);
} }
}); });
socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => {
setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev);
});
return () => { return () => {
socket.off(EVENT_CONNECT); socket.off(EVENT_CONNECT);
socket.off(EVENT_DISCONNECT); socket.off(EVENT_DISCONNECT);
socket.off(EVENT_MESSAGE); socket.off(EVENT_MESSAGE);
socket.off(EVENT_PENDING_QR);
} }
}, [socket]); }, [socket]);
// Připojení do osobní socket místnosti po přihlášení
useEffect(() => {
if (auth?.login) {
socket.emit('join', auth.login);
}
}, [auth?.login, socket]);
useEffect(() => { useEffect(() => {
if (!auth?.login || !data?.choices) { if (!auth?.login || !data?.choices) {
return return
@@ -736,9 +721,6 @@ function App() {
markAsBuyer(); markAsBuyer();
}} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{ cursor: 'pointer' }} /> }} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{ cursor: 'pointer' }} />
</span>} </span>}
{login === auth.login && locationKey === LunchChoice.OBJEDNAVAM && <span title='Přejít na stránku objednávek'>
<FontAwesomeIcon onClick={() => navigate('/objednani')} icon={faArrowUpRightFromSquare} className='action-icon' style={{ cursor: 'pointer' }} />
</span>}
{login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'> {login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
copyNote(userPayload.note!); copyNote(userPayload.note!);
@@ -911,12 +893,18 @@ function App() {
{data.pendingQrs.map(qr => ( {data.pendingQrs.map(qr => (
<div key={qr.id} className='qr-code mb-3'> <div key={qr.id} className='qr-code mb-3'>
<p> <p>
<strong>{formatDateString(qr.date)}</strong> {qr.creator} ({qr.totalPrice / 100} ) <strong>{formatDateString(qr.date)}</strong> {qr.creator} ({qr.totalPrice} )
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>} {qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
</p> </p>
<img src={`/api/qr?login=${auth.login}&id=${qr.id}`} alt='QR kód' /> <img src={`/api/qr?login=${auth.login}&id=${qr.id}`} alt='QR kód' />
<div className='mt-2'> <div className='mt-2'>
<Button variant="success" onClick={() => setDismissQrId(qr.id)}> <Button variant="success" onClick={async () => {
await dismissQr({ body: { id: qr.id } });
const response = await getData({ query: { dayIndex } });
if (response.data) {
setData(response.data);
}
}}>
Zaplatil jsem Zaplatil jsem
</Button> </Button>
</div> </div>
@@ -932,24 +920,6 @@ function App() {
/> */} /> */}
<Footer /> <Footer />
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} /> <NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
<ConfirmModal
isOpen={dismissQrId !== null}
title="Potvrzení platby"
message="Opravdu jste zaplatili? QR kód bude odstraněn."
confirmLabel="Zaplatil jsem"
confirmVariant="success"
onClose={() => setDismissQrId(null)}
onConfirm={async () => {
if (!dismissQrId) return;
const id = dismissQrId;
setDismissQrId(null);
await dismissQr({ body: { id } });
const response = await getData({ query: { dayIndex } });
if (response.data) {
setData(response.data);
}
}}
/>
{payForAllLocationKey && data && ( {payForAllLocationKey && data && (
<PayForAllModal <PayForAllModal
isOpen isOpen
+4 -4
View File
@@ -5,20 +5,20 @@ import { SnowOverlay } from 'react-snow-overlay';
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket"; import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage"; import StatsPage from "./pages/StatsPage";
import OrderGroupsPage from "./pages/OrderGroupsPage"; import ExtraPage from "./pages/ExtraPage";
import App from "./App"; import App from "./App";
export const STATS_URL = '/stats'; export const STATS_URL = '/stats';
export const OBJEDNANI_URL = '/objednani'; export const VECERE_URL = '/vecere';
export default function AppRoutes() { export default function AppRoutes() {
return ( return (
<Routes> <Routes>
<Route path={STATS_URL} element={<StatsPage />} /> <Route path={STATS_URL} element={<StatsPage />} />
<Route path={OBJEDNANI_URL} element={ <Route path={VECERE_URL} element={
<ProvideSettings> <ProvideSettings>
<SocketContext.Provider value={socket}> <SocketContext.Provider value={socket}>
<OrderGroupsPage /> <ExtraPage />
<ToastContainer /> <ToastContainer />
</SocketContext.Provider> </SocketContext.Provider>
</ProvideSettings> </ProvideSettings>
+2 -2
View File
@@ -10,7 +10,7 @@ import GenerateQrModal from "./modals/GenerateQrModal";
import GenerateMockDataModal from "./modals/GenerateMockDataModal"; import GenerateMockDataModal from "./modals/GenerateMockDataModal";
import ClearMockDataModal from "./modals/ClearMockDataModal"; import ClearMockDataModal from "./modals/ClearMockDataModal";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { STATS_URL, OBJEDNANI_URL } from "../AppRoutes"; import { STATS_URL, VECERE_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types"; import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons"; import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
@@ -207,7 +207,7 @@ export default function Header({ choices, dayIndex }: Props) {
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item> <NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item> <NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item> <NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(OBJEDNANI_URL)}>Objednání</NavDropdown.Item> <NavDropdown.Item onClick={() => navigate(VECERE_URL)}>Večeře</NavDropdown.Item>
<NavDropdown.Item onClick={() => { <NavDropdown.Item onClick={() => {
getChangelogs().then(response => { getChangelogs().then(response => {
const entries = response.data ?? {}; const entries = response.data ?? {};
+1 -1
View File
@@ -48,7 +48,7 @@ export default function PizzaOrderList({ state, orders, onDelete, creator }: Rea
borderTop: '2px solid var(--luncher-border)' borderTop: '2px solid var(--luncher-border)'
}}> }}>
<td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td> <td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td>
<td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total / 100}`}</td> <td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total}`}</td>
</tr> </tr>
</tbody> </tbody>
</Table> </Table>
+4 -4
View File
@@ -26,7 +26,7 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
<td>{order.customer}</td> <td>{order.customer}</td>
<td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder => <td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
<span key={pizzaOrder.name}> <span key={pizzaOrder.name}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price / 100} Kč)`} {`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED && {auth?.login === order.customer && state === PizzaDayState.CREATED &&
<span title='Odstranit'> <span title='Odstranit'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
@@ -38,10 +38,10 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])} .reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td> </td>
<td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td> <td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price / 100}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td> <td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td> <td>
{order.totalPrice / 100} {auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>} {order.totalPrice} {auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
</td> </td>
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price != null ? String(order.fee.price / 100) : undefined }} /> <PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
</> </>
} }
@@ -1,26 +0,0 @@
import { Modal, Button } from "react-bootstrap";
type Props = {
isOpen: boolean;
title: string;
message: string;
confirmLabel?: string;
confirmVariant?: string;
onConfirm: () => void;
onClose: () => void;
};
export default function ConfirmModal({ isOpen, title, message, confirmLabel = "Potvrdit", confirmVariant = "primary", onConfirm, onClose }: Readonly<Props>) {
return (
<Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>{message}</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>Zrušit</Button>
<Button variant={confirmVariant} onClick={onConfirm}>{confirmLabel}</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,197 +0,0 @@
import { useState, useEffect } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { updateGroupFees, OrderGroup, OrderGroupMember } from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
group: OrderGroup;
onSaved: (data: any) => void;
};
function parseHal(s: string): number {
const n = parseFloat(s.replace(',', '.'));
return isNaN(n) || n < 0 ? 0 : Math.round(n * 100);
}
function parsePercent(s: string): number {
const n = parseFloat(s.replace(',', '.'));
return isNaN(n) || n < 0 ? 0 : Math.round(n);
}
function computeMemberTotal(member: OrderGroupMember, feeShare: number, discountType: string, discountValue: number, memberCount: number): number {
const base = member.amount ?? 0;
const surcharge = member.surchargeAmount ?? 0;
const discount = discountType === 'percent'
? Math.round((base + surcharge) * discountValue / 100)
: Math.round(discountValue / memberCount);
return base + surcharge + feeShare - discount;
}
export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly<Props>) {
const [fees, setFees] = useState('');
const [shipping, setShipping] = useState('');
const [tip, setTip] = useState('');
const [discountType, setDiscountType] = useState<'percent' | 'fixed'>('percent');
const [discountValue, setDiscountValue] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isOpen) return;
setFees(group.fees ? String(group.fees / 100) : '');
setShipping(group.shipping ? String(group.shipping / 100) : '');
setTip(group.tip ? String(group.tip / 100) : '');
setDiscountType((group.discountType as 'percent' | 'fixed') ?? 'percent');
setDiscountValue(group.discountValue
? ((group.discountType as string) === 'fixed' ? String(group.discountValue / 100) : String(group.discountValue))
: '');
setError(null);
}, [isOpen, group]);
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
const memberCount = memberEntries.length;
const feesNum = parseHal(fees);
const shippingNum = parseHal(shipping);
const tipNum = parseHal(tip);
const discountNum = discountType === 'percent' ? parsePercent(discountValue) : parseHal(discountValue);
const totalFees = feesNum + shippingNum + tipNum;
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount) : 0;
const handleSave = async () => {
setError(null);
setLoading(true);
try {
const res = await updateGroupFees({
body: {
id: group.id,
fees: feesNum,
shipping: shippingNum,
tip: tipNum,
discountType: discountNum > 0 ? discountType : undefined,
discountValue: discountNum > 0 ? discountNum : undefined,
}
});
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else {
onSaved(res.data);
onClose();
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Poplatky skupiny {group.name}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>{error}</Alert>
)}
<div className="d-flex gap-3 flex-wrap mb-3">
<Form.Group>
<Form.Label>Poplatky ()</Form.Label>
<Form.Control
type="number" min={0} step={0.01}
value={fees} onChange={e => setFees(e.target.value)}
placeholder="0" style={{ width: 110 }}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<Form.Group>
<Form.Label>Doprava ()</Form.Label>
<Form.Control
type="number" min={0} step={0.01}
value={shipping} onChange={e => setShipping(e.target.value)}
placeholder="0" style={{ width: 110 }}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<Form.Group>
<Form.Label>Spropitné ()</Form.Label>
<Form.Control
type="number" min={0} step={0.01}
value={tip} onChange={e => setTip(e.target.value)}
placeholder="0" style={{ width: 110 }}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
</div>
<div className="d-flex gap-3 align-items-end flex-wrap mb-3">
<Form.Group>
<Form.Label>Sleva</Form.Label>
<div className="d-flex gap-2 align-items-center">
<Form.Select
value={discountType}
onChange={e => setDiscountType(e.target.value as 'percent' | 'fixed')}
style={{ width: 160 }}
>
<option value="percent">Procentuální (%)</option>
<option value="fixed">Pevná částka ()</option>
</Form.Select>
<Form.Control
type="number" min={0} step={discountType === 'percent' ? 1 : 0.01}
value={discountValue} onChange={e => setDiscountValue(e.target.value)}
placeholder="0" style={{ width: 100 }}
onKeyDown={e => e.stopPropagation()}
/>
<span className="text-muted">{discountType === 'percent' ? '%' : 'Kč'}</span>
</div>
</Form.Group>
</div>
<hr />
<h6>Náhled celkových částek ({memberCount} členů, {feeShare > 0 ? `poplatek ${feeShare / 100} Kč/os.` : 'bez poplatku'})</h6>
<Table size="sm" bordered>
<thead>
<tr>
<th>Člen</th>
<th className="text-end">Základ</th>
<th className="text-end">Příplatek</th>
<th className="text-end">Poplatek</th>
<th className="text-end">Sleva</th>
<th className="text-end fw-bold">Celkem</th>
</tr>
</thead>
<tbody>
{memberEntries.map(([login, member]) => {
const base = member.amount ?? 0;
const surcharge = member.surchargeAmount ?? 0;
const discount = discountNum > 0
? (discountType === 'percent'
? Math.round((base + surcharge) * discountNum / 100)
: Math.round(discountNum / memberCount))
: 0;
const total = computeMemberTotal(member, feeShare, discountType, discountNum, memberCount);
return (
<tr key={login}>
<td><strong>{login}</strong></td>
<td className="text-end">{base > 0 ? `${base / 100}` : '—'}</td>
<td className="text-end">{surcharge > 0 ? `${surcharge / 100}` : '—'}</td>
<td className="text-end">{feeShare > 0 ? `${feeShare / 100}` : '—'}</td>
<td className="text-end text-danger">{discount > 0 ? `-${discount / 100}` : '—'}</td>
<td className="text-end fw-bold">{total > 0 ? `${total / 100}` : '—'}</td>
</tr>
);
})}
</tbody>
</Table>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
<Button variant="primary" onClick={handleSave} disabled={loading}>
{loading ? 'Ukládám...' : 'Uložit'}
</Button>
</Modal.Footer>
</Modal>
);
}
+25 -22
View File
@@ -33,7 +33,9 @@ function parseAmount(s: string): number | null {
if (!s || s.trim().length === 0) return null; if (!s || s.trim().length === 0) return null;
const n = parseFloat(s); const n = parseFloat(s);
if (isNaN(n) || n < 0) return null; if (isNaN(n) || n < 0) return null;
return Math.round(n * 100); const parts = s.split('.');
if (parts.length === 2 && parts[1].length > 2) return null;
return Math.round(n * 100) / 100;
} }
export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) { export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
@@ -53,11 +55,11 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
let baseAmountParseFailed = false; let baseAmountParseFailed = false;
if (menu) { if (menu) {
for (const idx of selectedFoods) { for (const idx of selectedFoods) {
const priceKc = parsePriceCzk(menu.food?.[idx]?.price); const price = parsePriceCzk(menu.food?.[idx]?.price);
if (priceKc === null) { if (price === null) {
baseAmountParseFailed = true; baseAmountParseFailed = true;
} else { } else {
baseAmount += Math.round(priceKc * 100); baseAmount += price;
} }
} }
} }
@@ -82,19 +84,13 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
if (includedDiners.length === 0) return 0; if (includedDiners.length === 0) return 0;
const tip = parseAmount(tipTotal); const tip = parseAmount(tipTotal);
if (tip === null || tip === 0) return 0; if (tip === null || tip === 0) return 0;
const totalPeople = includedDiners.length + 1; return Math.round((tip / includedDiners.length) * 100) / 100;
return Math.round(tip / totalPeople);
})();
const payerTipShare = (() => {
const tip = parseAmount(tipTotal);
if (!tip) return 0;
return tip - tipPerPerson * includedDiners.length;
})(); })();
const getTotal = (d: DinerEntry): number => { const getTotal = (d: DinerEntry): number => {
const surcharge = parseAmount(d.surchargeAmount) ?? 0; const surcharge = parseAmount(d.surchargeAmount) ?? 0;
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson; const tip = d.included && d.login !== payerLogin ? tipPerPerson : 0;
return d.baseAmount + surcharge + tip; return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
}; };
const handleInclude = useCallback((login: string, checked: boolean) => { const handleInclude = useCallback((login: string, checked: boolean) => {
@@ -120,6 +116,11 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
setError(`Celková částka pro ${d.login} musí být kladná`); setError(`Celková částka pro ${d.login} musí být kladná`);
return; return;
} }
const amountStr = total.toString();
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
setError(`Částka pro ${d.login} má více než 2 desetinná místa`);
return;
}
const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', '); const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
const purposeBase = `Oběd ${locationName}${foods ? `${foods}` : ''}`; const purposeBase = `Oběd ${locationName}${foods ? `${foods}` : ''}`;
recipients.push({ recipients.push({
@@ -166,7 +167,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
</Alert> </Alert>
) : ( ) : (
<> <>
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p> <p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a dýško, poté vygenerujte QR kódy pro ostatní.</p>
{!hasMenu && ( {!hasMenu && (
<Alert variant="info"> <Alert variant="info">
@@ -193,7 +194,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
<th>Strávník</th> <th>Strávník</th>
<th>Jídla</th> <th>Jídla</th>
<th style={{ width: 220 }}>Příplatek</th> <th style={{ width: 220 }}>Příplatek</th>
<th style={{ width: 90 }}>Poplatek</th> <th style={{ width: 90 }}>Dýško</th>
<th style={{ width: 90 }}>Celkem</th> <th style={{ width: 90 }}>Celkem</th>
</tr> </tr>
</thead> </thead>
@@ -219,18 +220,19 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
<td> <td>
<small> <small>
{foodNames || <span className="text-muted">—</span>} {foodNames || <span className="text-muted">—</span>}
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>} {hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount} Kč)</span>}
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>} {d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
</small> </small>
</td> </td>
<td> <td>
{!isPayer && (
<div className="d-flex gap-1"> <div className="d-flex gap-1">
<Form.Control <Form.Control
type="text" type="text"
placeholder="popis" placeholder="popis"
value={d.surchargeText} value={d.surchargeText}
onChange={e => handleSurchargeText(d.login, e.target.value)} onChange={e => handleSurchargeText(d.login, e.target.value)}
disabled={!isPayer && !d.included} disabled={!d.included}
size="sm" size="sm"
onKeyDown={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}
/> />
@@ -239,18 +241,19 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
placeholder="" placeholder=""
value={d.surchargeAmount} value={d.surchargeAmount}
onChange={e => handleSurchargeAmount(d.login, e.target.value)} onChange={e => handleSurchargeAmount(d.login, e.target.value)}
disabled={!isPayer && !d.included} disabled={!d.included}
size="sm" size="sm"
style={{ width: 70 }} style={{ width: 70 }}
onKeyDown={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}
/> />
</div> </div>
)}
</td> </td>
<td className="text-end"> <td className="text-end">
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s / 100} Kč` : '—'; })()} {!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
</td> </td>
<td className="text-end fw-bold"> <td className="text-end fw-bold">
{`${total / 100} Kč`} {!isPayer ? `${total} Kč` : '—'}
</td> </td>
</tr> </tr>
); );
@@ -259,7 +262,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
</Table> </Table>
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-2">
<label className="mb-0 text-nowrap">Poplatky celkem (Kč):</label> <label className="mb-0 text-nowrap">Dýško celkem (Kč):</label>
<Form.Control <Form.Control
type="text" type="text"
placeholder="0" placeholder="0"
@@ -271,7 +274,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
/> />
<small className="text-muted"> <small className="text-muted">
{includedDiners.length > 0 && tipPerPerson > 0 {includedDiners.length > 0 && tipPerPerson > 0
? `(${tipPerPerson / 100} Kč / osoba)` ? `(${tipPerPerson} Kč / osoba)`
: ''} : ''}
</small> </small>
</div> </div>
@@ -1,222 +0,0 @@
import { useState, useEffect } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
group: OrderGroup;
payerLogin: string;
bankAccount: string;
bankAccountHolder: string;
groupId?: string;
};
type DinerEntry = {
login: string;
member: OrderGroupMember;
included: boolean;
};
export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) {
const [diners, setDiners] = useState<DinerEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!isOpen) return;
const entries: DinerEntry[] = (Object.entries(group.members) as [string, OrderGroupMember][]).map(([login, member]) => ({
login,
member,
included: login !== payerLogin,
}));
setDiners(entries);
setError(null);
setSuccess(false);
}, [isOpen, group, payerLogin]);
const memberCount = diners.length;
const fees = group.fees ?? 0;
const shipping = group.shipping ?? 0;
const tip = group.tip ?? 0;
const totalFees = fees + shipping + tip;
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount) : 0;
const getMemberTotal = (entry: DinerEntry): number => {
const base = entry.member.amount ?? 0;
const surcharge = entry.member.surchargeAmount ?? 0;
const discountType = group.discountType;
const discountValue = group.discountValue ?? 0;
const discount = discountValue > 0
? (discountType === 'percent'
? Math.round((base + surcharge) * discountValue / 100)
: Math.round(discountValue / memberCount))
: 0;
return base + surcharge + feeShare - discount;
};
const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin);
const handleInclude = (login: string, checked: boolean) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
};
const handleGenerate = async () => {
setError(null);
const recipients: QrRecipient[] = [];
for (const d of diners) {
if (!d.included || d.login === payerLogin) continue;
const total = getMemberTotal(d);
if (total <= 0) {
setError(`Celková částka pro ${d.login} musí být kladná`);
return;
}
const note = d.member.note?.trim();
recipients.push({
login: d.login,
purpose: note ? note.replace(/[^\x00-\xff*]/g, '').replace(/\*/g, '').substring(0, 60) : `Objednávka ${group.name}`.substring(0, 60),
amount: total,
});
}
if (recipients.length === 0) {
setError("Nebyl vybrán žádný příjemce");
return;
}
setLoading(true);
try {
const response = await generateQr({
body: { recipients, bankAccount, bankAccountHolder, ...(groupId ? { groupId } : {}) },
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
} else {
setSuccess(true);
onSuccess?.();
setTimeout(() => onClose(), 2000);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování QR kódů');
} finally {
setLoading(false);
}
};
const hasFees = totalFees > 0;
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Generovat QR {group.name}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci Nevyřízené platby".
</Alert>
) : (
<>
<p>Zaplatili jste za skupinu. Vyberte, komu vygenerovat QR kód k úhradě.</p>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
{hasFees && (
<div className="d-flex gap-3 mb-2 text-muted" style={{ fontSize: '0.9em' }}>
{fees > 0 && <span>Poplatky: <strong>{fees / 100} Kč</strong></span>}
{shipping > 0 && <span>Doprava: <strong>{shipping / 100} Kč</strong></span>}
{tip > 0 && <span>Spropitné: <strong>{tip / 100} Kč</strong></span>}
<span>→ {feeShare / 100} Kč/os.</span>
</div>
)}
{group.discountValue != null && group.discountValue > 0 && (
<div className="mb-2 text-success" style={{ fontSize: '0.9em' }}>
Sleva: {group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}
</div>
)}
<Table striped bordered hover responsive size="sm">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Člen</th>
<th style={{ width: 90 }} className="text-end">Základ</th>
<th style={{ width: 90 }} className="text-end">Příplatek</th>
{hasFees && <th style={{ width: 90 }} className="text-end">Poplatek</th>}
<th style={{ width: 90 }} className="text-end fw-bold">Celkem</th>
</tr>
</thead>
<tbody>
{diners.map(d => {
const isPayer = d.login === payerLogin;
const total = getMemberTotal(d);
const surcharge = d.member.surchargeAmount ?? 0;
return (
<tr key={d.login} className={!d.included && !isPayer ? 'text-muted' : ''}>
<td className="text-center">
{isPayer ? (
<small className="text-muted">plátce</small>
) : (
<Form.Check
type="checkbox"
checked={d.included}
onChange={e => handleInclude(d.login, e.target.checked)}
/>
)}
</td>
<td>
<strong>{d.login}</strong>
{d.member.surchargeText && (
<small className="text-muted ms-1">({d.member.surchargeText})</small>
)}
</td>
<td className="text-end">
{(d.member.amount ?? 0) > 0 ? `${d.member.amount! / 100} Kč` : <span className="text-muted">—</span>}
</td>
<td className="text-end">
{surcharge > 0 ? `${surcharge / 100} Kč` : <span className="text-muted">—</span>}
</td>
{hasFees && (
<td className="text-end">
{feeShare > 0 ? `${feeShare / 100} Kč` : '—'}
</td>
)}
<td className="text-end fw-bold">
{total > 0 ? `${total / 100} Kč` : <span className="text-muted">—</span>}
</td>
</tr>
);
})}
</tbody>
</Table>
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<span className="me-auto text-muted">Příjemci: {includedNonPayers.length}</span>
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={loading || includedNonPayers.length === 0}
>
{loading ? 'Generuji...' : 'Vygenerovat QR'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
)}
</Modal.Footer>
</Modal>
);
}
@@ -15,12 +15,12 @@ export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose,
const priceRef = useRef<HTMLInputElement>(null); const priceRef = useRef<HTMLInputElement>(null);
const doSubmit = () => { const doSubmit = () => {
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100)); onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
} }
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100)); onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
} }
} }
@@ -1,119 +0,0 @@
import { useState } from "react";
import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { addStore, deleteStore } from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
stores: string[];
onStoresChanged: (stores: string[]) => void;
};
export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) {
const [newName, setNewName] = useState('');
const [heslo, setHeslo] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleAdd = async () => {
if (!newName.trim()) return;
setError(null);
setLoading(true);
try {
const res = await addStore({ body: { name: newName.trim(), heslo } });
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else if (res.data) {
onStoresChanged(res.data as string[]);
setNewName('');
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
const handleRemove = async (name: string) => {
setError(null);
setLoading(true);
try {
const res = await deleteStore({ body: { name, heslo } });
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else if (res.data) {
onStoresChanged(res.data as string[]);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
return (
<Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title><h2>Správa obchodů</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<Form.Group className="mb-3">
<Form.Label>Admin heslo</Form.Label>
<Form.Control
type="password"
placeholder="Heslo"
value={heslo}
onChange={e => setHeslo(e.target.value)}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<hr />
<h6>Přidat obchod</h6>
<div className="d-flex gap-2 mb-3">
<Form.Control
type="text"
placeholder="Název obchodu"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
/>
<Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}>
Přidat
</Button>
</div>
<h6>Aktuální seznam</h6>
{stores.length === 0 ? (
<p className="text-muted">Žádné obchody v seznamu</p>
) : (
<ListGroup>
{stores.map(s => (
<ListGroup.Item key={s} className="d-flex justify-content-between align-items-center">
{s}
<FontAwesomeIcon
icon={faTrashCan}
className="action-icon"
title="Odebrat"
onClick={() => handleRemove(s)}
style={{ cursor: 'pointer' }}
/>
</ListGroup.Item>
))}
</ListGroup>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
</Modal.Footer>
</Modal>
);
}
-1
View File
@@ -18,4 +18,3 @@ export const SocketContext = React.createContext();
export const EVENT_CONNECT = 'connect'; export const EVENT_CONNECT = 'connect';
export const EVENT_DISCONNECT = 'disconnect'; export const EVENT_DISCONNECT = 'disconnect';
export const EVENT_MESSAGE = 'message'; export const EVENT_MESSAGE = 'message';
export const EVENT_PENDING_QR = 'pendingQr';
+209
View File
@@ -0,0 +1,209 @@
import { useContext, useEffect, useRef, useState } from 'react';
import { Button, Table } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleCheck, faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { faBasketShopping, faSearch } from '@fortawesome/free-solid-svg-icons';
import {
ClientData, LunchChoice, MealSlot, UserLunchChoice,
addChoice, removeChoices, updateNote, setBuyer, getData,
} from '../../../types';
import { EVENT_MESSAGE, SocketContext } from '../context/socket';
import { useAuth } from '../context/auth';
import Login from '../Login';
import Header from '../components/Header';
import Footer from '../components/Footer';
import Loader from '../components/Loader';
import NoteModal from '../components/modals/NoteModal';
const SLOT = MealSlot.EXTRA;
export default function ExtraPage() {
const auth = useAuth();
const socket = useContext(SocketContext);
const [data, setData] = useState<ClientData | undefined>();
const [failure, setFailure] = useState(false);
const [noteModalOpen, setNoteModalOpen] = useState(false);
const fetchData = async () => {
try {
const r = await getData({ query: { slot: SLOT } });
if (r.data) setData(r.data);
} catch {
setFailure(true);
}
};
useEffect(() => {
if (!auth?.login) return;
fetchData();
}, [auth?.login]);
useEffect(() => {
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
if (newData.slot === SLOT) setData(newData);
});
return () => { socket.off(EVENT_MESSAGE); };
}, [socket]);
const myChoice = data?.choices?.OBJEDNAVAM?.[auth?.login ?? ''];
const isIn = !!myChoice;
const isBuyer = myChoice?.isBuyer ?? false;
const joinOrder = async () => {
if (!auth?.login) return;
await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } });
await fetchData();
};
const joinAndBuy = async () => {
if (!auth?.login) return;
await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } });
await setBuyer({ body: { slot: SLOT } });
await fetchData();
};
const leaveOrder = async () => {
if (!auth?.login) return;
await removeChoices({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } });
await fetchData();
};
const toggleBuyer = async () => {
if (!auth?.login) return;
await setBuyer({ body: { slot: SLOT } });
await fetchData();
};
const saveNote = async (note?: string) => {
if (!auth?.login) return;
await updateNote({ body: { note, slot: SLOT } });
setNoteModalOpen(false);
await fetchData();
};
if (!auth?.login) return <Login />;
if (failure) return (
<Loader icon={faSearch} description="Nepodařilo se načíst data" animation="fa-beat" />
);
if (!data) return (
<Loader icon={faSearch} description="Načítám..." animation="fa-bounce" />
);
const orderEntries = Object.entries(data.choices?.OBJEDNAVAM ?? {}) as [string, UserLunchChoice][];
return (
<div className="app-container">
<Header choices={data.choices} />
<div className="wrapper">
<h1 className="title">Večeře</h1>
<p style={{ color: 'var(--luncher-text-muted)' }}>Extra jídlo pro ty, kdo zůstávají déle</p>
<div className="content-wrapper">
<div className="content">
<div className="choice-section fade-in">
{!isIn ? (
<div className="d-flex gap-2 flex-wrap">
<Button variant="primary" onClick={joinOrder}>
Přidám se
</Button>
<Button variant="outline-primary" onClick={joinAndBuy}>
<FontAwesomeIcon icon={faBasketShopping} className="me-2" />
Budu objednávat
</Button>
</div>
) : (
<div className="d-flex gap-2 flex-wrap align-items-center">
<span style={{ color: 'var(--luncher-text-secondary)' }}>
{isBuyer ? 'Objednáváš.' : 'Jsi přidán/a k objednávce.'}
</span>
<Button variant="outline-secondary" size="sm" onClick={toggleBuyer}>
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
{isBuyer ? 'Odebrat roli objednávajícího' : 'Označit se jako objednávající'}
</Button>
<Button variant="outline-secondary" size="sm" onClick={() => setNoteModalOpen(true)}>
<FontAwesomeIcon icon={faNoteSticky} className="me-1" />
Poznámka
</Button>
<Button variant="outline-danger" size="sm" onClick={leaveOrder}>
<FontAwesomeIcon icon={faTrashCan} className="me-1" />
Odhlásit se
</Button>
</div>
)}
</div>
{orderEntries.length > 0 && (
<Table className="choices-table mt-4 fade-in">
<tbody>
<tr>
<td>Budu objednávat / Přidám se</td>
<td className="p-0">
<Table className="nested-table">
<tbody>
{orderEntries.map(([login, payload]) => (
<tr key={login}>
<td>
<div className="user-row">
<div className="user-info">
{payload.trusted && (
<span className="trusted-icon" title="Ověřený uživatel">
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: 'help' }} />
</span>
)}
<strong>{login}</strong>
{payload.note && (
<span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>
({payload.note})
</span>
)}
</div>
<div className="user-actions">
{payload.isBuyer && (
<span title="Objednávající">
<FontAwesomeIcon icon={faBasketShopping} className="buyer-icon" />
</span>
)}
{login === auth.login && (
<>
<span title="Upravit poznámku">
<FontAwesomeIcon
onClick={() => setNoteModalOpen(true)}
className="action-icon"
icon={faNoteSticky}
/>
</span>
<span title="Odhlásit se z objednávky">
<FontAwesomeIcon
onClick={leaveOrder}
className="action-icon"
icon={faTrashCan}
/>
</span>
</>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</Table>
</td>
</tr>
</tbody>
</Table>
)}
</div>
</div>
</div>
<Footer />
<NoteModal
isOpen={noteModalOpen}
onClose={() => setNoteModalOpen(false)}
onSave={saveNote}
/>
</div>
);
}
-606
View File
@@ -1,606 +0,0 @@
import { useContext, useEffect, useState } from 'react';
import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { faBasketShopping, faCircleCheck, faGear, faLock, faLockOpen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons';
import {
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember,
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes,
} from '../../../types';
import { EVENT_MESSAGE, SocketContext } from '../context/socket';
import { useAuth } from '../context/auth';
import { useSettings } from '../context/settings';
import Login from '../Login';
import Header from '../components/Header';
import Footer from '../components/Footer';
import Loader from '../components/Loader';
import StoreAdminModal from '../components/modals/StoreAdminModal';
import PayForGroupModal from '../components/modals/PayForGroupModal';
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
const SLOT = MealSlot.EXTRA;
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
function stateBadge(state: GroupState) {
const map: Record<GroupState, { bg: string; label: string }> = {
[GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' },
[GroupState.LOCKED]: { bg: 'warning', label: 'Uzamčeno' },
[GroupState.ORDERED]: { bg: 'secondary', label: 'Objednáno' },
};
const { bg, label } = map[state] ?? { bg: 'light', label: state };
return <Badge bg={bg}>{label}</Badge>;
}
export default function OrderGroupsPage() {
const auth = useAuth();
const settings = useSettings();
const socket = useContext(SocketContext);
const [data, setData] = useState<ClientData | undefined>();
const [failure, setFailure] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [creating, setCreating] = useState(false);
const [adminModalOpen, setAdminModalOpen] = useState(false);
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({});
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string }>>({});
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
const [pageError, setPageError] = useState<string | null>(null);
const fetchData = async () => {
try {
const r = await getData({ query: { slot: SLOT } });
if (r.data) setData(r.data);
} catch {
setFailure(true);
}
};
useEffect(() => {
if (!auth?.login) return;
fetchData();
}, [auth?.login]);
useEffect(() => {
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
if (newData.slot === SLOT) setData(prev => ({
...newData,
stores: newData.stores ?? prev?.stores,
}));
});
return () => { socket.off(EVENT_MESSAGE); };
}, [socket]);
const refresh = async (fn: () => Promise<any>): Promise<boolean> => {
setPageError(null);
const result = await fn();
if (result?.error) {
setPageError((result.error as any).error || 'Nastala chyba');
await fetchData();
return false;
}
if (result?.data) {
setData(result.data);
socket.emit?.('message', result.data as ClientData);
}
await fetchData();
return true;
};
const handleCreate = async () => {
if (!newGroupName || !auth?.login) return;
setCreating(true);
const ok = await refresh(() => createGroup({ body: { name: newGroupName } }));
if (ok) setNewGroupName('');
setCreating(false);
};
const handleJoin = (groupId: string) =>
refresh(() => addGroupMember({ body: { id: groupId } }));
const handleToggleLock = (group: OrderGroup) => {
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
return refresh(() => setGroupState({ body: { id: group.id, state: next } }));
};
const handleConfirmOrdered = async (group: OrderGroup) => {
setConfirmOrderGroup(null);
await refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } }));
};
const handleRevertOrdered = (group: OrderGroup) =>
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.LOCKED } }));
const handleDelete = (groupId: string) =>
refresh(() => deleteGroup({ body: { id: groupId } }));
const handleSaveAmount = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`;
const raw = editAmounts[key];
const n = parseFloat(raw ?? '');
if (!raw || isNaN(n) || n < 0) {
setPageError('Zadejte platnou kladnou částku');
return;
}
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, amount: Math.round(n * 100) } }));
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
};
const handleSaveNote = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`;
const note = editNotes[key] ?? '';
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, note } }));
if (ok) setEditNotes(prev => { const next = { ...prev }; delete next[key]; return next; });
};
const handleSaveSurcharge = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`;
const surchargeText = editSurcharges[key]?.text ?? '';
const rawAmount = editSurcharges[key]?.amount ?? '';
const surchargeAmount = rawAmount === '' ? 0 : parseFloat(rawAmount.replace(',', '.'));
if (rawAmount !== '' && (isNaN(surchargeAmount) || surchargeAmount < 0)) {
setPageError('Zadejte platnou výši příplatku');
return;
}
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, surchargeText, surchargeAmount: rawAmount === '' ? 0 : Math.round(surchargeAmount * 100) } }));
if (ok) setEditSurcharges(prev => { const next = { ...prev }; delete next[key]; return next; });
};
const handleSaveTimes = async (group: OrderGroup) => {
const times = editTimes[group.id];
if (!times) return;
const { orderedAt, deliveryAt } = times;
if (orderedAt && !TIME_REGEX.test(orderedAt)) {
setPageError('Čas objednání musí být ve formátu HH:MM');
return;
}
if (deliveryAt && !TIME_REGEX.test(deliveryAt)) {
setPageError('Čas doručení musí být ve formátu HH:MM');
return;
}
const ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
};
const canEditMember = (group: OrderGroup, targetLogin: string) => {
if (group.state === GroupState.ORDERED) return false;
if (auth?.login === group.creatorLogin) return true;
if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true;
return false;
};
const canManageMembers = (group: OrderGroup) => {
if (group.state === GroupState.ORDERED) return false;
if (auth?.login === group.creatorLogin) return true;
return group.state === GroupState.OPEN;
};
if (!auth?.login) return <Login />;
if (failure) return (
<Loader icon={faSearch} description="Nepodařilo se načíst data" animation="fa-beat" />
);
if (!data) return (
<Loader icon={faSearch} description="Načítám..." animation="fa-bounce" />
);
const stores = data.stores ?? [];
const groups = data.groups ?? [];
return (
<div className="app-container">
<Header choices={data.choices} />
<div className="wrapper">
<div className="d-flex align-items-center justify-content-between mb-1">
<h1 className="title mb-0">Objednání</h1>
<Button variant="outline-secondary" size="sm" onClick={() => setAdminModalOpen(true)} title="Správa obchodů">
<FontAwesomeIcon icon={faGear} />
</Button>
</div>
<p style={{ color: 'var(--luncher-text-muted)' }}>Skupinové objednávky z obchodů a restaurací</p>
{pageError && (
<Alert variant="danger" dismissible onClose={() => setPageError(null)} className="mt-2">
{pageError}
</Alert>
)}
<div className="content-wrapper">
<div className="content" style={{ maxWidth: 1200 }}>
{/* Vytvoření nové skupiny */}
<div className="choice-section fade-in mb-4">
<h5>Vytvořit skupinu</h5>
{stores.length === 0 ? (
<p className="text-muted">
Nejsou přidány žádné obchody.{' '}
<Button variant="link" size="sm" className="p-0" onClick={() => setAdminModalOpen(true)}>
Přidat obchod
</Button>
</p>
) : (
<div className="d-flex gap-2 align-items-end flex-wrap">
<Form.Select
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
style={{ maxWidth: 260 }}
>
<option value=""> vyberte obchod </option>
{stores.map(s => <option key={s} value={s}>{s}</option>)}
</Form.Select>
<Button variant="primary" onClick={handleCreate} disabled={creating || !newGroupName}>
Vytvořit skupinu
</Button>
</div>
)}
</div>
{/* Seznam skupin */}
{groups.length === 0 && (
<p className="text-muted fade-in">Zatím žádné skupiny pro dnešní den.</p>
)}
{groups.map(group => {
const login = auth!.login ?? '';
const isCreator = login === group.creatorLogin;
const isMember = login in group.members;
const isOrdered = group.state === GroupState.ORDERED;
const isLocked = group.state === GroupState.LOCKED;
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
const memberCount = memberEntries.length;
const editingTimes = group.id in editTimes;
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount) : 0;
const getMemberTotal = (m: OrderGroupMember) => {
const base = m.amount ?? 0;
const surcharge = m.surchargeAmount ?? 0;
const dv = group.discountValue ?? 0;
const discount = dv > 0
? (group.discountType === 'percent'
? Math.round((base + surcharge) * dv / 100)
: Math.round(dv / memberCount))
: 0;
return base + surcharge + feeShare - discount;
};
return (
<Card key={group.id} className="mb-3 fade-in">
<Card.Header className="d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center gap-2">
<strong>{group.name}</strong>
{stateBadge(group.state)}
<small className="text-muted">zakladatel: {group.creatorLogin}</small>
</div>
<div className="d-flex gap-2">
{isCreator && !isOrdered && (
<>
<Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu">
Poplatky
</Button>
<Button variant="outline-secondary" size="sm" onClick={() => handleToggleLock(group)} title={isLocked ? 'Odemknout' : 'Uzamknout'}>
<FontAwesomeIcon icon={isLocked ? faLockOpen : faLock} />
</Button>
{isLocked && (
<Button variant="outline-primary" size="sm" onClick={() => setConfirmOrderGroup(group)}>
Objednáno
</Button>
)}
<Button variant="outline-danger" size="sm" onClick={() => handleDelete(group.id)} title="Smazat skupinu">
<FontAwesomeIcon icon={faTrashCan} />
</Button>
</>
)}
{isCreator && isOrdered && (
<>
{settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
Generovat QR
</Button>
)}
<Button variant="outline-warning" size="sm" onClick={() => handleRevertOrdered(group)} title="Vrátit na Uzamčeno (smaže QR kódy)">
<FontAwesomeIcon icon={faLockOpen} />
</Button>
</>
)}
{!isMember && !isOrdered && !isLocked && (
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
Přidat se
</Button>
)}
</div>
</Card.Header>
<Card.Body className="p-0">
<Table className="mb-0" size="sm">
<thead>
<tr>
<th>Člen</th>
<th style={{ width: 180 }}>Částka (bez slev)</th>
<th style={{ width: 220 }}>Příplatek</th>
<th>Poznámka</th>
<th style={{ width: 160 }}>Celkem (s poplatky)</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{memberEntries.map(([memberLogin, member]) => {
const key = `${group.id}:${memberLogin}`;
const editingAmount = key in editAmounts;
const editingNote = key in editNotes;
const editingSurcharge = key in editSurcharges;
const canEdit = canEditMember(group, memberLogin);
const memberTotal = getMemberTotal(member);
return (
<tr key={memberLogin}>
<td>
<span className="user-info">
<strong>{memberLogin}</strong>
{memberLogin === group.creatorLogin && (
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
<span className="ms-1"><FontAwesomeIcon icon={faBasketShopping} className="buyer-icon" /></span>
</OverlayTrigger>
)}
{member.paid && (
<OverlayTrigger placement="top" overlay={<Tooltip>Zaplaceno</Tooltip>}>
<span className="ms-1"><FontAwesomeIcon icon={faCircleCheck} className="text-success" /></span>
</OverlayTrigger>
)}
</span>
</td>
<td>
{canEdit && editingAmount ? (
<div className="d-flex gap-1">
<Form.Control
type="number"
size="sm"
value={editAmounts[key]}
onChange={e => setEditAmounts(prev => ({ ...prev, [key]: e.target.value }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); if (e.key === 'Escape') setEditAmounts(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
style={{ width: 95 }}
autoFocus
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveAmount(group.id, memberLogin)}></Button>
</div>
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [key]: member.amount != null ? String(member.amount / 100) : '' }))}
title={canEdit ? 'Klikněte pro úpravu' : undefined}
>
{member.amount != null ? `${member.amount / 100}` : <span className="text-muted"></span>}
</span>
)}
</td>
<td>
{canEdit && editingSurcharge ? (
<div className="d-flex gap-1">
<Form.Control
type="text"
size="sm"
placeholder="popis"
value={editSurcharges[key]?.text ?? ''}
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], text: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
style={{ width: 80 }}
autoFocus
/>
<Form.Control
type="number"
size="sm"
placeholder="Kč"
value={editSurcharges[key]?.amount ?? ''}
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], amount: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
style={{ width: 60 }}
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveSurcharge(group.id, memberLogin)}></Button>
</div>
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditSurcharges(prev => ({ ...prev, [key]: { text: member.surchargeText ?? '', amount: member.surchargeAmount != null ? String(member.surchargeAmount / 100) : '' } }))}
title={canEdit ? 'Klikněte pro úpravu příplatku' : undefined}
>
{member.surchargeAmount != null && member.surchargeAmount > 0 ? (
<small>{member.surchargeText ? `${member.surchargeText}: ` : ''}<strong>{member.surchargeAmount / 100} </strong></small>
) : (
<small className="text-muted"></small>
)}
</span>
)}
</td>
<td>
{canEdit && editingNote ? (
<div className="d-flex gap-1">
<Form.Control
type="text"
size="sm"
value={editNotes[key]}
onChange={e => setEditNotes(prev => ({ ...prev, [key]: e.target.value }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveNote(group.id, memberLogin); if (e.key === 'Escape') setEditNotes(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
autoFocus
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveNote(group.id, memberLogin)}></Button>
</div>
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditNotes(prev => ({ ...prev, [key]: member.note ?? '' }))}
title={canEdit ? 'Klikněte pro úpravu poznámky' : undefined}
>
<small className="text-muted">{member.note || '—'}</small>
</span>
)}
</td>
<td className="text-end">
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
{memberTotal > 0 ? `${memberTotal / 100}` : '—'}
</small>
</td>
<td>
<div className="d-flex gap-1 justify-content-end">
{canManageMembers(group) && (isCreator || memberLogin === login) && (memberLogin !== group.creatorLogin) && (
<FontAwesomeIcon
icon={faTrashCan}
className="action-icon"
title={memberLogin === login ? 'Odhlásit se' : 'Odebrat z skupiny'}
onClick={() => refresh(() => removeGroupMember({ body: { id: group.id, login: memberLogin } }))}
/>
)}
</div>
</td>
</tr>
);
})}
</tbody>
{(() => {
const sumBase = memberEntries.reduce((sum, [, m]) => sum + (m.amount ?? 0) + (m.surchargeAmount ?? 0), 0);
const dv = group.discountValue ?? 0;
const totalDiscount = dv > 0
? (group.discountType === 'percent' ? Math.round(sumBase * dv / 100) : dv)
: 0;
const groupTotal = sumBase + totalFees - totalDiscount;
return groupTotal > 0 ? (
<tfoot>
<tr style={{ fontWeight: 700, borderTop: '2px solid var(--luncher-border)' }}>
<td colSpan={4} className="text-end" style={{ fontSize: '0.9em' }}>Celkem za skupinu:</td>
<td className="text-end">{groupTotal / 100} </td>
<td></td>
</tr>
</tfoot>
) : null;
})()}
</Table>
{/* Souhrn poplatků a slevy */}
{(totalFees > 0 || (group.discountValue != null && group.discountValue > 0)) && (
<div className="px-3 py-2 border-top d-flex gap-3 flex-wrap" style={{ fontSize: '0.85em', color: 'var(--luncher-text-muted)' }}>
{group.fees != null && group.fees > 0 && <span>Poplatky: <strong>{group.fees / 100} </strong></span>}
{group.shipping != null && group.shipping > 0 && <span>Doprava: <strong>{group.shipping / 100} </strong></span>}
{group.tip != null && group.tip > 0 && <span>Spropitné: <strong>{group.tip / 100} </strong></span>}
{feeShare > 0 && <span> <strong>{feeShare / 100} </strong>/os.</span>}
{group.discountValue != null && group.discountValue > 0 && (
<span className="text-success">
Sleva: <strong>{group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100}`}</strong>
</span>
)}
</div>
)}
{/* Časy objednání a doručení */}
{isOrdered && (
<div className="px-3 py-2 border-top">
{isCreator && editingTimes ? (
<div className="d-flex align-items-center gap-3 flex-wrap">
<div className="d-flex align-items-center gap-1">
<small className="text-muted text-nowrap">Objednáno v:</small>
<Form.Control
type="text"
size="sm"
placeholder="HH:MM"
value={editTimes[group.id]?.orderedAt ?? ''}
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], orderedAt: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
style={{ width: 75 }}
autoFocus
/>
</div>
<div className="d-flex align-items-center gap-1">
<small className="text-muted text-nowrap">Doručení v:</small>
<Form.Control
type="text"
size="sm"
placeholder="HH:MM"
value={editTimes[group.id]?.deliveryAt ?? ''}
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], deliveryAt: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
style={{ width: 75 }}
/>
</div>
<Button size="sm" variant="outline-success" onClick={() => handleSaveTimes(group)}>Uložit</Button>
<Button size="sm" variant="outline-secondary" onClick={() => setEditTimes(prev => { const n = { ...prev }; delete n[group.id]; return n; })}>Zrušit</Button>
</div>
) : (
<div
className="d-flex align-items-center gap-3 flex-wrap"
style={{ cursor: isCreator ? 'pointer' : undefined }}
onClick={() => isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '' } }))}
title={isCreator ? 'Klikněte pro úpravu časů' : undefined}
>
<small className="text-muted">
Objednáno v: <strong>{group.orderedAt ?? '—'}</strong>
</small>
<small className="text-muted">
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
</small>
</div>
)}
</div>
)}
</Card.Body>
</Card>
);
})}
</div>
</div>
</div>
<Footer />
{/* Potvrzovací dialog pro přechod do stavu Objednáno */}
<Modal show={!!confirmOrderGroup} onHide={() => setConfirmOrderGroup(null)} centered>
<Modal.Header closeButton>
<Modal.Title>Potvrdit objednání</Modal.Title>
</Modal.Header>
<Modal.Body>
Opravdu chcete označit skupinu <strong>{confirmOrderGroup?.name}</strong> jako objednanou?
Tato akce uzavře skupinu a zaznamená čas objednání.
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setConfirmOrderGroup(null)}>Zrušit</Button>
<Button variant="primary" onClick={() => confirmOrderGroup && handleConfirmOrdered(confirmOrderGroup)}>
Objednáno
</Button>
</Modal.Footer>
</Modal>
<StoreAdminModal
isOpen={adminModalOpen}
onClose={() => setAdminModalOpen(false)}
stores={stores}
onStoresChanged={updated => setData(prev => prev ? { ...prev, stores: updated } : prev)}
/>
{payModal && settings?.bankAccount && settings?.holderName && (
<PayForGroupModal
isOpen={!!payModal}
onClose={() => setPayModal(null)}
onSuccess={fetchData}
group={payModal}
groupId={payModal.id}
payerLogin={auth.login}
bankAccount={settings.bankAccount}
bankAccountHolder={settings.holderName}
/>
)}
{feesModal && (
<EditGroupFeesModal
isOpen={!!feesModal}
onClose={() => setFeesModal(null)}
group={feesModal}
onSaved={newData => {
if (newData) {
setData(newData);
socket.emit?.('message', newData as ClientData);
}
setFeesModal(null);
}}
/>
)}
</div>
);
}
+2 -6
View File
@@ -4,10 +4,7 @@ import path from 'path';
// Use 127.0.0.1 explicitly — on Node.js 18+/Windows, `localhost` may resolve to ::1 // Use 127.0.0.1 explicitly — on Node.js 18+/Windows, `localhost` may resolve to ::1
// (IPv6) while the HTTP server only binds to 0.0.0.0 (IPv4), causing the webServer // (IPv6) while the HTTP server only binds to 0.0.0.0 (IPv4), causing the webServer
// readiness poll to time out even though the server is listening. // readiness poll to time out even though the server is listening.
// Port 3099 avoids conflicts with locally running Docker containers on 3001-3003. const BASE_URL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3001';
// Override with E2E_PORT env var if needed.
const E2E_PORT = process.env.E2E_PORT ?? '3099';
const BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${E2E_PORT}`;
// Server env vars injected for local runs. In CI these are set at the step level. // Server env vars injected for local runs. In CI these are set at the step level.
const serverEnv: Record<string, string> = { const serverEnv: Record<string, string> = {
@@ -18,7 +15,6 @@ const serverEnv: Record<string, string> = {
HTTP_REMOTE_USER_ENABLED: 'true', HTTP_REMOTE_USER_ENABLED: 'true',
HTTP_REMOTE_USER_HEADER_NAME: 'remote-user', HTTP_REMOTE_USER_HEADER_NAME: 'remote-user',
HTTP_REMOTE_TRUSTED_IPS: process.env.HTTP_REMOTE_TRUSTED_IPS ?? '127.0.0.1,::1,::ffff:127.0.0.1', HTTP_REMOTE_TRUSTED_IPS: process.env.HTTP_REMOTE_TRUSTED_IPS ?? '127.0.0.1,::1,::ffff:127.0.0.1',
PORT: E2E_PORT,
}; };
if (process.env.REDIS_HOST) { if (process.env.REDIS_HOST) {
serverEnv.REDIS_HOST = process.env.REDIS_HOST; serverEnv.REDIS_HOST = process.env.REDIS_HOST;
@@ -54,7 +50,7 @@ export default defineConfig({
cwd: path.resolve(__dirname, '../server'), cwd: path.resolve(__dirname, '../server'),
// Poll a dedicated health endpoint — polling '/' can stall in Express 5 when // Poll a dedicated health endpoint — polling '/' can stall in Express 5 when
// server/public/ doesn't exist in the working directory (no finalhandler match). // server/public/ doesn't exist in the working directory (no finalhandler match).
url: `http://127.0.0.1:${E2E_PORT}/api/health`, url: `http://127.0.0.1:3001/api/health`,
timeout: 15_000, timeout: 15_000,
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
env: serverEnv, env: serverEnv,
-23
View File
@@ -1,23 +0,0 @@
# Spustí server a klienta v samostatných panelech jednoho okna Windows Terminalu.
# Vyžaduje Windows Terminal (wt.exe) — výchozí součást Windows 11.
$ErrorActionPreference = 'Stop'
$ScriptDir = $PSScriptRoot
Push-Location (Join-Path $ScriptDir 'types')
try { yarn openapi-ts } finally { Pop-Location }
if (-not (Get-Command wt.exe -ErrorAction SilentlyContinue)) {
Write-Error "wt.exe (Windows Terminal) nebyl nalezen. Nainstalujte z Microsoft Store nebo použijte run_dev.sh v WSL."
exit 1
}
$serverDir = Join-Path $ScriptDir 'server'
$clientDir = Join-Path $ScriptDir 'client'
# wt splits on ';' before respecting quoting, so encode the compound server command to avoid it
$serverCmd = '$env:NODE_ENV = ''development''; yarn startReload'
$serverCmdB64 = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($serverCmd))
wt -w 0 new-tab --title 'luncher-server' -d $serverDir pwsh -NoExit -EncodedCommand $serverCmdB64 `; `
split-pane -H --title 'luncher-client' -d $clientDir pwsh -NoExit -Command "yarn start"
-4
View File
@@ -48,7 +48,3 @@
# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin). # Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin).
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu). # Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
# REFRESH_BYPASS_PASSWORD= # REFRESH_BYPASS_PASSWORD=
# Admin heslo pro správu seznamu obchodů na stránce /objednani.
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
# ADMIN_PASSWORD=
+3
View File
@@ -0,0 +1,3 @@
[
"Evidence večeří a pozdních obědů na samostatné stránce (/vecere)"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Skupinové objednávky s QR platbou — stránka /objednani (více skupin, každá z jiného obchodu, stavový automat open/locked/ordered)"
]
+8 -8
View File
@@ -9,13 +9,13 @@ import jwt from 'jsonwebtoken';
*/ */
export function generateToken(login?: string, trusted?: boolean): string { export function generateToken(login?: string, trusted?: boolean): string {
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET"); throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
} }
if (process.env.JWT_SECRET.length < 32) { if (process.env.JWT_SECRET.length < 32) {
throw new Error("Proměnná prostředí JWT_SECRET musí být minimálně 32 znaků"); throw Error("Proměnná prostředí JWT_SECRET musí být minimálně 32 znaků");
} }
if (!login || login.trim().length === 0) { if (!login || login.trim().length === 0) {
throw new Error("Nebyl předán login"); throw Error("Nebyl předán login");
} }
const payload = { login, trusted: trusted || false, logoutUrl: process.env.LOGOUT_URL }; const payload = { login, trusted: trusted || false, logoutUrl: process.env.LOGOUT_URL };
return jwt.sign(payload, process.env.JWT_SECRET); return jwt.sign(payload, process.env.JWT_SECRET);
@@ -28,7 +28,7 @@ export function generateToken(login?: string, trusted?: boolean): string {
*/ */
export function verify(token: string): boolean { export function verify(token: string): boolean {
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET"); throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
} }
try { try {
jwt.verify(token, process.env.JWT_SECRET); jwt.verify(token, process.env.JWT_SECRET);
@@ -45,10 +45,10 @@ export function verify(token: string): boolean {
*/ */
export function getLogin(token?: string): string { export function getLogin(token?: string): string {
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET"); throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
} }
if (!token) { if (!token) {
throw new Error("Nebyl předán token"); throw Error("Nebyl předán token");
} }
const payload: any = jwt.verify(token, process.env.JWT_SECRET); const payload: any = jwt.verify(token, process.env.JWT_SECRET);
return payload.login; return payload.login;
@@ -61,10 +61,10 @@ export function getLogin(token?: string): string {
*/ */
export function getTrusted(token?: string): boolean { export function getTrusted(token?: string): boolean {
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET"); throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
} }
if (!token) { if (!token) {
throw new Error("Nebyl předán token"); throw Error("Nebyl předán token");
} }
const payload: any = jwt.verify(token, process.env.JWT_SECRET); const payload: any = jwt.verify(token, process.env.JWT_SECRET);
return payload.trusted || false; return payload.trusted || false;
+9 -9
View File
@@ -28,16 +28,16 @@ const buildPizzaUrl = (pizzaUrl: string) => {
return `${baseUrl}/${pizzaUrl}`; return `${baseUrl}/${pizzaUrl}`;
} }
// Ceny krabic dle velikosti v haléřích // Ceny krabic dle velikosti
const boxPrices: { [key: string]: number } = { const boxPrices: { [key: string]: number } = {
"30cm": 1300, "30cm": 13,
"35cm": 1500, "35cm": 15,
"40cm": 1800, "40cm": 18,
"50cm": 2500 "50cm": 25
} }
// Cena obalu pro salát v haléřích // Cena obalu pro salát
const SALAT_BOX_PRICE = 1300; const SALAT_BOX_PRICE = 13;
/** /**
* Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie. * Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie.
@@ -79,7 +79,7 @@ export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
a.each((i, elm) => { a.each((i, elm) => {
const varId = Number.parseInt(elm.attribs.href.split('?varianta=')[1].trim()); const varId = Number.parseInt(elm.attribs.href.split('?varianta=')[1].trim());
const size = $($(elm).contents().get(0)).text().trim(); const size = $($(elm).contents().get(0)).text().trim();
const price = Number.parseInt($($(elm).contents().get(1)).text().trim().split(" Kč")[0]) * 100; const price = Number.parseInt($($(elm).contents().get(1)).text().trim().split(" Kč")[0]);
sizes.push({ varId, size, pizzaPrice: price, boxPrice: boxPrices[size], price: price + boxPrices[size] }); sizes.push({ varId, size, pizzaPrice: price, boxPrice: boxPrices[size], price: price + boxPrices[size] });
}) })
result.push({ result.push({
@@ -119,7 +119,7 @@ export async function downloadSalaty(mock: boolean): Promise<Salat[]> {
ingredients.push($(elm).text()); ingredients.push($(elm).text());
}); });
const priceText = $('.cena > span', salatHtml).first().text().trim(); const priceText = $('.cena > span', salatHtml).first().text().trim();
const price = Number.parseInt(priceText.split(' Kč')[0]) * 100; const price = Number.parseInt(priceText.split(' Kč')[0]);
result.push({ name, ingredients, price: price + SALAT_BOX_PRICE }); result.push({ name, ingredients, price: price + SALAT_BOX_PRICE });
} }
return result; return result;
-183
View File
@@ -1,183 +0,0 @@
import crypto from "crypto";
import getStorage from "./storage";
import { getClientData, getToday, initIfNeeded } from "./service";
import { getStores } from "./stores";
import { removePendingQrsByGroupId } from "./pizza";
import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen";
import { formatDate } from "./utils";
const storage = getStorage();
async function getExtraData(date?: Date): Promise<ClientData> {
await initIfNeeded(date, MealSlot.EXTRA);
const data = await getClientData(date, MealSlot.EXTRA);
data.stores = await getStores();
return data;
}
function getExtraKey(date?: Date): string {
return `${formatDate(date ?? getToday())}_extra`;
}
async function saveExtraData(data: ClientData, date?: Date): Promise<ClientData> {
await storage.setData(getExtraKey(date), data);
return data;
}
function findGroup(data: ClientData, id: string): OrderGroup | undefined {
return data.groups?.find(g => g.id === id);
}
export async function createGroup(creatorLogin: string, name: string, date?: Date): Promise<ClientData> {
const stores = await getStores();
if (!stores.some(s => s.toLowerCase() === name.trim().toLowerCase())) {
throw new Error('Obchod není v seznamu povolených obchodů');
}
const data = await getExtraData(date);
const canonical = stores.find(s => s.toLowerCase() === name.trim().toLowerCase())!;
const group: OrderGroup = {
id: crypto.randomUUID(),
name: canonical,
creatorLogin,
state: GroupState.OPEN,
members: { [creatorLogin]: {} },
};
data.groups = [...(data.groups ?? []), group];
return saveExtraData(data, date);
}
export async function deleteGroup(login: string, groupId: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Skupinu může smazat pouze zakladatel');
data.groups = (data.groups ?? []).filter(g => g.id !== groupId);
return saveExtraData(data, date);
}
export async function addGroupMember(login: string, groupId: string, targetLogin: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
if (login !== group.creatorLogin && login !== targetLogin) {
throw new Error('Přidat jiného uživatele může pouze zakladatel');
}
if (group.state === GroupState.LOCKED && login !== group.creatorLogin) {
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
}
if (group.members[targetLogin]) throw new Error('Uživatel je již ve skupině');
group.members[targetLogin] = {};
return saveExtraData(data, date);
}
export async function removeGroupMember(login: string, groupId: string, targetLogin: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
if (login !== group.creatorLogin && login !== targetLogin) {
throw new Error('Odebrat jiného uživatele může pouze zakladatel');
}
if (group.state === GroupState.LOCKED && login !== group.creatorLogin) {
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
}
if (targetLogin === group.creatorLogin) throw new Error('Zakladatel skupiny nemůže být odebrán');
if (!group.members[targetLogin]) throw new Error('Uživatel není ve skupině');
delete group.members[targetLogin];
return saveExtraData(data, date);
}
export async function updateGroupMember(login: string, groupId: string, targetLogin: string, patch: Partial<OrderGroupMember>, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
const isSelf = login === targetLogin;
const isCreator = login === group.creatorLogin;
if (!isSelf && !isCreator) throw new Error('Upravit jiného uživatele může pouze zakladatel');
if (!isCreator && group.state === GroupState.LOCKED) {
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
}
if (!group.members[targetLogin]) throw new Error('Uživatel není ve skupině');
group.members[targetLogin] = { ...group.members[targetLogin], ...patch };
return saveExtraData(data, date);
}
const VALID_TRANSITIONS: Record<GroupState, GroupState[]> = {
[GroupState.OPEN]: [GroupState.LOCKED],
[GroupState.LOCKED]: [GroupState.OPEN, GroupState.ORDERED],
[GroupState.ORDERED]: [GroupState.LOCKED],
};
function getCurrentHHMM(): string {
const now = new Date();
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
}
export async function setGroupState(login: string, groupId: string, newState: GroupState, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Stav může měnit pouze zakladatel');
if (!VALID_TRANSITIONS[group.state].includes(newState)) {
throw new Error(`Nelze přejít ze stavu "${group.state}" do stavu "${newState}"`);
}
if (newState === GroupState.ORDERED) {
group.orderedAt = getCurrentHHMM();
}
if (group.state === GroupState.ORDERED && newState === GroupState.LOCKED) {
const memberLogins = Object.keys(group.members);
await removePendingQrsByGroupId(memberLogins, groupId);
group.orderedAt = undefined;
group.deliveryAt = undefined;
group.qrGenerated = undefined;
for (const ml of memberLogins) {
group.members[ml] = { ...group.members[ml], paid: undefined };
}
}
group.state = newState;
return saveExtraData(data, date);
}
export async function markGroupQrGenerated(login: string, groupId: string, date?: Date): Promise<void> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('QR kódy může generovat pouze zakladatel');
if (group.state !== GroupState.ORDERED) throw new Error('QR kódy lze generovat pouze ve stavu "objednáno"');
group.qrGenerated = true;
await saveExtraData(data, date);
}
export async function markGroupMemberPaid(login: string, groupId: string, date?: Date): Promise<ClientData | null> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group || !group.members[login]) return null;
group.members[login] = { ...group.members[login], paid: true };
return saveExtraData(data, date);
}
export async function updateGroupFees(login: string, groupId: string, fees?: number, shipping?: number, tip?: number, discountType?: string, discountValue?: number, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Poplatky může měnit pouze zakladatel');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
if (fees !== undefined) group.fees = fees > 0 ? fees : undefined;
if (shipping !== undefined) group.shipping = shipping > 0 ? shipping : undefined;
if (tip !== undefined) group.tip = tip > 0 ? tip : undefined;
if (discountType !== undefined) group.discountType = (discountType as any) || undefined;
if (discountValue !== undefined) group.discountValue = discountValue > 0 ? discountValue : undefined;
return saveExtraData(data, date);
}
export async function updateGroupTimes(login: string, groupId: string, orderedAt?: string, deliveryAt?: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Časy může měnit pouze zakladatel');
if (orderedAt !== undefined) group.orderedAt = orderedAt || undefined;
if (deliveryAt !== undefined) group.deliveryAt = deliveryAt || undefined;
return saveExtraData(data, date);
}
+8 -30
View File
@@ -1,7 +1,7 @@
import express from "express"; import express from "express";
import bodyParser from "body-parser"; import bodyParser from "body-parser";
import cors from 'cors'; import cors from 'cors';
import { getData, addChoice, getDateForWeekIndex, getToday } from "./service"; import { getData, getDateForWeekIndex, getToday } from "./service";
import { MealSlot } from "../../types/gen/types.gen"; import { MealSlot } from "../../types/gen/types.gen";
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
@@ -9,8 +9,8 @@ import { getQr } from "./qr";
import { generateToken, getLogin, verify } from "./auth"; 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, getWebsocket } from "./websocket"; import { initWebsocket } from "./websocket";
import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder"; import { startReminderScheduler } from "./pushReminder";
import { storageReady } from "./storage"; import { storageReady } from "./storage";
import pizzaDayRoutes from "./routes/pizzaDayRoutes"; import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes"; import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
@@ -21,15 +21,13 @@ import notificationRoutes from "./routes/notificationRoutes";
import qrRoutes from "./routes/qrRoutes"; import qrRoutes from "./routes/qrRoutes";
import devRoutes from "./routes/devRoutes"; import devRoutes from "./routes/devRoutes";
import changelogRoutes from "./routes/changelogRoutes"; import changelogRoutes from "./routes/changelogRoutes";
import groupRoutes from "./routes/groupRoutes";
import storeRoutes from "./routes/storeRoutes";
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}`) });
// Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit // Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET"); throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
} }
const app = express(); const app = express();
@@ -83,12 +81,12 @@ app.post("/api/login", (req, res) => {
if (remoteUser && remoteUser.length > 0) { if (remoteUser && remoteUser.length > 0) {
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true)); res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
} else { } else {
throw new Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??"); throw Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
} }
} else { } else {
// Klasická autentizace loginem // Klasická autentizace loginem
if (!req.body?.login || req.body.login.trim().length === 0) { if (!req.body?.login || req.body.login.trim().length === 0) {
throw new Error("Nebyl předán login"); throw Error("Nebyl předán login");
} }
// TODO zavést podmínky pro délku loginu (min i max) // TODO zavést podmínky pro délku loginu (min i max)
res.status(200).json(generateToken(req.body.login, false)); res.status(200).json(generateToken(req.body.login, false));
@@ -116,22 +114,6 @@ app.get("/api/qr", async (req, res) => {
// Přeskočení auth pro refresh dat xd // Přeskočení auth pro refresh dat xd
app.use("/api/food/refresh", refreshMetoda); app.use("/api/food/refresh", refreshMetoda);
// Rychlá akce z push notifikace — autentizace pomocí HMAC tokenu z push payloadu (SW nemá přístup k JWT)
app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
try {
const { login, token } = req.body ?? {};
if (!login || typeof login !== 'string' || !token || typeof token !== 'string') {
return res.status(400).json({ error: 'Chybí login nebo token' });
}
if (!verifyQuickChoiceToken(login, token)) {
return res.status(403).json({ error: 'Neplatný token' });
}
const updatedData = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
getWebsocket().emit("message", updatedData);
res.status(200).json({});
} catch (e: any) { next(e); }
});
/** Middleware ověřující JWT token */ /** Middleware ověřující JWT token */
app.use("/api/", (req, res, next) => { app.use("/api/", (req, res, next) => {
if (HTTP_REMOTE_USER_ENABLED) { if (HTTP_REMOTE_USER_ENABLED) {
@@ -198,13 +180,9 @@ app.use("/api/notifications", notificationRoutes);
app.use("/api/qr", qrRoutes); app.use("/api/qr", qrRoutes);
app.use("/api/dev", devRoutes); app.use("/api/dev", devRoutes);
app.use("/api/changelogs", changelogRoutes); app.use("/api/changelogs", changelogRoutes);
app.use("/api/groups", groupRoutes);
app.use("/api/stores", storeRoutes);
app.use(express.static(path.join(process.cwd(), 'public'))); app.use('/stats', express.static('public'));
app.get('*splat', (_req, res) => { app.use(express.static('public'));
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
});
// Middleware pro zpracování chyb // Middleware pro zpracování chyb
app.use((err: any, req: any, res: any, next: any) => { app.use((err: any, req: any, res: any, next: any) => {
+220 -220
View File
@@ -661,30 +661,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 1, varId: 1,
size: "30cm", size: "30cm",
pizzaPrice: 13800, pizzaPrice: 138,
boxPrice: 1300, boxPrice: 13,
price: 15100 price: 151
}, },
{ {
varId: 2, varId: 2,
size: "35cm", size: "35cm",
pizzaPrice: 16600, pizzaPrice: 166,
boxPrice: 1500, boxPrice: 15,
price: 18100 price: 181
}, },
{ {
varId: 3, varId: 3,
size: "40cm", size: "40cm",
pizzaPrice: 22300, pizzaPrice: 223,
boxPrice: 1800, boxPrice: 18,
price: 24100 price: 241
}, },
{ {
varId: 4, varId: 4,
size: "50cm", size: "50cm",
pizzaPrice: 30600, pizzaPrice: 306,
boxPrice: 2500, boxPrice: 25,
price: 33100 price: 331
} }
] ]
}, },
@@ -700,30 +700,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 6, varId: 6,
size: "30cm", size: "30cm",
pizzaPrice: 14200, pizzaPrice: 142,
boxPrice: 1300, boxPrice: 13,
price: 15500 price: 155
}, },
{ {
varId: 7, varId: 7,
size: "35cm", size: "35cm",
pizzaPrice: 17200, pizzaPrice: 172,
boxPrice: 1500, boxPrice: 15,
price: 18700 price: 187
}, },
{ {
varId: 8, varId: 8,
size: "40cm", size: "40cm",
pizzaPrice: 23300, pizzaPrice: 233,
boxPrice: 1800, boxPrice: 18,
price: 25100 price: 251
}, },
{ {
varId: 9, varId: 9,
size: "50cm", size: "50cm",
pizzaPrice: 31600, pizzaPrice: 316,
boxPrice: 2500, boxPrice: 25,
price: 34100 price: 341
} }
] ]
}, },
@@ -741,30 +741,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 10, varId: 10,
size: "30cm", size: "30cm",
pizzaPrice: 14200, pizzaPrice: 142,
boxPrice: 1300, boxPrice: 13,
price: 15500 price: 155
}, },
{ {
varId: 11, varId: 11,
size: "35cm", size: "35cm",
pizzaPrice: 17200, pizzaPrice: 172,
boxPrice: 1500, boxPrice: 15,
price: 18700 price: 187
}, },
{ {
varId: 12, varId: 12,
size: "40cm", size: "40cm",
pizzaPrice: 23300, pizzaPrice: 233,
boxPrice: 1800, boxPrice: 18,
price: 25100 price: 251
}, },
{ {
varId: 13, varId: 13,
size: "50cm", size: "50cm",
pizzaPrice: 31600, pizzaPrice: 316,
boxPrice: 2500, boxPrice: 25,
price: 34100 price: 341
} }
] ]
}, },
@@ -780,30 +780,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 14, varId: 14,
size: "30cm", size: "30cm",
pizzaPrice: 14200, pizzaPrice: 142,
boxPrice: 1300, boxPrice: 13,
price: 15500 price: 155
}, },
{ {
varId: 15, varId: 15,
size: "35cm", size: "35cm",
pizzaPrice: 17200, pizzaPrice: 172,
boxPrice: 1500, boxPrice: 15,
price: 18700 price: 187
}, },
{ {
varId: 16, varId: 16,
size: "40cm", size: "40cm",
pizzaPrice: 23300, pizzaPrice: 233,
boxPrice: 1800, boxPrice: 18,
price: 25100 price: 251
}, },
{ {
varId: 17, varId: 17,
size: "50cm", size: "50cm",
pizzaPrice: 29400, pizzaPrice: 294,
boxPrice: 2500, boxPrice: 25,
price: 31900 price: 319
} }
] ]
}, },
@@ -821,30 +821,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 22, varId: 22,
size: "30cm", size: "30cm",
pizzaPrice: 16200, pizzaPrice: 162,
boxPrice: 1300, boxPrice: 13,
price: 17500 price: 175
}, },
{ {
varId: 23, varId: 23,
size: "35cm", size: "35cm",
pizzaPrice: 18600, pizzaPrice: 186,
boxPrice: 1500, boxPrice: 15,
price: 20100 price: 201
}, },
{ {
varId: 24, varId: 24,
size: "40cm", size: "40cm",
pizzaPrice: 26300, pizzaPrice: 263,
boxPrice: 1800, boxPrice: 18,
price: 28100 price: 281
}, },
{ {
varId: 25, varId: 25,
size: "50cm", size: "50cm",
pizzaPrice: 34600, pizzaPrice: 346,
boxPrice: 2500, boxPrice: 25,
price: 37100 price: 371
} }
] ]
}, },
@@ -861,30 +861,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 26, varId: 26,
size: "30cm", size: "30cm",
pizzaPrice: 16200, pizzaPrice: 162,
boxPrice: 1300, boxPrice: 13,
price: 17500 price: 175
}, },
{ {
varId: 27, varId: 27,
size: "35cm", size: "35cm",
pizzaPrice: 18600, pizzaPrice: 186,
boxPrice: 1500, boxPrice: 15,
price: 20100 price: 201
}, },
{ {
varId: 28, varId: 28,
size: "40cm", size: "40cm",
pizzaPrice: 26300, pizzaPrice: 263,
boxPrice: 1800, boxPrice: 18,
price: 28100 price: 281
}, },
{ {
varId: 29, varId: 29,
size: "50cm", size: "50cm",
pizzaPrice: 34600, pizzaPrice: 346,
boxPrice: 2500, boxPrice: 25,
price: 37100 price: 371
} }
] ]
}, },
@@ -902,30 +902,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 30, varId: 30,
size: "30cm", size: "30cm",
pizzaPrice: 16200, pizzaPrice: 162,
boxPrice: 1300, boxPrice: 13,
price: 17500 price: 175
}, },
{ {
varId: 31, varId: 31,
size: "35cm", size: "35cm",
pizzaPrice: 18600, pizzaPrice: 186,
boxPrice: 1500, boxPrice: 15,
price: 20100 price: 201
}, },
{ {
varId: 32, varId: 32,
size: "40cm", size: "40cm",
pizzaPrice: 26300, pizzaPrice: 263,
boxPrice: 1800, boxPrice: 18,
price: 28100 price: 281
}, },
{ {
varId: 33, varId: 33,
size: "50cm", size: "50cm",
pizzaPrice: 34600, pizzaPrice: 346,
boxPrice: 2500, boxPrice: 25,
price: 37100 price: 371
} }
] ]
}, },
@@ -946,30 +946,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 34, varId: 34,
size: "30cm", size: "30cm",
pizzaPrice: 16200, pizzaPrice: 162,
boxPrice: 1300, boxPrice: 13,
price: 17500 price: 175
}, },
{ {
varId: 35, varId: 35,
size: "35cm", size: "35cm",
pizzaPrice: 18600, pizzaPrice: 186,
boxPrice: 1500, boxPrice: 15,
price: 20100 price: 201
}, },
{ {
varId: 36, varId: 36,
size: "40cm", size: "40cm",
pizzaPrice: 26300, pizzaPrice: 263,
boxPrice: 1800, boxPrice: 18,
price: 28100 price: 281
}, },
{ {
varId: 37, varId: 37,
size: "50cm", size: "50cm",
pizzaPrice: 34600, pizzaPrice: 346,
boxPrice: 2500, boxPrice: 25,
price: 37100 price: 371
} }
] ]
}, },
@@ -987,30 +987,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 38, varId: 38,
size: "30cm", size: "30cm",
pizzaPrice: 16200, pizzaPrice: 162,
boxPrice: 1300, boxPrice: 13,
price: 17500 price: 175
}, },
{ {
varId: 39, varId: 39,
size: "35cm", size: "35cm",
pizzaPrice: 18600, pizzaPrice: 186,
boxPrice: 1500, boxPrice: 15,
price: 20100 price: 201
}, },
{ {
varId: 40, varId: 40,
size: "40cm", size: "40cm",
pizzaPrice: 26300, pizzaPrice: 263,
boxPrice: 1800, boxPrice: 18,
price: 28100 price: 281
}, },
{ {
varId: 41, varId: 41,
size: "50cm", size: "50cm",
pizzaPrice: 34600, pizzaPrice: 346,
boxPrice: 2500, boxPrice: 25,
price: 37100 price: 371
} }
] ]
}, },
@@ -1028,30 +1028,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 42, varId: 42,
size: "30cm", size: "30cm",
pizzaPrice: 17200, pizzaPrice: 172,
boxPrice: 1300, boxPrice: 13,
price: 18500 price: 185
}, },
{ {
varId: 43, varId: 43,
size: "35cm", size: "35cm",
pizzaPrice: 21200, pizzaPrice: 212,
boxPrice: 1500, boxPrice: 15,
price: 22700 price: 227
}, },
{ {
varId: 44, varId: 44,
size: "40cm", size: "40cm",
pizzaPrice: 29300, pizzaPrice: 293,
boxPrice: 1800, boxPrice: 18,
price: 31100 price: 311
}, },
{ {
varId: 45, varId: 45,
size: "50cm", size: "50cm",
pizzaPrice: 37600, pizzaPrice: 376,
boxPrice: 2500, boxPrice: 25,
price: 40100 price: 401
} }
] ]
}, },
@@ -1069,30 +1069,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 46, varId: 46,
size: "30cm", size: "30cm",
pizzaPrice: 18200, pizzaPrice: 182,
boxPrice: 1300, boxPrice: 13,
price: 19500 price: 195
}, },
{ {
varId: 47, varId: 47,
size: "35cm", size: "35cm",
pizzaPrice: 22200, pizzaPrice: 222,
boxPrice: 1500, boxPrice: 15,
price: 23700 price: 237
}, },
{ {
varId: 48, varId: 48,
size: "40cm", size: "40cm",
pizzaPrice: 30300, pizzaPrice: 303,
boxPrice: 1800, boxPrice: 18,
price: 32100 price: 321
}, },
{ {
varId: 49, varId: 49,
size: "50cm", size: "50cm",
pizzaPrice: 38600, pizzaPrice: 386,
boxPrice: 2500, boxPrice: 25,
price: 41100 price: 411
} }
] ]
}, },
@@ -1114,30 +1114,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 50, varId: 50,
size: "30cm", size: "30cm",
pizzaPrice: 18200, pizzaPrice: 182,
boxPrice: 1300, boxPrice: 13,
price: 19500 price: 195
}, },
{ {
varId: 51, varId: 51,
size: "35cm", size: "35cm",
pizzaPrice: 22200, pizzaPrice: 222,
boxPrice: 1500, boxPrice: 15,
price: 23700 price: 237
}, },
{ {
varId: 52, varId: 52,
size: "40cm", size: "40cm",
pizzaPrice: 30300, pizzaPrice: 303,
boxPrice: 1800, boxPrice: 18,
price: 32100 price: 321
}, },
{ {
varId: 53, varId: 53,
size: "50cm", size: "50cm",
pizzaPrice: 39600, pizzaPrice: 396,
boxPrice: 2500, boxPrice: 25,
price: 42100 price: 421
} }
] ]
}, },
@@ -1156,30 +1156,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 54, varId: 54,
size: "30cm", size: "30cm",
pizzaPrice: 18200, pizzaPrice: 182,
boxPrice: 1300, boxPrice: 13,
price: 19500 price: 195
}, },
{ {
varId: 55, varId: 55,
size: "35cm", size: "35cm",
pizzaPrice: 22200, pizzaPrice: 222,
boxPrice: 1500, boxPrice: 15,
price: 23700 price: 237
}, },
{ {
varId: 56, varId: 56,
size: "40cm", size: "40cm",
pizzaPrice: 30300, pizzaPrice: 303,
boxPrice: 1800, boxPrice: 18,
price: 32100 price: 321
}, },
{ {
varId: 57, varId: 57,
size: "50cm", size: "50cm",
pizzaPrice: 39600, pizzaPrice: 396,
boxPrice: 2500, boxPrice: 25,
price: 42100 price: 421
} }
] ]
}, },
@@ -1199,30 +1199,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 58, varId: 58,
size: "30cm", size: "30cm",
pizzaPrice: 18200, pizzaPrice: 182,
boxPrice: 1300, boxPrice: 13,
price: 19500 price: 195
}, },
{ {
varId: 59, varId: 59,
size: "35cm", size: "35cm",
pizzaPrice: 22200, pizzaPrice: 222,
boxPrice: 1500, boxPrice: 15,
price: 23700 price: 237
}, },
{ {
varId: 60, varId: 60,
size: "40cm", size: "40cm",
pizzaPrice: 30300, pizzaPrice: 303,
boxPrice: 1800, boxPrice: 18,
price: 32100 price: 321
}, },
{ {
varId: 61, varId: 61,
size: "50cm", size: "50cm",
pizzaPrice: 39600, pizzaPrice: 396,
boxPrice: 2500, boxPrice: 25,
price: 42100 price: 421
} }
] ]
}, },
@@ -1241,30 +1241,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 62, varId: 62,
size: "30cm", size: "30cm",
pizzaPrice: 18800, pizzaPrice: 188,
boxPrice: 1300, boxPrice: 13,
price: 20100 price: 201
}, },
{ {
varId: 63, varId: 63,
size: "35cm", size: "35cm",
pizzaPrice: 22600, pizzaPrice: 226,
boxPrice: 1500, boxPrice: 15,
price: 24100 price: 241
}, },
{ {
varId: 64, varId: 64,
size: "40cm", size: "40cm",
pizzaPrice: 31300, pizzaPrice: 313,
boxPrice: 1800, boxPrice: 18,
price: 33100 price: 331
}, },
{ {
varId: 65, varId: 65,
size: "50cm", size: "50cm",
pizzaPrice: 42600, pizzaPrice: 426,
boxPrice: 2500, boxPrice: 25,
price: 45100 price: 451
} }
] ]
}, },
@@ -1283,30 +1283,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 66, varId: 66,
size: "30cm", size: "30cm",
pizzaPrice: 18800, pizzaPrice: 188,
boxPrice: 1300, boxPrice: 13,
price: 20100 price: 201
}, },
{ {
varId: 67, varId: 67,
size: "35cm", size: "35cm",
pizzaPrice: 22600, pizzaPrice: 226,
boxPrice: 1500, boxPrice: 15,
price: 24100 price: 241
}, },
{ {
varId: 68, varId: 68,
size: "40cm", size: "40cm",
pizzaPrice: 31300, pizzaPrice: 313,
boxPrice: 1800, boxPrice: 18,
price: 33100 price: 331
}, },
{ {
varId: 69, varId: 69,
size: "50cm", size: "50cm",
pizzaPrice: 42600, pizzaPrice: 426,
boxPrice: 2500, boxPrice: 25,
price: 45100 price: 451
} }
] ]
}, },
@@ -1327,30 +1327,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 309, varId: 309,
size: "30cm", size: "30cm",
pizzaPrice: 18200, pizzaPrice: 182,
boxPrice: 1300, boxPrice: 13,
price: 19500 price: 195
}, },
{ {
varId: 310, varId: 310,
size: "35cm", size: "35cm",
pizzaPrice: 22200, pizzaPrice: 222,
boxPrice: 1500, boxPrice: 15,
price: 23700 price: 237
}, },
{ {
varId: 311, varId: 311,
size: "40cm", size: "40cm",
pizzaPrice: 30300, pizzaPrice: 303,
boxPrice: 1800, boxPrice: 18,
price: 32100 price: 321
}, },
{ {
varId: 312, varId: 312,
size: "50cm", size: "50cm",
pizzaPrice: 39600, pizzaPrice: 396,
boxPrice: 2500, boxPrice: 25,
price: 42100 price: 421
} }
] ]
}, },
@@ -1369,30 +1369,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 394, varId: 394,
size: "30cm", size: "30cm",
pizzaPrice: 18800, pizzaPrice: 188,
boxPrice: 1300, boxPrice: 13,
price: 20100 price: 201
}, },
{ {
varId: 395, varId: 395,
size: "35cm", size: "35cm",
pizzaPrice: 22600, pizzaPrice: 226,
boxPrice: 1500, boxPrice: 15,
price: 24100 price: 241
}, },
{ {
varId: 396, varId: 396,
size: "40cm", size: "40cm",
pizzaPrice: 31300, pizzaPrice: 313,
boxPrice: 1800, boxPrice: 18,
price: 33100 price: 331
}, },
{ {
varId: 397, varId: 397,
size: "50cm", size: "50cm",
pizzaPrice: 42600, pizzaPrice: 426,
boxPrice: 2500, boxPrice: 25,
price: 45100 price: 451
} }
] ]
} }
@@ -1434,22 +1434,22 @@ const MOCK_SALAT_LIST = [
{ {
name: "Greek", name: "Greek",
ingredients: ["Salát", "Černé olivy", "Paprika mix", "Červená cibule", "Rajčata", "Okurka salátová", "Jogurtový dresing"], ingredients: ["Salát", "Černé olivy", "Paprika mix", "Červená cibule", "Rajčata", "Okurka salátová", "Jogurtový dresing"],
price: (174 + 13) * 100, price: 174 + 13,
}, },
{ {
name: "Caesar", name: "Caesar",
ingredients: ["Salát", "Rajčata", "Kuřecí maso", "Krutony", "Parmazán", "Caesar dresing", "Olivový olej"], ingredients: ["Salát", "Rajčata", "Kuřecí maso", "Krutony", "Parmazán", "Caesar dresing", "Olivový olej"],
price: (184 + 13) * 100, price: 184 + 13,
}, },
{ {
name: "Šopský salát", name: "Šopský salát",
ingredients: ["Salátová okurka", "Rajčata", "Paprika mix", "Červená cibule", "Balkánský sýr"], ingredients: ["Salátová okurka", "Rajčata", "Paprika mix", "Červená cibule", "Balkánský sýr"],
price: (164 + 13) * 100, price: 164 + 13,
}, },
{ {
name: "Těstovinový salát", name: "Těstovinový salát",
ingredients: ["Penne", "Okurka", "Rajčata", "Paprika mix", "Kuřecí maso", "Jogurtový dresing"], ingredients: ["Penne", "Okurka", "Rajčata", "Paprika mix", "Kuřecí maso", "Jogurtový dresing"],
price: (184 + 13) * 100, price: 184 + 13,
}, },
] ]
+31 -48
View File
@@ -74,7 +74,7 @@ export async function createPizzaDay(creator: string): Promise<ClientData> {
await initIfNeeded(); await initIfNeeded();
const clientData = await getClientData(getToday()); const clientData = await getClientData(getToday());
if (clientData.pizzaDay) { if (clientData.pizzaDay) {
throw new Error("Pizza day pro dnešní den již existuje"); throw Error("Pizza day pro dnešní den již existuje");
} }
// TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě! // TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě!
const [pizzaList, salatList] = await Promise.all([getPizzaList(), getSalatList()]); const [pizzaList, salatList] = await Promise.all([getPizzaList(), getSalatList()]);
@@ -91,10 +91,10 @@ export async function createPizzaDay(creator: string): Promise<ClientData> {
export async function deletePizzaDay(login: string): Promise<ClientData> { export async function deletePizzaDay(login: string): Promise<ClientData> {
const clientData = await getClientData(getToday()); const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
if (clientData.pizzaDay.creator !== login) { if (clientData.pizzaDay.creator !== login) {
throw new Error("Login uživatele se neshoduje se zakladatelem Pizza Day"); throw Error("Login uživatele se neshoduje se zakladatelem Pizza Day");
} }
delete clientData.pizzaDay; delete clientData.pizzaDay;
const today = formatDate(getToday()); const today = formatDate(getToday());
@@ -113,10 +113,10 @@ export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize
const today = formatDate(getToday()); const today = formatDate(getToday());
const clientData = await getClientData(getToday()); const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) { if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED); throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
} }
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login); let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
if (!order) { if (!order) {
@@ -152,10 +152,10 @@ export async function addSalatOrder(login: string, salat: Salat) {
const today = formatDate(getToday()); const today = formatDate(getToday());
const clientData = await getClientData(getToday()); const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) { if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED); throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
} }
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login); let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
if (!order) { if (!order) {
@@ -222,16 +222,16 @@ export async function removePizzaOrder(login: string, pizzaOrder: PizzaVariant)
const today = formatDate(getToday()); const today = formatDate(getToday());
const clientData = await getClientData(getToday()); const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login); const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login);
if (orderIndex < 0) { if (orderIndex < 0) {
throw new Error("Nebyly nalezeny žádné objednávky pro uživatele " + login); throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login);
} }
const order = clientData.pizzaDay.orders![orderIndex]; const order = clientData.pizzaDay.orders![orderIndex];
const index = order.pizzaList!.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size); const index = order.pizzaList!.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size);
if (index < 0) { if (index < 0) {
throw new Error("Objednávka s danými parametry nebyla nalezena"); throw Error("Objednávka s danými parametry nebyla nalezena");
} }
const price = order.pizzaList![index].price; const price = order.pizzaList![index].price;
order.pizzaList!.splice(index, 1); order.pizzaList!.splice(index, 1);
@@ -253,13 +253,13 @@ export async function lockPizzaDay(login: string) {
const today = formatDate(getToday()); const today = formatDate(getToday());
const clientData = await getClientData(getToday()); const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
if (clientData.pizzaDay.creator !== login) { if (clientData.pizzaDay.creator !== login) {
throw new Error("Pizza day není spravován uživatelem " + login); throw Error("Pizza day není spravován uživatelem " + login);
} }
if (clientData.pizzaDay.state !== PizzaDayState.CREATED && clientData.pizzaDay.state !== PizzaDayState.ORDERED) { if (clientData.pizzaDay.state !== PizzaDayState.CREATED && clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED + " nebo " + PizzaDayState.ORDERED); throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED + " nebo " + PizzaDayState.ORDERED);
} }
clientData.pizzaDay.state = PizzaDayState.LOCKED; clientData.pizzaDay.state = PizzaDayState.LOCKED;
await storage.setData(today, clientData); await storage.setData(today, clientData);
@@ -276,13 +276,13 @@ export async function unlockPizzaDay(login: string) {
const today = formatDate(getToday()); const today = formatDate(getToday());
const clientData = await getClientData(getToday()); const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
if (clientData.pizzaDay.creator !== login) { if (clientData.pizzaDay.creator !== login) {
throw new Error("Pizza day není spravován uživatelem " + login); throw Error("Pizza day není spravován uživatelem " + login);
} }
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) { if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
throw new Error("Pizza day není ve stavu " + PizzaDayState.LOCKED); throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
} }
clientData.pizzaDay.state = PizzaDayState.CREATED; clientData.pizzaDay.state = PizzaDayState.CREATED;
await storage.setData(today, clientData); await storage.setData(today, clientData);
@@ -299,13 +299,13 @@ export async function finishPizzaOrder(login: string) {
const today = formatDate(getToday()); const today = formatDate(getToday());
const clientData = await getClientData(getToday()); const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
if (clientData.pizzaDay.creator !== login) { if (clientData.pizzaDay.creator !== login) {
throw new Error("Pizza day není spravován uživatelem " + login); throw Error("Pizza day není spravován uživatelem " + login);
} }
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) { if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
throw new Error("Pizza day není ve stavu " + PizzaDayState.LOCKED); throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
} }
clientData.pizzaDay.state = PizzaDayState.ORDERED; clientData.pizzaDay.state = PizzaDayState.ORDERED;
await storage.setData(today, clientData); await storage.setData(today, clientData);
@@ -324,13 +324,13 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
const today = formatDate(getToday()); const today = formatDate(getToday());
const clientData = await getClientData(getToday()); const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
if (clientData.pizzaDay.creator !== login) { if (clientData.pizzaDay.creator !== login) {
throw new Error("Pizza day není spravován uživatelem " + login); throw Error("Pizza day není spravován uživatelem " + login);
} }
if (clientData.pizzaDay.state !== PizzaDayState.ORDERED) { if (clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
throw new Error("Pizza day není ve stavu " + PizzaDayState.ORDERED); throw Error("Pizza day není ve stavu " + PizzaDayState.ORDERED);
} }
clientData.pizzaDay.state = PizzaDayState.DELIVERED; clientData.pizzaDay.state = PizzaDayState.DELIVERED;
@@ -342,7 +342,7 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
let message = order.pizzaList!.map(item => let message = order.pizzaList!.map(item =>
item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})` item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})`
).join(', '); ).join(', ');
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice / 100, message, id); await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message, id);
order.hasQr = true; order.hasQr = true;
// Uložíme nevyřízený QR kód pro persistentní zobrazení // Uložíme nevyřízený QR kód pro persistentní zobrazení
await addPendingQr(order.customer, { await addPendingQr(order.customer, {
@@ -370,14 +370,14 @@ export async function updatePizzaDayNote(login: string, note?: string) {
const today = formatDate(getToday()); const today = formatDate(getToday());
let clientData = await getClientData(getToday()); let clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) { if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED); throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
} }
const myOrder = clientData.pizzaDay.orders!.find(o => o.customer === login); const myOrder = clientData.pizzaDay.orders!.find(o => o.customer === login);
if (!myOrder?.pizzaList?.length) { if (!myOrder?.pizzaList?.length) {
throw new Error("Pizza day neobsahuje žádné objednávky uživatele " + login); throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
} }
myOrder.note = note; myOrder.note = note;
await storage.setData(today, clientData); await storage.setData(today, clientData);
@@ -397,17 +397,17 @@ export async function updatePizzaFee(login: string, targetLogin: string, text?:
const today = formatDate(getToday()); const today = formatDate(getToday());
let clientData = await getClientData(getToday()); let clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) { if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw new Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`); throw Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`);
} }
if (clientData.pizzaDay.creator !== login) { if (clientData.pizzaDay.creator !== login) {
throw new Error("Příplatky může měnit pouze zakladatel Pizza day"); throw Error("Příplatky může měnit pouze zakladatel Pizza day");
} }
const targetOrder = clientData.pizzaDay.orders!.find(o => o.customer === targetLogin); const targetOrder = clientData.pizzaDay.orders!.find(o => o.customer === targetLogin);
if (!targetOrder?.pizzaList?.length) { if (!targetOrder?.pizzaList?.length) {
throw new Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`); throw Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
} }
if (!price) { if (!price) {
delete targetOrder.fee; delete targetOrder.fee;
@@ -449,27 +449,10 @@ export async function getPendingQrs(login: string): Promise<PendingQr[]> {
/** /**
* Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených). * Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených).
* Vrátí odstraněný QR kód, pokud byl nalezen.
*/ */
export async function dismissPendingQr(login: string, id: string): Promise<PendingQr | undefined> { export async function dismissPendingQr(login: string, id: string): Promise<void> {
const key = getPendingQrKey(login); const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? []; const existing = await storage.getData<PendingQr[]>(key) ?? [];
const dismissed = existing.find(qr => qr.id === id);
const filtered = existing.filter(qr => qr.id !== id); const filtered = existing.filter(qr => qr.id !== id);
await storage.setData(key, filtered); await storage.setData(key, filtered);
return dismissed;
}
/**
* Odstraní všechny nevyřízené QR kódy patřící dané skupině objednávky.
*/
export async function removePendingQrsByGroupId(logins: string[], groupId: string): Promise<void> {
for (const login of logins) {
const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? [];
const filtered = existing.filter(qr => qr.groupId !== groupId);
if (filtered.length !== existing.length) {
await storage.setData(key, filtered);
}
}
} }
+8 -13
View File
@@ -1,5 +1,4 @@
import webpush from 'web-push'; import webpush from 'web-push';
import crypto from 'crypto';
import getStorage from './storage'; import getStorage from './storage';
import { getClientData, getToday } from './service'; import { getClientData, getToday } from './service';
import { getIsWeekend } from './utils'; import { getIsWeekend } from './utils';
@@ -66,19 +65,16 @@ export function getVapidPublicKey(): string | undefined {
return process.env.VAPID_PUBLIC_KEY; return process.env.VAPID_PUBLIC_KEY;
} }
function generateQuickChoiceToken(login: string): string { /** Najde login uživatele podle push subscription endpointu. */
const today = new Date().toISOString().slice(0, 10); export async function findLoginByEndpoint(endpoint: string): Promise<string | undefined> {
const secret = process.env.JWT_SECRET ?? ''; const registry = await getRegistry();
return crypto.createHmac('sha256', secret).update(`${login}:${today}`).digest('hex'); for (const [login, entry] of Object.entries(registry)) {
if (entry.subscription.endpoint === endpoint) {
return login;
} }
/** Ověří jednorázový token z push notifikace. */
export function verifyQuickChoiceToken(login: string, token: string): boolean {
if (!login || !token || token.length !== 64) return false;
const expected = generateQuickChoiceToken(login);
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex'));
} }
return undefined;
}
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */ /** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
async function checkAndSendReminders(): Promise<void> { async function checkAndSendReminders(): Promise<void> {
@@ -129,7 +125,6 @@ async function checkAndSendReminders(): Promise<void> {
title: 'Luncher', title: 'Luncher',
body: 'Ještě nemáte zvolený oběd!', body: 'Ještě nemáte zvolený oběd!',
login, login,
token: generateQuickChoiceToken(login),
}) })
); );
lastReminded.set(login, Date.now()); lastReminded.set(login, Date.now());
+7 -5
View File
@@ -31,10 +31,10 @@ export function convertBbanToIban(bankAccountNumber: string): string {
// Zatím napevno, nemá smysl řešit nic jiného než CZ // Zatím napevno, nemá smysl řešit nic jiného než CZ
iban = iban.replace('C', '12').replace('Z', '35'); iban = iban.replace('C', '12').replace('Z', '35');
const remainder = BigInt(iban) % BigInt(97); const remainder = BigInt(iban) % BigInt(97);
const checkDigits = (BigInt(98) - remainder).toString().padStart(2, '0'); const checkDigits = BigInt(98) - remainder;
iban = `${COUNTRY_CODE}${checkDigits}${bankCode}${prefix}${accountNumber}`; iban = `${COUNTRY_CODE}${checkDigits.toString()}${bankCode}${prefix}${accountNumber}`;
if (iban.length !== 24) { if (iban.length !== 24) {
throw new Error("Neplatná délka sestaveného IBAN: " + iban.length + ", očekáváno 24"); throw Error("Neplatná délka sestaveného IBAN: " + iban.length + ", očekáváno 24");
} }
return iban; return iban;
} }
@@ -56,8 +56,10 @@ function createStorageKey(customerName: string, id: string): string {
* @param id unikátní identifikátor (UUID) tohoto QR kódu * @param id unikátní identifikátor (UUID) tohoto QR kódu
*/ */
export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string, id: string): Promise<void> { export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string, id: string): Promise<void> {
// Zpráva nesmí obsahovat '*' a znaky mimo ISO 8859-1; délka max. 60 znaků // Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků
message = message.replace(/[^\x00-\xff]/g, '').replace(/\*/g, ''); if (message.indexOf('*') >= 0) {
message = message.replace(/\*/g, '');
}
if (message.length > 60) { if (message.length > 60) {
message = message.substring(0, 60); message = message.substring(0, 60);
} }
+1 -1
View File
@@ -189,7 +189,7 @@ router.post("/testPush", async (req, res, next) => {
webpush.setVapidDetails(subject, publicKey, privateKey); webpush.setVapidDetails(subject, publicKey, privateKey);
await webpush.sendNotification( await webpush.sendNotification(
entry.subscription, entry.subscription,
JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!', login }) JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!' })
); );
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
+11 -16
View File
@@ -56,23 +56,23 @@ function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean {
*/ */
const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"]>) => { const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"]>) => {
if (req.body.dayIndex == null) { if (req.body.dayIndex == null) {
throw new Error(`Nebyl předán index dne v týdnu.`); throw Error(`Nebyl předán index dne v týdnu.`);
} }
const todayDayIndex = getDayOfWeekIndex(getToday()); const todayDayIndex = getDayOfWeekIndex(getToday());
const dayIndex = req.body.dayIndex; const dayIndex = req.body.dayIndex;
if (isNaN(dayIndex)) { if (isNaN(dayIndex)) {
throw new Error(`Neplatný index dne v týdnu: ${req.body.dayIndex}`); throw Error(`Neplatný index dne v týdnu: ${req.body.dayIndex}`);
} }
if (dayIndex < todayDayIndex) { if (dayIndex < todayDayIndex) {
throw new Error(`Předaný index dne v týdnu (${dayIndex}) nesmí být nižší než dnešní den (${todayDayIndex})`); throw Error(`Předaný index dne v týdnu (${dayIndex}) nesmí být nižší než dnešní den (${todayDayIndex})`);
} }
return dayIndex; return dayIndex;
} }
const parseSlot = (body: Record<string, any>): MealSlot | undefined => { const parseSlot = (body: Record<string, any>): MealSlot | undefined => {
const slot = body?.slot; const slot = body?.slot;
if (slot != null && slot !== MealSlot.OBED) { if (slot != null && slot !== MealSlot.OBED && slot !== MealSlot.EXTRA) {
throw new Error(`Neplatný slot: ${slot}`); throw Error(`Neplatný slot: ${slot}`);
} }
return slot ?? undefined; return slot ?? undefined;
}; };
@@ -82,8 +82,7 @@ const router = express.Router();
router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => { router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
let slot: MealSlot | undefined; const slot = parseSlot(req.body);
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
let date = undefined; let date = undefined;
if (req.body.dayIndex != null) { if (req.body.dayIndex != null) {
let dayIndex; let dayIndex;
@@ -104,8 +103,7 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => { router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
let slot: MealSlot | undefined; const slot = parseSlot(req.body);
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
let date = undefined; let date = undefined;
if (req.body.dayIndex != null) { if (req.body.dayIndex != null) {
let dayIndex; let dayIndex;
@@ -126,8 +124,7 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => { router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
let slot: MealSlot | undefined; const slot = parseSlot(req.body);
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
let date = undefined; let date = undefined;
if (req.body.dayIndex != null) { if (req.body.dayIndex != null) {
let dayIndex; let dayIndex;
@@ -149,11 +146,10 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
const note = req.body.note; const note = req.body.note;
let slot: MealSlot | undefined; const slot = parseSlot(req.body);
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
try { try {
if (note && note.length > 70) { if (note && note.length > 70) {
throw new Error("Poznámka může mít maximálně 70 znaků"); throw Error("Poznámka může mít maximálně 70 znaků");
} }
let date = undefined; let date = undefined;
if (req.body.dayIndex != null) { if (req.body.dayIndex != null) {
@@ -200,8 +196,7 @@ router.post("/jdemeObed", async (req, res, next) => {
router.post("/updateBuyer", async (req, res, next) => { router.post("/updateBuyer", async (req, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
let slot: MealSlot | undefined; const slot = parseSlot(req.body ?? {});
try { slot = parseSlot(req.body ?? {}); } catch (e: any) { return res.status(400).json({ error: e.message }); }
try { try {
const data = await updateBuyer(login, slot); const data = await updateBuyer(login, slot);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
-156
View File
@@ -1,156 +0,0 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getWebsocket } from "../websocket";
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees } from "../groups";
import { GroupState } from "../../../types/gen/types.gen";
const router = express.Router();
function broadcastExtra(data: any) {
getWebsocket().emit("message", data);
}
router.post("/create", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { name } = req.body ?? {};
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Nebyl předán název skupiny' });
}
try {
const data = await createGroup(login, name);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
router.post("/delete", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
try {
const data = await deleteGroup(login, id);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
router.post("/addMember", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id, login: targetLogin } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
if (targetLogin !== undefined && (typeof targetLogin !== 'string' || targetLogin.trim() === '')) {
return res.status(400).json({ error: 'Neplatný login uživatele' });
}
const target = targetLogin ?? login;
try {
const data = await addGroupMember(login, id, target);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
router.post("/removeMember", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id, login: targetLogin } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
try {
const data = await removeGroupMember(login, id, targetLogin);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
router.post("/updateMember", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id, login: targetLogin, amount, note, surchargeText, surchargeAmount } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
const patch: Record<string, any> = {};
if (amount !== undefined) {
if (!Number.isInteger(amount) || amount < 0) {
return res.status(400).json({ error: 'Neplatná částka' });
}
patch.amount = amount;
}
if (note !== undefined) {
if (typeof note !== 'string') return res.status(400).json({ error: 'Neplatná poznámka' });
patch.note = note;
}
if (surchargeText !== undefined) {
if (typeof surchargeText !== 'string') return res.status(400).json({ error: 'Neplatný text příplatku' });
patch.surchargeText = surchargeText;
}
if (surchargeAmount !== undefined) {
if (!Number.isInteger(surchargeAmount) || surchargeAmount < 0) {
return res.status(400).json({ error: 'Neplatná výše příplatku' });
}
patch.surchargeAmount = surchargeAmount;
}
try {
const data = await updateGroupMember(login, id, targetLogin, patch);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
router.post("/setState", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id, state } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
if (!state || !Object.values(GroupState).includes(state)) {
return res.status(400).json({ error: 'Neplatný stav skupiny' });
}
try {
const data = await setGroupState(login, id, state as GroupState);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
router.post("/updateFees", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
if (fees !== undefined && (!Number.isInteger(fees) || fees < 0)) {
return res.status(400).json({ error: 'Neplatná výše poplatků' });
}
if (shipping !== undefined && (!Number.isInteger(shipping) || shipping < 0)) {
return res.status(400).json({ error: 'Neplatná výše dopravy' });
}
if (tip !== undefined && (!Number.isInteger(tip) || tip < 0)) {
return res.status(400).json({ error: 'Neplatná výše spropitného' });
}
if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) {
return res.status(400).json({ error: 'Neplatný typ slevy' });
}
if (discountValue !== undefined && (!Number.isInteger(discountValue) || discountValue < 0)) {
return res.status(400).json({ error: 'Neplatná výše slevy' });
}
try {
const data = await updateGroupFees(login, id, fees, shipping, tip, discountType, discountValue);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
router.post("/updateTimes", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id, orderedAt, deliveryAt } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
const timeRegex = /^([01]\d|2[0-3]):[0-5]\d$/;
if (orderedAt !== undefined && orderedAt !== '' && !timeRegex.test(orderedAt)) {
return res.status(400).json({ error: 'Neplatný formát času objednání (očekáváno HH:MM)' });
}
if (deliveryAt !== undefined && deliveryAt !== '' && !timeRegex.test(deliveryAt)) {
return res.status(400).json({ error: 'Neplatný formát času doručení (očekáváno HH:MM)' });
}
try {
const data = await updateGroupTimes(login, id, orderedAt, deliveryAt);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
export default router;
+15
View File
@@ -3,6 +3,8 @@ 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 { subscribePush, unsubscribePush, getVapidPublicKey } from "../pushReminder";
import { addChoice } from "../service";
import { getWebsocket } from "../websocket";
import { UpdateNotificationSettingsData } from "../../../types"; import { UpdateNotificationSettingsData } from "../../../types";
const router = express.Router(); const router = express.Router();
@@ -64,4 +66,17 @@ router.post("/push/unsubscribe", async (req, res, next) => {
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
/** Rychlá akce z push notifikace — nastaví volbu bez JWT (identita přes login v payloadu). */
router.post("/push/quickChoice", async (req, res, next) => {
try {
const { login } = req.body;
if (!login) {
return res.status(400).json({ error: "Nebyl předán login" });
}
const data = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
getWebsocket().emit("message", data);
res.status(200).json({});
} catch (e: any) { next(e) }
});
export default router; export default router;
+10 -15
View File
@@ -1,7 +1,6 @@
import express, { Request } from "express"; import express, { Request } from "express";
import { getLogin } from "../auth"; import { getLogin } from "../auth";
import { createPizzaDay, deletePizzaDay, getPizzaList, getSalatList, addPizzaOrder, addSalatOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza"; import { createPizzaDay, deletePizzaDay, getPizzaList, getSalatList, addPizzaOrder, addSalatOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza";
import { markGroupMemberPaid } from "../groups";
import { parseToken } from "../utils"; import { parseToken } from "../utils";
import { getWebsocket } from "../websocket"; import { getWebsocket } from "../websocket";
import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types"; import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types";
@@ -30,10 +29,10 @@ router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) =>
const salatIndex = req.body.salatIndex; const salatIndex = req.body.salatIndex;
const salaty = await getSalatList(); const salaty = await getSalatList();
if (!salaty) { if (!salaty) {
throw new Error("Selhalo získání seznamu dostupných salátů."); throw Error("Selhalo získání seznamu dostupných salátů.");
} }
if (!salaty[salatIndex]) { if (!salaty[salatIndex]) {
throw new Error("Neplatný index salátu: " + salatIndex); throw Error("Neplatný index salátu: " + salatIndex);
} }
const data = await addSalatOrder(login, salaty[salatIndex]); const data = await addSalatOrder(login, salaty[salatIndex]);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
@@ -41,22 +40,22 @@ router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) =>
} else { } else {
// Přidání pizzy // Přidání pizzy
if (req.body?.pizzaIndex === undefined || isNaN(req.body.pizzaIndex)) { if (req.body?.pizzaIndex === undefined || isNaN(req.body.pizzaIndex)) {
throw new Error("Nebyl předán index pizzy ani salátu"); throw Error("Nebyl předán index pizzy ani salátu");
} }
const pizzaIndex = req.body.pizzaIndex; const pizzaIndex = req.body.pizzaIndex;
if (req.body?.pizzaSizeIndex === undefined || isNaN(req.body.pizzaSizeIndex)) { if (req.body?.pizzaSizeIndex === undefined || isNaN(req.body.pizzaSizeIndex)) {
throw new Error("Nebyl předán index velikosti pizzy"); throw Error("Nebyl předán index velikosti pizzy");
} }
const pizzaSizeIndex = req.body.pizzaSizeIndex; const pizzaSizeIndex = req.body.pizzaSizeIndex;
let pizzy = await getPizzaList(); let pizzy = await getPizzaList();
if (!pizzy) { if (!pizzy) {
throw new Error("Selhalo získání seznamu dostupných pizz."); throw Error("Selhalo získání seznamu dostupných pizz.");
} }
if (!pizzy[pizzaIndex]) { if (!pizzy[pizzaIndex]) {
throw new Error("Neplatný index pizzy: " + pizzaIndex); throw Error("Neplatný index pizzy: " + pizzaIndex);
} }
if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) { if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) {
throw new Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex); throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
} }
const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]); const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
@@ -67,7 +66,7 @@ router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) =>
router.post("/remove", async (req: Request<{}, any, RemovePizzaData["body"]>, res) => { router.post("/remove", async (req: Request<{}, any, RemovePizzaData["body"]>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
if (!req.body?.pizzaOrder) { if (!req.body?.pizzaOrder) {
throw new Error("Nebyla předána objednávka"); throw Error("Nebyla předána objednávka");
} }
const data = await removePizzaOrder(login, req.body?.pizzaOrder); const data = await removePizzaOrder(login, req.body?.pizzaOrder);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
@@ -106,7 +105,7 @@ router.post("/updatePizzaDayNote", async (req: Request<{}, any, UpdatePizzaDayNo
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
try { try {
if (req.body.note && req.body.note.length > 70) { if (req.body.note && req.body.note.length > 70) {
throw new Error("Poznámka může mít maximálně 70 znaků"); throw Error("Poznámka může mít maximálně 70 znaků");
} }
const data = await updatePizzaDayNote(login, req.body.note); const data = await updatePizzaDayNote(login, req.body.note);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
@@ -133,11 +132,7 @@ router.post("/dismissQr", async (req: Request<{}, any, DismissQrData["body"]>, r
return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" }); return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" });
} }
try { try {
const dismissed = await dismissPendingQr(login, req.body.id); await dismissPendingQr(login, req.body.id);
if (dismissed?.groupId) {
const updatedExtra = await markGroupMemberPaid(login, dismissed.groupId);
if (updatedExtra) getWebsocket().emit("message", updatedExtra);
}
res.status(200).json({}); res.status(200).json({});
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
+11 -15
View File
@@ -3,8 +3,6 @@ import { getLogin } from "../auth";
import { parseToken, formatDate } from "../utils"; import { parseToken, formatDate } from "../utils";
import { generateQr } from "../qr"; import { generateQr } from "../qr";
import { addPendingQr } from "../pizza"; import { addPendingQr } from "../pizza";
import { markGroupQrGenerated } from "../groups";
import { emitToUser } from "../websocket";
import { GenerateQrData } from "../../../types"; import { GenerateQrData } from "../../../types";
import crypto from "crypto"; import crypto from "crypto";
@@ -16,7 +14,7 @@ const router = express.Router();
router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res, next) => { router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
try { try {
const { recipients, bankAccount, bankAccountHolder, groupId } = req.body; const { recipients, bankAccount, bankAccountHolder } = req.body;
if (!recipients || !Array.isArray(recipients) || recipients.length === 0) { if (!recipients || !Array.isArray(recipients) || recipients.length === 0) {
return res.status(400).json({ error: "Nebyl předán seznam příjemců" }); return res.status(400).json({ error: "Nebyl předán seznam příjemců" });
@@ -37,29 +35,27 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r
if (!recipient.purpose || recipient.purpose.trim().length === 0) { if (!recipient.purpose || recipient.purpose.trim().length === 0) {
return res.status(400).json({ error: `Příjemce ${recipient.login} nemá vyplněný účel platby` }); return res.status(400).json({ error: `Příjemce ${recipient.login} nemá vyplněný účel platby` });
} }
if (!Number.isInteger(recipient.amount) || recipient.amount <= 0) { if (typeof recipient.amount !== 'number' || recipient.amount <= 0) {
return res.status(400).json({ error: `Příjemce ${recipient.login} má neplatnou částku` }); return res.status(400).json({ error: `Příjemce ${recipient.login} má neplatnou částku` });
} }
// Validace max 2 desetinná místa
const amountStr = recipient.amount.toString();
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
return res.status(400).json({ error: `Částka pro ${recipient.login} má více než 2 desetinná místa` });
}
// Vygenerovat QR kód // Vygenerovat QR kód
const id = crypto.randomUUID(); const id = crypto.randomUUID();
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount / 100, recipient.purpose, id); await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose, id);
// Uložit jako nevyřízený QR kód a okamžitě doručit příjemci // Uložit jako nevyřízený QR kód
const pendingQr = { await addPendingQr(recipient.login, {
id, id,
date: today, date: today,
creator: login, creator: login,
totalPrice: recipient.amount, totalPrice: recipient.amount,
purpose: recipient.purpose, purpose: recipient.purpose,
...(groupId ? { groupId } : {}), });
};
await addPendingQr(recipient.login, pendingQr);
emitToUser(recipient.login, 'pendingQr', pendingQr);
}
if (groupId) {
await markGroupQrGenerated(login, groupId);
} }
res.status(200).json({ success: true, count: recipients.length }); res.status(200).json({ success: true, count: recipients.length });
-51
View File
@@ -1,51 +0,0 @@
import express from "express";
import { getStores, addStore, removeStore } from "../stores";
const router = express.Router();
router.get("/", async (_req, res, next) => {
try {
const stores = await getStores();
res.status(200).json(stores);
} catch (e: any) { next(e); }
});
router.post("/add", async (req, res, next) => {
const { name, heslo } = req.body ?? {};
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
}
if (!heslo || typeof heslo !== 'string') {
return res.status(400).json({ error: 'Nebylo předáno heslo' });
}
try {
const stores = await addStore(name, heslo);
res.status(200).json(stores);
} catch (e: any) {
if (e.message === 'UNAUTHORIZED') {
return res.status(403).json({ error: 'Nesprávné heslo' });
}
next(e);
}
});
router.post("/delete", async (req, res, next) => {
const { name, heslo } = req.body ?? {};
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
}
if (!heslo || typeof heslo !== 'string') {
return res.status(400).json({ error: 'Nebylo předáno heslo' });
}
try {
const stores = await removeStore(name, heslo);
res.status(200).json(stores);
} catch (e: any) {
if (e.message === 'UNAUTHORIZED') {
return res.status(403).json({ error: 'Nesprávné heslo' });
}
next(e);
}
});
export default router;
+9 -12
View File
@@ -3,7 +3,6 @@ import getStorage from "./storage";
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants"; import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants";
import { getTodayMock } from "./mock"; import { getTodayMock } from "./mock";
import { removeAllUserPizzas } from "./pizza"; import { removeAllUserPizzas } from "./pizza";
import { getStores } from "./stores";
import { ClientData, DepartureTime, LunchChoice, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen"; import { ClientData, DepartureTime, LunchChoice, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
const storage = getStorage(); const storage = getStorage();
@@ -51,9 +50,7 @@ export function getEmptyData(date?: Date): ClientData {
*/ */
export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData> { export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData> {
const clientData = await getClientData(date, slot); const clientData = await getClientData(date, slot);
if (slot === MealSlot.EXTRA) { if (slot !== MealSlot.EXTRA) {
clientData.stores = await getStores();
} else {
clientData.menus = { clientData.menus = {
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date), SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date), // UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
@@ -62,6 +59,7 @@ export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData>
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date), SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
} }
} }
if (slot === MealSlot.EXTRA) clientData.slot = MealSlot.EXTRA;
return clientData; return clientData;
} }
@@ -485,13 +483,13 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, date?: Date) { async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, date?: Date) {
if (foodIndex != null) { if (foodIndex != null) {
if (typeof foodIndex !== 'number') { if (typeof foodIndex !== 'number') {
throw new Error(`Neplatný index ${foodIndex} typu ${typeof foodIndex}`); throw Error(`Neplatný index ${foodIndex} typu ${typeof foodIndex}`);
} }
if (foodIndex < 0) { if (foodIndex < 0) {
throw new Error(`Neplatný index ${foodIndex}`); throw Error(`Neplatný index ${foodIndex}`);
} }
if (!Object.keys(Restaurant).includes(locationKey)) { if (!Object.keys(Restaurant).includes(locationKey)) {
throw new Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey} nepodporující indexy`); throw Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey} nepodporující indexy`);
} }
const usedDate = date ?? getToday(); const usedDate = date ?? getToday();
const menu = await getRestaurantMenu(locationKey as Restaurant, usedDate); const menu = await getRestaurantMenu(locationKey as Restaurant, usedDate);
@@ -533,9 +531,9 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
* @param time preferovaný čas odchodu * @param time preferovaný čas odchodu
* @param date datum, ke kterému se čas vztahuje * @param date datum, ke kterému se čas vztahuje
*/ */
export async function updateDepartureTime(login: string, time?: string, date?: Date) { export async function updateDepartureTime(login: string, time?: string, date?: Date, slot?: MealSlot) {
const usedDate = date ?? getToday(); const usedDate = date ?? getToday();
let clientData = await getClientData(usedDate); let clientData = await getClientData(usedDate, slot);
const found = Object.values(clientData.choices).find(location => login in location); const found = Object.values(clientData.choices).find(location => login in location);
// TODO validace, že se jedná o restauraci // TODO validace, že se jedná o restauraci
if (found) { if (found) {
@@ -543,11 +541,11 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
delete found[login].departureTime; delete found[login].departureTime;
} else { } else {
if (!Object.values<string>(DepartureTime).includes(time)) { if (!Object.values<string>(DepartureTime).includes(time)) {
throw new Error(`Neplatný čas odchodu ${time}`); throw Error(`Neplatný čas odchodu ${time}`);
} }
found[login].departureTime = time; found[login].departureTime = time;
} }
await storage.setData(getDataKey(usedDate), clientData); await storage.setData(getDataKey(usedDate, slot), clientData);
} }
return clientData; return clientData;
} }
@@ -583,6 +581,5 @@ export async function getClientData(date?: Date, slot?: MealSlot): Promise<Clien
return { return {
...clientData, ...clientData,
todayDayIndex: getDayOfWeekIndex(getToday()), todayDayIndex: getDayOfWeekIndex(getToday()),
...(slot === MealSlot.EXTRA ? { slot: MealSlot.EXTRA } : {}),
} }
} }
+2 -2
View File
@@ -22,13 +22,13 @@ export async function getStats(startDate: string, endDate: string): Promise<Week
// Dočasná validace, aby to někdo ručně neshodil // Dočasná validace, aby to někdo ručně neshodil
const daysDiff = ((end as any) - (start as any)) / (1000 * 60 * 60 * 24); const daysDiff = ((end as any) - (start as any)) / (1000 * 60 * 60 * 24);
if (daysDiff > 4) { if (daysDiff > 4) {
throw new Error('Neplatný rozsah'); throw Error('Neplatný rozsah');
} }
const today = new Date(); const today = new Date();
today.setHours(23, 59, 59, 999); today.setHours(23, 59, 59, 999);
if (end > today) { if (end > today) {
throw new Error('Nelze načíst statistiky pro budoucí datum'); throw Error('Nelze načíst statistiky pro budoucí datum');
} }
const result = []; const result = [];
+1 -1
View File
@@ -20,7 +20,7 @@ if (!process.env.STORAGE || process.env.STORAGE?.toLowerCase() === JSON_KEY) {
} else if (process.env.STORAGE?.toLowerCase() === MEMORY_KEY) { } else if (process.env.STORAGE?.toLowerCase() === MEMORY_KEY) {
storage = new MemoryStorage(); storage = new MemoryStorage();
} else { } else {
throw new Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json', 'redis' nebo 'memory'"); throw Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json', 'redis' nebo 'memory'");
} }
export const storageReady: Promise<void> = storage.initialize export const storageReady: Promise<void> = storage.initialize
-37
View File
@@ -1,37 +0,0 @@
import getStorage from "./storage";
const storage = getStorage();
const STORES_KEY = 'stores';
export async function getStores(): Promise<string[]> {
return (await storage.getData<string[]>(STORES_KEY)) ?? [];
}
export async function addStore(name: string, heslo: string): Promise<string[]> {
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword || heslo !== adminPassword) {
throw new Error('UNAUTHORIZED');
}
const trimmed = name.trim();
if (!trimmed) {
throw new Error('Název obchodu nesmí být prázdný');
}
const stores = await getStores();
if (stores.some(s => s.toLowerCase() === trimmed.toLowerCase())) {
throw new Error('Obchod s tímto názvem již existuje');
}
const updated = [...stores, trimmed];
await storage.setData(STORES_KEY, updated);
return updated;
}
export async function removeStore(name: string, heslo: string): Promise<string[]> {
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword || heslo !== adminPassword) {
throw new Error('UNAUTHORIZED');
}
const stores = await getStores();
const updated = stores.filter(s => s.toLowerCase() !== name.trim().toLowerCase());
await storage.setData(STORES_KEY, updated);
return updated;
}
+4 -4
View File
@@ -31,8 +31,8 @@ test('saláty mají name a ingredients', async () => {
test('cena salátu zahrnuje pevný příplatek 13 Kč za obal', async () => { test('cena salátu zahrnuje pevný příplatek 13 Kč za obal', async () => {
const salaty = await downloadSalaty(false); const salaty = await downloadSalaty(false);
// Caesar sticker price = 129, box = 13 → (129 + 13) * 100 haléřů // Caesar sticker price = 129, box = 13
expect(salaty[0].price).toBe((129 + 13) * 100); expect(salaty[0].price).toBe(129 + 13);
// Řecký sticker price = 119, box = 13 → (119 + 13) * 100 haléřů // Řecký sticker price = 119, box = 13
expect(salaty[1].price).toBe((119 + 13) * 100); expect(salaty[1].price).toBe(119 + 13);
}); });
-195
View File
@@ -1,195 +0,0 @@
import { resetMemoryStorage } from '../storage/memory';
import { getStores, addStore } from '../stores';
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState } from '../groups';
import { GroupState } from '../../../types/gen/types.gen';
const CREATOR = 'tomas';
const USER = 'petr';
const ADMIN_PW = 'testadmin';
const STORE = 'McDonald\'s';
const TODAY = new Date('2025-01-10');
beforeEach(async () => {
resetMemoryStorage();
process.env.ADMIN_PASSWORD = ADMIN_PW;
await addStore(STORE, ADMIN_PW);
});
afterEach(() => {
delete process.env.ADMIN_PASSWORD;
});
describe('createGroup', () => {
test('vytvoří skupinu, creator je člen', async () => {
const data = await createGroup(CREATOR, STORE, TODAY);
expect(data.groups).toHaveLength(1);
const group = data.groups![0];
expect(group.name).toBe(STORE);
expect(group.creatorLogin).toBe(CREATOR);
expect(group.state).toBe(GroupState.OPEN);
expect(group.members[CREATOR]).toBeDefined();
});
test('odmítne název mimo seznam obchodů', async () => {
await expect(createGroup(CREATOR, 'Neznámý obchod', TODAY)).rejects.toThrow('seznam');
});
test('vygeneruje unikátní ID', async () => {
const d1 = await createGroup(CREATOR, STORE, TODAY);
const d2 = await createGroup(USER, STORE, TODAY);
expect(d2.groups).toHaveLength(2);
expect(d2.groups![1].id).not.toBe(d2.groups![0].id);
});
});
describe('deleteGroup', () => {
test('creator může smazat skupinu', async () => {
const d = await createGroup(CREATOR, STORE, TODAY);
const groupId = d.groups![0].id;
const result = await deleteGroup(CREATOR, groupId, TODAY);
expect(result.groups).toHaveLength(0);
});
test('nečlen nemůže smazat skupinu', async () => {
const d = await createGroup(CREATOR, STORE, TODAY);
const groupId = d.groups![0].id;
await expect(deleteGroup(USER, groupId, TODAY)).rejects.toThrow('zakladatel');
});
test('smazání neexistující skupiny vyhodí chybu', async () => {
await expect(deleteGroup(CREATOR, 'nonexistent', TODAY)).rejects.toThrow('nalezena');
});
});
describe('addGroupMember', () => {
let groupId: string;
beforeEach(async () => {
const d = await createGroup(CREATOR, STORE, TODAY);
groupId = d.groups![0].id;
});
test('uživatel se může přidat sám (open)', async () => {
const d = await addGroupMember(USER, groupId, USER, TODAY);
expect(d.groups![0].members[USER]).toBeDefined();
});
test('creator může přidat jiného uživatele', async () => {
const d = await addGroupMember(CREATOR, groupId, USER, TODAY);
expect(d.groups![0].members[USER]).toBeDefined();
});
test('nečlen nemůže přidat jiného uživatele', async () => {
await expect(addGroupMember(USER, groupId, 'dalsi', TODAY)).rejects.toThrow('zakladatel');
});
test('nelze přidat do skupiny ve stavu ordered', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
await expect(addGroupMember(USER, groupId, USER, TODAY)).rejects.toThrow('objednáno');
});
test('nelze přidat existujícího člena', async () => {
await expect(addGroupMember(CREATOR, groupId, CREATOR, TODAY)).rejects.toThrow('již');
});
});
describe('removeGroupMember', () => {
let groupId: string;
beforeEach(async () => {
const d = await createGroup(CREATOR, STORE, TODAY);
groupId = d.groups![0].id;
await addGroupMember(CREATOR, groupId, USER, TODAY);
});
test('člen se může odhlásit sám', async () => {
const d = await removeGroupMember(USER, groupId, USER, TODAY);
expect(d.groups![0].members[USER]).toBeUndefined();
});
test('creator může odebrat jiného člena', async () => {
const d = await removeGroupMember(CREATOR, groupId, USER, TODAY);
expect(d.groups![0].members[USER]).toBeUndefined();
});
test('nelze odebrat zakladatele', async () => {
await expect(removeGroupMember(CREATOR, groupId, CREATOR, TODAY)).rejects.toThrow('Zakladatel');
});
test('nečlen nemůže odebrat jiného', async () => {
await expect(removeGroupMember(USER, groupId, CREATOR, TODAY)).rejects.toThrow('zakladatel');
});
});
describe('updateGroupMember', () => {
let groupId: string;
beforeEach(async () => {
const d = await createGroup(CREATOR, STORE, TODAY);
groupId = d.groups![0].id;
await addGroupMember(CREATOR, groupId, USER, TODAY);
});
test('člen může upravit svá data (open)', async () => {
const d = await updateGroupMember(USER, groupId, USER, { amount: 150, note: 'Big Mac' }, TODAY);
expect(d.groups![0].members[USER].amount).toBe(150);
expect(d.groups![0].members[USER].note).toBe('Big Mac');
});
test('creator může upravit data jiného člena', async () => {
const d = await updateGroupMember(CREATOR, groupId, USER, { amount: 200 }, TODAY);
expect(d.groups![0].members[USER].amount).toBe(200);
});
test('člen nemůže upravit data jiného (locked)', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await expect(updateGroupMember(USER, groupId, USER, { amount: 100 }, TODAY)).rejects.toThrow('uzamčeno');
});
test('nikdo nemůže upravit při stavu ordered', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
await expect(updateGroupMember(CREATOR, groupId, USER, { amount: 100 }, TODAY)).rejects.toThrow('objednáno');
});
});
describe('setGroupState', () => {
let groupId: string;
beforeEach(async () => {
const d = await createGroup(CREATOR, STORE, TODAY);
groupId = d.groups![0].id;
});
test('open → locked', async () => {
const d = await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
expect(d.groups![0].state).toBe(GroupState.LOCKED);
});
test('locked → open (odemčení)', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
const d = await setGroupState(CREATOR, groupId, GroupState.OPEN, TODAY);
expect(d.groups![0].state).toBe(GroupState.OPEN);
});
test('locked → ordered', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
const d = await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
expect(d.groups![0].state).toBe(GroupState.ORDERED);
});
test('open → ordered není povoleno', async () => {
await expect(setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY)).rejects.toThrow('Nelze přejít');
});
test('ordered je terminální stav', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
await expect(setGroupState(CREATOR, groupId, GroupState.OPEN, TODAY)).rejects.toThrow('Nelze přejít');
});
test('nečlen nemůže měnit stav', async () => {
await expect(setGroupState(USER, groupId, GroupState.LOCKED, TODAY)).rejects.toThrow('zakladatel');
});
});
+5 -5
View File
@@ -28,8 +28,8 @@ beforeEach(() => {
const VALID_BODY = { const VALID_BODY = {
recipients: [ recipients: [
{ login: 'uzivatel1', purpose: 'Pizza Margherita', amount: 14900 }, { login: 'uzivatel1', purpose: 'Pizza Margherita', amount: 149 },
{ login: 'uzivatel2', purpose: 'Pizza Diavola', amount: 17900 }, { login: 'uzivatel2', purpose: 'Pizza Diavola', amount: 179 },
], ],
bankAccount: '19-2000145399/0800', bankAccount: '19-2000145399/0800',
bankAccountHolder: 'Jan Novák', bankAccountHolder: 'Jan Novák',
@@ -76,17 +76,17 @@ test('POST /generate vrátí 400 pro zápornou částku', async () => {
expect(res.body.error).toContain('částku'); expect(res.body.error).toContain('částku');
}); });
test('POST /generate vrátí 400 pro necelou částku', async () => { test('POST /generate vrátí 400 pro částku s více než 2 desetinnými místy', async () => {
const body = { const body = {
...VALID_BODY, ...VALID_BODY,
recipients: [{ login: 'uzivatel1', purpose: 'Pizza', amount: 149.5 }], recipients: [{ login: 'uzivatel1', purpose: 'Pizza', amount: 149.999 }],
}; };
const res = await request(buildApp()) const res = await request(buildApp())
.post('/api/qr/generate') .post('/api/qr/generate')
.set('Authorization', TOKEN) .set('Authorization', TOKEN)
.send(body); .send(body);
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body.error).toContain('částku'); expect(res.body.error).toContain('desetinná');
}); });
test('POST /generate vrátí 400 pro příjemce bez login', async () => { test('POST /generate vrátí 400 pro příjemce bez login', async () => {
+2 -3
View File
@@ -27,9 +27,8 @@ describe('MealSlot storage isolation', () => {
jest.useRealTimers(); jest.useRealTimers();
}); });
test('addChoice slot=extra writes only to _extra key, not to obed key, and returns slot=EXTRA', async () => { test('addChoice slot=extra writes only to _extra key, not to obed key', async () => {
const result = await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA); await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA);
expect(result.slot).toBe(MealSlot.EXTRA);
expect(mockStorageData.has(TODAY_EXTRA_STR)).toBe(true); expect(mockStorageData.has(TODAY_EXTRA_STR)).toBe(true);
expect(mockStorageData.has(TODAY_STR)).toBe(false); expect(mockStorageData.has(TODAY_STR)).toBe(false);
const extraData = mockStorageData.get(TODAY_EXTRA_STR); const extraData = mockStorageData.get(TODAY_EXTRA_STR);
-78
View File
@@ -1,78 +0,0 @@
import { resetMemoryStorage } from '../storage/memory';
import { getStores, addStore, removeStore } from '../stores';
const ADMIN_PW = 'testadmin';
beforeEach(() => {
resetMemoryStorage();
process.env.ADMIN_PASSWORD = ADMIN_PW;
});
afterEach(() => {
delete process.env.ADMIN_PASSWORD;
});
describe('getStores', () => {
test('vrátí prázdné pole, pokud seznam neexistuje', async () => {
const stores = await getStores();
expect(stores).toEqual([]);
});
});
describe('addStore', () => {
test('přidá obchod se správným heslem', async () => {
const stores = await addStore('McDonald\'s', ADMIN_PW);
expect(stores).toContain('McDonald\'s');
});
test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => {
await expect(addStore('KFC', 'spatne')).rejects.toThrow('UNAUTHORIZED');
});
test('vyhodí UNAUTHORIZED pokud ADMIN_PASSWORD není nastaven', async () => {
delete process.env.ADMIN_PASSWORD;
await expect(addStore('KFC', '')).rejects.toThrow('UNAUTHORIZED');
});
test('odmítne prázdný název', async () => {
await expect(addStore(' ', ADMIN_PW)).rejects.toThrow('prázdný');
});
test('odmítne duplikát (case-insensitive)', async () => {
await addStore('McDonald\'s', ADMIN_PW);
await expect(addStore('mcdonald\'s', ADMIN_PW)).rejects.toThrow('existuje');
});
test('vrátí aktualizovaný seznam', async () => {
await addStore('McDonald\'s', ADMIN_PW);
const stores = await addStore('KFC', ADMIN_PW);
expect(stores).toHaveLength(2);
expect(stores).toContain('McDonald\'s');
expect(stores).toContain('KFC');
});
});
describe('removeStore', () => {
beforeEach(async () => {
await addStore('McDonald\'s', ADMIN_PW);
});
test('odebere obchod se správným heslem', async () => {
const stores = await removeStore('McDonald\'s', ADMIN_PW);
expect(stores).not.toContain('McDonald\'s');
});
test('case-insensitive odebrání', async () => {
const stores = await removeStore('MCDONALD\'S', ADMIN_PW);
expect(stores).not.toContain('McDonald\'s');
});
test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => {
await expect(removeStore('McDonald\'s', 'spatne')).rejects.toThrow('UNAUTHORIZED');
});
test('odebrání neexistujícího obchodu nic nerozbije', async () => {
const stores = await removeStore('Neexistuje', ADMIN_PW);
expect(stores).toContain('McDonald\'s');
});
});
+2 -2
View File
@@ -90,7 +90,7 @@ export const parseToken = (req: any) => {
export const checkQueryParams = (req: any, paramNames: string[]) => { export const checkQueryParams = (req: any, paramNames: string[]) => {
for (const name of paramNames) { for (const name of paramNames) {
if (req.query[name] == null) { if (req.query[name] == null) {
throw new Error(`Nebyl předán parametr '${name}' v query požadavku`); throw Error(`Nebyl předán parametr '${name}' v query požadavku`);
} }
} }
} }
@@ -105,7 +105,7 @@ export const checkQueryParams = (req: any, paramNames: string[]) => {
export const checkBodyParams = (req: any, paramNames: string[]) => { export const checkBodyParams = (req: any, paramNames: string[]) => {
for (const name of paramNames) { for (const name of paramNames) {
if (req.body[name] == null) { if (req.body[name] == null) {
throw new Error(`Nebyl předán parametr '${name}' v těle požadavku`); throw Error(`Nebyl předán parametr '${name}' v těle požadavku`);
} }
} }
} }
+2 -2
View File
@@ -40,7 +40,7 @@ export async function updateFeatureVote(login: string, option: FeatureRequest, a
const index = data[login].indexOf(option); const index = data[login].indexOf(option);
if (index > -1) { if (index > -1) {
if (active) { if (active) {
throw new Error('Pro tuto možnost jste již hlasovali'); throw Error('Pro tuto možnost jste již hlasovali');
} else { } else {
data[login].splice(index, 1); data[login].splice(index, 1);
if (data[login].length === 0) { if (data[login].length === 0) {
@@ -49,7 +49,7 @@ export async function updateFeatureVote(login: string, option: FeatureRequest, a
} }
} else if (active) { } else if (active) {
if (data[login].length == 4) { if (data[login].length == 4) {
throw new Error('Je možné hlasovat pro maximálně 4 možnosti'); throw Error('Je možné hlasovat pro maximálně 4 možnosti');
} }
data[login].push(option); data[login].push(option);
} }
+2 -12
View File
@@ -11,12 +11,6 @@ export const initWebsocket = (server: any) => {
io.on("connection", (socket) => { io.on("connection", (socket) => {
console.log(`New client connected: ${socket.id}`); console.log(`New client connected: ${socket.id}`);
socket.on("join", (login: string) => {
if (login && typeof login === "string") {
socket.join(`user:${login}`);
}
});
socket.on("message", (message) => { socket.on("message", (message) => {
io.emit("message", message); io.emit("message", message);
}); });
@@ -28,10 +22,6 @@ export const initWebsocket = (server: any) => {
return io; return io;
} }
export const getWebsocket = () => io; export const getWebsocket = () => {
return io;
/** Pošle event konkrétnímu přihlášenému uživateli (pokud je připojen). */
export const emitToUser = (login: string, event: string, data: unknown) => {
if (!io) return;
io.to(`user:${login}`).emit(event, data);
} }
-26
View File
@@ -81,32 +81,6 @@ paths:
/changelogs: /changelogs:
$ref: "./paths/changelogs/getChangelogs.yml" $ref: "./paths/changelogs/getChangelogs.yml"
# Skupiny objednávek (/api/groups)
/groups/create:
$ref: "./paths/groups/createGroup.yml"
/groups/delete:
$ref: "./paths/groups/deleteGroup.yml"
/groups/addMember:
$ref: "./paths/groups/addMember.yml"
/groups/removeMember:
$ref: "./paths/groups/removeMember.yml"
/groups/updateMember:
$ref: "./paths/groups/updateMember.yml"
/groups/setState:
$ref: "./paths/groups/setState.yml"
/groups/updateTimes:
$ref: "./paths/groups/updateTimes.yml"
/groups/updateFees:
$ref: "./paths/groups/updateFees.yml"
# Správa obchodů (/api/stores)
/stores:
$ref: "./paths/stores/listStores.yml"
/stores/add:
$ref: "./paths/stores/addStore.yml"
/stores/delete:
$ref: "./paths/stores/deleteStore.yml"
# DEV endpointy (/api/dev) # DEV endpointy (/api/dev)
/dev/generate: /dev/generate:
$ref: "./paths/dev/generate.yml" $ref: "./paths/dev/generate.yml"
-594
View File
@@ -1,594 +0,0 @@
{
"name": "@luncher/types",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@luncher/types",
"version": "1.0.0",
"license": "MIT",
"devDependencies": {
"@hey-api/client-fetch": "^0.8.2",
"@hey-api/openapi-ts": "^0.64.7",
"typescript": "^5.9.3"
}
},
"node_modules/@hey-api/client-fetch": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.8.2.tgz",
"integrity": "sha512-61T4UGfAzY5345vMxWDX8qnSTNRJcOpWuZyvNu3vNebCTLPwMQAM85mhEuBoACdWeRtLhNoUjU0UR5liRyD1bA==",
"deprecated": "Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts.",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/hey-api"
}
},
"node_modules/@hey-api/json-schema-ref-parser": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.2.tgz",
"integrity": "sha512-F6LSkttZcT/XiX3ydeDqTY3uRN3BLJMwyMTk4kg/ichZlKUp3+3Odv0WokSmXGSoZGTW/N66FROMYAm5NPdJlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jsdevtools/ono": "^7.1.3",
"@types/json-schema": "^7.0.15",
"js-yaml": "^4.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/hey-api"
}
},
"node_modules/@hey-api/openapi-ts": {
"version": "0.64.7",
"resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.64.7.tgz",
"integrity": "sha512-xpaBzdGAKz7cPuGah1GZWl3zTZquOXRnwmpVCQKEUpvDtSYWBLjSxtAYIqDBjwJkuNHcakZBIWLcappx7slc2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@hey-api/json-schema-ref-parser": "1.0.2",
"c12": "2.0.1",
"commander": "13.0.0",
"handlebars": "4.7.8"
},
"bin": {
"openapi-ts": "bin/index.cjs"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=22.11.0"
},
"funding": {
"url": "https://github.com/sponsors/hey-api"
},
"peerDependencies": {
"typescript": "^5.5.3"
}
},
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/c12": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/c12/-/c12-2.0.1.tgz",
"integrity": "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.1",
"confbox": "^0.1.7",
"defu": "^6.1.4",
"dotenv": "^16.4.5",
"giget": "^1.2.3",
"jiti": "^2.3.0",
"mlly": "^1.7.1",
"ohash": "^1.1.4",
"pathe": "^1.1.2",
"perfect-debounce": "^1.0.0",
"pkg-types": "^1.2.0",
"rc9": "^2.1.2"
},
"peerDependencies": {
"magicast": "^0.3.5"
},
"peerDependenciesMeta": {
"magicast": {
"optional": true
}
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/citty": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
}
},
"node_modules/commander": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz",
"integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true,
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz",
"integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"dev": true,
"license": "MIT"
},
"node_modules/destr": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.3.tgz",
"integrity": "sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==",
"dev": true,
"license": "MIT"
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
"dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^3.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/fs-minipass/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/giget": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz",
"integrity": "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.4.0",
"defu": "^6.1.4",
"node-fetch-native": "^1.6.6",
"nypm": "^0.5.4",
"pathe": "^2.0.3",
"tar": "^6.2.1"
},
"bin": {
"giget": "dist/cli.mjs"
}
},
"node_modules/giget/node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.2",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=8"
}
},
"node_modules/minizlib": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^3.0.0",
"yallist": "^4.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/minizlib/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"dev": true,
"license": "MIT",
"bin": {
"mkdirp": "bin/cmd.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/mlly": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
"integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.14.0",
"pathe": "^2.0.1",
"pkg-types": "^1.3.0",
"ufo": "^1.5.4"
}
},
"node_modules/mlly/node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true,
"license": "MIT"
},
"node_modules/node-fetch-native": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.6.tgz",
"integrity": "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==",
"dev": true,
"license": "MIT"
},
"node_modules/nypm": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.4.tgz",
"integrity": "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==",
"dev": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.4.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
"tinyexec": "^0.3.2",
"ufo": "^1.5.4"
},
"bin": {
"nypm": "dist/cli.mjs"
},
"engines": {
"node": "^14.16.0 || >=16.10.0"
}
},
"node_modules/nypm/node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/ohash": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.4.tgz",
"integrity": "sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==",
"dev": true,
"license": "MIT"
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"dev": true,
"license": "MIT"
},
"node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
}
},
"node_modules/pkg-types/node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/rc9": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
"destr": "^2.0.3"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^5.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/ufo": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
"integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==",
"dev": true,
"license": "MIT"
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"dev": true,
"license": "MIT"
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true,
"license": "ISC"
}
}
}
-21
View File
@@ -1,21 +0,0 @@
post:
operationId: addGroupMember
summary: Přidá uživatele do skupiny (sebe, nebo jiného jako zakladatel).
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
description: ID skupiny
type: string
login:
description: Login uživatele (volitelné — pokud není zadán, přidá přihlášeného uživatele)
type: string
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"
-18
View File
@@ -1,18 +0,0 @@
post:
operationId: createGroup
summary: Vytvoří novou skupinu objednávky pro aktuální den.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
properties:
name:
description: Název obchodu/restaurace (musí být v seznamu povolených obchodů)
type: string
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"
-18
View File
@@ -1,18 +0,0 @@
post:
operationId: deleteGroup
summary: Smaže skupinu objednávky (pouze zakladatel).
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
description: ID skupiny
type: string
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"
-22
View File
@@ -1,22 +0,0 @@
post:
operationId: removeGroupMember
summary: Odebere uživatele ze skupiny (sebe, nebo jiného jako zakladatel).
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
- login
properties:
id:
description: ID skupiny
type: string
login:
description: Login uživatele k odebrání
type: string
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"
-21
View File
@@ -1,21 +0,0 @@
post:
operationId: setGroupState
summary: Změní stav skupiny (pouze zakladatel).
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
- state
properties:
id:
description: ID skupiny
type: string
state:
$ref: "../../schemas/_index.yml#/GroupState"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"
-34
View File
@@ -1,34 +0,0 @@
post:
operationId: updateGroupFees
summary: Aktualizuje skupinové poplatky a slevu (pouze zakladatel, pouze otevřená skupina).
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
description: ID skupiny
type: string
fees:
description: Poplatky (haléře)
type: integer
shipping:
description: Doprava (haléře)
type: integer
tip:
description: Spropitné (haléře)
type: integer
discountType:
description: Typ slevy
type: string
enum: [percent, fixed]
discountValue:
description: Hodnota slevy (procenta nebo haléře pro fixed)
type: integer
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"
-34
View File
@@ -1,34 +0,0 @@
post:
operationId: updateGroupMember
summary: Aktualizuje data člena skupiny (částka, poznámka, příplatek).
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
- login
properties:
id:
description: ID skupiny
type: string
login:
description: Login člena ke změně
type: string
amount:
description: Částka k úhradě v haléřích
type: integer
note:
description: Poznámka
type: string
surchargeText:
description: Popis příplatku
type: string
surchargeAmount:
description: Výše příplatku v haléřích
type: integer
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"
-24
View File
@@ -1,24 +0,0 @@
post:
operationId: updateGroupTimes
summary: Aktualizuje časy objednání a doručení skupiny (pouze zakladatel).
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
description: ID skupiny
type: string
orderedAt:
description: Čas objednání ve formátu HH:MM
type: string
deliveryAt:
description: Očekávaný čas doručení ve formátu HH:MM
type: string
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"
+2 -2
View File
@@ -16,8 +16,8 @@ post:
type: string type: string
description: Textový popis přirážky/slevy description: Textový popis přirážky/slevy
price: price:
type: integer type: number
description: Částka přirážky/slevy v haléřích description: Částka přirážky/slevy v
responses: responses:
"200": "200":
description: Nastavení přirážky/slevy proběhlo úspěšně. description: Nastavení přirážky/slevy proběhlo úspěšně.
-28
View File
@@ -1,28 +0,0 @@
post:
operationId: addStore
summary: Přidá obchod do seznamu povolených (vyžaduje admin heslo).
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
- heslo
properties:
name:
description: Název obchodu/restaurace
type: string
heslo:
description: Admin heslo (ADMIN_PASSWORD)
type: string
responses:
"200":
description: Obchod byl přidán
content:
application/json:
schema:
type: array
items:
type: string
-28
View File
@@ -1,28 +0,0 @@
post:
operationId: deleteStore
summary: Odebere obchod ze seznamu povolených (vyžaduje admin heslo).
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
- heslo
properties:
name:
description: Název obchodu/restaurace k odebrání
type: string
heslo:
description: Admin heslo (ADMIN_PASSWORD)
type: string
responses:
"200":
description: Obchod byl odebrán
content:
application/json:
schema:
type: array
items:
type: string
-12
View File
@@ -1,12 +0,0 @@
get:
operationId: listStores
summary: Vrátí seznam povolených obchodů/restaurací.
responses:
"200":
description: Seznam obchodů
content:
application/json:
schema:
type: array
items:
type: string
+21 -122
View File
@@ -66,16 +66,6 @@ ClientData:
slot: slot:
description: Slot jídla, ke kterému se tato data vztahují description: Slot jídla, ke kterému se tato data vztahují
$ref: "#/MealSlot" $ref: "#/MealSlot"
groups:
description: Skupiny objednávajících pro extra slot
type: array
items:
$ref: "#/OrderGroup"
stores:
description: Seznam povolených obchodů/restaurací pro extra objednávky
type: array
items:
type: string
# --- OBĚDY --- # --- OBĚDY ---
UserLunchChoice: UserLunchChoice:
@@ -272,6 +262,7 @@ DepartureTime:
FeatureRequest: FeatureRequest:
type: string type: string
enum: enum:
- Ruční generování QR kódů mimo Pizza day (např. při objednávání)
- Možnost označovat si jídla jako oblíbená (taková jídla by se uživateli následně zvýrazňovala) - Možnost označovat si jídla jako oblíbená (taková jídla by se uživateli následně zvýrazňovala)
- Možnost úhrady v podniku za všechny jednou osobou a následné generování QR ostatním - Možnost úhrady v podniku za všechny jednou osobou a následné generování QR ostatním
- Zrušení \"užívejte víkend\", místo toho umožnit zpětně náhled na uplynulý týden - Zrušení \"užívejte víkend\", místo toho umožnit zpětně náhled na uplynulý týden
@@ -284,6 +275,7 @@ FeatureRequest:
- Celkové vylepšení UI/UX - Celkové vylepšení UI/UX
- Zlepšení dokumentace/postupů pro ostatní vývojáře - Zlepšení dokumentace/postupů pro ostatní vývojáře
x-enum-varnames: x-enum-varnames:
- CUSTOM_QR
- FAVORITES - FAVORITES
- SINGLE_PAYMENT - SINGLE_PAYMENT
- NO_WEEKENDS - NO_WEEKENDS
@@ -420,14 +412,14 @@ PizzaSize:
description: Velikost pizzy, např. "30cm" description: Velikost pizzy, např. "30cm"
type: string type: string
pizzaPrice: pizzaPrice:
description: Cena samotné pizzy v haléřích description: Cena samotné pizzy v
type: integer type: number
boxPrice: boxPrice:
description: Cena krabice pizzy v haléřích description: Cena krabice pizzy v
type: integer type: number
price: price:
description: Celková cena (pizza + krabice) v haléřích description: Celková cena (pizza + krabice)
type: integer type: number
Pizza: Pizza:
description: Údaje o konkrétní pizze. description: Údaje o konkrétní pizze.
type: object type: object
@@ -470,8 +462,8 @@ PizzaVariant:
description: Velikost pizzy (např. "30cm"), nebo "1 porce" pro salát description: Velikost pizzy (např. "30cm"), nebo "1 porce" pro salát
type: string type: string
price: price:
description: Cena v haléřích, včetně krabice/obalu description: Cena v , včetně krabice/obalu
type: integer type: number
category: category:
description: Kategorie položky (pizza nebo salat) description: Kategorie položky (pizza nebo salat)
type: string type: string
@@ -494,8 +486,8 @@ Salat:
items: items:
type: string type: string
price: price:
description: Cena salátu v haléřích (bez obalu) description: Cena salátu v (bez obalu)
type: integer type: number
PizzaOrder: PizzaOrder:
description: Údaje o objednávce pizzy jednoho uživatele. description: Údaje o objednávce pizzy jednoho uživatele.
type: object type: object
@@ -521,11 +513,11 @@ PizzaOrder:
description: Popis příplatku (např. "kuřecí maso navíc") description: Popis příplatku (např. "kuřecí maso navíc")
type: string type: string
price: price:
description: Cena příplatku v haléřích description: Cena příplatku v
type: integer type: number
totalPrice: totalPrice:
description: Celková cena všech objednaných pizz daného uživatele v haléřích, včetně krabic a příplatků description: Celková cena všech objednaných pizz daného uživatele, včetně krabic a příplatků
type: integer type: number
hasQr: hasQr:
description: | description: |
Příznak, pokud je k této objednávce vygenerován QR kód pro platbu. To je typicky pravda, pokud: Příznak, pokud je k této objednávce vygenerován QR kód pro platbu. To je typicky pravda, pokud:
@@ -635,9 +627,9 @@ QrRecipient:
description: Účel platby (např. "Pizza prosciutto") description: Účel platby (např. "Pizza prosciutto")
type: string type: string
amount: amount:
description: Částka v haléřích (kladné celé číslo) description: Částka v (kladné číslo, max 2 desetinná místa)
type: integer type: number
minimum: 1 minimum: 0.01
GenerateQrRequest: GenerateQrRequest:
description: Request pro generování QR kódů description: Request pro generování QR kódů
type: object type: object
@@ -658,9 +650,6 @@ GenerateQrRequest:
bankAccountHolder: bankAccountHolder:
description: Jméno držitele bankovního účtu description: Jméno držitele bankovního účtu
type: string type: string
groupId:
description: ID skupiny objednávky (pro propojení QR kódů se skupinou)
type: string
# --- DEV MOCK DATA --- # --- DEV MOCK DATA ---
GenerateMockDataRequest: GenerateMockDataRequest:
@@ -685,93 +674,6 @@ ClearMockDataRequest:
description: Index dne v týdnu (0 = pondělí, 4 = pátek). Pokud není zadán, použije se aktuální den. description: Index dne v týdnu (0 = pondělí, 4 = pátek). Pokud není zadán, použije se aktuální den.
$ref: "#/DayIndex" $ref: "#/DayIndex"
# --- SKUPINOVÉ OBJEDNÁVKY ---
GroupState:
description: Stav skupiny objednávky
type: string
enum:
- open
- locked
- ordered
x-enum-varnames:
- OPEN
- LOCKED
- ORDERED
OrderGroupMember:
description: Data člena skupiny objednávky
type: object
additionalProperties: false
properties:
amount:
description: Částka k úhradě v haléřích
type: integer
note:
description: Volitelná poznámka (např. co si objednává)
type: string
surchargeText:
description: Popis příplatku
type: string
surchargeAmount:
description: Výše příplatku v haléřích
type: integer
paid:
description: Příznak, zda člen uhradil svůj podíl objednávky
type: boolean
OrderGroup:
description: Skupina uživatelů objednávajících z jednoho místa
type: object
additionalProperties: false
required:
- id
- name
- creatorLogin
- state
- members
properties:
id:
description: Unikátní identifikátor skupiny
type: string
name:
description: Název obchodu/restaurace
type: string
creatorLogin:
description: Login zakladatele skupiny
type: string
state:
$ref: "#/GroupState"
members:
description: Členové skupiny
type: object
additionalProperties:
$ref: "#/OrderGroupMember"
orderedAt:
description: Čas objednání ve formátu HH:MM
type: string
deliveryAt:
description: Očekávaný čas doručení ve formátu HH:MM
type: string
fees:
description: Poplatky (balení apod.) celkem v haléřích
type: integer
shipping:
description: Doprava v haléřích
type: integer
tip:
description: Spropitné v haléřích
type: integer
discountType:
description: Typ slevy aplikované na objednávku
type: string
enum: [percent, fixed]
discountValue:
description: Hodnota slevy (procenta pro typ 'percent', haléře pro typ 'fixed')
type: integer
qrGenerated:
description: Příznak, zda byly pro skupinu vygenerovány QR kódy (blokuje opakované generování)
type: boolean
# --- NEVYŘÍZENÉ QR KÓDY --- # --- NEVYŘÍZENÉ QR KÓDY ---
PendingQr: PendingQr:
description: Nevyřízený QR kód pro platbu description: Nevyřízený QR kód pro platbu
@@ -793,11 +695,8 @@ PendingQr:
description: Jméno uživatele, který QR vygeneroval (příjemce platby) description: Jméno uživatele, který QR vygeneroval (příjemce platby)
type: string type: string
totalPrice: totalPrice:
description: Celková cena objednávky v haléřích description: Celková cena objednávky v
type: integer type: number
purpose: purpose:
description: Účel platby (např. "Pizza prosciutto") description: Účel platby (např. "Pizza prosciutto")
type: string type: string
groupId:
description: ID skupiny objednávky, ke které QR patří
type: string
+48 -53
View File
@@ -4,12 +4,12 @@
"@hey-api/client-fetch@^0.8.2": "@hey-api/client-fetch@^0.8.2":
version "0.8.2" version "0.8.2"
resolved "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.8.2.tgz" resolved "https://registry.yarnpkg.com/@hey-api/client-fetch/-/client-fetch-0.8.2.tgz#675aadfbc9478bb8eef5679f11a9334258dff4c8"
integrity sha512-61T4UGfAzY5345vMxWDX8qnSTNRJcOpWuZyvNu3vNebCTLPwMQAM85mhEuBoACdWeRtLhNoUjU0UR5liRyD1bA== integrity sha512-61T4UGfAzY5345vMxWDX8qnSTNRJcOpWuZyvNu3vNebCTLPwMQAM85mhEuBoACdWeRtLhNoUjU0UR5liRyD1bA==
"@hey-api/json-schema-ref-parser@1.0.2": "@hey-api/json-schema-ref-parser@1.0.2":
version "1.0.2" version "1.0.2"
resolved "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.2.tgz" resolved "https://registry.yarnpkg.com/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.2.tgz#c3824c5d9d531eeb5c2b2557857a8ad20b5c75a7"
integrity sha512-F6LSkttZcT/XiX3ydeDqTY3uRN3BLJMwyMTk4kg/ichZlKUp3+3Odv0WokSmXGSoZGTW/N66FROMYAm5NPdJlA== integrity sha512-F6LSkttZcT/XiX3ydeDqTY3uRN3BLJMwyMTk4kg/ichZlKUp3+3Odv0WokSmXGSoZGTW/N66FROMYAm5NPdJlA==
dependencies: dependencies:
"@jsdevtools/ono" "^7.1.3" "@jsdevtools/ono" "^7.1.3"
@@ -18,7 +18,7 @@
"@hey-api/openapi-ts@^0.64.7": "@hey-api/openapi-ts@^0.64.7":
version "0.64.7" version "0.64.7"
resolved "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.64.7.tgz" resolved "https://registry.yarnpkg.com/@hey-api/openapi-ts/-/openapi-ts-0.64.7.tgz#f239d268b4a35b91f5ff25479d15578feb01f365"
integrity sha512-xpaBzdGAKz7cPuGah1GZWl3zTZquOXRnwmpVCQKEUpvDtSYWBLjSxtAYIqDBjwJkuNHcakZBIWLcappx7slc2g== integrity sha512-xpaBzdGAKz7cPuGah1GZWl3zTZquOXRnwmpVCQKEUpvDtSYWBLjSxtAYIqDBjwJkuNHcakZBIWLcappx7slc2g==
dependencies: dependencies:
"@hey-api/json-schema-ref-parser" "1.0.2" "@hey-api/json-schema-ref-parser" "1.0.2"
@@ -28,27 +28,27 @@
"@jsdevtools/ono@^7.1.3": "@jsdevtools/ono@^7.1.3":
version "7.1.3" version "7.1.3"
resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz" resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796"
integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==
"@types/json-schema@^7.0.15": "@types/json-schema@^7.0.15":
version "7.0.15" version "7.0.15"
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
acorn@^8.14.0: acorn@^8.14.0:
version "8.14.0" version "8.14.0"
resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0"
integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==
argparse@^2.0.1: argparse@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
c12@2.0.1: c12@2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.npmjs.org/c12/-/c12-2.0.1.tgz" resolved "https://registry.yarnpkg.com/c12/-/c12-2.0.1.tgz#5702d280b31a08abba39833494c9b1202f0f5aec"
integrity sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A== integrity sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==
dependencies: dependencies:
chokidar "^4.0.1" chokidar "^4.0.1"
@@ -66,63 +66,63 @@ c12@2.0.1:
chokidar@^4.0.1: chokidar@^4.0.1:
version "4.0.3" version "4.0.3"
resolved "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30"
integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==
dependencies: dependencies:
readdirp "^4.0.1" readdirp "^4.0.1"
chownr@^2.0.0: chownr@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz" resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece"
integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
citty@^0.1.6: citty@^0.1.6:
version "0.1.6" version "0.1.6"
resolved "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz" resolved "https://registry.yarnpkg.com/citty/-/citty-0.1.6.tgz#0f7904da1ed4625e1a9ea7e0fa780981aab7c5e4"
integrity sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ== integrity sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==
dependencies: dependencies:
consola "^3.2.3" consola "^3.2.3"
commander@13.0.0: commander@13.0.0:
version "13.0.0" version "13.0.0"
resolved "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz" resolved "https://registry.yarnpkg.com/commander/-/commander-13.0.0.tgz#1b161f60ee3ceb8074583a0f95359a4f8701845c"
integrity sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ== integrity sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==
confbox@^0.1.7, confbox@^0.1.8: confbox@^0.1.7, confbox@^0.1.8:
version "0.1.8" version "0.1.8"
resolved "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz" resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06"
integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w== integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==
consola@^3.2.3, consola@^3.4.0: consola@^3.2.3, consola@^3.4.0:
version "3.4.0" version "3.4.0"
resolved "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz" resolved "https://registry.yarnpkg.com/consola/-/consola-3.4.0.tgz#4cfc9348fd85ed16a17940b3032765e31061ab88"
integrity sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA== integrity sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==
defu@^6.1.4: defu@^6.1.4:
version "6.1.4" version "6.1.4"
resolved "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz" resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.4.tgz#4e0c9cf9ff68fe5f3d7f2765cc1a012dfdcb0479"
integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg== integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==
destr@^2.0.3: destr@^2.0.3:
version "2.0.3" version "2.0.3"
resolved "https://registry.npmjs.org/destr/-/destr-2.0.3.tgz" resolved "https://registry.yarnpkg.com/destr/-/destr-2.0.3.tgz#7f9e97cb3d16dbdca7be52aca1644ce402cfe449"
integrity sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ== integrity sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==
dotenv@^16.4.5: dotenv@^16.4.5:
version "16.4.7" version "16.4.7"
resolved "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.7.tgz#0e20c5b82950140aa99be360a8a5f52335f53c26"
integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ== integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==
fs-minipass@^2.0.0: fs-minipass@^2.0.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb"
integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==
dependencies: dependencies:
minipass "^3.0.0" minipass "^3.0.0"
giget@^1.2.3: giget@^1.2.3:
version "1.2.5" version "1.2.5"
resolved "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz" resolved "https://registry.yarnpkg.com/giget/-/giget-1.2.5.tgz#0bd4909356a0da75cc1f2b33538f93adec0d202f"
integrity sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug== integrity sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==
dependencies: dependencies:
citty "^0.1.6" citty "^0.1.6"
@@ -135,7 +135,7 @@ giget@^1.2.3:
handlebars@4.7.8: handlebars@4.7.8:
version "4.7.8" version "4.7.8"
resolved "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9"
integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==
dependencies: dependencies:
minimist "^1.2.5" minimist "^1.2.5"
@@ -147,36 +147,36 @@ handlebars@4.7.8:
jiti@^2.3.0: jiti@^2.3.0:
version "2.4.2" version "2.4.2"
resolved "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz" resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.2.tgz#d19b7732ebb6116b06e2038da74a55366faef560"
integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A== integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==
js-yaml@^4.1.0: js-yaml@^4.1.0:
version "4.1.0" version "4.1.0"
resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
dependencies: dependencies:
argparse "^2.0.1" argparse "^2.0.1"
minimist@^1.2.5: minimist@^1.2.5:
version "1.2.8" version "1.2.8"
resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
minipass@^3.0.0: minipass@^3.0.0:
version "3.3.6" version "3.3.6"
resolved "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz" resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a"
integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==
dependencies: dependencies:
yallist "^4.0.0" yallist "^4.0.0"
minipass@^5.0.0: minipass@^5.0.0:
version "5.0.0" version "5.0.0"
resolved "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz" resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d"
integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==
minizlib@^2.1.1: minizlib@^2.1.1:
version "2.1.2" version "2.1.2"
resolved "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==
dependencies: dependencies:
minipass "^3.0.0" minipass "^3.0.0"
@@ -184,12 +184,12 @@ minizlib@^2.1.1:
mkdirp@^1.0.3: mkdirp@^1.0.3:
version "1.0.4" version "1.0.4"
resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
mlly@^1.7.1, mlly@^1.7.4: mlly@^1.7.1, mlly@^1.7.4:
version "1.7.4" version "1.7.4"
resolved "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz" resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.4.tgz#3d7295ea2358ec7a271eaa5d000a0f84febe100f"
integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw== integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==
dependencies: dependencies:
acorn "^8.14.0" acorn "^8.14.0"
@@ -199,17 +199,17 @@ mlly@^1.7.1, mlly@^1.7.4:
neo-async@^2.6.2: neo-async@^2.6.2:
version "2.6.2" version "2.6.2"
resolved "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
node-fetch-native@^1.6.6: node-fetch-native@^1.6.6:
version "1.6.6" version "1.6.6"
resolved "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.6.tgz" resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.6.6.tgz#ae1d0e537af35c2c0b0de81cbff37eedd410aa37"
integrity sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ== integrity sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==
nypm@^0.5.4: nypm@^0.5.4:
version "0.5.4" version "0.5.4"
resolved "https://registry.npmjs.org/nypm/-/nypm-0.5.4.tgz" resolved "https://registry.yarnpkg.com/nypm/-/nypm-0.5.4.tgz#a5ab0d8d37f96342328479f88ef58699f29b3051"
integrity sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA== integrity sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==
dependencies: dependencies:
citty "^0.1.6" citty "^0.1.6"
@@ -221,32 +221,27 @@ nypm@^0.5.4:
ohash@^1.1.4: ohash@^1.1.4:
version "1.1.4" version "1.1.4"
resolved "https://registry.npmjs.org/ohash/-/ohash-1.1.4.tgz" resolved "https://registry.yarnpkg.com/ohash/-/ohash-1.1.4.tgz#ae8d83014ab81157d2c285abf7792e2995fadd72"
integrity sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g== integrity sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==
pathe@^1.1.2: pathe@^1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz" resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec"
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ== integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
pathe@^2.0.1: pathe@^2.0.1, pathe@^2.0.3:
version "2.0.3" version "2.0.3"
resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz" resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
pathe@^2.0.3:
version "2.0.3"
resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz"
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
perfect-debounce@^1.0.0: perfect-debounce@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz" resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz#9c2e8bc30b169cc984a58b7d5b28049839591d2a"
integrity sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA== integrity sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==
pkg-types@^1.2.0, pkg-types@^1.3.0, pkg-types@^1.3.1: pkg-types@^1.2.0, pkg-types@^1.3.0, pkg-types@^1.3.1:
version "1.3.1" version "1.3.1"
resolved "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz" resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df"
integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ== integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==
dependencies: dependencies:
confbox "^0.1.8" confbox "^0.1.8"
@@ -255,7 +250,7 @@ pkg-types@^1.2.0, pkg-types@^1.3.0, pkg-types@^1.3.1:
rc9@^2.1.2: rc9@^2.1.2:
version "2.1.2" version "2.1.2"
resolved "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz" resolved "https://registry.yarnpkg.com/rc9/-/rc9-2.1.2.tgz#6282ff638a50caa0a91a31d76af4a0b9cbd1080d"
integrity sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg== integrity sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==
dependencies: dependencies:
defu "^6.1.4" defu "^6.1.4"
@@ -263,17 +258,17 @@ rc9@^2.1.2:
readdirp@^4.0.1: readdirp@^4.0.1:
version "4.1.2" version "4.1.2"
resolved "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
source-map@^0.6.1: source-map@^0.6.1:
version "0.6.1" version "0.6.1"
resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
tar@^6.2.1: tar@^6.2.1:
version "6.2.1" version "6.2.1"
resolved "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz" resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a"
integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==
dependencies: dependencies:
chownr "^2.0.0" chownr "^2.0.0"
@@ -285,30 +280,30 @@ tar@^6.2.1:
tinyexec@^0.3.2: tinyexec@^0.3.2:
version "0.3.2" version "0.3.2"
resolved "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz" resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2"
integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==
typescript@^5.5.3, typescript@^5.9.3: typescript@^5.9.3:
version "5.9.3" version "5.9.3"
resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
ufo@^1.5.4: ufo@^1.5.4:
version "1.5.4" version "1.5.4"
resolved "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz" resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.4.tgz#16d6949674ca0c9e0fbbae1fa20a71d7b1ded754"
integrity sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ== integrity sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==
uglify-js@^3.1.4: uglify-js@^3.1.4:
version "3.19.3" version "3.19.3"
resolved "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f"
integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==
wordwrap@^1.0.0: wordwrap@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
yallist@^4.0.0: yallist@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz" resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==