1 Commits

Author SHA1 Message Date
mates 1e13a1d02b test: opravy Playwright testů
CI / Generate TypeScript types (pull_request) Failing after 7s
CI / Server unit tests (pull_request) Has been skipped
CI / Build server (pull_request) Has been skipped
CI / Build client (pull_request) Has been skipped
CI / Playwright E2E tests (pull_request) Has been skipped
CI / Build and push Docker image (pull_request) Has been skipped
CI / Notify (pull_request) Has been skipped
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Failing after 7s
CI / Build client (push) Failing after 8s
CI / Playwright E2E tests (push) Has been skipped
CI / Server unit tests (push) Successful in 20s
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 3s
2026-04-29 20:56:48 +02:00
156 changed files with 1727 additions and 8979 deletions
+6 -7
View File
@@ -24,7 +24,7 @@ jobs:
with: with:
node-version: "22" node-version: "22"
- run: corepack enable - run: npm install -g yarn
- run: cd types && yarn install --frozen-lockfile && yarn openapi-ts - run: cd types && yarn install --frozen-lockfile && yarn openapi-ts
@@ -51,7 +51,7 @@ jobs:
with: with:
node-version: "22" node-version: "22"
- run: corepack enable - run: npm install -g yarn
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
@@ -73,7 +73,7 @@ jobs:
with: with:
node-version: "22" node-version: "22"
- run: corepack enable - run: npm install -g yarn
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
@@ -102,7 +102,7 @@ jobs:
with: with:
node-version: "22" node-version: "22"
- run: corepack enable - run: npm install -g yarn
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
@@ -189,7 +189,7 @@ jobs:
with: with:
node-version: "22" node-version: "22"
- run: corepack enable - run: npm install -g yarn
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
@@ -215,8 +215,7 @@ jobs:
- uses: docker/build-push-action@v5 - uses: docker/build-push-action@v5
with: with:
context: . context: .
file: Dockerfile file: Dockerfile-Woodpecker
target: runner-prebuilt
platforms: linux/amd64 platforms: linux/amd64
push: true push: true
tags: ${{ secrets.REPO_URL }}/${{ secrets.REPO_NAME }}:latest tags: ${{ secrets.REPO_URL }}/${{ secrets.REPO_NAME }}:latest
-3
View File
@@ -4,6 +4,3 @@ types/gen
.mcp.json .mcp.json
.claude/settings.local.json .claude/settings.local.json
server/public/ server/public/
.claude/*.lock
.claude/worktrees
.playwright-mcp
-32
View File
@@ -1,32 +0,0 @@
{
"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
}
]
}
-67
View File
@@ -1,67 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "types: openapi-ts",
"type": "shell",
"command": "yarn openapi-ts",
"options": {
"cwd": "${workspaceFolder}/types"
},
"presentation": {
"reveal": "silent",
"panel": "dedicated"
},
"problemMatcher": []
},
{
"label": "server: startReload",
"type": "shell",
"command": "yarn startReload",
"options": {
"cwd": "${workspaceFolder}/server",
"env": {
"NODE_ENV": "development"
}
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"group": "dev"
},
"problemMatcher": []
},
{
"label": "client: vite",
"type": "shell",
"command": "yarn start",
"options": {
"cwd": "${workspaceFolder}/client"
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"group": "dev"
},
"problemMatcher": []
},
{
"label": "dev: server+client",
"dependsOn": [
"server: startReload",
"client: vite"
]
},
{
"label": "dev: all",
"dependsOrder": "sequence",
"dependsOn": [
"types: openapi-ts",
"dev: server+client"
],
"problemMatcher": []
}
]
}
+9 -35
View File
@@ -12,12 +12,9 @@ Luncher is a lunch management app for teams — daily restaurant menus, food ord
types/ → Shared OpenAPI-generated TypeScript types (source of truth: types/api.yml) types/ → Shared OpenAPI-generated TypeScript types (source of truth: types/api.yml)
server/ → Express 5 backend (Node.js 22, ts-node) server/ → Express 5 backend (Node.js 22, ts-node)
client/ → React 19 frontend (Vite 7, React Bootstrap) client/ → React 19 frontend (Vite 7, React Bootstrap)
e2e/ → Playwright E2E tests (separate package)
``` ```
Each of the four directories has its own `package.json`. Package manager: **Yarn Classic**. Each directory has its own `package.json` and `tsconfig.json`. Package manager: **Yarn Classic**.
Deployment files at repo root: `Dockerfile` (multi-stage, dva runner targety: výchozí `runner` pro lokální build, `runner-prebuilt` pro CI s předem sestavenými artefakty), `compose.yml`, `compose-traefik.yml`.
## Development Commands ## Development Commands
@@ -26,7 +23,6 @@ Deployment files at repo root: `Dockerfile` (multi-stage, dva runner targety: v
cd types && yarn install && yarn openapi-ts # Generate API types first cd types && yarn install && yarn openapi-ts # Generate API types first
cd ../server && yarn install cd ../server && yarn install
cd ../client && yarn install cd ../client && yarn install
cd ../e2e && yarn install
``` ```
### Running dev environment ### Running dev environment
@@ -48,30 +44,11 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
### Tests ### Tests
```bash ```bash
# Server unit tests (Jest) cd server && yarn test # Jest (tests in server/src/tests/)
cd server && yarn test # All tests in server/src/tests/ cd server && yarn test dates # Run one test file
cd server && yarn test dates # Run one file by name
cd server && yarn test -t "name" # Run by test name pattern cd server && yarn test -t "name" # Run by test name pattern
# E2E (Playwright) — requires prebuilt server
cd server && yarn build
cd e2e && yarn test # chromium + firefox, baseURL 127.0.0.1:3001
cd e2e && yarn test:ui # interactive UI mode
cd e2e && yarn report # open last HTML report
``` ```
Jest setup (`server/src/tests/setupEnv.ts`) forces `STORAGE=memory`, deletes `MOCK_DATA`, and sets a fixed `JWT_SECRET`. Playwright auto-starts the prebuilt server and authenticates via the `remote-user: e2e-user` trusted-header path; locally uses `STORAGE=json` + `MOCK_DATA=true`, CI uses `STORAGE=redis`.
### CI pipeline
Gitea Actions — `.gitea/workflows/ci.yaml` (no `.github/` equivalent):
1. `generate-types` — runs `yarn openapi-ts`, uploads artifact
2. `server-test` — Jest
3. `server-build` + `client-build` — parallel tsc/vite builds
4. `e2e` — Playwright in `mcr.microsoft.com/playwright:v1.59.1-jammy` with a Redis service container; only Firefox installed in CI
5. `docker-build` — master branch only, uses `Dockerfile` with `--target runner-prebuilt` (skládá image z artefaktů `server-build` + `client-build`)
6. `notify` — Discord + ntfy webhooks
### Formatting ### Formatting
Prettier is installed in `client/` (devDependency only, no script or config) — invoke via `yarn prettier <path>` with defaults. Prettier is installed in `client/` (devDependency only, no script or config) — invoke via `yarn prettier <path>` with defaults.
@@ -86,11 +63,10 @@ Prettier is installed in `client/` (devDependency only, no script or config) —
### Server (server/src/) ### Server (server/src/)
- **Entry:** `index.ts` — Express app + Socket.io setup - **Entry:** `index.ts` — Express app + Socket.io setup
- **Routes:** `routes/` 9 modular Express route handlers (food, pizzaDay, voting, notifications, qr, stats, easterEgg, dev, changelog) - **Routes:** `routes/` — modular Express route handlers (food, pizzaDay, voting, notifications, qr, stats, easterEgg, dev)
- **Services:** domain logic files at root level (`service.ts`, `pizza.ts`, `restaurants.ts`, `chefie.ts`, `voting.ts`, `stats.ts`, `qr.ts`, `notifikace.ts`) - **Services:** domain logic files at root level (`service.ts`, `pizza.ts`, `restaurants.ts`, `chefie.ts`, `voting.ts`, `stats.ts`, `qr.ts`, `notifikace.ts`)
- **Helpers:** `mock.ts` (fake menu data for `MOCK_DATA=true`), `pushReminder.ts` (push notification reminders), `utils.ts` (shared utilities)
- **Auth:** `auth.ts` — JWT + optional trusted-header authentication - **Auth:** `auth.ts` — JWT + optional trusted-header authentication
- **Storage:** `storage/index.ts` factory selects implementation; backends: `json.ts` (file-based, dev), `redis.ts` (production), `memory.ts` (tests). Data keyed by date (YYYY-MM-DD). - **Storage:** `storage/StorageInterface.ts` defines the interface; implementations in `storage/json.ts` (file-based, dev) and `storage/redis.ts` (production). Data keyed by date (YYYY-MM-DD).
- **Restaurant scrapers:** Cheerio-based HTML parsing for daily menus from multiple restaurants - **Restaurant scrapers:** Cheerio-based HTML parsing for daily menus from multiple restaurants
- **Pizza Chefie integration:** `chefie.ts` scrapes pizzachefie.cz for Pizza day menus and salads (only active when a Pizza day is open) - **Pizza Chefie integration:** `chefie.ts` scrapes pizzachefie.cz for Pizza day menus and salads (only active when a Pizza day is open)
- **WebSocket:** `websocket.ts` — Socket.io for real-time client updates - **WebSocket:** `websocket.ts` — Socket.io for real-time client updates
@@ -98,12 +74,10 @@ Prettier is installed in `client/` (devDependency only, no script or config) —
- **Config:** `.env.development` / `.env.production` (see `.env.template` for all options) - **Config:** `.env.development` / `.env.production` (see `.env.template` for all options)
### Client (client/src/) ### Client (client/src/)
- **Entry:** `index.tsx``App.tsx``AppRoutes.tsx`; `Login.tsx` is the auth screen; `FallingLeaves.tsx` is a seasonal visual effect - **Entry:** `index.tsx``App.tsx``AppRoutes.tsx`
- **Pages:** `pages/` (StatsPage) - **Pages:** `pages/` (StatsPage)
- **Components:** `components/` (Header, Footer, Loader, PizzaOrderList, PizzaOrderRow, and modals in `components/modals/`) - **Components:** `components/` (Header, Footer, Loader, modals/, PizzaOrderList, PizzaOrderRow)
- **Context providers:** `context/``auth.tsx`, `settings.tsx`, `socket.js`, `eggs.tsx` (note: `socket.js` is the only non-TSX context file) - **Context providers:** `context/`AuthContext, SettingsContext, SocketContext, EasterEggContext
- **Hooks:** `hooks/` (`usePushReminder.ts`)
- **Utils:** `utils/` (`parsePrice.ts`)
- **Styling:** Bootstrap 5 + React Bootstrap + custom SCSS files (co-located with components) - **Styling:** Bootstrap 5 + React Bootstrap + custom SCSS files (co-located with components)
- **API calls:** use OpenAPI-generated SDK from `types/gen/` - **API calls:** use OpenAPI-generated SDK from `types/gen/`
- **Routing:** React Router DOM v7 - **Routing:** React Router DOM v7
@@ -117,7 +91,7 @@ Prettier is installed in `client/` (devDependency only, no script or config) —
## Environment ## Environment
- **Server env files:** `server/.env.development`, `server/.env.production` (see `server/.env.template`) - **Server env files:** `server/.env.development`, `server/.env.production` (see `server/.env.template`)
- Key vars: `JWT_SECRET`, `STORAGE` (json/redis/memory), `MOCK_DATA`, `REDIS_HOST`, `REDIS_PORT` - Key vars: `JWT_SECRET`, `STORAGE` (json/redis), `MOCK_DATA`, `REDIS_HOST`, `REDIS_PORT`
- **Docker:** multi-stage Dockerfile, `compose.yml` for app + Redis. Timezone: Europe/Prague. - **Docker:** multi-stage Dockerfile, `compose.yml` for app + Redis. Timezone: Europe/Prague.
## Conventions ## Conventions
+8 -33
View File
@@ -1,6 +1,6 @@
ARG NODE_VERSION="node:22-alpine" ARG NODE_VERSION="node:22-alpine"
# ─── Builder ────────────────────────────────────────────────────────────────── # Builder
FROM ${NODE_VERSION} AS builder FROM ${NODE_VERSION} AS builder
WORKDIR /build WORKDIR /build
@@ -62,9 +62,8 @@ RUN yarn build
WORKDIR /build/client WORKDIR /build/client
RUN yarn build RUN yarn build
# ─── Runner base ────────────────────────────────────────────────────────────── # Runner
# Společný základ pro oba runner targety nastaví prostředí a metadata běhu. FROM ${NODE_VERSION}
FROM ${NODE_VERSION} AS runner-base
RUN apk add --no-cache tzdata RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \ ENV TZ=Europe/Prague \
@@ -73,17 +72,6 @@ ENV TZ=Europe/Prague \
WORKDIR /app WORKDIR /app
# Export /data/db.json do složky /data
VOLUME ["/data"]
EXPOSE 3001
CMD [ "node", "./server/src/index.js" ]
# ─── Runner (default) ─────────────────────────────────────────────────────────
# Použití: docker build . (lokální sestavení vše se buildí uvnitř image)
FROM runner-base AS runner
# Vykopírování sestaveného serveru # Vykopírování sestaveného serveru
COPY --from=builder /build/server/node_modules ./server/node_modules COPY --from=builder /build/server/node_modules ./server/node_modules
COPY --from=builder /build/server/dist ./ COPY --from=builder /build/server/dist ./
@@ -97,25 +85,12 @@ COPY /server/.env.production ./server
# Zkopírování changelogů (seznamu novinek) # Zkopírování changelogů (seznamu novinek)
COPY /server/changelogs ./server/changelogs COPY /server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů # Zkopírování konfigurace easter eggů a changelogů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
# ─── Runner (prebuilt) ──────────────────────────────────────────────────────── # Export /data/db.json do složky /data
# Použití: docker build --target runner-prebuilt . VOLUME ["/data"]
# Očekává předem sestavené artefakty v build kontextu (server/dist,
# client/dist, server/node_modules) využívá Gitea Actions, kde se
# server i klient buildí v separátních jobech a sem se jen kopírují.
FROM runner-base AS runner-prebuilt
# Vykopírování sestaveného serveru EXPOSE 3000
COPY ./server/node_modules ./server/node_modules
COPY ./server/dist ./
# Vykopírování sestaveného klienta CMD [ "node", "./server/src/index.js" ]
COPY ./client/dist ./public
# Zkopírování changelogů (seznamu novinek)
COPY ./server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
+30
View File
@@ -0,0 +1,30 @@
ARG NODE_VERSION="node:22-alpine"
FROM ${NODE_VERSION}
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
WORKDIR /app
# Vykopírování sestaveného serveru
COPY ./server/node_modules ./server/node_modules
COPY ./server/dist ./
# TODO tohle není dobře, má to být součástí serveru
# COPY ./server/resources ./resources
# Vykopírování sestaveného klienta
COPY ./client/dist ./public
# Zkopírování changelogů (seznamu novinek)
COPY ./server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů a changelogů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi \
&& if [ -d ./server/changelogs ]; then cp -r ./server/changelogs ./server/changelogs; fi
EXPOSE 3000
CMD [ "node", "./server/src/index.js" ]
-5
View File
@@ -1,9 +1,4 @@
# 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í)
-1
View File
@@ -18,7 +18,6 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"react": "^19.2.0", "react": "^19.2.0",
"react-bootstrap": "^2.10.10", "react-bootstrap": "^2.10.10",
"react-datepicker": "^9.1.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-jwt": "^1.3.0", "react-jwt": "^1.3.0",
"react-modal": "^3.16.3", "react-modal": "^3.16.3",
+7 -6
View File
@@ -7,7 +7,6 @@ self.addEventListener('push', (event) => {
body: data.body, body: data.body,
icon: '/favicon.ico', icon: '/favicon.ico',
tag: 'lunch-reminder', tag: 'lunch-reminder',
data: { login: data.login, token: data.token },
actions: [ actions: [
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' }, { action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
], ],
@@ -19,26 +18,28 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close(); event.notification.close();
if (event.action === 'neobedvam') { if (event.action === 'neobedvam') {
const { login, token } = event.notification.data ?? {};
if (login && token) {
event.waitUntil( event.waitUntil(
fetch('/api/notifications/push/quickChoice', { self.registration.pushManager.getSubscription().then((subscription) => {
if (!subscription) return;
return fetch('/api/notifications/push/quickChoice', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login, token }), body: JSON.stringify({ endpoint: subscription.endpoint }),
});
}) })
); );
}
return; return;
} }
event.waitUntil( event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clientList) => { self.clients.matchAll({ type: 'window' }).then((clientList) => {
// Pokud je již otevřené okno, zaostříme na něj
for (const client of clientList) { for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) { if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus(); return client.focus();
} }
} }
// Jinak otevřeme nové
return self.clients.openWindow('/'); return self.clients.openWindow('/');
}) })
); );
-100
View File
@@ -226,7 +226,6 @@ body {
&:hover svg { &:hover svg {
transform: rotate(15deg); transform: rotate(15deg);
} }
} }
// ============================================ // ============================================
@@ -279,105 +278,6 @@ body {
} }
} }
// Varianta navigace mezi dny na stránce objednávání šipky kolem date pickeru
.order-day-navigator {
margin-bottom: 16px;
gap: 16px;
// react-datepicker obaluje input do wrapperu necháme ho zabrat jen potřebnou šířku
.react-datepicker-wrapper {
width: auto;
}
.order-date-input {
width: 160px;
cursor: pointer;
}
}
// Zvýraznění dnů, ve kterých existuje alespoň jedna objednávka tečka pod číslem dne
.react-datepicker__day.luncher-order-day {
position: relative;
font-weight: 700;
&::after {
content: "";
position: absolute;
left: 50%;
bottom: 2px;
transform: translateX(-50%);
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--luncher-primary, #0d6efd);
}
// U vybraného dne (tmavé pozadí) je tečka světlá, aby byla vidět
&.react-datepicker__day--selected::after,
&.react-datepicker__day--keyboard-selected::after {
background: #fff;
}
}
// Vybraný den používá akcentovou barvu aplikace (v obou režimech), místo výchozí modré
.react-datepicker__day--selected,
.react-datepicker__day--keyboard-selected {
background-color: var(--luncher-primary);
color: #fff;
&:hover {
background-color: var(--luncher-primary-hover);
}
}
// Tmavý režim kalendáře (react-datepicker) navázáno na CSS proměnné motivu
[data-bs-theme="dark"] {
.react-datepicker {
background-color: var(--luncher-bg-card);
border-color: var(--luncher-border);
color: var(--luncher-text);
}
.react-datepicker__header {
background-color: var(--luncher-bg-hover);
border-bottom-color: var(--luncher-border);
}
.react-datepicker__current-month,
.react-datepicker__day-name,
.react-datepicker__day,
.react-datepicker-year-header {
color: var(--luncher-text);
}
.react-datepicker__day:hover,
.react-datepicker__month-text:hover {
background-color: var(--luncher-bg-hover);
}
.react-datepicker__day--today {
color: var(--luncher-primary);
}
.react-datepicker__day--disabled,
.react-datepicker__day--outside-month {
color: var(--luncher-text-muted);
}
// Šipky pro přepínání měsíců
.react-datepicker__navigation-icon::before {
border-color: var(--luncher-text-secondary);
}
// Špička popoveru (SVG) míří do hlavičky sladíme barvy.
// !important kvůli vyšší specificitě knihovního pravidla [data-placement].
.react-datepicker__triangle {
fill: var(--luncher-bg-hover) !important;
color: var(--luncher-bg-hover) !important;
stroke: var(--luncher-border) !important;
}
}
// ============================================ // ============================================
// FOOD TABLES - CARD STYLE // FOOD TABLES - CARD STYLE
// ============================================ // ============================================
+39 -45
View File
@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react'; import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import { EVENT_DISCONNECT, EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from './context/socket'; import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket';
import { useAuth } from './context/auth'; import { useAuth } from './context/auth';
import Login from './Login'; import Login from './Login';
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap'; import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
@@ -13,15 +13,13 @@ import './App.scss';
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons'; import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
import { useSettings } from './context/settings'; import { useSettings } from './context/settings';
import Footer from './components/Footer'; import Footer from './components/Footer';
import { faArrowUpRightFromSquare, faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import { useNavigate } from 'react-router-dom';
import Loader from './components/Loader'; import Loader from './components/Loader';
import { getHumanDateTime, isInTheFuture } from './Utils'; import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils';
import NoteModal from './components/modals/NoteModal'; import NoteModal from './components/modals/NoteModal';
import PayForAllModal from './components/modals/PayForAllModal'; import PayForAllModal from './components/modals/PayForAllModal';
import PendingPayments from './components/PendingPayments';
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, generateQr } from '../../types'; import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr, generateQr } from '../../types';
import { getLunchChoiceName } from './enums'; import { getLunchChoiceName } from './enums';
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves'; // import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
// import './FallingLeaves.scss'; // import './FallingLeaves.scss';
@@ -61,7 +59,6 @@ const EASTER_EGG_DEFAULT_DURATION = 0.75;
function App() { function App() {
const auth = useAuth(); const auth = useAuth();
const settings = useSettings(); const settings = useSettings();
const navigate = useNavigate();
const [easterEgg, _] = useEasterEgg(auth); const [easterEgg, _] = useEasterEgg(auth);
const [isConnected, setIsConnected] = useState<boolean>(false); const [isConnected, setIsConnected] = useState<boolean>(false);
const [data, setData] = useState<ClientData>(); const [data, setData] = useState<ClientData>();
@@ -129,46 +126,19 @@ function App() {
}); });
socket.on(EVENT_MESSAGE, (newData: ClientData) => { socket.on(EVENT_MESSAGE, (newData: ClientData) => {
// console.log("Přijata nová data ze socketu", newData); // console.log("Přijata nová data ze socketu", newData);
if (newData.slot === MealSlot.EXTRA) return;
// Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený // Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený
if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) { if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) {
setData(newData); setData(newData);
} }
}); });
socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => {
setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev);
});
return () => { return () => {
socket.off(EVENT_CONNECT); socket.off(EVENT_CONNECT);
socket.off(EVENT_DISCONNECT); socket.off(EVENT_DISCONNECT);
socket.off(EVENT_MESSAGE); socket.off(EVENT_MESSAGE);
socket.off(EVENT_PENDING_QR);
} }
}, [socket]); }, [socket]);
// Připojení do osobní socket místnosti po přihlášení
useEffect(() => {
if (auth?.login) {
socket.emit('join', auth.login);
}
}, [auth?.login, socket]);
// 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
@@ -467,7 +437,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 / 100} Kč)`; const name = `${size.size} (${size.price} Kč)`;
const value = `pizza|${index}|${sizeIndex}`; const value = `pizza|${index}|${sizeIndex}`;
group.items?.push({ name, value }); group.items?.push({ name, value });
}) })
@@ -476,7 +446,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 / 100} Kč)`, value: `salat|${index}` }); salatGroup.items?.push({ name: `${salat.name} (${salat.price} Kč)`, value: `salat|${index}` });
}); });
suggestions.push(salatGroup); suggestions.push(salatGroup);
} }
@@ -750,9 +720,6 @@ function App() {
markAsBuyer(); markAsBuyer();
}} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{ cursor: 'pointer' }} /> }} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{ cursor: 'pointer' }} />
</span>} </span>}
{login === auth.login && locationKey === LunchChoice.OBJEDNAVAM && <span title='Přejít na stránku objednávek'>
<FontAwesomeIcon onClick={() => navigate('/objednani')} icon={faArrowUpRightFromSquare} className='action-icon' style={{ cursor: 'pointer' }} />
</span>}
{login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'> {login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
copyNote(userPayload.note!); copyNote(userPayload.note!);
@@ -902,21 +869,48 @@ function App() {
</div> </div>
} }
<PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} /> <PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} />
{
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr && (() => {
const pizzaQr = data.pendingQrs?.find(qr => qr.creator === data.pizzaDay?.creator);
return pizzaQr ? (
<div className='qr-code'>
<h3>QR platba</h3>
<img src={`/api/qr?login=${auth.login}&id=${pizzaQr.id}`} alt='QR kód' />
</div>
) : null;
})()
}
</> </>
} }
</div> </div>
} }
</div> </div>
<PendingPayments {data.pendingQrs && data.pendingQrs.length > 0 &&
pendingQrs={data.pendingQrs} <div className='pizza-section fade-in mt-4'>
login={auth.login} <h3>Nevyřízené platby</h3>
onDismissed={async () => { <p>Máte neuhrazené platby.</p>
{data.pendingQrs.map(qr => (
<div key={qr.id} className='qr-code mb-3'>
<p>
<strong>{formatDateString(qr.date)}</strong> {qr.creator} ({qr.totalPrice} )
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
</p>
<img src={`/api/qr?login=${auth.login}&id=${qr.id}`} alt='QR kód' />
<div className='mt-2'>
<Button variant="success" onClick={async () => {
await dismissQr({ body: { id: qr.id } });
const response = await getData({ query: { dayIndex } }); const response = await getData({ query: { dayIndex } });
if (response.data) { if (response.data) {
setData(response.data); setData(response.data);
} }
}} }}>
/> Zaplatil jsem
</Button>
</div>
</div>
))}
</div>
}
</> </>
</div> </div>
{/* <FallingLeaves {/* <FallingLeaves
-17
View File
@@ -5,31 +5,14 @@ import { SnowOverlay } from 'react-snow-overlay';
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket"; import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage"; import StatsPage from "./pages/StatsPage";
import OrderGroupsPage from "./pages/OrderGroupsPage";
import SuggestionsPage from "./pages/SuggestionsPage";
import App from "./App"; import App from "./App";
export const STATS_URL = '/stats'; export const STATS_URL = '/stats';
export const OBJEDNANI_URL = '/objednani';
export const NAVRHY_URL = '/navrhy';
export default function AppRoutes() { export default function AppRoutes() {
return ( return (
<Routes> <Routes>
<Route path={STATS_URL} element={<StatsPage />} /> <Route path={STATS_URL} element={<StatsPage />} />
<Route path={NAVRHY_URL} element={
<ProvideSettings>
<SuggestionsPage />
</ProvideSettings>
} />
<Route path={OBJEDNANI_URL} element={
<ProvideSettings>
<SocketContext.Provider value={socket}>
<OrderGroupsPage />
<ToastContainer />
</SocketContext.Provider>
</ProvideSettings>
} />
<Route path="/" element={ <Route path="/" element={
<ProvideSettings> <ProvideSettings>
<SocketContext.Provider value={socket}> <SocketContext.Provider value={socket}>
-13
View File
@@ -110,16 +110,3 @@ export function formatDateString(dateString: string): string {
const [year, month, day] = dateString.split('-'); const [year, month, day] = dateString.split('-');
return `${day}.${month}.${year}`; return `${day}.${month}.${year}`;
} }
/**
* Očistí zprávu (účel platby) pro QR platbu musí odpovídat serverové logice (qr.ts):
* transliteruje diakritiku na základní písmena (š→s, č→c, ...), odstraní znaky mimo
* ISO 8859-1 a hvězdičku (oddělovač polí v QR platbě) a ořízne na max. 60 znaků.
*/
export function sanitizeQrMessage(message: string): string {
const sanitized = message
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // diakritika → základní písmeno
.replace(/[^\x00-\xff]/g, '') // znaky mimo ISO 8859-1
.replace(/\*/g, ''); // '*' je v QR platbě oddělovač
return sanitized.length > 60 ? sanitized.substring(0, 60) : sanitized;
}
@@ -1,70 +0,0 @@
.bolt-progress {
display: flex;
align-items: flex-start;
max-width: 400px;
.bolt-step {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-width: 64px;
.bolt-dot {
position: relative;
z-index: 1;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--luncher-border, #ced4da);
}
.bolt-label {
margin-top: 2px;
font-size: 0.7em;
color: var(--luncher-text-muted, #6c757d);
white-space: nowrap;
}
// Spojnice k předchozímu kroku
&:not(:first-child)::before {
content: '';
position: absolute;
top: 5px;
right: 50%;
width: 100%;
height: 2px;
background: var(--luncher-border, #ced4da);
}
&.done {
.bolt-dot {
background: var(--bs-success, #198754);
}
&:not(:first-child)::before {
background: var(--bs-success, #198754);
}
}
&.active .bolt-label {
font-weight: 600;
color: inherit;
}
}
// Pulzování aktivního kroku, dokud sledování běží
&.live .bolt-step.active .bolt-dot {
animation: bolt-pulse 2s ease-in-out infinite;
}
}
@keyframes bolt-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5);
}
50% {
box-shadow: 0 0 0 5px rgba(25, 135, 84, 0);
}
}
@@ -1,89 +0,0 @@
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
import './BoltOrderProgress.scss';
const STEPS = ['Přijato', 'Příprava', 'Na cestě', 'Doručeno'];
/**
* Známé stavy objednávky z Bolt API → index kroku ve stepperu.
* Pozor: waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí",
* nikoli "na cestě" — tu signalizuje až stav kurýra (picked_up apod.).
*/
const ORDER_STATE_TO_STEP: Record<string, number> = {
created: 0,
pending: 0,
accepted: 0,
waiting_preparation: 0,
preparing: 1,
waiting_delivery: 1,
ready_for_pickup: 1,
waiting_courier: 1,
waiting_pickup: 1,
picked_up: 2,
in_delivery: 2,
delivering: 2,
heading_to_client: 2,
delivered: 3,
finished: 3,
};
/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */
const COURIER_STATE_TO_STEP: Record<string, number> = {
heading_to_provider: 1,
arrived_to_provider: 1,
picked_up: 2,
heading_to_client: 2,
delivering: 2,
arrived_to_client: 2,
delivered: 3,
};
/** Neznámé stavy se mapují heuristicky podle klíčových slov. */
function stepForOrderState(state: string): number | 'cancelled' {
const s = state.toLowerCase();
if (s in ORDER_STATE_TO_STEP) return ORDER_STATE_TO_STEP[s];
if (/cancel|reject|fail/.test(s)) return 'cancelled';
if (/delivered|finished/.test(s)) return 3;
if (/^waiting|prepar|ready|cook/.test(s)) return 1;
if (/picked|delivering|heading_to_client|transport/.test(s)) return 2;
return 0;
}
function stepForCourierState(state?: string): number {
if (!state) return 0;
const s = state.toLowerCase();
if (s in COURIER_STATE_TO_STEP) return COURIER_STATE_TO_STEP[s];
if (/picked|client|delivering|transport/.test(s)) return 2;
return 0;
}
interface Props {
/** Raw order_state z Bolt API (např. waiting_preparation) */
state: string;
/** Raw courier.state z Bolt API (např. arrived_to_provider) */
courierState?: string;
/** Zda sledování stále běží (skupina má boltTrackingToken) */
tracking: boolean;
}
/** Mini progress stepper se stavem objednávky Bolt Food. */
export default function BoltOrderProgress({ state, courierState, tracking }: Props) {
const orderStep = stepForOrderState(state);
if (orderStep === 'cancelled') {
return <small className="text-danger">Objednávka Bolt byla zrušena</small>;
}
// Stav kurýra může krok jen zpřesnit dopředu (např. waiting_delivery + picked_up → Na cestě)
const step = Math.max(orderStep, stepForCourierState(courierState));
const rawInfo = courierState ? `${state} / kurýr: ${courierState}` : state;
return (
<OverlayTrigger overlay={<Tooltip>Stav z Bolt Food: {rawInfo}</Tooltip>}>
<div className={`bolt-progress${tracking && step < 3 ? ' live' : ''}`}>
{STEPS.map((label, i) => (
<div key={label} className={`bolt-step${i <= step ? ' done' : ''}${i === step ? ' active' : ''}`}>
<div className="bolt-dot" />
<div className="bolt-label">{label}</div>
</div>
))}
</div>
</OverlayTrigger>
);
}
+54 -15
View File
@@ -3,15 +3,15 @@ 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 PizzaCalculatorModal from "./modals/PizzaCalculatorModal"; import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import RefreshMenuModal from "./modals/RefreshMenuModal"; import RefreshMenuModal from "./modals/RefreshMenuModal";
import GenerateQrModal from "./modals/GenerateQrModal"; import GenerateQrModal from "./modals/GenerateQrModal";
import GenerateMockDataModal from "./modals/GenerateMockDataModal"; import GenerateMockDataModal from "./modals/GenerateMockDataModal";
import ClearMockDataModal from "./modals/ClearMockDataModal"; import ClearMockDataModal from "./modals/ClearMockDataModal";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { STATS_URL, OBJEDNANI_URL, NAVRHY_URL } from "../AppRoutes"; import { STATS_URL } from "../AppRoutes";
import { LunchChoices, getChangelogs } from "../../../types"; import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons"; import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import { formatDateString } from "../Utils"; import { formatDateString } from "../Utils";
@@ -30,6 +30,7 @@ export default function Header({ choices, dayIndex }: Props) {
const settings = useSettings(); const settings = useSettings();
const navigate = useNavigate(); const navigate = useNavigate();
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false); const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false); const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false); const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false); const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
@@ -37,8 +38,35 @@ export default function Header({ choices, dayIndex }: Props) {
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false); const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false); const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false); const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
const effectiveDark = settings?.effectiveDark ?? false; // Zjistíme aktuální efektivní téma (pro zobrazení správné ikony)
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(() => {
if (auth?.login) {
getVotes().then(response => {
setFeatureVotes(response.data);
})
}
}, [auth?.login]);
useEffect(() => { useEffect(() => {
if (!auth?.login) return; if (!auth?.login) return;
@@ -57,6 +85,10 @@ export default function Header({ choices, dayIndex }: Props) {
setSettingsModalOpen(false); setSettingsModalOpen(false);
} }
const closeVotingModal = () => {
setVotingModalOpen(false);
}
const closePizzaModal = () => { const closePizzaModal = () => {
setPizzaModalOpen(false); setPizzaModalOpen(false);
} }
@@ -78,7 +110,8 @@ export default function Header({ choices, dayIndex }: Props) {
} }
const toggleTheme = () => { const toggleTheme = () => {
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark'; // Přepínáme mezi light a dark (ignorujeme system pro jednoduchost)
const newTheme: ThemePreference = effectiveTheme === 'dark' ? 'light' : 'dark';
settings?.setThemePreference(newTheme); settings?.setThemePreference(newTheme);
} }
@@ -143,6 +176,17 @@ export default function Header({ choices, dayIndex }: Props) {
closeSettingsModal(); closeSettingsModal();
} }
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
await updateVote({ body: { option, active } });
const votes = [...featureVotes || []];
if (active) {
votes.push(option);
} else {
votes.splice(votes.indexOf(option), 1);
}
setFeatureVotes(votes);
}
return <Navbar variant='dark' expand="lg"> return <Navbar variant='dark' expand="lg">
<Navbar.Brand href="/">Luncher</Navbar.Brand> <Navbar.Brand href="/">Luncher</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" /> <Navbar.Toggle aria-controls="basic-navbar-nav" />
@@ -151,24 +195,18 @@ export default function Header({ choices, dayIndex }: Props) {
<button <button
className="theme-toggle" className="theme-toggle"
onClick={toggleTheme} onClick={toggleTheme}
title={effectiveDark ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'} title={effectiveTheme === 'dark' ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
aria-label="Přepnout světlý/tmavý režim" aria-label="Přepnout barevný motiv"
> >
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} /> <FontAwesomeIcon icon={effectiveTheme === 'dark' ? 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>
<NavDropdown.Item onClick={() => navigate(NAVRHY_URL)}>Návrhy na vylepšení</NavDropdown.Item> <NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item> <NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item> <NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item> <NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(OBJEDNANI_URL)}>Objednání</NavDropdown.Item>
<NavDropdown.Item onClick={() => { <NavDropdown.Item onClick={() => {
getChangelogs().then(response => { getChangelogs().then(response => {
const entries = response.data ?? {}; const entries = response.data ?? {};
@@ -194,6 +232,7 @@ export default function Header({ choices, dayIndex }: Props) {
</Navbar.Collapse> </Navbar.Collapse>
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} /> <SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} /> <RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} /> <PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
{choices && settings?.bankAccount && settings?.holderName && ( {choices && settings?.bankAccount && settings?.holderName && (
<GenerateQrModal <GenerateQrModal
-138
View File
@@ -1,138 +0,0 @@
.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);
}
}
-71
View File
@@ -1,71 +0,0 @@
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>
);
}
-102
View File
@@ -1,102 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Modal } from 'react-bootstrap';
import { PendingQr, dismissQr } from '../../../types';
import { formatDateString } from '../Utils';
import ConfirmModal from './modals/ConfirmModal';
type Props = {
pendingQrs?: PendingQr[];
login?: string;
// Zavolá se po úspěšném potvrzení platby, aby si rodič mohl znovu načíst data
onDismissed?: () => void | Promise<void>;
};
// Sekce "Nevyřízené platby" zobrazí QR kódy neuhrazených plateb přihlášeného uživatele
// včetně tlačítka "Zaplatil jsem" a potvrzovacího dialogu. Sdíleno hlavní stránkou i stránkou objednávek.
// Při příchodu nových nevyřízených plateb se navíc automaticky otevře modální dialog,
// aby si uživatel QR kódů určitě všiml (často si jich nevšimnou, protože sekce je dole na stránce).
export default function PendingPayments({ pendingQrs, login, onDismissed }: Readonly<Props>) {
const [dismissQrId, setDismissQrId] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
// ID QR kódů, pro které už byl v rámci tohoto načtení stránky automaticky zobrazen
// modální dialog. Drží se jen v paměti (ne v sessionStorage), takže se při každém
// ručním přenačtení stránky vynuluje a dialog se znovu otevře, dokud uživatel platby
// neuhradí. Zároveň se nepřekrývá při pouhém obnovení dat či příchodu už zobrazeného QR.
const autoShownQrIds = useRef<Set<string>>(new Set());
const qrIdsKey = (pendingQrs ?? []).map(qr => qr.id).join(',');
// Automaticky otevřeme modální dialog, jakmile přijdou nové (dosud nezobrazené) platby.
useEffect(() => {
const ids = (pendingQrs ?? []).map(qr => qr.id);
if (ids.length === 0) return;
const unseen = ids.filter(id => !autoShownQrIds.current.has(id));
if (unseen.length > 0) {
setModalOpen(true);
unseen.forEach(id => autoShownQrIds.current.add(id));
}
}, [qrIdsKey, pendingQrs]);
if (!pendingQrs || pendingQrs.length === 0) return null;
// Vykreslení jednoho QR kódu i s tlačítkem "Zaplatil jsem" sdíleno sekcí i modálem.
const renderQr = (qr: PendingQr) => (
<div key={qr.id} className='qr-code mb-3'>
<p>
<strong>{formatDateString(qr.date)}</strong> {qr.creator} ({qr.totalPrice / 100} )
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
</p>
<img src={`/api/qr?login=${login}&id=${qr.id}`} alt='QR kód' />
<div className='mt-2'>
<Button variant="success" onClick={() => setDismissQrId(qr.id)}>
Zaplatil jsem
</Button>
</div>
</div>
);
return (
<>
<div className='pizza-section fade-in mt-4'>
<h3>Nevyřízené platby</h3>
<p>
Máte neuhrazené platby.{' '}
<Button variant="link" className="p-0 align-baseline" onClick={() => setModalOpen(true)}>
Zobrazit QR kódy
</Button>
</p>
{pendingQrs.map(renderQr)}
</div>
<Modal show={modalOpen} onHide={() => setModalOpen(false)} centered scrollable>
<Modal.Header closeButton>
<Modal.Title>Nevyřízené platby</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Máte neuhrazené platby. Naskenujte QR kód pro zaplacení.</p>
{pendingQrs.map(renderQr)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setModalOpen(false)}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
<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 } });
await onDismissed?.();
}}
/>
</>
);
}
+1 -1
View File
@@ -48,7 +48,7 @@ export default function PizzaOrderList({ state, orders, onDelete, creator }: Rea
borderTop: '2px solid var(--luncher-border)' borderTop: '2px solid var(--luncher-border)'
}}> }}>
<td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td> <td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td>
<td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total / 100}`}</td> <td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total}`}</td>
</tr> </tr>
</tbody> </tbody>
</Table> </Table>
+4 -4
View File
@@ -26,7 +26,7 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
<td>{order.customer}</td> <td>{order.customer}</td>
<td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder => <td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
<span key={pizzaOrder.name}> <span key={pizzaOrder.name}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price / 100} Kč)`} {`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED && {auth?.login === order.customer && state === PizzaDayState.CREATED &&
<span title='Odstranit'> <span title='Odstranit'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
@@ -38,10 +38,10 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])} .reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td> </td>
<td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td> <td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price / 100}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td> <td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td> <td>
{order.totalPrice / 100} {auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>} {order.totalPrice} {auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
</td> </td>
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price != null ? String(order.fee.price / 100) : undefined }} /> <PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
</> </>
} }
@@ -1,79 +0,0 @@
import { useState } from "react";
import { Modal, Button, Form } from "react-bootstrap";
type Props = {
isOpen: boolean;
onClose: () => void;
onSubmit: (title: string, description: string) => Promise<void>;
};
/** Modální dialog pro přidání nového návrhu na vylepšení. */
export default function AddSuggestionModal({ isOpen, onClose, onSubmit }: Readonly<Props>) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
const reset = () => {
setTitle("");
setDescription("");
};
const handleClose = () => {
reset();
onClose();
};
const handleSubmit = async () => {
if (!title.trim() || !description.trim()) return;
setSubmitting(true);
try {
await onSubmit(title.trim(), description.trim());
reset();
onClose();
} finally {
setSubmitting(false);
}
};
return (
<Modal show={isOpen} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Nový návrh na vylepšení</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label>Název</Form.Label>
<Form.Control
type="text"
placeholder="Stručný název návrhu"
value={title}
maxLength={120}
onChange={e => setTitle(e.target.value)}
onKeyDown={e => e.stopPropagation()}
autoFocus
/>
<Form.Text className="text-muted">Krátký, výstižný název navrhované úpravy.</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Popis</Form.Label>
<Form.Control
as="textarea"
rows={5}
placeholder="Detailní popis navrhované úpravy, řešení apod."
value={description}
onChange={e => setDescription(e.target.value)}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Storno
</Button>
<Button onClick={handleSubmit} disabled={submitting || !title.trim() || !description.trim()}>
Přidat
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,26 +0,0 @@
import { Modal, Button } from "react-bootstrap";
type Props = {
isOpen: boolean;
title: string;
message: string;
confirmLabel?: string;
confirmVariant?: string;
onConfirm: () => void;
onClose: () => void;
};
export default function ConfirmModal({ isOpen, title, message, confirmLabel = "Potvrdit", confirmVariant = "primary", onConfirm, onClose }: Readonly<Props>) {
return (
<Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>{message}</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>Zrušit</Button>
<Button variant={confirmVariant} onClick={onConfirm}>{confirmLabel}</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,193 +0,0 @@
import { useState, useEffect } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { updateGroupFees, OrderGroup, OrderGroupMember } from "../../../../types";
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
type Props = {
isOpen: boolean;
onClose: () => void;
group: OrderGroup;
onSaved: (data: any) => void;
};
function parseHal(s: string): number {
const n = parseFloat(s.replace(',', '.'));
return isNaN(n) || n < 0 ? 0 : Math.round(n * 100);
}
function parsePercent(s: string): number {
const n = parseFloat(s.replace(',', '.'));
return isNaN(n) || n < 0 ? 0 : Math.round(n);
}
export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly<Props>) {
const [fees, setFees] = useState('');
const [shipping, setShipping] = useState('');
const [tip, setTip] = useState('');
const [discountType, setDiscountType] = useState<'percent' | 'fixed'>('percent');
const [discountValue, setDiscountValue] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isOpen) return;
setFees(group.fees ? String(group.fees / 100) : '');
setShipping(group.shipping ? String(group.shipping / 100) : '');
setTip(group.tip ? String(group.tip / 100) : '');
setDiscountType((group.discountType as 'percent' | 'fixed') ?? 'percent');
setDiscountValue(group.discountValue
? ((group.discountType as string) === 'fixed' ? String(group.discountValue / 100) : String(group.discountValue))
: '');
setError(null);
}, [isOpen, group]);
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
const activeCount = countActiveMembers(group.members);
const feesNum = parseHal(fees);
const shippingNum = parseHal(shipping);
const tipNum = parseHal(tip);
const discountNum = discountType === 'percent' ? parsePercent(discountValue) : parseHal(discountValue);
const totalFees = feesNum + shippingNum + tipNum;
const feeShare = computeFeeShare(totalFees, activeCount);
const feeParams = { totalFees, discountType, discountValue: discountNum };
const handleSave = async () => {
setError(null);
setLoading(true);
try {
const res = await updateGroupFees({
body: {
id: group.id,
fees: feesNum,
shipping: shippingNum,
tip: tipNum,
discountType: discountNum > 0 ? discountType : undefined,
discountValue: discountNum > 0 ? discountNum : undefined,
}
});
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else {
onSaved(res.data);
onClose();
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Poplatky skupiny {group.name}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>{error}</Alert>
)}
<div className="d-flex gap-3 flex-wrap mb-3">
<Form.Group>
<Form.Label>Poplatky ()</Form.Label>
<Form.Control
type="number" min={0} step={0.01}
value={fees} onChange={e => setFees(e.target.value)}
placeholder="0" style={{ width: 110 }}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<Form.Group>
<Form.Label>Doprava ()</Form.Label>
<Form.Control
type="number" min={0} step={0.01}
value={shipping} onChange={e => setShipping(e.target.value)}
placeholder="0" style={{ width: 110 }}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<Form.Group>
<Form.Label>Spropitné ()</Form.Label>
<Form.Control
type="number" min={0} step={0.01}
value={tip} onChange={e => setTip(e.target.value)}
placeholder="0" style={{ width: 110 }}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
</div>
<div className="d-flex gap-3 align-items-end flex-wrap mb-3">
<Form.Group>
<Form.Label>Sleva</Form.Label>
<div className="d-flex gap-2 align-items-center">
<Form.Select
value={discountType}
onChange={e => setDiscountType(e.target.value as 'percent' | 'fixed')}
style={{ width: 160 }}
>
<option value="percent">Procentuální (%)</option>
<option value="fixed">Pevná částka ()</option>
</Form.Select>
<Form.Control
type="number" min={0} step={discountType === 'percent' ? 1 : 0.01}
value={discountValue} onChange={e => setDiscountValue(e.target.value)}
placeholder="0" style={{ width: 100 }}
onKeyDown={e => e.stopPropagation()}
/>
<span className="text-muted">{discountType === 'percent' ? '%' : 'Kč'}</span>
</div>
</Form.Group>
</div>
<hr />
<h6>Náhled celkových částek ({activeCount} {activeCount === 1 ? 'strávník' : 'strávníků'} s objednávkou, {feeShare > 0 ? `poplatek ${feeShare / 100} Kč/os.` : 'bez poplatku'})</h6>
<Table size="sm" bordered>
<thead>
<tr>
<th>Člen</th>
<th className="text-end">Základ</th>
<th className="text-end">Příplatek</th>
<th className="text-end">Poplatek</th>
<th className="text-end">Sleva</th>
<th className="text-end fw-bold">Celkem</th>
</tr>
</thead>
<tbody>
{memberEntries.map(([login, member]) => {
const base = member.amount ?? 0;
const surcharge = member.surchargeAmount ?? 0;
const active = isActiveMember(member);
const total = computeMemberTotal(member, feeParams, feeShare, activeCount);
// Sleva i poplatek se týkají jen aktivních strávníků.
const discount = active && discountNum > 0
? (discountType === 'percent'
? Math.round((base + surcharge) * discountNum / 100)
: Math.round(discountNum / activeCount))
: 0;
return (
<tr key={login} className={active ? '' : 'text-muted'}>
<td><strong>{login}</strong>{!active && <small className="ms-1">(jen objednává)</small>}</td>
<td className="text-end">{base > 0 ? `${base / 100}` : '—'}</td>
<td className="text-end">{surcharge > 0 ? `${surcharge / 100}` : '—'}</td>
<td className="text-end">{active && feeShare > 0 ? `${feeShare / 100}` : '—'}</td>
<td className="text-end text-danger">{discount > 0 ? `-${discount / 100}` : '—'}</td>
<td className="text-end fw-bold">{total > 0 ? `${total / 100}` : '—'}</td>
</tr>
);
})}
</tbody>
</Table>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
<Button variant="primary" onClick={handleSave} disabled={loading}>
{loading ? 'Ukládám...' : 'Uložit'}
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,45 @@
import { Modal, Button, Form } from "react-bootstrap"
import { FeatureRequest } from "../../../../types";
type Props = {
isOpen: boolean,
onClose: () => void,
onChange: (option: FeatureRequest, active: boolean) => void,
initialValues?: FeatureRequest[],
}
/** Modální dialog pro hlasování o nových funkcích. */
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Readonly<Props>) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked);
}
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>
Hlasujte pro nové funkce
<p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 4 možnosti</p>
</Modal.Title>
</Modal.Header>
<Modal.Body>
{(Object.keys(FeatureRequest) as Array<keyof typeof FeatureRequest>).map(key => {
return <Form.Check
key={key}
type='checkbox'
id={key}
label={FeatureRequest[key]}
onChange={handleChange}
value={key}
defaultChecked={initialValues?.includes(key as FeatureRequest)}
/>
})}
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={onClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
}
+25 -22
View File
@@ -33,7 +33,9 @@ function parseAmount(s: string): number | null {
if (!s || s.trim().length === 0) return null; if (!s || s.trim().length === 0) return null;
const n = parseFloat(s); const n = parseFloat(s);
if (isNaN(n) || n < 0) return null; if (isNaN(n) || n < 0) return null;
return Math.round(n * 100); const parts = s.split('.');
if (parts.length === 2 && parts[1].length > 2) return null;
return Math.round(n * 100) / 100;
} }
export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) { export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
@@ -53,11 +55,11 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
let baseAmountParseFailed = false; let baseAmountParseFailed = false;
if (menu) { if (menu) {
for (const idx of selectedFoods) { for (const idx of selectedFoods) {
const priceKc = parsePriceCzk(menu.food?.[idx]?.price); const price = parsePriceCzk(menu.food?.[idx]?.price);
if (priceKc === null) { if (price === null) {
baseAmountParseFailed = true; baseAmountParseFailed = true;
} else { } else {
baseAmount += Math.round(priceKc * 100); baseAmount += price;
} }
} }
} }
@@ -82,19 +84,13 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
if (includedDiners.length === 0) return 0; if (includedDiners.length === 0) return 0;
const tip = parseAmount(tipTotal); const tip = parseAmount(tipTotal);
if (tip === null || tip === 0) return 0; if (tip === null || tip === 0) return 0;
const totalPeople = includedDiners.length + 1; return Math.round((tip / includedDiners.length) * 100) / 100;
return Math.round(tip / totalPeople);
})();
const payerTipShare = (() => {
const tip = parseAmount(tipTotal);
if (!tip) return 0;
return tip - tipPerPerson * includedDiners.length;
})(); })();
const getTotal = (d: DinerEntry): number => { const getTotal = (d: DinerEntry): number => {
const surcharge = parseAmount(d.surchargeAmount) ?? 0; const surcharge = parseAmount(d.surchargeAmount) ?? 0;
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson; const tip = d.included && d.login !== payerLogin ? tipPerPerson : 0;
return d.baseAmount + surcharge + tip; return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
}; };
const handleInclude = useCallback((login: string, checked: boolean) => { const handleInclude = useCallback((login: string, checked: boolean) => {
@@ -120,6 +116,11 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
setError(`Celková částka pro ${d.login} musí být kladná`); setError(`Celková částka pro ${d.login} musí být kladná`);
return; return;
} }
const amountStr = total.toString();
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
setError(`Částka pro ${d.login} má více než 2 desetinná místa`);
return;
}
const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', '); const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
const purposeBase = `Oběd ${locationName}${foods ? `${foods}` : ''}`; const purposeBase = `Oběd ${locationName}${foods ? `${foods}` : ''}`;
recipients.push({ recipients.push({
@@ -166,7 +167,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
</Alert> </Alert>
) : ( ) : (
<> <>
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p> <p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a dýško, poté vygenerujte QR kódy pro ostatní.</p>
{!hasMenu && ( {!hasMenu && (
<Alert variant="info"> <Alert variant="info">
@@ -193,7 +194,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
<th>Strávník</th> <th>Strávník</th>
<th>Jídla</th> <th>Jídla</th>
<th style={{ width: 220 }}>Příplatek</th> <th style={{ width: 220 }}>Příplatek</th>
<th style={{ width: 90 }}>Poplatek</th> <th style={{ width: 90 }}>Dýško</th>
<th style={{ width: 90 }}>Celkem</th> <th style={{ width: 90 }}>Celkem</th>
</tr> </tr>
</thead> </thead>
@@ -219,18 +220,19 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
<td> <td>
<small> <small>
{foodNames || <span className="text-muted">—</span>} {foodNames || <span className="text-muted">—</span>}
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>} {hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount} Kč)</span>}
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>} {d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
</small> </small>
</td> </td>
<td> <td>
{!isPayer && (
<div className="d-flex gap-1"> <div className="d-flex gap-1">
<Form.Control <Form.Control
type="text" type="text"
placeholder="popis" placeholder="popis"
value={d.surchargeText} value={d.surchargeText}
onChange={e => handleSurchargeText(d.login, e.target.value)} onChange={e => handleSurchargeText(d.login, e.target.value)}
disabled={!isPayer && !d.included} disabled={!d.included}
size="sm" size="sm"
onKeyDown={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}
/> />
@@ -239,18 +241,19 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
placeholder="" placeholder=""
value={d.surchargeAmount} value={d.surchargeAmount}
onChange={e => handleSurchargeAmount(d.login, e.target.value)} onChange={e => handleSurchargeAmount(d.login, e.target.value)}
disabled={!isPayer && !d.included} disabled={!d.included}
size="sm" size="sm"
style={{ width: 70 }} style={{ width: 70 }}
onKeyDown={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}
/> />
</div> </div>
)}
</td> </td>
<td className="text-end"> <td className="text-end">
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s / 100} Kč` : '—'; })()} {!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
</td> </td>
<td className="text-end fw-bold"> <td className="text-end fw-bold">
{`${total / 100} Kč`} {!isPayer ? `${total} Kč` : '—'}
</td> </td>
</tr> </tr>
); );
@@ -259,7 +262,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
</Table> </Table>
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-2">
<label className="mb-0 text-nowrap">Poplatky celkem (Kč):</label> <label className="mb-0 text-nowrap">Dýško celkem (Kč):</label>
<Form.Control <Form.Control
type="text" type="text"
placeholder="0" placeholder="0"
@@ -271,7 +274,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
/> />
<small className="text-muted"> <small className="text-muted">
{includedDiners.length > 0 && tipPerPerson > 0 {includedDiners.length > 0 && tipPerPerson > 0
? `(${tipPerPerson / 100} Kč / osoba)` ? `(${tipPerPerson} Kč / osoba)`
: ''} : ''}
</small> </small>
</div> </div>
@@ -1,220 +0,0 @@
import { useState, useEffect } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
import { sanitizeQrMessage } from "../../Utils";
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
type Props = {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
group: OrderGroup;
payerLogin: string;
bankAccount: string;
bankAccountHolder: string;
groupId?: string;
};
type DinerEntry = {
login: string;
member: OrderGroupMember;
included: boolean;
};
export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) {
const [diners, setDiners] = useState<DinerEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!isOpen) return;
const entries: DinerEntry[] = (Object.entries(group.members) as [string, OrderGroupMember][]).map(([login, member]) => ({
login,
member,
// Standardně zahrnout všechny, kdo nejsou plátce a něco si objednali.
included: login !== payerLogin && isActiveMember(member),
}));
setDiners(entries);
setError(null);
setSuccess(false);
}, [isOpen, group, payerLogin]);
const fees = group.fees ?? 0;
const shipping = group.shipping ?? 0;
const tip = group.tip ?? 0;
const totalFees = fees + shipping + tip;
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
const activeCount = countActiveMembers(group.members);
const feeShare = computeFeeShare(totalFees, activeCount);
const feeParams = { totalFees, discountType: group.discountType, discountValue: group.discountValue ?? 0 };
const getMemberTotal = (entry: DinerEntry): number =>
computeMemberTotal(entry.member, feeParams, feeShare, activeCount);
const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin);
const handleInclude = (login: string, checked: boolean) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
};
const handleGenerate = async () => {
setError(null);
const recipients: QrRecipient[] = [];
for (const d of diners) {
if (!d.included || d.login === payerLogin) continue;
const total = getMemberTotal(d);
if (total <= 0) {
setError(`Celková částka pro ${d.login} musí být kladná`);
return;
}
const note = d.member.note?.trim();
recipients.push({
login: d.login,
purpose: sanitizeQrMessage(note || `Objednávka ${group.name}`),
amount: total,
});
}
if (recipients.length === 0) {
setError("Nebyl vybrán žádný příjemce");
return;
}
setLoading(true);
try {
const response = await generateQr({
body: { recipients, bankAccount, bankAccountHolder, ...(groupId ? { groupId } : {}) },
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
} else {
setSuccess(true);
onSuccess?.();
setTimeout(() => onClose(), 2000);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování QR kódů');
} finally {
setLoading(false);
}
};
const hasFees = totalFees > 0;
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Generovat QR {group.name}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci Nevyřízené platby".
</Alert>
) : (
<>
<p>Zaplatili jste za skupinu. Vyberte, komu vygenerovat QR kód k úhradě.</p>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
{hasFees && (
<div className="d-flex gap-3 mb-2 text-muted" style={{ fontSize: '0.9em' }}>
{fees > 0 && <span>Poplatky: <strong>{fees / 100} Kč</strong></span>}
{shipping > 0 && <span>Doprava: <strong>{shipping / 100} Kč</strong></span>}
{tip > 0 && <span>Spropitné: <strong>{tip / 100} Kč</strong></span>}
<span>→ {feeShare / 100} Kč/os.</span>
</div>
)}
{group.discountValue != null && group.discountValue > 0 && (
<div className="mb-2 text-success" style={{ fontSize: '0.9em' }}>
Sleva: {group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}
</div>
)}
<Table striped bordered hover responsive size="sm">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Člen</th>
<th style={{ width: 90 }} className="text-end">Základ</th>
<th style={{ width: 90 }} className="text-end">Příplatek</th>
{hasFees && <th style={{ width: 90 }} className="text-end">Poplatek</th>}
<th style={{ width: 90 }} className="text-end fw-bold">Celkem</th>
</tr>
</thead>
<tbody>
{diners.map(d => {
const isPayer = d.login === payerLogin;
const active = isActiveMember(d.member);
const total = getMemberTotal(d);
const surcharge = d.member.surchargeAmount ?? 0;
return (
<tr key={d.login} className={(!d.included && !isPayer) || !active ? 'text-muted' : ''}>
<td className="text-center">
{isPayer ? (
<small className="text-muted">plátce</small>
) : !active ? (
<small className="text-muted">jen objednává</small>
) : (
<Form.Check
type="checkbox"
checked={d.included}
onChange={e => handleInclude(d.login, e.target.checked)}
/>
)}
</td>
<td>
<strong>{d.login}</strong>
{d.member.surchargeText && (
<small className="text-muted ms-1">({d.member.surchargeText})</small>
)}
</td>
<td className="text-end">
{(d.member.amount ?? 0) > 0 ? `${d.member.amount! / 100} Kč` : <span className="text-muted">—</span>}
</td>
<td className="text-end">
{surcharge > 0 ? `${surcharge / 100} Kč` : <span className="text-muted">—</span>}
</td>
{hasFees && (
<td className="text-end">
{active && feeShare > 0 ? `${feeShare / 100} Kč` : '—'}
</td>
)}
<td className="text-end fw-bold">
{total > 0 ? `${total / 100} Kč` : <span className="text-muted">—</span>}
</td>
</tr>
);
})}
</tbody>
</Table>
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<span className="me-auto text-muted">Příjemci: {includedNonPayers.length}</span>
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={loading || includedNonPayers.length === 0}
>
{loading ? 'Generuji...' : 'Vygenerovat QR'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
)}
</Modal.Footer>
</Modal>
);
}
@@ -15,12 +15,12 @@ export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose,
const priceRef = useRef<HTMLInputElement>(null); const priceRef = useRef<HTMLInputElement>(null);
const doSubmit = () => { const doSubmit = () => {
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100)); onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
} }
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100)); onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
} }
} }
@@ -1,137 +0,0 @@
import { useState } from "react";
import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { addStore, deleteStore, Store } from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
stores: Store[];
onStoresChanged: (stores: Store[]) => void;
};
export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) {
const [newName, setNewName] = useState('');
const [newUrl, setNewUrl] = useState('');
const [heslo, setHeslo] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleAdd = async () => {
if (!newName.trim()) return;
setError(null);
setLoading(true);
try {
const res = await addStore({ body: { name: newName.trim(), url: newUrl.trim() || undefined, heslo } });
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else if (res.data) {
onStoresChanged(res.data as Store[]);
setNewName('');
setNewUrl('');
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
const handleRemove = async (name: string) => {
setError(null);
setLoading(true);
try {
const res = await deleteStore({ body: { name, heslo } });
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else if (res.data) {
onStoresChanged(res.data as Store[]);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
return (
<Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title><h2>Správa obchodů</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<Form.Group className="mb-3">
<Form.Label>Admin heslo</Form.Label>
<Form.Control
type="password"
placeholder="Heslo"
value={heslo}
onChange={e => setHeslo(e.target.value)}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<hr />
<h6>Přidat obchod</h6>
<Form.Control
className="mb-2"
type="text"
placeholder="Název obchodu"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
/>
<div className="d-flex gap-2 mb-3">
<Form.Control
type="url"
placeholder="URL na nabídku (volitelné, např. Bolt Food/Wolt)"
value={newUrl}
onChange={e => setNewUrl(e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
/>
<Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}>
Přidat
</Button>
</div>
<h6>Aktuální seznam</h6>
{stores.length === 0 ? (
<p className="text-muted">Žádné obchody v seznamu</p>
) : (
<ListGroup>
{stores.map(s => (
<ListGroup.Item key={s.name} className="d-flex justify-content-between align-items-center">
<span>
{s.name}
{s.url && /^https?:\/\//i.test(s.url) && (
<a href={s.url} target="_blank" rel="noopener noreferrer" className="ms-2" title="Otevřít nabídku v nové záložce">
<FontAwesomeIcon icon={faUpRightFromSquare} />
</a>
)}
</span>
<FontAwesomeIcon
icon={faTrashCan}
className="action-icon"
title="Odebrat"
onClick={() => handleRemove(s.name)}
style={{ cursor: 'pointer' }}
/>
</ListGroup.Item>
))}
</ListGroup>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,30 +0,0 @@
import { Modal, Button } from "react-bootstrap";
import { Suggestion } from "../../../../types";
type Props = {
suggestion?: Suggestion;
onClose: () => void;
};
/** Modální dialog zobrazující celý detail návrhu na vylepšení. */
export default function SuggestionDetailModal({ suggestion, onClose }: Readonly<Props>) {
return (
<Modal show={!!suggestion} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>{suggestion?.title}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="text-muted mb-3">
Navrhovatel: <strong>{suggestion?.author}</strong> · Hlasy: <strong>{suggestion?.voteScore}</strong>
{suggestion?.resolved && <> · <strong>Vyřešeno</strong></>}
</p>
<p style={{ whiteSpace: "pre-wrap" }}>{suggestion?.description}</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
);
}
+12 -90
View File
@@ -4,8 +4,6 @@ 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';
@@ -14,13 +12,10 @@ 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 = {
@@ -50,74 +45,11 @@ 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);
@@ -163,27 +95,24 @@ function useProvideSettings(): SettingsContextProps {
}, [themePreference]); }, [themePreference]);
useEffect(() => { useEffect(() => {
const applyTheme = (dark: boolean) => { const applyTheme = (theme: 'light' | 'dark') => {
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light'); document.documentElement.setAttribute('data-bs-theme', theme);
setEffectiveDark(dark);
}; };
if (themePreference === 'system') { if (themePreference === 'system') {
const mq = window.matchMedia('(prefers-color-scheme: dark)'); const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
applyTheme(mq.matches); applyTheme(mediaQuery.matches ? 'dark' : 'light');
const handler = (e: MediaQueryListEvent) => applyTheme(e.matches);
mq.addEventListener('change', handler); const handler = (e: MediaQueryListEvent) => {
return () => mq.removeEventListener('change', handler); applyTheme(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
} else { } else {
applyTheme(themePreference === 'dark'); applyTheme(themePreference);
} }
}, [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);
} }
@@ -200,21 +129,14 @@ 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,
} }
} }
+1 -15
View File
@@ -8,27 +8,13 @@ if (process.env.NODE_ENV === 'development') {
socketPath = undefined; socketPath = undefined;
} else { } else {
socketUrl = `${globalThis.location.host}`; socketUrl = `${globalThis.location.host}`;
socketPath = '/socket.io'; socketPath = `${globalThis.location.pathname}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';
export const EVENT_MESSAGE = 'message'; export const EVENT_MESSAGE = 'message';
export const EVENT_PENDING_QR = 'pendingQr';
-879
View File
@@ -1,879 +0,0 @@
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { faBasketShopping, faChevronLeft, faChevronRight, faCircleCheck, faClockRotateLeft, faGear, faLock, faLockOpen, faPen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons';
import DatePicker, { registerLocale } from 'react-datepicker';
import { cs } from 'date-fns/locale';
import 'react-datepicker/dist/react-datepicker.css';
import {
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr,
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setBoltTracking, getOrderDates,
} from '../../../types';
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
import { useAuth } from '../context/auth';
import { useSettings } from '../context/settings';
import { formatDate, formatDateString } from '../Utils';
import Login from '../Login';
import Header from '../components/Header';
import Footer from '../components/Footer';
import Loader from '../components/Loader';
import StoreAdminModal from '../components/modals/StoreAdminModal';
import PayForGroupModal from '../components/modals/PayForGroupModal';
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
import PendingPayments from '../components/PendingPayments';
import BoltOrderProgress from '../components/BoltOrderProgress';
const SLOT = MealSlot.EXTRA;
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
const BOLT_SHARE_URL_PREFIX = 'https://food.bolt.eu/sharedActiveOrder/';
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
/** Vytáhne sledovací token ze sdílecí URL Bolt Food, nebo přijme samotný token. Null = neplatný vstup. */
function extractBoltToken(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed;
try {
const segments = new URL(trimmed).pathname.split('/').filter(Boolean);
const last = segments[segments.length - 1];
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
} catch {
return null;
}
}
/** Zkrátí dlouhý odkaz pro zobrazení v řádku (zachová začátek i konec). */
function shortenUrl(url: string, max = 48): string {
if (url.length <= max) return url;
const head = Math.ceil((max - 1) / 2);
const tail = Math.floor((max - 1) / 2);
return `${url.slice(0, head)}${url.slice(url.length - tail)}`;
}
// Český lokál pro date picker (názvy měsíců/dnů, pondělí jako první den)
registerLocale('cs', cs);
/** Vrátí ISO datum (YYYY-MM-DD) posunuté o předaný počet dní. */
function shiftIsoDate(iso: string, days: number): string {
const date = new Date(`${iso}T00:00:00`);
date.setDate(date.getDate() + days);
return formatDate(date);
}
/** Převede ISO datum (YYYY-MM-DD) na lokální Date (půlnoc), nebo null. */
function isoToDate(iso?: string): Date | null {
return iso ? new Date(`${iso}T00:00:00`) : null;
}
function stateBadge(state: GroupState) {
const map: Record<GroupState, { bg: string; label: string }> = {
[GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' },
[GroupState.LOCKED]: { bg: 'warning', label: 'Uzamčeno' },
[GroupState.ORDERED]: { bg: 'secondary', label: 'Objednáno' },
};
const { bg, label } = map[state] ?? { bg: 'light', label: state };
return <Badge bg={bg}>{label}</Badge>;
}
export default function OrderGroupsPage() {
const auth = useAuth();
const settings = useSettings();
const socket = useContext(SocketContext);
const [data, setData] = useState<ClientData | undefined>();
const [failure, setFailure] = useState(false);
// Vybrané datum pro zobrazení historie (undefined = aktuální den)
const [selectedDate, setSelectedDate] = useState<string | undefined>();
// ISO datum dnešního dne dle serveru (horní hranice navigace), zjištěné při prvním načtení
const [todayIso, setTodayIso] = useState<string | undefined>();
// Ref pro socket handler aby věděl, zda se zobrazuje historie (na ní se živé aktualizace neaplikují)
const selectedDateRef = useRef<string | undefined>(undefined);
// ISO data dnů, ve kterých existuje aspoň jedna objednávka (pro zvýraznění v date pickeru)
const [orderDates, setOrderDates] = useState<string[]>([]);
const [newGroupName, setNewGroupName] = useState('');
const [creating, setCreating] = useState(false);
const [adminModalOpen, setAdminModalOpen] = useState(false);
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({});
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string; boltUrl: string }>>({});
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
const [pageError, setPageError] = useState<string | null>(null);
const fetchData = async (date?: string) => {
try {
const r = await getData({ query: { slot: SLOT, date } });
if (r.data) {
setData(r.data);
// Při zobrazení aktuálního dne si zapamatujeme dnešní ISO datum jako horní hranici navigace
if (!date && r.data.isoDate) setTodayIso(r.data.isoDate);
}
} catch {
setFailure(true);
}
};
// Načte dny s objednávkou pro zvýraznění v date pickeru
const fetchOrderDates = async () => {
const r = await getOrderDates();
if (r.data?.dates) setOrderDates(r.data.dates);
};
useEffect(() => {
selectedDateRef.current = selectedDate;
}, [selectedDate]);
useEffect(() => {
if (!auth?.login) return;
fetchData(selectedDate);
}, [auth?.login, selectedDate]);
useEffect(() => {
if (!auth?.login) return;
fetchOrderDates();
}, [auth?.login]);
useEffect(() => {
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
// Živé aktualizace se týkají vždy dneška při zobrazení historie je ignorujeme
if (selectedDateRef.current) return;
if (newData.slot === SLOT) setData(prev => ({
...newData,
stores: newData.stores ?? prev?.stores,
}));
});
// Nová nevyřízená platba (QR kód) připojíme do dat, aby se zobrazila i bez znovunačtení stránky
socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => {
if (selectedDateRef.current) return;
setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev);
});
return () => { socket.off(EVENT_MESSAGE); socket.off(EVENT_PENDING_QR); };
}, [socket]);
// Připojení do osobní socket místnosti po přihlášení bez toho nechodí události
// o nových nevyřízených platbách (QR kódy se posílají do místnosti user:<login>)
useEffect(() => {
if (auth?.login) {
socket.emit('join', auth.login);
}
}, [auth?.login, socket]);
useEffect(() => {
// Po znovupřipojení socketu znovu vstoupíme do osobní místnosti a načteme aktuálně
// zobrazený den (mohli jsme přijít o živé aktualizace)
const onReconnect = () => {
if (auth?.login) socket.emit('join', auth.login);
fetchData(selectedDateRef.current);
};
socket.io.on('reconnect', onReconnect);
return () => { socket.io.off('reconnect', onReconnect); };
}, [socket, auth?.login]);
// Navigace mezi dny pomocí klávesových šipek (←/→), obdobně jako na hlavní stránce
const handleKeyDown = useCallback((e: KeyboardEvent) => {
// Ignorujeme, pokud uživatel právě píše do formulářového pole
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
const currentIso = data?.isoDate;
if (!currentIso) return;
if (e.keyCode === 37) {
// Předchozí den do minulosti bez omezení
setSelectedDate(shiftIsoDate(currentIso, -1));
} else if (e.keyCode === 39 && todayIso != null && currentIso < todayIso) {
// Následující den nejvýše po dnešek (na dnešek přes undefined kvůli živým aktualizacím)
const target = shiftIsoDate(currentIso, 1);
setSelectedDate(target >= todayIso ? undefined : target);
}
}, [data?.isoDate, todayIso]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const refresh = async (fn: () => Promise<any>): Promise<boolean> => {
setPageError(null);
const result = await fn();
if (result?.error) {
setPageError((result.error as any).error || 'Nastala chyba');
await fetchData();
return false;
}
if (result?.data) {
setData(result.data);
socket.emit?.('message', result.data as ClientData);
}
// Sada dnů s objednávkou se mohla změnit (vytvoření/smazání skupiny)
fetchOrderDates();
return true;
};
const handleCreate = async () => {
if (!newGroupName || !auth?.login) return;
setCreating(true);
const ok = await refresh(() => createGroup({ body: { name: newGroupName } }));
if (ok) setNewGroupName('');
setCreating(false);
};
const handleJoin = (groupId: string) =>
refresh(() => addGroupMember({ body: { id: groupId } }));
const handleToggleLock = (group: OrderGroup) => {
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
return refresh(() => setGroupState({ body: { id: group.id, state: next } }));
};
const handleConfirmOrdered = async (group: OrderGroup) => {
setConfirmOrderGroup(null);
await refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } }));
};
const handleRevertOrdered = (group: OrderGroup) =>
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.LOCKED } }));
const handleDelete = (groupId: string) =>
refresh(() => deleteGroup({ body: { id: groupId } }));
const handleSaveAmount = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`;
const raw = editAmounts[key];
const n = parseFloat(raw ?? '');
if (!raw || isNaN(n) || n < 0) {
setPageError('Zadejte platnou kladnou částku');
return;
}
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, amount: Math.round(n * 100) } }));
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
};
const handleSaveNote = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`;
const note = editNotes[key] ?? '';
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, note } }));
if (ok) setEditNotes(prev => { const next = { ...prev }; delete next[key]; return next; });
};
const handleSaveSurcharge = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`;
const surchargeText = editSurcharges[key]?.text ?? '';
const rawAmount = editSurcharges[key]?.amount ?? '';
const surchargeAmount = rawAmount === '' ? 0 : parseFloat(rawAmount.replace(',', '.'));
if (rawAmount !== '' && (isNaN(surchargeAmount) || surchargeAmount < 0)) {
setPageError('Zadejte platnou výši příplatku');
return;
}
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, surchargeText, surchargeAmount: rawAmount === '' ? 0 : Math.round(surchargeAmount * 100) } }));
if (ok) setEditSurcharges(prev => { const next = { ...prev }; delete next[key]; return next; });
};
const handleSaveTimes = async (group: OrderGroup) => {
const times = editTimes[group.id];
if (!times) return;
const { orderedAt, deliveryAt, boltUrl } = times;
if (orderedAt && !TIME_REGEX.test(orderedAt)) {
setPageError('Čas objednání musí být ve formátu HH:MM');
return;
}
if (deliveryAt && !TIME_REGEX.test(deliveryAt)) {
setPageError('Čas doručení musí být ve formátu HH:MM');
return;
}
// Bolt odkaz se odesílá jen při změně oproti aktuálnímu tokenu skupiny
const boltToken = boltUrl.trim() ? extractBoltToken(boltUrl) : null;
if (boltUrl.trim() && !boltToken) {
setPageError('Neplatný odkaz Bolt (očekávána URL sdílení objednávky)');
return;
}
const boltChanged = (boltToken ?? undefined) !== group.boltTrackingToken;
let ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
if (ok && boltChanged) {
ok = await refresh(() => setBoltTracking({ body: { id: group.id, shareUrl: boltUrl.trim() } }));
}
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
};
// Pozn.: tyto funkce se volají až v renderu, kde je k dispozici `selectedDate`.
// Historie (jiný než aktuální den) je vždy read-only.
const canEditMember = (group: OrderGroup, targetLogin: string) => {
if (selectedDate) return false;
if (group.state === GroupState.ORDERED) return false;
if (auth?.login === group.creatorLogin) return true;
if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true;
return false;
};
const canManageMembers = (group: OrderGroup) => {
if (selectedDate) return false;
if (group.state === GroupState.ORDERED) return false;
if (auth?.login === group.creatorLogin) return true;
return group.state === GroupState.OPEN;
};
if (!auth?.login) return <Login />;
if (failure) return (
<Loader icon={faSearch} description="Nepodařilo se načíst data" animation="fa-beat" />
);
if (!data) return (
<Loader icon={faSearch} description="Načítám..." animation="fa-bounce" />
);
const stores = data.stores ?? [];
const groups = data.groups ?? [];
// Zobrazené datum a režim historie (vše read-only, pokud nejde o aktuální den)
const displayedIso = data.isoDate;
const isToday = !selectedDate || (todayIso != null && displayedIso === todayIso);
const isReadOnly = !isToday;
const canGoNext = todayIso != null && displayedIso != null && displayedIso < todayIso;
const goToDay = (offset: number) => {
if (!displayedIso) return;
const target = shiftIsoDate(displayedIso, offset);
// Na dnešek (či dál) se vracíme přes undefined, aby se obnovily živé aktualizace
setSelectedDate(todayIso != null && target >= todayIso ? undefined : target);
};
const handleDatePick = (value: string) => {
if (!value) return;
setSelectedDate(todayIso != null && value >= todayIso ? undefined : value);
};
// Dny s objednávkou jako Date objekty pro zvýraznění v kalendáři
const highlightedOrderDates = orderDates
.map(d => isoToDate(d))
.filter((d): d is Date => d != null);
return (
<div className="app-container">
<Header choices={data.choices} />
<div className="wrapper">
<div className="d-flex align-items-center justify-content-between mb-1">
<h1 className="title mb-0">Objednání</h1>
<Button variant="outline-secondary" size="sm" onClick={() => setAdminModalOpen(true)} title="Správa obchodů">
<FontAwesomeIcon icon={faGear} />
</Button>
</div>
<p style={{ color: 'var(--luncher-text-muted)' }}>Skupinové objednávky z obchodů a restaurací</p>
{/* Navigace mezi dny šipky kolem výběru data (i klávesami ←/→) */}
<div className="day-navigator order-day-navigator">
<span title="Předchozí den">
<FontAwesomeIcon icon={faChevronLeft} onClick={() => goToDay(-1)} />
</span>
<DatePicker
selected={isoToDate(displayedIso)}
onChange={(d: Date | null) => handleDatePick(d ? formatDate(d) : '')}
maxDate={isoToDate(todayIso) ?? undefined}
highlightDates={[{ 'luncher-order-day': highlightedOrderDates }]}
locale="cs"
dateFormat="d. M. yyyy"
calendarStartDay={1}
popperPlacement="bottom"
className={`form-control text-center fw-semibold order-date-input ${isReadOnly ? 'text-muted' : ''}`}
/>
<span title="Následující den">
<FontAwesomeIcon
icon={faChevronRight}
style={{ visibility: canGoNext ? 'visible' : 'hidden' }}
onClick={() => canGoNext && goToDay(1)}
/>
</span>
</div>
{isReadOnly && (
<Alert variant="secondary" className="d-flex align-items-center gap-2 py-2">
<FontAwesomeIcon icon={faClockRotateLeft} />
<span>
Prohlížíte historii ze dne <strong>{displayedIso ? formatDateString(displayedIso) : data.date}</strong> data jsou pouze pro čtení.
</span>
<Button variant="link" size="sm" className="p-0 ms-auto" onClick={() => setSelectedDate(undefined)}>
Zpět na dnešek
</Button>
</Alert>
)}
{pageError && (
<Alert variant="danger" dismissible onClose={() => setPageError(null)} className="mt-2">
{pageError}
</Alert>
)}
<div className="content-wrapper">
<div className="content" style={{ maxWidth: 1200 }}>
{/* Vytvoření nové skupiny pouze pro aktuální den */}
{!isReadOnly && (
<div className="choice-section fade-in mb-4">
<h5>Vytvořit skupinu</h5>
{stores.length === 0 ? (
<p className="text-muted">
Nejsou přidány žádné obchody.{' '}
<Button variant="link" size="sm" className="p-0" onClick={() => setAdminModalOpen(true)}>
Přidat obchod
</Button>
</p>
) : (
<div className="d-flex gap-2 align-items-end flex-wrap">
<Form.Select
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
style={{ maxWidth: 260 }}
>
<option value=""> vyberte obchod </option>
{stores.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
</Form.Select>
<Button variant="primary" onClick={handleCreate} disabled={creating || !newGroupName}>
Vytvořit skupinu
</Button>
</div>
)}
</div>
)}
{/* Seznam skupin */}
{groups.length === 0 && (
<p className="text-muted fade-in">
{isReadOnly ? 'Pro tento den nejsou žádné skupiny.' : 'Zatím žádné skupiny pro dnešní den.'}
</p>
)}
{groups.map(group => {
const login = auth!.login ?? '';
const isCreator = login === group.creatorLogin;
const isMember = login in group.members;
const isOrdered = group.state === GroupState.ORDERED;
const isLocked = group.state === GroupState.LOCKED;
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
const editingTimes = group.id in editTimes;
// URL na nabídku podniku (pokud ji má dohledatelný obchod vyplněnou).
// Povolíme jen http(s), aby odkaz nemohl být zneužit (např. javascript:).
const rawStoreUrl = stores.find(s => s.name === group.name)?.url;
const storeUrl = rawStoreUrl && /^https?:\/\//i.test(rawStoreUrl) ? rawStoreUrl : undefined;
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
const activeCount = countActiveMembers(group.members);
const feeShare = computeFeeShare(totalFees, activeCount);
const feeParams = { totalFees, discountType: group.discountType, discountValue: group.discountValue ?? 0 };
const getMemberTotal = (m: OrderGroupMember) =>
computeMemberTotal(m, feeParams, feeShare, activeCount);
return (
<Card key={group.id} className="mb-3 fade-in">
<Card.Header className="d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center gap-2">
{storeUrl ? (
<strong>
<a href={storeUrl} target="_blank" rel="noopener noreferrer" title="Otevřít nabídku v nové záložce">
{group.name}
</a>
</strong>
) : (
<strong>{group.name}</strong>
)}
{stateBadge(group.state)}
<small className="text-muted">zakladatel: {group.creatorLogin}</small>
</div>
<div className="d-flex gap-2">
{!isReadOnly && isCreator && !isOrdered && (
<>
<Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu">
Poplatky
</Button>
<Button variant="outline-secondary" size="sm" onClick={() => handleToggleLock(group)} title={isLocked ? 'Odemknout' : 'Uzamknout'}>
<FontAwesomeIcon icon={isLocked ? faLockOpen : faLock} />
</Button>
{isLocked && (
<Button variant="outline-primary" size="sm" onClick={() => setConfirmOrderGroup(group)}>
Objednáno
</Button>
)}
<Button variant="outline-danger" size="sm" onClick={() => handleDelete(group.id)} title="Smazat skupinu">
<FontAwesomeIcon icon={faTrashCan} />
</Button>
</>
)}
{!isReadOnly && isCreator && isOrdered && (
<>
{settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
Generovat QR
</Button>
)}
<Button variant="outline-warning" size="sm" onClick={() => handleRevertOrdered(group)} title="Vrátit na Uzamčeno (smaže QR kódy)">
<FontAwesomeIcon icon={faLockOpen} />
</Button>
</>
)}
{!isReadOnly && !isMember && !isOrdered && !isLocked && (
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
Přidat se
</Button>
)}
</div>
</Card.Header>
<Card.Body className="p-0">
<Table className="mb-0" size="sm">
<thead>
<tr>
<th>Člen</th>
<th style={{ width: 180 }}>Částka (bez slev)</th>
<th style={{ width: 220 }}>Příplatek</th>
<th>Poznámka</th>
<th style={{ width: 160 }}>Celkem (s poplatky)</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{memberEntries.map(([memberLogin, member]) => {
const key = `${group.id}:${memberLogin}`;
const editingAmount = key in editAmounts;
const editingNote = key in editNotes;
const editingSurcharge = key in editSurcharges;
const canEdit = canEditMember(group, memberLogin);
const memberTotal = getMemberTotal(member);
return (
<tr key={memberLogin}>
<td>
<span className="user-info">
<strong>{memberLogin}</strong>
{memberLogin === group.creatorLogin && (
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
<span className="ms-1"><FontAwesomeIcon icon={faBasketShopping} className="buyer-icon" /></span>
</OverlayTrigger>
)}
{member.paid && (
<OverlayTrigger placement="top" overlay={<Tooltip>Zaplaceno</Tooltip>}>
<span className="ms-1"><FontAwesomeIcon icon={faCircleCheck} className="text-success" /></span>
</OverlayTrigger>
)}
</span>
</td>
<td>
{canEdit && editingAmount ? (
<div className="d-flex gap-1">
<Form.Control
type="number"
size="sm"
value={editAmounts[key]}
onChange={e => setEditAmounts(prev => ({ ...prev, [key]: e.target.value }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); if (e.key === 'Escape') setEditAmounts(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
style={{ width: 95 }}
autoFocus
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveAmount(group.id, memberLogin)}></Button>
</div>
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [key]: member.amount != null ? String(member.amount / 100) : '' }))}
title={canEdit ? 'Klikněte pro úpravu' : undefined}
>
{member.amount != null ? `${member.amount / 100}` : <span className="text-muted"></span>}
</span>
)}
</td>
<td>
{canEdit && editingSurcharge ? (
<div className="d-flex gap-1">
<Form.Control
type="text"
size="sm"
placeholder="popis"
value={editSurcharges[key]?.text ?? ''}
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], text: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
style={{ width: 80 }}
autoFocus
/>
<Form.Control
type="number"
size="sm"
placeholder="Kč"
value={editSurcharges[key]?.amount ?? ''}
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], amount: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
style={{ width: 60 }}
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveSurcharge(group.id, memberLogin)}></Button>
</div>
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditSurcharges(prev => ({ ...prev, [key]: { text: member.surchargeText ?? '', amount: member.surchargeAmount != null ? String(member.surchargeAmount / 100) : '' } }))}
title={canEdit ? 'Klikněte pro úpravu příplatku' : undefined}
>
{member.surchargeAmount != null && member.surchargeAmount > 0 ? (
<small>{member.surchargeText ? `${member.surchargeText}: ` : ''}<strong>{member.surchargeAmount / 100} </strong></small>
) : (
<small className="text-muted"></small>
)}
</span>
)}
</td>
<td>
{canEdit && editingNote ? (
<div className="d-flex gap-1">
<Form.Control
type="text"
size="sm"
value={editNotes[key]}
onChange={e => setEditNotes(prev => ({ ...prev, [key]: e.target.value }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveNote(group.id, memberLogin); if (e.key === 'Escape') setEditNotes(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
autoFocus
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveNote(group.id, memberLogin)}></Button>
</div>
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditNotes(prev => ({ ...prev, [key]: member.note ?? '' }))}
title={canEdit ? 'Klikněte pro úpravu poznámky' : undefined}
>
<small className="text-muted">{member.note || '—'}</small>
</span>
)}
</td>
<td className="text-end">
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
{memberTotal > 0 ? `${memberTotal / 100}` : '—'}
</small>
</td>
<td>
<div className="d-flex gap-1 justify-content-end">
{canManageMembers(group) && (isCreator || memberLogin === login) && (memberLogin !== group.creatorLogin) && (
<FontAwesomeIcon
icon={faTrashCan}
className="action-icon"
title={memberLogin === login ? 'Odhlásit se' : 'Odebrat z skupiny'}
onClick={() => refresh(() => removeGroupMember({ body: { id: group.id, login: memberLogin } }))}
/>
)}
</div>
</td>
</tr>
);
})}
</tbody>
{(() => {
const sumBase = memberEntries.reduce((sum, [, m]) => sum + (m.amount ?? 0) + (m.surchargeAmount ?? 0), 0);
const dv = group.discountValue ?? 0;
const totalDiscount = dv > 0
? (group.discountType === 'percent' ? Math.round(sumBase * dv / 100) : dv)
: 0;
const groupTotal = sumBase + totalFees - totalDiscount;
return groupTotal > 0 ? (
<tfoot>
<tr style={{ fontWeight: 700, borderTop: '2px solid var(--luncher-border)' }}>
<td colSpan={4} className="text-end" style={{ fontSize: '0.9em' }}>Celkem za skupinu:</td>
<td className="text-end">{groupTotal / 100} </td>
<td></td>
</tr>
</tfoot>
) : null;
})()}
</Table>
{/* Souhrn poplatků a slevy */}
{(totalFees > 0 || (group.discountValue != null && group.discountValue > 0)) && (
<div className="px-3 py-2 border-top d-flex gap-3 flex-wrap" style={{ fontSize: '0.85em', color: 'var(--luncher-text-muted)' }}>
{group.fees != null && group.fees > 0 && <span>Poplatky: <strong>{group.fees / 100} </strong></span>}
{group.shipping != null && group.shipping > 0 && <span>Doprava: <strong>{group.shipping / 100} </strong></span>}
{group.tip != null && group.tip > 0 && <span>Spropitné: <strong>{group.tip / 100} </strong></span>}
{feeShare > 0 && <span> <strong>{feeShare / 100} </strong>/os.</span>}
{group.discountValue != null && group.discountValue > 0 && (
<span className="text-success">
Sleva: <strong>{group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100}`}</strong>
</span>
)}
</div>
)}
{/* Časy objednání a doručení */}
{isOrdered && (
<div className="px-3 py-2 border-top">
{!isReadOnly && isCreator && editingTimes ? (
<div className="d-flex align-items-center gap-3 flex-wrap">
<div className="d-flex align-items-center gap-1">
<small className="text-muted text-nowrap">Objednáno v:</small>
<Form.Control
type="text"
size="sm"
placeholder="HH:MM"
value={editTimes[group.id]?.orderedAt ?? ''}
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], orderedAt: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
style={{ width: 75 }}
autoFocus
/>
</div>
<div className="d-flex align-items-center gap-1">
<small className="text-muted text-nowrap">Doručení v:</small>
<Form.Control
type="text"
size="sm"
placeholder="HH:MM"
value={editTimes[group.id]?.deliveryAt ?? ''}
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], deliveryAt: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
style={{ width: 75 }}
/>
</div>
<div className="d-flex align-items-center gap-1">
<small className="text-muted text-nowrap">Bolt odkaz pro sledování:</small>
<Form.Control
type="text"
size="sm"
placeholder={`${BOLT_SHARE_URL_PREFIX}`}
value={editTimes[group.id]?.boltUrl ?? ''}
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], boltUrl: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
style={{ width: 260 }}
/>
</div>
<Button size="sm" variant="outline-success" onClick={() => handleSaveTimes(group)}>Uložit</Button>
<Button size="sm" variant="outline-secondary" onClick={() => setEditTimes(prev => { const n = { ...prev }; delete n[group.id]; return n; })}>Zrušit</Button>
</div>
) : (
<div className="d-flex align-items-center gap-3 flex-wrap">
{(() => {
const canEdit = !isReadOnly && isCreator;
// Aktivace editačního režimu stejné chování jako tlačítko s tužkou
const startEdit = () => canEdit && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', boltUrl: group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : '' } }));
const trackingUrl = group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : null;
return (
<>
{canEdit && (
<FontAwesomeIcon
icon={faPen}
className="action-icon"
title="Upravit časy a odkaz pro sledování"
onClick={startEdit}
/>
)}
<small
className="text-muted"
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={startEdit}
>
Objednáno v: <strong>{group.orderedAt ?? '—'}</strong>
</small>
<small
className="text-muted"
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={startEdit}
>
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
{group.boltTrackingToken && (
<OverlayTrigger overlay={<Tooltip>Čas doručení se aktualizuje automaticky z Bolt Food</Tooltip>}>
<Badge bg="success" className="ms-1">Bolt</Badge>
</OverlayTrigger>
)}
</small>
<small className="text-muted text-nowrap">
URL pro sledování:{' '}
{trackingUrl ? (
<a
href={trackingUrl}
target="_blank"
rel="noopener noreferrer"
title={trackingUrl}
onClick={e => e.stopPropagation()}
>
{shortenUrl(trackingUrl)}
</a>
) : (
<strong></strong>
)}
</small>
</>
);
})()}
</div>
)}
{group.boltOrderState && (
<div className="mt-2">
<BoltOrderProgress state={group.boltOrderState} courierState={group.boltCourierState} tracking={!!group.boltTrackingToken} />
</div>
)}
</div>
)}
</Card.Body>
</Card>
);
})}
{/* Nevyřízené platby přihlášeného uživatele jen v režimu aktuálního dne */}
{!isReadOnly && (
<PendingPayments
pendingQrs={data.pendingQrs}
login={auth.login}
onDismissed={() => fetchData()}
/>
)}
</div>
</div>
</div>
<Footer />
{/* Potvrzovací dialog pro přechod do stavu Objednáno */}
<Modal show={!!confirmOrderGroup} onHide={() => setConfirmOrderGroup(null)} centered>
<Modal.Header closeButton>
<Modal.Title>Potvrdit objednání</Modal.Title>
</Modal.Header>
<Modal.Body>
Opravdu chcete označit skupinu <strong>{confirmOrderGroup?.name}</strong> jako objednanou?
Tato akce uzavře skupinu a zaznamená čas objednání.
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setConfirmOrderGroup(null)}>Zrušit</Button>
<Button variant="primary" onClick={() => confirmOrderGroup && handleConfirmOrdered(confirmOrderGroup)}>
Objednáno
</Button>
</Modal.Footer>
</Modal>
<StoreAdminModal
isOpen={adminModalOpen}
onClose={() => setAdminModalOpen(false)}
stores={stores}
onStoresChanged={updated => setData(prev => prev ? { ...prev, stores: updated } : prev)}
/>
{payModal && settings?.bankAccount && settings?.holderName && (
<PayForGroupModal
isOpen={!!payModal}
onClose={() => setPayModal(null)}
onSuccess={() => fetchData()}
group={payModal}
groupId={payModal.id}
payerLogin={auth.login}
bankAccount={settings.bankAccount}
bankAccountHolder={settings.holderName}
/>
)}
{feesModal && (
<EditGroupFeesModal
isOpen={!!feesModal}
onClose={() => setFeesModal(null)}
group={feesModal}
onSaved={newData => {
if (newData) {
setData(newData);
socket.emit?.('message', newData as ClientData);
}
setFeesModal(null);
}}
/>
)}
</div>
);
}
+63
View File
@@ -89,4 +89,67 @@
.recharts-cartesian-grid-vertical line { .recharts-cartesian-grid-vertical line {
stroke: var(--luncher-border); stroke: var(--luncher-border);
} }
.voting-stats-section {
margin-top: 48px;
width: 100%;
max-width: 800px;
h2 {
font-size: 1.5rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 16px;
text-align: center;
}
}
.voting-stats-table {
width: 100%;
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
border: 1px solid var(--luncher-border-light);
overflow: hidden;
border-collapse: collapse;
th {
background: var(--luncher-primary);
color: #ffffff;
padding: 12px 20px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
&:last-child {
text-align: center;
width: 120px;
}
}
td {
padding: 12px 20px;
border-bottom: 1px solid var(--luncher-border-light);
color: var(--luncher-text);
font-size: 0.9rem;
&:last-child {
text-align: center;
font-weight: 600;
color: var(--luncher-primary);
}
}
tbody tr {
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
}
&:last-child td {
border-bottom: none;
}
}
}
} }
+36 -1
View File
@@ -4,7 +4,7 @@ import Header from "../components/Header";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import Login from "../Login"; import Login from "../Login";
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils"; import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
import { WeeklyStats, LunchChoice, getStats } from "../../../types"; import { WeeklyStats, LunchChoice, VotingStats, FeatureRequest, getStats, getVotingStats } from "../../../types";
import Loader from "../components/Loader"; import Loader from "../components/Loader";
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons"; import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts"; import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
@@ -32,6 +32,7 @@ export default function StatsPage() {
const auth = useAuth(); const auth = useAuth();
const [dateRange, setDateRange] = useState<Date[]>(); const [dateRange, setDateRange] = useState<Date[]>();
const [data, setData] = useState<WeeklyStats>(); const [data, setData] = useState<WeeklyStats>();
const [votingStats, setVotingStats] = useState<VotingStats>();
// Prvotní nastavení aktuálního týdne // Prvotní nastavení aktuálního týdne
useEffect(() => { useEffect(() => {
@@ -48,6 +49,19 @@ export default function StatsPage() {
} }
}, [dateRange]); }, [dateRange]);
// Načtení statistik hlasování
useEffect(() => {
getVotingStats().then(response => {
setVotingStats(response.data);
});
}, []);
const sortedVotingStats = useMemo(() => {
if (!votingStats) return [];
return Object.entries(votingStats)
.sort((a, b) => (b[1] as number) - (a[1] as number));
}, [votingStats]);
const renderLine = (location: LunchChoice) => { const renderLine = (location: LunchChoice) => {
const index = Object.values(LunchChoice).indexOf(location); const index = Object.values(LunchChoice).indexOf(location);
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} /> return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
@@ -128,6 +142,27 @@ export default function StatsPage() {
<Tooltip /> <Tooltip />
<Legend /> <Legend />
</LineChart> </LineChart>
{sortedVotingStats.length > 0 && (
<div className="voting-stats-section">
<h2>Hlasování o funkcích</h2>
<table className="voting-stats-table">
<thead>
<tr>
<th>Funkce</th>
<th>Počet hlasů</th>
</tr>
</thead>
<tbody>
{sortedVotingStats.map(([feature, count]) => (
<tr key={feature}>
<td>{FeatureRequest[feature as keyof typeof FeatureRequest] ?? feature}</td>
<td>{count as number}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div> </div>
<Footer /> <Footer />
</> </>
-145
View File
@@ -1,145 +0,0 @@
.suggestions-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
min-height: calc(100vh - 140px);
background: var(--luncher-bg);
.suggestions-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
width: 100%;
max-width: 900px;
flex-wrap: wrap;
h1 {
font-size: 2rem;
font-weight: 700;
color: var(--luncher-text);
margin: 0;
}
}
.suggestions-info {
width: 100%;
max-width: 900px;
margin: 12px 0 24px;
color: var(--luncher-text-secondary);
font-size: 0.95rem;
}
.suggestions-empty {
color: var(--luncher-text-secondary);
margin-top: 32px;
}
.resolved-section {
width: 100%;
max-width: 900px;
margin-top: 48px;
h2 {
font-size: 1.4rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 8px;
}
}
.suggestions-table.resolved {
th {
background: var(--luncher-text-secondary);
}
td.col-score {
color: var(--luncher-text-secondary);
}
}
.suggestions-table {
width: 100%;
max-width: 900px;
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
border: 1px solid var(--luncher-border-light);
overflow: hidden;
border-collapse: collapse;
th {
background: var(--luncher-primary);
color: #ffffff;
padding: 12px 20px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
}
td {
padding: 12px 20px;
border-bottom: 1px solid var(--luncher-border-light);
color: var(--luncher-text);
font-size: 0.9rem;
vertical-align: middle;
}
.col-score {
text-align: center;
width: 80px;
font-weight: 600;
}
td.col-score {
color: var(--luncher-primary);
}
.col-actions {
text-align: center;
width: 150px;
white-space: nowrap;
}
tbody tr {
cursor: pointer;
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
}
&:last-child td {
border-bottom: none;
}
}
.vote-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 6px 8px;
border-radius: var(--luncher-radius-sm, 6px);
color: var(--luncher-text-secondary);
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
color: var(--luncher-text);
}
&.vote-up.active {
color: #2e7d32;
}
&.vote-down.active {
color: #c62828;
}
&.delete-btn:hover {
color: #c62828;
}
}
}
}
-187
View File
@@ -1,187 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { Button, OverlayTrigger, Tooltip } from "react-bootstrap";
import { ToastContainer } from "react-toastify";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faThumbsUp, faThumbsDown, faTrash, faPlus, faGear } from "@fortawesome/free-solid-svg-icons";
import Header from "../components/Header";
import Footer from "../components/Footer";
import Loader from "../components/Loader";
import { useAuth } from "../context/auth";
import Login from "../Login";
import AddSuggestionModal from "../components/modals/AddSuggestionModal";
import SuggestionDetailModal from "../components/modals/SuggestionDetailModal";
import {
Suggestion,
VoteDirection,
listSuggestions,
addSuggestion,
voteSuggestion,
deleteSuggestion,
} from "../../../types";
import "./SuggestionsPage.scss";
export default function SuggestionsPage() {
const auth = useAuth();
const [suggestions, setSuggestions] = useState<Suggestion[]>();
const [addModalOpen, setAddModalOpen] = useState(false);
const [detail, setDetail] = useState<Suggestion>();
const reload = useCallback(async () => {
if (!auth?.login) return;
const response = await listSuggestions();
setSuggestions(response.data ?? []);
}, [auth?.login]);
useEffect(() => {
reload();
}, [reload]);
const handleAdd = async (title: string, description: string) => {
const response = await addSuggestion({ body: { title, description } });
if (response.data) {
setSuggestions(response.data);
}
};
const handleVote = async (id: string, direction: VoteDirection) => {
const response = await voteSuggestion({ body: { id, direction } });
if (response.data) {
setSuggestions(response.data);
}
};
const handleDelete = async (suggestion: Suggestion) => {
if (!window.confirm(`Opravdu chcete smazat návrh „${suggestion.title}“? Smažou se i všechny jeho hlasy.`)) {
return;
}
const response = await deleteSuggestion({ body: { id: suggestion.id } });
if (response.data) {
setSuggestions(response.data);
}
};
// Vykreslí jeden řádek tabulky. Vyřešené návrhy jsou read-only (bez hlasování),
// ale autor je stále může smazat.
const renderRow = (suggestion: Suggestion) => (
<OverlayTrigger
key={suggestion.id}
placement="top"
overlay={<Tooltip id={`tooltip-${suggestion.id}`}>{suggestion.description}</Tooltip>}
>
<tr onClick={() => setDetail(suggestion)}>
<td>{suggestion.author}</td>
<td>{suggestion.title}</td>
<td className="col-score">{suggestion.voteScore}</td>
<td className="col-actions" onClick={e => e.stopPropagation()}>
{!suggestion.resolved && (
<>
<button
type="button"
className={`vote-btn vote-up ${suggestion.myVote === VoteDirection.UP ? "active" : ""}`}
title="Hlasovat pro"
onClick={() => handleVote(suggestion.id, VoteDirection.UP)}
>
<FontAwesomeIcon icon={faThumbsUp} />
</button>
<button
type="button"
className={`vote-btn vote-down ${suggestion.myVote === VoteDirection.DOWN ? "active" : ""}`}
title="Hlasovat proti"
onClick={() => handleVote(suggestion.id, VoteDirection.DOWN)}
>
<FontAwesomeIcon icon={faThumbsDown} />
</button>
</>
)}
{suggestion.isMine && (
<button
type="button"
className="vote-btn delete-btn"
title="Smazat návrh"
onClick={() => handleDelete(suggestion)}
>
<FontAwesomeIcon icon={faTrash} />
</button>
)}
</td>
</tr>
</OverlayTrigger>
);
if (!auth?.login) {
return <Login />;
}
if (!suggestions) {
return <Loader icon={faGear} description={"Načítám návrhy..."} animation={"fa-bounce"} />;
}
const activeSuggestions = suggestions.filter(s => !s.resolved);
const resolvedSuggestions = suggestions.filter(s => s.resolved);
return (
<>
<Header />
<div className="suggestions-page">
<div className="suggestions-header">
<h1>Návrhy na vylepšení</h1>
<Button onClick={() => setAddModalOpen(true)}>
<FontAwesomeIcon icon={faPlus} /> Přidat návrh
</Button>
</div>
<p className="suggestions-info">
Zde můžete navrhovat vylepšení aplikace a hlasovat o návrzích ostatních. U každého návrhu je
zobrazeno jméno navrhovatele. Jména hlasujících jsou dostupná pouze administrátorům.
</p>
{suggestions.length === 0 ? (
<p className="suggestions-empty">Zatím nebyly přidány žádné návrhy. Buďte první!</p>
) : (
<>
{activeSuggestions.length > 0 && (
<table className="suggestions-table">
<thead>
<tr>
<th>Navrhovatel</th>
<th>Název</th>
<th className="col-score">Hlasy</th>
<th className="col-actions">Akce</th>
</tr>
</thead>
<tbody>
{activeSuggestions.map(renderRow)}
</tbody>
</table>
)}
{resolvedSuggestions.length > 0 && (
<div className="resolved-section">
<h2>Vyřešené návrhy</h2>
<p className="suggestions-info">
Tyto návrhy již byly zapracovány. Nelze pro hlasovat, autor je však může odstranit.
</p>
<table className="suggestions-table resolved">
<thead>
<tr>
<th>Navrhovatel</th>
<th>Název</th>
<th className="col-score">Hlasy</th>
<th className="col-actions">Akce</th>
</tr>
</thead>
<tbody>
{resolvedSuggestions.map(renderRow)}
</tbody>
</table>
</div>
)}
</>
)}
</div>
<Footer />
<AddSuggestionModal isOpen={addModalOpen} onClose={() => setAddModalOpen(false)} onSubmit={handleAdd} />
<SuggestionDetailModal suggestion={detail} onClose={() => setDetail(undefined)} />
<ToastContainer />
</>
);
}
-67
View File
@@ -1,67 +0,0 @@
import { OrderGroup, OrderGroupMember } from "../../../types";
/**
* Pomocné funkce pro výpočet částek ve skupinových objednávkách.
*
* Klíčové pravidlo: poplatky (balné + doprava + spropitné) se rozpočítávají
* pouze mezi "aktivní" strávníky — tedy ty, kteří si reálně něco objednali.
* Kdo si nic neobjedná (typicky objednávající, který nakupuje jen pro ostatní),
* neplatí nic a nezapočítává se mu ani poměrná část poplatků.
*/
/** Parametry poplatků a slevy potřebné k výpočtu částky člena. */
export type GroupFeeParams = {
/** Celkové poplatky skupiny v haléřích (balné + doprava + spropitné). */
totalFees: number;
/** Typ slevy ('percent' = procenta, 'fixed' = pevná částka v haléřích). */
discountType?: string;
/** Hodnota slevy — procenta, nebo pevná částka v haléřích dle discountType. */
discountValue?: number;
};
/** Vrátí true, pokud si člen něco objednal (má kladnou částku nebo příplatek). */
export function isActiveMember(member: OrderGroupMember): boolean {
return (member.amount ?? 0) + (member.surchargeAmount ?? 0) > 0;
}
/** Počet aktivních strávníků — jen mezi ně se dělí poplatky. */
export function countActiveMembers(members: OrderGroup["members"]): number {
return Object.values(members).filter(isActiveMember).length;
}
/** Celkové poplatky skupiny (balné + doprava + spropitné) v haléřích. */
export function totalGroupFees(group: OrderGroup): number {
return (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
}
/** Poměrná část poplatků na jednoho aktivního strávníka v haléřích. */
export function computeFeeShare(totalFees: number, activeCount: number): number {
return activeCount > 0 ? Math.round(totalFees / activeCount) : 0;
}
/**
* Celková částka, kterou má člen zaplatit (v haléřích).
* Neaktivní člen (nic si neobjednal) platí 0 — nepodílí se ani na poplatcích.
*
* @param member člen skupiny
* @param params poplatky a sleva
* @param feeShare poměrná část poplatků na osobu (viz computeFeeShare)
* @param activeCount počet aktivních strávníků (dělitel pevné slevy)
*/
export function computeMemberTotal(
member: OrderGroupMember,
params: GroupFeeParams,
feeShare: number,
activeCount: number,
): number {
if (!isActiveMember(member)) return 0;
const base = member.amount ?? 0;
const surcharge = member.surchargeAmount ?? 0;
const discountValue = params.discountValue ?? 0;
const discount = discountValue > 0
? (params.discountType === 'percent'
? Math.round((base + surcharge) * discountValue / 100)
: Math.round(discountValue / activeCount))
: 0;
return base + surcharge + feeShare - discount;
}
-1
View File
@@ -8,7 +8,6 @@ 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',
-55
View File
@@ -428,42 +428,6 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b"
integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ== integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==
"@floating-ui/core@^1.7.5":
version "1.7.5"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.5.tgz#d4af157a03330af5a60e69da7a4692507ada0622"
integrity sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==
dependencies:
"@floating-ui/utils" "^0.2.11"
"@floating-ui/dom@^1.7.6":
version "1.7.6"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.6.tgz#f915bba5abbb177e1f227cacee1b4d0634b187bf"
integrity sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==
dependencies:
"@floating-ui/core" "^1.7.5"
"@floating-ui/utils" "^0.2.11"
"@floating-ui/react-dom@^2.1.8":
version "2.1.8"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.8.tgz#5fb5a20d10aafb9505f38c24f38d00c8e1598893"
integrity sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==
dependencies:
"@floating-ui/dom" "^1.7.6"
"@floating-ui/react@^0.27.15":
version "0.27.19"
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.27.19.tgz#d8d5d895b7cb97dac370bfbf55f3e630878fdf1f"
integrity sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==
dependencies:
"@floating-ui/react-dom" "^2.1.8"
"@floating-ui/utils" "^0.2.11"
tabbable "^6.0.0"
"@floating-ui/utils@^0.2.11":
version "0.2.11"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.11.tgz#a269e055e40e2f45873bae9d1a2fdccbd314ea3f"
integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==
"@fortawesome/fontawesome-common-types@7.1.0": "@fortawesome/fontawesome-common-types@7.1.0":
version "7.1.0" version "7.1.0"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz#a4e0b7e40073d5fdef41182da1bc216a05875659" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz#a4e0b7e40073d5fdef41182da1bc216a05875659"
@@ -1252,11 +1216,6 @@ d3-timer@^3.0.1:
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
date-fns@^4.1.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.4.0.tgz#806539edf45c616b2b76b5f78b88c56ed3c7e036"
integrity sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==
debug@^4.1.0, debug@^4.3.1, debug@~4.4.1: debug@^4.1.0, debug@^4.3.1, debug@~4.4.1:
version "4.4.3" version "4.4.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
@@ -1667,15 +1626,6 @@ react-bootstrap@^2.10.10:
uncontrollable "^7.2.1" uncontrollable "^7.2.1"
warning "^4.0.3" warning "^4.0.3"
react-datepicker@^9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-9.1.0.tgz#638c636780ae98f1930f87e1f76850de5fc37d5c"
integrity sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==
dependencies:
"@floating-ui/react" "^0.27.15"
clsx "^2.1.1"
date-fns "^4.1.0"
react-dom@^19.2.0: react-dom@^19.2.0:
version "19.2.3" version "19.2.3"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.3.tgz#f0b61d7e5c4a86773889fcc1853af3ed5f215b17" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.3.tgz#f0b61d7e5c4a86773889fcc1853af3ed5f215b17"
@@ -1931,11 +1881,6 @@ supports-color@^7.1.0:
dependencies: dependencies:
has-flag "^4.0.0" has-flag "^4.0.0"
tabbable@^6.0.0:
version "6.4.0"
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.4.0.tgz#36eb7a06d80b3924a22095daf45740dea3bf5581"
integrity sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==
tiny-invariant@^1.3.3: tiny-invariant@^1.3.3:
version "1.3.3" version "1.3.3"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
+2 -6
View File
@@ -4,10 +4,7 @@ import path from 'path';
// Use 127.0.0.1 explicitly — on Node.js 18+/Windows, `localhost` may resolve to ::1 // Use 127.0.0.1 explicitly — on Node.js 18+/Windows, `localhost` may resolve to ::1
// (IPv6) while the HTTP server only binds to 0.0.0.0 (IPv4), causing the webServer // (IPv6) while the HTTP server only binds to 0.0.0.0 (IPv4), causing the webServer
// readiness poll to time out even though the server is listening. // readiness poll to time out even though the server is listening.
// Port 3099 avoids conflicts with locally running Docker containers on 3001-3003. const BASE_URL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3001';
// Override with E2E_PORT env var if needed.
const E2E_PORT = process.env.E2E_PORT ?? '3099';
const BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${E2E_PORT}`;
// Server env vars injected for local runs. In CI these are set at the step level. // Server env vars injected for local runs. In CI these are set at the step level.
const serverEnv: Record<string, string> = { const serverEnv: Record<string, string> = {
@@ -18,7 +15,6 @@ const serverEnv: Record<string, string> = {
HTTP_REMOTE_USER_ENABLED: 'true', HTTP_REMOTE_USER_ENABLED: 'true',
HTTP_REMOTE_USER_HEADER_NAME: 'remote-user', HTTP_REMOTE_USER_HEADER_NAME: 'remote-user',
HTTP_REMOTE_TRUSTED_IPS: process.env.HTTP_REMOTE_TRUSTED_IPS ?? '127.0.0.1,::1,::ffff:127.0.0.1', HTTP_REMOTE_TRUSTED_IPS: process.env.HTTP_REMOTE_TRUSTED_IPS ?? '127.0.0.1,::1,::ffff:127.0.0.1',
PORT: E2E_PORT,
}; };
if (process.env.REDIS_HOST) { if (process.env.REDIS_HOST) {
serverEnv.REDIS_HOST = process.env.REDIS_HOST; serverEnv.REDIS_HOST = process.env.REDIS_HOST;
@@ -54,7 +50,7 @@ export default defineConfig({
cwd: path.resolve(__dirname, '../server'), cwd: path.resolve(__dirname, '../server'),
// Poll a dedicated health endpoint — polling '/' can stall in Express 5 when // Poll a dedicated health endpoint — polling '/' can stall in Express 5 when
// server/public/ doesn't exist in the working directory (no finalhandler match). // server/public/ doesn't exist in the working directory (no finalhandler match).
url: `http://127.0.0.1:${E2E_PORT}/api/health`, url: `http://127.0.0.1:3001/api/health`,
timeout: 15_000, timeout: 15_000,
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
env: serverEnv, env: serverEnv,
+5 -8
View File
@@ -11,14 +11,11 @@ export async function loginViaApi(page: Page, login: string): Promise<void> {
await page.evaluate((t) => localStorage.setItem('token', t), token); await page.evaluate((t) => localStorage.setItem('token', t), token);
} }
/** Vyčistí stav dne pro zadaný dayIndex (0=pondělí…4=pátek) přes dev API. /** Vyčistí stav pizza dne pro zadaný dayIndex (0=pondělí…4=pátek) přes dev API. */
* /api/dev/* vyžaduje JWT nejdřív získáme token přes /api/login. export async function clearPizzaDay(request: APIRequestContext): Promise<void> {
*/ const today = new Date('2025-01-10'); // MOCK_DATA pins to Friday = dayIndex 4
export async function clearDay(request: APIRequestContext, dayIndex = 4): Promise<void> {
const loginResp = await request.post('/api/login', { data: {} });
const token = await loginResp.json() as string;
await request.post('/api/dev/clear', { await request.post('/api/dev/clear', {
headers: { Authorization: `Bearer ${token}` }, headers: { 'Content-Type': 'application/json', 'remote-user': 'e2e-user' },
data: { dayIndex }, data: { dayIndex: 4 },
}); });
} }
+4 -2
View File
@@ -1,9 +1,11 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { clearDay } from './helpers'; import { clearPizzaDay } from './helpers';
test.beforeEach(async ({ page, request }) => { test.beforeEach(async ({ page, request }) => {
// Vyčistíme volby dne, aby testy neovlivnily navzájem // Vyčistíme volby dne, aby testy neovlivnily navzájem
await clearDay(request); await request.post('/api/dev/clear', {
data: { dayIndex: 4 },
});
await page.goto('/'); await page.goto('/');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// Počkáme, až se zobrazí volba stravování // Počkáme, až se zobrazí volba stravování
+4 -12
View File
@@ -1,12 +1,11 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { clearDay } from './helpers';
// Pizza day testy musí běžet sekvenčně (sdílejí stav mock dne) // Pizza day testy musí běžet sekvenčně (sdílejí stav mock dne)
test.describe.serial('pizza day životní cyklus', () => { test.describe.serial('pizza day životní cyklus', () => {
test.beforeEach(async ({ request }) => { test.beforeEach(async ({ request }) => {
// Vyčistíme data mock dne před každým testem // Vyčistíme data mock dne před každým testem
await clearDay(request); await request.post('/api/dev/clear', { data: { dayIndex: 4 } });
}); });
test('zobrazí sekci Pizza Day bez aktivního dne', async ({ page }) => { test('zobrazí sekci Pizza Day bez aktivního dne', async ({ page }) => {
@@ -21,26 +20,17 @@ test.describe.serial('pizza day životní cyklus', () => {
}); });
test('vytvoří, uzamkne a dokončí pizza day', async ({ page }) => { test('vytvoří, uzamkne a dokončí pizza day', async ({ page }) => {
// Tento test má více kroků a server při MOCK_DATA=true záměrně zpožďuje scraping pizz o 3s
test.setTimeout(60_000);
await page.goto('/'); await page.goto('/');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// Sekce pizza-section se zobrazí jen pokud má uživatel zvolenou možnost "Pizza day" // Sekce pizza-section se zobrazí jen pokud má uživatel zvolenou možnost "Pizza day"
await page.locator('select').selectOption({ label: 'Pizza day' }); await page.locator('select').selectOption({ label: 'Pizza day' });
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// Přijmeme všechny window.confirm() dialogy v celém testu (vytvoření i doručení pizza dne)
page.on('dialog', dialog => dialog.accept());
// --- CREATED --- // --- CREATED ---
const createBtn = page.locator('.pizza-section button', { hasText: 'Založit Pizza day' }); const createBtn = page.locator('.pizza-section button', { hasText: 'Založit Pizza day' });
await expect(createBtn).toBeVisible({ timeout: 10_000 }); await expect(createBtn).toBeVisible({ timeout: 10_000 });
// Čekáme na odpověď API před reloadem jinak by reload přerušil probíhající request // Čekáme na odpověď API před reloadem jinak by reload přerušil probíhající request
// Server s MOCK_DATA=true záměrně zpožďuje stahování pizz o 3s, proto velkorysý timeout const createResponse = page.waitForResponse(resp => resp.url().includes('/api/pizzaDay/create'));
const createResponse = page.waitForResponse(
resp => resp.url().includes('/api/pizzaDay/create'),
{ timeout: 15_000 },
);
await createBtn.click(); await createBtn.click();
await createResponse; await createResponse;
await page.reload(); await page.reload();
@@ -76,6 +66,8 @@ test.describe.serial('pizza day životní cyklus', () => {
// --- DELIVERED --- // --- DELIVERED ---
const deliverBtn = page.locator('.pizza-section button', { hasText: 'Doručeno' }); const deliverBtn = page.locator('.pizza-section button', { hasText: 'Doručeno' });
await expect(deliverBtn).toBeVisible({ timeout: 5_000 }); await expect(deliverBtn).toBeVisible({ timeout: 5_000 });
// window.confirm dialog Playwright automaticky potvrdí
page.on('dialog', dialog => dialog.accept());
await deliverBtn.click(); await deliverBtn.click();
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await expect(page.locator('.pizza-section')).toContainText('doručeny', { timeout: 5_000 }); await expect(page.locator('.pizza-section')).toContainText('doručeny', { timeout: 5_000 });
-186
View File
@@ -1,186 +0,0 @@
# 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`
-16
View File
@@ -1,16 +0,0 @@
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
-4
View File
@@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: luncher
-12
View File
@@ -1,12 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: luncher
spec:
clusterIP: None # headless — StatefulSet pod discovery
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
-50
View File
@@ -1,50 +0,0 @@
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
-184
View File
@@ -1,184 +0,0 @@
# 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
-12
View File
@@ -1,12 +0,0 @@
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"
-85
View File
@@ -1,85 +0,0 @@
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"]
-10
View File
@@ -1,10 +0,0 @@
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
-14
View File
@@ -1,14 +0,0 @@
# Š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
-11
View File
@@ -1,11 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: luncher
namespace: luncher
spec:
selector:
app: luncher
ports:
- port: 3001
targetPort: 3001
-16
View File
@@ -1,16 +0,0 @@
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
-23
View File
@@ -1,23 +0,0 @@
# Spustí server a klienta v samostatných panelech jednoho okna Windows Terminalu.
# Vyžaduje Windows Terminal (wt.exe) — výchozí součást Windows 11.
$ErrorActionPreference = 'Stop'
$ScriptDir = $PSScriptRoot
Push-Location (Join-Path $ScriptDir 'types')
try { yarn openapi-ts } finally { Pop-Location }
if (-not (Get-Command wt.exe -ErrorAction SilentlyContinue)) {
Write-Error "wt.exe (Windows Terminal) nebyl nalezen. Nainstalujte z Microsoft Store nebo použijte run_dev.sh v WSL."
exit 1
}
$serverDir = Join-Path $ScriptDir 'server'
$clientDir = Join-Path $ScriptDir 'client'
# wt splits on ';' before respecting quoting, so encode the compound server command to avoid it
$serverCmd = '$env:NODE_ENV = ''development''; yarn startReload'
$serverCmdB64 = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($serverCmd))
wt -w 0 new-tab --title 'luncher-server' -d $serverDir pwsh -NoExit -EncodedCommand $serverCmdB64 `; `
split-pane -H --title 'luncher-client' -d $clientDir pwsh -NoExit -Command "yarn start"
-4
View File
@@ -48,7 +48,3 @@
# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin). # Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin).
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu). # Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
# REFRESH_BYPASS_PASSWORD= # REFRESH_BYPASS_PASSWORD=
# Admin heslo pro správu seznamu obchodů na stránce /objednani.
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
# ADMIN_PASSWORD=
-1
View File
@@ -2,7 +2,6 @@
/dist /dist
/resources/easterEggs /resources/easterEggs
/src/gen /src/gen
/coverage
.env.production .env.production
.env.development .env.development
.easter-eggs.json .easter-eggs.json
-3
View File
@@ -1,3 +0,0 @@
[
"Zobrazení nabídky salátů z Pizza Chefie"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Možnost úhrady celého účtu jednou osobou s rozesláním QR kódů ostatním (na Pizza day)"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Skupinové objednávky s QR platbou — stránka /objednani (více skupin, každá z jiného obchodu, stavový automat open/locked/ordered)"
]
-7
View File
@@ -1,7 +0,0 @@
[
"Možnost zobrazení objednávek z historie",
"Podpora neplatících osob u objednávání",
"Zobrazení neuhrazených plateb i na stránce objednávek",
"Oprava duplicitního zobrazení QR kódu u Pizza day",
"Odstranění diakritiky v platebních QR kódech"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Nová stránka pro návrhy na vylepšení (dostupná z uživatelského menu)"
]
-5
View File
@@ -1,5 +0,0 @@
[
"Proklik na nabídku podniku ze stránky objednávek",
"Možnost přidat URL pro sledování stavu doručení pro Bolt Food",
"Automatická aktualizace času doručení na základě sledovací URL pro Bolt Food"
]
+2 -3
View File
@@ -1,6 +1,5 @@
module.exports = { module.exports = {
testEnvironment: 'node', testEnvironment: 'node',
testMatch: ['<rootDir>/src/**/*.test.ts'], testMatch: ['<rootDir>/src/tests/**/*.test.ts'],
testPathIgnorePatterns: ['/node_modules/', '/dist/'], setupFiles: ['<rootDir>/src/tests/helpers/setupEnv.ts'],
setupFiles: ['<rootDir>/src/tests/setupEnv.ts'],
}; };
-3
View File
@@ -19,17 +19,14 @@
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/request-promise": "^4.1.48", "@types/request-promise": "^4.1.48",
"@types/supertest": "^6.0.0",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"babel-jest": "^30.2.0", "babel-jest": "^30.2.0",
"jest": "^30.2.0", "jest": "^30.2.0",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
"supertest": "^7.0.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"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
View File
@@ -9,13 +9,13 @@ import jwt from 'jsonwebtoken';
*/ */
export function generateToken(login?: string, trusted?: boolean): string { export function generateToken(login?: string, trusted?: boolean): string {
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET"); throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
} }
if (process.env.JWT_SECRET.length < 32) { if (process.env.JWT_SECRET.length < 32) {
throw new Error("Proměnná prostředí JWT_SECRET musí být minimálně 32 znaků"); throw Error("Proměnná prostředí JWT_SECRET musí být minimálně 32 znaků");
} }
if (!login || login.trim().length === 0) { if (!login || login.trim().length === 0) {
throw new Error("Nebyl předán login"); throw Error("Nebyl předán login");
} }
const payload = { login, trusted: trusted || false, logoutUrl: process.env.LOGOUT_URL }; const payload = { login, trusted: trusted || false, logoutUrl: process.env.LOGOUT_URL };
return jwt.sign(payload, process.env.JWT_SECRET); return jwt.sign(payload, process.env.JWT_SECRET);
@@ -28,7 +28,7 @@ export function generateToken(login?: string, trusted?: boolean): string {
*/ */
export function verify(token: string): boolean { export function verify(token: string): boolean {
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET"); throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
} }
try { try {
jwt.verify(token, process.env.JWT_SECRET); jwt.verify(token, process.env.JWT_SECRET);
@@ -45,10 +45,10 @@ export function verify(token: string): boolean {
*/ */
export function getLogin(token?: string): string { export function getLogin(token?: string): string {
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET"); throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
} }
if (!token) { if (!token) {
throw new Error("Nebyl předán token"); throw Error("Nebyl předán token");
} }
const payload: any = jwt.verify(token, process.env.JWT_SECRET); const payload: any = jwt.verify(token, process.env.JWT_SECRET);
return payload.login; return payload.login;
@@ -61,10 +61,10 @@ export function getLogin(token?: string): string {
*/ */
export function getTrusted(token?: string): boolean { export function getTrusted(token?: string): boolean {
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET"); throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
} }
if (!token) { if (!token) {
throw new Error("Nebyl předán token"); throw Error("Nebyl předán token");
} }
const payload: any = jwt.verify(token, process.env.JWT_SECRET); const payload: any = jwt.verify(token, process.env.JWT_SECRET);
return payload.trusted || false; return payload.trusted || false;
-178
View File
@@ -1,178 +0,0 @@
import axios from 'axios';
import crypto from 'crypto';
import getStorage from './storage';
import { createLeaderLease } from './leaderLease';
import { getToday } from './service';
import { formatDate } from './utils';
import { getWebsocket } from './websocket';
import { ClientData, GroupState } from '../../types/gen/types.gen';
const storage = getStorage();
const lease = createLeaderLease('luncher:bolt:leader');
const BOLT_POLLING_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getOrderPolling';
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
const TERMINAL_STATE_REGEX = /delivered|finished|cancelled|rejected|failed/i;
const MAX_CONSECUTIVE_FAILURES = 10;
/** Identifikátor zařízení pro Bolt API — generuje se jednou na proces. */
const DEVICE_ID = crypto.randomUUID();
let boltInterval: ReturnType<typeof setInterval> | undefined;
/** Mapa groupId → počet po sobě jdoucích selhání dotazu na Bolt API. */
const consecutiveFailures = new Map<string, number>();
/**
* Vytáhne sledovací token ze sdílecí URL Bolt Food
* (https://food.bolt.eu/sharedActiveOrder/<token>) nebo přijme samotný token.
* Vrátí null, pokud vstup neobsahuje platný token (64 hex znaků).
*/
export function extractBoltToken(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed;
let pathname: string;
try {
pathname = new URL(trimmed).pathname;
} catch {
return null;
}
const segments = pathname.split('/').filter(Boolean);
const last = segments[segments.length - 1];
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
}
/** Spočítá očekávaný čas doručení (teď + sekundy) ve formátu HH:MM. */
export function computeDeliveryHHMM(seconds: number, now: Date = new Date()): string {
const eta = new Date(now.getTime() + seconds * 1000);
return `${String(eta.getHours()).padStart(2, '0')}:${String(eta.getMinutes()).padStart(2, '0')}`;
}
interface BoltOrder {
order_id: number;
order_state: string;
expected_time_to_client_in_seconds?: number;
courier?: { state?: string } | null;
}
/** Dotáže se veřejného Bolt API na stav sdílené objednávky. Vrátí null, pokud objednávka už neexistuje. */
export async function pollBoltOrder(token: string): Promise<BoltOrder | null> {
const res = await axios.post(BOLT_POLLING_URL, { token }, {
params: {
version: 'FW.1.111',
language: 'cs-CZ',
country: 'cz',
device_name: 'web',
device_os_version: 'web',
deviceType: 'web',
session_id: DEVICE_ID,
distinct_id: DEVICE_ID,
deviceId: DEVICE_ID,
},
headers: { 'Content-Type': 'application/json' },
timeout: 10_000,
});
if (res.data?.code !== 0) {
throw new Error(`Bolt API vrátilo kód ${res.data?.code}: ${res.data?.message}`);
}
return res.data?.data?.orders?.[0] ?? null;
}
/**
* Jeden tik scheduleru: pro dnešní objednané skupiny se sledovacím tokenem
* zjistí očekávaný čas doručení z Bolt API a aktualizuje deliveryAt.
* Sledování se automaticky ukončí (token se smaže), když objednávka skončí
* nebo dotazy opakovaně selhávají.
*/
export async function checkBoltTracking(): Promise<void> {
const isLeader = await lease.tryAcquireOrRenew();
if (!isLeader) return;
const key = `${formatDate(getToday())}_extra`;
const data = await storage.getData<ClientData>(key);
const candidates = (data?.groups ?? []).filter(g => g.boltTrackingToken && g.state === GroupState.ORDERED);
// Úklid čítačů selhání pro skupiny, které už nesledujeme
for (const groupId of consecutiveFailures.keys()) {
if (!candidates.some(g => g.id === groupId)) consecutiveFailures.delete(groupId);
}
if (candidates.length === 0) return;
let updated: ClientData | undefined;
for (const group of candidates) {
let deliveryAt: string | undefined;
let orderState: string | undefined;
let courierState: string | undefined;
let clearToken = false;
try {
const order = await pollBoltOrder(group.boltTrackingToken!);
consecutiveFailures.delete(group.id);
if (!order) {
// Objednávka z API zmizela — považujeme ji za doručenou
orderState = 'delivered';
clearToken = true;
} else {
orderState = order.order_state || undefined;
courierState = order.courier?.state || undefined;
if (TERMINAL_STATE_REGEX.test(order.order_state ?? '')) {
clearToken = true;
} else if (typeof order.expected_time_to_client_in_seconds === 'number') {
deliveryAt = computeDeliveryHHMM(order.expected_time_to_client_in_seconds);
}
}
} catch (e) {
const failures = (consecutiveFailures.get(group.id) ?? 0) + 1;
consecutiveFailures.set(group.id, failures);
console.error(`Bolt tracking: chyba dotazu pro skupinu "${group.name}" (${failures}/${MAX_CONSECUTIVE_FAILURES})`, e);
if (failures < MAX_CONSECUTIVE_FAILURES) continue;
consecutiveFailures.delete(group.id);
clearToken = true;
}
const timeChanged = deliveryAt !== undefined && deliveryAt !== group.deliveryAt;
const stateChanged = orderState !== undefined && orderState !== group.boltOrderState;
const courierChanged = courierState !== group.boltCourierState && !clearToken;
if (!clearToken && !timeChanged && !stateChanged && !courierChanged) continue;
updated = await storage.updateData<ClientData>(key, current => {
const d = current ?? data!;
const g = d.groups?.find(x => x.id === group.id);
if (g?.boltTrackingToken) {
if (timeChanged) g.deliveryAt = deliveryAt;
if (stateChanged) g.boltOrderState = orderState;
if (courierChanged) g.boltCourierState = courierState;
if (clearToken) g.boltTrackingToken = undefined;
}
return d;
});
if (clearToken) {
console.log(`Bolt tracking: sledování skupiny "${group.name}" ukončeno`);
}
}
if (updated) {
getWebsocket()?.emit('message', updated);
}
}
/** Spustí scheduler pro sledování Bolt objednávek (každou minutu). */
export function startBoltTrackingScheduler(): void {
boltInterval = setInterval(checkBoltTracking, 60_000);
console.log('Bolt tracking: scheduler spuštěn');
}
/** Stopne scheduler sledování. Volá se při graceful shutdown. */
export function stopBoltTrackingScheduler(): void {
if (boltInterval) {
clearInterval(boltInterval);
boltInterval = undefined;
}
}
/** Uvolní leader lease při graceful shutdown. */
export async function releaseBoltTrackingLease(): Promise<void> {
await lease.release();
}
+9 -9
View File
@@ -28,16 +28,16 @@ const buildPizzaUrl = (pizzaUrl: string) => {
return `${baseUrl}/${pizzaUrl}`; return `${baseUrl}/${pizzaUrl}`;
} }
// Ceny krabic dle velikosti v haléřích // Ceny krabic dle velikosti
const boxPrices: { [key: string]: number } = { const boxPrices: { [key: string]: number } = {
"30cm": 1300, "30cm": 13,
"35cm": 1500, "35cm": 15,
"40cm": 1800, "40cm": 18,
"50cm": 2500 "50cm": 25
} }
// Cena obalu pro salát v haléřích // Cena obalu pro salát
const SALAT_BOX_PRICE = 1300; const SALAT_BOX_PRICE = 13;
/** /**
* Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie. * Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie.
@@ -79,7 +79,7 @@ export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
a.each((i, elm) => { a.each((i, elm) => {
const varId = Number.parseInt(elm.attribs.href.split('?varianta=')[1].trim()); const varId = Number.parseInt(elm.attribs.href.split('?varianta=')[1].trim());
const size = $($(elm).contents().get(0)).text().trim(); const size = $($(elm).contents().get(0)).text().trim();
const price = Number.parseInt($($(elm).contents().get(1)).text().trim().split(" Kč")[0]) * 100; const price = Number.parseInt($($(elm).contents().get(1)).text().trim().split(" Kč")[0]);
sizes.push({ varId, size, pizzaPrice: price, boxPrice: boxPrices[size], price: price + boxPrices[size] }); sizes.push({ varId, size, pizzaPrice: price, boxPrice: boxPrices[size], price: price + boxPrices[size] });
}) })
result.push({ result.push({
@@ -119,7 +119,7 @@ export async function downloadSalaty(mock: boolean): Promise<Salat[]> {
ingredients.push($(elm).text()); ingredients.push($(elm).text());
}); });
const priceText = $('.cena > span', salatHtml).first().text().trim(); const priceText = $('.cena > span', salatHtml).first().text().trim();
const price = Number.parseInt(priceText.split(' Kč')[0]) * 100; const price = Number.parseInt(priceText.split(' Kč')[0]);
result.push({ name, ingredients, price: price + SALAT_BOX_PRICE }); result.push({ name, ingredients, price: price + SALAT_BOX_PRICE });
} }
return result; return result;
-228
View File
@@ -1,228 +0,0 @@
import crypto from "crypto";
import getStorage from "./storage";
import { getClientData, getToday, initIfNeeded } from "./service";
import { getStores } from "./stores";
import { removePendingQrsByGroupId } from "./pizza";
import { extractBoltToken } from "./boltTracking";
import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen";
import { formatDate } from "./utils";
const storage = getStorage();
async function getExtraData(date?: Date): Promise<ClientData> {
await initIfNeeded(date, MealSlot.EXTRA);
const data = await getClientData(date, MealSlot.EXTRA);
data.stores = await getStores();
return data;
}
function getExtraKey(date?: Date): string {
return `${formatDate(date ?? getToday())}_extra`;
}
async function saveExtraData(data: ClientData, date?: Date): Promise<ClientData> {
await storage.setData(getExtraKey(date), data);
return data;
}
function findGroup(data: ClientData, id: string): OrderGroup | undefined {
return data.groups?.find(g => g.id === id);
}
/**
* Vrátí seznam ISO dat (YYYY-MM-DD), pro která existuje alespoň jedna objednávková skupina.
* Slouží ke zvýraznění dnů v date pickeru na stránce objednávání.
*/
export async function getOrderDates(): Promise<string[]> {
const EXTRA_SUFFIX = '_extra';
const keys = await storage.listKeys(EXTRA_SUFFIX);
const dates: string[] = [];
for (const key of keys) {
if (!key.endsWith(EXTRA_SUFFIX)) continue;
const data = await storage.getData<ClientData>(key);
if (data?.groups && data.groups.length > 0) {
dates.push(key.slice(0, -EXTRA_SUFFIX.length));
}
}
return dates.sort();
}
export async function createGroup(creatorLogin: string, name: string, date?: Date): Promise<ClientData> {
const stores = await getStores();
if (!stores.some(s => s.name.toLowerCase() === name.trim().toLowerCase())) {
throw new Error('Obchod není v seznamu povolených obchodů');
}
const data = await getExtraData(date);
const canonical = stores.find(s => s.name.toLowerCase() === name.trim().toLowerCase())!.name;
const group: OrderGroup = {
id: crypto.randomUUID(),
name: canonical,
creatorLogin,
state: GroupState.OPEN,
members: { [creatorLogin]: {} },
};
data.groups = [...(data.groups ?? []), group];
return saveExtraData(data, date);
}
export async function deleteGroup(login: string, groupId: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Skupinu může smazat pouze zakladatel');
data.groups = (data.groups ?? []).filter(g => g.id !== groupId);
return saveExtraData(data, date);
}
export async function addGroupMember(login: string, groupId: string, targetLogin: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
if (login !== group.creatorLogin && login !== targetLogin) {
throw new Error('Přidat jiného uživatele může pouze zakladatel');
}
if (group.state === GroupState.LOCKED && login !== group.creatorLogin) {
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
}
if (group.members[targetLogin]) throw new Error('Uživatel je již ve skupině');
group.members[targetLogin] = {};
return saveExtraData(data, date);
}
export async function removeGroupMember(login: string, groupId: string, targetLogin: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
if (login !== group.creatorLogin && login !== targetLogin) {
throw new Error('Odebrat jiného uživatele může pouze zakladatel');
}
if (group.state === GroupState.LOCKED && login !== group.creatorLogin) {
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
}
if (targetLogin === group.creatorLogin) throw new Error('Zakladatel skupiny nemůže být odebrán');
if (!group.members[targetLogin]) throw new Error('Uživatel není ve skupině');
delete group.members[targetLogin];
return saveExtraData(data, date);
}
export async function updateGroupMember(login: string, groupId: string, targetLogin: string, patch: Partial<OrderGroupMember>, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
const isSelf = login === targetLogin;
const isCreator = login === group.creatorLogin;
if (!isSelf && !isCreator) throw new Error('Upravit jiného uživatele může pouze zakladatel');
if (!isCreator && group.state === GroupState.LOCKED) {
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
}
if (!group.members[targetLogin]) throw new Error('Uživatel není ve skupině');
group.members[targetLogin] = { ...group.members[targetLogin], ...patch };
return saveExtraData(data, date);
}
const VALID_TRANSITIONS: Record<GroupState, GroupState[]> = {
[GroupState.OPEN]: [GroupState.LOCKED],
[GroupState.LOCKED]: [GroupState.OPEN, GroupState.ORDERED],
[GroupState.ORDERED]: [GroupState.LOCKED],
};
function getCurrentHHMM(): string {
const now = new Date();
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
}
export async function setGroupState(login: string, groupId: string, newState: GroupState, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Stav může měnit pouze zakladatel');
if (!VALID_TRANSITIONS[group.state].includes(newState)) {
throw new Error(`Nelze přejít ze stavu "${group.state}" do stavu "${newState}"`);
}
if (newState === GroupState.ORDERED) {
group.orderedAt = getCurrentHHMM();
}
if (group.state === GroupState.ORDERED && newState === GroupState.LOCKED) {
const memberLogins = Object.keys(group.members);
await removePendingQrsByGroupId(memberLogins, groupId);
group.orderedAt = undefined;
group.deliveryAt = undefined;
group.qrGenerated = undefined;
group.boltTrackingToken = undefined;
group.boltOrderState = undefined;
group.boltCourierState = undefined;
for (const ml of memberLogins) {
group.members[ml] = { ...group.members[ml], paid: undefined };
}
}
group.state = newState;
return saveExtraData(data, date);
}
export async function markGroupQrGenerated(login: string, groupId: string, date?: Date): Promise<void> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('QR kódy může generovat pouze zakladatel');
if (group.state !== GroupState.ORDERED) throw new Error('QR kódy lze generovat pouze ve stavu "objednáno"');
group.qrGenerated = true;
await saveExtraData(data, date);
}
export async function markGroupMemberPaid(login: string, groupId: string, date?: Date): Promise<ClientData | null> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group || !group.members[login]) return null;
group.members[login] = { ...group.members[login], paid: true };
return saveExtraData(data, date);
}
export async function updateGroupFees(login: string, groupId: string, fees?: number, shipping?: number, tip?: number, discountType?: string, discountValue?: number, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Poplatky může měnit pouze zakladatel');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
if (fees !== undefined) group.fees = fees > 0 ? fees : undefined;
if (shipping !== undefined) group.shipping = shipping > 0 ? shipping : undefined;
if (tip !== undefined) group.tip = tip > 0 ? tip : undefined;
if (discountType !== undefined) group.discountType = (discountType as any) || undefined;
if (discountValue !== undefined) group.discountValue = discountValue > 0 ? discountValue : undefined;
return saveExtraData(data, date);
}
export async function updateGroupTimes(login: string, groupId: string, orderedAt?: string, deliveryAt?: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Časy může měnit pouze zakladatel');
if (orderedAt !== undefined) group.orderedAt = orderedAt || undefined;
if (deliveryAt !== undefined) group.deliveryAt = deliveryAt || undefined;
return saveExtraData(data, date);
}
export async function setGroupBoltTracking(login: string, groupId: string, shareUrl?: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Sledování Bolt může nastavit pouze zakladatel');
if (!shareUrl) {
group.boltTrackingToken = undefined;
group.boltOrderState = undefined;
group.boltCourierState = undefined;
} else {
if (group.state !== GroupState.ORDERED) throw new Error('Sledování Bolt lze nastavit pouze ve stavu "objednáno"');
const token = extractBoltToken(shareUrl);
if (!token) throw new Error('Neplatný odkaz na sledování objednávky Bolt');
if (token !== group.boltTrackingToken) {
group.boltTrackingToken = token;
// Stav patří k předchozí objednávce — vyčistíme, doplní ho první poll
group.boltOrderState = undefined;
group.boltCourierState = undefined;
}
}
return saveExtraData(data, date);
}
+43 -129
View File
@@ -1,53 +1,46 @@
import express from "express"; import express from "express";
import bodyParser from "body-parser"; import bodyParser from "body-parser";
import cors from 'cors'; import cors from 'cors';
import { getData, addChoice, getDateForWeekIndex, getToday } from "./service"; import { getData, getDateForWeekIndex, getToday } from "./service";
import { MealSlot } from "../../types/gen/types.gen";
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { getQr } from "./qr"; import { getQr } from "./qr";
import { generateToken, 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, initRedisAdapter, shutdownWebsocketClients, getWebsocket } from "./websocket"; import { initWebsocket } from "./websocket";
import { startReminderScheduler, stopReminderScheduler, releaseReminderLease, verifyQuickChoiceToken } from "./pushReminder"; import { startReminderScheduler } from "./pushReminder";
import { startBoltTrackingScheduler, stopBoltTrackingScheduler, releaseBoltTrackingLease } from "./boltTracking";
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 suggestionRoutes from "./routes/suggestionRoutes"; import votingRoutes from "./routes/votingRoutes";
import easterEggRoutes from "./routes/easterEggRoutes"; import easterEggRoutes from "./routes/easterEggRoutes";
import statsRoutes from "./routes/statsRoutes"; import statsRoutes from "./routes/statsRoutes";
import notificationRoutes from "./routes/notificationRoutes"; import notificationRoutes from "./routes/notificationRoutes";
import qrRoutes from "./routes/qrRoutes"; import qrRoutes from "./routes/qrRoutes";
import devRoutes from "./routes/devRoutes"; import devRoutes from "./routes/devRoutes";
import changelogRoutes from "./routes/changelogRoutes"; import changelogRoutes from "./routes/changelogRoutes";
import groupRoutes from "./routes/groupRoutes";
import storeRoutes from "./routes/storeRoutes";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
// Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET"); throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
} }
const app = express(); const app = express();
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) {
@@ -55,73 +48,19 @@ 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 ──────────────────────────────────────────────────────────
let shuttingDown = false; // ----------- Metody nevyžadující token --------------
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();
// Stop Bolt tracking scheduler and release leader lease
stopBoltTrackingScheduler();
await releaseBoltTrackingLease();
// 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' });
@@ -134,17 +73,21 @@ app.get("/api/whoami", (req, res) => {
}) })
app.post("/api/login", (req, res) => { app.post("/api/login", (req, res) => {
if (HTTP_REMOTE_USER_ENABLED) { if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy')
// 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 new Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??"); throw Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
} }
} else { } else {
// Klasická autentizace loginem
if (!req.body?.login || req.body.login.trim().length === 0) { if (!req.body?.login || req.body.login.trim().length === 0) {
throw new Error("Nebyl předán login"); throw Error("Nebyl předán login");
} }
// TODO zavést podmínky pro délku loginu (min i max)
res.status(200).json(generateToken(req.body.login, false)); res.status(200).json(generateToken(req.body.login, false));
} }
}); });
@@ -165,29 +108,15 @@ 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);
app.post("/api/notifications/push/quickChoice", async (req, res, next) => { /** Middleware ověřující JWT token */
try {
const { login, token } = req.body ?? {};
if (!login || typeof login !== 'string' || !token || typeof token !== 'string') {
return res.status(400).json({ error: 'Chybí login nebo token' });
}
if (!verifyQuickChoiceToken(login, token)) {
return res.status(403).json({ error: 'Neplatný token' });
}
const updatedData = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
getWebsocket().emit("message", updatedData);
res.status(200).json({});
} catch (e: any) { next(e); }
});
// ─── 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"]
@@ -210,31 +139,20 @@ app.use("/api/", (req, res, next) => {
next(); next();
}); });
// ─── Authenticated routes ───────────────────────────────────────────────────── /** Vrátí data pro aktuální den. */
app.get("/api/data", async (req, res) => { app.get("/api/data", async (req, res) => {
let date = undefined; let date = undefined;
if (req.query.date != null && typeof req.query.date === 'string') { if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') {
// Konkrétní datum (YYYY-MM-DD) umožňuje načtení historie i mimo aktuální týden
const parsed = new Date(`${req.query.date}T00:00:00`);
if (isNaN(parsed.getTime())) {
return res.status(400).json({ error: 'Neplatné datum' });
}
// Budoucnost ořízneme na dnešek do budoucna historii nedává smysl zobrazovat
date = parsed.getTime() > getToday().getTime() ? getToday() : parsed;
} else if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') {
const index = parseInt(req.query.dayIndex); const index = parseInt(req.query.dayIndex);
if (!isNaN(index)) { if (!isNaN(index)) {
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 data = await getData(date);
if (slotParam && slotParam !== MealSlot.OBED && slotParam !== MealSlot.EXTRA) { // Připojíme nevyřízené QR kódy pro přihlášeného uživatele
return res.status(400).json({ error: 'Neplatný slot' });
}
const data = await getData(date, slotParam);
try { try {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const pendingQrs = await getPendingQrs(login); const pendingQrs = await getPendingQrs(login);
@@ -247,24 +165,21 @@ 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/suggestions", suggestionRoutes); app.use("/api/voting", votingRoutes);
app.use("/api/easterEggs", easterEggRoutes); app.use("/api/easterEggs", easterEggRoutes);
app.use("/api/stats", statsRoutes); app.use("/api/stats", statsRoutes);
app.use("/api/notifications", notificationRoutes); app.use("/api/notifications", notificationRoutes);
app.use("/api/qr", qrRoutes); app.use("/api/qr", qrRoutes);
app.use("/api/dev", devRoutes); app.use("/api/dev", devRoutes);
app.use("/api/changelogs", changelogRoutes); app.use("/api/changelogs", changelogRoutes);
app.use("/api/groups", groupRoutes);
app.use("/api/stores", storeRoutes);
app.use(express.static(path.join(process.cwd(), 'public'))); app.use('/stats', express.static('public'));
app.get('*splat', (_req, res) => { app.use(express.static('public'));
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
});
// Error handling middleware // Middleware pro zpracování chyb
app.use((err: any, req: any, res: any, next: any) => { app.use((err: any, req: any, res: any, next: any) => {
if (err instanceof InsufficientPermissions) { if (err instanceof InsufficientPermissions) {
res.status(403).send({ error: err.message }) res.status(403).send({ error: err.message })
@@ -276,19 +191,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(async () => { storageReady.then(() => {
// 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();
startBoltTrackingScheduler();
}); });
}); });
// 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);
});
-56
View File
@@ -1,56 +0,0 @@
import { getRedisClient } from './storage/redis';
const POD_ID = process.env.POD_ID ?? `local-${process.pid}`;
export interface LeaderLease {
tryAcquireOrRenew(): Promise<boolean>;
release(): Promise<void>;
}
/**
* Vytvoří leader lease pro daný klíč zajišťuje, že periodickou úlohu
* spouští v multi-replica nasazení pouze jedna instance.
* Při non-Redis storage vždy vrací true (single-process, leader election není potřeba).
*/
export function createLeaderLease(leaseKey: string, ttlSeconds = 90): LeaderLease {
return {
async tryAcquireOrRenew(): 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(leaseKey, POD_ID, { NX: true, EX: ttlSeconds });
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(leaseKey);
if (currentHolder === POD_ID) {
// Naše lease — obnovíme TTL
await c.set(leaseKey, POD_ID, { EX: ttlSeconds });
return true;
}
return false; // lease drží jiná instance
} catch (e) {
console.error(`Leader lease (${leaseKey}): chyba při získávání, úloha bude spuštěna`, e);
return true; // při chybě raději spustíme, než vynecháme
}
},
async release(): Promise<void> {
if (process.env.STORAGE?.toLowerCase() !== 'redis') return;
try {
const c = getRedisClient();
if (!c) return;
const currentHolder = await c.get(leaseKey);
if (currentHolder === POD_ID) {
await c.del(leaseKey);
console.log(`Leader lease (${leaseKey}): uvolněna`);
}
} catch (e) {
console.error(`Leader lease (${leaseKey}): chyba při uvolňování`, e);
}
},
};
}
+220 -220
View File
@@ -661,30 +661,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 1, varId: 1,
size: "30cm", size: "30cm",
pizzaPrice: 13800, pizzaPrice: 138,
boxPrice: 1300, boxPrice: 13,
price: 15100 price: 151
}, },
{ {
varId: 2, varId: 2,
size: "35cm", size: "35cm",
pizzaPrice: 16600, pizzaPrice: 166,
boxPrice: 1500, boxPrice: 15,
price: 18100 price: 181
}, },
{ {
varId: 3, varId: 3,
size: "40cm", size: "40cm",
pizzaPrice: 22300, pizzaPrice: 223,
boxPrice: 1800, boxPrice: 18,
price: 24100 price: 241
}, },
{ {
varId: 4, varId: 4,
size: "50cm", size: "50cm",
pizzaPrice: 30600, pizzaPrice: 306,
boxPrice: 2500, boxPrice: 25,
price: 33100 price: 331
} }
] ]
}, },
@@ -700,30 +700,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 6, varId: 6,
size: "30cm", size: "30cm",
pizzaPrice: 14200, pizzaPrice: 142,
boxPrice: 1300, boxPrice: 13,
price: 15500 price: 155
}, },
{ {
varId: 7, varId: 7,
size: "35cm", size: "35cm",
pizzaPrice: 17200, pizzaPrice: 172,
boxPrice: 1500, boxPrice: 15,
price: 18700 price: 187
}, },
{ {
varId: 8, varId: 8,
size: "40cm", size: "40cm",
pizzaPrice: 23300, pizzaPrice: 233,
boxPrice: 1800, boxPrice: 18,
price: 25100 price: 251
}, },
{ {
varId: 9, varId: 9,
size: "50cm", size: "50cm",
pizzaPrice: 31600, pizzaPrice: 316,
boxPrice: 2500, boxPrice: 25,
price: 34100 price: 341
} }
] ]
}, },
@@ -741,30 +741,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 10, varId: 10,
size: "30cm", size: "30cm",
pizzaPrice: 14200, pizzaPrice: 142,
boxPrice: 1300, boxPrice: 13,
price: 15500 price: 155
}, },
{ {
varId: 11, varId: 11,
size: "35cm", size: "35cm",
pizzaPrice: 17200, pizzaPrice: 172,
boxPrice: 1500, boxPrice: 15,
price: 18700 price: 187
}, },
{ {
varId: 12, varId: 12,
size: "40cm", size: "40cm",
pizzaPrice: 23300, pizzaPrice: 233,
boxPrice: 1800, boxPrice: 18,
price: 25100 price: 251
}, },
{ {
varId: 13, varId: 13,
size: "50cm", size: "50cm",
pizzaPrice: 31600, pizzaPrice: 316,
boxPrice: 2500, boxPrice: 25,
price: 34100 price: 341
} }
] ]
}, },
@@ -780,30 +780,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 14, varId: 14,
size: "30cm", size: "30cm",
pizzaPrice: 14200, pizzaPrice: 142,
boxPrice: 1300, boxPrice: 13,
price: 15500 price: 155
}, },
{ {
varId: 15, varId: 15,
size: "35cm", size: "35cm",
pizzaPrice: 17200, pizzaPrice: 172,
boxPrice: 1500, boxPrice: 15,
price: 18700 price: 187
}, },
{ {
varId: 16, varId: 16,
size: "40cm", size: "40cm",
pizzaPrice: 23300, pizzaPrice: 233,
boxPrice: 1800, boxPrice: 18,
price: 25100 price: 251
}, },
{ {
varId: 17, varId: 17,
size: "50cm", size: "50cm",
pizzaPrice: 29400, pizzaPrice: 294,
boxPrice: 2500, boxPrice: 25,
price: 31900 price: 319
} }
] ]
}, },
@@ -821,30 +821,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 22, varId: 22,
size: "30cm", size: "30cm",
pizzaPrice: 16200, pizzaPrice: 162,
boxPrice: 1300, boxPrice: 13,
price: 17500 price: 175
}, },
{ {
varId: 23, varId: 23,
size: "35cm", size: "35cm",
pizzaPrice: 18600, pizzaPrice: 186,
boxPrice: 1500, boxPrice: 15,
price: 20100 price: 201
}, },
{ {
varId: 24, varId: 24,
size: "40cm", size: "40cm",
pizzaPrice: 26300, pizzaPrice: 263,
boxPrice: 1800, boxPrice: 18,
price: 28100 price: 281
}, },
{ {
varId: 25, varId: 25,
size: "50cm", size: "50cm",
pizzaPrice: 34600, pizzaPrice: 346,
boxPrice: 2500, boxPrice: 25,
price: 37100 price: 371
} }
] ]
}, },
@@ -861,30 +861,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 26, varId: 26,
size: "30cm", size: "30cm",
pizzaPrice: 16200, pizzaPrice: 162,
boxPrice: 1300, boxPrice: 13,
price: 17500 price: 175
}, },
{ {
varId: 27, varId: 27,
size: "35cm", size: "35cm",
pizzaPrice: 18600, pizzaPrice: 186,
boxPrice: 1500, boxPrice: 15,
price: 20100 price: 201
}, },
{ {
varId: 28, varId: 28,
size: "40cm", size: "40cm",
pizzaPrice: 26300, pizzaPrice: 263,
boxPrice: 1800, boxPrice: 18,
price: 28100 price: 281
}, },
{ {
varId: 29, varId: 29,
size: "50cm", size: "50cm",
pizzaPrice: 34600, pizzaPrice: 346,
boxPrice: 2500, boxPrice: 25,
price: 37100 price: 371
} }
] ]
}, },
@@ -902,30 +902,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 30, varId: 30,
size: "30cm", size: "30cm",
pizzaPrice: 16200, pizzaPrice: 162,
boxPrice: 1300, boxPrice: 13,
price: 17500 price: 175
}, },
{ {
varId: 31, varId: 31,
size: "35cm", size: "35cm",
pizzaPrice: 18600, pizzaPrice: 186,
boxPrice: 1500, boxPrice: 15,
price: 20100 price: 201
}, },
{ {
varId: 32, varId: 32,
size: "40cm", size: "40cm",
pizzaPrice: 26300, pizzaPrice: 263,
boxPrice: 1800, boxPrice: 18,
price: 28100 price: 281
}, },
{ {
varId: 33, varId: 33,
size: "50cm", size: "50cm",
pizzaPrice: 34600, pizzaPrice: 346,
boxPrice: 2500, boxPrice: 25,
price: 37100 price: 371
} }
] ]
}, },
@@ -946,30 +946,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 34, varId: 34,
size: "30cm", size: "30cm",
pizzaPrice: 16200, pizzaPrice: 162,
boxPrice: 1300, boxPrice: 13,
price: 17500 price: 175
}, },
{ {
varId: 35, varId: 35,
size: "35cm", size: "35cm",
pizzaPrice: 18600, pizzaPrice: 186,
boxPrice: 1500, boxPrice: 15,
price: 20100 price: 201
}, },
{ {
varId: 36, varId: 36,
size: "40cm", size: "40cm",
pizzaPrice: 26300, pizzaPrice: 263,
boxPrice: 1800, boxPrice: 18,
price: 28100 price: 281
}, },
{ {
varId: 37, varId: 37,
size: "50cm", size: "50cm",
pizzaPrice: 34600, pizzaPrice: 346,
boxPrice: 2500, boxPrice: 25,
price: 37100 price: 371
} }
] ]
}, },
@@ -987,30 +987,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 38, varId: 38,
size: "30cm", size: "30cm",
pizzaPrice: 16200, pizzaPrice: 162,
boxPrice: 1300, boxPrice: 13,
price: 17500 price: 175
}, },
{ {
varId: 39, varId: 39,
size: "35cm", size: "35cm",
pizzaPrice: 18600, pizzaPrice: 186,
boxPrice: 1500, boxPrice: 15,
price: 20100 price: 201
}, },
{ {
varId: 40, varId: 40,
size: "40cm", size: "40cm",
pizzaPrice: 26300, pizzaPrice: 263,
boxPrice: 1800, boxPrice: 18,
price: 28100 price: 281
}, },
{ {
varId: 41, varId: 41,
size: "50cm", size: "50cm",
pizzaPrice: 34600, pizzaPrice: 346,
boxPrice: 2500, boxPrice: 25,
price: 37100 price: 371
} }
] ]
}, },
@@ -1028,30 +1028,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 42, varId: 42,
size: "30cm", size: "30cm",
pizzaPrice: 17200, pizzaPrice: 172,
boxPrice: 1300, boxPrice: 13,
price: 18500 price: 185
}, },
{ {
varId: 43, varId: 43,
size: "35cm", size: "35cm",
pizzaPrice: 21200, pizzaPrice: 212,
boxPrice: 1500, boxPrice: 15,
price: 22700 price: 227
}, },
{ {
varId: 44, varId: 44,
size: "40cm", size: "40cm",
pizzaPrice: 29300, pizzaPrice: 293,
boxPrice: 1800, boxPrice: 18,
price: 31100 price: 311
}, },
{ {
varId: 45, varId: 45,
size: "50cm", size: "50cm",
pizzaPrice: 37600, pizzaPrice: 376,
boxPrice: 2500, boxPrice: 25,
price: 40100 price: 401
} }
] ]
}, },
@@ -1069,30 +1069,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 46, varId: 46,
size: "30cm", size: "30cm",
pizzaPrice: 18200, pizzaPrice: 182,
boxPrice: 1300, boxPrice: 13,
price: 19500 price: 195
}, },
{ {
varId: 47, varId: 47,
size: "35cm", size: "35cm",
pizzaPrice: 22200, pizzaPrice: 222,
boxPrice: 1500, boxPrice: 15,
price: 23700 price: 237
}, },
{ {
varId: 48, varId: 48,
size: "40cm", size: "40cm",
pizzaPrice: 30300, pizzaPrice: 303,
boxPrice: 1800, boxPrice: 18,
price: 32100 price: 321
}, },
{ {
varId: 49, varId: 49,
size: "50cm", size: "50cm",
pizzaPrice: 38600, pizzaPrice: 386,
boxPrice: 2500, boxPrice: 25,
price: 41100 price: 411
} }
] ]
}, },
@@ -1114,30 +1114,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 50, varId: 50,
size: "30cm", size: "30cm",
pizzaPrice: 18200, pizzaPrice: 182,
boxPrice: 1300, boxPrice: 13,
price: 19500 price: 195
}, },
{ {
varId: 51, varId: 51,
size: "35cm", size: "35cm",
pizzaPrice: 22200, pizzaPrice: 222,
boxPrice: 1500, boxPrice: 15,
price: 23700 price: 237
}, },
{ {
varId: 52, varId: 52,
size: "40cm", size: "40cm",
pizzaPrice: 30300, pizzaPrice: 303,
boxPrice: 1800, boxPrice: 18,
price: 32100 price: 321
}, },
{ {
varId: 53, varId: 53,
size: "50cm", size: "50cm",
pizzaPrice: 39600, pizzaPrice: 396,
boxPrice: 2500, boxPrice: 25,
price: 42100 price: 421
} }
] ]
}, },
@@ -1156,30 +1156,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 54, varId: 54,
size: "30cm", size: "30cm",
pizzaPrice: 18200, pizzaPrice: 182,
boxPrice: 1300, boxPrice: 13,
price: 19500 price: 195
}, },
{ {
varId: 55, varId: 55,
size: "35cm", size: "35cm",
pizzaPrice: 22200, pizzaPrice: 222,
boxPrice: 1500, boxPrice: 15,
price: 23700 price: 237
}, },
{ {
varId: 56, varId: 56,
size: "40cm", size: "40cm",
pizzaPrice: 30300, pizzaPrice: 303,
boxPrice: 1800, boxPrice: 18,
price: 32100 price: 321
}, },
{ {
varId: 57, varId: 57,
size: "50cm", size: "50cm",
pizzaPrice: 39600, pizzaPrice: 396,
boxPrice: 2500, boxPrice: 25,
price: 42100 price: 421
} }
] ]
}, },
@@ -1199,30 +1199,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 58, varId: 58,
size: "30cm", size: "30cm",
pizzaPrice: 18200, pizzaPrice: 182,
boxPrice: 1300, boxPrice: 13,
price: 19500 price: 195
}, },
{ {
varId: 59, varId: 59,
size: "35cm", size: "35cm",
pizzaPrice: 22200, pizzaPrice: 222,
boxPrice: 1500, boxPrice: 15,
price: 23700 price: 237
}, },
{ {
varId: 60, varId: 60,
size: "40cm", size: "40cm",
pizzaPrice: 30300, pizzaPrice: 303,
boxPrice: 1800, boxPrice: 18,
price: 32100 price: 321
}, },
{ {
varId: 61, varId: 61,
size: "50cm", size: "50cm",
pizzaPrice: 39600, pizzaPrice: 396,
boxPrice: 2500, boxPrice: 25,
price: 42100 price: 421
} }
] ]
}, },
@@ -1241,30 +1241,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 62, varId: 62,
size: "30cm", size: "30cm",
pizzaPrice: 18800, pizzaPrice: 188,
boxPrice: 1300, boxPrice: 13,
price: 20100 price: 201
}, },
{ {
varId: 63, varId: 63,
size: "35cm", size: "35cm",
pizzaPrice: 22600, pizzaPrice: 226,
boxPrice: 1500, boxPrice: 15,
price: 24100 price: 241
}, },
{ {
varId: 64, varId: 64,
size: "40cm", size: "40cm",
pizzaPrice: 31300, pizzaPrice: 313,
boxPrice: 1800, boxPrice: 18,
price: 33100 price: 331
}, },
{ {
varId: 65, varId: 65,
size: "50cm", size: "50cm",
pizzaPrice: 42600, pizzaPrice: 426,
boxPrice: 2500, boxPrice: 25,
price: 45100 price: 451
} }
] ]
}, },
@@ -1283,30 +1283,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 66, varId: 66,
size: "30cm", size: "30cm",
pizzaPrice: 18800, pizzaPrice: 188,
boxPrice: 1300, boxPrice: 13,
price: 20100 price: 201
}, },
{ {
varId: 67, varId: 67,
size: "35cm", size: "35cm",
pizzaPrice: 22600, pizzaPrice: 226,
boxPrice: 1500, boxPrice: 15,
price: 24100 price: 241
}, },
{ {
varId: 68, varId: 68,
size: "40cm", size: "40cm",
pizzaPrice: 31300, pizzaPrice: 313,
boxPrice: 1800, boxPrice: 18,
price: 33100 price: 331
}, },
{ {
varId: 69, varId: 69,
size: "50cm", size: "50cm",
pizzaPrice: 42600, pizzaPrice: 426,
boxPrice: 2500, boxPrice: 25,
price: 45100 price: 451
} }
] ]
}, },
@@ -1327,30 +1327,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 309, varId: 309,
size: "30cm", size: "30cm",
pizzaPrice: 18200, pizzaPrice: 182,
boxPrice: 1300, boxPrice: 13,
price: 19500 price: 195
}, },
{ {
varId: 310, varId: 310,
size: "35cm", size: "35cm",
pizzaPrice: 22200, pizzaPrice: 222,
boxPrice: 1500, boxPrice: 15,
price: 23700 price: 237
}, },
{ {
varId: 311, varId: 311,
size: "40cm", size: "40cm",
pizzaPrice: 30300, pizzaPrice: 303,
boxPrice: 1800, boxPrice: 18,
price: 32100 price: 321
}, },
{ {
varId: 312, varId: 312,
size: "50cm", size: "50cm",
pizzaPrice: 39600, pizzaPrice: 396,
boxPrice: 2500, boxPrice: 25,
price: 42100 price: 421
} }
] ]
}, },
@@ -1369,30 +1369,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 394, varId: 394,
size: "30cm", size: "30cm",
pizzaPrice: 18800, pizzaPrice: 188,
boxPrice: 1300, boxPrice: 13,
price: 20100 price: 201
}, },
{ {
varId: 395, varId: 395,
size: "35cm", size: "35cm",
pizzaPrice: 22600, pizzaPrice: 226,
boxPrice: 1500, boxPrice: 15,
price: 24100 price: 241
}, },
{ {
varId: 396, varId: 396,
size: "40cm", size: "40cm",
pizzaPrice: 31300, pizzaPrice: 313,
boxPrice: 1800, boxPrice: 18,
price: 33100 price: 331
}, },
{ {
varId: 397, varId: 397,
size: "50cm", size: "50cm",
pizzaPrice: 42600, pizzaPrice: 426,
boxPrice: 2500, boxPrice: 25,
price: 45100 price: 451
} }
] ]
} }
@@ -1434,22 +1434,22 @@ const MOCK_SALAT_LIST = [
{ {
name: "Greek", name: "Greek",
ingredients: ["Salát", "Černé olivy", "Paprika mix", "Červená cibule", "Rajčata", "Okurka salátová", "Jogurtový dresing"], ingredients: ["Salát", "Černé olivy", "Paprika mix", "Červená cibule", "Rajčata", "Okurka salátová", "Jogurtový dresing"],
price: (174 + 13) * 100, price: 174 + 13,
}, },
{ {
name: "Caesar", name: "Caesar",
ingredients: ["Salát", "Rajčata", "Kuřecí maso", "Krutony", "Parmazán", "Caesar dresing", "Olivový olej"], ingredients: ["Salát", "Rajčata", "Kuřecí maso", "Krutony", "Parmazán", "Caesar dresing", "Olivový olej"],
price: (184 + 13) * 100, price: 184 + 13,
}, },
{ {
name: "Šopský salát", name: "Šopský salát",
ingredients: ["Salátová okurka", "Rajčata", "Paprika mix", "Červená cibule", "Balkánský sýr"], ingredients: ["Salátová okurka", "Rajčata", "Paprika mix", "Červená cibule", "Balkánský sýr"],
price: (164 + 13) * 100, price: 164 + 13,
}, },
{ {
name: "Těstovinový salát", name: "Těstovinový salát",
ingredients: ["Penne", "Okurka", "Rajčata", "Paprika mix", "Kuřecí maso", "Jogurtový dresing"], ingredients: ["Penne", "Okurka", "Rajčata", "Paprika mix", "Kuřecí maso", "Jogurtový dresing"],
price: (184 + 13) * 100, price: 184 + 13,
}, },
] ]
+325 -157
View File
@@ -10,6 +10,10 @@ 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());
@@ -20,17 +24,25 @@ 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());
return storage.updateData<ClientData>(today, (current) => { const clientData = await getClientData(getToday());
const data = current ?? ({} as ClientData); clientData.pizzaList = pizzaList;
data.pizzaList = pizzaList; clientData.pizzaListLastUpdate = formatDate(new Date());
data.pizzaListLastUpdate = formatDate(new Date()); await storage.setData(today, clientData);
return data; return clientData;
});
} }
/**
* 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());
@@ -41,250 +53,406 @@ 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());
return storage.updateData<ClientData>(today, (current) => { const clientData = await getClientData(getToday());
const data = current ?? ({} as ClientData); clientData.salatList = salatList;
data.salatList = salatList; await storage.setData(today, clientData);
return data; return clientData;
});
} }
/**
* 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();
// Stáhneme pizzy a saláty před samotnou atomickou operací const clientData = await getClientData(getToday());
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());
const result = await storage.updateData<ClientData>(today, (current) => { await storage.setData(today, data);
if (!current) throw Error("Data pro dnešní den nejsou inicializována"); callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } })
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;
}
export async function deletePizzaDay(login: string): Promise<ClientData> {
const today = formatDate(getToday());
return storage.updateData<ClientData>(today, (current) => {
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; return data;
});
} }
/**
* Smaže pizza day pro aktuální den.
*/
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());
await storage.setData(today, clientData);
return clientData;
}
/**
* 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());
return storage.updateData<ClientData>(today, (current) => { const clientData = await getClientData(getToday());
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); if (!clientData.pizzaDay) {
if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED); throw Error("Pizza day pro dnešden neexistuje");
let order: PizzaOrder | undefined = current.pizzaDay.orders?.find(o => o.customer === login); }
if (!order) { if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
order = { customer: login, pizzaList: [], totalPrice: 0, hasQr: false }; throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
current.pizzaDay.orders ??= []; }
current.pizzaDay.orders.push(order); let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
if (!order) {
order = {
customer: login,
pizzaList: [],
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;
return current; await storage.setData(today, clientData);
}); 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());
return storage.updateData<ClientData>(today, (current) => { const clientData = await getClientData(getToday());
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); if (!clientData.pizzaDay) {
if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED); throw Error("Pizza day pro dnešden neexistuje");
let order: PizzaOrder | undefined = current.pizzaDay.orders?.find(o => o.customer === login); }
if (!order) { if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
order = { customer: login, pizzaList: [], totalPrice: 0, hasQr: false }; throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
current.pizzaDay.orders ??= []; }
current.pizzaDay.orders.push(order); let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
if (!order) {
order = {
customer: login,
pizzaList: [],
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;
return current; await storage.setData(today, clientData);
}); 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);
return storage.updateData<ClientData>(today, (current) => { const clientData = await getClientData(usedDate);
if (!current?.pizzaDay) return current ?? ({} as ClientData);
if (current.pizzaDay.state !== PizzaDayState.CREATED) return current; if (!clientData.pizzaDay) {
const orderIndex = current.pizzaDay.orders!.findIndex(o => o.customer === login); return clientData; // Pizza day neexistuje, není co mazat
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());
return storage.updateData<ClientData>(today, (current) => { const clientData = await getClientData(getToday());
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); if (!clientData.pizzaDay) {
const orderIndex = current.pizzaDay.orders!.findIndex(o => o.customer === login); throw Error("Pizza day pro dnešní den neexistuje");
if (orderIndex < 0) throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login); }
const order = current.pizzaDay.orders![orderIndex]; const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login);
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) throw Error("Objednávka s danými parametry nebyla nalezena"); if (index < 0) {
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) current.pizzaDay.orders!.splice(orderIndex, 1); if (order.pizzaList!.length == 0) {
return current; clientData.pizzaDay.orders!.splice(orderIndex, 1);
}); }
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());
return storage.updateData<ClientData>(today, (current) => { const clientData = await getClientData(getToday());
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); if (!clientData.pizzaDay) {
if (current.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login); throw Error("Pizza day pro dnešden neexistuje");
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);
} }
current.pizzaDay.state = PizzaDayState.LOCKED; clientData.pizzaDay.state = PizzaDayState.LOCKED;
return current; await storage.setData(today, clientData);
}); 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());
return storage.updateData<ClientData>(today, (current) => { const clientData = await getClientData(getToday());
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); if (!clientData.pizzaDay) {
if (current.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login); throw Error("Pizza day pro dnešden neexistuje");
if (current.pizzaDay.state !== PizzaDayState.LOCKED) throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED); }
current.pizzaDay.state = PizzaDayState.CREATED; if (clientData.pizzaDay.creator !== login) {
return current; throw Error("Pizza day není spravován uživatelem " + login);
}); }
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 result = await storage.updateData<ClientData>(today, (current) => { const clientData = await getClientData(getToday());
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); if (!clientData.pizzaDay) {
if (current.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login); throw Error("Pizza day pro dnešden neexistuje");
if (current.pizzaDay.state !== PizzaDayState.LOCKED) throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED); }
current.pizzaDay.state = PizzaDayState.ORDERED; if (clientData.pizzaDay.creator !== login) {
return current; throw Error("Pizza day není spravován uživatelem " + login);
}); }
callNotifikace({ input: { udalost: UdalostEnum.OBJEDNANA_PIZZA, user: result.pizzaDay!.creator! } }); if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
return result; throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
}
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í 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) throw Error("Pizza day pro dnešní den neexistuje"); if (!clientData.pizzaDay) {
if (clientData.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login); throw Error("Pizza day pro dnešden neexistuje");
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;
// Generujeme QR kódy před atomickým zápisem // Vygenerujeme QR kód, pokud k tomu máme data
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) { if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const message = order.pizzaList!.map(item => let message = order.pizzaList!.map(item =>
item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})` item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})`
).join(', '); ).join(', ');
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice / 100, message, id); await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message, id);
pendingQrs.push({ order.hasQr = true;
customer: order.customer, id, pendingQr: { // Uložíme nevyřízený QR kód pro persistentní zobrazení
id, date: today, creator: login, totalPrice: order.totalPrice, purpose: message, await addPendingQr(order.customer, {
}, id,
date: today,
creator: login,
totalPrice: order.totalPrice,
purpose: message,
}); });
} }
} }
} }
await storage.setData(today, clientData);
const result = await storage.updateData<ClientData>(today, (current) => { return clientData;
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());
return storage.updateData<ClientData>(today, (current) => { let clientData = await getClientData(getToday());
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); if (!clientData.pizzaDay) {
if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED); throw Error("Pizza day pro dnešden neexistuje");
const myOrder = current.pizzaDay.orders!.find(o => o.customer === login); }
if (!myOrder?.pizzaList?.length) throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login); if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
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;
return current; await storage.setData(today, clientData);
}); 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());
return storage.updateData<ClientData>(today, (current) => { let clientData = await getClientData(getToday());
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); if (!clientData.pizzaDay) {
if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`); throw Error("Pizza day pro dnešden neexistuje");
if (current.pizzaDay.creator !== login) throw Error("Příplatky může měnit pouze zakladatel Pizza day"); }
const targetOrder = current.pizzaDay.orders!.find(o => o.customer === targetLogin); if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
if (!targetOrder?.pizzaList?.length) throw Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`); throw Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`);
}
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 };
} }
targetOrder.totalPrice = targetOrder.pizzaList.reduce((p, o) => p + o.price, 0) + (targetOrder.fee?.price ?? 0); // Přepočet ceny
return current; targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price ?? 0);
}); 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> {
await storage.updateData<PendingQr[]>(getPendingQrKey(login), (current) => { const key = getPendingQrKey(login);
const existing = current ?? []; const existing = await storage.getData<PendingQr[]>(key) ?? [];
if (!existing.some(qr => qr.id === pendingQr.id)) existing.push(pendingQr); // Deduplikace podle id (ne podle data — jeden den může mít uživatel víc QR kódů)
return existing; if (!existing.some(qr => qr.id === pendingQr.id)) {
}); 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)) ?? [];
} }
export async function dismissPendingQr(login: string, id: string): Promise<PendingQr | undefined> { /**
let dismissed: PendingQr | undefined; * Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených).
await storage.updateData<PendingQr[]>(getPendingQrKey(login), (current) => { */
const existing = current ?? []; export async function dismissPendingQr(login: string, id: string): Promise<void> {
dismissed = existing.find(qr => qr.id === id); const key = getPendingQrKey(login);
return existing.filter(qr => qr.id !== id); const existing = await storage.getData<PendingQr[]>(key) ?? [];
}); const filtered = existing.filter(qr => qr.id !== id);
return dismissed; await storage.setData(key, filtered);
}
export async function removePendingQrsByGroupId(logins: string[], groupId: string): Promise<void> {
for (const login of logins) {
await storage.updateData<PendingQr[]>(getPendingQrKey(login), (current) => {
return (current ?? []).filter(qr => qr.groupId !== groupId);
});
}
} }
+54 -66
View File
@@ -1,16 +1,11 @@
import webpush from 'web-push'; import webpush from 'web-push';
import crypto from 'crypto';
import getStorage from './storage'; import getStorage from './storage';
import { createLeaderLease } from './leaderLease';
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 lease = createLeaderLease('luncher:reminder:leader');
const POD_ID = process.env.POD_ID ?? `local-${process.pid}`;
interface RegistryEntry { interface RegistryEntry {
time: string; time: string;
@@ -19,12 +14,13 @@ interface RegistryEntry {
type Registry = Record<string, RegistryEntry>; type Registry = Record<string, RegistryEntry>;
/** Mapa login → timestamp (ms) posledního odeslání připomínky. */ /** Mapa login → datum (YYYY-MM-DD), kdy byl uživatel naposledy upozorněn. */
const lastReminded = new Map<string, number>(); const remindedToday = new Map<string, string>();
const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami function getTodayDateString(): string {
const now = new Date();
let reminderInterval: ReturnType<typeof setInterval> | undefined; return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
function getCurrentTimeHHMM(): string { function getCurrentTimeHHMM(): string {
const now = new Date(); const now = new Date();
@@ -42,37 +38,28 @@ function userHasChoice(choices: LunchChoices, login: string): boolean {
return false; return false;
} }
/** Uvolní leader lease při graceful shutdown. */ async function getRegistry(): Promise<Registry> {
export async function releaseReminderLease(): Promise<void> { return await storage.getData<Registry>(REGISTRY_KEY) ?? {};
await lease.release();
} }
/** Stopne scheduler připomínek. Volá se při graceful shutdown. */ async function saveRegistry(registry: Registry): Promise<void> {
export function stopReminderScheduler(): void { await storage.setData(REGISTRY_KEY, registry);
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> {
await storage.updateData<Registry>(REGISTRY_KEY, (current) => { const registry = await getRegistry();
const registry = current ?? {};
registry[login] = { time: reminderTime, subscription }; registry[login] = { time: reminderTime, subscription };
return registry; await saveRegistry(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> {
await storage.updateData<Registry>(REGISTRY_KEY, (current) => { const registry = await getRegistry();
const registry = current ?? {};
delete registry[login]; delete registry[login];
return registry; await saveRegistry(registry);
}); remindedToday.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`);
} }
@@ -81,33 +68,34 @@ export function getVapidPublicKey(): string | undefined {
return process.env.VAPID_PUBLIC_KEY; return process.env.VAPID_PUBLIC_KEY;
} }
function generateQuickChoiceToken(login: string): string { /** Najde login uživatele podle push subscription endpointu. */
const today = new Date().toISOString().slice(0, 10); export async function findLoginByEndpoint(endpoint: string): Promise<string | undefined> {
const secret = process.env.JWT_SECRET ?? ''; const registry = await getRegistry();
return crypto.createHmac('sha256', secret).update(`${login}:${today}`).digest('hex'); for (const [login, entry] of Object.entries(registry)) {
} if (entry.subscription.endpoint === endpoint) {
return login;
/** Ověří jednorázový token z push notifikace. */ }
export function verifyQuickChoiceToken(login: string, token: string): boolean { }
if (!login || !token || token.length !== 64) return false; return undefined;
const expected = generateQuickChoiceToken(login);
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> {
if (getIsWeekend(getToday())) return; // Přeskočit víkendy
if (getIsWeekend(getToday())) {
return;
}
// Leader election — pouze jeden pod spouští připomínky const registry = await getRegistry();
const isLeader = await lease.tryAcquireOrRenew();
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) return; if (entries.length === 0) {
return;
}
const currentTime = getCurrentTimeHHMM(); const currentTime = getCurrentTimeHHMM();
const todayStr = getTodayDateString();
// 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());
@@ -116,45 +104,44 @@ async function checkAndSendReminders(): Promise<void> {
return; return;
} }
const expiredLogins: string[] = [];
for (const [login, entry] of entries) { for (const [login, entry] of entries) {
if (currentTime < entry.time) continue; // Ještě nedosáhl čas připomínky
if (currentTime < entry.time) {
continue;
}
const last = lastReminded.get(login) ?? 0; // Už jsme dnes připomenuli
if (Date.now() - last < REMINDER_COOLDOWN_MS) continue; if (remindedToday.get(login) === todayStr) {
continue;
}
if (clientData.choices && userHasChoice(clientData.choices, login)) continue; // Uživatel už má zvolenou možnost
if (clientData.choices && userHasChoice(clientData.choices, login)) {
continue;
}
// Odešleme push notifikaci
try { try {
await webpush.sendNotification( await webpush.sendNotification(
entry.subscription, entry.subscription,
JSON.stringify({ JSON.stringify({
title: 'Luncher', title: 'Luncher',
body: 'Ještě nemáte zvolený oběd!', body: 'Ještě nemáte zvolený oběd!',
login,
token: generateQuickChoiceToken(login),
}) })
); );
lastReminded.set(login, Date.now()); remindedToday.set(login, todayStr);
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`);
expiredLogins.push(login); delete registry[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. */
@@ -170,6 +157,7 @@ export function startReminderScheduler(): void {
webpush.setVapidDetails(subject, publicKey, privateKey); webpush.setVapidDetails(subject, publicKey, privateKey);
reminderInterval = setInterval(checkAndSendReminders, 60_000); // Spustíme kontrolu každou minutu
console.log(`Push reminder: scheduler spuštěn (POD_ID=${POD_ID})`); setInterval(checkAndSendReminders, 60_000);
console.log('Push reminder: scheduler spuštěn');
} }
+11 -23
View File
@@ -14,7 +14,7 @@ const storage = getStorage();
* *
* @param bankAccountNumber číslo účtu ve formátu BBAN (123456-0123456789/0100) * @param bankAccountNumber číslo účtu ve formátu BBAN (123456-0123456789/0100)
*/ */
export function convertBbanToIban(bankAccountNumber: string): string { function convertBbanToIban(bankAccountNumber: string): string {
// TODO validovat číslo účtu stejně jako na klientovi, pro případ že sem někdo pošle nesmysl // TODO validovat číslo účtu stejně jako na klientovi, pro případ že sem někdo pošle nesmysl
let prefix: string = ''; let prefix: string = '';
let accountNumber: string = bankAccountNumber; let accountNumber: string = bankAccountNumber;
@@ -31,10 +31,10 @@ export function convertBbanToIban(bankAccountNumber: string): string {
// Zatím napevno, nemá smysl řešit nic jiného než CZ // Zatím napevno, nemá smysl řešit nic jiného než CZ
iban = iban.replace('C', '12').replace('Z', '35'); iban = iban.replace('C', '12').replace('Z', '35');
const remainder = BigInt(iban) % BigInt(97); const remainder = BigInt(iban) % BigInt(97);
const checkDigits = (BigInt(98) - remainder).toString().padStart(2, '0'); const checkDigits = BigInt(98) - remainder;
iban = `${COUNTRY_CODE}${checkDigits}${bankCode}${prefix}${accountNumber}`; iban = `${COUNTRY_CODE}${checkDigits.toString()}${bankCode}${prefix}${accountNumber}`;
if (iban.length !== 24) { if (iban.length !== 24) {
throw new Error("Neplatná délka sestaveného IBAN: " + iban.length + ", očekáváno 24"); throw Error("Neplatná délka sestaveného IBAN: " + iban.length + ", očekáváno 24");
} }
return iban; return iban;
} }
@@ -44,24 +44,6 @@ function createStorageKey(customerName: string, id: string): string {
return `qr_${nameHash}_${id}`; return `qr_${nameHash}_${id}`;
} }
/**
* Očistí zprávu (účel platby) pro QR platbu:
* - transliteruje diakritiku na základní písmena (šs, čc, řr, ...)
* - odstraní zbylé znaky mimo ISO 8859-1
* - odstraní '*', který v QR platbě slouží jako oddělovač polí
* - ořízne na max. 60 znaků
*
* @param message původní zpráva
* @returns očištěná zpráva vhodná pro QR platbu
*/
export function sanitizeQrMessage(message: string): string {
const sanitized = message
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // diakritika → základní písmeno
.replace(/[^\x00-\xff]/g, '') // znaky mimo ISO 8859-1
.replace(/\*/g, ''); // '*' je v QR platbě oddělovač
return sanitized.length > 60 ? sanitized.substring(0, 60) : sanitized;
}
/** /**
* Vygeneruje a uloží obrázek platebního QR kódu do storage (Redis/JSON). * Vygeneruje a uloží obrázek platebního QR kódu do storage (Redis/JSON).
* Data přežijí redeploy není třeba persistentní filesystém. * Data přežijí redeploy není třeba persistentní filesystém.
@@ -74,7 +56,13 @@ export function sanitizeQrMessage(message: 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> {
message = sanitizeQrMessage(message); // Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků
if (message.indexOf('*') >= 0) {
message = message.replace('*', '');
}
if (message.length > 60) {
message = message.substring(0, 60);
}
const payload = { const payload = {
iban: convertBbanToIban(bankAccountNumber), iban: convertBbanToIban(bankAccountNumber),
amount, amount,
+1 -56
View File
@@ -314,65 +314,11 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
} }
const result: Food[][] = []; const result: Food[][] = [];
const siblings = thirdTry
// Čtvrtý pokus (detekce): thirdTry našel <font>, ale nový formát má každý den v jednom <p>
// s položkami oddělenými <br> místo separátních <p> pro každou položku
const fourthTry = thirdTry && $(font).parent().siblings('p').toArray().some(el => {
const firstChild = $(el).contents().first();
return firstChild.is('strong') && DAYS_IN_WEEK.includes(firstChild.text().trim().toLocaleLowerCase());
});
const siblings = (fourthTry || thirdTry)
? $(font).parent().siblings('p') ? $(font).parent().siblings('p')
: secondTry : secondTry
? $(font).parent().parent().parent().siblings('p') ? $(font).parent().parent().parent().siblings('p')
: $(font).parent().parent().siblings(); : $(font).parent().parent().siblings();
if (fourthTry) {
siblings.each((_, el) => {
const $el = $(el);
const firstChild = $el.contents().first();
if (!firstChild.is('strong')) return;
const dayName = firstChild.text().trim().toLocaleLowerCase();
if (!DAYS_IN_WEEK.includes(dayName)) return;
const dayIndex = DAYS_IN_WEEK.indexOf(dayName);
result[dayIndex] ??= [];
const elHtml = $el.html() ?? '';
const itemLines = elHtml.split(/<br\s*\/?>/i).slice(1)
.map(part => $(`<span>${part}</span>`).text().trim())
.filter(line => line.length > 0);
for (const text of itemLines) {
let price = 'na\xA0váhu';
let nameRaw = text.replace('•', '');
if (text.toLowerCase().endsWith('kč')) {
const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
price = `${split.slice(1)[0]}\xA0Kč`;
nameRaw = split[0].replace('•', '');
} else if (text.toLowerCase().endsWith(',-')) {
const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -1).join(' ')].concat(tmp.slice(-1));
price = `${split.slice(1)[0].replace(',-', '')}\xA0Kč`;
nameRaw = split[0].replace('•', '');
}
if (nameRaw.endsWith('') || nameRaw.endsWith('—')) {
nameRaw = nameRaw.slice(0, -1).trim();
}
const parsed = parseAllergens(nameRaw);
result[dayIndex].push({
amount: '-',
name: parsed.cleanName,
price,
isSoup: isTextSoupName(parsed.cleanName),
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
});
}
});
} else {
let parsing = false; let parsing = false;
let currentDayIndex = 0; let currentDayIndex = 0;
for (let i = 0; i < siblings.length; i++) { for (let i = 0; i < siblings.length; i++) {
@@ -417,7 +363,6 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
}) })
} }
} }
}
// Validace, zda text hlavičky obsahuje datum odpovídající požadovanému týdnu // Validace, zda text hlavičky obsahuje datum odpovídající požadovanému týdnu
const headerText = $(font).text().trim(); const headerText = $(font).text().trim();
+2 -3
View File
@@ -141,9 +141,8 @@ router.post("/clear", async (req: Request<{}, any, any>, res, next) => {
const dateKey = formatDate(date); const dateKey = formatDate(date);
const data = await storage.getData<any>(dateKey); const data = await storage.getData<any>(dateKey);
// Vymažeme všechny volby i aktivní pizza day // Vymažeme všechny volby
data.choices = {}; data.choices = {};
delete data.pizzaDay;
await storage.setData(dateKey, data); await storage.setData(dateKey, data);
@@ -189,7 +188,7 @@ router.post("/testPush", async (req, res, next) => {
webpush.setVapidDetails(subject, publicKey, privateKey); webpush.setVapidDetails(subject, publicKey, privateKey);
await webpush.sendNotification( await webpush.sendNotification(
entry.subscription, entry.subscription,
JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!', login }) JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!' })
); );
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
+10 -28
View File
@@ -4,7 +4,7 @@ import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices,
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils"; import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
import { getWebsocket } from "../websocket"; import { getWebsocket } from "../websocket";
import { callNotifikace } from "../notifikace"; import { callNotifikace } from "../notifikace";
import { AddChoiceData, ChangeDepartureTimeData, MealSlot, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen"; import { AddChoiceData, ChangeDepartureTimeData, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen";
// RateLimit na refresh endpoint // RateLimit na refresh endpoint
@@ -56,34 +56,24 @@ function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean {
*/ */
const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"]>) => { const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"]>) => {
if (req.body.dayIndex == null) { if (req.body.dayIndex == null) {
throw new Error(`Nebyl předán index dne v týdnu.`); throw Error(`Nebyl předán index dne v týdnu.`);
} }
const todayDayIndex = getDayOfWeekIndex(getToday()); const todayDayIndex = getDayOfWeekIndex(getToday());
const dayIndex = req.body.dayIndex; const dayIndex = req.body.dayIndex;
if (isNaN(dayIndex)) { if (isNaN(dayIndex)) {
throw new Error(`Neplatný index dne v týdnu: ${req.body.dayIndex}`); throw Error(`Neplatný index dne v týdnu: ${req.body.dayIndex}`);
} }
if (dayIndex < todayDayIndex) { if (dayIndex < todayDayIndex) {
throw new Error(`Předaný index dne v týdnu (${dayIndex}) nesmí být nižší než dnešní den (${todayDayIndex})`); throw Error(`Předaný index dne v týdnu (${dayIndex}) nesmí být nižší než dnešní den (${todayDayIndex})`);
} }
return dayIndex; return dayIndex;
} }
const parseSlot = (body: Record<string, any>): MealSlot | undefined => {
const slot = body?.slot;
if (slot != null && slot !== MealSlot.OBED) {
throw new Error(`Neplatný slot: ${slot}`);
}
return slot ?? undefined;
};
const router = express.Router(); const router = express.Router();
router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => { router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
let slot: MealSlot | undefined;
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
let date = undefined; let date = undefined;
if (req.body.dayIndex != null) { if (req.body.dayIndex != null) {
let dayIndex; let dayIndex;
@@ -95,7 +85,7 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
date = getDateForWeekIndex(dayIndex); date = getDateForWeekIndex(dayIndex);
} }
try { try {
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date, slot); const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
return res.status(200).json(data); return res.status(200).json(data);
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
@@ -104,8 +94,6 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => { router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
let slot: MealSlot | undefined;
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
let date = undefined; let date = undefined;
if (req.body.dayIndex != null) { if (req.body.dayIndex != null) {
let dayIndex; let dayIndex;
@@ -117,7 +105,7 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
date = getDateForWeekIndex(dayIndex); date = getDateForWeekIndex(dayIndex);
} }
try { try {
const data = await removeChoices(login, trusted, req.body.locationKey, date, slot); const data = await removeChoices(login, trusted, req.body.locationKey, date);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
res.status(200).json(data); res.status(200).json(data);
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
@@ -126,8 +114,6 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => { router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
let slot: MealSlot | undefined;
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
let date = undefined; let date = undefined;
if (req.body.dayIndex != null) { if (req.body.dayIndex != null) {
let dayIndex; let dayIndex;
@@ -139,7 +125,7 @@ router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body
date = getDateForWeekIndex(dayIndex); date = getDateForWeekIndex(dayIndex);
} }
try { try {
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date, slot); const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
res.status(200).json(data); res.status(200).json(data);
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
@@ -149,11 +135,9 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
const note = req.body.note; const note = req.body.note;
let slot: MealSlot | undefined;
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
try { try {
if (note && note.length > 70) { if (note && note.length > 70) {
throw new Error("Poznámka může mít maximálně 70 znaků"); throw Error("Poznámka může mít maximálně 70 znaků");
} }
let date = undefined; let date = undefined;
if (req.body.dayIndex != null) { if (req.body.dayIndex != null) {
@@ -165,7 +149,7 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
} }
date = getDateForWeekIndex(dayIndex); date = getDateForWeekIndex(dayIndex);
} }
const data = await updateNote(login, trusted, note, date, slot); const data = await updateNote(login, trusted, note, date);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
res.status(200).json(data); res.status(200).json(data);
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
@@ -200,10 +184,8 @@ router.post("/jdemeObed", async (req, res, next) => {
router.post("/updateBuyer", async (req, res, next) => { router.post("/updateBuyer", async (req, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
let slot: MealSlot | undefined;
try { slot = parseSlot(req.body ?? {}); } catch (e: any) { return res.status(400).json({ error: e.message }); }
try { try {
const data = await updateBuyer(login, slot); const data = await updateBuyer(login);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
res.status(200).json({}); res.status(200).json({});
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
-182
View File
@@ -1,182 +0,0 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getWebsocket } from "../websocket";
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupBoltTracking, getOrderDates } from "../groups";
import { GroupState } from "../../../types/gen/types.gen";
import { checkBoltTracking } from "../boltTracking";
const router = express.Router();
function broadcastExtra(data: any) {
getWebsocket().emit("message", data);
}
router.get("/dates", async (_req, res, next) => {
try {
const dates = await getOrderDates();
res.status(200).json({ dates });
} catch (e: any) { next(e); }
});
router.post("/create", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { name } = req.body ?? {};
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Nebyl předán název skupiny' });
}
try {
const data = await createGroup(login, name);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
router.post("/delete", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
try {
const data = await deleteGroup(login, id);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
router.post("/addMember", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id, login: targetLogin } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
if (targetLogin !== undefined && (typeof targetLogin !== 'string' || targetLogin.trim() === '')) {
return res.status(400).json({ error: 'Neplatný login uživatele' });
}
const target = targetLogin ?? login;
try {
const data = await addGroupMember(login, id, target);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
router.post("/removeMember", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id, login: targetLogin } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
try {
const data = await removeGroupMember(login, id, targetLogin);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
router.post("/updateMember", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id, login: targetLogin, amount, note, surchargeText, surchargeAmount } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
const patch: Record<string, any> = {};
if (amount !== undefined) {
if (!Number.isInteger(amount) || amount < 0) {
return res.status(400).json({ error: 'Neplatná částka' });
}
patch.amount = amount;
}
if (note !== undefined) {
if (typeof note !== 'string') return res.status(400).json({ error: 'Neplatná poznámka' });
patch.note = note;
}
if (surchargeText !== undefined) {
if (typeof surchargeText !== 'string') return res.status(400).json({ error: 'Neplatný text příplatku' });
patch.surchargeText = surchargeText;
}
if (surchargeAmount !== undefined) {
if (!Number.isInteger(surchargeAmount) || surchargeAmount < 0) {
return res.status(400).json({ error: 'Neplatná výše příplatku' });
}
patch.surchargeAmount = surchargeAmount;
}
try {
const data = await updateGroupMember(login, id, targetLogin, patch);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
router.post("/setState", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id, state } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
if (!state || !Object.values(GroupState).includes(state)) {
return res.status(400).json({ error: 'Neplatný stav skupiny' });
}
try {
const data = await setGroupState(login, id, state as GroupState);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
router.post("/updateFees", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
if (fees !== undefined && (!Number.isInteger(fees) || fees < 0)) {
return res.status(400).json({ error: 'Neplatná výše poplatků' });
}
if (shipping !== undefined && (!Number.isInteger(shipping) || shipping < 0)) {
return res.status(400).json({ error: 'Neplatná výše dopravy' });
}
if (tip !== undefined && (!Number.isInteger(tip) || tip < 0)) {
return res.status(400).json({ error: 'Neplatná výše spropitného' });
}
if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) {
return res.status(400).json({ error: 'Neplatný typ slevy' });
}
if (discountValue !== undefined && (!Number.isInteger(discountValue) || discountValue < 0)) {
return res.status(400).json({ error: 'Neplatná výše slevy' });
}
try {
const data = await updateGroupFees(login, id, fees, shipping, tip, discountType, discountValue);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
router.post("/updateTimes", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id, orderedAt, deliveryAt } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
const timeRegex = /^([01]\d|2[0-3]):[0-5]\d$/;
if (orderedAt !== undefined && orderedAt !== '' && !timeRegex.test(orderedAt)) {
return res.status(400).json({ error: 'Neplatný formát času objednání (očekáváno HH:MM)' });
}
if (deliveryAt !== undefined && deliveryAt !== '' && !timeRegex.test(deliveryAt)) {
return res.status(400).json({ error: 'Neplatný formát času doručení (očekáváno HH:MM)' });
}
try {
const data = await updateGroupTimes(login, id, orderedAt, deliveryAt);
broadcastExtra(data);
res.status(200).json(data);
} catch (e: any) { next(e); }
});
router.post("/setBoltTracking", async (req: Request, res, next) => {
const login = getLogin(parseToken(req));
const { id, shareUrl } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
if (shareUrl !== undefined && typeof shareUrl !== 'string') {
return res.status(400).json({ error: 'Neplatný odkaz na sledování objednávky Bolt' });
}
try {
const data = await setGroupBoltTracking(login, id, shareUrl);
broadcastExtra(data);
res.status(200).json(data);
// Okamžitý poll, ať uživatel nečeká na další tik scheduleru
if (shareUrl) {
checkBoltTracking().catch(e => console.error('Bolt tracking: okamžitý poll selhal', e));
}
} catch (e: any) { next(e); }
});
export default router;
+20 -1
View File
@@ -2,7 +2,9 @@ import express, { Request } from "express";
import { getLogin } from "../auth"; import { getLogin } from "../auth";
import { parseToken } from "../utils"; import { parseToken } from "../utils";
import { getNotificationSettings, saveNotificationSettings } from "../notifikace"; import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
import { subscribePush, unsubscribePush, getVapidPublicKey } from "../pushReminder"; import { subscribePush, unsubscribePush, getVapidPublicKey, findLoginByEndpoint } from "../pushReminder";
import { addChoice } from "../service";
import { getWebsocket } from "../websocket";
import { UpdateNotificationSettingsData } from "../../../types"; import { UpdateNotificationSettingsData } from "../../../types";
const router = express.Router(); const router = express.Router();
@@ -64,4 +66,21 @@ router.post("/push/unsubscribe", async (req, res, next) => {
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
/** Rychlá akce z push notifikace — nastaví volbu bez JWT (identita přes subscription endpoint). */
router.post("/push/quickChoice", async (req, res, next) => {
try {
const { endpoint } = req.body;
if (!endpoint) {
return res.status(400).json({ error: "Nebyl předán endpoint" });
}
const login = await findLoginByEndpoint(endpoint);
if (!login) {
return res.status(404).json({ error: "Subscription nenalezena" });
}
const data = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
getWebsocket().emit("message", data);
res.status(200).json({});
} catch (e: any) { next(e) }
});
export default router; export default router;
+10 -15
View File
@@ -1,7 +1,6 @@
import express, { Request } from "express"; import express, { Request } from "express";
import { getLogin } from "../auth"; import { getLogin } from "../auth";
import { createPizzaDay, deletePizzaDay, getPizzaList, getSalatList, addPizzaOrder, addSalatOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza"; import { createPizzaDay, deletePizzaDay, getPizzaList, getSalatList, addPizzaOrder, addSalatOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza";
import { markGroupMemberPaid } from "../groups";
import { parseToken } from "../utils"; import { parseToken } from "../utils";
import { getWebsocket } from "../websocket"; import { getWebsocket } from "../websocket";
import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types"; import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types";
@@ -30,10 +29,10 @@ router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) =>
const salatIndex = req.body.salatIndex; const salatIndex = req.body.salatIndex;
const salaty = await getSalatList(); const salaty = await getSalatList();
if (!salaty) { if (!salaty) {
throw new Error("Selhalo získání seznamu dostupných salátů."); throw Error("Selhalo získání seznamu dostupných salátů.");
} }
if (!salaty[salatIndex]) { if (!salaty[salatIndex]) {
throw new Error("Neplatný index salátu: " + salatIndex); throw Error("Neplatný index salátu: " + salatIndex);
} }
const data = await addSalatOrder(login, salaty[salatIndex]); const data = await addSalatOrder(login, salaty[salatIndex]);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
@@ -41,22 +40,22 @@ router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) =>
} else { } else {
// Přidání pizzy // Přidání pizzy
if (req.body?.pizzaIndex === undefined || isNaN(req.body.pizzaIndex)) { if (req.body?.pizzaIndex === undefined || isNaN(req.body.pizzaIndex)) {
throw new Error("Nebyl předán index pizzy ani salátu"); throw Error("Nebyl předán index pizzy ani salátu");
} }
const pizzaIndex = req.body.pizzaIndex; const pizzaIndex = req.body.pizzaIndex;
if (req.body?.pizzaSizeIndex === undefined || isNaN(req.body.pizzaSizeIndex)) { if (req.body?.pizzaSizeIndex === undefined || isNaN(req.body.pizzaSizeIndex)) {
throw new Error("Nebyl předán index velikosti pizzy"); throw Error("Nebyl předán index velikosti pizzy");
} }
const pizzaSizeIndex = req.body.pizzaSizeIndex; const pizzaSizeIndex = req.body.pizzaSizeIndex;
let pizzy = await getPizzaList(); let pizzy = await getPizzaList();
if (!pizzy) { if (!pizzy) {
throw new Error("Selhalo získání seznamu dostupných pizz."); throw Error("Selhalo získání seznamu dostupných pizz.");
} }
if (!pizzy[pizzaIndex]) { if (!pizzy[pizzaIndex]) {
throw new Error("Neplatný index pizzy: " + pizzaIndex); throw Error("Neplatný index pizzy: " + pizzaIndex);
} }
if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) { if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) {
throw new Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex); throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
} }
const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]); const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
@@ -67,7 +66,7 @@ router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) =>
router.post("/remove", async (req: Request<{}, any, RemovePizzaData["body"]>, res) => { router.post("/remove", async (req: Request<{}, any, RemovePizzaData["body"]>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
if (!req.body?.pizzaOrder) { if (!req.body?.pizzaOrder) {
throw new Error("Nebyla předána objednávka"); throw Error("Nebyla předána objednávka");
} }
const data = await removePizzaOrder(login, req.body?.pizzaOrder); const data = await removePizzaOrder(login, req.body?.pizzaOrder);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
@@ -106,7 +105,7 @@ router.post("/updatePizzaDayNote", async (req: Request<{}, any, UpdatePizzaDayNo
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
try { try {
if (req.body.note && req.body.note.length > 70) { if (req.body.note && req.body.note.length > 70) {
throw new Error("Poznámka může mít maximálně 70 znaků"); throw Error("Poznámka může mít maximálně 70 znaků");
} }
const data = await updatePizzaDayNote(login, req.body.note); const data = await updatePizzaDayNote(login, req.body.note);
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
@@ -133,11 +132,7 @@ router.post("/dismissQr", async (req: Request<{}, any, DismissQrData["body"]>, r
return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" }); return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" });
} }
try { try {
const dismissed = await dismissPendingQr(login, req.body.id); await dismissPendingQr(login, req.body.id);
if (dismissed?.groupId) {
const updatedExtra = await markGroupMemberPaid(login, dismissed.groupId);
if (updatedExtra) getWebsocket().emit("message", updatedExtra);
}
res.status(200).json({}); res.status(200).json({});
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
+11 -15
View File
@@ -3,8 +3,6 @@ import { getLogin } from "../auth";
import { parseToken, formatDate } from "../utils"; import { parseToken, formatDate } from "../utils";
import { generateQr } from "../qr"; import { generateQr } from "../qr";
import { addPendingQr } from "../pizza"; import { addPendingQr } from "../pizza";
import { markGroupQrGenerated } from "../groups";
import { emitToUser } from "../websocket";
import { GenerateQrData } from "../../../types"; import { GenerateQrData } from "../../../types";
import crypto from "crypto"; import crypto from "crypto";
@@ -16,7 +14,7 @@ const router = express.Router();
router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res, next) => { router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
try { try {
const { recipients, bankAccount, bankAccountHolder, groupId } = req.body; const { recipients, bankAccount, bankAccountHolder } = req.body;
if (!recipients || !Array.isArray(recipients) || recipients.length === 0) { if (!recipients || !Array.isArray(recipients) || recipients.length === 0) {
return res.status(400).json({ error: "Nebyl předán seznam příjemců" }); return res.status(400).json({ error: "Nebyl předán seznam příjemců" });
@@ -37,29 +35,27 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r
if (!recipient.purpose || recipient.purpose.trim().length === 0) { if (!recipient.purpose || recipient.purpose.trim().length === 0) {
return res.status(400).json({ error: `Příjemce ${recipient.login} nemá vyplněný účel platby` }); return res.status(400).json({ error: `Příjemce ${recipient.login} nemá vyplněný účel platby` });
} }
if (!Number.isInteger(recipient.amount) || recipient.amount <= 0) { if (typeof recipient.amount !== 'number' || recipient.amount <= 0) {
return res.status(400).json({ error: `Příjemce ${recipient.login} má neplatnou částku` }); return res.status(400).json({ error: `Příjemce ${recipient.login} má neplatnou částku` });
} }
// Validace max 2 desetinná místa
const amountStr = recipient.amount.toString();
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
return res.status(400).json({ error: `Částka pro ${recipient.login} má více než 2 desetinná místa` });
}
// Vygenerovat QR kód // Vygenerovat QR kód
const id = crypto.randomUUID(); const id = crypto.randomUUID();
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount / 100, recipient.purpose, id); await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose, id);
// Uložit jako nevyřízený QR kód a okamžitě doručit příjemci // Uložit jako nevyřízený QR kód
const pendingQr = { await addPendingQr(recipient.login, {
id, id,
date: today, date: today,
creator: login, creator: login,
totalPrice: recipient.amount, totalPrice: recipient.amount,
purpose: recipient.purpose, purpose: recipient.purpose,
...(groupId ? { groupId } : {}), });
};
await addPendingQr(recipient.login, pendingQr);
emitToUser(recipient.login, 'pendingQr', pendingQr);
}
if (groupId) {
await markGroupQrGenerated(login, groupId);
} }
res.status(200).json({ success: true, count: recipients.length }); res.status(200).json({ success: true, count: recipients.length });
-54
View File
@@ -1,54 +0,0 @@
import express from "express";
import { getStores, addStore, removeStore } from "../stores";
const router = express.Router();
router.get("/", async (_req, res, next) => {
try {
const stores = await getStores();
res.status(200).json(stores);
} catch (e: any) { next(e); }
});
router.post("/add", async (req, res, next) => {
const { name, heslo, url } = req.body ?? {};
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
}
if (!heslo || typeof heslo !== 'string') {
return res.status(400).json({ error: 'Nebylo předáno heslo' });
}
if (url != null && typeof url !== 'string') {
return res.status(400).json({ error: 'Neplatná URL obchodu' });
}
try {
const stores = await addStore(name, heslo, url);
res.status(200).json(stores);
} catch (e: any) {
if (e.message === 'UNAUTHORIZED') {
return res.status(403).json({ error: 'Nesprávné heslo' });
}
next(e);
}
});
router.post("/delete", async (req, res, next) => {
const { name, heslo } = req.body ?? {};
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
}
if (!heslo || typeof heslo !== 'string') {
return res.status(400).json({ error: 'Nebylo předáno heslo' });
}
try {
const stores = await removeStore(name, heslo);
res.status(200).json(stores);
} catch (e: any) {
if (e.message === 'UNAUTHORIZED') {
return res.status(403).json({ error: 'Nesprávné heslo' });
}
next(e);
}
});
export default router;
-50
View File
@@ -1,50 +0,0 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { addSuggestion, deleteSuggestion, listSuggestions, voteSuggestion } from "../suggestions";
import { AddSuggestionData, VoteSuggestionData, DeleteSuggestionData } from "../../../types";
const router = express.Router();
router.get("/list", async (req: Request, res, next) => {
try {
const login = getLogin(parseToken(req));
const data = await listSuggestions(login);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/add", async (req: Request<{}, any, AddSuggestionData["body"]>, res, next) => {
try {
const login = getLogin(parseToken(req));
if (!req.body?.title || !req.body?.description) {
return res.status(400).json({ error: "Chybné parametry volání" });
}
const data = await addSuggestion(login, req.body.title, req.body.description);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/vote", async (req: Request<{}, any, VoteSuggestionData["body"]>, res, next) => {
try {
const login = getLogin(parseToken(req));
if (!req.body?.id || !req.body?.direction) {
return res.status(400).json({ error: "Chybné parametry volání" });
}
const data = await voteSuggestion(login, req.body.id, req.body.direction);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/delete", async (req: Request<{}, any, DeleteSuggestionData["body"]>, res, next) => {
try {
const login = getLogin(parseToken(req));
if (!req.body?.id) {
return res.status(400).json({ error: "Chybné parametry volání" });
}
const data = await deleteSuggestion(login, req.body.id);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
export default router;
+33
View File
@@ -0,0 +1,33 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getUserVotes, updateFeatureVote, getVotingStats } from "../voting";
import { GetVotesData, UpdateVoteData } from "../../../types";
const router = express.Router();
router.get("/getVotes", async (req: Request<{}, any, GetVotesData["body"]>, res) => {
const login = getLogin(parseToken(req));
const data = await getUserVotes(login);
res.status(200).json(data);
});
router.post("/updateVote", async (req: Request<{}, any, UpdateVoteData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
if (req.body?.option == null || req.body?.active == null) {
res.status(400).json({ error: "Chybné parametry volání" });
}
try {
const data = await updateFeatureVote(login, req.body.option, req.body.active);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.get("/stats", async (req, res, next) => {
try {
const data = await getVotingStats();
res.status(200).json(data);
} catch (e: any) { next(e) }
});
export default router;
+75 -80
View File
@@ -3,17 +3,11 @@ import getStorage from "./storage";
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants"; import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants";
import { getTodayMock } from "./mock"; import { getTodayMock } from "./mock";
import { removeAllUserPizzas } from "./pizza"; import { removeAllUserPizzas } from "./pizza";
import { getStores } from "./stores"; import { ClientData, DepartureTime, LunchChoice, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
import { ClientData, DepartureTime, LunchChoice, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
const storage = getStorage(); const storage = getStorage();
const MENU_PREFIX = 'menu'; const MENU_PREFIX = 'menu';
function getDataKey(date: Date, slot?: MealSlot): string {
const base = formatDate(date);
return slot === MealSlot.EXTRA ? `${base}_extra` : base;
}
/** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */ /** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */
export function getToday(): Date { export function getToday(): Date {
if (process.env.MOCK_DATA === 'true') { if (process.env.MOCK_DATA === 'true') {
@@ -40,7 +34,6 @@ export function getEmptyData(date?: Date): ClientData {
return { return {
todayDayIndex: getDayOfWeekIndex(getToday()), todayDayIndex: getDayOfWeekIndex(getToday()),
date: getHumanDate(usedDate), date: getHumanDate(usedDate),
isoDate: formatDate(usedDate),
isWeekend: getIsWeekend(usedDate), isWeekend: getIsWeekend(usedDate),
dayIndex: getDayOfWeekIndex(usedDate), dayIndex: getDayOfWeekIndex(usedDate),
choices: {}, choices: {},
@@ -50,11 +43,8 @@ export function getEmptyData(date?: Date): ClientData {
/** /**
* Vrátí veškerá klientská data pro předaný den, nebo aktuální den, pokud není předán. * Vrátí veškerá klientská data pro předaný den, nebo aktuální den, pokud není předán.
*/ */
export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData> { export async function getData(date?: Date): Promise<ClientData> {
const clientData = await getClientData(date, slot); const clientData = await getClientData(date);
if (slot === MealSlot.EXTRA) {
clientData.stores = await getStores();
} else {
clientData.menus = { clientData.menus = {
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date), SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date), // UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
@@ -62,7 +52,6 @@ export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData>
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date), ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date), SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
} }
}
return clientData; return clientData;
} }
@@ -301,8 +290,8 @@ function generateMenuWarnings(menu: RestaurantDayMenu): string[] {
* *
* @param date datum * @param date datum
*/ */
export async function initIfNeeded(date?: Date, slot?: MealSlot) { export async function initIfNeeded(date?: Date) {
const usedDate = getDataKey(date ?? getToday(), slot); const usedDate = formatDate(date ?? getToday());
const hasData = await storage.hasData(usedDate); const hasData = await storage.hasData(usedDate);
if (!hasData) { if (!hasData) {
await storage.setData(usedDate, getEmptyData(date || getToday())); await storage.setData(usedDate, getEmptyData(date || getToday()));
@@ -318,19 +307,20 @@ export async function initIfNeeded(date?: Date, slot?: MealSlot) {
* @param date datum, ke kterému se volba vztahuje * @param date datum, ke kterému se volba vztahuje
* @returns * @returns
*/ */
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) {
const selectedDay = getDataKey(date ?? getToday(), slot); const selectedDay = formatDate(date ?? getToday());
// Validate trusted flag against current data before atomic update let data = await getClientData(date);
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); delete data.choices[locationKey][login]
if (locationKey in data.choices && data.choices[locationKey] && login in data.choices[locationKey]) { if (Object.keys(data.choices[locationKey]).length === 0) {
delete data.choices[locationKey][login]; delete data.choices[locationKey]
if (Object.keys(data.choices[locationKey]).length === 0) delete data.choices[locationKey]; }
await storage.setData(selectedDay, data);
}
} }
return data; return data;
});
} }
/** /**
@@ -344,18 +334,20 @@ export async function removeChoices(login: string, trusted: boolean, locationKey
* @param date datum, ke kterému se volba vztahuje * @param date datum, ke kterému se volba vztahuje
* @returns * @returns
*/ */
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) {
const selectedDay = getDataKey(date ?? getToday(), slot); const selectedDay = formatDate(date ?? getToday());
const snapshot = await getClientData(date, slot); let data = await getClientData(date);
validateTrusted(snapshot, login, trusted); validateTrusted(data, login, trusted);
return storage.updateData<ClientData>(selectedDay, (current) => { if (locationKey in data.choices) {
const data = current ?? getEmptyData(date); if (data.choices[locationKey] && login in data.choices[locationKey]) {
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) data.choices[locationKey][login].selectedFoods?.splice(index, 1); if (index != null && index > -1) {
data.choices[locationKey][login].selectedFoods?.splice(index, 1);
await storage.setData(selectedDay, data);
}
}
} }
return data; return data;
});
} }
/** /**
@@ -365,9 +357,9 @@ export async function removeChoice(login: string, trusted: boolean, locationKey:
* @param date datum, ke kterému se volby vztahují * @param date datum, ke kterému se volby vztahují
* @param ignoredLocationKey volba, která nebude odstraněna, pokud existuje * @param ignoredLocationKey volba, která nebude odstraněna, pokud existuje
*/ */
async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: LunchChoice, slot?: MealSlot) { async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: LunchChoice) {
const usedDate = date ?? getToday(); const usedDate = date ?? getToday();
let data = await getClientData(usedDate, slot); let data = await getClientData(usedDate);
for (const key of Object.keys(data.choices)) { for (const key of Object.keys(data.choices)) {
const locationKey = key as LunchChoice; const locationKey = key as LunchChoice;
if (ignoredLocationKey != null && ignoredLocationKey == locationKey) { if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
@@ -378,7 +370,7 @@ async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocation
if (Object.keys(data.choices[locationKey]).length === 0) { if (Object.keys(data.choices[locationKey]).length === 0) {
delete data.choices[locationKey]; delete data.choices[locationKey];
} }
await storage.setData(getDataKey(usedDate, slot), data); await storage.setData(formatDate(usedDate), data);
} }
} }
return data; return data;
@@ -417,14 +409,13 @@ function validateTrusted(data: ClientData, login: string, trusted: boolean) {
* @param date datum, ke kterému se volba vztahuje * @param date datum, ke kterému se volba vztahuje
* @returns aktuální data * @returns aktuální data
*/ */
export async function addChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex?: number, date?: Date, slot?: MealSlot) { export async function addChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex?: number, date?: Date) {
const usedDate = date ?? getToday(); const usedDate = date ?? getToday();
await initIfNeeded(usedDate, slot); await initIfNeeded(usedDate);
let data = await getClientData(usedDate, slot); let data = await getClientData(usedDate);
validateTrusted(data, login, trusted); validateTrusted(data, login, trusted);
await validateFoodIndex(locationKey, foodIndex, date); await validateFoodIndex(locationKey, foodIndex, date);
if (!slot || slot === MealSlot.OBED) {
// Pokud uživatel měl vybranou PIZZA a mění na něco jiného // Pokud uživatel měl vybranou PIZZA a mění na něco jiného
const hadPizzaChoice = data.choices.PIZZA && login in data.choices.PIZZA; const hadPizzaChoice = data.choices.PIZZA && login in data.choices.PIZZA;
if (hadPizzaChoice && locationKey !== LunchChoice.PIZZA && foodIndex == null) { if (hadPizzaChoice && locationKey !== LunchChoice.PIZZA && foodIndex == null) {
@@ -444,16 +435,15 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
// nebo byl již smazán frontendem) // nebo byl již smazán frontendem)
await removeAllUserPizzas(login, usedDate); await removeAllUserPizzas(login, usedDate);
// Znovu načteme data, protože removeAllUserPizzas je upravila // Znovu načteme data, protože removeAllUserPizzas je upravila
data = await getClientData(usedDate, slot); data = await getClientData(usedDate);
}
} }
// Pokud měníme pouze lokaci, mažeme případné předchozí // Pokud měníme pouze lokaci, mažeme případné předchozí
if (foodIndex == null) { if (foodIndex == null) {
data = await removeChoiceIfPresent(login, usedDate, undefined, slot); data = await removeChoiceIfPresent(login, usedDate);
} else { } else {
// Mažeme případné ostatní volby (měla by být maximálně jedna) // Mažeme případné ostatní volby (měla by být maximálně jedna)
data = await removeChoiceIfPresent(login, usedDate, locationKey, slot); data = await removeChoiceIfPresent(login, usedDate, locationKey);
} }
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce // TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
data.choices[locationKey] ??= {}; data.choices[locationKey] ??= {};
@@ -469,7 +459,8 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
if (foodIndex != null && !data.choices[locationKey][login].selectedFoods?.includes(foodIndex)) { if (foodIndex != null && !data.choices[locationKey][login].selectedFoods?.includes(foodIndex)) {
data.choices[locationKey][login].selectedFoods?.push(foodIndex); data.choices[locationKey][login].selectedFoods?.push(foodIndex);
} }
await storage.setData(getDataKey(usedDate, slot), data); const selectedDate = formatDate(usedDate);
await storage.setData(selectedDate, data);
return data; return data;
} }
@@ -483,13 +474,13 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, date?: Date) { async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, date?: Date) {
if (foodIndex != null) { if (foodIndex != null) {
if (typeof foodIndex !== 'number') { if (typeof foodIndex !== 'number') {
throw new Error(`Neplatný index ${foodIndex} typu ${typeof foodIndex}`); throw Error(`Neplatný index ${foodIndex} typu ${typeof foodIndex}`);
} }
if (foodIndex < 0) { if (foodIndex < 0) {
throw new Error(`Neplatný index ${foodIndex}`); throw Error(`Neplatný index ${foodIndex}`);
} }
if (!Object.keys(Restaurant).includes(locationKey)) { if (!Object.keys(Restaurant).includes(locationKey)) {
throw new Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey} nepodporující indexy`); throw Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey} nepodporující indexy`);
} }
const usedDate = date ?? getToday(); const usedDate = date ?? getToday();
const menu = await getRestaurantMenu(locationKey as Restaurant, usedDate); const menu = await getRestaurantMenu(locationKey as Restaurant, usedDate);
@@ -507,20 +498,22 @@ async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, d
* @param note poznámka * @param note poznámka
* @param date datum, ke kterému se volba vztahuje * @param date datum, ke kterému se volba vztahuje
*/ */
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) {
const usedDate = date ?? getToday(); const usedDate = date ?? getToday();
await initIfNeeded(usedDate, slot); await initIfNeeded(usedDate);
const snapshot = await getClientData(usedDate, slot); let data = await getClientData(usedDate);
validateTrusted(snapshot, login, trusted); validateTrusted(data, 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) delete userEntry[1][login].note; if (!note?.length) {
else userEntry[1][login].note = note; delete userEntry[1][login].note;
} else {
userEntry[1][login].note = note;
}
const selectedDate = formatDate(usedDate);
await storage.setData(selectedDate, data);
} }
return data; return data;
});
} }
/** /**
@@ -532,18 +525,21 @@ 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();
if (time?.length && !Object.values<string>(DepartureTime).includes(time)) { let clientData = await getClientData(usedDate);
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}`);
} }
return storage.updateData<ClientData>(getDataKey(usedDate), (current) => { found[login].departureTime = time;
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;
} }
return data; await storage.setData(formatDate(usedDate), clientData);
}); }
return clientData;
} }
/** /**
@@ -552,15 +548,16 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
* *
* @param login přihlašovací jméno uživatele * @param login přihlašovací jméno uživatele
*/ */
export async function updateBuyer(login: string, slot?: MealSlot) { export async function updateBuyer(login: string) {
const usedDate = getToday(); const usedDate = getToday();
return storage.updateData<ClientData>(getDataKey(usedDate, slot), (current) => { let clientData = await getClientData(usedDate);
const data = current ?? getEmptyData(); const userEntry = clientData.choices?.['OBJEDNAVAM']?.[login];
const userEntry = data.choices?.['OBJEDNAVAM']?.[login]; if (!userEntry) {
if (!userEntry) throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\""); 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);
return data; await storage.setData(formatDate(usedDate), clientData);
}); return clientData;
} }
/** /**
@@ -569,14 +566,12 @@ export async function updateBuyer(login: string, slot?: MealSlot) {
* @param date datum pro který vrátit data, pokud není vyplněno, je použit dnešní den * @param date datum pro který vrátit data, pokud není vyplněno, je použit dnešní den
* @returns data pro klienta * @returns data pro klienta
*/ */
export async function getClientData(date?: Date, slot?: MealSlot): Promise<ClientData> { export async function getClientData(date?: Date): Promise<ClientData> {
const targetDate = date ?? getToday(); const targetDate = date ?? getToday();
const dateString = getDataKey(targetDate, slot); const dateString = formatDate(targetDate);
const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date); const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date);
return { return {
...clientData, ...clientData,
todayDayIndex: getDayOfWeekIndex(getToday()), todayDayIndex: getDayOfWeekIndex(getToday()),
isoDate: formatDate(targetDate),
...(slot === MealSlot.EXTRA ? { slot: MealSlot.EXTRA } : {}),
} }
} }
+2 -2
View File
@@ -22,13 +22,13 @@ export async function getStats(startDate: string, endDate: string): Promise<Week
// Dočasná validace, aby to někdo ručně neshodil // Dočasná validace, aby to někdo ručně neshodil
const daysDiff = ((end as any) - (start as any)) / (1000 * 60 * 60 * 24); const daysDiff = ((end as any) - (start as any)) / (1000 * 60 * 60 * 24);
if (daysDiff > 4) { if (daysDiff > 4) {
throw new Error('Neplatný rozsah'); throw Error('Neplatný rozsah');
} }
const today = new Date(); const today = new Date();
today.setHours(23, 59, 59, 999); today.setHours(23, 59, 59, 999);
if (end > today) { if (end > today) {
throw new Error('Nelze načíst statistiky pro budoucí datum'); throw Error('Nelze načíst statistiky pro budoucí datum');
} }
const result = []; const result = [];
+19 -16
View File
@@ -1,29 +1,32 @@
/** /**
* 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>;
/**
* Vrátí seznam všech klíčů, případně jen těch obsahujících předaný podřetězec.
* @param contains volitelný podřetězec, který musí klíč obsahovat (např. '_extra')
*/
listKeys(contains?: string): Promise<string[]>;
/**
* 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>;
} }
+1 -5
View File
@@ -3,24 +3,20 @@ import path from 'path';
import { StorageInterface } from "./StorageInterface"; import { StorageInterface } from "./StorageInterface";
import JsonStorage from "./json"; import JsonStorage from "./json";
import RedisStorage from "./redis"; import RedisStorage from "./redis";
import MemoryStorage from "./memory";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../../.env.${ENVIRONMENT}`) }); dotenv.config({ path: path.resolve(__dirname, `../../.env.${ENVIRONMENT}`) });
const JSON_KEY = 'json'; const JSON_KEY = 'json';
const REDIS_KEY = 'redis'; const REDIS_KEY = 'redis';
const MEMORY_KEY = 'memory';
let storage: StorageInterface; let storage: StorageInterface;
if (!process.env.STORAGE || process.env.STORAGE?.toLowerCase() === JSON_KEY) { if (!process.env.STORAGE || process.env.STORAGE?.toLowerCase() === JSON_KEY) {
storage = new JsonStorage(); storage = new JsonStorage();
} else if (process.env.STORAGE?.toLowerCase() === REDIS_KEY) { } else if (process.env.STORAGE?.toLowerCase() === REDIS_KEY) {
storage = new RedisStorage(); storage = new RedisStorage();
} else if (process.env.STORAGE?.toLowerCase() === MEMORY_KEY) {
storage = new MemoryStorage();
} else { } else {
throw new Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json', 'redis' nebo 'memory'"); throw Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json' nebo 'redis'");
} }
export const storageReady: Promise<void> = storage.initialize export const storageReady: Promise<void> = storage.initialize
+1 -16
View File
@@ -6,6 +6,7 @@ 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 });
} }
@@ -28,20 +29,4 @@ export default class JsonStorage implements StorageInterface {
db.set(key, data); db.set(key, data);
return Promise.resolve(); return Promise.resolve();
} }
listKeys(contains?: string): Promise<string[]> {
const keys = Object.keys(db.JSON());
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
}
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);
}
} }
-43
View File
@@ -1,43 +0,0 @@
import { StorageInterface } from "./StorageInterface";
const store = new Map<string, unknown>();
/** Vymaže všechna data z in-memory úložiště. Slouží k resetu mezi testy. */
export function resetMemoryStorage(): void {
store.clear();
}
/**
* In-memory implementace úložiště. Používá se výhradně v testovacím prostředí.
*/
export default class MemoryStorage implements StorageInterface {
hasData(key: string): Promise<boolean> {
return Promise.resolve(store.has(key));
}
getData<Type>(key: string): Promise<Type | undefined> {
return Promise.resolve(store.get(key) as Type | undefined);
}
setData<Type>(key: string, data: Type): Promise<void> {
store.set(key, data);
return Promise.resolve();
}
listKeys(contains?: string): Promise<string[]> {
const keys = Array.from(store.keys());
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
}
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);
}
}
+2 -52
View File
@@ -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}` }) as RedisClientType; client = createClient({ url: `redis://${HOST}:${PORT}` });
} }
async initialize() { async initialize() {
@@ -29,56 +29,6 @@ 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 listKeys(contains?: string): Promise<string[]> {
// SCAN je bezpečnější než KEYS na produkci (neblokuje server)
const match = contains ? `*${contains}*` : '*';
const keys: string[] = [];
for await (const key of client.scanIterator({ MATCH: match, COUNT: 100 })) {
// node-redis v4 vrací buď string, nebo (novější verze) pole stringů
if (Array.isArray(key)) keys.push(...key);
else keys.push(key);
}
return keys;
}
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();
}
-73
View File
@@ -1,73 +0,0 @@
import { Store } from "../../types/gen/types.gen";
import getStorage from "./storage";
const storage = getStorage();
const STORES_KEY = 'stores';
/**
* Vrátí seznam povolených obchodů. Zachovává zpětnou kompatibilitu se starším
* formátem, kdy byly obchody uloženy jako pole řetězců (převede je na objekty).
*/
export async function getStores(): Promise<Store[]> {
const raw = await storage.getData<(string | Store)[]>(STORES_KEY);
if (!raw) {
return [];
}
return raw.map(s => (typeof s === 'string' ? { name: s } : s));
}
/**
* Přidá nový obchod do seznamu povolených.
*
* @param name název obchodu
* @param heslo admin heslo
* @param url volitelná URL na nabídku podniku
*/
export async function addStore(name: string, heslo: string, url?: string): Promise<Store[]> {
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword || heslo !== adminPassword) {
throw new Error('UNAUTHORIZED');
}
const trimmed = name.trim();
if (!trimmed) {
throw new Error('Název obchodu nesmí být prázdný');
}
const stores = await getStores();
if (stores.some(s => s.name.toLowerCase() === trimmed.toLowerCase())) {
throw new Error('Obchod s tímto názvem již existuje');
}
const trimmedUrl = url?.trim();
if (trimmedUrl) {
// Povolíme pouze http(s), aby URL nemohla být zneužita (např. javascript: → XSS)
let parsed: URL;
try {
parsed = new URL(trimmedUrl);
} catch {
throw new Error('Neplatná URL obchodu');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('URL musí začínat http:// nebo https://');
}
}
const store: Store = trimmedUrl ? { name: trimmed, url: trimmedUrl } : { name: trimmed };
const updated = [...stores, store];
await storage.setData(STORES_KEY, updated);
return updated;
}
/**
* Odebere obchod ze seznamu povolených (dle názvu).
*
* @param name název obchodu
* @param heslo admin heslo
*/
export async function removeStore(name: string, heslo: string): Promise<Store[]> {
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword || heslo !== adminPassword) {
throw new Error('UNAUTHORIZED');
}
const stores = await getStores();
const updated = stores.filter(s => s.name.toLowerCase() !== name.trim().toLowerCase());
await storage.setData(STORES_KEY, updated);
return updated;
}
-148
View File
@@ -1,148 +0,0 @@
import crypto from "crypto";
import { Suggestion, VoteDirection } from "../../types/gen/types.gen";
import getStorage from "./storage";
/** Interní reprezentace návrhu uložená ve storage (včetně seznamů hlasujících). */
interface StoredSuggestion {
id: string;
author: string;
title: string;
description: string;
createdAt: string;
/** Loginy uživatelů hlasujících PRO návrh */
upvoters: string[];
/** Loginy uživatelů hlasujících PROTI návrhu */
downvoters: string[];
/** Příznak vyřešeného (zapracovaného) návrhu - nastavuje se pouze ručním zásahem do dat */
resolved?: boolean;
}
const storage = getStorage();
const STORAGE_KEY = 'suggestions';
/** Načte interní seznam návrhů ze storage. */
async function loadSuggestions(): Promise<StoredSuggestion[]> {
return (await storage.getData<StoredSuggestion[]>(STORAGE_KEY)) ?? [];
}
/**
* Převede interní návrh na DTO pro daného uživatele - skryje seznamy hlasujících
* a doplní hlas přihlášeného uživatele a příznak vlastnictví.
*/
function toDto(suggestion: StoredSuggestion, login: string): Suggestion {
let myVote: VoteDirection | undefined;
if (suggestion.upvoters.includes(login)) {
myVote = 'up';
} else if (suggestion.downvoters.includes(login)) {
myVote = 'down';
}
return {
id: suggestion.id,
author: suggestion.author,
title: suggestion.title,
description: suggestion.description,
voteScore: suggestion.upvoters.length - suggestion.downvoters.length,
myVote,
isMine: suggestion.author === login,
resolved: suggestion.resolved ?? false,
};
}
/**
* Vrátí seznam návrhů jako DTO pro daného uživatele, seřazený sestupně dle skóre
* (při shodě skóre stabilně dle data vytvoření vzestupně).
*
* @param login login přihlášeného uživatele
*/
export async function listSuggestions(login: string): Promise<Suggestion[]> {
const suggestions = await loadSuggestions();
return suggestions
.map(s => toDto(s, login))
.sort((a, b) => b.voteScore - a.voteScore);
}
/**
* Přidá nový návrh. Autorovi se automaticky nastaví hlas pro.
*
* @param login login autora
* @param title název návrhu
* @param description detailní popis návrhu
* @returns aktualizovaný seznam návrhů jako DTO
*/
export async function addSuggestion(login: string, title: string, description: string): Promise<Suggestion[]> {
const trimmedTitle = title?.trim();
const trimmedDescription = description?.trim();
if (!trimmedTitle) {
throw new Error('Název návrhu nesmí být prázdný');
}
if (!trimmedDescription) {
throw new Error('Popis návrhu nesmí být prázdný');
}
const suggestions = await loadSuggestions();
suggestions.push({
id: crypto.randomUUID(),
author: login,
title: trimmedTitle,
description: trimmedDescription,
createdAt: new Date().toISOString(),
// Autor automaticky hlasuje pro svůj návrh
upvoters: [login],
downvoters: [],
});
await storage.setData(STORAGE_KEY, suggestions);
return listSuggestions(login);
}
/**
* Přepne hlas uživatele u návrhu. Klik na již aktivní směr hlas zruší,
* opačný směr stávající hlas přepíše.
*
* @param login login hlasujícího uživatele
* @param id identifikátor návrhu
* @param direction směr hlasu, na který uživatel klikl
* @returns aktualizovaný seznam návrhů jako DTO
*/
export async function voteSuggestion(login: string, id: string, direction: VoteDirection): Promise<Suggestion[]> {
const suggestions = await loadSuggestions();
const suggestion = suggestions.find(s => s.id === id);
if (!suggestion) {
throw new Error('Návrh nebyl nalezen');
}
if (suggestion.resolved) {
throw new Error('Pro vyřešený návrh nelze hlasovat');
}
const hadUp = suggestion.upvoters.includes(login);
const hadDown = suggestion.downvoters.includes(login);
// Nejprve odebereme případný stávající hlas uživatele
suggestion.upvoters = suggestion.upvoters.filter(l => l !== login);
suggestion.downvoters = suggestion.downvoters.filter(l => l !== login);
// Hlas přidáme pouze pokud uživatel neklikl na již aktivní směr (jinak ho jen zrušíme)
if (direction === 'up' && !hadUp) {
suggestion.upvoters.push(login);
} else if (direction === 'down' && !hadDown) {
suggestion.downvoters.push(login);
}
await storage.setData(STORAGE_KEY, suggestions);
return listSuggestions(login);
}
/**
* Smaže návrh včetně všech jeho hlasů. Smazat lze pouze vlastní návrh.
*
* @param login login uživatele požadujícího smazání
* @param id identifikátor návrhu ke smazání
* @returns aktualizovaný seznam návrhů jako DTO
*/
export async function deleteSuggestion(login: string, id: string): Promise<Suggestion[]> {
const suggestions = await loadSuggestions();
const suggestion = suggestions.find(s => s.id === id);
if (!suggestion) {
throw new Error('Návrh nebyl nalezen');
}
if (suggestion.author !== login) {
throw new Error('Smazat lze pouze vlastní návrh');
}
const filtered = suggestions.filter(s => s.id !== id);
await storage.setData(STORAGE_KEY, filtered);
return listSuggestions(login);
}
+2 -6
View File
@@ -1,7 +1,6 @@
import { generateToken, verify, getLogin, getTrusted } from '../auth'; import { generateToken, verify, getLogin, getTrusted } from '../auth';
const VALID_SECRET = 'test-jwt-secret-ktery-ma-alespon-32-znaku'; const VALID_SECRET = 'test-secret-min-32-chars-aaaaaaa!';
const SHORT_SECRET = 'kratky';
beforeEach(() => { beforeEach(() => {
process.env.JWT_SECRET = VALID_SECRET; process.env.JWT_SECRET = VALID_SECRET;
@@ -24,15 +23,12 @@ describe('generateToken', () => {
}); });
test('vyhodí chybu pro příliš krátký JWT_SECRET', () => { test('vyhodí chybu pro příliš krátký JWT_SECRET', () => {
process.env.JWT_SECRET = SHORT_SECRET; process.env.JWT_SECRET = 'short';
expect(() => generateToken('alice')).toThrow('32'); expect(() => generateToken('alice')).toThrow('32');
}); });
test('vyhodí chybu pro prázdný login', () => { test('vyhodí chybu pro prázdný login', () => {
expect(() => generateToken('')).toThrow('login'); expect(() => generateToken('')).toThrow('login');
});
test('vyhodí chybu pro login obsahující jen mezery', () => {
expect(() => generateToken(' ')).toThrow('login'); expect(() => generateToken(' ')).toThrow('login');
}); });
-265
View File
@@ -1,265 +0,0 @@
import axios from 'axios';
import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage';
import { addStore } from '../stores';
import { createGroup, setGroupState, setGroupBoltTracking } from '../groups';
import { extractBoltToken, computeDeliveryHHMM, checkBoltTracking } from '../boltTracking';
import { ClientData, GroupState } from '../../../types/gen/types.gen';
import { formatDate } from '../utils';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
const mockEmit = jest.fn();
jest.mock('../websocket', () => ({
getWebsocket: () => ({ emit: mockEmit }),
}));
const storage = getStorage();
const CREATOR = 'tomas';
const USER = 'petr';
const ADMIN_PW = 'testadmin';
const STORE = 'McDonald\'s';
const TOKEN = '0d521a8be3c4acebb26d8bd5716d91eac67050fb152a899a55fa19bd5ed65f15';
const SHARE_URL = `https://food.bolt.eu/sharedActiveOrder/${TOKEN}`;
function boltResponse(order: object | null) {
return {
data: {
code: 0,
message: 'OK',
data: { orders: order ? [order] : [], baskets: [] },
},
};
}
beforeEach(async () => {
resetMemoryStorage();
jest.clearAllMocks();
process.env.ADMIN_PASSWORD = ADMIN_PW;
await addStore(STORE, ADMIN_PW);
});
afterEach(() => {
delete process.env.ADMIN_PASSWORD;
});
describe('extractBoltToken', () => {
test('přijme plnou share URL', () => {
expect(extractBoltToken(SHARE_URL)).toBe(TOKEN);
});
test('toleruje lomítko, query a hash na konci', () => {
expect(extractBoltToken(`${SHARE_URL}/`)).toBe(TOKEN);
expect(extractBoltToken(`${SHARE_URL}?utm=x`)).toBe(TOKEN);
expect(extractBoltToken(`${SHARE_URL}#sekce`)).toBe(TOKEN);
expect(extractBoltToken(` ${SHARE_URL} `)).toBe(TOKEN);
});
test('přijme samotný token včetně velkých písmen', () => {
expect(extractBoltToken(TOKEN)).toBe(TOKEN);
expect(extractBoltToken(TOKEN.toUpperCase())).toBe(TOKEN.toUpperCase());
});
test('odmítne neplatný vstup', () => {
expect(extractBoltToken('')).toBeNull();
expect(extractBoltToken('nesmysl')).toBeNull();
expect(extractBoltToken('https://food.bolt.eu/sharedActiveOrder/abc123')).toBeNull();
expect(extractBoltToken(`https://food.bolt.eu/sharedActiveOrder/${'z'.repeat(64)}`)).toBeNull();
expect(extractBoltToken(TOKEN.slice(0, 63))).toBeNull();
});
});
describe('computeDeliveryHHMM', () => {
test('přičte sekundy k aktuálnímu času', () => {
expect(computeDeliveryHHMM(1800, new Date('2025-01-10T11:00:00'))).toBe('11:30');
});
test('přechod přes půlnoc', () => {
expect(computeDeliveryHHMM(1200, new Date('2025-01-10T23:50:00'))).toBe('00:10');
});
});
describe('setGroupBoltTracking', () => {
const TODAY = new Date('2025-01-10');
let groupId: string;
beforeEach(async () => {
const d = await createGroup(CREATOR, STORE, TODAY);
groupId = d.groups![0].id;
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
});
test('uloží token ze share URL', async () => {
const d = await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
expect(d.groups![0].boltTrackingToken).toBe(TOKEN);
});
test('prázdná hodnota sledování zruší včetně stavu', async () => {
await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
const d = await setGroupBoltTracking(CREATOR, groupId, '', TODAY);
expect(d.groups![0].boltTrackingToken).toBeUndefined();
expect(d.groups![0].boltOrderState).toBeUndefined();
});
test('nový token vynuluje stav předchozí objednávky', async () => {
await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
await storage.updateData<ClientData>(`2025-01-10_extra`, (current) => {
current!.groups![0].boltOrderState = 'preparing';
return current!;
});
// Stejný token stav nemění
let d = await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
expect(d.groups![0].boltOrderState).toBe('preparing');
// Jiný token stav vynuluje
const otherUrl = `https://food.bolt.eu/sharedActiveOrder/${'b'.repeat(64)}`;
d = await setGroupBoltTracking(CREATOR, groupId, otherUrl, TODAY);
expect(d.groups![0].boltOrderState).toBeUndefined();
expect(d.groups![0].boltTrackingToken).toBe('b'.repeat(64));
});
test('odmítne neplatný odkaz', async () => {
await expect(setGroupBoltTracking(CREATOR, groupId, 'nesmysl', TODAY)).rejects.toThrow('Neplatný odkaz');
});
test('nezakladatel nemůže sledování nastavit', async () => {
await expect(setGroupBoltTracking(USER, groupId, SHARE_URL, TODAY)).rejects.toThrow('zakladatel');
});
test('nelze nastavit mimo stav objednáno', async () => {
const d = await createGroup(CREATOR, STORE, TODAY);
const openGroupId = d.groups![1].id;
await expect(setGroupBoltTracking(CREATOR, openGroupId, SHARE_URL, TODAY)).rejects.toThrow('objednáno');
});
});
describe('checkBoltTracking', () => {
// Scheduler čte vždy dnešní data (getToday), proto se skupiny zakládají bez explicitního data
const extraKey = () => `${formatDate(new Date())}_extra`;
let groupId: string;
beforeEach(async () => {
const d = await createGroup(CREATOR, STORE);
groupId = d.groups![0].id;
await setGroupState(CREATOR, groupId, GroupState.LOCKED);
await setGroupState(CREATOR, groupId, GroupState.ORDERED);
await setGroupBoltTracking(CREATOR, groupId, SHARE_URL);
});
async function getGroup() {
const data = await storage.getData<ClientData>(extraKey());
return data!.groups!.find(g => g.id === groupId)!;
}
test('aktualizuje deliveryAt podle expected_time_to_client_in_seconds', async () => {
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'waiting_preparation', expected_time_to_client_in_seconds: 1800 }));
const before = computeDeliveryHHMM(1800);
await checkBoltTracking();
const after = computeDeliveryHHMM(1800);
const group = await getGroup();
expect([before, after]).toContain(group.deliveryAt);
expect(group.boltOrderState).toBe('waiting_preparation');
expect(group.boltTrackingToken).toBe(TOKEN);
expect(mockEmit).toHaveBeenCalledWith('message', expect.anything());
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.stringContaining('getOrderPolling'),
{ token: TOKEN },
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
);
});
test('nezapisuje, pokud se čas nezměnil', async () => {
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 }));
await checkBoltTracking();
expect(mockEmit).toHaveBeenCalledTimes(1);
await checkBoltTracking();
expect(mockEmit).toHaveBeenCalledTimes(1);
});
test('ukončí sledování po doručení (token smazán, deliveryAt zůstává)', async () => {
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 }));
await checkBoltTracking();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'delivered', expected_time_to_client_in_seconds: 0 }));
await checkBoltTracking();
const group = await getGroup();
expect(group.boltTrackingToken).toBeUndefined();
expect(group.boltOrderState).toBe('delivered');
expect(group.deliveryAt).toMatch(/^\d{2}:\d{2}$/);
});
test('ukončí sledování, když objednávka už neexistuje', async () => {
mockedAxios.post.mockResolvedValue(boltResponse(null));
await checkBoltTracking();
const group = await getGroup();
expect(group.boltTrackingToken).toBeUndefined();
expect(group.boltOrderState).toBe('delivered');
});
test('ukládá stav kurýra (reálná odpověď s waiting_delivery)', async () => {
mockedAxios.post.mockResolvedValue(boltResponse({
order_id: 312222357,
order_state: 'waiting_delivery',
expected_time_to_client_in_seconds: 911,
provider: { provider_id: 82859, state: 'waiting_pickup' },
courier: { courier_id: 1958424, state: 'arrived_to_provider', lat: 49.7, lng: 13.3 },
}));
await checkBoltTracking();
let group = await getGroup();
expect(group.boltOrderState).toBe('waiting_delivery');
expect(group.boltCourierState).toBe('arrived_to_provider');
// Kurýr vyzvedl — změní se jen courier state
mockedAxios.post.mockResolvedValue(boltResponse({
order_state: 'waiting_delivery',
expected_time_to_client_in_seconds: 911,
courier: { state: 'picked_up' },
}));
await checkBoltTracking();
group = await getGroup();
expect(group.boltCourierState).toBe('picked_up');
});
test('aktualizuje boltOrderState při změně stavu beze změny času', async () => {
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'waiting_preparation', expected_time_to_client_in_seconds: 1800 }));
await checkBoltTracking();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 }));
await checkBoltTracking();
const group = await getGroup();
expect(group.boltOrderState).toBe('preparing');
expect(group.boltTrackingToken).toBe(TOKEN);
});
test('chybová odpověď Bolt API (code != 0) se počítá jako selhání', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockedAxios.post.mockResolvedValue({ data: { code: 42, message: 'FAIL' } });
await checkBoltTracking();
const group = await getGroup();
expect(group.boltTrackingToken).toBe(TOKEN);
errorSpy.mockRestore();
});
test('po 10 po sobě jdoucích selháních sledování ukončí', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockedAxios.post.mockRejectedValue(new Error('network down'));
for (let i = 0; i < 9; i++) {
await checkBoltTracking();
}
expect((await getGroup()).boltTrackingToken).toBe(TOKEN);
await checkBoltTracking();
expect((await getGroup()).boltTrackingToken).toBeUndefined();
errorSpy.mockRestore();
});
test('ignoruje skupiny mimo stav objednáno', async () => {
await storage.updateData<ClientData>(extraKey(), (current) => {
const d = current!;
const g = d.groups!.find(x => x.id === groupId)!;
g.state = GroupState.LOCKED;
return d;
});
await checkBoltTracking();
expect(mockedAxios.post).not.toHaveBeenCalled();
});
});
-38
View File
@@ -1,38 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import { downloadSalaty } from '../chefie';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
const fixturesDir = path.join(__dirname, 'fixtures');
const loadFixture = (name: string) => fs.readFileSync(path.join(fixturesDir, name), 'utf-8');
beforeEach(() => {
jest.resetAllMocks();
// První volání = stránka se seznamem salátů, následující volání = jednotlivé stránky salátů
mockedAxios.get = jest.fn()
.mockResolvedValueOnce({ data: loadFixture('chefie-salaty.html') })
.mockResolvedValueOnce({ data: loadFixture('chefie-salat-caesar.html') })
.mockResolvedValueOnce({ data: loadFixture('chefie-salat-recky.html') });
});
test('downloadSalaty vrátí seznam salátů', async () => {
const salaty = await downloadSalaty(false);
expect(salaty).toHaveLength(2);
});
test('saláty mají name a ingredients', async () => {
const salaty = await downloadSalaty(false);
expect(salaty[0].name).toBe('Caesar salát');
expect(salaty[0].ingredients).toContain('Kuřecí maso');
});
test('cena salátu zahrnuje pevný příplatek 13 Kč za obal', async () => {
const salaty = await downloadSalaty(false);
// Caesar sticker price = 129, box = 13 → (129 + 13) * 100 haléřů
expect(salaty[0].price).toBe((129 + 13) * 100);
// Řecký sticker price = 119, box = 13 → (119 + 13) * 100 haléřů
expect(salaty[1].price).toBe((119 + 13) * 100);
});

Some files were not shown because too many files have changed in this diff Show More