Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 986c36b677 | |||
| 67abbf19b5 | |||
|
a26d6cf85c
|
|||
|
640c7ed41d
|
|||
|
a166634db8
|
|||
| 916766450a | |||
| 5e596c3b99 | |||
| 3ba5fdd086 | |||
| 03f4e438a3 | |||
| b591411d10 | |||
|
8a588cf486
|
|||
|
0e4dc061b8
|
|||
|
7fd3ba0fc4
|
|||
| 94b8f0a452 | |||
| 3e6ecd4e6a | |||
| f12dc7b562 | |||
| 8aef00ab05 | |||
|
d91c8db49c
|
|||
| d8714b2086 |
@@ -4,3 +4,6 @@ types/gen
|
|||||||
.mcp.json
|
.mcp.json
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
server/public/
|
server/public/
|
||||||
|
.claude/*.lock
|
||||||
|
.claude/worktrees
|
||||||
|
.playwright-mcp
|
||||||
Vendored
+32
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Server (ts-node, debug)",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"cwd": "${workspaceFolder}/server",
|
||||||
|
"runtimeArgs": ["-r", "ts-node/register"],
|
||||||
|
"program": "${workspaceFolder}/server/src/index.ts",
|
||||||
|
"env": { "NODE_ENV": "development" },
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"skipFiles": ["<node_internals>/**"],
|
||||||
|
"preLaunchTask": "types: openapi-ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Client (vite + Edge)",
|
||||||
|
"type": "msedge",
|
||||||
|
"request": "launch",
|
||||||
|
"url": "http://localhost:3000",
|
||||||
|
"webRoot": "${workspaceFolder}/client",
|
||||||
|
"preLaunchTask": "client: vite"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compounds": [
|
||||||
|
{
|
||||||
|
"name": "Dev: server + client",
|
||||||
|
"configurations": ["Server (ts-node, debug)", "Client (vite + Edge)"],
|
||||||
|
"stopAll": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-1
@@ -76,7 +76,7 @@ WORKDIR /app
|
|||||||
# Export /data/db.json do složky /data
|
# Export /data/db.json do složky /data
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3001
|
||||||
|
|
||||||
CMD [ "node", "./server/src/index.js" ]
|
CMD [ "node", "./server/src/index.js" ]
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
|
## HA / multi-replica follow-ups
|
||||||
|
- [ ] `foodRoutes.ts` per-pod rate limiter (`rateLimits` map) — s více replikami může uživatel překročit limit ~N× rychleji; přesunout do Redis (např. `INCR` + `EXPIRE`)
|
||||||
|
- [ ] `easterEggRoutes.ts` — náhodně generované URL easter eggů jsou per-pod; URL funguje pouze na podu, který ji vygeneroval; zvážit deterministické seedy nebo sdílení přes Redis
|
||||||
|
- [ ] `service.ts` — komplexní víceúrovňové funkce (`addChoice`, `removeChoiceIfPresent`) provádějí více po sobě jdoucích zápisů do stejného Redis klíče; pro plnou atomicitu je potřeba per-klíčový distribuovaný zámek (Redlock nebo `SET NX EX`) nebo sloučení logiky do jednoho `updateData` volání
|
||||||
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli
|
- [ ] 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ěď)
|
- [ ] 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í)
|
- [ ] 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í)
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ body {
|
|||||||
&:hover svg {
|
&:hover svg {
|
||||||
transform: rotate(15deg);
|
transform: rotate(15deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
+45
-11
@@ -13,10 +13,12 @@ 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 { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
import { faArrowUpRightFromSquare, 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, 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';
|
||||||
@@ -59,6 +61,7 @@ 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>();
|
||||||
@@ -75,6 +78,7 @@ 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);
|
||||||
@@ -151,6 +155,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [auth?.login, socket]);
|
}, [auth?.login, socket]);
|
||||||
|
|
||||||
|
// Po znovupřipojení socketu znovu vstoupit do místnosti a obnovit data
|
||||||
|
useEffect(() => {
|
||||||
|
const onReconnect = () => {
|
||||||
|
if (auth?.login) socket.emit('join', auth.login);
|
||||||
|
getData({ query: { dayIndex: dayIndexRef.current } }).then(response => {
|
||||||
|
if (response.data) {
|
||||||
|
setData(response.data);
|
||||||
|
setFood(response.data.menus);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
socket.io.on('reconnect', onReconnect);
|
||||||
|
return () => { socket.io.off('reconnect', onReconnect); };
|
||||||
|
}, [socket, auth?.login]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth?.login || !data?.choices) {
|
if (!auth?.login || !data?.choices) {
|
||||||
return
|
return
|
||||||
@@ -449,7 +468,7 @@ function App() {
|
|||||||
data.pizzaList?.forEach((pizza, index) => {
|
data.pizzaList?.forEach((pizza, index) => {
|
||||||
const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] }
|
const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] }
|
||||||
pizza.sizes.forEach((size, sizeIndex) => {
|
pizza.sizes.forEach((size, sizeIndex) => {
|
||||||
const name = `${size.size} (${size.price} Kč)`;
|
const name = `${size.size} (${size.price / 100} Kč)`;
|
||||||
const value = `pizza|${index}|${sizeIndex}`;
|
const value = `pizza|${index}|${sizeIndex}`;
|
||||||
group.items?.push({ name, value });
|
group.items?.push({ name, value });
|
||||||
})
|
})
|
||||||
@@ -458,7 +477,7 @@ function App() {
|
|||||||
if (data.salatList?.length) {
|
if (data.salatList?.length) {
|
||||||
const salatGroup: SelectSearchOption = { name: "Saláty", type: "group", items: [] }
|
const salatGroup: SelectSearchOption = { name: "Saláty", type: "group", items: [] }
|
||||||
data.salatList.forEach((salat, index) => {
|
data.salatList.forEach((salat, index) => {
|
||||||
salatGroup.items?.push({ name: `${salat.name} (${salat.price} Kč)`, value: `salat|${index}` });
|
salatGroup.items?.push({ name: `${salat.name} (${salat.price / 100} Kč)`, value: `salat|${index}` });
|
||||||
});
|
});
|
||||||
suggestions.push(salatGroup);
|
suggestions.push(salatGroup);
|
||||||
}
|
}
|
||||||
@@ -732,6 +751,9 @@ 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!);
|
||||||
@@ -904,18 +926,12 @@ 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} Kč)
|
<strong>{formatDateString(qr.date)}</strong> — {qr.creator} ({qr.totalPrice / 100} Kč)
|
||||||
{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={async () => {
|
<Button variant="success" onClick={() => setDismissQrId(qr.id)}>
|
||||||
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>
|
||||||
@@ -931,6 +947,24 @@ 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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
|||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import SettingsModal from "./modals/SettingsModal";
|
import SettingsModal from "./modals/SettingsModal";
|
||||||
import { useSettings, ThemePreference } from "../context/settings";
|
import { useSettings, ThemePreference } from "../context/settings";
|
||||||
|
import HuePicker from "./HuePicker";
|
||||||
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
|
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
|
||||||
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
||||||
import RefreshMenuModal from "./modals/RefreshMenuModal";
|
import RefreshMenuModal from "./modals/RefreshMenuModal";
|
||||||
@@ -40,25 +41,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
||||||
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
|
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
|
||||||
|
|
||||||
// Zjistíme aktuální efektivní téma (pro zobrazení správné ikony)
|
const effectiveDark = settings?.effectiveDark ?? false;
|
||||||
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateEffectiveTheme = () => {
|
|
||||||
if (settings?.themePreference === 'system') {
|
|
||||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
setEffectiveTheme(isDark ? 'dark' : 'light');
|
|
||||||
} else {
|
|
||||||
setEffectiveTheme(settings?.themePreference || 'light');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updateEffectiveTheme();
|
|
||||||
|
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
mediaQuery.addEventListener('change', updateEffectiveTheme);
|
|
||||||
return () => mediaQuery.removeEventListener('change', updateEffectiveTheme);
|
|
||||||
}, [settings?.themePreference]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auth?.login) {
|
if (auth?.login) {
|
||||||
@@ -110,8 +93,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
// Přepínáme mezi light a dark (ignorujeme system pro jednoduchost)
|
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
|
||||||
const newTheme: ThemePreference = effectiveTheme === 'dark' ? 'light' : 'dark';
|
|
||||||
settings?.setThemePreference(newTheme);
|
settings?.setThemePreference(newTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,11 +177,16 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
<button
|
<button
|
||||||
className="theme-toggle"
|
className="theme-toggle"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
title={effectiveTheme === 'dark' ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
|
title={effectiveDark ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
|
||||||
aria-label="Přepnout barevný motiv"
|
aria-label="Přepnout světlý/tmavý režim"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={effectiveTheme === 'dark' ? faSun : faMoon} />
|
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
|
||||||
</button>
|
</button>
|
||||||
|
<HuePicker
|
||||||
|
accentHue={settings?.accentHue ?? 142}
|
||||||
|
isDark={effectiveDark}
|
||||||
|
onChange={hue => settings?.setAccentHue(hue)}
|
||||||
|
/>
|
||||||
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
||||||
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
.hue-picker-dropdown {
|
||||||
|
.dropdown-toggle {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
color: var(--luncher-navbar-text) !important;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--luncher-radius-sm);
|
||||||
|
transition: var(--luncher-transition);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-picker-panel {
|
||||||
|
padding: 0 !important;
|
||||||
|
min-width: 240px;
|
||||||
|
|
||||||
|
.hue-picker-inner {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-picker-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--luncher-text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
hsl(0 70% 50%), hsl(30 70% 50%), hsl(60 70% 50%), hsl(90 70% 50%),
|
||||||
|
hsl(120 70% 50%), hsl(150 70% 50%), hsl(180 70% 50%), hsl(210 70% 50%),
|
||||||
|
hsl(240 70% 50%), hsl(270 70% 50%), hsl(300 70% 50%), hsl(330 70% 50%), hsl(360 70% 50%)
|
||||||
|
);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-presets {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
|
||||||
|
.hue-swatch {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--luncher-text);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid var(--luncher-border);
|
||||||
|
|
||||||
|
.hue-preview-chip {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--luncher-radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--luncher-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Dropdown } from 'react-bootstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPalette } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import './HuePicker.scss';
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ hue: 142, label: 'Zelená' },
|
||||||
|
{ hue: 217, label: 'Modrá' },
|
||||||
|
{ hue: 263, label: 'Fialová' },
|
||||||
|
{ hue: 0, label: 'Červená' },
|
||||||
|
{ hue: 28, label: 'Oranžová' },
|
||||||
|
{ hue: 340, label: 'Růžová' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
accentHue: number;
|
||||||
|
isDark: boolean;
|
||||||
|
onChange: (hue: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function swatchColor(hue: number, isDark: boolean): string {
|
||||||
|
return `hsl(${hue} 70% ${isDark ? 55 : 38}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HuePicker({ accentHue, isDark, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<Dropdown align="end" autoClose="outside" className="hue-picker-dropdown">
|
||||||
|
<Dropdown.Toggle
|
||||||
|
as="button"
|
||||||
|
className="theme-toggle"
|
||||||
|
aria-label="Barva zvýraznění"
|
||||||
|
title="Barva zvýraznění"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPalette} />
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu className="hue-picker-panel">
|
||||||
|
<div className="hue-picker-inner">
|
||||||
|
<div className="hue-picker-label">Barva zvýraznění</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={360}
|
||||||
|
value={accentHue}
|
||||||
|
onChange={e => onChange(parseInt(e.target.value, 10))}
|
||||||
|
className="hue-slider"
|
||||||
|
aria-label="Odstín barvy zvýraznění"
|
||||||
|
/>
|
||||||
|
<div className="hue-presets">
|
||||||
|
{PRESETS.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.hue}
|
||||||
|
className={`hue-swatch${accentHue === p.hue ? ' active' : ''}`}
|
||||||
|
style={{ background: swatchColor(p.hue, isDark) }}
|
||||||
|
title={p.label}
|
||||||
|
onClick={() => onChange(p.hue)}
|
||||||
|
aria-label={p.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="hue-preview">
|
||||||
|
<div
|
||||||
|
className="hue-preview-chip"
|
||||||
|
style={{ background: swatchColor(accentHue, isDark) }}
|
||||||
|
/>
|
||||||
|
<span>Aktuální barva zvýraznění</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} Kč`}</td>
|
<td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total / 100} Kč`}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@@ -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} Kč)`}
|
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price / 100} 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} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
|
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price / 100} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
{order.totalPrice} Kč{auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
|
{order.totalPrice / 100} Kč{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?.toString() }} />
|
<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 }} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,18 +9,23 @@ type Props = {
|
|||||||
onSaved: (data: any) => void;
|
onSaved: (data: any) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseNum(s: string): number {
|
function parseHal(s: string): number {
|
||||||
const n = parseFloat(s.replace(',', '.'));
|
const n = parseFloat(s.replace(',', '.'));
|
||||||
return isNaN(n) || n < 0 ? 0 : Math.round(n * 100) / 100;
|
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 {
|
function computeMemberTotal(member: OrderGroupMember, feeShare: number, discountType: string, discountValue: number, memberCount: number): number {
|
||||||
const base = member.amount ?? 0;
|
const base = member.amount ?? 0;
|
||||||
const surcharge = member.surchargeAmount ?? 0;
|
const surcharge = member.surchargeAmount ?? 0;
|
||||||
const discount = discountType === 'percent'
|
const discount = discountType === 'percent'
|
||||||
? Math.round((base + surcharge) * discountValue / 100 * 100) / 100
|
? Math.round((base + surcharge) * discountValue / 100)
|
||||||
: Math.round(discountValue / memberCount * 100) / 100;
|
: Math.round(discountValue / memberCount);
|
||||||
return Math.round((base + surcharge + feeShare - discount) * 100) / 100;
|
return base + surcharge + feeShare - discount;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly<Props>) {
|
export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly<Props>) {
|
||||||
@@ -34,40 +39,40 @@ export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }:
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
setFees(group.fees ? String(group.fees) : '');
|
setFees(group.fees ? String(group.fees / 100) : '');
|
||||||
setShipping(group.shipping ? String(group.shipping) : '');
|
setShipping(group.shipping ? String(group.shipping / 100) : '');
|
||||||
setTip(group.tip ? String(group.tip) : '');
|
setTip(group.tip ? String(group.tip / 100) : '');
|
||||||
setDiscountType((group.discountType as 'percent' | 'fixed') ?? 'percent');
|
setDiscountType((group.discountType as 'percent' | 'fixed') ?? 'percent');
|
||||||
setDiscountValue(group.discountValue ? String(group.discountValue) : '');
|
setDiscountValue(group.discountValue
|
||||||
|
? ((group.discountType as string) === 'fixed' ? String(group.discountValue / 100) : String(group.discountValue))
|
||||||
|
: '');
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [isOpen, group]);
|
}, [isOpen, group]);
|
||||||
|
|
||||||
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
||||||
const memberCount = memberEntries.length;
|
const memberCount = memberEntries.length;
|
||||||
|
|
||||||
const feesNum = parseNum(fees);
|
const feesNum = parseHal(fees);
|
||||||
const shippingNum = parseNum(shipping);
|
const shippingNum = parseHal(shipping);
|
||||||
const tipNum = parseNum(tip);
|
const tipNum = parseHal(tip);
|
||||||
const discountNum = parseNum(discountValue);
|
const discountNum = discountType === 'percent' ? parsePercent(discountValue) : parseHal(discountValue);
|
||||||
const totalFees = feesNum + shippingNum + tipNum;
|
const totalFees = feesNum + shippingNum + tipNum;
|
||||||
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount * 100) / 100 : 0;
|
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount) : 0;
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const body: Record<string, any> = { id: group.id };
|
const res = await updateGroupFees({
|
||||||
body.fees = feesNum;
|
body: {
|
||||||
body.shipping = shippingNum;
|
id: group.id,
|
||||||
body.tip = tipNum;
|
fees: feesNum,
|
||||||
if (discountNum > 0) {
|
shipping: shippingNum,
|
||||||
body.discountType = discountType;
|
tip: tipNum,
|
||||||
body.discountValue = discountNum;
|
discountType: discountNum > 0 ? discountType : undefined,
|
||||||
} else {
|
discountValue: discountNum > 0 ? discountNum : undefined,
|
||||||
body.discountType = '';
|
|
||||||
body.discountValue = 0;
|
|
||||||
}
|
}
|
||||||
const res = await updateGroupFees({ body });
|
});
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
setError((res.error as any).error || 'Nastala chyba');
|
setError((res.error as any).error || 'Nastala chyba');
|
||||||
} else {
|
} else {
|
||||||
@@ -145,7 +150,7 @@ export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
<h6>Náhled celkových částek ({memberCount} členů, {feeShare > 0 ? `poplatek ${feeShare} Kč/os.` : 'bez poplatku'})</h6>
|
<h6>Náhled celkových částek ({memberCount} členů, {feeShare > 0 ? `poplatek ${feeShare / 100} Kč/os.` : 'bez poplatku'})</h6>
|
||||||
<Table size="sm" bordered>
|
<Table size="sm" bordered>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -163,18 +168,18 @@ export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }:
|
|||||||
const surcharge = member.surchargeAmount ?? 0;
|
const surcharge = member.surchargeAmount ?? 0;
|
||||||
const discount = discountNum > 0
|
const discount = discountNum > 0
|
||||||
? (discountType === 'percent'
|
? (discountType === 'percent'
|
||||||
? Math.round((base + surcharge) * discountNum / 100 * 100) / 100
|
? Math.round((base + surcharge) * discountNum / 100)
|
||||||
: Math.round(discountNum / memberCount * 100) / 100)
|
: Math.round(discountNum / memberCount))
|
||||||
: 0;
|
: 0;
|
||||||
const total = computeMemberTotal(member, feeShare, discountType, discountNum, memberCount);
|
const total = computeMemberTotal(member, feeShare, discountType, discountNum, memberCount);
|
||||||
return (
|
return (
|
||||||
<tr key={login}>
|
<tr key={login}>
|
||||||
<td><strong>{login}</strong></td>
|
<td><strong>{login}</strong></td>
|
||||||
<td className="text-end">{base > 0 ? `${base} Kč` : '—'}</td>
|
<td className="text-end">{base > 0 ? `${base / 100} Kč` : '—'}</td>
|
||||||
<td className="text-end">{surcharge > 0 ? `${surcharge} Kč` : '—'}</td>
|
<td className="text-end">{surcharge > 0 ? `${surcharge / 100} Kč` : '—'}</td>
|
||||||
<td className="text-end">{feeShare > 0 ? `${feeShare} Kč` : '—'}</td>
|
<td className="text-end">{feeShare > 0 ? `${feeShare / 100} Kč` : '—'}</td>
|
||||||
<td className="text-end text-danger">{discount > 0 ? `-${discount} Kč` : '—'}</td>
|
<td className="text-end text-danger">{discount > 0 ? `-${discount / 100} Kč` : '—'}</td>
|
||||||
<td className="text-end fw-bold">{total > 0 ? `${total} Kč` : '—'}</td>
|
<td className="text-end fw-bold">{total > 0 ? `${total / 100} Kč` : '—'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -33,9 +33,7 @@ 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;
|
||||||
const parts = s.split('.');
|
return Math.round(n * 100);
|
||||||
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>) {
|
||||||
@@ -55,11 +53,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 price = parsePriceCzk(menu.food?.[idx]?.price);
|
const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
|
||||||
if (price === null) {
|
if (priceKc === null) {
|
||||||
baseAmountParseFailed = true;
|
baseAmountParseFailed = true;
|
||||||
} else {
|
} else {
|
||||||
baseAmount += price;
|
baseAmount += Math.round(priceKc * 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,19 +82,19 @@ 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; // +1 for payer
|
const totalPeople = includedDiners.length + 1;
|
||||||
return Math.round((tip / totalPeople) * 100) / 100;
|
return Math.round(tip / totalPeople);
|
||||||
})();
|
})();
|
||||||
const payerTipShare = (() => {
|
const payerTipShare = (() => {
|
||||||
const tip = parseAmount(tipTotal);
|
const tip = parseAmount(tipTotal);
|
||||||
if (!tip) return 0;
|
if (!tip) return 0;
|
||||||
return Math.round((tip - tipPerPerson * includedDiners.length) * 100) / 100;
|
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.login === payerLogin ? payerTipShare : tipPerPerson;
|
||||||
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
|
return d.baseAmount + surcharge + tip;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInclude = useCallback((login: string, checked: boolean) => {
|
const handleInclude = useCallback((login: string, checked: boolean) => {
|
||||||
@@ -122,11 +120,6 @@ 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({
|
||||||
@@ -226,7 +219,7 @@ 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} Kč)</span>}
|
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>}
|
||||||
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
|
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
|
||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
@@ -254,10 +247,10 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s} Kč` : '—'; })()}
|
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s / 100} Kč` : '—'; })()}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end fw-bold">
|
<td className="text-end fw-bold">
|
||||||
{`${total} Kč`}
|
{`${total / 100} Kč`}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -278,7 +271,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} Kč / osoba)`
|
? `(${tipPerPerson / 100} Kč / osoba)`
|
||||||
: ''}
|
: ''}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../
|
|||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
group: OrderGroup;
|
group: OrderGroup;
|
||||||
payerLogin: string;
|
payerLogin: string;
|
||||||
bankAccount: string;
|
bankAccount: string;
|
||||||
@@ -18,7 +19,7 @@ type DinerEntry = {
|
|||||||
included: boolean;
|
included: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) {
|
export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) {
|
||||||
const [diners, setDiners] = useState<DinerEntry[]>([]);
|
const [diners, setDiners] = useState<DinerEntry[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -41,7 +42,7 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
|||||||
const shipping = group.shipping ?? 0;
|
const shipping = group.shipping ?? 0;
|
||||||
const tip = group.tip ?? 0;
|
const tip = group.tip ?? 0;
|
||||||
const totalFees = fees + shipping + tip;
|
const totalFees = fees + shipping + tip;
|
||||||
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount * 100) / 100 : 0;
|
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount) : 0;
|
||||||
|
|
||||||
const getMemberTotal = (entry: DinerEntry): number => {
|
const getMemberTotal = (entry: DinerEntry): number => {
|
||||||
const base = entry.member.amount ?? 0;
|
const base = entry.member.amount ?? 0;
|
||||||
@@ -50,10 +51,10 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
|||||||
const discountValue = group.discountValue ?? 0;
|
const discountValue = group.discountValue ?? 0;
|
||||||
const discount = discountValue > 0
|
const discount = discountValue > 0
|
||||||
? (discountType === 'percent'
|
? (discountType === 'percent'
|
||||||
? Math.round((base + surcharge) * discountValue / 100 * 100) / 100
|
? Math.round((base + surcharge) * discountValue / 100)
|
||||||
: Math.round(discountValue / memberCount * 100) / 100)
|
: Math.round(discountValue / memberCount))
|
||||||
: 0;
|
: 0;
|
||||||
return Math.round((base + surcharge + feeShare - discount) * 100) / 100;
|
return base + surcharge + feeShare - discount;
|
||||||
};
|
};
|
||||||
|
|
||||||
const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin);
|
const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin);
|
||||||
@@ -73,14 +74,10 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
|||||||
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();
|
const note = d.member.note?.trim();
|
||||||
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
|
|
||||||
setError(`Částka pro ${d.login} má více než 2 desetinná místa`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
recipients.push({
|
recipients.push({
|
||||||
login: d.login,
|
login: d.login,
|
||||||
purpose: `Objednávka ${group.name}`.substring(0, 60),
|
purpose: note ? note.replace(/[^\x00-\xff*]/g, '').replace(/\*/g, '').substring(0, 60) : `Objednávka ${group.name}`.substring(0, 60),
|
||||||
amount: total,
|
amount: total,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -99,6 +96,7 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
|||||||
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
|
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
|
||||||
} else {
|
} else {
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
|
onSuccess?.();
|
||||||
setTimeout(() => onClose(), 2000);
|
setTimeout(() => onClose(), 2000);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -132,15 +130,15 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
|||||||
|
|
||||||
{hasFees && (
|
{hasFees && (
|
||||||
<div className="d-flex gap-3 mb-2 text-muted" style={{ fontSize: '0.9em' }}>
|
<div className="d-flex gap-3 mb-2 text-muted" style={{ fontSize: '0.9em' }}>
|
||||||
{fees > 0 && <span>Poplatky: <strong>{fees} Kč</strong></span>}
|
{fees > 0 && <span>Poplatky: <strong>{fees / 100} Kč</strong></span>}
|
||||||
{shipping > 0 && <span>Doprava: <strong>{shipping} Kč</strong></span>}
|
{shipping > 0 && <span>Doprava: <strong>{shipping / 100} Kč</strong></span>}
|
||||||
{tip > 0 && <span>Spropitné: <strong>{tip} Kč</strong></span>}
|
{tip > 0 && <span>Spropitné: <strong>{tip / 100} Kč</strong></span>}
|
||||||
<span>→ {feeShare} Kč/os.</span>
|
<span>→ {feeShare / 100} Kč/os.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{group.discountValue != null && group.discountValue > 0 && (
|
{group.discountValue != null && group.discountValue > 0 && (
|
||||||
<div className="mb-2 text-success" style={{ fontSize: '0.9em' }}>
|
<div className="mb-2 text-success" style={{ fontSize: '0.9em' }}>
|
||||||
Sleva: {group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue} Kč`}
|
Sleva: {group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -180,18 +178,18 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{(d.member.amount ?? 0) > 0 ? `${d.member.amount} Kč` : <span className="text-muted">—</span>}
|
{(d.member.amount ?? 0) > 0 ? `${d.member.amount! / 100} Kč` : <span className="text-muted">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{surcharge > 0 ? `${surcharge} Kč` : <span className="text-muted">—</span>}
|
{surcharge > 0 ? `${surcharge / 100} Kč` : <span className="text-muted">—</span>}
|
||||||
</td>
|
</td>
|
||||||
{hasFees && (
|
{hasFees && (
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{feeShare > 0 ? `${feeShare} Kč` : '—'}
|
{feeShare > 0 ? `${feeShare / 100} Kč` : '—'}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="text-end fw-bold">
|
<td className="text-end fw-bold">
|
||||||
{total > 0 ? `${total} Kč` : <span className="text-muted">—</span>}
|
{total > 0 ? `${total / 100} Kč` : <span className="text-muted">—</span>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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, Number.parseInt(priceRef.current?.value ?? "0"));
|
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
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, Number.parseInt(priceRef.current?.value ?? "0"));
|
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
|
|||||||
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
|
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
|
||||||
const HIDE_SOUPS_KEY = 'hide_soups';
|
const HIDE_SOUPS_KEY = 'hide_soups';
|
||||||
const THEME_KEY = 'theme_preference';
|
const THEME_KEY = 'theme_preference';
|
||||||
|
const ACCENT_HUE_KEY = 'accent_hue';
|
||||||
|
const LEGACY_COLOR_THEME_KEY = 'color_theme';
|
||||||
|
|
||||||
export type ThemePreference = 'system' | 'light' | 'dark';
|
export type ThemePreference = 'system' | 'light' | 'dark';
|
||||||
|
|
||||||
@@ -12,10 +14,13 @@ export type SettingsContextProps = {
|
|||||||
holderName?: string,
|
holderName?: string,
|
||||||
hideSoups?: boolean,
|
hideSoups?: boolean,
|
||||||
themePreference: ThemePreference,
|
themePreference: ThemePreference,
|
||||||
|
accentHue: number,
|
||||||
|
effectiveDark: boolean,
|
||||||
setBankAccountNumber: (accountNumber?: string) => void,
|
setBankAccountNumber: (accountNumber?: string) => void,
|
||||||
setBankAccountHolderName: (holderName?: string) => void,
|
setBankAccountHolderName: (holderName?: string) => void,
|
||||||
setHideSoupsOption: (hideSoups?: boolean) => void,
|
setHideSoupsOption: (hideSoups?: boolean) => void,
|
||||||
setThemePreference: (theme: ThemePreference) => void,
|
setThemePreference: (theme: ThemePreference) => void,
|
||||||
|
setAccentHue: (hue: number) => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContextProps = {
|
type ContextProps = {
|
||||||
@@ -45,11 +50,74 @@ function getInitialTheme(): ThemePreference {
|
|||||||
return 'system';
|
return 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInitialAccentHue(): number {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(ACCENT_HUE_KEY);
|
||||||
|
if (saved !== null) {
|
||||||
|
const n = parseInt(saved, 10);
|
||||||
|
if (!isNaN(n) && n >= 0 && n <= 360) return n;
|
||||||
|
}
|
||||||
|
// Migrace ze starého string formátu (green/blue/purple)
|
||||||
|
const old = localStorage.getItem(LEGACY_COLOR_THEME_KEY);
|
||||||
|
if (old === 'blue') return 217;
|
||||||
|
if (old === 'purple') return 263;
|
||||||
|
} catch {
|
||||||
|
// localStorage nedostupný
|
||||||
|
}
|
||||||
|
return 142;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Převod HSL na relativní jas dle WCAG (pro výpočet kontrastu s bílým textem)
|
||||||
|
function hslToRelativeLuminance(h: number, s: number, l: number): number {
|
||||||
|
const sn = s / 100, ln = l / 100;
|
||||||
|
const a = sn * Math.min(ln, 1 - ln);
|
||||||
|
const ch = (n: number) => {
|
||||||
|
const k = (n + h / 30) % 12;
|
||||||
|
return ln - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||||
|
};
|
||||||
|
const toLinear = (c: number) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||||
|
return 0.2126 * toLinear(ch(0)) + 0.7152 * toLinear(ch(8)) + 0.0722 * toLinear(ch(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Najde nejnižší světlost, při které má barva dostatečný kontrast s bílým textem (WCAG AA 4.5:1)
|
||||||
|
function adjustedL(hue: number, sat: number, targetL: number): number {
|
||||||
|
let l = targetL;
|
||||||
|
while (l >= 5) {
|
||||||
|
const lum = hslToRelativeLuminance(hue, sat, l);
|
||||||
|
if (1.05 / (lum + 0.05) >= 4.5) return l;
|
||||||
|
l -= 1;
|
||||||
|
}
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAccentColors(hue: number, isDark: boolean): void {
|
||||||
|
const sat = 70;
|
||||||
|
const baseL = adjustedL(hue, sat, isDark ? 55 : 38);
|
||||||
|
const hoverL = isDark ? Math.min(baseL + 10, 80) : Math.max(baseL - 10, 10);
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty('--luncher-primary', `hsl(${hue} ${sat}% ${baseL}%)`);
|
||||||
|
root.style.setProperty('--luncher-primary-hover', `hsl(${hue} ${sat}% ${hoverL}%)`);
|
||||||
|
root.style.setProperty('--luncher-primary-light', isDark
|
||||||
|
? `hsl(${hue} 60% 12%)`
|
||||||
|
: `hsl(${hue} 60% 92%)`);
|
||||||
|
root.style.setProperty('--luncher-action-icon', `hsl(${hue} ${sat}% ${baseL}%)`);
|
||||||
|
root.style.setProperty('--luncher-success', `hsl(${hue} ${sat}% ${baseL}%)`);
|
||||||
|
}
|
||||||
|
|
||||||
function useProvideSettings(): SettingsContextProps {
|
function useProvideSettings(): SettingsContextProps {
|
||||||
const [bankAccount, setBankAccount] = useState<string | undefined>();
|
const [bankAccount, setBankAccount] = useState<string | undefined>();
|
||||||
const [holderName, setHolderName] = useState<string | undefined>();
|
const [holderName, setHolderName] = useState<string | undefined>();
|
||||||
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
|
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
|
||||||
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
|
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
|
||||||
|
const [accentHue, setHue] = useState<number>(getInitialAccentHue);
|
||||||
|
const [effectiveDark, setEffectiveDark] = useState<boolean>(() => {
|
||||||
|
try {
|
||||||
|
const pref = localStorage.getItem(THEME_KEY) as ThemePreference | null;
|
||||||
|
if (pref === 'dark') return true;
|
||||||
|
if (pref === 'light') return false;
|
||||||
|
} catch { /* noop */ }
|
||||||
|
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
|
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
|
||||||
@@ -95,24 +163,27 @@ function useProvideSettings(): SettingsContextProps {
|
|||||||
}, [themePreference]);
|
}, [themePreference]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const applyTheme = (theme: 'light' | 'dark') => {
|
const applyTheme = (dark: boolean) => {
|
||||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
|
||||||
|
setEffectiveDark(dark);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (themePreference === 'system') {
|
if (themePreference === 'system') {
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
applyTheme(mediaQuery.matches ? 'dark' : 'light');
|
applyTheme(mq.matches);
|
||||||
|
const handler = (e: MediaQueryListEvent) => applyTheme(e.matches);
|
||||||
const handler = (e: MediaQueryListEvent) => {
|
mq.addEventListener('change', handler);
|
||||||
applyTheme(e.matches ? 'dark' : 'light');
|
return () => mq.removeEventListener('change', handler);
|
||||||
};
|
|
||||||
mediaQuery.addEventListener('change', handler);
|
|
||||||
return () => mediaQuery.removeEventListener('change', handler);
|
|
||||||
} else {
|
} else {
|
||||||
applyTheme(themePreference);
|
applyTheme(themePreference === 'dark');
|
||||||
}
|
}
|
||||||
}, [themePreference]);
|
}, [themePreference]);
|
||||||
|
|
||||||
|
// Aplikuje accent barvy při změně hue nebo přepnutí světlý/tmavý
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(ACCENT_HUE_KEY, String(accentHue));
|
||||||
|
applyAccentColors(accentHue, effectiveDark);
|
||||||
|
}, [accentHue, effectiveDark]);
|
||||||
|
|
||||||
function setBankAccountNumber(bankAccount?: string) {
|
function setBankAccountNumber(bankAccount?: string) {
|
||||||
setBankAccount(bankAccount);
|
setBankAccount(bankAccount);
|
||||||
}
|
}
|
||||||
@@ -129,14 +200,21 @@ function useProvideSettings(): SettingsContextProps {
|
|||||||
setTheme(theme);
|
setTheme(theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAccentHue(hue: number) {
|
||||||
|
setHue(hue);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bankAccount,
|
bankAccount,
|
||||||
holderName,
|
holderName,
|
||||||
hideSoups,
|
hideSoups,
|
||||||
themePreference,
|
themePreference,
|
||||||
|
accentHue,
|
||||||
|
effectiveDark,
|
||||||
setBankAccountNumber,
|
setBankAccountNumber,
|
||||||
setBankAccountHolderName,
|
setBankAccountHolderName,
|
||||||
setHideSoupsOption,
|
setHideSoupsOption,
|
||||||
setThemePreference,
|
setThemePreference,
|
||||||
|
setAccentHue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,25 @@ if (process.env.NODE_ENV === 'development') {
|
|||||||
socketPath = undefined;
|
socketPath = undefined;
|
||||||
} else {
|
} else {
|
||||||
socketUrl = `${globalThis.location.host}`;
|
socketUrl = `${globalThis.location.host}`;
|
||||||
socketPath = `${globalThis.location.pathname}socket.io`;
|
socketPath = '/socket.io';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] });
|
export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] });
|
||||||
export const SocketContext = React.createContext();
|
export const SocketContext = React.createContext();
|
||||||
|
|
||||||
|
// Prohlížeče throttlují setTimeout v neaktivních tabech, což zdržuje automatické
|
||||||
|
// znovupřipojení socket.io. Po návratu do tabu nebo focusu okna se připojíme hned.
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible' && !socket.connected) {
|
||||||
|
socket.connect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('focus', () => {
|
||||||
|
if (!socket.connected) {
|
||||||
|
socket.connect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Konstanty websocket eventů, musí odpovídat těm na serveru!
|
// Konstanty websocket eventů, musí odpovídat těm na serveru!
|
||||||
export const EVENT_CONNECT = 'connect';
|
export const EVENT_CONNECT = 'connect';
|
||||||
export const EVENT_DISCONNECT = 'disconnect';
|
export const EVENT_DISCONNECT = 'disconnect';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useContext, useEffect, useRef, useState } from 'react';
|
import { useContext, useEffect, useState } from 'react';
|
||||||
import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
|
import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
|
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
|
||||||
@@ -48,7 +48,6 @@ export default function OrderGroupsPage() {
|
|||||||
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
|
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
|
||||||
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
|
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
|
||||||
const [pageError, setPageError] = useState<string | null>(null);
|
const [pageError, setPageError] = useState<string | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -74,6 +73,12 @@ export default function OrderGroupsPage() {
|
|||||||
return () => { socket.off(EVENT_MESSAGE); };
|
return () => { socket.off(EVENT_MESSAGE); };
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onReconnect = () => fetchData();
|
||||||
|
socket.io.on('reconnect', onReconnect);
|
||||||
|
return () => { socket.io.off('reconnect', onReconnect); };
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
const refresh = async (fn: () => Promise<any>): Promise<boolean> => {
|
const refresh = async (fn: () => Promise<any>): Promise<boolean> => {
|
||||||
setPageError(null);
|
setPageError(null);
|
||||||
const result = await fn();
|
const result = await fn();
|
||||||
@@ -86,7 +91,6 @@ export default function OrderGroupsPage() {
|
|||||||
setData(result.data);
|
setData(result.data);
|
||||||
socket.emit?.('message', result.data as ClientData);
|
socket.emit?.('message', result.data as ClientData);
|
||||||
}
|
}
|
||||||
await fetchData();
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,7 +129,7 @@ export default function OrderGroupsPage() {
|
|||||||
setPageError('Zadejte platnou kladnou částku');
|
setPageError('Zadejte platnou kladnou částku');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, amount: n } }));
|
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; });
|
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,7 +149,7 @@ export default function OrderGroupsPage() {
|
|||||||
setPageError('Zadejte platnou výši příplatku');
|
setPageError('Zadejte platnou výši příplatku');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, surchargeText, surchargeAmount: rawAmount === '' ? 0 : surchargeAmount } }));
|
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; });
|
if (ok) setEditSurcharges(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,7 +214,7 @@ export default function OrderGroupsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="content-wrapper">
|
<div className="content-wrapper">
|
||||||
<div className="content">
|
<div className="content" style={{ maxWidth: 1200 }}>
|
||||||
{/* Vytvoření nové skupiny */}
|
{/* Vytvoření nové skupiny */}
|
||||||
<div className="choice-section fade-in mb-4">
|
<div className="choice-section fade-in mb-4">
|
||||||
<h5>Vytvořit skupinu</h5>
|
<h5>Vytvořit skupinu</h5>
|
||||||
@@ -254,17 +258,17 @@ export default function OrderGroupsPage() {
|
|||||||
const editingTimes = group.id in editTimes;
|
const editingTimes = group.id in editTimes;
|
||||||
|
|
||||||
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
|
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
|
||||||
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount * 100) / 100 : 0;
|
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount) : 0;
|
||||||
const getMemberTotal = (m: OrderGroupMember) => {
|
const getMemberTotal = (m: OrderGroupMember) => {
|
||||||
const base = m.amount ?? 0;
|
const base = m.amount ?? 0;
|
||||||
const surcharge = m.surchargeAmount ?? 0;
|
const surcharge = m.surchargeAmount ?? 0;
|
||||||
const dv = group.discountValue ?? 0;
|
const dv = group.discountValue ?? 0;
|
||||||
const discount = dv > 0
|
const discount = dv > 0
|
||||||
? (group.discountType === 'percent'
|
? (group.discountType === 'percent'
|
||||||
? Math.round((base + surcharge) * dv / 100 * 100) / 100
|
? Math.round((base + surcharge) * dv / 100)
|
||||||
: Math.round(dv / memberCount * 100) / 100)
|
: Math.round(dv / memberCount))
|
||||||
: 0;
|
: 0;
|
||||||
return Math.round((base + surcharge + feeShare - discount) * 100) / 100;
|
return base + surcharge + feeShare - discount;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -296,7 +300,7 @@ export default function OrderGroupsPage() {
|
|||||||
)}
|
)}
|
||||||
{isCreator && isOrdered && (
|
{isCreator && isOrdered && (
|
||||||
<>
|
<>
|
||||||
{settings?.bankAccount && settings?.holderName && (
|
{settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
|
||||||
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
|
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
|
||||||
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
|
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
|
||||||
Generovat QR
|
Generovat QR
|
||||||
@@ -320,10 +324,10 @@ export default function OrderGroupsPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Člen</th>
|
<th>Člen</th>
|
||||||
<th style={{ width: 120 }}>Částka (Kč)</th>
|
<th style={{ width: 180 }}>Částka (bez slev)</th>
|
||||||
<th style={{ width: 180 }}>Příplatek</th>
|
<th style={{ width: 220 }}>Příplatek</th>
|
||||||
<th>Poznámka</th>
|
<th>Poznámka</th>
|
||||||
<th style={{ width: 90 }}>Celkem</th>
|
<th style={{ width: 160 }}>Celkem (s poplatky)</th>
|
||||||
<th style={{ width: 40 }}></th>
|
<th style={{ width: 40 }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -356,24 +360,23 @@ export default function OrderGroupsPage() {
|
|||||||
{canEdit && editingAmount ? (
|
{canEdit && editingAmount ? (
|
||||||
<div className="d-flex gap-1">
|
<div className="d-flex gap-1">
|
||||||
<Form.Control
|
<Form.Control
|
||||||
ref={memberLogin === login ? inputRef : undefined}
|
|
||||||
type="number"
|
type="number"
|
||||||
size="sm"
|
size="sm"
|
||||||
value={editAmounts[key]}
|
value={editAmounts[key]}
|
||||||
onChange={e => setEditAmounts(prev => ({ ...prev, [key]: e.target.value }))}
|
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; }); }}
|
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: 75 }}
|
style={{ width: 95 }}
|
||||||
autoFocus={memberLogin === login}
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button size="sm" variant="outline-success" onClick={() => handleSaveAmount(group.id, memberLogin)}>✓</Button>
|
<Button size="sm" variant="outline-success" onClick={() => handleSaveAmount(group.id, memberLogin)}>✓</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||||
onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [key]: String(member.amount ?? '') }))}
|
onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [key]: member.amount != null ? String(member.amount / 100) : '' }))}
|
||||||
title={canEdit ? 'Klikněte pro úpravu' : undefined}
|
title={canEdit ? 'Klikněte pro úpravu' : undefined}
|
||||||
>
|
>
|
||||||
{member.amount != null ? `${member.amount} Kč` : <span className="text-muted">—</span>}
|
{member.amount != null ? `${member.amount / 100} Kč` : <span className="text-muted">—</span>}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -404,11 +407,11 @@ export default function OrderGroupsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||||
onClick={() => canEdit && setEditSurcharges(prev => ({ ...prev, [key]: { text: member.surchargeText ?? '', amount: member.surchargeAmount != null ? String(member.surchargeAmount) : '' } }))}
|
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}
|
title={canEdit ? 'Klikněte pro úpravu příplatku' : undefined}
|
||||||
>
|
>
|
||||||
{member.surchargeAmount != null && member.surchargeAmount > 0 ? (
|
{member.surchargeAmount != null && member.surchargeAmount > 0 ? (
|
||||||
<small>{member.surchargeText ? `${member.surchargeText}: ` : ''}<strong>{member.surchargeAmount} Kč</strong></small>
|
<small>{member.surchargeText ? `${member.surchargeText}: ` : ''}<strong>{member.surchargeAmount / 100} Kč</strong></small>
|
||||||
) : (
|
) : (
|
||||||
<small className="text-muted">—</small>
|
<small className="text-muted">—</small>
|
||||||
)}
|
)}
|
||||||
@@ -440,7 +443,7 @@ export default function OrderGroupsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
|
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
|
||||||
{memberTotal > 0 ? `${memberTotal} Kč` : '—'}
|
{memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'}
|
||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -459,18 +462,35 @@ export default function OrderGroupsPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</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} Kč</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{/* Souhrn poplatků a slevy */}
|
{/* Souhrn poplatků a slevy */}
|
||||||
{(totalFees > 0 || (group.discountValue != null && group.discountValue > 0)) && (
|
{(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)' }}>
|
<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} Kč</strong></span>}
|
{group.fees != null && group.fees > 0 && <span>Poplatky: <strong>{group.fees / 100} Kč</strong></span>}
|
||||||
{group.shipping != null && group.shipping > 0 && <span>Doprava: <strong>{group.shipping} Kč</strong></span>}
|
{group.shipping != null && group.shipping > 0 && <span>Doprava: <strong>{group.shipping / 100} Kč</strong></span>}
|
||||||
{group.tip != null && group.tip > 0 && <span>Spropitné: <strong>{group.tip} Kč</strong></span>}
|
{group.tip != null && group.tip > 0 && <span>Spropitné: <strong>{group.tip / 100} Kč</strong></span>}
|
||||||
{feeShare > 0 && <span>→ <strong>{feeShare} Kč</strong>/os.</span>}
|
{feeShare > 0 && <span>→ <strong>{feeShare / 100} Kč</strong>/os.</span>}
|
||||||
{group.discountValue != null && group.discountValue > 0 && (
|
{group.discountValue != null && group.discountValue > 0 && (
|
||||||
<span className="text-success">
|
<span className="text-success">
|
||||||
Sleva: <strong>{group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue} Kč`}</strong>
|
Sleva: <strong>{group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -522,7 +542,6 @@ export default function OrderGroupsPage() {
|
|||||||
<small className="text-muted">
|
<small className="text-muted">
|
||||||
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
|
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
|
||||||
</small>
|
</small>
|
||||||
{isCreator && <small className="text-muted fst-italic">(upravit)</small>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -564,6 +583,7 @@ export default function OrderGroupsPage() {
|
|||||||
<PayForGroupModal
|
<PayForGroupModal
|
||||||
isOpen={!!payModal}
|
isOpen={!!payModal}
|
||||||
onClose={() => setPayModal(null)}
|
onClose={() => setPayModal(null)}
|
||||||
|
onSuccess={fetchData}
|
||||||
group={payModal}
|
group={payModal}
|
||||||
groupId={payModal.id}
|
groupId={payModal.id}
|
||||||
payerLogin={auth.login}
|
payerLogin={auth.login}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
|||||||
plugins: [react(), viteTsconfigPaths()],
|
plugins: [react(), viteTsconfigPaths()],
|
||||||
server: {
|
server: {
|
||||||
open: true,
|
open: true,
|
||||||
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:3001',
|
'/api': 'http://localhost:3001',
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ 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.
|
||||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3001';
|
// Port 3099 avoids conflicts with locally running Docker containers on 3001-3003.
|
||||||
|
// 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> = {
|
||||||
@@ -15,6 +18,7 @@ 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;
|
||||||
@@ -50,7 +54,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:3001/api/health`,
|
url: `http://127.0.0.1:${E2E_PORT}/api/health`,
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
env: serverEnv,
|
env: serverEnv,
|
||||||
|
|||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
# Kubernetes — Luncher HA
|
||||||
|
|
||||||
|
Manifesty pro nasazení Luncheru na Kubernetes s vysokou dostupností (3 repliky, Redis adapter pro Socket.io, WATCH/MULTI atomické zápisy, graceful shutdown).
|
||||||
|
|
||||||
|
## Prerekvizity
|
||||||
|
|
||||||
|
- kubectl nakonfigurovaný na cílový cluster
|
||||||
|
- `helm` nainstalovaný
|
||||||
|
- Redis Stack image přístupný z clusteru (`redis/redis-stack-server:7.2.0-v14`)
|
||||||
|
- Obraz `luncher:ha-test` načtený do clusteru (viz níže)
|
||||||
|
|
||||||
|
## Lokální kind cluster (testik) — setup
|
||||||
|
|
||||||
|
### 1. Smazat a znovu vytvořit cluster s port mappings
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:KIND_EXPERIMENTAL_PROVIDER = "nerdctl"
|
||||||
|
# Přidat nerdctl do PATH (Rancher Desktop)
|
||||||
|
$env:PATH += ";$env:LOCALAPPDATA\Programs\Rancher Desktop\resources\resources\win32\bin"
|
||||||
|
|
||||||
|
kind delete cluster --name testik
|
||||||
|
kind create cluster --name testik --config k8s/kind/testik.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Sestavit a načíst obraz
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker build -t luncher:ha-test .
|
||||||
|
|
||||||
|
# Uložit a načíst přes nerdctl (kind + nerdctl provider)
|
||||||
|
nerdctl save luncher:ha-test -o luncher.tar
|
||||||
|
kind load image-archive luncher.tar --name testik
|
||||||
|
Remove-Item luncher.tar
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Nainstalovat Traefik (rke2-traefik)
|
||||||
|
|
||||||
|
> **Prerekvizita (Rancher Desktop):** Pokud Rancher Desktop běží s `kubernetes.options.traefik=true`,
|
||||||
|
> host-switch.exe obsadí port 80 dříve než kind. Vypni traefik v k3s:
|
||||||
|
> ```powershell
|
||||||
|
> rdctl set --kubernetes.options.traefik=false
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> **Prerekvizita — inotify limity:** Čtyř-uzlový kind cluster vyčerpá výchozí
|
||||||
|
> `fs.inotify.max_user_instances=128`. kube-proxy pak padá s „too many open files".
|
||||||
|
> Zvyš limit v rancher-desktop WSL2 (přežije restart WSL2, ale ne reboot — přidej do
|
||||||
|
> `/etc/sysctl.d/99-kind.conf` pro trvalost):
|
||||||
|
> ```powershell
|
||||||
|
> wsl -d rancher-desktop -- sysctl -w fs.inotify.max_user_instances=1280
|
||||||
|
> ```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# rke2-traefik je v rke2-charts, ne rancher-charts
|
||||||
|
helm repo add rke2-charts https://rke2-charts.rancher.io
|
||||||
|
helm repo update
|
||||||
|
|
||||||
|
# Nejdřív CRD chart, pak samotný chart
|
||||||
|
helm install traefik-crd rke2-charts/rke2-traefik-crd -n kube-system --create-namespace
|
||||||
|
helm install traefik rke2-charts/rke2-traefik -n kube-system `
|
||||||
|
--set "tolerations[0].key=node-role.kubernetes.io/control-plane" `
|
||||||
|
--set "tolerations[0].operator=Exists" `
|
||||||
|
--set "tolerations[0].effect=NoSchedule"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ověř že Traefik DaemonSet běží na control-plane (má hostPort 80):
|
||||||
|
```powershell
|
||||||
|
kubectl get ds -n kube-system traefik-rke2-traefik
|
||||||
|
kubectl get pods -n kube-system -o wide | Select-String traefik
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Nainstalovat Reloader
|
||||||
|
|
||||||
|
[stakater/Reloader](https://github.com/stakater/Reloader) sleduje změny Secret a ConfigMap a automaticky spustí rolling restart Deploymentu — odpadá nutnost ručního `kubectl rollout restart` po rotaci `JWT_SECRET` nebo `ADMIN_PASSWORD`.
|
||||||
|
|
||||||
|
Manifest je vendorovaný ve verzi v1.4.16 (`k8s/base/reloader.yaml`). Nasadit do `default` namespace:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
kubectl apply -f k8s/base/reloader.yaml
|
||||||
|
kubectl rollout status deploy/reloader-reloader
|
||||||
|
```
|
||||||
|
|
||||||
|
Reloader běží cluster-wide díky `ClusterRoleBinding` — nepotřebuje žádnou konfiguraci per-namespace. Deployment Luncheru má anotaci `reloader.stakater.com/auto: "true"`, která říká Reloaderu, ať sleduje všechny Secrety a ConfigMapy odkazované přes `envFrom`.
|
||||||
|
|
||||||
|
### 5. Nasadit Luncher
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Namespace + Redis
|
||||||
|
kubectl apply -f k8s/base/namespace.yaml
|
||||||
|
kubectl apply -f k8s/base/redis-statefulset.yaml
|
||||||
|
kubectl apply -f k8s/base/redis-service.yaml
|
||||||
|
|
||||||
|
# Počkat na Redis
|
||||||
|
kubectl rollout status statefulset/redis -n luncher
|
||||||
|
|
||||||
|
# Server secret (nebo použít šablonu server-secret.yaml)
|
||||||
|
kubectl create secret generic luncher-secrets -n luncher `
|
||||||
|
--from-literal=JWT_SECRET=dev-secret-change-me `
|
||||||
|
--from-literal=ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
|
# Server
|
||||||
|
kubectl apply -f k8s/base/server-configmap.yaml
|
||||||
|
kubectl apply -f k8s/base/server-deployment.yaml
|
||||||
|
kubectl apply -f k8s/base/server-service.yaml
|
||||||
|
kubectl apply -f k8s/base/server-pdb.yaml
|
||||||
|
kubectl apply -f k8s/base/ingressroute.yaml
|
||||||
|
|
||||||
|
# Počkat na server
|
||||||
|
kubectl rollout status deploy/luncher -n luncher
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testovací scénáře
|
||||||
|
|
||||||
|
### Baseline
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
kubectl get pods -n luncher -o wide
|
||||||
|
# Ověř: 3 pody na 3 různých worker uzlech, status Running
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rolling update bez výpadku
|
||||||
|
|
||||||
|
V jednom terminálu posílej provoz:
|
||||||
|
```powershell
|
||||||
|
# Nainstaluj hey: go install github.com/rakyll/hey@latest
|
||||||
|
hey -z 60s -c 20 http://luncher.localhost/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Ve druhém terminálu spusť rollout:
|
||||||
|
```powershell
|
||||||
|
kubectl rollout restart deploy/luncher -n luncher
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kritérium: 0 non-2xx odpovědí, 0 connection errors.**
|
||||||
|
|
||||||
|
### Node drain
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
kubectl cordon testik-worker2
|
||||||
|
kubectl drain testik-worker2 --ignore-daemonsets --delete-emptydir-data
|
||||||
|
# PDB zabrání souběžnému drainu druhého nodu
|
||||||
|
kubectl get pods -n luncher -o wide # pody se přeplánují
|
||||||
|
kubectl uncordon testik-worker2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ověření Socket.io cross-pod
|
||||||
|
|
||||||
|
1. Otevři dvě záložky prohlížeče na `http://luncher.localhost`
|
||||||
|
2. Z jednoho podu vyvolej změnu:
|
||||||
|
```powershell
|
||||||
|
kubectl exec -it deploy/luncher -n luncher -- curl -s -X POST localhost:3001/api/...
|
||||||
|
```
|
||||||
|
3. Ověř, že druhá záložka (pravděpodobně jiný pod) obdrží WebSocket event
|
||||||
|
|
||||||
|
### Concurrent write test
|
||||||
|
|
||||||
|
1. Otevři stejnou Pizza day objednávku ve dvou záložkách
|
||||||
|
2. Simuluj souběžné odeslání (otevřít DevTools → síť → odeslat obě požadavky současně)
|
||||||
|
3. Ověř Redis: `kubectl exec -it redis-0 -n luncher -- redis-cli JSON.GET luncher:<datum>`
|
||||||
|
— oba zápisy musí být zachovány (WATCH/MULTI retry)
|
||||||
|
|
||||||
|
### Auto-rollout při změně Secret / ConfigMap
|
||||||
|
|
||||||
|
Reloader automaticky spustí rolling restart, kdykoli se změní `luncher-secrets` nebo `luncher-config`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Příklad: rotace admin hesla
|
||||||
|
kubectl -n luncher patch secret luncher-secrets --type=merge `
|
||||||
|
-p '{"stringData":{"ADMIN_PASSWORD":"nove-heslo"}}'
|
||||||
|
|
||||||
|
# Reloader detekuje změnu resourceVersion a patchne pod template
|
||||||
|
kubectl rollout status deploy/luncher -n luncher
|
||||||
|
|
||||||
|
# Ověř anotaci přidanou Reloaderem na pod template
|
||||||
|
kubectl get deploy luncher -n luncher -o yaml | Select-String "STAKATER"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kritérium: pody se automaticky vyrolují bez ručního restartu. PDB zajistí, že alespoň jeden pod zůstane dostupný.**
|
||||||
|
|
||||||
|
## Pořadí aplikace manifestů
|
||||||
|
|
||||||
|
1. `reloader.yaml` (do `default` namespace — musí být před Deployment)
|
||||||
|
2. `namespace.yaml`
|
||||||
|
3. `redis-statefulset.yaml` + `redis-service.yaml`
|
||||||
|
4. `server-configmap.yaml` + `server-secret.yaml`
|
||||||
|
5. `server-deployment.yaml` + `server-service.yaml` + `server-pdb.yaml`
|
||||||
|
6. `ingressroute.yaml`
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: luncher
|
||||||
|
namespace: luncher
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/ingress.class: traefik
|
||||||
|
spec:
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
routes:
|
||||||
|
- match: Host(`luncher.localhost`)
|
||||||
|
kind: Rule
|
||||||
|
services:
|
||||||
|
- name: luncher
|
||||||
|
port: 3001
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: luncher
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: redis
|
||||||
|
namespace: luncher
|
||||||
|
spec:
|
||||||
|
clusterIP: None # headless — StatefulSet pod discovery
|
||||||
|
selector:
|
||||||
|
app: redis
|
||||||
|
ports:
|
||||||
|
- port: 6379
|
||||||
|
targetPort: 6379
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: redis
|
||||||
|
namespace: luncher
|
||||||
|
spec:
|
||||||
|
serviceName: redis
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: redis
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: redis
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: redis
|
||||||
|
# Redis Stack je nutný — aplikace používá JSON.GET / JSON.SET (modul RedisJSON)
|
||||||
|
image: redis/redis-stack-server:7.2.0-v14
|
||||||
|
ports:
|
||||||
|
- containerPort: 6379
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["redis-cli", "ping"]
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["redis-cli", "ping"]
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: data
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# stakater/Reloader v1.4.16
|
||||||
|
# Zdroj: https://raw.githubusercontent.com/stakater/Reloader/v1.4.16/deployments/kubernetes/reloader.yaml
|
||||||
|
# Aktualizace: stáhnout novou verzi ze stejné URL a nahradit tento soubor.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: reloader-reloader
|
||||||
|
namespace: default
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: reloader-reloader-metadata-role
|
||||||
|
namespace: default
|
||||||
|
rules:
|
||||||
|
- apiGroups:
|
||||||
|
- ""
|
||||||
|
resources:
|
||||||
|
- configmaps
|
||||||
|
verbs:
|
||||||
|
- list
|
||||||
|
- get
|
||||||
|
- watch
|
||||||
|
- create
|
||||||
|
- update
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: reloader-reloader-role
|
||||||
|
rules:
|
||||||
|
- apiGroups:
|
||||||
|
- ""
|
||||||
|
resources:
|
||||||
|
- secrets
|
||||||
|
- configmaps
|
||||||
|
verbs:
|
||||||
|
- list
|
||||||
|
- get
|
||||||
|
- watch
|
||||||
|
- apiGroups:
|
||||||
|
- apps
|
||||||
|
resources:
|
||||||
|
- deployments
|
||||||
|
- daemonsets
|
||||||
|
- statefulsets
|
||||||
|
verbs:
|
||||||
|
- list
|
||||||
|
- get
|
||||||
|
- update
|
||||||
|
- patch
|
||||||
|
- apiGroups:
|
||||||
|
- extensions
|
||||||
|
resources:
|
||||||
|
- deployments
|
||||||
|
- daemonsets
|
||||||
|
verbs:
|
||||||
|
- list
|
||||||
|
- get
|
||||||
|
- update
|
||||||
|
- patch
|
||||||
|
- apiGroups:
|
||||||
|
- batch
|
||||||
|
resources:
|
||||||
|
- cronjobs
|
||||||
|
verbs:
|
||||||
|
- list
|
||||||
|
- get
|
||||||
|
- apiGroups:
|
||||||
|
- batch
|
||||||
|
resources:
|
||||||
|
- jobs
|
||||||
|
verbs:
|
||||||
|
- create
|
||||||
|
- delete
|
||||||
|
- list
|
||||||
|
- get
|
||||||
|
- apiGroups:
|
||||||
|
- ""
|
||||||
|
resources:
|
||||||
|
- events
|
||||||
|
verbs:
|
||||||
|
- create
|
||||||
|
- patch
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: reloader-reloader-metadata-rolebinding
|
||||||
|
namespace: default
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: reloader-reloader-metadata-role
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: reloader-reloader
|
||||||
|
namespace: default
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: reloader-reloader-role-binding
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: reloader-reloader-role
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: reloader-reloader
|
||||||
|
namespace: default
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: reloader-reloader
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
revisionHistoryLimit: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: reloader-reloader
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: reloader-reloader
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- env:
|
||||||
|
- name: GOMAXPROCS
|
||||||
|
valueFrom:
|
||||||
|
resourceFieldRef:
|
||||||
|
divisor: "1"
|
||||||
|
resource: limits.cpu
|
||||||
|
- name: GOMEMLIMIT
|
||||||
|
valueFrom:
|
||||||
|
resourceFieldRef:
|
||||||
|
divisor: "1"
|
||||||
|
resource: limits.memory
|
||||||
|
- name: RELOADER_NAMESPACE
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.namespace
|
||||||
|
- name: RELOADER_DEPLOYMENT_NAME
|
||||||
|
value: reloader-reloader
|
||||||
|
image: ghcr.io/stakater/reloader:v1.4.16
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
livenessProbe:
|
||||||
|
failureThreshold: 5
|
||||||
|
httpGet:
|
||||||
|
path: /live
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
successThreshold: 1
|
||||||
|
timeoutSeconds: 5
|
||||||
|
name: reloader-reloader
|
||||||
|
ports:
|
||||||
|
- containerPort: 9090
|
||||||
|
name: http
|
||||||
|
readinessProbe:
|
||||||
|
failureThreshold: 5
|
||||||
|
httpGet:
|
||||||
|
path: /metrics
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
successThreshold: 1
|
||||||
|
timeoutSeconds: 5
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: 512Mi
|
||||||
|
requests:
|
||||||
|
cpu: 10m
|
||||||
|
memory: 512Mi
|
||||||
|
securityContext: {}
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65534
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
serviceAccountName: reloader-reloader
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: luncher-config
|
||||||
|
namespace: luncher
|
||||||
|
data:
|
||||||
|
NODE_ENV: production
|
||||||
|
STORAGE: redis
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: "6379"
|
||||||
|
PORT: "3001"
|
||||||
|
HOST: "0.0.0.0"
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: luncher
|
||||||
|
namespace: luncher
|
||||||
|
spec:
|
||||||
|
replicas: 3
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: luncher
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxSurge: 0 # nelze přidat extra pod — každý worker je obsazen
|
||||||
|
maxUnavailable: 1 # nejdřív smaž starý pod, pak naplánuj nový
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: luncher
|
||||||
|
annotations:
|
||||||
|
reloader.stakater.com/auto: "true"
|
||||||
|
spec:
|
||||||
|
terminationGracePeriodSeconds: 30
|
||||||
|
|
||||||
|
# Rozmístit každý pod na jiný worker uzel
|
||||||
|
affinity:
|
||||||
|
podAntiAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
- labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: luncher
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
|
||||||
|
containers:
|
||||||
|
- name: luncher
|
||||||
|
image: luncher:ha-test
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- containerPort: 3001
|
||||||
|
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: luncher-config
|
||||||
|
- secretRef:
|
||||||
|
name: luncher-secrets
|
||||||
|
|
||||||
|
env:
|
||||||
|
# POD_ID pro leader election scheduleru připomínek
|
||||||
|
- name: POD_ID
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
|
||||||
|
# Liveness — levná kontrola bez externích závislostí
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 3001
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
failureThreshold: 3
|
||||||
|
|
||||||
|
# Readiness — kontroluje Redis; při shutdown vrací 503
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health/ready
|
||||||
|
port: 3001
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 5
|
||||||
|
failureThreshold: 2
|
||||||
|
|
||||||
|
# preStop sleep: dá čas kube-proxy a Traefiku odebrat endpoint
|
||||||
|
# dřív než kontejner začne odmítat nová spojení
|
||||||
|
lifecycle:
|
||||||
|
preStop:
|
||||||
|
exec:
|
||||||
|
command: ["sleep", "5"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: policy/v1
|
||||||
|
kind: PodDisruptionBudget
|
||||||
|
metadata:
|
||||||
|
name: luncher-pdb
|
||||||
|
namespace: luncher
|
||||||
|
spec:
|
||||||
|
minAvailable: 2 # ze 3 replik, max 1 voluntary disruption najednou
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: luncher
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Šablona — hodnoty jsou zástupné symboly.
|
||||||
|
# Pro kind test vytvoř secret příkazem:
|
||||||
|
# kubectl create secret generic luncher-secrets -n luncher \
|
||||||
|
# --from-literal=JWT_SECRET=<your-secret> \
|
||||||
|
# --from-literal=ADMIN_PASSWORD=<your-password>
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: luncher-secrets
|
||||||
|
namespace: luncher
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
JWT_SECRET: CHANGE_ME
|
||||||
|
ADMIN_PASSWORD: CHANGE_ME
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: luncher
|
||||||
|
namespace: luncher
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: luncher
|
||||||
|
ports:
|
||||||
|
- port: 3001
|
||||||
|
targetPort: 3001
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
kind: Cluster
|
||||||
|
apiVersion: kind.x-k8s.io/v1alpha4
|
||||||
|
nodes:
|
||||||
|
- role: control-plane
|
||||||
|
# Mapuje porty na Windows localhost — luncher.localhost resolves to 127.0.0.1
|
||||||
|
# Traefik na control-plane podu poslouchá na těchto portech přes hostPort
|
||||||
|
extraPortMappings:
|
||||||
|
- containerPort: 80
|
||||||
|
hostPort: 80
|
||||||
|
protocol: TCP
|
||||||
|
- containerPort: 443
|
||||||
|
hostPort: 443
|
||||||
|
protocol: TCP
|
||||||
|
- role: worker
|
||||||
|
- role: worker
|
||||||
|
- role: worker
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
+8
-8
@@ -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 Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
throw new 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 Error("Proměnná prostředí JWT_SECRET musí být minimálně 32 znaků");
|
throw new 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 Error("Nebyl předán login");
|
throw new 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 Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
throw new 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 Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||||
}
|
}
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw Error("Nebyl předán token");
|
throw new 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 Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||||
}
|
}
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw Error("Nebyl předán token");
|
throw new 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;
|
||||||
|
|||||||
@@ -28,16 +28,16 @@ const buildPizzaUrl = (pizzaUrl: string) => {
|
|||||||
return `${baseUrl}/${pizzaUrl}`;
|
return `${baseUrl}/${pizzaUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ceny krabic dle velikosti
|
// Ceny krabic dle velikosti v haléřích
|
||||||
const boxPrices: { [key: string]: number } = {
|
const boxPrices: { [key: string]: number } = {
|
||||||
"30cm": 13,
|
"30cm": 1300,
|
||||||
"35cm": 15,
|
"35cm": 1500,
|
||||||
"40cm": 18,
|
"40cm": 1800,
|
||||||
"50cm": 25
|
"50cm": 2500
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cena obalu pro salát
|
// Cena obalu pro salát v haléřích
|
||||||
const SALAT_BOX_PRICE = 13;
|
const SALAT_BOX_PRICE = 1300;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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]);
|
const price = Number.parseInt($($(elm).contents().get(1)).text().trim().split(" Kč")[0]) * 100;
|
||||||
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]);
|
const price = Number.parseInt(priceText.split(' Kč')[0]) * 100;
|
||||||
result.push({ name, ingredients, price: price + SALAT_BOX_PRICE });
|
result.push({ name, ingredients, price: price + SALAT_BOX_PRICE });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
|
|||||||
await removePendingQrsByGroupId(memberLogins, groupId);
|
await removePendingQrsByGroupId(memberLogins, groupId);
|
||||||
group.orderedAt = undefined;
|
group.orderedAt = undefined;
|
||||||
group.deliveryAt = undefined;
|
group.deliveryAt = undefined;
|
||||||
|
group.qrGenerated = undefined;
|
||||||
for (const ml of memberLogins) {
|
for (const ml of memberLogins) {
|
||||||
group.members[ml] = { ...group.members[ml], paid: undefined };
|
group.members[ml] = { ...group.members[ml], paid: undefined };
|
||||||
}
|
}
|
||||||
@@ -139,6 +140,16 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
|
|||||||
return saveExtraData(data, date);
|
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> {
|
export async function markGroupMemberPaid(login: string, groupId: string, date?: Date): Promise<ClientData | null> {
|
||||||
const data = await getExtraData(date);
|
const data = await getExtraData(date);
|
||||||
const group = findGroup(data, groupId);
|
const group = findGroup(data, groupId);
|
||||||
|
|||||||
+86
-39
@@ -9,9 +9,11 @@ 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, initRedisAdapter, shutdownWebsocketClients, getWebsocket } from "./websocket";
|
||||||
import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder";
|
import { startReminderScheduler, stopReminderScheduler, releaseReminderLease, verifyQuickChoiceToken } from "./pushReminder";
|
||||||
import { storageReady } from "./storage";
|
import { storageReady } from "./storage";
|
||||||
|
import getStorage from "./storage";
|
||||||
|
import { shutdownRedisStorage } from "./storage/redis";
|
||||||
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
||||||
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
||||||
import votingRoutes from "./routes/votingRoutes";
|
import votingRoutes from "./routes/votingRoutes";
|
||||||
@@ -27,23 +29,24 @@ 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
|
|
||||||
if (!process.env.JWT_SECRET) {
|
if (!process.env.JWT_SECRET) {
|
||||||
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = require("http").createServer(app);
|
const server = require("http").createServer(app);
|
||||||
|
|
||||||
|
// Tune keep-alive timeouts to outlive Traefik's 60s idle timeout.
|
||||||
|
// headersTimeout must be strictly greater than keepAliveTimeout.
|
||||||
|
server.keepAliveTimeout = 65_000;
|
||||||
|
server.headersTimeout = 66_000;
|
||||||
|
server.requestTimeout = 30_000;
|
||||||
|
|
||||||
initWebsocket(server);
|
initWebsocket(server);
|
||||||
|
|
||||||
// Body-parser middleware for parsing JSON
|
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
|
app.use(cors({ origin: '*' }));
|
||||||
|
|
||||||
app.use(cors({
|
|
||||||
origin: '*'
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Zapínatelný login přes hlavičky - pokud je zapnutý nepovolí "basicauth"
|
|
||||||
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
|
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
|
||||||
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
|
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
|
||||||
if (HTTP_REMOTE_USER_ENABLED) {
|
if (HTTP_REMOTE_USER_ENABLED) {
|
||||||
@@ -51,19 +54,69 @@ if (HTTP_REMOTE_USER_ENABLED) {
|
|||||||
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
|
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
|
||||||
}
|
}
|
||||||
const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim());
|
const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim());
|
||||||
//TODO: nevim jak udelat console.log pouze pro "debug"
|
|
||||||
//console.log("Budu věřit hlavičkám z: " + HTTP_REMOTE_TRUSTED_IPS);
|
|
||||||
app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS);
|
app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS);
|
||||||
console.log('Zapnutý login přes hlavičky z proxy.');
|
console.log('Zapnutý login přes hlavičky z proxy.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Shutdown state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ----------- Metody nevyžadující token --------------
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
async function shutdown(signal: string) {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
console.log(`${signal} received — initiating graceful shutdown`);
|
||||||
|
|
||||||
|
// Hard-exit failsafe: fires before terminationGracePeriodSeconds (30s)
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error('Graceful shutdown timed out, forcing exit');
|
||||||
|
process.exit(1);
|
||||||
|
}, 25_000).unref();
|
||||||
|
|
||||||
|
// Disconnect WebSocket clients so they reconnect to another pod
|
||||||
|
const io = getWebsocket();
|
||||||
|
io?.disconnectSockets(true);
|
||||||
|
|
||||||
|
// Stop accepting new HTTP connections and drain in-flight requests
|
||||||
|
(server as any).closeIdleConnections?.();
|
||||||
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||||
|
|
||||||
|
// Stop reminder scheduler and release leader lease
|
||||||
|
stopReminderScheduler();
|
||||||
|
await releaseReminderLease();
|
||||||
|
|
||||||
|
// Shut down Redis pub/sub clients (Socket.io adapter)
|
||||||
|
await shutdownWebsocketClients();
|
||||||
|
|
||||||
|
// Shut down main Redis storage client
|
||||||
|
if (process.env.STORAGE?.toLowerCase() === 'redis') {
|
||||||
|
await shutdownRedisStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Graceful shutdown complete');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|
||||||
|
// ─── Routes — no auth required ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Liveness probe — cheap, no external deps. */
|
||||||
app.get("/api/health", (_req, res) => {
|
app.get("/api/health", (_req, res) => {
|
||||||
res.status(200).json({ ok: true });
|
res.status(200).json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Readiness probe — verifies Redis connectivity and rejects traffic during shutdown. */
|
||||||
|
app.get("/api/health/ready", async (_req, res) => {
|
||||||
|
if (shuttingDown) {
|
||||||
|
return res.status(503).json({ ok: false, reason: 'shutting down' });
|
||||||
|
}
|
||||||
|
const healthy = await getStorage().healthCheck?.() ?? true;
|
||||||
|
if (!healthy) return res.status(503).json({ ok: false, reason: 'storage unavailable' });
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/whoami", (req, res) => {
|
app.get("/api/whoami", (req, res) => {
|
||||||
if (!HTTP_REMOTE_USER_ENABLED) {
|
if (!HTTP_REMOTE_USER_ENABLED) {
|
||||||
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
|
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
|
||||||
@@ -76,21 +129,17 @@ app.get("/api/whoami", (req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.post("/api/login", (req, res) => {
|
app.post("/api/login", (req, res) => {
|
||||||
if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy')
|
if (HTTP_REMOTE_USER_ENABLED) {
|
||||||
// Autentizace pomocí trusted headers
|
|
||||||
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
|
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
|
||||||
//const remoteName = req.header('remote-name');
|
|
||||||
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 Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
|
throw new Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Klasická autentizace loginem
|
|
||||||
if (!req.body?.login || req.body.login.trim().length === 0) {
|
if (!req.body?.login || req.body.login.trim().length === 0) {
|
||||||
throw Error("Nebyl předán login");
|
throw new Error("Nebyl předán login");
|
||||||
}
|
}
|
||||||
// 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));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -111,12 +160,10 @@ app.get("/api/qr", async (req, res) => {
|
|||||||
res.end(img);
|
res.end(img);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ─── Semi-public routes ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
// 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) => {
|
app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { login, token } = req.body ?? {};
|
const { login, token } = req.body ?? {};
|
||||||
@@ -132,10 +179,10 @@ app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
|
|||||||
} catch (e: any) { next(e); }
|
} catch (e: any) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Middleware ověřující JWT token */
|
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.use("/api/", (req, res, next) => {
|
app.use("/api/", (req, res, next) => {
|
||||||
if (HTTP_REMOTE_USER_ENABLED) {
|
if (HTTP_REMOTE_USER_ENABLED) {
|
||||||
// Autentizace pomocí trusted headers
|
|
||||||
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
|
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
|
||||||
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
|
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
|
||||||
delete req.headers["cookie"]
|
delete req.headers["cookie"]
|
||||||
@@ -158,7 +205,8 @@ app.use("/api/", (req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Vrátí data pro aktuální den. */
|
// ─── Authenticated routes ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get("/api/data", async (req, res) => {
|
app.get("/api/data", async (req, res) => {
|
||||||
let date = undefined;
|
let date = undefined;
|
||||||
if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') {
|
if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') {
|
||||||
@@ -167,7 +215,6 @@ app.get("/api/data", async (req, res) => {
|
|||||||
date = getDateForWeekIndex(parseInt(req.query.dayIndex));
|
date = getDateForWeekIndex(parseInt(req.query.dayIndex));
|
||||||
}
|
}
|
||||||
} else if (getIsWeekend(getToday())) {
|
} else if (getIsWeekend(getToday())) {
|
||||||
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
|
|
||||||
date = getDateForWeekIndex(4);
|
date = getDateForWeekIndex(4);
|
||||||
}
|
}
|
||||||
const slotParam = typeof req.query.slot === 'string' ? req.query.slot as MealSlot : undefined;
|
const slotParam = typeof req.query.slot === 'string' ? req.query.slot as MealSlot : undefined;
|
||||||
@@ -175,7 +222,6 @@ app.get("/api/data", async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Neplatný slot' });
|
return res.status(400).json({ error: 'Neplatný slot' });
|
||||||
}
|
}
|
||||||
const data = await getData(date, slotParam);
|
const data = await getData(date, slotParam);
|
||||||
// Připojíme nevyřízené QR kódy pro přihlášeného uživatele
|
|
||||||
try {
|
try {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const pendingQrs = await getPendingQrs(login);
|
const pendingQrs = await getPendingQrs(login);
|
||||||
@@ -188,7 +234,6 @@ app.get("/api/data", async (req, res) => {
|
|||||||
res.status(200).json(data);
|
res.status(200).json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ostatní routes
|
|
||||||
app.use("/api/pizzaDay", pizzaDayRoutes);
|
app.use("/api/pizzaDay", pizzaDayRoutes);
|
||||||
app.use("/api/food", foodRoutes);
|
app.use("/api/food", foodRoutes);
|
||||||
app.use("/api/voting", votingRoutes);
|
app.use("/api/voting", votingRoutes);
|
||||||
@@ -201,10 +246,12 @@ app.use("/api/changelogs", changelogRoutes);
|
|||||||
app.use("/api/groups", groupRoutes);
|
app.use("/api/groups", groupRoutes);
|
||||||
app.use("/api/stores", storeRoutes);
|
app.use("/api/stores", storeRoutes);
|
||||||
|
|
||||||
app.use('/stats', express.static('public'));
|
app.use(express.static(path.join(process.cwd(), 'public')));
|
||||||
app.use(express.static('public'));
|
app.get('*splat', (_req, res) => {
|
||||||
|
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
// Middleware pro zpracování chyb
|
// Error handling middleware
|
||||||
app.use((err: any, req: any, res: any, next: any) => {
|
app.use((err: any, req: any, res: any, next: any) => {
|
||||||
if (err instanceof InsufficientPermissions) {
|
if (err instanceof InsufficientPermissions) {
|
||||||
res.status(403).send({ error: err.message })
|
res.status(403).send({ error: err.message })
|
||||||
@@ -216,18 +263,18 @@ app.use((err: any, req: any, res: any, next: any) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Bootstrap ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PORT = process.env.PORT ?? 3001;
|
const PORT = process.env.PORT ?? 3001;
|
||||||
const HOST = process.env.HOST ?? '0.0.0.0';
|
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||||
|
|
||||||
storageReady.then(() => {
|
storageReady.then(async () => {
|
||||||
|
// Init Redis adapter after storage is connected (only in Redis mode)
|
||||||
|
if (process.env.STORAGE?.toLowerCase() === 'redis') {
|
||||||
|
await initRedisAdapter();
|
||||||
|
}
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`Server listening on ${HOST}, port ${PORT}`);
|
console.log(`Server listening on ${HOST}, port ${PORT}`);
|
||||||
startReminderScheduler();
|
startReminderScheduler();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Umožníme vypnutí serveru přes SIGINT, jinak Docker čeká než ho sestřelí
|
|
||||||
process.on('SIGINT', function () {
|
|
||||||
console.log("\nSIGINT (Ctrl-C), vypínám server");
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
+220
-220
@@ -661,30 +661,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 1,
|
varId: 1,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 138,
|
pizzaPrice: 13800,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 151
|
price: 15100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 2,
|
varId: 2,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 166,
|
pizzaPrice: 16600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 181
|
price: 18100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 3,
|
varId: 3,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 223,
|
pizzaPrice: 22300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 241
|
price: 24100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 4,
|
varId: 4,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 306,
|
pizzaPrice: 30600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 331
|
price: 33100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -700,30 +700,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 6,
|
varId: 6,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 142,
|
pizzaPrice: 14200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 155
|
price: 15500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 7,
|
varId: 7,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 172,
|
pizzaPrice: 17200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 187
|
price: 18700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 8,
|
varId: 8,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 233,
|
pizzaPrice: 23300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 251
|
price: 25100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 9,
|
varId: 9,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 316,
|
pizzaPrice: 31600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 341
|
price: 34100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -741,30 +741,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 10,
|
varId: 10,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 142,
|
pizzaPrice: 14200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 155
|
price: 15500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 11,
|
varId: 11,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 172,
|
pizzaPrice: 17200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 187
|
price: 18700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 12,
|
varId: 12,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 233,
|
pizzaPrice: 23300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 251
|
price: 25100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 13,
|
varId: 13,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 316,
|
pizzaPrice: 31600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 341
|
price: 34100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -780,30 +780,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 14,
|
varId: 14,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 142,
|
pizzaPrice: 14200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 155
|
price: 15500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 15,
|
varId: 15,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 172,
|
pizzaPrice: 17200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 187
|
price: 18700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 16,
|
varId: 16,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 233,
|
pizzaPrice: 23300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 251
|
price: 25100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 17,
|
varId: 17,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 294,
|
pizzaPrice: 29400,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 319
|
price: 31900
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -821,30 +821,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 22,
|
varId: 22,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 162,
|
pizzaPrice: 16200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 175
|
price: 17500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 23,
|
varId: 23,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 186,
|
pizzaPrice: 18600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 24,
|
varId: 24,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 263,
|
pizzaPrice: 26300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 281
|
price: 28100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 25,
|
varId: 25,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 346,
|
pizzaPrice: 34600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 371
|
price: 37100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -861,30 +861,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 26,
|
varId: 26,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 162,
|
pizzaPrice: 16200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 175
|
price: 17500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 27,
|
varId: 27,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 186,
|
pizzaPrice: 18600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 28,
|
varId: 28,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 263,
|
pizzaPrice: 26300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 281
|
price: 28100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 29,
|
varId: 29,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 346,
|
pizzaPrice: 34600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 371
|
price: 37100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -902,30 +902,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 30,
|
varId: 30,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 162,
|
pizzaPrice: 16200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 175
|
price: 17500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 31,
|
varId: 31,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 186,
|
pizzaPrice: 18600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 32,
|
varId: 32,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 263,
|
pizzaPrice: 26300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 281
|
price: 28100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 33,
|
varId: 33,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 346,
|
pizzaPrice: 34600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 371
|
price: 37100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -946,30 +946,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 34,
|
varId: 34,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 162,
|
pizzaPrice: 16200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 175
|
price: 17500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 35,
|
varId: 35,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 186,
|
pizzaPrice: 18600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 36,
|
varId: 36,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 263,
|
pizzaPrice: 26300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 281
|
price: 28100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 37,
|
varId: 37,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 346,
|
pizzaPrice: 34600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 371
|
price: 37100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -987,30 +987,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 38,
|
varId: 38,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 162,
|
pizzaPrice: 16200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 175
|
price: 17500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 39,
|
varId: 39,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 186,
|
pizzaPrice: 18600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 40,
|
varId: 40,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 263,
|
pizzaPrice: 26300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 281
|
price: 28100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 41,
|
varId: 41,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 346,
|
pizzaPrice: 34600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 371
|
price: 37100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1028,30 +1028,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 42,
|
varId: 42,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 172,
|
pizzaPrice: 17200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 185
|
price: 18500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 43,
|
varId: 43,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 212,
|
pizzaPrice: 21200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 227
|
price: 22700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 44,
|
varId: 44,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 293,
|
pizzaPrice: 29300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 311
|
price: 31100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 45,
|
varId: 45,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 376,
|
pizzaPrice: 37600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 401
|
price: 40100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1069,30 +1069,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 46,
|
varId: 46,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 182,
|
pizzaPrice: 18200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 195
|
price: 19500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 47,
|
varId: 47,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 222,
|
pizzaPrice: 22200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 237
|
price: 23700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 48,
|
varId: 48,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 303,
|
pizzaPrice: 30300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 321
|
price: 32100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 49,
|
varId: 49,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 386,
|
pizzaPrice: 38600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 411
|
price: 41100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1114,30 +1114,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 50,
|
varId: 50,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 182,
|
pizzaPrice: 18200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 195
|
price: 19500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 51,
|
varId: 51,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 222,
|
pizzaPrice: 22200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 237
|
price: 23700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 52,
|
varId: 52,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 303,
|
pizzaPrice: 30300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 321
|
price: 32100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 53,
|
varId: 53,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 396,
|
pizzaPrice: 39600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 421
|
price: 42100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1156,30 +1156,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 54,
|
varId: 54,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 182,
|
pizzaPrice: 18200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 195
|
price: 19500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 55,
|
varId: 55,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 222,
|
pizzaPrice: 22200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 237
|
price: 23700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 56,
|
varId: 56,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 303,
|
pizzaPrice: 30300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 321
|
price: 32100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 57,
|
varId: 57,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 396,
|
pizzaPrice: 39600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 421
|
price: 42100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1199,30 +1199,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 58,
|
varId: 58,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 182,
|
pizzaPrice: 18200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 195
|
price: 19500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 59,
|
varId: 59,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 222,
|
pizzaPrice: 22200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 237
|
price: 23700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 60,
|
varId: 60,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 303,
|
pizzaPrice: 30300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 321
|
price: 32100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 61,
|
varId: 61,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 396,
|
pizzaPrice: 39600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 421
|
price: 42100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1241,30 +1241,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 62,
|
varId: 62,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 188,
|
pizzaPrice: 18800,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 63,
|
varId: 63,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 226,
|
pizzaPrice: 22600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 241
|
price: 24100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 64,
|
varId: 64,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 313,
|
pizzaPrice: 31300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 331
|
price: 33100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 65,
|
varId: 65,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 426,
|
pizzaPrice: 42600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 451
|
price: 45100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1283,30 +1283,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 66,
|
varId: 66,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 188,
|
pizzaPrice: 18800,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 67,
|
varId: 67,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 226,
|
pizzaPrice: 22600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 241
|
price: 24100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 68,
|
varId: 68,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 313,
|
pizzaPrice: 31300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 331
|
price: 33100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 69,
|
varId: 69,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 426,
|
pizzaPrice: 42600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 451
|
price: 45100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1327,30 +1327,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 309,
|
varId: 309,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 182,
|
pizzaPrice: 18200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 195
|
price: 19500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 310,
|
varId: 310,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 222,
|
pizzaPrice: 22200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 237
|
price: 23700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 311,
|
varId: 311,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 303,
|
pizzaPrice: 30300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 321
|
price: 32100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 312,
|
varId: 312,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 396,
|
pizzaPrice: 39600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 421
|
price: 42100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1369,30 +1369,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 394,
|
varId: 394,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 188,
|
pizzaPrice: 18800,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 395,
|
varId: 395,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 226,
|
pizzaPrice: 22600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 241
|
price: 24100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 396,
|
varId: 396,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 313,
|
pizzaPrice: 31300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 331
|
price: 33100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 397,
|
varId: 397,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 426,
|
pizzaPrice: 42600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 451
|
price: 45100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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,
|
price: (174 + 13) * 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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,
|
price: (184 + 13) * 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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,
|
price: (164 + 13) * 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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,
|
price: (184 + 13) * 100,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+145
-330
@@ -10,10 +10,6 @@ import crypto from "crypto";
|
|||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
const PENDING_QR_PREFIX = 'pending_qr';
|
const PENDING_QR_PREFIX = 'pending_qr';
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí seznam dostupných pizz pro dnešní den.
|
|
||||||
* Stáhne je, pokud je pro dnešní den nemá.
|
|
||||||
*/
|
|
||||||
export async function getPizzaList(): Promise<Pizza[] | undefined> {
|
export async function getPizzaList(): Promise<Pizza[] | undefined> {
|
||||||
await initIfNeeded();
|
await initIfNeeded();
|
||||||
let clientData = await getClientData(getToday());
|
let clientData = await getClientData(getToday());
|
||||||
@@ -24,25 +20,17 @@ export async function getPizzaList(): Promise<Pizza[] | undefined> {
|
|||||||
return Promise.resolve(clientData.pizzaList);
|
return Promise.resolve(clientData.pizzaList);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Uloží seznam dostupných pizz pro dnešní den.
|
|
||||||
*
|
|
||||||
* @param pizzaList seznam dostupných pizz
|
|
||||||
*/
|
|
||||||
export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
|
export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
|
||||||
await initIfNeeded();
|
await initIfNeeded();
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
clientData.pizzaList = pizzaList;
|
const data = current ?? ({} as ClientData);
|
||||||
clientData.pizzaListLastUpdate = formatDate(new Date());
|
data.pizzaList = pizzaList;
|
||||||
await storage.setData(today, clientData);
|
data.pizzaListLastUpdate = formatDate(new Date());
|
||||||
return clientData;
|
return data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí seznam dostupných salátů pro dnešní den.
|
|
||||||
* Stáhne je, pokud je pro dnešní den nemá.
|
|
||||||
*/
|
|
||||||
export async function getSalatList(): Promise<Salat[] | undefined> {
|
export async function getSalatList(): Promise<Salat[] | undefined> {
|
||||||
await initIfNeeded();
|
await initIfNeeded();
|
||||||
let clientData = await getClientData(getToday());
|
let clientData = await getClientData(getToday());
|
||||||
@@ -53,423 +41,250 @@ export async function getSalatList(): Promise<Salat[] | undefined> {
|
|||||||
return Promise.resolve(clientData.salatList);
|
return Promise.resolve(clientData.salatList);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Uloží seznam dostupných salátů pro dnešní den.
|
|
||||||
*
|
|
||||||
* @param salatList seznam dostupných salátů
|
|
||||||
*/
|
|
||||||
export async function saveSalatList(salatList: Salat[]): Promise<ClientData> {
|
export async function saveSalatList(salatList: Salat[]): Promise<ClientData> {
|
||||||
await initIfNeeded();
|
await initIfNeeded();
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
clientData.salatList = salatList;
|
const data = current ?? ({} as ClientData);
|
||||||
await storage.setData(today, clientData);
|
data.salatList = salatList;
|
||||||
return clientData;
|
return data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Vytvoří pizza day pro aktuální den a vrátí data pro klienta.
|
|
||||||
*/
|
|
||||||
export async function createPizzaDay(creator: string): Promise<ClientData> {
|
export async function createPizzaDay(creator: string): Promise<ClientData> {
|
||||||
await initIfNeeded();
|
await initIfNeeded();
|
||||||
const clientData = await getClientData(getToday());
|
// Stáhneme pizzy a saláty před samotnou atomickou operací
|
||||||
if (clientData.pizzaDay) {
|
|
||||||
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ě!
|
|
||||||
const [pizzaList, salatList] = await Promise.all([getPizzaList(), getSalatList()]);
|
const [pizzaList, salatList] = await Promise.all([getPizzaList(), getSalatList()]);
|
||||||
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, salatList, ...clientData };
|
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
await storage.setData(today, data);
|
const result = await storage.updateData<ClientData>(today, (current) => {
|
||||||
callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } })
|
if (!current) throw Error("Data pro dnešní den nejsou inicializována");
|
||||||
return data;
|
if (current.pizzaDay) throw Error("Pizza day pro dnešní den již existuje");
|
||||||
|
return { ...current, pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, salatList };
|
||||||
|
});
|
||||||
|
callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } });
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Smaže pizza day pro aktuální den.
|
|
||||||
*/
|
|
||||||
export async function deletePizzaDay(login: string): Promise<ClientData> {
|
export async function deletePizzaDay(login: string): Promise<ClientData> {
|
||||||
const clientData = await getClientData(getToday());
|
|
||||||
if (!clientData.pizzaDay) {
|
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
|
||||||
}
|
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
|
||||||
throw Error("Login uživatele se neshoduje se zakladatelem Pizza Day");
|
|
||||||
}
|
|
||||||
delete clientData.pizzaDay;
|
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
await storage.setData(today, clientData);
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
return clientData;
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
|
if (current.pizzaDay.creator !== login) throw Error("Login uživatele se neshoduje se zakladatelem Pizza Day");
|
||||||
|
const data = { ...current };
|
||||||
|
delete data.pizzaDay;
|
||||||
|
return data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Přidá objednávku pizzy uživateli.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @param pizza zvolená pizza
|
|
||||||
* @param size zvolená velikost pizzy
|
|
||||||
*/
|
|
||||||
export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize) {
|
export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
||||||
}
|
let order: PizzaOrder | undefined = current.pizzaDay.orders?.find(o => o.customer === login);
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
|
||||||
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
|
||||||
}
|
|
||||||
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
|
|
||||||
if (!order) {
|
if (!order) {
|
||||||
order = {
|
order = { customer: login, pizzaList: [], totalPrice: 0, hasQr: false };
|
||||||
customer: login,
|
current.pizzaDay.orders ??= [];
|
||||||
pizzaList: [],
|
current.pizzaDay.orders.push(order);
|
||||||
totalPrice: 0,
|
|
||||||
hasQr: false,
|
|
||||||
}
|
|
||||||
clientData.pizzaDay.orders ??= [];
|
|
||||||
clientData.pizzaDay.orders.push(order);
|
|
||||||
}
|
|
||||||
const pizzaOrder: PizzaVariant = {
|
|
||||||
varId: size.varId,
|
|
||||||
name: pizza.name,
|
|
||||||
size: size.size,
|
|
||||||
price: size.price,
|
|
||||||
}
|
}
|
||||||
|
const pizzaOrder: PizzaVariant = { varId: size.varId, name: pizza.name, size: size.size, price: size.price };
|
||||||
order.pizzaList ??= [];
|
order.pizzaList ??= [];
|
||||||
order.pizzaList.push(pizzaOrder);
|
order.pizzaList.push(pizzaOrder);
|
||||||
order.totalPrice += pizzaOrder.price;
|
order.totalPrice += pizzaOrder.price;
|
||||||
await storage.setData(today, clientData);
|
return current;
|
||||||
return clientData;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Přidá objednávku salátu uživateli.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @param salat zvolený salát
|
|
||||||
*/
|
|
||||||
export async function addSalatOrder(login: string, salat: Salat) {
|
export async function addSalatOrder(login: string, salat: Salat) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
||||||
}
|
let order: PizzaOrder | undefined = current.pizzaDay.orders?.find(o => o.customer === login);
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
|
||||||
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
|
||||||
}
|
|
||||||
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
|
|
||||||
if (!order) {
|
if (!order) {
|
||||||
order = {
|
order = { customer: login, pizzaList: [], totalPrice: 0, hasQr: false };
|
||||||
customer: login,
|
current.pizzaDay.orders ??= [];
|
||||||
pizzaList: [],
|
current.pizzaDay.orders.push(order);
|
||||||
totalPrice: 0,
|
|
||||||
hasQr: false,
|
|
||||||
}
|
|
||||||
clientData.pizzaDay.orders ??= [];
|
|
||||||
clientData.pizzaDay.orders.push(order);
|
|
||||||
}
|
|
||||||
const salatOrder: PizzaVariant = {
|
|
||||||
varId: 0,
|
|
||||||
name: salat.name,
|
|
||||||
size: "1 porce",
|
|
||||||
price: salat.price,
|
|
||||||
category: 'salat',
|
|
||||||
}
|
}
|
||||||
|
const salatOrder: PizzaVariant = { varId: 0, name: salat.name, size: "1 porce", price: salat.price, category: 'salat' };
|
||||||
order.pizzaList ??= [];
|
order.pizzaList ??= [];
|
||||||
order.pizzaList.push(salatOrder);
|
order.pizzaList.push(salatOrder);
|
||||||
order.totalPrice += salatOrder.price;
|
order.totalPrice += salatOrder.price;
|
||||||
await storage.setData(today, clientData);
|
return current;
|
||||||
return clientData;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Odstraní všechny pizzy uživatele (celou jeho objednávku).
|
|
||||||
* Pokud Pizza day neexistuje nebo není ve stavu CREATED, neudělá nic.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @param date datum, pro které se objednávka odstraňuje (výchozí je dnešek)
|
|
||||||
* @returns aktuální data pro klienta
|
|
||||||
*/
|
|
||||||
export async function removeAllUserPizzas(login: string, date?: Date) {
|
export async function removeAllUserPizzas(login: string, date?: Date) {
|
||||||
const usedDate = date ?? getToday();
|
const usedDate = date ?? getToday();
|
||||||
const today = formatDate(usedDate);
|
const today = formatDate(usedDate);
|
||||||
const clientData = await getClientData(usedDate);
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
|
if (!current?.pizzaDay) return current ?? ({} as ClientData);
|
||||||
if (!clientData.pizzaDay) {
|
if (current.pizzaDay.state !== PizzaDayState.CREATED) return current;
|
||||||
return clientData; // Pizza day neexistuje, není co mazat
|
const orderIndex = current.pizzaDay.orders!.findIndex(o => o.customer === login);
|
||||||
}
|
if (orderIndex >= 0) current.pizzaDay.orders!.splice(orderIndex, 1);
|
||||||
|
return current;
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
});
|
||||||
return clientData; // Pizza day není ve stavu CREATED, nelze mazat
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login);
|
|
||||||
if (orderIndex >= 0) {
|
|
||||||
clientData.pizzaDay.orders!.splice(orderIndex, 1);
|
|
||||||
await storage.setData(today, clientData);
|
|
||||||
}
|
|
||||||
|
|
||||||
return clientData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Odstraní danou objednávku pizzy.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @param pizzaOrder objednávka pizzy
|
|
||||||
*/
|
|
||||||
export async function removePizzaOrder(login: string, pizzaOrder: PizzaVariant) {
|
export async function removePizzaOrder(login: string, pizzaOrder: PizzaVariant) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
const orderIndex = current.pizzaDay.orders!.findIndex(o => o.customer === login);
|
||||||
}
|
if (orderIndex < 0) throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login);
|
||||||
const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login);
|
const order = current.pizzaDay.orders![orderIndex];
|
||||||
if (orderIndex < 0) {
|
|
||||||
throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login);
|
|
||||||
}
|
|
||||||
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 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);
|
||||||
order.totalPrice -= price;
|
order.totalPrice -= price;
|
||||||
if (order.pizzaList!.length == 0) {
|
if (order.pizzaList!.length === 0) current.pizzaDay.orders!.splice(orderIndex, 1);
|
||||||
clientData.pizzaDay.orders!.splice(orderIndex, 1);
|
return current;
|
||||||
}
|
});
|
||||||
await storage.setData(today, clientData);
|
|
||||||
return clientData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Uzamkne možnost editovat objednávky pizzy.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @returns aktuální data pro uživatele
|
|
||||||
*/
|
|
||||||
export async function lockPizzaDay(login: string) {
|
export async function lockPizzaDay(login: string) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
if (current.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login);
|
||||||
}
|
if (current.pizzaDay.state !== PizzaDayState.CREATED && current.pizzaDay.state !== PizzaDayState.ORDERED) {
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
|
||||||
throw Error("Pizza day není spravován uživatelem " + login);
|
|
||||||
}
|
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED && clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
|
|
||||||
throw 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;
|
current.pizzaDay.state = PizzaDayState.LOCKED;
|
||||||
await storage.setData(today, clientData);
|
return current;
|
||||||
return clientData;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Odekmne možnost editovat objednávky pizzy.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @returns aktuální data pro uživatele
|
|
||||||
*/
|
|
||||||
export async function unlockPizzaDay(login: string) {
|
export async function unlockPizzaDay(login: string) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
if (current.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login);
|
||||||
}
|
if (current.pizzaDay.state !== PizzaDayState.LOCKED) throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
current.pizzaDay.state = PizzaDayState.CREATED;
|
||||||
throw Error("Pizza day není spravován uživatelem " + login);
|
return current;
|
||||||
}
|
});
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
|
|
||||||
throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
|
|
||||||
}
|
|
||||||
clientData.pizzaDay.state = PizzaDayState.CREATED;
|
|
||||||
await storage.setData(today, clientData);
|
|
||||||
return clientData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Nastaví stav pizza day na "pizzy objednány".
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @returns aktuální data pro uživatele
|
|
||||||
*/
|
|
||||||
export async function finishPizzaOrder(login: string) {
|
export async function finishPizzaOrder(login: string) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
const result = await storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
if (current.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login);
|
||||||
}
|
if (current.pizzaDay.state !== PizzaDayState.LOCKED) throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
current.pizzaDay.state = PizzaDayState.ORDERED;
|
||||||
throw Error("Pizza day není spravován uživatelem " + login);
|
return current;
|
||||||
}
|
});
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
|
callNotifikace({ input: { udalost: UdalostEnum.OBJEDNANA_PIZZA, user: result.pizzaDay!.creator! } });
|
||||||
throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
|
return result;
|
||||||
}
|
|
||||||
clientData.pizzaDay.state = PizzaDayState.ORDERED;
|
|
||||||
await storage.setData(today, clientData);
|
|
||||||
callNotifikace({ input: { udalost: UdalostEnum.OBJEDNANA_PIZZA, user: clientData?.pizzaDay?.creator } })
|
|
||||||
return clientData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Nastaví stav pizza day na "pizzy doručeny".
|
|
||||||
* Vygeneruje QR kódy pro všechny objednatele, pokud objednávající má vyplněné číslo účtu a jméno.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @returns aktuální data pro uživatele
|
|
||||||
*/
|
|
||||||
export async function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) {
|
export async function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
|
// Načteme aktuální data pro přípravu QR (potřebujeme objednávky)
|
||||||
const clientData = await getClientData(getToday());
|
const clientData = await getClientData(getToday());
|
||||||
if (!clientData.pizzaDay) {
|
if (!clientData.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
if (clientData.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login);
|
||||||
}
|
if (clientData.pizzaDay.state !== PizzaDayState.ORDERED) throw Error("Pizza day není ve stavu " + PizzaDayState.ORDERED);
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
|
||||||
throw Error("Pizza day není spravován uživatelem " + login);
|
|
||||||
}
|
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
|
|
||||||
throw Error("Pizza day není ve stavu " + PizzaDayState.ORDERED);
|
|
||||||
}
|
|
||||||
clientData.pizzaDay.state = PizzaDayState.DELIVERED;
|
|
||||||
|
|
||||||
// Vygenerujeme QR kód, pokud k tomu máme data
|
// Generujeme QR kódy před atomickým zápisem
|
||||||
|
const pendingQrs: Array<{ customer: string; id: string; pendingQr: PendingQr }> = [];
|
||||||
if (bankAccount?.length && bankAccountHolder?.length) {
|
if (bankAccount?.length && bankAccountHolder?.length) {
|
||||||
for (const order of clientData.pizzaDay.orders!) {
|
for (const order of clientData.pizzaDay.orders!) {
|
||||||
if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
|
if (order.customer !== login) {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
let message = order.pizzaList!.map(item =>
|
const 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, message, id);
|
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice / 100, message, id);
|
||||||
order.hasQr = true;
|
pendingQrs.push({
|
||||||
// Uložíme nevyřízený QR kód pro persistentní zobrazení
|
customer: order.customer, id, pendingQr: {
|
||||||
await addPendingQr(order.customer, {
|
id, date: today, creator: login, totalPrice: order.totalPrice, purpose: message,
|
||||||
id,
|
},
|
||||||
date: today,
|
|
||||||
creator: login,
|
|
||||||
totalPrice: order.totalPrice,
|
|
||||||
purpose: message,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await storage.setData(today, clientData);
|
|
||||||
return clientData;
|
const result = await storage.updateData<ClientData>(today, (current) => {
|
||||||
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
|
current.pizzaDay.state = PizzaDayState.DELIVERED;
|
||||||
|
for (const { customer } of pendingQrs) {
|
||||||
|
const order = current.pizzaDay.orders!.find(o => o.customer === customer);
|
||||||
|
if (order) { order.hasQr = true; }
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uložení nevyřízených QR kódů mimo hlavní transakci (per-user klíče)
|
||||||
|
for (const { customer, pendingQr } of pendingQrs) {
|
||||||
|
await addPendingQr(customer, pendingQr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Aktualizuje poznámku k Pizza day uživatele.
|
|
||||||
*
|
|
||||||
* @param login přihlašovací jméno uživatele
|
|
||||||
* @param note nová poznámka k Pizza day
|
|
||||||
* @returns aktuální klientská data
|
|
||||||
*/
|
|
||||||
export async function updatePizzaDayNote(login: string, note?: string) {
|
export async function updatePizzaDayNote(login: string, note?: string) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
let clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
||||||
}
|
const myOrder = current.pizzaDay.orders!.find(o => o.customer === login);
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
if (!myOrder?.pizzaList?.length) throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
|
||||||
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
|
||||||
}
|
|
||||||
const myOrder = clientData.pizzaDay.orders!.find(o => o.customer === login);
|
|
||||||
if (!myOrder?.pizzaList?.length) {
|
|
||||||
throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
|
|
||||||
}
|
|
||||||
myOrder.note = note;
|
myOrder.note = note;
|
||||||
await storage.setData(today, clientData);
|
return current;
|
||||||
return clientData;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Aktualizuje příplatek uživatele k objednávce pizzy.
|
|
||||||
* V případě nevyplnění ceny je příplatek odebrán.
|
|
||||||
*
|
|
||||||
* @param login přihlašovací jméno aktuálního uživatele
|
|
||||||
* @param targetLogin přihlašovací jméno uživatele, kterému je nastavován příplatek
|
|
||||||
* @param text text popisující příplatek
|
|
||||||
* @param price celková cena příplatku
|
|
||||||
*/
|
|
||||||
export async function updatePizzaFee(login: string, targetLogin: string, text?: string, price?: number) {
|
export async function updatePizzaFee(login: string, targetLogin: string, text?: string, price?: number) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
let clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`);
|
||||||
}
|
if (current.pizzaDay.creator !== login) throw Error("Příplatky může měnit pouze zakladatel Pizza day");
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
const targetOrder = current.pizzaDay.orders!.find(o => o.customer === targetLogin);
|
||||||
throw Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`);
|
if (!targetOrder?.pizzaList?.length) throw Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
|
||||||
}
|
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
|
||||||
throw Error("Příplatky může měnit pouze zakladatel Pizza day");
|
|
||||||
}
|
|
||||||
const targetOrder = clientData.pizzaDay.orders!.find(o => o.customer === targetLogin);
|
|
||||||
if (!targetOrder?.pizzaList?.length) {
|
|
||||||
throw Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
|
|
||||||
}
|
|
||||||
if (!price) {
|
if (!price) {
|
||||||
delete targetOrder.fee;
|
delete targetOrder.fee;
|
||||||
} else {
|
} else {
|
||||||
targetOrder.fee = { text, price };
|
targetOrder.fee = { text, price };
|
||||||
}
|
}
|
||||||
// Přepočet ceny
|
targetOrder.totalPrice = targetOrder.pizzaList.reduce((p, o) => p + o.price, 0) + (targetOrder.fee?.price ?? 0);
|
||||||
targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price ?? 0);
|
return current;
|
||||||
await storage.setData(today, clientData);
|
});
|
||||||
return clientData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí klíč pro uložení nevyřízených QR kódů uživatele.
|
|
||||||
*/
|
|
||||||
function getPendingQrKey(login: string): string {
|
function getPendingQrKey(login: string): string {
|
||||||
return `${PENDING_QR_PREFIX}_${login}`;
|
return `${PENDING_QR_PREFIX}_${login}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Přidá nevyřízený QR kód pro uživatele.
|
|
||||||
*/
|
|
||||||
export async function addPendingQr(login: string, pendingQr: PendingQr): Promise<void> {
|
export async function addPendingQr(login: string, pendingQr: PendingQr): Promise<void> {
|
||||||
const key = getPendingQrKey(login);
|
await storage.updateData<PendingQr[]>(getPendingQrKey(login), (current) => {
|
||||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
const existing = current ?? [];
|
||||||
// Deduplikace podle id (ne podle data — jeden den může mít uživatel víc QR kódů)
|
if (!existing.some(qr => qr.id === pendingQr.id)) existing.push(pendingQr);
|
||||||
if (!existing.some(qr => qr.id === pendingQr.id)) {
|
return existing;
|
||||||
existing.push(pendingQr);
|
});
|
||||||
await storage.setData(key, existing);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí nevyřízené QR kódy pro uživatele.
|
|
||||||
*/
|
|
||||||
export async function getPendingQrs(login: string): Promise<PendingQr[]> {
|
export async function getPendingQrs(login: string): Promise<PendingQr[]> {
|
||||||
return await storage.getData<PendingQr[]>(getPendingQrKey(login)) ?? [];
|
return await storage.getData<PendingQr[]>(getPendingQrKey(login)) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<PendingQr | undefined> {
|
||||||
const key = getPendingQrKey(login);
|
let dismissed: PendingQr | undefined;
|
||||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
await storage.updateData<PendingQr[]>(getPendingQrKey(login), (current) => {
|
||||||
const dismissed = existing.find(qr => qr.id === id);
|
const existing = current ?? [];
|
||||||
const filtered = existing.filter(qr => qr.id !== id);
|
dismissed = existing.find(qr => qr.id === id);
|
||||||
await storage.setData(key, filtered);
|
return existing.filter(qr => qr.id !== id);
|
||||||
|
});
|
||||||
return dismissed;
|
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> {
|
export async function removePendingQrsByGroupId(logins: string[], groupId: string): Promise<void> {
|
||||||
for (const login of logins) {
|
for (const login of logins) {
|
||||||
const key = getPendingQrKey(login);
|
await storage.updateData<PendingQr[]>(getPendingQrKey(login), (current) => {
|
||||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
return (current ?? []).filter(qr => qr.groupId !== groupId);
|
||||||
const filtered = existing.filter(qr => qr.groupId !== groupId);
|
});
|
||||||
if (filtered.length !== existing.length) {
|
|
||||||
await storage.setData(key, filtered);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+87
-37
@@ -1,12 +1,17 @@
|
|||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import getStorage from './storage';
|
import getStorage from './storage';
|
||||||
|
import { getRedisClient } from './storage/redis';
|
||||||
import { getClientData, getToday } from './service';
|
import { getClientData, getToday } from './service';
|
||||||
import { getIsWeekend } from './utils';
|
import { getIsWeekend } from './utils';
|
||||||
import { LunchChoices } from '../../types';
|
import { LunchChoices } from '../../types';
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
const REGISTRY_KEY = 'push_reminder_registry';
|
const REGISTRY_KEY = 'push_reminder_registry';
|
||||||
|
const LEADER_LEASE_KEY = 'luncher:reminder:leader';
|
||||||
|
const LEASE_TTL_SECONDS = 90;
|
||||||
|
|
||||||
|
const POD_ID = process.env.POD_ID ?? `local-${process.pid}`;
|
||||||
|
|
||||||
interface RegistryEntry {
|
interface RegistryEntry {
|
||||||
time: string;
|
time: string;
|
||||||
@@ -20,6 +25,8 @@ const lastReminded = new Map<string, number>();
|
|||||||
|
|
||||||
const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami
|
const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami
|
||||||
|
|
||||||
|
let reminderInterval: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
function getCurrentTimeHHMM(): string {
|
function getCurrentTimeHHMM(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||||
@@ -36,27 +43,76 @@ function userHasChoice(choices: LunchChoices, login: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRegistry(): Promise<Registry> {
|
/**
|
||||||
return await storage.getData<Registry>(REGISTRY_KEY) ?? {};
|
* Pokusí se získat nebo obnovit leader lease pro scheduler připomínek.
|
||||||
|
* Vrátí true pokud tato instance smí spustit připomínky.
|
||||||
|
* Při non-Redis storage vždy vrací true (single-process, leader election není potřeba).
|
||||||
|
*/
|
||||||
|
async function tryAcquireOrRenewLease(): Promise<boolean> {
|
||||||
|
if (process.env.STORAGE?.toLowerCase() !== 'redis') return true;
|
||||||
|
try {
|
||||||
|
const c = getRedisClient();
|
||||||
|
if (!c) return true;
|
||||||
|
|
||||||
|
// Zkusíme získat lease atomicky (SET NX EX)
|
||||||
|
const acquired = await c.set(LEADER_LEASE_KEY, POD_ID, { NX: true, EX: LEASE_TTL_SECONDS });
|
||||||
|
if (acquired !== null) return true; // lease čerstvě získána
|
||||||
|
|
||||||
|
// Pokud jsme ji nedostali, ověříme zda ji držíme my
|
||||||
|
const currentHolder = await c.get(LEADER_LEASE_KEY);
|
||||||
|
if (currentHolder === POD_ID) {
|
||||||
|
// Naše lease — obnovíme TTL
|
||||||
|
await c.set(LEADER_LEASE_KEY, POD_ID, { EX: LEASE_TTL_SECONDS });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false; // lease drží jiná instance
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Push reminder: chyba při získávání lease, připomínky budou odeslány', e);
|
||||||
|
return true; // při chybě raději spustíme, než vynecháme
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveRegistry(registry: Registry): Promise<void> {
|
/** Uvolní leader lease při graceful shutdown. */
|
||||||
await storage.setData(REGISTRY_KEY, registry);
|
export async function releaseReminderLease(): Promise<void> {
|
||||||
|
if (process.env.STORAGE?.toLowerCase() !== 'redis') return;
|
||||||
|
try {
|
||||||
|
const c = getRedisClient();
|
||||||
|
if (!c) return;
|
||||||
|
const currentHolder = await c.get(LEADER_LEASE_KEY);
|
||||||
|
if (currentHolder === POD_ID) {
|
||||||
|
await c.del(LEADER_LEASE_KEY);
|
||||||
|
console.log('Push reminder: lease uvolněna');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Push reminder: chyba při uvolňování lease', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stopne scheduler připomínek. Volá se při graceful shutdown. */
|
||||||
|
export function stopReminderScheduler(): void {
|
||||||
|
if (reminderInterval) {
|
||||||
|
clearInterval(reminderInterval);
|
||||||
|
reminderInterval = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Přidá nebo aktualizuje push subscription pro uživatele. */
|
/** Přidá nebo aktualizuje push subscription pro uživatele. */
|
||||||
export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime: string): Promise<void> {
|
export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime: string): Promise<void> {
|
||||||
const registry = await getRegistry();
|
await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
|
||||||
|
const registry = current ?? {};
|
||||||
registry[login] = { time: reminderTime, subscription };
|
registry[login] = { time: reminderTime, subscription };
|
||||||
await saveRegistry(registry);
|
return registry;
|
||||||
|
});
|
||||||
console.log(`Push reminder: uživatel ${login} přihlášen k připomínkám v ${reminderTime}`);
|
console.log(`Push reminder: uživatel ${login} přihlášen k připomínkám v ${reminderTime}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Odebere push subscription pro uživatele. */
|
/** Odebere push subscription pro uživatele. */
|
||||||
export async function unsubscribePush(login: string): Promise<void> {
|
export async function unsubscribePush(login: string): Promise<void> {
|
||||||
const registry = await getRegistry();
|
await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
|
||||||
|
const registry = current ?? {};
|
||||||
delete registry[login];
|
delete registry[login];
|
||||||
await saveRegistry(registry);
|
return registry;
|
||||||
|
});
|
||||||
lastReminded.delete(login);
|
lastReminded.delete(login);
|
||||||
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
|
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
|
||||||
}
|
}
|
||||||
@@ -79,23 +135,20 @@ export function verifyQuickChoiceToken(login: string, token: string): boolean {
|
|||||||
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex'));
|
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** 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> {
|
||||||
// Přeskočit víkendy
|
if (getIsWeekend(getToday())) return;
|
||||||
if (getIsWeekend(getToday())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const registry = await getRegistry();
|
// Leader election — pouze jeden pod spouští připomínky
|
||||||
|
const isLeader = await tryAcquireOrRenewLease();
|
||||||
|
if (!isLeader) return;
|
||||||
|
|
||||||
|
const registry = await storage.getData<Registry>(REGISTRY_KEY) ?? {};
|
||||||
const entries = Object.entries(registry);
|
const entries = Object.entries(registry);
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTime = getCurrentTimeHHMM();
|
const currentTime = getCurrentTimeHHMM();
|
||||||
|
|
||||||
// Získáme data pro dnešek jednou pro všechny uživatele
|
|
||||||
let clientData;
|
let clientData;
|
||||||
try {
|
try {
|
||||||
clientData = await getClientData(getToday());
|
clientData = await getClientData(getToday());
|
||||||
@@ -104,24 +157,16 @@ async function checkAndSendReminders(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expiredLogins: string[] = [];
|
||||||
|
|
||||||
for (const [login, entry] of entries) {
|
for (const [login, entry] of entries) {
|
||||||
// Ještě nedosáhl čas připomínky
|
if (currentTime < entry.time) continue;
|
||||||
if (currentTime < entry.time) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cooldown — nepřipomínat častěji než jednou za hodinu
|
|
||||||
const last = lastReminded.get(login) ?? 0;
|
const last = lastReminded.get(login) ?? 0;
|
||||||
if (Date.now() - last < REMINDER_COOLDOWN_MS) {
|
if (Date.now() - last < REMINDER_COOLDOWN_MS) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uživatel už má zvolenou možnost
|
if (clientData.choices && userHasChoice(clientData.choices, login)) continue;
|
||||||
if (clientData.choices && userHasChoice(clientData.choices, login)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Odešleme push notifikaci
|
|
||||||
try {
|
try {
|
||||||
await webpush.sendNotification(
|
await webpush.sendNotification(
|
||||||
entry.subscription,
|
entry.subscription,
|
||||||
@@ -136,15 +181,21 @@ async function checkAndSendReminders(): Promise<void> {
|
|||||||
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
|
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.statusCode === 410 || error.statusCode === 404) {
|
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||||
// Subscription expirovala nebo je neplatná — odebereme z registry
|
|
||||||
console.log(`Push reminder: subscription uživatele ${login} expirovala, odebírám`);
|
console.log(`Push reminder: subscription uživatele ${login} expirovala, odebírám`);
|
||||||
delete registry[login];
|
expiredLogins.push(login);
|
||||||
await saveRegistry(registry);
|
|
||||||
} else {
|
} else {
|
||||||
console.error(`Push reminder: chyba při odesílání notifikace uživateli ${login}:`, error);
|
console.error(`Push reminder: chyba při odesílání notifikace uživateli ${login}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (expiredLogins.length > 0) {
|
||||||
|
await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
|
||||||
|
const r = current ?? {};
|
||||||
|
for (const login of expiredLogins) delete r[login];
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Spustí scheduler pro kontrolu a odesílání připomínek každou minutu. */
|
/** Spustí scheduler pro kontrolu a odesílání připomínek každou minutu. */
|
||||||
@@ -160,7 +211,6 @@ export function startReminderScheduler(): void {
|
|||||||
|
|
||||||
webpush.setVapidDetails(subject, publicKey, privateKey);
|
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||||
|
|
||||||
// Spustíme kontrolu každou minutu
|
reminderInterval = setInterval(checkAndSendReminders, 60_000);
|
||||||
setInterval(checkAndSendReminders, 60_000);
|
console.log(`Push reminder: scheduler spuštěn (POD_ID=${POD_ID})`);
|
||||||
console.log('Push reminder: scheduler spuštěn');
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-7
@@ -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;
|
const checkDigits = (BigInt(98) - remainder).toString().padStart(2, '0');
|
||||||
iban = `${COUNTRY_CODE}${checkDigits.toString()}${bankCode}${prefix}${accountNumber}`;
|
iban = `${COUNTRY_CODE}${checkDigits}${bankCode}${prefix}${accountNumber}`;
|
||||||
if (iban.length !== 24) {
|
if (iban.length !== 24) {
|
||||||
throw Error("Neplatná délka sestaveného IBAN: " + iban.length + ", očekáváno 24");
|
throw new Error("Neplatná délka sestaveného IBAN: " + iban.length + ", očekáváno 24");
|
||||||
}
|
}
|
||||||
return iban;
|
return iban;
|
||||||
}
|
}
|
||||||
@@ -56,10 +56,8 @@ 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 pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků
|
// Zpráva nesmí obsahovat '*' a znaky mimo ISO 8859-1; délka max. 60 znaků
|
||||||
if (message.indexOf('*') >= 0) {
|
message = message.replace(/[^\x00-\xff]/g, '').replace(/\*/g, '');
|
||||||
message = message.replace(/\*/g, '');
|
|
||||||
}
|
|
||||||
if (message.length > 60) {
|
if (message.length > 60) {
|
||||||
message = message.substring(0, 60);
|
message = message.substring(0, 60);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,15 +56,15 @@ 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 Error(`Nebyl předán index dne v týdnu.`);
|
throw new 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 Error(`Neplatný index dne v týdnu: ${req.body.dayIndex}`);
|
throw new Error(`Neplatný index dne v týdnu: ${req.body.dayIndex}`);
|
||||||
}
|
}
|
||||||
if (dayIndex < todayDayIndex) {
|
if (dayIndex < todayDayIndex) {
|
||||||
throw Error(`Předaný index dne v týdnu (${dayIndex}) nesmí být nižší než dnešní den (${todayDayIndex})`);
|
throw new Error(`Předaný index dne v týdnu (${dayIndex}) nesmí být nižší než dnešní den (${todayDayIndex})`);
|
||||||
}
|
}
|
||||||
return dayIndex;
|
return dayIndex;
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"]
|
|||||||
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) {
|
||||||
throw Error(`Neplatný slot: ${slot}`);
|
throw new Error(`Neplatný slot: ${slot}`);
|
||||||
}
|
}
|
||||||
return slot ?? undefined;
|
return slot ?? undefined;
|
||||||
};
|
};
|
||||||
@@ -153,7 +153,7 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
|
|||||||
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
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 Error("Poznámka může mít maximálně 70 znaků");
|
throw new 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) {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ router.post("/updateMember", async (req: Request, res, next) => {
|
|||||||
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
|
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
|
||||||
const patch: Record<string, any> = {};
|
const patch: Record<string, any> = {};
|
||||||
if (amount !== undefined) {
|
if (amount !== undefined) {
|
||||||
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
|
if (!Number.isInteger(amount) || amount < 0) {
|
||||||
return res.status(400).json({ error: 'Neplatná částka' });
|
return res.status(400).json({ error: 'Neplatná částka' });
|
||||||
}
|
}
|
||||||
patch.amount = amount;
|
patch.amount = amount;
|
||||||
@@ -83,7 +83,7 @@ router.post("/updateMember", async (req: Request, res, next) => {
|
|||||||
patch.surchargeText = surchargeText;
|
patch.surchargeText = surchargeText;
|
||||||
}
|
}
|
||||||
if (surchargeAmount !== undefined) {
|
if (surchargeAmount !== undefined) {
|
||||||
if (typeof surchargeAmount !== 'number' || !Number.isFinite(surchargeAmount) || surchargeAmount < 0) {
|
if (!Number.isInteger(surchargeAmount) || surchargeAmount < 0) {
|
||||||
return res.status(400).json({ error: 'Neplatná výše příplatku' });
|
return res.status(400).json({ error: 'Neplatná výše příplatku' });
|
||||||
}
|
}
|
||||||
patch.surchargeAmount = surchargeAmount;
|
patch.surchargeAmount = surchargeAmount;
|
||||||
@@ -113,19 +113,19 @@ router.post("/updateFees", async (req: Request, res, next) => {
|
|||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {};
|
const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {};
|
||||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
if (fees !== undefined && (typeof fees !== 'number' || !Number.isFinite(fees) || fees < 0)) {
|
if (fees !== undefined && (!Number.isInteger(fees) || fees < 0)) {
|
||||||
return res.status(400).json({ error: 'Neplatná výše poplatků' });
|
return res.status(400).json({ error: 'Neplatná výše poplatků' });
|
||||||
}
|
}
|
||||||
if (shipping !== undefined && (typeof shipping !== 'number' || !Number.isFinite(shipping) || shipping < 0)) {
|
if (shipping !== undefined && (!Number.isInteger(shipping) || shipping < 0)) {
|
||||||
return res.status(400).json({ error: 'Neplatná výše dopravy' });
|
return res.status(400).json({ error: 'Neplatná výše dopravy' });
|
||||||
}
|
}
|
||||||
if (tip !== undefined && (typeof tip !== 'number' || !Number.isFinite(tip) || tip < 0)) {
|
if (tip !== undefined && (!Number.isInteger(tip) || tip < 0)) {
|
||||||
return res.status(400).json({ error: 'Neplatná výše spropitného' });
|
return res.status(400).json({ error: 'Neplatná výše spropitného' });
|
||||||
}
|
}
|
||||||
if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) {
|
if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) {
|
||||||
return res.status(400).json({ error: 'Neplatný typ slevy' });
|
return res.status(400).json({ error: 'Neplatný typ slevy' });
|
||||||
}
|
}
|
||||||
if (discountValue !== undefined && (typeof discountValue !== 'number' || !Number.isFinite(discountValue) || discountValue < 0)) {
|
if (discountValue !== undefined && (!Number.isInteger(discountValue) || discountValue < 0)) {
|
||||||
return res.status(400).json({ error: 'Neplatná výše slevy' });
|
return res.status(400).json({ error: 'Neplatná výše slevy' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -30,10 +30,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 Error("Selhalo získání seznamu dostupných salátů.");
|
throw new Error("Selhalo získání seznamu dostupných salátů.");
|
||||||
}
|
}
|
||||||
if (!salaty[salatIndex]) {
|
if (!salaty[salatIndex]) {
|
||||||
throw Error("Neplatný index salátu: " + salatIndex);
|
throw new 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 +41,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 Error("Nebyl předán index pizzy ani salátu");
|
throw new 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 Error("Nebyl předán index velikosti pizzy");
|
throw new 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 Error("Selhalo získání seznamu dostupných pizz.");
|
throw new Error("Selhalo získání seznamu dostupných pizz.");
|
||||||
}
|
}
|
||||||
if (!pizzy[pizzaIndex]) {
|
if (!pizzy[pizzaIndex]) {
|
||||||
throw Error("Neplatný index pizzy: " + pizzaIndex);
|
throw new Error("Neplatný index pizzy: " + pizzaIndex);
|
||||||
}
|
}
|
||||||
if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) {
|
if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) {
|
||||||
throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
|
throw new 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 +67,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 Error("Nebyla předána objednávka");
|
throw new 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 +106,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 Error("Poznámka může mít maximálně 70 znaků");
|
throw new 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);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 { emitToUser } from "../websocket";
|
||||||
import { GenerateQrData } from "../../../types";
|
import { GenerateQrData } from "../../../types";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
@@ -36,18 +37,13 @@ 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 (typeof recipient.amount !== 'number' || recipient.amount <= 0) {
|
if (!Number.isInteger(recipient.amount) || 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, recipient.purpose, id);
|
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount / 100, 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 a okamžitě doručit příjemci
|
||||||
const pendingQr = {
|
const pendingQr = {
|
||||||
@@ -62,6 +58,10 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r
|
|||||||
emitToUser(recipient.login, 'pendingQr', 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 });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
next(e);
|
next(e);
|
||||||
|
|||||||
+41
-49
@@ -319,18 +319,17 @@ export async function initIfNeeded(date?: Date, slot?: MealSlot) {
|
|||||||
*/
|
*/
|
||||||
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date, slot?: MealSlot) {
|
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date, slot?: MealSlot) {
|
||||||
const selectedDay = getDataKey(date ?? getToday(), slot);
|
const selectedDay = getDataKey(date ?? getToday(), slot);
|
||||||
let data = await getClientData(date, slot);
|
// Validate trusted flag against current data before atomic update
|
||||||
validateTrusted(data, login, trusted);
|
const snapshot = await getClientData(date, slot);
|
||||||
if (locationKey in data.choices) {
|
validateTrusted(snapshot, login, trusted);
|
||||||
if (data.choices[locationKey] && login in data.choices[locationKey]) {
|
return storage.updateData<ClientData>(selectedDay, (current) => {
|
||||||
delete data.choices[locationKey][login]
|
const data = current ?? getEmptyData(date);
|
||||||
if (Object.keys(data.choices[locationKey]).length === 0) {
|
if (locationKey in data.choices && data.choices[locationKey] && login in data.choices[locationKey]) {
|
||||||
delete data.choices[locationKey]
|
delete data.choices[locationKey][login];
|
||||||
}
|
if (Object.keys(data.choices[locationKey]).length === 0) delete data.choices[locationKey];
|
||||||
await storage.setData(selectedDay, data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -346,18 +345,16 @@ export async function removeChoices(login: string, trusted: boolean, locationKey
|
|||||||
*/
|
*/
|
||||||
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date, slot?: MealSlot) {
|
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date, slot?: MealSlot) {
|
||||||
const selectedDay = getDataKey(date ?? getToday(), slot);
|
const selectedDay = getDataKey(date ?? getToday(), slot);
|
||||||
let data = await getClientData(date, slot);
|
const snapshot = await getClientData(date, slot);
|
||||||
validateTrusted(data, login, trusted);
|
validateTrusted(snapshot, login, trusted);
|
||||||
if (locationKey in data.choices) {
|
return storage.updateData<ClientData>(selectedDay, (current) => {
|
||||||
if (data.choices[locationKey] && login in data.choices[locationKey]) {
|
const data = current ?? getEmptyData(date);
|
||||||
|
if (locationKey in data.choices && data.choices[locationKey] && login in data.choices[locationKey]) {
|
||||||
const index = data.choices[locationKey][login].selectedFoods?.indexOf(foodIndex);
|
const index = data.choices[locationKey][login].selectedFoods?.indexOf(foodIndex);
|
||||||
if (index != null && index > -1) {
|
if (index != null && index > -1) data.choices[locationKey][login].selectedFoods?.splice(index, 1);
|
||||||
data.choices[locationKey][login].selectedFoods?.splice(index, 1);
|
|
||||||
await storage.setData(selectedDay, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -485,13 +482,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 Error(`Neplatný index ${foodIndex} typu ${typeof foodIndex}`);
|
throw new Error(`Neplatný index ${foodIndex} typu ${typeof foodIndex}`);
|
||||||
}
|
}
|
||||||
if (foodIndex < 0) {
|
if (foodIndex < 0) {
|
||||||
throw Error(`Neplatný index ${foodIndex}`);
|
throw new Error(`Neplatný index ${foodIndex}`);
|
||||||
}
|
}
|
||||||
if (!Object.keys(Restaurant).includes(locationKey)) {
|
if (!Object.keys(Restaurant).includes(locationKey)) {
|
||||||
throw Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey} nepodporující indexy`);
|
throw new 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);
|
||||||
@@ -512,18 +509,17 @@ async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, d
|
|||||||
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date, slot?: MealSlot) {
|
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date, slot?: MealSlot) {
|
||||||
const usedDate = date ?? getToday();
|
const usedDate = date ?? getToday();
|
||||||
await initIfNeeded(usedDate, slot);
|
await initIfNeeded(usedDate, slot);
|
||||||
let data = await getClientData(usedDate, slot);
|
const snapshot = await getClientData(usedDate, slot);
|
||||||
validateTrusted(data, login, trusted);
|
validateTrusted(snapshot, login, trusted);
|
||||||
|
return storage.updateData<ClientData>(getDataKey(usedDate, slot), (current) => {
|
||||||
|
const data = current ?? getEmptyData(date);
|
||||||
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
|
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
|
||||||
if (userEntry) {
|
if (userEntry) {
|
||||||
if (!note?.length) {
|
if (!note?.length) delete userEntry[1][login].note;
|
||||||
delete userEntry[1][login].note;
|
else userEntry[1][login].note = note;
|
||||||
} else {
|
|
||||||
userEntry[1][login].note = note;
|
|
||||||
}
|
|
||||||
await storage.setData(getDataKey(usedDate, slot), data);
|
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -535,21 +531,18 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
|
|||||||
*/
|
*/
|
||||||
export async function updateDepartureTime(login: string, time?: string, date?: Date) {
|
export async function updateDepartureTime(login: string, time?: string, date?: Date) {
|
||||||
const usedDate = date ?? getToday();
|
const usedDate = date ?? getToday();
|
||||||
let clientData = await getClientData(usedDate);
|
if (time?.length && !Object.values<string>(DepartureTime).includes(time)) {
|
||||||
const found = Object.values(clientData.choices).find(location => login in location);
|
|
||||||
// TODO validace, že se jedná o restauraci
|
|
||||||
if (found) {
|
|
||||||
if (!time?.length) {
|
|
||||||
delete found[login].departureTime;
|
|
||||||
} else {
|
|
||||||
if (!Object.values<string>(DepartureTime).includes(time)) {
|
|
||||||
throw Error(`Neplatný čas odchodu ${time}`);
|
throw Error(`Neplatný čas odchodu ${time}`);
|
||||||
}
|
}
|
||||||
found[login].departureTime = time;
|
return storage.updateData<ClientData>(getDataKey(usedDate), (current) => {
|
||||||
|
const data = current ?? getEmptyData(date);
|
||||||
|
const found = Object.values(data.choices).find(location => login in location);
|
||||||
|
if (found) {
|
||||||
|
if (!time?.length) delete found[login].departureTime;
|
||||||
|
else found[login].departureTime = time;
|
||||||
}
|
}
|
||||||
await storage.setData(getDataKey(usedDate), clientData);
|
return data;
|
||||||
}
|
});
|
||||||
return clientData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -560,14 +553,13 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
|
|||||||
*/
|
*/
|
||||||
export async function updateBuyer(login: string, slot?: MealSlot) {
|
export async function updateBuyer(login: string, slot?: MealSlot) {
|
||||||
const usedDate = getToday();
|
const usedDate = getToday();
|
||||||
let clientData = await getClientData(usedDate, slot);
|
return storage.updateData<ClientData>(getDataKey(usedDate, slot), (current) => {
|
||||||
const userEntry = clientData.choices?.['OBJEDNAVAM']?.[login];
|
const data = current ?? getEmptyData();
|
||||||
if (!userEntry) {
|
const userEntry = data.choices?.['OBJEDNAVAM']?.[login];
|
||||||
throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
|
if (!userEntry) throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
|
||||||
}
|
|
||||||
userEntry.isBuyer = !(userEntry.isBuyer || false);
|
userEntry.isBuyer = !(userEntry.isBuyer || false);
|
||||||
await storage.setData(getDataKey(usedDate, slot), clientData);
|
return data;
|
||||||
return clientData;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+2
-2
@@ -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 Error('Neplatný rozsah');
|
throw new 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 Error('Nelze načíst statistiky pro budoucí datum');
|
throw new Error('Nelze načíst statistiky pro budoucí datum');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = [];
|
const result = [];
|
||||||
|
|||||||
@@ -1,32 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Interface pro úložiště dat.
|
* Interface pro úložiště dat.
|
||||||
*
|
|
||||||
* Aktuálně pouze "primitivní" has, get a set odrážející původní JSON DB.
|
|
||||||
* Postupem času lze předělat pro efektivnější využití Redis.
|
|
||||||
*/
|
*/
|
||||||
export interface StorageInterface {
|
export interface StorageInterface {
|
||||||
|
|
||||||
/**
|
|
||||||
* Inicializuje úložiště, pokud je potřeba (např. připojení k databázi).
|
|
||||||
*/
|
|
||||||
initialize?(): Promise<void>;
|
initialize?(): Promise<void>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí příznak, zda existují data pro předaný klíč.
|
|
||||||
* @param key klíč, pro který zjišťujeme data (typicky datum)
|
|
||||||
*/
|
|
||||||
hasData(key: string): Promise<boolean>;
|
hasData(key: string): Promise<boolean>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí veškerá data pro předaný klíč.
|
|
||||||
* @param key klíč, pro který vrátit data (typicky datum)
|
|
||||||
*/
|
|
||||||
getData<Type>(key: string): Promise<Type | undefined>;
|
getData<Type>(key: string): Promise<Type | undefined>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Uloží data pod předaný klíč.
|
|
||||||
* @param key klíč, pod kterým uložit data (typicky datum)
|
|
||||||
* @param data data pro uložení
|
|
||||||
*/
|
|
||||||
setData<Type>(key: string, data: Type): Promise<void>;
|
setData<Type>(key: string, data: Type): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomicky načte, zmutuje a uloží data pod daným klíčem.
|
||||||
|
* V Redis implementaci používá WATCH/MULTI/EXEC retry loop.
|
||||||
|
* Vrátí výslednou hodnotu po aplikaci mutátoru.
|
||||||
|
*/
|
||||||
|
updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type>;
|
||||||
|
|
||||||
|
/** Ověří dostupnost úložiště. Vrátí false pokud není dostupné. */
|
||||||
|
healthCheck?(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
@@ -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 Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json', 'redis' nebo 'memory'");
|
throw new 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
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import * as path from 'path';
|
|||||||
const dbPath = path.resolve(__dirname, '../../data/db.json');
|
const dbPath = path.resolve(__dirname, '../../data/db.json');
|
||||||
const dbDir = path.dirname(dbPath);
|
const dbDir = path.dirname(dbPath);
|
||||||
|
|
||||||
// Zajistěte, že adresář existuje
|
|
||||||
if (!fs.existsSync(dbDir)) {
|
if (!fs.existsSync(dbDir)) {
|
||||||
fs.mkdirSync(dbDir, { recursive: true });
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -29,4 +28,15 @@ export default class JsonStorage implements StorageInterface {
|
|||||||
db.set(key, data);
|
db.set(key, data);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
|
||||||
|
const current = db.get(key) as Type | undefined;
|
||||||
|
const next = mutator(current);
|
||||||
|
db.set(key, next);
|
||||||
|
return Promise.resolve(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
healthCheck(): Promise<boolean> {
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -24,4 +24,15 @@ export default class MemoryStorage implements StorageInterface {
|
|||||||
store.set(key, data);
|
store.set(key, data);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
|
||||||
|
const current = store.get(key) as Type | undefined;
|
||||||
|
const next = mutator(current);
|
||||||
|
store.set(key, next);
|
||||||
|
return Promise.resolve(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
healthCheck(): Promise<boolean> {
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default class RedisStorage implements StorageInterface {
|
|||||||
constructor() {
|
constructor() {
|
||||||
const HOST = process.env.REDIS_HOST ?? 'localhost';
|
const HOST = process.env.REDIS_HOST ?? 'localhost';
|
||||||
const PORT = process.env.REDIS_PORT ?? 6379;
|
const PORT = process.env.REDIS_PORT ?? 6379;
|
||||||
client = createClient({ url: `redis://${HOST}:${PORT}` });
|
client = createClient({ url: `redis://${HOST}:${PORT}` }) as RedisClientType;
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
@@ -29,6 +29,44 @@ export default class RedisStorage implements StorageInterface {
|
|||||||
|
|
||||||
async setData<Type>(key: string, data: Type) {
|
async setData<Type>(key: string, data: Type) {
|
||||||
await client.json.set(key, '.', data as any);
|
await client.json.set(key, '.', data as any);
|
||||||
await client.json.get(key);
|
}
|
||||||
|
|
||||||
|
async updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
|
||||||
|
// node-redis v5 nemá executeIsolated — pro WATCH/MULTI potřebujeme dedikované spojení
|
||||||
|
const c = client.duplicate();
|
||||||
|
await c.connect();
|
||||||
|
try {
|
||||||
|
for (let attempt = 0; attempt < 10; attempt++) {
|
||||||
|
await c.watch(key);
|
||||||
|
const current = await c.json.get(key, { path: '.' }) as Type | undefined;
|
||||||
|
const next = mutator(current);
|
||||||
|
const multi = c.multi();
|
||||||
|
multi.json.set(key, '.', next as any);
|
||||||
|
const result = await multi.exec();
|
||||||
|
if (result !== null) return next;
|
||||||
|
}
|
||||||
|
throw new Error(`updateData: optimistic lock failed after 10 retries for key: ${key}`);
|
||||||
|
} finally {
|
||||||
|
await c.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const pong = await client.ping();
|
||||||
|
return pong === 'PONG';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vrátí hlavní Redis klient — používá se pro lease připomínkovače a shutdown. */
|
||||||
|
export function getRedisClient(): RedisClientType | undefined {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zavře připojení k Redisu. Volá se při graceful shutdown. */
|
||||||
|
export async function shutdownRedisStorage(): Promise<void> {
|
||||||
|
await client?.quit();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
// Caesar sticker price = 129, box = 13 → (129 + 13) * 100 haléřů
|
||||||
expect(salaty[0].price).toBe(129 + 13);
|
expect(salaty[0].price).toBe((129 + 13) * 100);
|
||||||
// Řecký sticker price = 119, box = 13
|
// Řecký sticker price = 119, box = 13 → (119 + 13) * 100 haléřů
|
||||||
expect(salaty[1].price).toBe(119 + 13);
|
expect(salaty[1].price).toBe((119 + 13) * 100);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ beforeEach(() => {
|
|||||||
|
|
||||||
const VALID_BODY = {
|
const VALID_BODY = {
|
||||||
recipients: [
|
recipients: [
|
||||||
{ login: 'uzivatel1', purpose: 'Pizza Margherita', amount: 149 },
|
{ login: 'uzivatel1', purpose: 'Pizza Margherita', amount: 14900 },
|
||||||
{ login: 'uzivatel2', purpose: 'Pizza Diavola', amount: 179 },
|
{ login: 'uzivatel2', purpose: 'Pizza Diavola', amount: 17900 },
|
||||||
],
|
],
|
||||||
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 částku s více než 2 desetinnými místy', async () => {
|
test('POST /generate vrátí 400 pro necelou částku', async () => {
|
||||||
const body = {
|
const body = {
|
||||||
...VALID_BODY,
|
...VALID_BODY,
|
||||||
recipients: [{ login: 'uzivatel1', purpose: 'Pizza', amount: 149.999 }],
|
recipients: [{ login: 'uzivatel1', purpose: 'Pizza', amount: 149.5 }],
|
||||||
};
|
};
|
||||||
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('desetinná');
|
expect(res.body.error).toContain('částku');
|
||||||
});
|
});
|
||||||
|
|
||||||
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
-2
@@ -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 Error(`Nebyl předán parametr '${name}' v query požadavku`);
|
throw new 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 Error(`Nebyl předán parametr '${name}' v těle požadavku`);
|
throw new Error(`Nebyl předán parametr '${name}' v těle požadavku`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-36
@@ -1,4 +1,4 @@
|
|||||||
import { FeatureRequest, VotingStats } from "../../types/gen/types.gen";
|
import { FeatureRequest } from "../../types/gen/types.gen";
|
||||||
import getStorage from "./storage";
|
import getStorage from "./storage";
|
||||||
|
|
||||||
interface VotingData {
|
interface VotingData {
|
||||||
@@ -12,56 +12,28 @@ export interface VotingStatsResult {
|
|||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
const STORAGE_KEY = 'voting';
|
const STORAGE_KEY = 'voting';
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí pole voleb, pro které uživatel aktuálně hlasuje.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @returns pole voleb
|
|
||||||
*/
|
|
||||||
export async function getUserVotes(login: string) {
|
export async function getUserVotes(login: string) {
|
||||||
const data = await storage.getData<VotingData>(STORAGE_KEY);
|
const data = await storage.getData<VotingData>(STORAGE_KEY);
|
||||||
return data?.[login] || [];
|
return data?.[login] || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Aktualizuje hlas uživatele pro konkrétní volbu.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @param option volba
|
|
||||||
* @param active příznak, zda volbu přidat nebo odebrat
|
|
||||||
* @returns aktuální data
|
|
||||||
*/
|
|
||||||
export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> {
|
export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> {
|
||||||
let data = await storage.getData<VotingData>(STORAGE_KEY);
|
return storage.updateData<VotingData>(STORAGE_KEY, (current) => {
|
||||||
data ??= {};
|
const data = current ?? {};
|
||||||
if (!(login in data)) {
|
if (!(login in data)) data[login] = [];
|
||||||
data[login] = [];
|
|
||||||
}
|
|
||||||
const index = data[login].indexOf(option);
|
const index = data[login].indexOf(option);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
if (active) {
|
if (active) throw Error('Pro tuto možnost jste již hlasovali');
|
||||||
throw Error('Pro tuto možnost jste již hlasovali');
|
|
||||||
} else {
|
|
||||||
data[login].splice(index, 1);
|
data[login].splice(index, 1);
|
||||||
if (data[login].length === 0) {
|
if (data[login].length === 0) delete data[login];
|
||||||
delete data[login];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (active) {
|
} else if (active) {
|
||||||
if (data[login].length == 4) {
|
if (data[login].length === 4) throw 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);
|
||||||
}
|
}
|
||||||
await storage.setData(STORAGE_KEY, data);
|
|
||||||
return data;
|
return data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí agregované statistiky hlasování - počet hlasů pro každou funkci.
|
|
||||||
*
|
|
||||||
* @returns objekt, kde klíčem je název funkce a hodnotou počet hlasů
|
|
||||||
*/
|
|
||||||
export async function getVotingStats(): Promise<VotingStatsResult> {
|
export async function getVotingStats(): Promise<VotingStatsResult> {
|
||||||
const data = await storage.getData<VotingData>(STORAGE_KEY);
|
const data = await storage.getData<VotingData>(STORAGE_KEY);
|
||||||
const stats: VotingStatsResult = {};
|
const stats: VotingStatsResult = {};
|
||||||
|
|||||||
+26
-5
@@ -1,12 +1,15 @@
|
|||||||
import { DefaultEventsMap, Server } from "socket.io";
|
import { DefaultEventsMap, Server } from "socket.io";
|
||||||
|
import { createAdapter } from "@socket.io/redis-adapter";
|
||||||
|
import { createClient } from "redis";
|
||||||
|
|
||||||
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
|
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
|
||||||
|
let pubClient: ReturnType<typeof createClient>;
|
||||||
|
let subClient: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
export const initWebsocket = (server: any) => {
|
export const initWebsocket = (server: any) => {
|
||||||
io = new Server(server, {
|
io = new Server(server, {
|
||||||
cors: {
|
cors: { origin: "*" },
|
||||||
origin: "*",
|
transports: ["websocket"],
|
||||||
},
|
|
||||||
});
|
});
|
||||||
io.on("connection", (socket) => {
|
io.on("connection", (socket) => {
|
||||||
console.log(`New client connected: ${socket.id}`);
|
console.log(`New client connected: ${socket.id}`);
|
||||||
@@ -26,11 +29,29 @@ export const initWebsocket = (server: any) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
return io;
|
return io;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/** Připojí Redis adapter pro cross-pod broadcasting. Volat až po inicializaci Redis klienta. */
|
||||||
|
export const initRedisAdapter = async () => {
|
||||||
|
const HOST = process.env.REDIS_HOST ?? 'localhost';
|
||||||
|
const PORT = process.env.REDIS_PORT ?? 6379;
|
||||||
|
const url = `redis://${HOST}:${PORT}`;
|
||||||
|
pubClient = createClient({ url }) as ReturnType<typeof createClient>;
|
||||||
|
subClient = pubClient.duplicate();
|
||||||
|
await Promise.all([pubClient.connect(), subClient.connect()]);
|
||||||
|
io.adapter(createAdapter(pubClient as any, subClient as any));
|
||||||
|
console.log('Socket.io: Redis adapter connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Zavře pub/sub Redis klienty adaptéru při graceful shutdown. */
|
||||||
|
export const shutdownWebsocketClients = async () => {
|
||||||
|
await Promise.allSettled([pubClient?.quit(), subClient?.quit()]);
|
||||||
|
};
|
||||||
|
|
||||||
export const getWebsocket = () => io;
|
export const getWebsocket = () => io;
|
||||||
|
|
||||||
/** Pošle event konkrétnímu přihlášenému uživateli (pokud je připojen). */
|
/** 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) => {
|
export const emitToUser = (login: string, event: string, data: unknown) => {
|
||||||
|
if (!io) return;
|
||||||
io.to(`user:${login}`).emit(event, data);
|
io.to(`user:${login}`).emit(event, data);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1521,6 +1521,15 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
|
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
|
||||||
integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==
|
integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==
|
||||||
|
|
||||||
|
"@socket.io/redis-adapter@^8.3.0":
|
||||||
|
version "8.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@socket.io/redis-adapter/-/redis-adapter-8.3.0.tgz#bdce1e8f34c07df4a8baf98170bf24dc84eaed4a"
|
||||||
|
integrity sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==
|
||||||
|
dependencies:
|
||||||
|
debug "~4.3.1"
|
||||||
|
notepack.io "~3.0.1"
|
||||||
|
uid2 "1.0.0"
|
||||||
|
|
||||||
"@tsconfig/node10@^1.0.7":
|
"@tsconfig/node10@^1.0.7":
|
||||||
version "1.0.11"
|
version "1.0.11"
|
||||||
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"
|
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"
|
||||||
@@ -2438,6 +2447,13 @@ debug@^4.1.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms "^2.1.3"
|
ms "^2.1.3"
|
||||||
|
|
||||||
|
debug@~4.3.1:
|
||||||
|
version "4.3.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52"
|
||||||
|
integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==
|
||||||
|
dependencies:
|
||||||
|
ms "^2.1.3"
|
||||||
|
|
||||||
dedent@^1.6.0:
|
dedent@^1.6.0:
|
||||||
version "1.7.0"
|
version "1.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.0.tgz#c1f9445335f0175a96587be245a282ff451446ca"
|
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.0.tgz#c1f9445335f0175a96587be245a282ff451446ca"
|
||||||
@@ -3844,6 +3860,11 @@ normalize-path@^3.0.0, normalize-path@~3.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
|
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
|
||||||
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
|
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
|
||||||
|
|
||||||
|
notepack.io@~3.0.1:
|
||||||
|
version "3.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/notepack.io/-/notepack.io-3.0.1.tgz#2c2c9de1bd4e64a79d34e33c413081302a0d4019"
|
||||||
|
integrity sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==
|
||||||
|
|
||||||
npm-run-path@^4.0.1:
|
npm-run-path@^4.0.1:
|
||||||
version "4.0.1"
|
version "4.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
|
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
|
||||||
@@ -4571,6 +4592,11 @@ typescript@^5.9.3:
|
|||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
|
||||||
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
||||||
|
|
||||||
|
uid2@1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/uid2/-/uid2-1.0.0.tgz#ef8d95a128d7c5c44defa1a3d052eecc17a06bfb"
|
||||||
|
integrity sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==
|
||||||
|
|
||||||
undefsafe@^2.0.5:
|
undefsafe@^2.0.5:
|
||||||
version "2.0.5"
|
version "2.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c"
|
resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c"
|
||||||
|
|||||||
@@ -14,21 +14,21 @@ post:
|
|||||||
description: ID skupiny
|
description: ID skupiny
|
||||||
type: string
|
type: string
|
||||||
fees:
|
fees:
|
||||||
description: Poplatky (Kč)
|
description: Poplatky (haléře)
|
||||||
type: number
|
type: integer
|
||||||
shipping:
|
shipping:
|
||||||
description: Doprava (Kč)
|
description: Doprava (haléře)
|
||||||
type: number
|
type: integer
|
||||||
tip:
|
tip:
|
||||||
description: Spropitné (Kč)
|
description: Spropitné (haléře)
|
||||||
type: number
|
type: integer
|
||||||
discountType:
|
discountType:
|
||||||
description: Typ slevy
|
description: Typ slevy
|
||||||
type: string
|
type: string
|
||||||
enum: [percent, fixed]
|
enum: [percent, fixed]
|
||||||
discountValue:
|
discountValue:
|
||||||
description: Hodnota slevy
|
description: Hodnota slevy (procenta nebo haléře pro fixed)
|
||||||
type: number
|
type: integer
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ post:
|
|||||||
description: Login člena ke změně
|
description: Login člena ke změně
|
||||||
type: string
|
type: string
|
||||||
amount:
|
amount:
|
||||||
description: Částka k úhradě v Kč
|
description: Částka k úhradě v haléřích
|
||||||
type: number
|
type: integer
|
||||||
note:
|
note:
|
||||||
description: Poznámka
|
description: Poznámka
|
||||||
type: string
|
type: string
|
||||||
@@ -27,8 +27,8 @@ post:
|
|||||||
description: Popis příplatku
|
description: Popis příplatku
|
||||||
type: string
|
type: string
|
||||||
surchargeAmount:
|
surchargeAmount:
|
||||||
description: Výše příplatku v Kč
|
description: Výše příplatku v haléřích
|
||||||
type: number
|
type: integer
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
|
|||||||
@@ -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: number
|
type: integer
|
||||||
description: Částka přirážky/slevy v Kč
|
description: Částka přirážky/slevy v haléřích
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Nastavení přirážky/slevy proběhlo úspěšně.
|
description: Nastavení přirážky/slevy proběhlo úspěšně.
|
||||||
|
|||||||
+34
-31
@@ -420,14 +420,14 @@ PizzaSize:
|
|||||||
description: Velikost pizzy, např. "30cm"
|
description: Velikost pizzy, např. "30cm"
|
||||||
type: string
|
type: string
|
||||||
pizzaPrice:
|
pizzaPrice:
|
||||||
description: Cena samotné pizzy v Kč
|
description: Cena samotné pizzy v haléřích
|
||||||
type: number
|
type: integer
|
||||||
boxPrice:
|
boxPrice:
|
||||||
description: Cena krabice pizzy v Kč
|
description: Cena krabice pizzy v haléřích
|
||||||
type: number
|
type: integer
|
||||||
price:
|
price:
|
||||||
description: Celková cena (pizza + krabice)
|
description: Celková cena (pizza + krabice) v haléřích
|
||||||
type: number
|
type: integer
|
||||||
Pizza:
|
Pizza:
|
||||||
description: Údaje o konkrétní pizze.
|
description: Údaje o konkrétní pizze.
|
||||||
type: object
|
type: object
|
||||||
@@ -470,8 +470,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 Kč, včetně krabice/obalu
|
description: Cena v haléřích, včetně krabice/obalu
|
||||||
type: number
|
type: integer
|
||||||
category:
|
category:
|
||||||
description: Kategorie položky (pizza nebo salat)
|
description: Kategorie položky (pizza nebo salat)
|
||||||
type: string
|
type: string
|
||||||
@@ -494,8 +494,8 @@ Salat:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
price:
|
price:
|
||||||
description: Cena salátu v Kč (bez obalu)
|
description: Cena salátu v haléřích (bez obalu)
|
||||||
type: number
|
type: integer
|
||||||
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 +521,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 Kč
|
description: Cena příplatku v haléřích
|
||||||
type: number
|
type: integer
|
||||||
totalPrice:
|
totalPrice:
|
||||||
description: Celková cena všech objednaných pizz daného uživatele, včetně krabic a příplatků
|
description: Celková cena všech objednaných pizz daného uživatele v haléřích, včetně krabic a příplatků
|
||||||
type: number
|
type: integer
|
||||||
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 +635,9 @@ QrRecipient:
|
|||||||
description: Účel platby (např. "Pizza prosciutto")
|
description: Účel platby (např. "Pizza prosciutto")
|
||||||
type: string
|
type: string
|
||||||
amount:
|
amount:
|
||||||
description: Částka v Kč (kladné číslo, max 2 desetinná místa)
|
description: Částka v haléřích (kladné celé číslo)
|
||||||
type: number
|
type: integer
|
||||||
minimum: 0.01
|
minimum: 1
|
||||||
GenerateQrRequest:
|
GenerateQrRequest:
|
||||||
description: Request pro generování QR kódů
|
description: Request pro generování QR kódů
|
||||||
type: object
|
type: object
|
||||||
@@ -704,8 +704,8 @@ OrderGroupMember:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
amount:
|
amount:
|
||||||
description: Částka k úhradě v Kč
|
description: Částka k úhradě v haléřích
|
||||||
type: number
|
type: integer
|
||||||
note:
|
note:
|
||||||
description: Volitelná poznámka (např. co si objednává)
|
description: Volitelná poznámka (např. co si objednává)
|
||||||
type: string
|
type: string
|
||||||
@@ -713,8 +713,8 @@ OrderGroupMember:
|
|||||||
description: Popis příplatku
|
description: Popis příplatku
|
||||||
type: string
|
type: string
|
||||||
surchargeAmount:
|
surchargeAmount:
|
||||||
description: Výše příplatku v Kč
|
description: Výše příplatku v haléřích
|
||||||
type: number
|
type: integer
|
||||||
paid:
|
paid:
|
||||||
description: Příznak, zda člen uhradil svůj podíl objednávky
|
description: Příznak, zda člen uhradil svůj podíl objednávky
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -753,21 +753,24 @@ OrderGroup:
|
|||||||
description: Očekávaný čas doručení ve formátu HH:MM
|
description: Očekávaný čas doručení ve formátu HH:MM
|
||||||
type: string
|
type: string
|
||||||
fees:
|
fees:
|
||||||
description: Poplatky (balení apod.) celkem v Kč
|
description: Poplatky (balení apod.) celkem v haléřích
|
||||||
type: number
|
type: integer
|
||||||
shipping:
|
shipping:
|
||||||
description: Doprava v Kč
|
description: Doprava v haléřích
|
||||||
type: number
|
type: integer
|
||||||
tip:
|
tip:
|
||||||
description: Spropitné v Kč
|
description: Spropitné v haléřích
|
||||||
type: number
|
type: integer
|
||||||
discountType:
|
discountType:
|
||||||
description: Typ slevy aplikované na objednávku
|
description: Typ slevy aplikované na objednávku
|
||||||
type: string
|
type: string
|
||||||
enum: [percent, fixed]
|
enum: [percent, fixed]
|
||||||
discountValue:
|
discountValue:
|
||||||
description: Hodnota slevy (procenta nebo Kč)
|
description: Hodnota slevy (procenta pro typ 'percent', haléře pro typ 'fixed')
|
||||||
type: number
|
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:
|
||||||
@@ -790,8 +793,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 Kč
|
description: Celková cena objednávky v haléřích
|
||||||
type: number
|
type: integer
|
||||||
purpose:
|
purpose:
|
||||||
description: Účel platby (např. "Pizza prosciutto")
|
description: Účel platby (např. "Pizza prosciutto")
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
Reference in New Issue
Block a user