146 Commits

Author SHA1 Message Date
Michal Hájek
2f39d04120 Do funkce jdemeObed doplněn parametr locationKey 2025-02-23 01:11:25 +01:00
Michal Hájek
8137ca6fc0 Teamsová notifikace "Jdeme na oběd"
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-22 20:43:34 +01:00
Michal Hájek
3817126ac0 Výběr restaurace kliknutím na její název
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-19 20:39:27 +01:00
Michal Hájek
c1856b2eee Pokud bylo v osobním nastavení vypnuto zobraování polévky, předával se do funkce doAddClickFoodChoice spatně foodIndex
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-19 20:21:17 +01:00
Michal Hájek
eaf0bc353d Výběr obědu kliknutím
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-18 10:07:35 +01:00
ff650ec3b8 rm db.json
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-17 09:32:23 +01:00
f8aa293413 fix
Some checks are pending
ci/woodpecker/push/workflow Pipeline is running
2025-02-17 09:26:03 +01:00
cafcd0a467 Log username a email pri kazdem dotazu pouze pro neproduction env
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-17 09:19:28 +01:00
9e247eb2a1 Podpora sestavování přes Woodpecker CI
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-09 00:34:59 +01:00
469a6b9031 Oprava .gitignore 2025-02-08 23:28:20 +01:00
Michal Hájek
89dec1c194 Založení složky server/data, pokud neexistuje, do které je vytvořen soubor db.json 2025-02-02 19:46:20 +01:00
Michal Hájek
f3af64923c Přesun json databaze (souboru db.json) do složky data, související úpravy v Dockerfile 2025-02-02 16:09:07 +01:00
Michal Hájek
44b09a9d1a Začištění souborů .gitignore 2025-02-02 16:06:52 +01:00
Michal Hájek
c311cc2fd7 Oprava importů klienta do složky types, aby nebylo potřeba složku kokírovat 2025-02-02 16:01:21 +01:00
Michal Hájek
a9fe369abc Oprava možnosti vybrat V kolik hodin preferuješ odchod pro následující dny 2025-01-29 08:48:43 +01:00
Michal Hájek
ea9fe980f0 U restaurace Pivovarský šenk Šeříková nahrazena mezera mezi cenou a Kč pevnou mezerou, aby nedocházelo k zalomení 2025-01-29 01:29:51 +01:00
Michal Hájek
d367826ce0 Přidání restaurace Pivovarský šenk Šeříková 2025-01-29 01:14:03 +01:00
Michal Hájek
fdf1ae938f Načtení menu celého týdne restaurace Zastávka u Michala 2025-01-28 22:20:44 +01:00
57c22958be Oprava chybné detekce některých jídel TechTower jako polévka 2025-01-20 14:41:18 +01:00
Michal Hájek
fe9cee3a80 Odfiltrovat ze selectboxu pro výběr preferovaného odchodu časy z minulosti 2025-01-15 00:35:17 +01:00
Michal Hájek
1d995faf8e Odfiltrovat ze selectboxu pro výběr preferovaného odchodu časy z minulosti 2025-01-15 00:34:47 +01:00
Michal Hájek
62fff22a12 Přidání restaurace Zastávka u Michala do výběru "Jak to dnes vidíš s obědem" 2025-01-15 00:03:15 +01:00
Michal Hájek
0fd1482810 Přidání restaurace Zastávka u Michala 2025-01-14 23:45:06 +01:00
02de6691a8 Migrace z pořadových indexů na unikátní klíče 2025-01-09 22:05:20 +01:00
774cb4f9d2 Oprava syntaxe - zapomenutá migrace interface 2025-01-09 21:04:12 +01:00
fd9aa547e2 Migrace "interface" na "type" 2025-01-08 20:53:48 +01:00
e611d36995 Otypování requestů na API 2025-01-08 17:58:49 +01:00
414664b2d7 Úprava API pro podporu TypeScript 2025-01-08 17:43:47 +01:00
a2167038da Redukce velikosti obrázku 2025-01-07 15:52:10 +01:00
219f7ffbc8 Zimní atmosféra 2025-01-07 15:49:22 +01:00
4d2ec529bb Skrytí podniku U Motlíků 2025-01-07 15:44:39 +01:00
86af490e94 Oprava parsování TechTower 2025-01-07 15:21:10 +01:00
e21da059c6 Aktualizace posledních změn 2024-12-11 23:31:55 +01:00
e990108140 Migrace na React 19 2024-12-11 23:04:03 +01:00
18f2b72133 Migrace sestavování klienta na Vite 2024-12-11 22:54:57 +01:00
b0d8a1a830 Povýšení závislostí 2024-12-11 20:24:06 +01:00
7e4fa236b1 Podpora easter eggů 2024-12-11 20:09:45 +01:00
98f2b2a1e0 Přidání vánočních prvků 2024-12-06 16:53:24 +01:00
9b7abb0703 throw error 2024-11-19 12:10:02 +01:00
5678e4a606 Snad fix timeout 2024-11-19 12:01:59 +01:00
582216015c Přidání easter-eggs.json do .gitignore 2024-11-13 23:30:54 +01:00
31daf4fb36 Začištění Dockerfile a compose.yml 2024-10-30 13:05:05 +01:00
4f858a19d8 Oprava parsování TechTower 2024-10-30 13:01:32 +01:00
91ea07a539 Oprava parsování TechTower 2024-07-08 20:45:09 +02:00
101bd60ddb Oprava case-sensitive parsování TechTower 2024-06-10 12:56:20 +02:00
7e061aa890 Nové možnosti hlasování 2024-04-11 22:00:34 +02:00
ff2d9e4fdb Vylepšení mobilního zobrazení 2024-04-09 17:40:13 +02:00
e261d32170 Úprava zápatí 2024-03-24 18:53:51 +01:00
731fd2eeb9 Oprava validace délky poznámky 2024-03-24 18:51:38 +01:00
93ba8def03 Oprava posunů mezi dny v inputech 2024-03-05 23:10:38 +01:00
1e280e9d05 Možnost zadání obecné poznámky k volbě 2024-03-04 23:35:58 +01:00
44187bc316 Oprava vyhodnocení nastavení trusted headers 2024-03-04 23:33:22 +01:00
4bd825fbcf Aktualizace TODO 2024-02-26 20:34:14 +01:00
b087c790ad Povýšení závislostí 2024-02-26 20:23:14 +01:00
e4a146995f Oprava nodemon hotreload 2024-02-26 20:16:11 +01:00
2883e80658 urcite neco rozbije a pavel to najde jako prvni 2024-02-05 20:02:21 +01:00
5830cde9ac Aktualizace frází pro detekci polévek v TechTower 2024-02-02 22:05:54 +01:00
52c4a53b9e Aktualizace changelogu 2024-01-28 21:15:38 +01:00
e9ea42c636 Oprava varování linteru 2024-01-28 20:46:38 +01:00
e735af4fc1 Neuskakování šipek 2024-01-25 19:18:05 +01:00
56125eea2e Přidání možnosti "Rozhoduji se" 2024-01-24 19:29:09 +01:00
61b6ec04f4 Rozšíření výčtu polévek pro TechTower 2024-01-24 19:11:21 +01:00
b954374425 Aktualizace mock dat 2024-01-24 19:07:49 +01:00
72c7bfe80c Možnost skrytí polévek 2024-01-24 18:54:07 +01:00
2633d445cc Doplnění chybějící nedělitelné mezery 2024-01-23 08:00:21 +01:00
3fd6b7dfcb Text "na váhu" v případě neznámé ceny u TechTower 2024-01-22 21:32:04 +01:00
8e075dd904 Základní Pizza kalkulačka 2024-01-08 23:39:12 +01:00
74f6e1ab69 Oprava parsování Sladovnická
Opravena chyba, kdy docházelo k posunu nabídky o den, pokud nabídka nezačínala pondělím.
2024-01-03 14:03:38 +01:00
fcad338921 Oprava parsování U Motlíků
Nyní je počítáno s případy, kdy neexistuje nabídka pro první den v daném týdnu.
2024-01-03 14:01:45 +01:00
aca4055d57 Přidání trusted headers do .env.template 2023-12-02 21:07:15 +01:00
4991b813bf Umožnit zadání trusted IPs s bílými znaky 2023-12-02 20:54:39 +01:00
515d4bb47e Opravy překlepů 2023-12-02 20:50:19 +01:00
b9b2492cb4 Odstranění zbytečné proměnné 2023-12-02 20:47:38 +01:00
4ff5d70331 tohle prepsalo muj list ip adres 2023-12-02 19:09:12 +01:00
44de01f6eb doufam ze jsem to hodne rozjebal lol 2023-12-02 17:45:56 +01:00
f85d19bbd6 Vylepšení parsování U Motlíků
Nově je počítáno s případy, kdy na stránkách existuje menu pro více týdnů současně.
2023-11-20 19:33:59 +01:00
7024c75f37 Přidání favicony 2023-11-10 21:04:33 +01:00
b9b9487375 Neumožnit změnu volby do minulosti 2023-11-10 20:48:34 +01:00
bbcb4c34b1 Lepší zobrazení poslední aktualizace menu 2023-11-10 20:40:59 +01:00
2b9d817af5 Zobrazování data u poslední aktualizace menu 2023-11-10 20:22:47 +01:00
3021e6159d Oprava parsování pro TechTower 2023-10-23 12:24:32 +02:00
c5103f902d Oprava sestavování ntfy URL 2023-10-17 08:44:14 +02:00
60563eaf0d Oprava sestavování URL pro ntfy 2023-10-16 09:56:22 +02:00
dc959543f4 Úprava uživatelských jmen pro ntfy URL 2023-10-16 09:18:08 +02:00
9736646b03 Kódování username pro ntfy v base64 2023-10-16 09:10:51 +02:00
9c2808d4ec Oprava funkčnosti při selhání načtení dat 2023-10-16 08:54:17 +02:00
eb82c23386 Odstranění přebytečných dat, aktualizace TODO 2023-10-15 21:02:58 +02:00
f2983b4397 Oprava pádu o víkendech 2023-10-15 20:47:59 +02:00
6d89858e3e feat: podpora notifikací z notify 2023-10-15 20:28:58 +02:00
3460d69899 Oprava mizejícího Pizza day 2023-10-15 19:42:03 +02:00
74c8ab9e39 Testy datumových funkcí 2023-10-15 19:05:27 +02:00
ca9a7c5c23 Parsování jídel na celý týden 2023-10-15 19:05:19 +02:00
74893c38eb Refaktor, rozdělení api, zpřehlednění kódu 2023-10-03 22:52:09 +02:00
829197e17a Oprava výchozích dat pro hlasování 2023-10-02 19:13:47 +02:00
c15f33323d Oprava počátečního hlasování 2023-10-01 19:27:34 +02:00
8e285e9197 Možnost hlasování o nových funkcích 2023-09-27 18:35:18 +02:00
401833f763 Oprava zbytečného posílání loginu na server 2023-09-27 18:34:14 +02:00
bef6178a6f Generalizace úložiště pro libovolná data 2023-09-27 15:09:36 +02:00
87beb5b66e Oprava zvýraznění aktuálního dne 2023-09-26 18:44:09 +02:00
8d80678a9a Vylepšená detekce uzavření podniků 2023-09-24 21:16:47 +02:00
eb27591727 Možnost příplatků u Pizza day objednávek 2023-09-24 20:15:04 +02:00
c3d35ccc9c Odstranění neexistujícího types workspace 2023-09-24 09:28:50 +02:00
47cd9f90c8 Oddělení TODO do samostatného souboru 2023-09-24 09:25:24 +02:00
f5ecedb3b9 Loader při zakládání Pizza day, mock data 2023-09-24 09:21:43 +02:00
3f16485368 Aktualizace TODO v README.md 2023-09-24 08:53:29 +02:00
dc9d1d0e9a Vylepšení začištění názvů jídel 2023-09-24 08:45:47 +02:00
e4451e299a Serverová validace času odchodu 2023-09-24 08:38:40 +02:00
c286bd1778 Začištění závislostí klienta 2023-09-24 08:15:49 +02:00
00cf8655e5 Opravy eslint 2023-09-24 08:10:48 +02:00
862614ae9d Refaktor, oddělení Pizza Day do vlastní servisky 2023-09-24 08:08:41 +02:00
1b132a7ca7 Oprava generování QR kódů pro Pizza day 2023-09-22 20:30:27 +02:00
3a357f077f Neorientovat se dle datumu klienta 2023-09-22 19:41:33 +02:00
8ec87ec200 Navigace mezi dny klávesovými šipkami 2023-09-18 22:56:38 +02:00
bc181defa8 Zpracování chyb z API 2023-09-18 22:38:04 +02:00
8a67325c85 tmp fix pondelku (mozna) 2023-09-11 09:32:05 +02:00
bf2683182e Aktualizace posledních změn 2023-09-06 22:57:43 +02:00
8615286c45 Opravy TypeScriptu 2023-09-06 22:43:51 +02:00
1a2b3c425e Oprava TypeScript typu 2023-09-06 19:30:21 +02:00
832d3089ec Možnost náhledu a výběru na další dny v týdnu 2023-09-06 19:22:19 +02:00
5379c21203 Redukce zbytečných pokusů o příhlášení 2023-09-06 19:19:13 +02:00
700a6980ca Aktualizace posledních změn 2023-08-20 21:12:33 +02:00
3d2dfb10f1 Vylepšené loadery 2023-08-19 11:56:24 +02:00
47d23c1581 Aktualizace TODO, oprava překlepu 2023-08-11 10:06:03 +02:00
282184b80b Neumožnit výběr zavřených podniků 2023-08-11 00:17:11 +02:00
45bd84f96f Oprava stahování pizz pro Pizza day 2023-08-10 21:47:56 +02:00
e78f9cfd3a Začištění kódu 2023-08-08 21:45:21 +02:00
d41e0e9113 Podpora odhlášení přes Authelia 2023-08-08 21:32:11 +02:00
6b824ce33a Oprava časové zóny a výběru storage dle .env 2023-08-08 20:44:15 +02:00
fd2e460a82 Dekódování jména uživatele z trusted headers 2023-08-08 20:05:41 +02:00
f008d364c5 Opravy brainfart v README.md 2023-08-07 16:24:44 +02:00
ce41c14446 Oprava stahování HTML restaurací 2023-08-07 09:40:49 +02:00
0b8f00fa49 Oprava chybného zpracování env proměnné MOCK_DATA 2023-08-07 09:38:36 +02:00
c4b14bdf6b Ukládání dat výhradně do DB 2023-08-06 21:45:27 +02:00
18cb172e06 Ukládání pizz do DB místo dočasného souboru 2023-08-06 18:52:18 +02:00
3f303ea5ea Možnost zadání preferovaného času odchodu 2023-08-06 18:13:54 +02:00
37542499a9 Zavedení podpory pro Redis, agnostické úložiště dat 2023-08-06 17:46:51 +02:00
8a75c98c9a Základ zobrazování ověřených uživatelů 2023-07-30 23:36:18 +02:00
028186c8ea Možnost zvolit pouze jednu variantu obědu 2023-07-30 21:24:15 +02:00
c0efb01803 Zpřehlednění tabulky výběru 2023-07-29 10:43:00 +02:00
5727c8eca1 Aktualizace changelogu 2023-07-28 00:06:07 +02:00
75fd75510b Přidání footeru s odkazem na zdrojové kódy 2023-07-26 22:26:50 +02:00
8cfe415bb3 Přidání nových návrhů do TODO, seřazení dle priorit 2023-07-26 21:39:39 +02:00
8ac0e72371 NOMERGE: Sloučits předchozími commity 2023-07-26 00:21:22 +02:00
bbf1cf1850 Možnost výběru konkrétních jídel 2023-07-25 23:53:05 +02:00
24c301b141 Oprava ukládání dat při změně 2023-07-25 23:51:17 +02:00
b1138bc104 NOMERGE: Přenos změn 2023-07-23 22:12:47 +02:00
78 changed files with 11206 additions and 11548 deletions

22
.gitignore vendored
View File

@@ -1,23 +1 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

56
.woodpecker/workflow.yaml Normal file
View File

@@ -0,0 +1,56 @@
variables:
- &node_image 'node:18-alpine3.18'
- &branch 'master'
when:
- event: push
branch: *branch
steps:
- name: Install server dependencies
image: *node_image
commands:
- cd server
- yarn install --frozen-lockfile
- name: Install client dependencies
image: *node_image
commands:
- cd client
- yarn install --frozen-lockfile
- name: Build server
depends_on: [Install server dependencies]
image: *node_image
commands:
- cd server
- yarn build
- name: Build client
depends_on: [Install client dependencies]
image: *node_image
commands:
- cd client
- yarn build
- name: Build Docker image
depends_on: [Build server, Build client]
image: woodpeckerci/plugin-docker-buildx
settings:
dockerfile: Dockerfile-Woodpecker
platforms: linux/amd64
registry:
from_secret: REPO_URL
username:
from_secret: REPO_USERNAME
password:
from_secret: REPO_PASSWORD
repo:
from_secret: REPO_NAME
- name: Discord notification - build
image: appleboy/drone-discord
depends_on: [Build Docker image]
when:
- status: [success, failure]
settings:
webhook_id:
from_secret: DISCORD_WEBHOOK_ID
webhook_token:
from_secret: DISCORD_WEBHOOK_TOKEN
message: "{{#success build.status}}✅ Sestavení {{build.number}} proběhlo úspěšně.{{else}}❌ Sestavení {{build.number}} selhalo.{{/success}}\n\nPipeline: {{build.link}}\nPoslední commit: {{commit.message}}Autor: {{commit.author}}"

View File

@@ -1,28 +1,46 @@
# Builder # Builder
FROM node:18-alpine3.18 as builder FROM node:18-alpine3.18 AS builder
WORKDIR /build WORKDIR /build
COPY package.json . # Zkopírování závislostí - server
COPY yarn.lock .
COPY server/package.json ./server/ COPY server/package.json ./server/
COPY client/package.json ./client/ COPY server/yarn.lock ./server/
# Zkopírování závislostí - klient
COPY client/package.json ./client/
COPY client/yarn.lock ./client/
# Instalace závislostí - server
WORKDIR /build/server
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
# Instalace závislostí - klient
WORKDIR /build/client
RUN yarn install --frozen-lockfile
WORKDIR /build
# Zkopírování build závislostí - server
COPY server/tsconfig.json ./server/ COPY server/tsconfig.json ./server/
COPY server/src ./server/src/ COPY server/src ./server/src/
# Zkopírování build závislostí - klient
COPY client/tsconfig.json ./client/ COPY client/tsconfig.json ./client/
COPY client/vite.config.ts ./client/
COPY client/vite-env.d.ts ./client/
COPY client/index.html ./client/
COPY client/src ./client/src COPY client/src ./client/src
COPY client/public ./client/public COPY client/public ./client/public
# Zkopírování společných typů
COPY types ./types/ COPY types ./types/
# Sestavení serveru
WORKDIR /build/server WORKDIR /build/server
RUN yarn build RUN yarn build
# Sestavení klienta
WORKDIR /build/client WORKDIR /build/client
RUN yarn build RUN yarn build
@@ -33,11 +51,22 @@ ENV NODE_ENV production
WORKDIR /app WORKDIR /app
COPY --from=builder /build/node_modules ./node_modules # Vykopírování sestaveného serveru
COPY --from=builder /build/server/node_modules ./server/node_modules
COPY --from=builder /build/server/dist ./ COPY --from=builder /build/server/dist ./
COPY --from=builder /build/client/build ./public
# Vykopírování sestaveného klienta
COPY --from=builder /build/client/dist ./public
# Zkopírování produkčních .env serveru
COPY /server/.env.production ./server/src COPY /server/.env.production ./server/src
# Zkopírování konfigurace easter eggů
RUN if [ -f /server/.easter-eggs.json ]; then cp /server/.easter-eggs.json ./server/; fi
# Export /data/db.json do složky /data
VOLUME ["/data"]
EXPOSE 3000 EXPOSE 3000
CMD [ "node", "./server/src/index.js" ] CMD [ "node", "./server/src/index.js" ]

21
Dockerfile-Woodpecker Normal file
View File

@@ -0,0 +1,21 @@
FROM node:18-alpine3.18
ENV LANG=cs_CZ.UTF-8
ENV 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í konfigurace easter eggů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
EXPOSE 3000
CMD [ "node", "./server/src/index.js" ]

View File

@@ -1,9 +1,7 @@
# Luncher # Luncher
Aplikace pro profesionální management obědů. Aplikace pro profesionální management obědů.
Aplikace sestává ze tří modulů. Aplikace sestává ze dvou modulů + společných TypeScript definic (adresář `types`).
- types
- společné TypeScript definice, pro objekty posílané mezi serverem a klientem
- server - server
- backend psaný v [node.js](https://nodejs.dev) - backend psaný v [node.js](https://nodejs.dev)
- client - client
@@ -29,46 +27,4 @@ Aplikace sestává ze tří modulů.
- `docker compose -f compose-traefik.yml up --build -d` - `docker compose -f compose-traefik.yml up --build -d`
## TODO ## TODO
- [x] Umožnit smazání aktuální volby "popelnicí", místo nutnosti vybrat prázdnou položku v selectu Dostupné [zde](TODO.md).
- [x] Přívětivější možnost odhlašování
- [x] Vyřešit responzivní design pro použití na mobilu
- [x] Vyndat URL na Food API do .env
- [x] Neselhat při nedostupnosti nebo chybě z Food API
- [x] Dokončit docker-compose pro kompletní funkčnost
- [x] Implementovat Pizza day
- [x] Umožnit uzamčení objednávek zakladatelem
- [x] Možnost uložení čísla účtu
- [x] Automatické generování a zobrazení QR kódů
- [x] https://qr-platba.cz/pro-vyvojare/restful-api/
- [x] Zobrazovat celkovou cenu objednávky pod tabulkou objednávek
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
- [x] Umožnit přidat k objednávce poznámku (např. "bez oliv")
- [x] Negenerovat QR kód pro objednávajícího
- [ ] Pizzy se samy budou při naklikání přidávat do košíku
- [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch()
- [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní
- [ ] Předvyplnění poslední vybrané hodnoty občas nefunguje, viz komentář
- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není venku)
- [x] Vylepšit dokumentaci projektu
- [x] Popsat závislosti, co je nutné provést před vývojem a postup spuštění pro vývoj
- [x] Popsat dostupné env
- [x] Přesunout autentizaci na server (JWT?)
- [x] Zavést .env.template a přidat .env do .gitignore
- [x] Zkrášlit dialog pro vyplnění čísla účtu, vypadá mizerně
- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
- [ ] Umožnit zadat URL/tokeny uživatelem
- [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
- [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh)
- [ ] Možnost náhledu na jiné dny v týdnu (např. pomocí šipek)
- [ ] Možnost zadat si oběd dopředu na následující dny v týdnu
- [x] Zbavit se Food API, potřebnou funkcionalitu zahrnout do serveru
- [x] Vyřešit API mezi serverem a klientem, aby nebyl v obou projektech duplicitní kód (viz types.ts a Types.tsx)
- [ ] Mazat z databáze předchozí dny, aktuálně je to k ničemu
- [ ] Vylepšit parsery restaurací
- [ ] Sladovnická
- [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru
- [ ] U Motlíků
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.')
- [ ] Jídelní lístek se stahuje jednou každý den, stačilo by to jednou týdně ("ID" by mohlo být číslo týdne v roce, místo celého datumu)
- [ ] TechTower
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)')

73
TODO.md Normal file
View File

@@ -0,0 +1,73 @@
# TODO
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli
- [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
- [ ] Nemělo by se jít dostat na přihlašovací formulář (měla by tam být nanejvýš hláška nebo přesměrování)
- [ ] Možnost úhrady celé útraty jednou osobou
- Základní myšlenka: jedna osoba uhradí celou útratu (v zájmu rychlosti odbavení), ostatním se automaticky vygeneruje QR kód, kterým následně uhradí svoji část útraty
- Obecně to bude problém např. pokud si někdo objedná něco navíc (pití apod.)
- [ ] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul
- [ ] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu
- [ ] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům
- [ ] Generování a zobrazení QR kódů ostatním strávníkům
- [ ] Umožnit u každého strávníka připočíst vlastní částku (např. za pití)
- [ ] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť)
- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
- [ ] Umožnit zadat URL/tokeny uživatelem
- [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
- [ ] Zavést notifikace typu "Jdeme na oběd"
- [ ] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
- [ ] Vylepšit parsery restaurací
- [ ] Sladovnická
- [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru
- [ ] U Motlíků
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.')
- [ ] Jídelní lístek se stahuje jednou každý den, teoreticky by stačilo jednou týdně (za předpokladu, že se během týdne nemění)
- [ ] TechTower
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)')
- [ ] Jídelní lístek se stahuje v rámci prvního požadavku daný den, ale často se jídelní lístek na stránkách aktualizuje až v průběhu pondělního dopoledne a ten zobrazený je proto neaktuální
- Stránka neposílá hlavičku o času poslední modifikace, takže o to se nelze opřít
- Nevím aktuálně jak řešit jinak, než častějším scrapováním celé stránky
- [X] Někdy jsou v názvech jídel přebytečné mezery kolem čárek ( , )
- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru)
- [ ] Zavést složku /data
- [ ] Mazat z databáze data z minulosti, aktuálně je to k ničemu
- [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh)
- [ ] Implementovat Pizza day
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
- [ ] Pizzy se samy budou při naklikání přidávat do košíku
- [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch()
- [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní
- [X] Umožnit u Pizza day ručně připočíst cenu za přísady
- [X] Prvotní načtení pizz při založení Pizza Day trvá a nic se během toho nezobrazuje (např. loader)
- [X] Po doručení zobrazit komu zaplatit (kdo objednával)
- [x] Zbytečně nescrapovat každý den pizzy z Pizza Chefie, dokud není založen Pizza Day
- [x] Umožnit uzamčení objednávek zakladatelem
- [x] Možnost uložení čísla účtu
- [x] Automatické generování a zobrazení QR kódů
- [x] https://qr-platba.cz/pro-vyvojare/restful-api/
- [x] Zobrazovat celkovou cenu objednávky pod tabulkou objednávek
- [x] Umožnit přidat k objednávce poznámku (např. "bez oliv")
- [x] Negenerovat QR kód pro objednávajícího
- [X] Možnost náhledu na ostatní dny v týdnu (např. pomocí šipek)
- [X] Možnost výběru oběda na následující dny v týdnu
- [X] Umožnit vybrat libovolný čas odchodu
- [X] Validace zadání smysluplného času (ideálně i klientská)
- [x] Umožnit smazání aktuální volby "popelnicí", místo nutnosti vybrat prázdnou položku v selectu
- [x] Přívětivější možnost odhlašování
- [x] Vyřešit responzivní design pro použití na mobilu
- [x] Vyndat URL na Food API do .env
- [x] Neselhat při nedostupnosti nebo chybě z Food API
- [x] Dokončit docker-compose pro kompletní funkčnost
- [x] Vylepšit dokumentaci projektu
- [x] Popsat závislosti, co je nutné provést před vývojem a postup spuštění pro vývoj
- [x] Popsat dostupné env
- [x] Přesunout autentizaci na server (JWT?)
- [x] Zavést .env.template a přidat .env do .gitignore
- [x] Zkrášlit dialog pro vyplnění čísla účtu, vypadá mizerně
- [x] Zbavit se Food API, potřebnou funkcionalitu zahrnout do serveru
- [x] Vyřešit API mezi serverem a klientem, aby nebyl v obou projektech duplicitní kód (viz types.ts a Types.tsx)
- [X] Vybraná jídla strávníků zobrazovat v samostatném sloupci
- [X] Umožnit výběr/zadání preferovaného času odchodu na oběd
- Hodí se např. pokud má někdo schůzky
- [X] Ukládat dostupné pizzy do DB místo souborů
- [X] Ukládat jídla do DB místo souborů

2
client/.gitignore vendored
View File

@@ -1,2 +1,2 @@
build build
src/types dist

21
client/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Luncher</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@@ -3,43 +3,40 @@
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"type": "module",
"homepage": ".", "homepage": ".",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0", "@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@testing-library/jest-dom": "^5.16.5", "@types/jest": "^29.5.12",
"@testing-library/react": "^13.4.0", "@types/node": "^20.11.20",
"@testing-library/user-event": "^13.5.0", "@types/react": "^19.0.0",
"@types/jest": "^27.5.2", "@types/react-dom": "^19.0.0",
"@types/node": "^16.18.23", "@vitejs/plugin-react": "^4.3.4",
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"bootstrap": "^5.2.3", "bootstrap": "^5.2.3",
"react": "^18.2.0", "react": "^19.0.0",
"react-bootstrap": "^2.7.2", "react-bootstrap": "^2.7.2",
"react-dom": "^18.2.0", "react-dom": "^19.0.0",
"react-jwt": "^1.2.0", "react-jwt": "^1.2.0",
"react-modal": "^3.16.1", "react-modal": "^3.16.1",
"react-scripts": "5.0.1",
"react-select-search": "^4.1.6", "react-select-search": "^4.1.6",
"react-toastify": "^9.1.3", "react-snowfall": "^2.2.0",
"react-toastify": "^10.0.4",
"sass": "^1.80.6",
"socket.io-client": "^4.6.1", "socket.io-client": "^4.6.1",
"typescript": "^4.9.5", "typescript": "^5.3.3",
"web-vitals": "^2.1.4" "vite": "^6.0.3",
"vite-tsconfig-paths": "^5.1.4"
}, },
"scripts": { "scripts": {
"copy-types": "cp -r ../types ./src", "start": "yarn vite",
"start": "yarn copy-types && react-scripts start", "build": "yarn vite build"
"build": "yarn copy-types && react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
"react-app", "react-app"
"react-app/jest"
] ]
}, },
"browserslist": { "browserslist": {
@@ -55,6 +52,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"prettier": "^2.8.8" "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"prettier": "^3.2.5"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

BIN
client/public/hat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Luncher</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 60 KiB

BIN
client/public/snowman.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -1,81 +0,0 @@
import { PizzaOrder } from "./types";
import { getBaseUrl, getToken } from "./Utils";
async function request<TResponse>(
url: string,
config: RequestInit = {}
): Promise<TResponse> {
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
return fetch(getBaseUrl() + url, config).then(response => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json() as TResponse;
});
}
const api = {
get: <TResponse>(url: string) => request<TResponse>(url),
post: <TBody extends BodyInit, TResponse>(url: string, body: TBody) => request<TResponse>(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }),
}
export const getQrUrl = (login: string) => {
return `${getBaseUrl()}/api/qr?login=${login}`;
}
export const getData = async () => {
return await api.get<any>('/api/data');
}
export const getFood = async () => {
return await api.get<any>('/api/food');
}
export const getPizzy = async () => {
return await api.get<any>('/api/pizza');
}
export const createPizzaDay = async () => {
return await api.post<any, any>('/api/createPizzaDay', undefined);
}
export const deletePizzaDay = async () => {
return await api.post<any, any>('/api/deletePizzaDay', undefined);
}
export const lockPizzaDay = async () => {
return await api.post<any, any>('/api/lockPizzaDay', undefined);
}
export const unlockPizzaDay = async () => {
return await api.post<any, any>('/api/unlockPizzaDay', undefined);
}
export const finishOrder = async () => {
return await api.post<any, any>('/api/finishOrder', undefined);
}
export const finishDelivery = async (bankAccount?: string, bankAccountHolder?: string) => {
return await api.post<any, any>('/api/finishDelivery', JSON.stringify({ bankAccount, bankAccountHolder }));
}
export const updateChoice = async (choice: number | null) => {
return await api.post<any, any>('/api/updateChoice', JSON.stringify({ choice }));
}
export const addPizza = async (pizzaIndex: number, pizzaSizeIndex: number) => {
return await api.post<any, any>('/api/addPizza', JSON.stringify({ pizzaIndex, pizzaSizeIndex }));
}
export const removePizza = async (pizzaOrder: PizzaOrder) => {
return await api.post<any, any>('/api/removePizza', JSON.stringify({ pizzaOrder }));
}
export const updateNote = async (note?: string) => {
return await api.post<any, any>('/api/updateNote', JSON.stringify({ note }));
}
export const login = async (login: string) => {
return await api.post<any, any>('/api/login', JSON.stringify({ login }));
}

View File

@@ -1,82 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.wrapper {
padding: 20px;
}
.title {
margin: 50px 0;
}
.food-tables {
margin-bottom: 50px;
}
.content-wrapper {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.navbar {
background-color: #3c3c3c;
padding-left: 20px;
padding-right: 20px;
}
#basic-navbar-nav {
justify-content: flex-end;
}
.trash-icon {
color: rgb(0, 89, 255);
cursor: pointer;
margin-left: 10px;
}
.qr-code {
text-align: center;
}
.select-search-container {
margin: auto;
}

168
client/src/App.scss Normal file
View File

@@ -0,0 +1,168 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.loader {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.loader>.loader-icon {
font-size: 64px;
}
.wrapper {
padding: 20px;
}
.title {
margin: 50px 20px;
}
.food-tables {
margin-bottom: 50px;
}
.content-wrapper {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.navbar {
background-color: #3c3c3c;
padding-left: 20px;
padding-right: 20px;
}
#basic-navbar-nav {
justify-content: flex-end;
}
.table {
margin-bottom: 0;
}
.table> :not(caption) .action-icon {
color: rgb(0, 89, 255);
cursor: pointer;
margin-left: 10px;
padding: 0;
}
.table ul {
padding: 0;
margin-left: 20px;
margin-bottom: 0;
}
.table td {
vertical-align: top;
}
.table>tbody>tr>td>table>tbody>tr>td {
border: none;
}
.qr-code {
text-align: center;
margin-top: 30px;
}
.select-search-container {
margin: auto;
}
.trusted-icon {
color: rgb(0, 89, 255);
margin-right: 10px;
}
.day-navigator {
display: flex;
align-items: center;
font-size: xx-large;
}
@keyframes bounce-in {
0% {
left: var(--start-left);
right: var(--start-right);
top: var(--start-top);
bottom: var(--start-bottom);
}
25% {
left: var(--end-left);
right: var(--end-right);
top: var(--end-top);
bottom: var(--end-bottom);
}
50% {
left: var(--start-left);
right: var(--start-right);
top: var(--start-top);
bottom: var(--start-bottom);
}
75% {
left: var(--end-left);
right: var(--end-right);
top: var(--end-top);
bottom: var(--end-bottom);
}
100% {
left: var(--start-left);
right: var(--start-right);
top: var(--start-top);
bottom: var(--start-bottom);
}
}
// TODO zjistit, zda to nedokážeme lépe - tohle je kvůli overflow easter egg obrázků, ale skrývá to úplně scrollbar
html {
overflow-x: hidden;
}

View File

@@ -1,52 +1,94 @@
import React, { useContext, useEffect, useMemo, useRef, useState } 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, SocketContext } from './context/socket'; import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket';
import { addPizza, createPizzaDay, deletePizzaDay, finishDelivery, finishOrder, getData, getFood, getPizzy, getQrUrl, lockPizzaDay, removePizza, unlockPizzaDay, updateChoice, updateNote } from './Api'; import { addPizza, createPizzaDay, deletePizzaDay, finishDelivery, finishOrder, lockPizzaDay, removePizza, unlockPizzaDay, updatePizzaDayNote } from './api/PizzaDayApi';
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';
import Header from './components/Header'; import Header from './components/Header';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import PizzaOrderList from './components/PizzaOrderList'; import PizzaOrderList from './components/PizzaOrderList';
import SelectSearch, { SelectedOptionValue } from 'react-select-search'; import SelectSearch, {SelectedOptionValue, SelectSearchOption} from 'react-select-search';
import 'react-select-search/style.css'; import 'react-select-search/style.css';
import './App.css'; import './App.scss';
import { SelectSearchOption } from 'react-select-search'; import { faCircleCheck, faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { faTrashCan } from '@fortawesome/free-regular-svg-icons'; import { useSettings } from './context/settings';
import { useBank } from './context/bank'; import { ClientData, Restaurants, Food, Order, Locations, PizzaOrder, PizzaDayState, FoodChoices, DayMenu, DepartureTime, LocationKey } from '../../types';
import { ClientData, Restaurants, Food, Pizza, Order, Locations, PizzaOrder, PizzaDayState } from './types'; import Footer from './components/Footer';
import { faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons';
import Loader from './components/Loader';
import { getData, errorHandler, getQrUrl } from './api/Api';
import { addChoice, removeChoices, removeChoice, changeDepartureTime, jdemeObed, updateNote } from './api/FoodApi';
import { getHumanDateTime, isInTheFuture } from './Utils';
import NoteModal from './components/modals/NoteModal';
import { useEasterEgg } from './context/eggs';
import { getImage } from './api/EasterEggApi';
const EVENT_CONNECT = "connect" const EVENT_CONNECT = "connect"
// Fixní styl pro všechny easter egg obrázky
const EASTER_EGG_STYLE = {
zIndex: 1,
animationName: "bounce-in",
animationTimingFunction: "ease"
}
// Výchozí doba trvání animace v sekundách, pokud není přetíženo v konfiguračním JSONu
const EASTER_EGG_DEFAULT_DURATION = 0.75;
function App() { function App() {
const auth = useAuth(); const auth = useAuth();
const bank = useBank(); const settings = useSettings();
const [easterEgg, easterEggLoading] = useEasterEgg(auth);
const [isConnected, setIsConnected] = useState<boolean>(false); const [isConnected, setIsConnected] = useState<boolean>(false);
const [data, setData] = useState<ClientData>(); const [data, setData] = useState<ClientData>();
const [food, setFood] = useState<{ [key in Restaurants]: Food[] }>(); const [food, setFood] = useState<{ [key in Restaurants]?: DayMenu }>();
const [pizzy, setPizzy] = useState<Pizza[]>();
const [myOrder, setMyOrder] = useState<Order>(); const [myOrder, setMyOrder] = useState<Order>();
const [foodChoiceList, setFoodChoiceList] = useState<Food[]>();
const [closed, setClosed] = useState<boolean>(false);
const socket = useContext(SocketContext); const socket = useContext(SocketContext);
const choiceRef = useRef<HTMLSelectElement>(null); const choiceRef = useRef<HTMLSelectElement>(null);
const poznamkaRef = useRef<HTMLInputElement>(null); const foodChoiceRef = useRef<HTMLSelectElement>(null);
const departureChoiceRef = useRef<HTMLSelectElement>(null);
const pizzaPoznamkaRef = useRef<HTMLInputElement>(null);
const [failure, setFailure] = useState<boolean>(false);
const [dayIndex, setDayIndex] = useState<number>();
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
const [eggImage, setEggImage] = useState<Blob>();
const eggRef = useRef<HTMLImageElement>(null);
// Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu
// https://medium.com/@kishorkrishna/cant-access-latest-state-inside-socket-io-listener-heres-how-to-fix-it-1522a5abebdb
const dayIndexRef = useRef<number | undefined>(dayIndex);
// Načtení dat po přihlášení // Načtení dat po přihlášení
useEffect(() => { useEffect(() => {
if (!auth || !auth.login) { if (!auth || !auth.login) {
return return
} }
getPizzy().then(pizzy => { getData().then((data: ClientData) => {
setPizzy(pizzy);
});
getData().then(data => {
setData(data); setData(data);
}) setDayIndex(data.weekIndex);
getFood().then(food => { dayIndexRef.current = data.weekIndex;
setFood(food); setFood(data.menus);
}).catch(e => {
setFailure(true);
}) })
}, [auth, auth?.login]); }, [auth, auth?.login]);
// Přenačtení pro zvolený den
useEffect(() => {
if (!auth || !auth.login) {
return
}
getData(dayIndex).then((data: ClientData) => {
setData(data);
setFood(data.menus);
}).catch(e => {
setFailure(true);
})
}, [dayIndex, auth]);
// Registrace socket eventů // Registrace socket eventů
useEffect(() => { useEffect(() => {
socket.on(EVENT_CONNECT, () => { socket.on(EVENT_CONNECT, () => {
@@ -59,7 +101,10 @@ 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);
setData(newData); // Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený
if (dayIndexRef.current == null || newData.weekIndex === dayIndexRef.current) {
setData(newData);
}
}); });
return () => { return () => {
@@ -74,14 +119,15 @@ function App() {
return return
} }
// TODO tohle občas náhodně nezafunguje, nutno přepsat, viz https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780 // TODO tohle občas náhodně nezafunguje, nutno přepsat, viz https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780
if (data?.choices && choiceRef.current) { // TODO nutno opravit
for (let entry of Object.entries(data.choices)) { // if (data?.choices && choiceRef.current) {
if (entry[1].includes(auth.login)) { // for (let entry of Object.entries(data.choices)) {
const value = entry[0] as any as number; // TODO tohle je absurdní // if (entry[1].includes(auth.login)) {
choiceRef.current.value = Object.values(Locations)[value]; // const value = entry[0] as any as number; // TODO tohle je absurdní
} // choiceRef.current.value = Object.values(Locations)[value];
} // }
} // }
// }
}, [auth, auth?.login, data?.choices]) }, [auth, auth?.login, data?.choices])
// Reference na mojí objednávku // Reference na mojí objednávku
@@ -92,28 +138,127 @@ function App() {
} }
}, [auth?.login, data?.pizzaDay?.orders]) }, [auth?.login, data?.pizzaDay?.orders])
const changeChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => { useEffect(() => {
const index = Object.values(Locations).indexOf(event.target.value as unknown as Locations); if (choiceRef?.current?.value && choiceRef.current.value !== "") {
const locationKey = choiceRef.current.value as LocationKey;
const restaurantKey = Object.keys(Restaurants).indexOf(locationKey);
if (restaurantKey > -1 && food) {
const restaurant = Object.values(Restaurants)[restaurantKey];
setFoodChoiceList(food[restaurant]?.food);
setClosed(food[restaurant]?.closed ?? false);
} else {
setFoodChoiceList(undefined);
setClosed(false);
}
} else {
setFoodChoiceList(undefined);
setClosed(false);
}
}, [choiceRef.current?.value, food])
// Navigace mezi dny pomocí klávesových šípek
const handleKeyDown = useCallback((e: any) => {
if (e.keyCode === 37 && dayIndex != null && dayIndex > 0) {
handleDayChange(dayIndex - 1);
} else if (e.keyCode === 39 && dayIndex != null && dayIndex < 4) {
handleDayChange(dayIndex + 1);
}
}, [dayIndex]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
}
}, [handleKeyDown]);
// Stažení a nastavení easter egg obrázku
useEffect(() => {
if (auth?.login && easterEgg?.url && !eggImage) {
getImage(easterEgg.url).then(data => {
if (data) {
setEggImage(data);
// Smazání obrázku z DOMu po animaci
setTimeout(() => {
if (eggRef?.current) {
eggRef.current.remove();
}
}, (easterEgg.duration || EASTER_EGG_DEFAULT_DURATION) * 1000);
}
});
}
}, [auth?.login, easterEgg?.duration, easterEgg?.url, eggImage]);
const doAddClickFoodChoice = async (location: Locations, foodIndex?: number) => {
const locationKey = Object.keys(Locations).find(key => Locations[key as keyof typeof Locations] === location) as LocationKey;
if (auth?.login) { if (auth?.login) {
await updateChoice(index > -1 ? index : null); await errorHandler(() => addChoice(locationKey, foodIndex, dayIndex));
} }
} }
const removeChoice = async (key: string) => { const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const locationKey = event.target.value as LocationKey;
if (auth?.login) { if (auth?.login) {
await updateChoice(null); await errorHandler(() => addChoice(locationKey, undefined, dayIndex));
if (choiceRef?.current?.value) { if (foodChoiceRef.current?.value) {
choiceRef.current.value = ""; foodChoiceRef.current.value = "";
} }
} }
} }
const doJdemeObed = async (locationKey: LocationKey) => {
if (auth?.login) {
await jdemeObed(locationKey);
}
}
const doAddFoodChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (event.target.value && foodChoiceList?.length && choiceRef.current?.value) {
const locationKey = choiceRef.current.value as LocationKey;
if (auth?.login) {
await errorHandler(() => addChoice(locationKey, Number(event.target.value), dayIndex));
}
}
}
const doRemoveChoices = async (locationKey: LocationKey) => {
if (auth?.login) {
await errorHandler(() => removeChoices(locationKey, dayIndex));
// Vyresetujeme výběr, aby bylo jasné pro který případně vybíráme jídlo
if (choiceRef?.current?.value) {
choiceRef.current.value = "";
}
if (foodChoiceRef?.current?.value) {
foodChoiceRef.current.value = "";
}
}
}
const doRemoveFoodChoice = async (locationKey: LocationKey, foodIndex: number) => {
if (auth?.login) {
await errorHandler(() => removeChoice(locationKey, foodIndex, dayIndex));
if (choiceRef?.current?.value) {
choiceRef.current.value = "";
}
if (foodChoiceRef?.current?.value) {
foodChoiceRef.current.value = "";
}
}
}
const saveNote = async (note?: string) => {
if (auth?.login) {
await errorHandler(() => updateNote(note, dayIndex));
setNoteModalOpen(false);
}
}
const pizzaSuggestions = useMemo(() => { const pizzaSuggestions = useMemo(() => {
if (!pizzy) { if (!data?.pizzaList) {
return []; return [];
} }
const suggestions: SelectSearchOption[] = []; const suggestions: SelectSearchOption[] = [];
pizzy.forEach((pizza, index) => { data.pizzaList.forEach((pizza, index) => {
const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] } const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] }
pizza.sizes.forEach((size, sizeIndex) => { pizza.sizes.forEach((size, sizeIndex) => {
const name = `${size.size} (${size.price} Kč)`; const name = `${size.size} (${size.price} Kč)`;
@@ -123,10 +268,10 @@ function App() {
suggestions.push(group); suggestions.push(group);
}) })
return suggestions; return suggestions;
}, [pizzy]); }, [data?.pizzaList]);
const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => { const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => {
if (auth?.login && pizzy) { if (auth?.login && data?.pizzaList) {
if (!(typeof value === 'string')) { if (!(typeof value === 'string')) {
throw Error('Nepodporovaný typ hodnoty'); throw Error('Nepodporovaný typ hodnoty');
} }
@@ -141,12 +286,12 @@ function App() {
await removePizza(pizzaOrder); await removePizza(pizzaOrder);
} }
const handlePoznamkaChange = async () => { const handlePizzaPoznamkaChange = async () => {
if (poznamkaRef.current?.value && poznamkaRef.current.value.length > 100) { if (pizzaPoznamkaRef.current?.value && pizzaPoznamkaRef.current.value.length > 70) {
alert("Poznámka může mít maximálně 100 znaků"); alert("Poznámka může mít maximálně 70 znaků");
return; return;
} }
updateNote(poznamkaRef.current?.value); updatePizzaDayNote(pizzaPoznamkaRef.current?.value);
} }
// const addToCart = async () => { // const addToCart = async () => {
@@ -173,20 +318,50 @@ function App() {
// } // }
// } // }
const renderFoodTable = (name: string, food: Food[]) => { const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => {
return <Col md={12} lg={4}> if (foodChoiceList?.length && choiceRef.current?.value) {
<h3>{name}</h3> await changeDepartureTime(event.target.value, dayIndex);
<Table striped bordered hover> }
<tbody> }
{food?.length > 0 ? food.map((f: any, index: number) =>
<tr key={index}> const handleDayChange = async (dayIndex: number) => {
setDayIndex(dayIndex);
dayIndexRef.current = dayIndex;
if (choiceRef?.current?.value) {
choiceRef.current.value = "";
}
if (foodChoiceRef?.current?.value) {
foodChoiceRef.current.value = "";
}
if (departureChoiceRef?.current?.value) {
departureChoiceRef.current.value = "";
}
}
const renderFoodTable = (location: Locations, menu: DayMenu) => {
let content;
if (menu?.closed) {
content = <h3>Zavřeno</h3>
} else if (menu?.food?.length > 0) {
const hideSoups = settings?.hideSoups;
content = <Table striped bordered hover>
<tbody style={{ cursor: 'pointer' }}>
{menu.food.filter(f => (hideSoups ? !f.isSoup : true)).map((f: any, index: number) =>
<tr key={index} onClick={() => doAddClickFoodChoice(location, hideSoups ? index + 1 : index)}>
<td>{f.amount}</td> <td>{f.amount}</td>
<td>{f.name}</td> <td>{f.name}</td>
<td>{f.price}</td> <td>{f.price}</td>
</tr> </tr>
) : <h1>Hmmmmm podivné.... nic se nevrátilo</h1>} )}
</tbody> </tbody>
</Table> </Table>
} else {
content = <h3>Chyba načtení dat</h3>
}
return <Col md={12} lg={3} className='mt-3'>
<h3 style={{ cursor: 'pointer' }} onClick={() => doAddClickFoodChoice(location, undefined)}>{location}</h3>
{menu?.lastUpdate && <small>Poslední aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>}
{content}
</Col> </Col>
} }
@@ -194,180 +369,294 @@ function App() {
return <Login />; return <Login />;
} }
if (!data || !isConnected || !food) { if (!isConnected) {
return <div>Načítám data...</div> return <Loader
icon={faSatelliteDish}
description={'Zdá se, že máme problémy se spojením se serverem. Pokud problém přetrvává, kontaktujte správce systému.'}
animation={'fa-beat-fade'}
/>
}
if (failure) {
return <Loader
title="Něco se nám nepovedlo :("
icon={faChainBroken}
description={'Ale to nevadí. To se stává, takový je život. Kontaktujte správce systému, který zajistí nápravu.'}
/>
}
if (!data || !food) {
return <Loader
icon={faSearch}
description={'Hledáme, co dobrého je aktuálně v nabídce'}
animation={'fa-bounce'}
/>
} }
const noOrders = data?.pizzaDay?.orders?.length === 0; const noOrders = data?.pizzaDay?.orders?.length === 0;
const canChangeChoice = dayIndex == null || data.todayWeekIndex == null || dayIndex >= data.todayWeekIndex;
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
return ( return (
<> <>
{easterEgg && eggImage && <img ref={eggRef} alt='' src={URL.createObjectURL(eggImage)} style={{ position: 'absolute', ...EASTER_EGG_STYLE, ...style, animationDuration: `${duration ?? EASTER_EGG_DEFAULT_DURATION}s` }} />}
<Header /> <Header />
<div className='wrapper'> <div className='wrapper'>
{data.isWeekend ? <h4>Užívejte víkend :)</h4> : <> {data.isWeekend ? <h4>Užívejte víkend :)</h4> : <>
<Alert variant={'primary'}> <Alert variant={'primary'}>
<img src='hat.png' style={{ position: "absolute", width: "70px", rotate: "-45deg", left: -40, top: -58 }} />
<img src='snowman.png' style={{ position: "absolute", height: "110px", right: 10, top: 5 }} />
Poslední změny: Poslední změny:
<ul> <ul>
<li>Oprava parsování při neočekávané velikosti písmen v ceně</li> <li>Přidání restaurací Zastávka u Michala a Pivovarský šenk Šeříková</li>
<li>Přidání nedělitelných mezer k cenám</li> <li>Možnost výběru restaurace a jídel kliknutím v tabulce</li>
</ul> </ul>
</Alert> </Alert>
<h1 className='title'>Dnes je {data.date}</h1> {dayIndex != null &&
<div className='day-navigator'>
<FontAwesomeIcon title="Předchozí den" icon={faChevronLeft} style={{ cursor: "pointer", visibility: dayIndex > 0 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex - 1)} />
<h1 className='title' style={{ color: dayIndex === data.todayWeekIndex ? 'black' : 'gray' }}>{data.date}</h1>
<FontAwesomeIcon title="Následující den" icon={faChevronRight} style={{ cursor: "pointer", visibility: dayIndex < 4 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex + 1)} />
</div>
}
<Row className='food-tables'> <Row className='food-tables'>
{renderFoodTable('Sladovnická', food[Restaurants.SLADOVNICKA])} {food[Restaurants.SLADOVNICKA] && renderFoodTable(Locations.SLADOVNICKA, food[Restaurants.SLADOVNICKA])}
{renderFoodTable('U Motlíků', food[Restaurants.UMOTLIKU])} {/* {food[Restaurants.UMOTLIKU] && renderFoodTable(food[Restaurants.UMOTLIKU])} */}
{renderFoodTable('TechTower', food[Restaurants.TECHTOWER])} {food[Restaurants.TECHTOWER] && renderFoodTable(Locations.TECHTOWER, food[Restaurants.TECHTOWER])}
{food[Restaurants.ZASTAVKAUMICHALA] && renderFoodTable(Locations.ZASTAVKAUMICHALA, food[Restaurants.ZASTAVKAUMICHALA])}
{food[Restaurants.SENKSERIKOVA] && renderFoodTable(Locations.SENKSERIKOVA, food[Restaurants.SENKSERIKOVA])}
</Row> </Row>
<div className='content-wrapper'> <div className='content-wrapper'>
<div className='content'> <div className='content'>
<p>Jak to dnes vidíš s obědem?</p> {canChangeChoice && <>
<Form.Select ref={choiceRef} onChange={changeChoice}> <p>{`Jak to ${dayIndex == null || dayIndex === data.todayWeekIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}</p>
<option></option> <Form.Select ref={choiceRef} onChange={doAddChoice}>
<option value={Locations.SLADOVNICKA}>Sladovnická</option> <option></option>
<option value={Locations.UMOTLIKU}>U Motlíků</option> {Object.entries(Locations)
<option value={Locations.TECHTOWER}>TechTower</option> .filter(entry => {
<option value={Locations.SPSE}>SPŠE</option> const locationKey = entry[0] as LocationKey;
<option value={Locations.PIZZA}>Pizza day</option> const restaurantKey = Object.keys(Restaurants).indexOf(locationKey);
<option value={Locations.OBJEDNAVAM}>Budu objednávat (mimo pizzu)</option> const v = Object.values(Restaurants)[restaurantKey];
<option value={Locations.NEOBEDVAM}>Mám vlastní/neobědvám</option> return v == null || !food[v]?.closed;
</Form.Select> })
<p style={{ fontSize: "12px", marginTop: "5px" }}> .map(entry => <option key={entry[0]} value={entry[0]}>{entry[1]}</option>)}
Aktuálně je možné vybrat pouze jednu variantu. </Form.Select>
</p> <small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small>
{foodChoiceList && !closed && <>
<p style={{ marginTop: "10px" }}>Na co dobrého? <small>(nepovinné)</small></p>
<Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}>
<option></option>
{foodChoiceList.map((food, index) => <option key={index} value={index}>{food.name}</option>)}
</Form.Select>
</>}
{foodChoiceList && !closed && <>
<p style={{ marginTop: "10px" }}>V kolik hodin preferuješ odchod?</p>
<Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}>
<option></option>
{Object.values(DepartureTime)
.filter(time => isInTheFuture(time))
.map(time => <option key={time} value={time}>{time}</option>)}
</Form.Select>
</>}
</>}
{Object.keys(data.choices).length > 0 ? {Object.keys(data.choices).length > 0 ?
<Table striped bordered hover className='results-table mt-5'> <Table bordered className='mt-5'>
<tbody> <tbody>
{Object.keys(data.choices).map((key: string, index: number) => {Object.keys(data.choices).map(key => {
<tr key={index}> const locationKey = key as LocationKey;
<td>{Object.values(Locations)[Number(key)]}</td> const locationName = Locations[locationKey];
<td> const loginObject = data.choices[locationKey];
<ul> if (!loginObject) {
{data.choices[Number(key)].map((p: string, index: number) => return;
<li key={index}>{p} {p === auth.login && <FontAwesomeIcon onClick={() => { }
removeChoice(key); const locationLoginList = Object.entries(loginObject);
}} title='Odstranit' className='trash-icon' icon={faTrashCan} />}</li> const disabled = false;
)} return (
</ul> <tr key={key}>
</td> <td>{locationName}</td>
</tr> <td className='p-0'>
<Table>
<tbody>
{locationLoginList.map((entry: [string, FoodChoices], index) => {
const login = entry[0];
const userPayload = entry[1];
const userChoices = userPayload?.options;
const trusted = userPayload?.trusted || false;
return <tr key={index}>
<td>
{trusted && <span className='trusted-icon'>
<FontAwesomeIcon title='Uživatel ověřený doménovým přihlášením' icon={faCircleCheck} style={{ cursor: "help" }} />
</span>}
{login}
{userPayload.departureTime && <small> ({userPayload.departureTime})</small>}
{userPayload.note && <small style={{ overflowWrap: 'anywhere' }}> ({userPayload.note})</small>}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => {
setNoteModalOpen(true);
}} title='Upravit poznámku' className='action-icon' icon={faNoteSticky} />}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => {
doRemoveChoices(key as LocationKey);
}} title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`} className='action-icon' icon={faTrashCan} />}
</td>
{userChoices?.length && food ? <td>
<ul>
{userChoices?.map(foodIndex => {
// TODO narovnat, tohle je zbytečně složité
const restaurantKey = Object.keys(Restaurants).indexOf(key);
const restaurant = Object.values(Restaurants)[restaurantKey];
const foodName = food[restaurant]?.food[foodIndex].name;
return <li key={foodIndex}>
{foodName}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => {
doRemoveFoodChoice(key as LocationKey, foodIndex);
}} title={`Odstranit ${foodName}`} className='action-icon' icon={faTrashCan} />}
</li>
})}
</ul>
</td> : null}
</tr>
}
)}
</tbody>
</Table>
</td>
<td>
<Button onClick={() => doJdemeObed(locationKey)} disabled={false}>Jdeme na oběd</Button>
</td>
</tr>)
}
)} )}
</tbody> </tbody>
</Table> </Table>
: <div className='mt-5'><i>Zatím nikdo nehlasoval...</i></div> : <div className='mt-5'><i>Zatím nikdo nehlasoval...</i></div>
} }
</div> </div>
<div className='mt-5'> {dayIndex === data.todayWeekIndex &&
{!data.pizzaDay && <div className='mt-5'>
<div style={{ textAlign: 'center' }}> {!data.pizzaDay &&
<p>Pro dnešní den není aktuálně založen Pizza day.</p>
<Button onClick={async () => {
await createPizzaDay();
}}>Založit Pizza day</Button>
</div>
}
{data.pizzaDay &&
<div>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<h3>Pizza day</h3> <p>Pro dnešní den není aktuálně založen Pizza day.</p>
{ {loadingPizzaDay ?
data.pizzaDay.state === PizzaDayState.CREATED && <span>
<div> <FontAwesomeIcon icon={faGear} className='fa-spin' /> Zjišťujeme dostupné pizzy
<p> </span>
Pizza Day je založen a spravován uživatelem {data.pizzaDay.creator}.<br /> :
Můžete upravovat své objednávky. <>
</p> <Button onClick={async () => {
{ setLoadingPizzaDay(true);
data.pizzaDay.creator === auth.login && await createPizzaDay().then(() => setLoadingPizzaDay(false));
<> }}>Založit Pizza day</Button>
<Button className='danger mb-3' title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={async () => { </>
await deletePizzaDay();
}}>Smazat Pizza day</Button>
<Button className='mb-3' style={{ marginLeft: '20px' }} title={noOrders ? "Nelze uzamknout - neexistuje žádná objednávka" : "Zamezí přidávat/odebírat objednávky. Použij před samotným objednáním, aby již nemohlo docházet ke změnám."} disabled={noOrders} onClick={async () => {
await lockPizzaDay();
}}>Uzamknout</Button>
</>
}
</div>
} }
{ </div>
data.pizzaDay.state === PizzaDayState.LOCKED && }
<div> {data.pizzaDay &&
<p>Objednávky jsou uzamčeny uživatelem {data.pizzaDay.creator}</p> <div>
{data.pizzaDay.creator === auth.login && <div style={{ textAlign: 'center' }}>
<> <h3>Pizza day</h3>
<Button className='danger mb-3' title="Umožní znovu editovat objednávky." onClick={async () => { {
await unlockPizzaDay(); data.pizzaDay.state === PizzaDayState.CREATED &&
}}>Odemknout</Button> <div>
{/* <Button className='danger mb-3' style={{ marginLeft: '20px' }} onClick={async () => { <p>
Pizza Day je založen a spravován uživatelem {data.pizzaDay.creator}.<br />
Můžete upravovat své objednávky.
</p>
{
data.pizzaDay.creator === auth.login &&
<>
<Button className='danger mb-3' title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={async () => {
await deletePizzaDay();
}}>Smazat Pizza day</Button>
<Button className='mb-3' style={{ marginLeft: '20px' }} title={noOrders ? "Nelze uzamknout - neexistuje žádná objednávka" : "Zamezí přidávat/odebírat objednávky. Použij před samotným objednáním, aby již nemohlo docházet ke změnám."} disabled={noOrders} onClick={async () => {
await lockPizzaDay();
}}>Uzamknout</Button>
</>
}
</div>
}
{
data.pizzaDay.state === PizzaDayState.LOCKED &&
<div>
<p>Objednávky jsou uzamčeny uživatelem {data.pizzaDay.creator}</p>
{data.pizzaDay.creator === auth.login &&
<>
<Button className='danger mb-3' title="Umožní znovu editovat objednávky." onClick={async () => {
await unlockPizzaDay();
}}>Odemknout</Button>
{/* <Button className='danger mb-3' style={{ marginLeft: '20px' }} onClick={async () => {
await addToCart(); await addToCart();
}}>Přidat vše do košíku</Button> */} }}>Přidat vše do košíku</Button> */}
<Button className='danger mb-3' style={{ marginLeft: '20px' }} title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={async () => { <Button className='danger mb-3' style={{ marginLeft: '20px' }} title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={async () => {
await finishOrder(); await finishOrder();
}}>Objednáno</Button> }}>Objednáno</Button>
</> </>
} }
</div>
}
{
data.pizzaDay.state === PizzaDayState.ORDERED &&
<div>
<p>Pizzy byly objednány uživatelem {data.pizzaDay.creator}</p>
{data.pizzaDay.creator === auth.login &&
<div>
<Button className='danger mb-3' title="Vrátí stav do předchozího kroku (před objednáním)." onClick={async () => {
await lockPizzaDay();
}}>Vrátit do "uzamčeno"</Button>
<Button className='danger mb-3' style={{ marginLeft: '20px' }} title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => {
await finishDelivery(settings?.bankAccount, settings?.holderName);
}}>Doručeno</Button>
</div>
}
</div>
}
{
data.pizzaDay.state === PizzaDayState.DELIVERED &&
<div>
<p>{`Pizzy byly doručeny.${myOrder?.hasQr ? ` Objednávku můžete uživateli ${data.pizzaDay.creator} uhradit pomocí QR kódu níže.` : ''}`}</p>
</div>
}
</div>
{data.pizzaDay.state === PizzaDayState.CREATED &&
<div style={{ textAlign: 'center' }}>
<SelectSearch
search={true}
options={pizzaSuggestions}
placeholder='Vyhledat pizzu...'
onChange={handlePizzaChange}
onBlur={_ => { }}
onFocus={_ => { }}
/>
Poznámka: <input ref={pizzaPoznamkaRef} className='mt-3' type="text" onKeyDown={event => {
if (event.key === 'Enter') {
handlePizzaPoznamkaChange();
}
event.stopPropagation();
}} />
<Button
style={{ marginLeft: '20px' }}
disabled={!myOrder?.pizzaList?.length}
onClick={handlePizzaPoznamkaChange}>
Uložit
</Button>
</div> </div>
} }
<PizzaOrderList state={data.pizzaDay.state} orders={data.pizzaDay.orders} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator} />
{ {
data.pizzaDay.state === PizzaDayState.ORDERED && data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr &&
<div> <div className='qr-code'>
<p>Pizzy byly objednány uživatelem {data.pizzaDay.creator}</p> <h3>QR platba</h3>
{data.pizzaDay.creator === auth.login && <img src={getQrUrl(auth.login)} alt='QR kód' />
<div>
<Button className='danger mb-3' title="Vrátí stav do předchozího kroku (před objednáním)." onClick={async () => {
await lockPizzaDay();
}}>Vrátit do "uzamčeno"</Button>
<Button className='danger mb-3' style={{ marginLeft: '20px' }} title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => {
await finishDelivery(bank?.bankAccount, bank?.holderName);
}}>Doručeno</Button>
</div>
}
</div>
}
{
data.pizzaDay.state === PizzaDayState.DELIVERED &&
<div>
<p>Pizzy byly doručeny. Objednávku můžete uhradit pomocí QR kódu níže.</p>
</div> </div>
} }
</div> </div>
{data.pizzaDay.state === PizzaDayState.CREATED && }
<div style={{ textAlign: 'center' }}> </div>
<SelectSearch }
search={true}
options={pizzaSuggestions}
placeholder='Vyhledat pizzu...'
onChange={handlePizzaChange}
/>
Poznámka: <input ref={poznamkaRef} className='mt-3' type="text" onKeyDown={event => {
if (event.key === 'Enter') {
handlePoznamkaChange();
}
}} />
<Button
style={{ marginLeft: '20px' }}
disabled={!myOrder?.pizzaList?.length}
onClick={handlePoznamkaChange}>
Uložit
</Button>
</div>
}
<PizzaOrderList state={data.pizzaDay.state} orders={data.pizzaDay.orders} onDelete={handlePizzaDelete} />
{
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr &&
<div className='qr-code'>
<h3>QR platba</h3>
<div>Částka: {myOrder.totalPrice} </div>
<img src={getQrUrl(auth.login)} alt='QR kód' />
<p>Generování QR kódů je v experimentální fázi - doporučujeme si překontrolovat údaje před odesláním platby.</p>
</div>
}
</div>
}
</div>
</div> </div>
</>} </>}
</div> </div>
<Footer />
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
</> </>
); );
} }

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { Button } from 'react-bootstrap'; import { Button } from 'react-bootstrap';
import { useAuth } from './context/auth'; import { useAuth } from './context/auth';
import { login } from './Api'; import { login } from './api/Api';
import './Login.css'; import './Login.css';
/** /**
@@ -11,6 +11,19 @@ export default function Login() {
const auth = useAuth(); const auth = useAuth();
const loginRef = useRef<HTMLInputElement>(null); const loginRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (auth && !auth.login) {
// Vyzkoušíme přihlášení "naprázdno", pokud projde, přihlásili nás trusted headers
login().then(token => {
if (token) {
auth?.setToken(token);
}
}).catch(error => {
// nezajímá nás
});
}
}, [auth]);
const doLogin = useCallback(async () => { const doLogin = useCallback(async () => {
const length = loginRef?.current?.value && loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length const length = loginRef?.current?.value && loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length
if (length) { if (length) {

View File

@@ -1,14 +1,4 @@
/** import {DepartureTime} from "../../types";
* Vrátí kořenovou URL serveru na základě aktuálního prostředí (vývojovou či produkční).
*
* @returns kořenová URL serveru
*/
export const getBaseUrl = (): string => {
if (process.env.PUBLIC_URL) {
return process.env.PUBLIC_URL;
}
return 'http://127.0.0.1:3001';
}
const TOKEN_KEY = "token"; const TOKEN_KEY = "token";
@@ -35,4 +25,40 @@ export const getToken = (): string | null => {
*/ */
export const deleteToken = () => { export const deleteToken = () => {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
} }
/**
* Vrátí human-readable reprezentaci předaného data a času pro zobrazení.
* Příklady:
* - dnes 10:52
* - 10.05.2023 10:52
*/
export function getHumanDateTime(datetime: Date) {
let hours = String(datetime.getHours()).padStart(2, '0');
let minutes = String(datetime.getMinutes()).padStart(2, "0");
if (new Date().toDateString() === datetime.toDateString()) {
return `dnes ${hours}:${minutes}`;
} else {
let day = String(datetime.getDate()).padStart(2, '0');
let month = String(datetime.getMonth() + 1).padStart(2, "0");
let year = datetime.getFullYear();
return `${day}.${month}.${year} ${hours}:${minutes}`;
}
}
/**
* Vrátí true, pokud je předaný čas větší než aktuální čas.
*/
export function isInTheFuture(time: DepartureTime) {
const now = new Date();
const currentHours = now.getHours();
const currentMinutes = now.getMinutes();
const currentDate = now.toDateString();
const [hours, minutes] = time.split(':').map(Number);
if (currentDate === now.toDateString()) {
return hours > currentHours || (hours === currentHours && minutes > currentMinutes);
}
return true;
}

83
client/src/api/Api.ts Normal file
View File

@@ -0,0 +1,83 @@
import { toast } from "react-toastify";
import { getToken } from "../Utils";
/**
* Wrapper pro volání API, u kterých chceme automaticky zobrazit toaster s chybou ze serveru.
*
* @param apiFunction volaná API funkce
*/
export function errorHandler<T>(apiFunction: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
apiFunction().then((result) => {
resolve(result);
}).catch(e => {
toast.error(e.message, { theme: "colored" });
});
});
}
async function request<TResponse>(
url: string,
config: RequestInit = {}
): Promise<TResponse> {
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
try {
const response = await fetch(url, config);
if (!response.ok) {
// TODO tohle je blbě, jelikož automaticky očekáváme, že v případě chyby přijde vždy JSON, což není pravda
const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error);
}
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
return response.json() as TResponse;
} else {
return response.text() as TResponse;
}
} catch (e) {
return Promise.reject(e);
}
}
async function blobRequest(
url: string,
config: RequestInit = {}
): Promise<Blob> {
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
try {
const response = await fetch(url, config);
if (!response.ok) {
const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error);
}
return response.blob()
} catch (e) {
return Promise.reject(e);
}
}
export const api = {
get: <TResponse>(url: string) => request<TResponse>(url),
blobGet: (url: string) => blobRequest(url),
post: <TBody, TResponse>(url: string, body?: TBody) => request<TResponse>(url, { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }),
}
export const getQrUrl = (login: string) => {
return `/api/qr?login=${login}`;
}
export const getData = async (dayIndex?: number) => {
let url = '/api/data';
if (dayIndex != null) {
url += '?dayIndex=' + dayIndex;
}
return await api.get<any>(url);
}
export const login = async (login?: string) => {
return await api.post<any, any>('/api/login', { login });
}

View File

@@ -0,0 +1,12 @@
import { EasterEgg } from "../../../types";
import { api } from "./Api";
const EASTER_EGGS_API_PREFIX = '/api/easterEggs';
export const getEasterEgg = async (): Promise<EasterEgg | undefined> => {
return await api.get<EasterEgg>(`${EASTER_EGGS_API_PREFIX}`);
}
export const getImage = async (url: string) => {
return await api.blobGet(`${EASTER_EGGS_API_PREFIX}/${url}`);
}

36
client/src/api/FoodApi.ts Normal file
View File

@@ -0,0 +1,36 @@
import {
AddChoiceRequest,
ChangeDepartureTimeRequest,
JdemeObedRequest,
LocationKey,
RemoveChoiceRequest,
RemoveChoicesRequest,
UpdateNoteRequest
} from "../../../types";
import { api } from "./Api";
const FOOD_API_PREFIX = '/api/food';
export const addChoice = async (locationKey: LocationKey, foodIndex?: number, dayIndex?: number) => {
return await api.post<AddChoiceRequest, void>(`${FOOD_API_PREFIX}/addChoice`, { locationKey, foodIndex, dayIndex });
}
export const removeChoices = async (locationKey: LocationKey, dayIndex?: number) => {
return await api.post<RemoveChoicesRequest, void>(`${FOOD_API_PREFIX}/removeChoices`, { locationKey, dayIndex });
}
export const removeChoice = async (locationKey: LocationKey, foodIndex: number, dayIndex?: number) => {
return await api.post<RemoveChoiceRequest, void>(`${FOOD_API_PREFIX}/removeChoice`, { locationKey, foodIndex, dayIndex });
}
export const updateNote = async (note?: string, dayIndex?: number) => {
return await api.post<UpdateNoteRequest, void>(`${FOOD_API_PREFIX}/updateNote`, { note, dayIndex });
}
export const changeDepartureTime = async (time: string, dayIndex?: number) => {
return await api.post<ChangeDepartureTimeRequest, void>(`${FOOD_API_PREFIX}/changeDepartureTime`, { time, dayIndex });
}
export const jdemeObed = async (locationKey: LocationKey) => {
return await api.post<JdemeObedRequest, void>(`${FOOD_API_PREFIX}/jdemeObed`, { locationKey });
}

View File

@@ -0,0 +1,44 @@
import { AddPizzaRequest, FinishDeliveryRequest, PizzaOrder, RemovePizzaRequest, UpdatePizzaDayNoteRequest, UpdatePizzaFeeRequest } from "../../../types";
import { api } from "./Api";
const PIZZADAY_API_PREFIX = '/api/pizzaDay';
export const createPizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/create`);
}
export const deletePizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/delete`);
}
export const lockPizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/lock`);
}
export const unlockPizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/unlock`);
}
export const finishOrder = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/finishOrder`);
}
export const finishDelivery = async (bankAccount?: string, bankAccountHolder?: string) => {
return await api.post<FinishDeliveryRequest, void>(`${PIZZADAY_API_PREFIX}/finishDelivery`, { bankAccount, bankAccountHolder });
}
export const addPizza = async (pizzaIndex: number, pizzaSizeIndex: number) => {
return await api.post<AddPizzaRequest, void>(`${PIZZADAY_API_PREFIX}/add`, { pizzaIndex, pizzaSizeIndex });
}
export const removePizza = async (pizzaOrder: PizzaOrder) => {
return await api.post<RemovePizzaRequest, void>(`${PIZZADAY_API_PREFIX}/remove`, { pizzaOrder });
}
export const updatePizzaDayNote = async (note?: string) => {
return await api.post<UpdatePizzaDayNoteRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaDayNote`, { note });
}
export const updatePizzaFee = async (login: string, text?: string, price?: number) => {
return await api.post<UpdatePizzaFeeRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaFee`, { login, text, price });
}

View File

@@ -0,0 +1,12 @@
import { FeatureRequest, UpdateFeatureVoteRequest } from "../../../types";
import { api } from "./Api";
const VOTING_API_PREFIX = '/api/voting';
export const getFeatureVotes = async () => {
return await api.get<FeatureRequest[]>(`${VOTING_API_PREFIX}/getVotes`);
}
export const updateFeatureVote = async (option: FeatureRequest, active: boolean) => {
return await api.post<UpdateFeatureVoteRequest, void>(`${VOTING_API_PREFIX}/updateVote`, { option, active });
}

View File

@@ -0,0 +1,7 @@
import { Navbar } from "react-bootstrap";
export default function Footer() {
return <Navbar className="text-light" variant='dark' expand="lg" style={{ display: "flex", justifyContent: "center" }}>
<span>🄯 Žádná práva nevyhrazena. TODO a zdrojové kódy dostupné <a href="https://gitea.melancholik.eu/mates/Luncher">zde</a>.</span>
</Navbar >
}

View File

@@ -1,22 +1,41 @@
import React, { useRef, useState } from "react"; import { useEffect, useState } from "react";
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap"; import { Navbar, Nav, NavDropdown } from "react-bootstrap";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import { useBank } from "../context/bank"; import SettingsModal from "./modals/SettingsModal";
import { useSettings } from "../context/settings";
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import { FeatureRequest } from "../../../types";
import { errorHandler } from "../api/Api";
import { getFeatureVotes, updateFeatureVote } from "../api/VotingApi";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
export default function Header() { export default function Header() {
const auth = useAuth(); const auth = useAuth();
const bank = useBank(); const settings = useSettings();
const [modalOpen, setModalOpen] = useState<boolean>(false); const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const bankAccountRef = useRef<HTMLInputElement>(null); const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const nameRef = useRef<HTMLInputElement>(null); const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[]>([]);
const openBankSettings = () => { useEffect(() => {
setModalOpen(true); if (auth?.login) {
getFeatureVotes().then(votes => {
setFeatureVotes(votes);
})
}
}, [auth?.login]);
const closeSettingsModal = () => {
setSettingsModalOpen(false);
} }
const closeModal = () => { const closeVotingModal = () => {
setModalOpen(false); setVotingModalOpen(false);
}
const closePizzaModal = () => {
setPizzaModalOpen(false);
} }
const isValidInteger = (str: string) => { const isValidInteger = (str: string) => {
@@ -29,14 +48,14 @@ export default function Header() {
return n !== Infinity && String(n) === str && n >= 0; return n !== Infinity && String(n) === str && n >= 0;
} }
const save = () => { const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => {
if (bankAccountRef.current?.value) { if (bankAccountNumber) {
try { try {
// Validace kódu banky // Validace kódu banky
if (bankAccountRef.current?.value.indexOf('/') < 0) { if (bankAccountNumber.indexOf('/') < 0) {
throw Error("Číslo účtu neobsahuje lomítko/kód banky") throw Error("Číslo účtu neobsahuje lomítko/kód banky")
} }
const split = bankAccountRef.current?.value.split("/"); const split = bankAccountNumber.split("/");
if (split[1].length !== 4) { if (split[1].length !== 4) {
throw Error("Kód banky musí být 4 číslice") throw Error("Kód banky musí být 4 číslice")
} }
@@ -71,9 +90,21 @@ export default function Header() {
return return
} }
} }
bank?.setBankAccountNumber(bankAccountRef.current?.value); settings?.setBankAccountNumber(bankAccountNumber);
bank?.setBankAccountHolderName(nameRef.current?.value); settings?.setBankAccountHolderName(bankAccountHolderName);
closeModal(); settings?.setHideSoupsOption(hideSoupsOption);
closeSettingsModal();
}
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
await errorHandler(() => updateFeatureVote(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">
@@ -82,28 +113,16 @@ export default function Header() {
<Navbar.Collapse id="basic-navbar-nav"> <Navbar.Collapse id="basic-navbar-nav">
<Nav className="nav"> <Nav className="nav">
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown"> <NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={openBankSettings}>Nastavit číslo účtu</NavDropdown.Item> <NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</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.Divider />
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item> <NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
</NavDropdown> </NavDropdown>
</Nav> </Nav>
</Navbar.Collapse> </Navbar.Collapse>
<Modal show={modalOpen} onHide={closeModal} size="lg"> <SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<Modal.Header closeButton> <FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<Modal.Title>Bankovní účet</Modal.Title> <PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
</Modal.Header>
<Modal.Body>
<p>Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.<br />Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.<br /><br />Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.</p>
Číslo účtu: <input className="mb-3" ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" defaultValue={bank?.bankAccount} /> <br />
Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={bank?.holderName} />
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={closeModal}>
Storno
</Button>
<Button variant="primary" onClick={save}>
Uložit
</Button>
</Modal.Footer>
</Modal>
</Navbar> </Navbar>
} }

View File

@@ -0,0 +1,19 @@
import { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
type Props = {
title?: String,
icon: IconDefinition,
description: String,
animation?: String,
}
function Loader(props: Props) {
return <div className='loader'>
<h1>{props.title || 'Prosím čekejte...'}</h1>
<FontAwesomeIcon icon={props.icon} className={`loader-icon mb-3 ` + (props.animation ?? '')} />
<p>{props.description}</p>
</div>
}
export default Loader;

View File

@@ -1,49 +1,46 @@
import React from "react";
import { Table } from "react-bootstrap"; import { Table } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Order, PizzaDayState, PizzaOrder } from "../../../types";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons"; import PizzaOrderRow from "./PizzaOrderRow";
import { useAuth } from "../context/auth"; import { updatePizzaFee } from "../api/PizzaDayApi";
import { Order, PizzaDayState, PizzaOrder } from "../types";
export default function PizzaOrderList({ state, orders, onDelete }: { state: PizzaDayState, orders: Order[], onDelete: (pizzaOrder: PizzaOrder) => void }) { type Props = {
const auth = useAuth(); state: PizzaDayState,
orders: Order[],
onDelete: (pizzaOrder: PizzaOrder) => void,
creator: string,
}
export default function PizzaOrderList({ state, orders, onDelete, creator }: Props) {
const saveFees = async (customer: string, text?: string, price?: number) => {
await updatePizzaFee(customer, text, price);
}
if (!orders?.length) { if (!orders?.length) {
return <p className="mt-3"><i>Zatím žádné objednávky...</i></p> return <p className="mt-3"><i>Zatím žádné objednávky...</i></p>
} }
const total = orders.map(order => order.pizzaList.map(o => o.price).reduce((total, i) => total + i)).reduce((total, i) => total + i); const total = orders.reduce((total, order) => total + order.totalPrice, 0);
return <Table className="mt-3" striped bordered hover> return <>
<thead> <Table className="mt-3" striped bordered hover>
<tr> <thead>
<th>Jméno</th> <tr>
<th>Objednávka</th> <th>Jméno</th>
<th>Pozmka</th> <th>Objedvka</th>
<th>Cena</th> <th>Poznámka</th>
</tr> <th>Příplatek</th>
</thead> <th>Cena</th>
<tbody> </tr>
{orders.map(order => <tr key={order.customer}> </thead>
<td>{order.customer}</td> <tbody>
<td>{order.pizzaList.map<React.ReactNode>((pizzaOrder, index) => {orders.map(order => <tr key={order.customer}>
<span key={index}> <PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`} </tr>)}
{auth?.login === order.customer && state === PizzaDayState.CREATED && <tr style={{ fontWeight: 'bold' }}>
<FontAwesomeIcon onClick={() => { <td colSpan={4}>Celkem</td>
onDelete(pizzaOrder); <td>{`${total}`}</td>
}} title='Odstranit' className='trash-icon' icon={faTrashCan} /> </tr>
} </tbody>
</span>) </Table>
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])} </>
</td>
<td>{order.note || '-'}</td>
<td>{order.totalPrice} </td>
</tr>)}
<tr style={{ fontWeight: 'bold' }}>
<td colSpan={3}>Celkem</td>
<td>{`${total}`}</td>
</tr>
</tbody>
</Table>
} }

View File

@@ -0,0 +1,45 @@
import React, { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMoneyBill1, faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { useAuth } from "../context/auth";
import { Order, PizzaDayState, PizzaOrder } from "../../../types";
import PizzaAdditionalFeeModal from "./modals/PizzaAdditionalFeeModal";
type Props = {
creator: string,
order: Order,
state: PizzaDayState,
onDelete: (order: PizzaOrder) => void,
onFeeModalSave: (customer: string, name?: string, price?: number) => void,
}
export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Props) {
const auth = useAuth();
const [isFeeModalOpen, setFeeModalOpen] = useState<boolean>(false);
const saveFees = (customer: string, text?: string, price?: number) => {
onFeeModalSave(customer, text, price);
setFeeModalOpen(false);
}
return <>
<td>{order.customer}</td>
<td>{order.pizzaList.map<React.ReactNode>((pizzaOrder, index) =>
<span key={index}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED &&
<FontAwesomeIcon onClick={() => {
onDelete(pizzaOrder);
}} title='Odstranit' className='action-icon' icon={faTrashCan} />
}
</span>)
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td>
<td style={{ maxWidth: "200px" }}>{order.note || '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td>
{order.totalPrice} {auth?.login === creator && state === PizzaDayState.CREATED && <FontAwesomeIcon onClick={() => { setFeeModalOpen(true) }} title='Nastavit příplatek' className='action-icon' icon={faMoneyBill1} />}
</td>
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
</>
}

View File

@@ -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 }: 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 && 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>
}

View File

@@ -0,0 +1,36 @@
import { useRef } from "react";
import { Modal, Button, Form } from "react-bootstrap"
type Props = {
isOpen: boolean,
onClose: () => void,
onSave: (note?: string) => void,
}
/** Modální dialog pro úpravu obecné poznámky. */
export default function NoteModal({ isOpen, onClose, onSave }: Props) {
const note = useRef<HTMLInputElement>(null);
const save = () => {
onSave(note?.current?.value);
}
return <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Úprava poznámky</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Control ref={note} autoFocus={true} type="text" id="note" onKeyDown={event => {
if (event.key === 'Enter') {
save();
}
event.stopPropagation();
}} />
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={save}>
Uložit
</Button>
</Modal.Footer>
</Modal>
}

View File

@@ -0,0 +1,45 @@
import { useRef } from "react";
import { Modal, Button } from "react-bootstrap"
type Props = {
customerName: string,
isOpen: boolean,
onClose: () => void,
onSave: (customer: string, name?: string, price?: number) => void,
initialValues?: { text?: string, price?: string },
}
/** Modální dialog pro nastavení příplatků za pizzu. */
export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose, onSave, initialValues }: Props) {
const textRef = useRef<HTMLInputElement>(null);
const priceRef = useRef<HTMLInputElement>(null);
const doSubmit = () => {
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0"));
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0"));
}
}
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>Příplatky za objednávku pro {customerName}</Modal.Title>
</Modal.Header>
<Modal.Body>
Popis: <input className="mb-3" ref={textRef} type="text" placeholder="např. kuřecí maso" defaultValue={initialValues?.text} onKeyDown={handleKeyDown} /> <br />
Cena v : <input ref={priceRef} type="number" placeholder="0" defaultValue={initialValues?.price} onKeyDown={handleKeyDown} /> <br />
<div className="mt-3" style={{ fontSize: 'small' }}>Je možné zadávat i záporné částky (např. v případě slev)</div>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Storno
</Button>
<Button variant="primary" onClick={doSubmit}>
Uložit
</Button>
</Modal.Footer>
</Modal>
}

View File

@@ -0,0 +1,141 @@
import { useRef, useState } from "react";
import { Modal, Button, Row, Col } from "react-bootstrap"
type Props = {
isOpen: boolean,
onClose: () => void,
}
type Result = {
pizza1?: {
diameter?: number,
area?: number,
pricePerM?: number,
},
pizza2?: {
diameter?: number,
area?: number,
pricePerM?: number,
}
choice?: number,
ratio?: number,
diameterDiff?: number,
}
/** Modální dialog pro výpočet výhodnosti pizzy. */
export default function PizzaCalculatorModal({ isOpen, onClose }: Props) {
const diameter1Ref = useRef<HTMLInputElement>(null);
const price1Ref = useRef<HTMLInputElement>(null);
const diameter2Ref = useRef<HTMLInputElement>(null);
const price2Ref = useRef<HTMLInputElement>(null);
const [result, setResult] = useState<Result | null>(null);
const recalculate = () => {
const r: Result = { ...result }
// 1. pizza
if (diameter1Ref.current?.value) {
const diameter1 = parseInt(diameter1Ref.current?.value);
if (!r.pizza1) {
r.pizza1 = {};
}
if (diameter1 && diameter1 > 0) {
r.pizza1.diameter = diameter1;
r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2);
if (price1Ref.current?.value) {
const price1 = parseInt(price1Ref.current?.value);
if (price1) {
r.pizza1.pricePerM = price1 / r.pizza1.area;
} else {
r.pizza1.pricePerM = undefined;
}
}
} else {
r.pizza1.area = undefined;
}
}
// 2. pizza
if (diameter2Ref.current?.value) {
const diameter2 = parseInt(diameter2Ref.current?.value);
if (!r.pizza2) {
r.pizza2 = {};
}
if (diameter2 && diameter2 > 0) {
r.pizza2.diameter = diameter2;
r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2);
if (price2Ref.current?.value) {
const price2 = parseInt(price2Ref.current?.value);
if (price2) {
r.pizza2.pricePerM = price2 / r.pizza2.area;
} else {
r.pizza2.pricePerM = undefined;
}
}
} else {
r.pizza2.area = undefined;
}
}
// Srovnání
if (r.pizza1?.pricePerM && r.pizza2?.pricePerM && r.pizza1.diameter && r.pizza2.diameter) {
r.choice = r.pizza1.pricePerM < r.pizza2.pricePerM ? 1 : 2;
const bigger = r.pizza1.pricePerM > r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM;
const smaller = r.pizza1.pricePerM < r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM;
r.ratio = (bigger / smaller) - 1;
r.diameterDiff = Math.abs(r.pizza1.diameter - r.pizza2.diameter);
} else {
r.choice = undefined;
r.ratio = undefined;
r.diameterDiff = undefined;
}
setResult(r);
}
const close = () => {
setResult(null);
onClose();
}
return <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Pizza kalkulačka</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Zadejte parametry pizzy pro jejich srovnání.</p>
<Row>
<Col size="6">
<input className="mb-3" ref={diameter1Ref} type="number" step="1" min="1" placeholder="Průměr 1. pizzy (cm)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
<Col size="6">
<input className="mb-3" ref={diameter2Ref} type="number" step="1" min="1" placeholder="Průměr 2. pizzy (cm)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
</Row>
<Row>
<Col size="6">
<input className="mb-3" ref={price1Ref} type="number" min="1" placeholder="Cena 1. pizzy (Kč)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
<Col size="6">
<input className="mb-3" ref={price2Ref} type="number" min="1" placeholder="Cena 2. pizzy (Kč)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
</Row>
<Row>
<Col size="6">
{result?.pizza1?.area && <p>Plocha: <b>{Math.round(result.pizza1.area * 10) / 10}</b> cm²</p>}
{result?.pizza1?.pricePerM && <p>Cena za m²: <b>{Math.round(result.pizza1.pricePerM * 1000000) / 100}</b> </p>}
</Col>
<Col size="6">
{result?.pizza2?.area && <p>Plocha: <b>{Math.round(result.pizza2.area * 10) / 10}</b> cm²</p>}
{result?.pizza2?.pricePerM && <p>Cena za m²: <b>{Math.round(result.pizza2.pricePerM * 1000000) / 100}</b> </p>}
</Col>
</Row>
{(result?.choice && result?.ratio && result?.ratio > 0 && result?.diameterDiff != null && <p><b>{result.choice}. pizza</b> je zhruba o <b>{Math.round(result.ratio * 1000) / 10}%</b> výhodnější než {result.choice === 1 ? "2" : "1"}. pizza.</p>) || ''}
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={close}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
}

View File

@@ -0,0 +1,42 @@
import { useRef } from "react";
import { Modal, Button } from "react-bootstrap"
import { useSettings } from "../../context/settings";
type Props = {
isOpen: boolean,
onClose: () => void,
onSave: (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => void,
}
/** Modální dialog pro uživatelská nastavení. */
export default function SettingsModal({ isOpen, onClose, onSave }: Props) {
const settings = useSettings();
const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const hideSoupsRef = useRef<HTMLInputElement>(null);
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Nastavení</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<h4>Obecné</h4>
<span title="V nabídkách nebudou zobrazovány polévky. Tato funkce je experimentální, a zejména u TechTower bývá často problém polévky spolehlivě rozeznat. V případě využití této funkce průběžně nahlašujte stále se zobrazující polévky." style={{ "cursor": "help" }}>
<input ref={hideSoupsRef} type="checkbox" defaultChecked={settings?.hideSoups} /> Skrýt polévky
</span>
<hr />
<h4>Bankovní účet</h4>
<p>Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.<br />Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.<br /><br />Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.</p>
Číslo účtu: <input className="mb-3" ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" defaultValue={settings?.bankAccount} onKeyDown={e => e.stopPropagation()} /> <br />
Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={settings?.holderName} onKeyDown={e => e.stopPropagation()} />
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Storno
</Button>
<Button variant="primary" onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value, hideSoupsRef.current?.checked)}>
Uložit
</Button>
</Modal.Footer>
</Modal>
}

View File

@@ -5,6 +5,7 @@ import { deleteToken, getToken, storeToken } from "../Utils";
export type AuthContextProps = { export type AuthContextProps = {
login?: string, login?: string,
trusted?: boolean,
setToken: (name: string) => void, setToken: (name: string) => void,
logout: () => void, logout: () => void,
} }
@@ -26,6 +27,7 @@ export const useAuth = () => {
function useProvideAuth(): AuthContextProps { function useProvideAuth(): AuthContextProps {
const [loginName, setLoginName] = useState<string | undefined>(); const [loginName, setLoginName] = useState<string | undefined>();
const [trusted, setTrusted] = useState<boolean | undefined>();
const [token, setToken] = useState<string | null>(getToken()); const [token, setToken] = useState<string | null>(getToken());
const { decodedToken } = useJwt(token || ''); const { decodedToken } = useJwt(token || '');
@@ -40,18 +42,27 @@ function useProvideAuth(): AuthContextProps {
useEffect(() => { useEffect(() => {
if (decodedToken) { if (decodedToken) {
setLoginName((decodedToken as any).login); setLoginName((decodedToken as any).login);
setTrusted((decodedToken as any).trusted);
} else { } else {
setLoginName(undefined); setLoginName(undefined);
setTrusted(undefined);
} }
}, [decodedToken]); }, [decodedToken]);
function logout() { function logout() {
const trusted = (decodedToken as any).trusted;
const logoutUrl = (decodedToken as any).logoutUrl;
setToken(null); setToken(null);
setLoginName(undefined); setLoginName(undefined);
setTrusted(undefined);
if (trusted && logoutUrl?.length) {
window.location.replace(logoutUrl);
}
} }
return { return {
login: loginName, login: loginName,
trusted,
setToken, setToken,
logout, logout,
} }

View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
import { getEasterEgg } from "../api/EasterEggApi";
import { AuthContextProps } from "./auth";
import { EasterEgg } from "../../../types";
export const useEasterEgg = (auth?: AuthContextProps | null): [EasterEgg | undefined, boolean] => {
const [result, setResult] = useState<EasterEgg | undefined>();
const [loading, setLoading] = useState(false);
useEffect(() => {
async function fetchEasterEgg() {
if (auth?.login) {
setLoading(true);
const egg = await getEasterEgg();
setResult(egg);
setLoading(false);
}
}
fetchEasterEgg();
}, [auth?.login]);
return [result, loading];
}

View File

@@ -3,32 +3,36 @@ import { useEffect } from "react"
const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number'; 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';
export type BankContextProps = { export type SettingsContextProps = {
bankAccount?: string, bankAccount?: string,
holderName?: string, holderName?: string,
hideSoups?: boolean,
setBankAccountNumber: (accountNumber?: string) => void, setBankAccountNumber: (accountNumber?: string) => void,
setBankAccountHolderName: (holderName?: string) => void, setBankAccountHolderName: (holderName?: string) => void,
setHideSoupsOption: (hideSoups?: boolean) => void,
} }
type ContextProps = { type ContextProps = {
children: ReactNode children: ReactNode
} }
const bankContext = React.createContext<BankContextProps | null>(null); const settingsContext = React.createContext<SettingsContextProps | null>(null);
export function ProvideBank(props: ContextProps) { export function ProvideSettings(props: ContextProps) {
const bank = useProvideBank(); const settings = useProvideSettings();
return <bankContext.Provider value={bank}>{props.children}</bankContext.Provider> return <settingsContext.Provider value={settings}>{props.children}</settingsContext.Provider>
} }
export const useBank = () => { export const useSettings = () => {
return useContext(bankContext); return useContext(settingsContext);
} }
function useProvideBank(): BankContextProps { 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>();
useEffect(() => { useEffect(() => {
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY); const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
@@ -39,6 +43,10 @@ function useProvideBank(): BankContextProps {
if (holderName) { if (holderName) {
setHolderName(holderName); setHolderName(holderName);
} }
const hideSoups = localStorage.getItem(HIDE_SOUPS_KEY);
if (hideSoups !== null) {
setHideSoups(hideSoups === 'true' ? true : false);
}
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -57,6 +65,14 @@ function useProvideBank(): BankContextProps {
} }
}, [holderName]); }, [holderName]);
useEffect(() => {
if (hideSoups) {
localStorage.setItem(HIDE_SOUPS_KEY, hideSoups ? 'true' : 'false');
} else {
localStorage.removeItem(HIDE_SOUPS_KEY);
}
}, [hideSoups]);
function setBankAccountNumber(bankAccount?: string) { function setBankAccountNumber(bankAccount?: string) {
setBankAccount(bankAccount); setBankAccount(bankAccount);
} }
@@ -65,10 +81,16 @@ function useProvideBank(): BankContextProps {
setHolderName(holderName); setHolderName(holderName);
} }
function setHideSoupsOption(hideSoups?: boolean) {
setHideSoups(hideSoups);
}
return { return {
bankAccount, bankAccount,
holderName, holderName,
hideSoups,
setBankAccountNumber, setBankAccountNumber,
setBankAccountHolderName, setBankAccountHolderName,
setHideSoupsOption,
} }
} }

View File

@@ -4,9 +4,10 @@ import App from './App';
import { SocketContext, socket } from './context/socket'; import { SocketContext, socket } from './context/socket';
import { ProvideAuth } from './context/auth'; import { ProvideAuth } from './context/auth';
import { ToastContainer } from 'react-toastify'; import { ToastContainer } from 'react-toastify';
import { ProvideBank } from './context/bank'; import { ProvideSettings } from './context/settings';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import './index.css'; import './index.css';
import Snowfall from 'react-snowfall';
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement document.getElementById('root') as HTMLElement
@@ -14,12 +15,19 @@ const root = ReactDOM.createRoot(
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<ProvideAuth> <ProvideAuth>
<ProvideBank> <ProvideSettings>
<SocketContext.Provider value={socket}> <SocketContext.Provider value={socket}>
<App /> <>
<Snowfall style={{
zIndex: 2,
position: 'fixed',
width: '100vw',
height: '100vh'}} />
<App />
</>
<ToastContainer /> <ToastContainer />
</SocketContext.Provider> </SocketContext.Provider>
</ProvideBank> </ProvideSettings>
</ProvideAuth> </ProvideAuth>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -1 +0,0 @@
/// <reference types="react-scripts" />

View File

@@ -1,11 +1,14 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "ESNext",
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",
"esnext" "esnext"
], ],
"types": [
"vite/client"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,

1
client/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

16
client/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import viteTsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
// depending on your application, base can also be "/"
base: '',
plugins: [react(), viteTsconfigPaths()],
server: {
open: true,
port: 3000,
proxy: {
'/api': 'http://localhost:3001',
}
},
})

1556
client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

27
compose.yml Normal file
View File

@@ -0,0 +1,27 @@
services:
redis:
image: redis/redis-stack-server:7.2.0-RC3
restart: always
ports:
- '6379:6379'
#expose:
# - 6379
environment:
- REDIS_ARGS=--save 3600 1 --loglevel warning
volumes:
- redis:/data
luncher:
depends_on:
- redis
restart: always
build:
context: ./
ports:
- 3001:3001
environment:
- TZ=Europe/Prague
volumes:
- "/etc/timezone:/etc/timezone:ro"
volumes:
redis:
driver: local

View File

@@ -1,9 +0,0 @@
version: '3.8'
services:
luncher:
restart: always
build:
context: ./
ports:
- 3001:3001

View File

@@ -1,8 +0,0 @@
{
"private": true,
"workspaces": [
"client",
"server",
"types"
]
}

View File

@@ -1,6 +1,19 @@
# Secret pro podepisování JWT tokenů. Minimální délka 32 znaků. # Secret pro podepisování JWT tokenů. Minimální délka 32 znaků.
# JWT_SECRET='CHANGE_ME' # JWT_SECRET='CHANGE_ME'
# URL pro externí odhlášení, kam bude uživatel při odhlášení přesměrován pokud byl přihlášen pomocí Trusted Headers.
# LOGOUT_URL='https://auth.example.com/logout'
# Datové úložiště. Musí být 'json' nebo 'redis' (není case sensitive).
# json - Data jsou ukládána do JSON souboru. Pomalé (práce se souborem), ale vhodné pro vývoj (snadnější prohlížení dat).
# redis - Data jsou ukládána v Redis serveru. Dle potřeby může být nutné upravit REDIS_ proměnné viz dále.
# STORAGE='json'
# Hostname/IP Redis serveru, pokud je použit STORAGE='redis'. Výchozí hodnota je 'localhost'.
# REDIS_HOST='localhost'
# Port Redis serveru, pokud je použit STORAGE='redis', výchozí hodnota je 6379.
# REDIS_PORT=6379
# Zapne režim mockování obědových menu. # Zapne režim mockování obědových menu.
# Vhodné pro vývoj o víkendech, svátcích a dalších dnech, pro které podniky nenabízejí obědové menu. # Vhodné pro vývoj o víkendech, svátcích a dalších dnech, pro které podniky nenabízejí obědové menu.
# V tomto režimu vrací server vždy falešné datum (pracovní den) a pevně nadefinovanou, smyšlenou nabídku jídel. # V tomto režimu vrací server vždy falešné datum (pracovní den) a pevně nadefinovanou, smyšlenou nabídku jídel.
@@ -11,3 +24,17 @@
# To je užitečné pro odesílání upozornění na různé servery Gotify s různými klíči API. # To je užitečné pro odesílání upozornění na různé servery Gotify s různými klíči API.
# Struktura dat je ve formátu JSON a je uložena jako řetězec. # Struktura dat je ve formátu JSON a je uložena jako řetězec.
# GOTIFY_SERVERS_AND_KEYS='[{"server":"https://notification.server.eu", "api_keys":["key1", "key2"]},{"server":"https://notification.server2.eu", "api_keys":["key3", "key4"]}]' # GOTIFY_SERVERS_AND_KEYS='[{"server":"https://notification.server.eu", "api_keys":["key1", "key2"]},{"server":"https://notification.server2.eu", "api_keys":["key3", "key4"]}]'
#NTFY_HOST = "http://192.168.0.113:80"
#NTFY_USERNAME="username"
#NTFY_PASSWD="password"
# Zapne přihlašování pomocí důvěryhodných hlaviček (trusted headers). Výchozí hodnota je false.
# V případě zapnutí je nutno vyplnit také HTTP_REMOTE_TRUSTED_IPS.
# HTTP_REMOTE_USER_ENABLED=true
# Seznam IP adres nebo rozsahů oddělených čárkou, ze kterých budou akceptovány důvěryhodné hlavičky.
# HTTP_REMOTE_TRUSTED_IPS=127.0.0.1,192.168.1.0/24
# Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'.
# HTTP_REMOTE_USER_HEADER_NAME=remote-user

6
server/.gitignore vendored
View File

@@ -1,5 +1,5 @@
/node_modules
/dist /dist
data.json /resources/easterEggs
.env.production .env.production
.env.development .env.development
.easter-eggs.json

6
server/babel.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
};

View File

@@ -6,15 +6,22 @@
"private": true, "private": true,
"scripts": { "scripts": {
"start": "ts-node src/index.ts", "start": "ts-node src/index.ts",
"startReload": "nodemon src/index.ts", "startReload": "nodemon --watch src src/index.ts",
"build": "tsc -p ." "build": "tsc -p .",
"test": "jest"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.23.0",
"@babel/preset-env": "^7.22.20",
"@babel/preset-typescript": "^7.23.0",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.2", "@types/jest": "^29.5.14",
"@types/node": "^20.2.5", "@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.11.20",
"@types/request-promise": "^4.1.48", "@types/request-promise": "^4.1.48",
"nodemon": "^2.0.22", "babel-jest": "^29.7.0",
"jest": "^29.7.0",
"nodemon": "^3.1.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.0.2" "typescript": "^5.0.2"
}, },
@@ -22,9 +29,10 @@
"axios": "^1.4.0", "axios": "^1.4.0",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.1.3", "dotenv": "^16.4.5",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"redis": "^4.6.7",
"simple-json-db": "^2.0.0", "simple-json-db": "^2.0.0",
"socket.io": "^4.6.1" "socket.io": "^4.6.1"
} }

View File

@@ -4,9 +4,10 @@ import jwt from 'jsonwebtoken';
* Vygeneruje a vrátí podepsaný JWT token pro daný login. * Vygeneruje a vrátí podepsaný JWT token pro daný login.
* *
* @param login přihlašovací jméno uživatele * @param login přihlašovací jméno uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @returns JWT token * @returns JWT token
*/ */
export function generateToken(login?: string): string { export function generateToken(login?: string, trusted?: boolean): string {
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
throw Error("Není vyplněna proměnná prostředí JWT_SECRET"); throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
} }
@@ -16,7 +17,8 @@ export function generateToken(login?: string): string {
if (!login || login.trim().length === 0) { if (!login || login.trim().length === 0) {
throw Error("Nebyl předán login"); throw Error("Nebyl předán login");
} }
return jwt.sign({ login }, process.env.JWT_SECRET); const payload = { login, trusted: trusted || false, logoutUrl: process.env.LOGOUT_URL };
return jwt.sign(payload, process.env.JWT_SECRET);
} }
/** /**
@@ -50,4 +52,20 @@ export function getLogin(token?: string): string {
} }
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;
}
/**
* Vrátí zda je uživatel používající daný token ověřený, pokud je token platný.
*
* @param token JWT token
*/
export function getTrusted(token?: string): boolean {
if (!process.env.JWT_SECRET) {
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
if (!token) {
throw Error("Nebyl předán token");
}
const payload: any = jwt.verify(token, process.env.JWT_SECRET);
return payload.trusted || false;
} }

View File

@@ -1,9 +1,6 @@
import os from 'os';
import path from 'path';
import fs from 'fs';
import axios from 'axios'; import axios from 'axios';
import { load } from 'cheerio'; import { load } from 'cheerio';
import { formatDate } from './utils'; import { getPizzaListMock } from './mock';
// TODO přesunout do types // TODO přesunout do types
type PizzaSize = { type PizzaSize = {
@@ -39,8 +36,14 @@ const boxPrices: { [key: string]: number } = {
/** /**
* Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie. * Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie.
*
* @param mock zda vrátit pouze mock data
*/ */
const downloadPizzy = async () => { export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
if (mock) {
// Záměrné zpoždění pro testování
return new Promise((resolve) => setTimeout(() => resolve(getPizzaListMock()), 3000));
}
// Získáme seznam pizz // Získáme seznam pizz
const html = await axios.get(pizzyUrl).then(res => res.data); const html = await axios.get(pizzyUrl).then(res => res.data);
const $ = load(html); const $ = load(html);
@@ -81,26 +84,4 @@ const downloadPizzy = async () => {
}); });
} }
return result; return result;
}
/**
* Vrátí pizzy z tempu, nebo čerstvě stažené, pokud v tempu nejsou.
*/
export const fetchPizzy = async (): Promise<Pizza[]> => {
const tmpDir = os.tmpdir();
const date_ob = new Date();
const dateStr = formatDate(date_ob);
const dataPath = path.join(tmpDir, `chefie-${dateStr}.json`);
if (fs.existsSync(dataPath)) {
console.log(`Soubor pro ${dataPath} již existuje, bude použit.`);
const rawdata = fs.readFileSync(dataPath);
return JSON.parse(rawdata.toString());
} else {
console.log(`Soubor pro ${dataPath} neexistuje, stahuji...`);
const pizzy = await downloadPizzy();
fs.writeFileSync(dataPath, JSON.stringify(pizzy));
console.log(`Zapsán ${dataPath}`);
return pizzy;
}
} }

View File

@@ -1,3 +0,0 @@
import JSONdb from 'simple-json-db';
export const db = new JSONdb('./data.json');

View File

@@ -1,15 +1,17 @@
import express from "express"; import express from "express";
import { Server } from "socket.io";
import bodyParser from "body-parser"; import bodyParser from "body-parser";
import { fetchPizzy } from "./chefie";
import cors from 'cors'; import cors from 'cors';
import { addPizzaOrder, createPizzaDay, deletePizzaDay, finishPizzaDelivery, finishPizzaOrder, getData, lockPizzaDay, removePizzaOrder, unlockPizzaDay, updateChoice, updateNote } from "./service"; import { getData, getDateForWeekIndex } from "./service";
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { getMenuSladovnicka, getMenuTechTower, getMenuUMotliku } from "./restaurants";
import { getQr } from "./qr"; import { getQr } from "./qr";
import { generateToken, getLogin, verify } from "./auth"; import { generateToken, verify } from "./auth";
import { Restaurants } from "../../types"; import { InsufficientPermissions } from "./utils";
import { initWebsocket } from "./websocket";
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes from "./routes/foodRoutes";
import votingRoutes from "./routes/votingRoutes";
import easterEggRoutes from "./routes/easterEggRoutes";
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}`) });
@@ -21,47 +23,57 @@ if (!process.env.JWT_SECRET) {
const app = express(); const app = express();
const server = require("http").createServer(app); const server = require("http").createServer(app);
const io = new Server(server, { initWebsocket(server);
cors: {
origin: "*",
},
});
// Body-parser middleware for parsing JSON // Body-parser middleware for parsing JSON
app.use(bodyParser.json()); app.use(bodyParser.json());
// app.use(express.json());
app.use(cors({ app.use(cors({
origin: '*' origin: '*'
})); }));
app.use(express.static('public')) // 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 parseToken = (req: any) => { const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME || 'remote-user';
if (req?.headers?.authorization) { if (HTTP_REMOTE_USER_ENABLED) {
return req.headers.authorization.split(' ')[1]; if (!process.env.HTTP_REMOTE_TRUSTED_IPS) {
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());
//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);
console.log('Zapnutý login přes hlavičky z proxy.');
} }
// ----------- Metody nevyžadující token -------------- // ----------- Metody nevyžadující token --------------
app.get("/api/whoami", (req, res) => { app.get("/api/whoami", (req, res) => {
res.send(req.header('remote-user')); if (!HTTP_REMOTE_USER_ENABLED) {
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
}
res.send(req.header(HTTP_REMOTE_USER_HEADER_NAME));
}) })
app.post("/api/login", (req, res) => { app.post("/api/login", (req, res) => {
// Autentizace pomocí trusted headers if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy')
const remoteUser = req.header('remote-user'); // Autentizace pomocí trusted headers
if (remoteUser && remoteUser.length > 0) { const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
res.status(200).json(generateToken(remoteUser)); const remoteName = req.header('remote-name');
return; if (remoteUser && remoteUser.length > 0 && remoteName && remoteName.length > 0) {
res.status(200).json(generateToken(Buffer.from(remoteName, 'latin1').toString(), true));
} else {
throw Error("Tohle nema nastat nekdo neco dela spatne.");
}
} else {
// Klasická autentizace loginem
if (!req.body?.login || req.body.login.trim().length === 0) {
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));
} }
// Klasická autentizace loginem
if (!req.body?.login || req.body.login.trim().length === 0) {
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));
}); });
// TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token // TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token
@@ -81,9 +93,17 @@ app.get("/api/qr", (req, res) => {
// ---------------------------------------------------- // ----------------------------------------------------
/** Middleware ověřující JWT token */ /** Middleware ověřující JWT token */
app.use((req, res, next) => { app.use("/api/", (req, res, next) => {
if (req.header('remote-user')) { if (HTTP_REMOTE_USER_ENABLED) {
console.log("Tvuj username: %s.", req.header('remote-user')); const userHeader = req.header(HTTP_REMOTE_USER_HEADER_NAME);
const nameHeader = req.header('remote-name');
const emailHeader = req.header('remote-email');
if (userHeader !== undefined && nameHeader !== undefined) {
const remoteName = Buffer.from(nameHeader, 'latin1').toString();
if (ENVIRONMENT !== "production"){
console.log("Tvuj username, name a email: %s, %s, %s.", userHeader, remoteName, emailHeader);
}
}
} }
if (!req.headers.authorization) { if (!req.headers.authorization) {
return res.status(401).json({ error: 'Nebyl předán autentizační token' }); return res.status(401).json({ error: 'Nebyl předán autentizační token' });
@@ -96,133 +116,32 @@ app.use((req, res, next) => {
}); });
/** Vrátí data pro aktuální den. */ /** Vrátí data pro aktuální den. */
app.get("/api/data", (req, res) => { app.get("/api/data", async (req, res) => {
res.status(200).json(getData()); let date = undefined;
}); if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') {
const index = parseInt(req.query.dayIndex);
/** Vrátí obědové menu pro dostupné podniky. */ if (!isNaN(index)) {
app.get("/api/food", async (req, res) => { date = getDateForWeekIndex(parseInt(req.query.dayIndex));
const mock = !!process.env.MOCK_DATA;
const date = new Date();
const data = {
[Restaurants.SLADOVNICKA]: await getMenuSladovnicka(date, mock),
[Restaurants.UMOTLIKU]: await getMenuUMotliku(date, mock),
[Restaurants.TECHTOWER]: await getMenuTechTower(date, mock),
}
res.status(200).json(data);
});
/** Vrátí seznam dostupných pizz. */
app.get("/api/pizza", (req, res) => {
fetchPizzy().then(pizzaList => {
// console.log("Výsledek", pizzaList);
res.status(200).json(pizzaList);
});
});
/** Založí pizza day pro aktuální den, za předpokladu že dosud neexistuje. */
app.post("/api/createPizzaDay", (req, res) => {
const login = getLogin(parseToken(req));
const data = createPizzaDay(login);
res.status(200).json(data);
io.emit("message", data);
});
/** Smaže pizza day pro aktuální den, za předpokladu že existuje. */
app.post("/api/deletePizzaDay", (req, res) => {
const login = getLogin(parseToken(req));
const data = deletePizzaDay(login);
io.emit("message", data);
});
app.post("/api/addPizza", (req, res) => {
const login = getLogin(parseToken(req));
if (isNaN(req.body?.pizzaIndex)) {
throw Error("Nebyl předán index pizzy");
}
const pizzaIndex = req.body.pizzaIndex;
if (isNaN(req.body?.pizzaSizeIndex)) {
throw Error("Nebyl předán index velikosti pizzy");
}
const pizzaSizeIndex = req.body.pizzaSizeIndex;
fetchPizzy().then(pizzy => {
if (!pizzy[pizzaIndex]) {
throw Error("Neplatný index pizzy: " + pizzaIndex);
} }
if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) {
throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
}
const data = addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
io.emit("message", data);
res.status(200).json({});
})
});
app.post("/api/removePizza", (req, res) => {
const login = getLogin(parseToken(req));
if (!req.body?.pizzaOrder) {
throw Error("Nebyla předána objednávka");
} }
const data = removePizzaOrder(login, req.body?.pizzaOrder); res.status(200).json(await getData(date));
io.emit("message", data);
res.status(200).json({});
}); });
app.post("/api/lockPizzaDay", (req, res) => { // Ostatní routes
const login = getLogin(parseToken(req)); app.use("/api/pizzaDay", pizzaDayRoutes);
const data = lockPizzaDay(login); app.use("/api/food", foodRoutes);
io.emit("message", data); app.use("/api/voting", votingRoutes);
res.status(200).json({}); app.use("/api/easterEggs", easterEggRoutes);
}); app.use(express.static('public'))
app.post("/api/unlockPizzaDay", (req, res) => { // Middleware pro zpracování chyb
const login = getLogin(parseToken(req)); app.use((err: any, req: any, res: any, next: any) => {
const data = unlockPizzaDay(login); if (err instanceof InsufficientPermissions) {
io.emit("message", data); res.status(403).send({ error: err.message })
res.status(200).json({}); } else {
}); res.status(500).send({ error: err.message })
app.post("/api/finishOrder", (req, res) => {
const login = getLogin(parseToken(req));
const data = finishPizzaOrder(login);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/finishDelivery", (req, res) => {
const login = getLogin(parseToken(req));
const data = finishPizzaDelivery(login, req.body.bankAccount, req.body.bankAccountHolder);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/updateChoice", (req, res) => {
const login = getLogin(parseToken(req));
const data = updateChoice(login, req.body.choice);
io.emit("message", data);
res.status(200).json(data);
});
app.post("/api/updateNote", (req, res) => {
const login = getLogin(parseToken(req));
if (req.body.note && req.body.note.length > 100) {
throw Error("Poznámka může mít maximálně 100 znaků");
} }
const data = updateNote(login, req.body.note); next();
io.emit("message", data);
res.status(200).json(data);
});
io.on("connection", (socket) => {
console.log(`New client connected: ${socket.id}`);
socket.on("message", (message) => {
io.emit("message", message);
});
socket.on("disconnect", () => {
console.log(`Client disconnected: ${socket.id}`);
});
}); });
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;

1386
server/src/mock.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,71 +1,160 @@
/** Notifikace pro gotify*/ /** Notifikace */
import { GotifyServer, NotififaceInput, NotifikaceData } from '../../types'; import {ClientData, Locations, NotififaceInput} from '../../types';
import axios, { AxiosError } from 'axios'; import axios from 'axios';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { getToday } from "./service";
import {formatDate, getUsersByLocation, getHumanTime} from "./utils";
import getStorage from "./storage";
const storage = getStorage();
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 gotifyDataRaw = process.env.GOTIFY_SERVERS_AND_KEYS || "{}"; // const gotifyDataRaw = process.env.GOTIFY_SERVERS_AND_KEYS || "{}";
const gotifyData: GotifyServer[] = JSON.parse(gotifyDataRaw); // const gotifyData: GotifyServer[] = JSON.parse(gotifyDataRaw);
// export const gotifyCall = async (data: NotififaceInput, gotifyServers?: GotifyServer[]): Promise<any[]> => {
// if (!Array.isArray(gotifyServers)) {
// return []
// }
// const urls = gotifyServers.flatMap(gotifyServer =>
// gotifyServer.api_keys.map(apiKey => `${gotifyServer.server}/message?token=${apiKey}`));
//
// const dataPayload = {
// title: "Luncher",
// message: `${data.udalost} - spustil:${data.user}`,
// priority: 7,
// };
//
// const headers = { "Content-Type": "application/json" };
//
// const promises = urls.map(url =>
// axios.post(url, dataPayload, { headers }).then(response => {
// response.data = {
// success: true,
// message: "Notifikace doručena",
// };
// return response;
// }).catch(error => {
// if (axios.isAxiosError(error)) {
// const axiosError = error as AxiosError;
// if (axiosError.response) {
// axiosError.response.data = {
// success: false,
// message: "fail",
// };
// console.log(error)
// return axiosError.response;
// }
// }
// // Handle unknown error without a response
// console.log(error, "unknown error");
// })
// );
// return promises;
// };
export const gotifyCall = async (data: NotififaceInput, gotifyServers?: GotifyServer[]): Promise<any[]> => { export const ntfyCall = async (data: NotififaceInput) => {
if (!Array.isArray(gotifyServers)) { const url = process.env.NTFY_HOST
return [] const username = process.env.NTFY_USERNAME;
const password = process.env.NTFY_PASSWD;
if (!url) {
console.log("NTFY_HOST není definován v env")
return
} }
const urls = gotifyServers.flatMap(gotifyServer => if (!username) {
gotifyServer.api_keys.map(apiKey => `${gotifyServer.server}/message?token=${apiKey}`)); console.log("NTFY_USERNAME není definován v env")
return
}
if (!password) {
console.log("NTFY_PASSWD není definován v env")
return
}
const today = formatDate(getToday());
let clientData: ClientData = await storage.getData(today);
const usersByLocation = getUsersByLocation(clientData.choices, data.user)
const dataPayload = { const token = Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
title: "Luncher", const promises = usersByLocation.map(async user => {
message: `${data.udalost} - spustil:${data.user}`, try {
priority: 7, // Odstraníme mezery a diakritiku a převedeme na lowercase
const topic = user.normalize('NFD').replace(' ', '').replace(/[\u0300-\u036f]/g, '').toLowerCase();
const response = await axios({
url: `${url}/${topic}`,
method: 'POST',
data: `${data.udalost} - spustil:${data.user}`,
headers: {
'Authorization': `Basic ${token}`,
'Tag': 'meat_on_bone'
}
});
console.log(response.data);
} catch (error) {
console.error(error);
}
})
return promises;
}
export const teamsCall = async (data: NotififaceInput) => {
const url = process.env.TEAMS_WEBHOOK_URL;
if (!url) {
console.log("TEAMS_WEBHOOK_URL není definován v env")
return
}
const title = data.udalost;
// const today = formatDate(getToday());
// let clientData: ClientData = await storage.getData(today);
// const usersByLocation = getUsersByLocation(clientData.choices, data.user)
let location = data.locationKey ? ` odcházíme do ${Locations[data.locationKey] ?? ''}` : ' jdeme na oběd';
const message = 'V ' + getHumanTime(getToday()) + location;
const card = {
'@type': 'MessageCard',
'@context': 'http://schema.org/extensions',
'themeColor': "0072C6", // light blue
summary: 'Summary description',
sections: [
{
activityTitle: title,
text: message,
},
],
}; };
const headers = { "Content-Type": "application/json" }; if (!url) {
console.log("TEAMS_WEBHOOK_URL není definován v env")
const promises = urls.map(url => return
axios.post(url, dataPayload, { headers }).then(response => {
response.data = {
success: true,
message: "Notifikace doručena",
};
return response;
}).catch(error => {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.response) {
axiosError.response.data = {
success: false,
message: "fail",
};
console.log(error)
return axiosError.response;
}
}
// Handle unknown error without a response
console.log(error, "unknown error");
})
);
return promises;
};
/** Zavolá notifikace na všechny konfigurované způsoby notifikace, přetížení proměných na false pro jednotlivé způsoby je vypne*/
export const callNotifikace = async ({ input, teams = true, gotify = true }: NotifikaceData) => {
const notifications = [];
if (gotify) {
const gotifyPromises = await gotifyCall(input, gotifyData);
notifications.push(...gotifyPromises);
} }
/* Zatím není try {
if (teams) { const response = await axios.post(url, card, {
notifications.push(teamsCall(input)); headers: {
}*/ 'content-type': 'application/vnd.microsoft.teams.card.o365connector'
},
});
return `${response.status} - ${response.statusText}`;
} catch (err) {
return err;
}
}
// Add more notifications as necessary /** Zavolá notifikace na všechny konfigurované způsoby notifikace, přetížení proměných na false pro jednotlivé způsoby je vypne*/
export const callNotifikace = async (input: NotififaceInput) => {
const notifications = [];
const ntfyPromises = await ntfyCall(input);
if (ntfyPromises) {
notifications.push(...ntfyPromises);
}
const teamsPromises = await teamsCall(input);
if (teamsPromises) {
notifications.push(teamsPromises);
}
// gotify bych řekl, že už je deprecated
// const gotifyPromises = await gotifyCall(input, gotifyData);
// notifications.push(...gotifyPromises);
try { try {
const results = await Promise.all(notifications); const results = await Promise.all(notifications);
@@ -74,4 +163,4 @@ export const callNotifikace = async ({ input, teams = true, gotify = true }: Not
console.error("Error in callNotifikace: ", error); console.error("Error in callNotifikace: ", error);
// Handle the error as needed // Handle the error as needed
} }
}; };

308
server/src/pizza.ts Normal file
View File

@@ -0,0 +1,308 @@
import { formatDate } from "./utils";
import { callNotifikace } from "./notifikace";
import { generateQr } from "./qr";
import { ClientData, PizzaDayState, UdalostEnum, Pizza, PizzaSize, Order, PizzaOrder, DayData } from "../../types";
import getStorage from "./storage";
import { downloadPizzy } from "./chefie";
import { getToday, initIfNeeded } from "./service";
const storage = getStorage();
/**
* 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> {
await initIfNeeded();
const today = formatDate(getToday());
let clientData: DayData = await storage.getData(today);
if (!clientData.pizzaList) {
const mock = process.env.MOCK_DATA === 'true';
clientData = await savePizzaList(await downloadPizzy(mock));
}
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> {
await initIfNeeded();
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
clientData.pizzaList = pizzaList;
clientData.pizzaListLastUpdate = new Date();
await storage.setData(today, clientData);
return clientData;
}
/**
* Vytvoří pizza day pro aktuální den a vrátí data pro klienta.
*/
export async function createPizzaDay(creator: string): Promise<ClientData> {
await initIfNeeded();
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
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 = await getPizzaList();
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, ...clientData };
await storage.setData(today, data);
callNotifikace({ udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator })
return data;
}
/**
* Smaže pizza day pro aktuální den.
*/
export async function deletePizzaDay(login: string): Promise<ClientData> {
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
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;
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) {
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
}
let order: Order | undefined = clientData.pizzaDay.orders.find(o => o.customer === login);
if (!order) {
order = {
customer: login,
pizzaList: [],
totalPrice: 0,
}
clientData.pizzaDay.orders.push(order);
}
const pizzaOrder: PizzaOrder = {
varId: size.varId,
name: pizza.name,
size: size.size,
price: size.price,
}
order.pizzaList.push(pizzaOrder);
order.totalPrice += pizzaOrder.price;
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: PizzaOrder) {
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
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);
if (index < 0) {
throw Error("Objednávka s danými parametry nebyla nalezena");
}
const price = order.pizzaList[index].price;
order.pizzaList.splice(index, 1);
order.totalPrice -= price;
if (order.pizzaList.length == 0) {
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) {
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED && clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED + " nebo " + PizzaDayState.ORDERED);
}
clientData.pizzaDay.state = PizzaDayState.LOCKED;
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) {
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.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) {
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
}
clientData.pizzaDay.state = PizzaDayState.ORDERED;
await storage.setData(today, clientData);
callNotifikace({ udalost: UdalostEnum.OBJEDNANA_PIZZA, user: clientData?.pizzaDay?.creator })
return clientData;
}
/**
* Nastaví stav pizza day na "pizzy doručeny".
* Vygeneruje QR kódy pro všechny objednatele, pokud objednávající má vyplněné číslo účtu a jméno.
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export async function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) {
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.ORDERED);
}
clientData.pizzaDay.state = PizzaDayState.DELIVERED;
// Vygenerujeme QR kód, pokud k tomu máme data
if (bankAccount?.length && bankAccountHolder?.length) {
for (const order of clientData.pizzaDay.orders) {
if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
let message = order.pizzaList.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message);
order.hasQr = true;
}
}
}
await storage.setData(today, clientData);
return clientData;
}
/**
* 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) {
const today = formatDate(getToday());
let clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
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 || !myOrder.pizzaList.length) {
throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
}
myOrder.note = note;
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) {
const today = formatDate(getToday());
let clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
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 || !targetOrder.pizzaList.length) {
throw Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
}
if (!price) {
delete targetOrder.fee;
} else {
targetOrder.fee = { text, price };
}
// Přepočet ceny
targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price || 0);
await storage.setData(today, clientData);
return clientData;
}

View File

@@ -1,19 +1,33 @@
import axios from "axios"; import axios from "axios";
import os from "os";
import path from "path";
import fs from "fs";
import { load } from 'cheerio'; import { load } from 'cheerio';
import { formatDate } from "./utils";
import { Food } from "../../types"; import { Food } from "../../types";
import {getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock, getMenuZastavkaUmichalaMock, getMenuSenkSerikovaMock} from "./mock";
import {formatDate} from "./utils";
// Fráze v názvech jídel, které naznačují že se jedná o polévku // Fráze v názvech jídel, které naznačují že se jedná o polévku
const SOUP_NAMES = ['polévka', 'česnečka', 'česnekový krém', 'cibulačka', 'vývar'] const SOUP_NAMES = [
'polévka',
'česnečka',
'česnekový krém',
'cibulačka',
'vývar',
'fazolová',
'cuketový krém',
'boršč',
'slepičí s ',
'zeleninová s ',
'hovězí s ',
'kachní kaldoun',
'dršťková'
];
const DAYS_IN_WEEK = ['pondělí', 'úterý', 'středa', 'čtvrtek', 'pátek', 'sobota', 'neděle']; const DAYS_IN_WEEK = ['pondělí', 'úterý', 'středa', 'čtvrtek', 'pátek', 'sobota', 'neděle'];
// URL na týdenní menu jednotlivých restaurací // URL na týdenní menu jednotlivých restaurací
const SLADOVNICKA_URL = 'https://sladovnicka.unasplzenchutna.cz/cz/denni-nabidka'; const SLADOVNICKA_URL = 'https://sladovnicka.unasplzenchutna.cz/cz/denni-nabidka';
const U_MOTLIKU_URL = 'https://www.umotliku.cz/menu'; const U_MOTLIKU_URL = 'https://www.umotliku.cz/menu';
const TECHTOWER_URL = 'https://www.equifarm.cz/restaurace-techtower'; const TECHTOWER_URL = 'https://www.equifarm.cz/restaurace-techtower';
const ZASTAVKAUMICHALA_URL = 'https://www.zastavkaumichala.cz';
const SENKSERIKOVA_URL = 'https://www.menicka.cz/6561-pivovarsky-senk-serikova.html';
/** /**
* Vrátí true, pokud předaný text obsahuje některé ze slov, které naznačuje, že se jedná o polévku. * Vrátí true, pokud předaný text obsahuje některé ze slov, které naznačuje, že se jedná o polévku.
@@ -36,324 +50,277 @@ const capitalize = (word: string): string => {
} }
const sanitizeText = (text: string): string => { const sanitizeText = (text: string): string => {
return text.replace('\t', '').trim(); return text.replace('\t', '').replace(' , ', ', ').trim();
} }
/** /**
* Vrátí index dne v týdnu, kde pondělí=0, neděle=6 * Stáhne a vrátí aktuální HTML z dané URL.
*
* @param date datum
* @returns index dne v týdnu
*/
const getDayOfWeekIndex = (date: Date) => {
// https://stackoverflow.com/a/4467559
return (((date.getDay() - 1) % 7) + 7) % 7;
}
/**
* Stáhne (v případě potřeby) a vrátí HTML z dané URL pro předané datum.
* Pokud je pro dané datum již staženo, vrátí jeho obsah ze souboru.
* *
* @param url URL pro stažení * @param url URL pro stažení
* @param prefix prefix pro uložení do souboru * @returns stažené HTML
* @param date datum ke kterému stáhnout HTML
* @returns stažené HTML, nebo HTML ze souborové cache
*/ */
const getHtml = async (url: string, prefix: string, date: Date): Promise<Buffer> => { const getHtml = async (url: string): Promise<any> => {
const fileName = path.join(os.tmpdir(), `${prefix}_${formatDate(date)}.html`); return await axios.get(url).then(res => res.data).then(content => content);
if (!fs.existsSync(fileName)) {
await axios.get(url).then(res => res.data).then(content => {
fs.writeFileSync(fileName, content);
});
}
return fs.readFileSync(fileName);
} }
/** /**
* Získá obědovou nabídku Sladovnické pro předané datum. * Získá obědovou nabídku Sladovnické pro jeden týden.
* *
* @param date datum, pro které získat menu * @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data * @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum * @returns seznam jídel pro daný týden
*/ */
export const getMenuSladovnicka = async (date: Date = new Date(), mock: boolean = false): Promise<Food[]> => { export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) { if (mock) {
return [ return getMenuSladovnickaMock();
{
amount: "0,25l",
name: "Zelná polévka s klobásou",
price: "35\xA0Kč",
isSoup: true,
},
{
amount: "150g",
name: "Hovězí na česneku s bramborovým knedlíkem",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "250g",
name: "Přírodní holandský řízek s bramborovou kaší, rajčatový salát",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "350g",
name: "Bagel s vinnou klobásou, cibulový konfit, kysané zelí, slanina a hořčicová mayo, hranolky, curry omáčka",
price: "135\xA0Kč",
isSoup: false,
}
]
} }
const todayDayIndex = getDayOfWeekIndex(date);
if (todayDayIndex == 5 || todayDayIndex == 6) { // Víkend const html = await getHtml(SLADOVNICKA_URL);
return [];
}
const html = await getHtml(SLADOVNICKA_URL, 'sladovnicka', date);
const $ = load(html); const $ = load(html);
// Najdeme index pro vstupní datum (např. při svátcích bude posunutý)
// TODO validovat, že vstupní datum je v aktuálním týdnu
// TODO tenhle způsob je zbytečně komplikovaný - stačilo by hledat rovnou v div.tab-content, protože každý den tam má datum taky (akorát je print-only)
const list = $('ul.tab-links').children(); const list = $('ul.tab-links').children();
const searchedDayText = `${date.getDate()}.${date.getMonth() + 1}.${capitalize(DAYS_IN_WEEK[todayDayIndex])}`; const result: Food[][] = [];
let index = undefined; for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
list.each((i, dayRow) => { const currentDate = new Date(firstDayOfWeek);
const rowText = $(dayRow).first().text().trim(); currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
if (rowText === searchedDayText) { const searchedDayText = `${currentDate.getDate()}.${currentDate.getMonth() + 1}.${capitalize(DAYS_IN_WEEK[dayIndex])}`;
index = i; // Najdeme index pro vstupní datum (např. při svátcích bude posunutý)
return; // TODO validovat, že vstupní datum je v aktuálním týdnu
} // TODO tenhle způsob je zbytečně komplikovaný - stačilo by hledat rovnou v div.tab-content, protože každý den tam má datum taky (akorát je print-only)
}) let index = undefined;
if (index === undefined) { list.each((i, dayRow) => {
// Pravděpodobně svátek, nebo je zavřeno const rowText = $(dayRow).first().text().trim();
return [{ if (rowText === searchedDayText) {
amount: undefined, index = i;
name: "Pro daný den nebyla nalezena denní nabídka",
price: "",
isSoup: false,
}];
}
// Dle dohledaného indexu najdeme správný tabpanel
const rows = $('div.tab-content').children();
if (index >= rows.length) {
throw Error("V HTML nebyl nalezen řádek menu pro index " + index);
}
const tabPanel = $(rows.get(index));
// Opětovná validace, že daný tabpanel je pro vstupní datum
const headers = tabPanel.find('h2');
if (headers.length !== 3) {
throw Error("Neočekávaný počet elementů h2 v menu pro datum " + searchedDayText + ", očekávány 3, ale nalezeno bylo " + headers.length);
}
const dayText = $(headers.get(0)).text().trim();
if (dayText !== searchedDayText) {
throw Error("Neočekávaný datum na řádce nalezeného dne: '" + dayText + "', ale očekáváno bylo '" + searchedDayText + "'");
}
// V tabpanelu očekáváme dvě tabulky - pro polévku a pro hlavní jídlo
const tables = tabPanel.find('table');
if (tables.length !== 2) {
throw Error("Neočekávaný počet tabulek na řádce nalezeného dne: " + tables.length + ", ale očekávány byly 2");
}
const results: Food[] = [];
// Polévka - div -> table -> tbody -> tr -> 3x td
const soupCells = $(tables.get(0)).children().first().children().first().children();
if (soupCells.length !== 3) {
throw Error("Neočekávaný počet buněk v tabulce polévky: " + soupCells.length + ", ale očekávány byly 3");
}
results.push({
amount: sanitizeText($(soupCells.get(0)).text()),
name: sanitizeText($(soupCells.get(1)).text()),
price: sanitizeText($(soupCells.get(2)).text().replace(' ', '\xA0')),
isSoup: true,
});
// Hlavní jídla - div -> table -> tbody -> 3x tr
const mainCourseRows = $(tables.get(1)).children().first().children();
// Záměrně zakomentováno - občas je ve Sladovnické jídel méně
// if (mainCourseRows.length !== 3) {
// throw Error("Neočekávaný počet řádek jídel: " + mainCourseRows.length + ", ale očekávány byly 3");
// }
mainCourseRows.each((i, foodRow) => {
const foodCells = $(foodRow).children();
if (foodCells.length !== 3) {
throw Error("Neočekávaný počet buněk v řádku jídla: " + foodCells.length + ", ale očekávány byly 3");
}
results.push({
amount: sanitizeText($(foodCells.get(0)).text()),
name: sanitizeText($(foodCells.get(1)).text()),
price: sanitizeText($(foodCells.get(2)).text().replace(' ', '\xA0')),
isSoup: false,
});
})
return results;
}
/**
* Získá obědovou nabídku restaurace U Motlíků pro předané datum.
*
* @param date datum, pro které získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuUMotliku = async (date: Date = new Date(), mock: boolean = false): Promise<Food[]> => {
if (mock) {
return [
{
amount: "0,33l",
name: "Hovězí vývar s nudlemi",
price: "35\xA0Kč",
isSoup: true,
},
{
amount: "150g",
name: "Opečený párek, čočka, sázené vejce, okurka",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "150g",
name: "Hovězí líčka na červeném víně, bramborová kaše",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "150g",
name: "Tortilla s trhaným kuřecím masem, uzeným sýrem, dipem a kukuřicí, míchaný salát",
price: "135\xA0Kč",
isSoup: false,
},
]
}
const todayDayIndex = getDayOfWeekIndex(date);
if (todayDayIndex == 5 || todayDayIndex == 6) { // Víkend
return [];
}
const html = await getHtml(U_MOTLIKU_URL, 'umotliku', date);
const $ = load(html);
const table = $('table.table.table-hover.Xtable-striped').first();
const body = table.children().first();
const rows = body.children();
const results: Food[] = [];
let parsing = false;
let isSoup = false;
rows.each((i, row) => {
const firstChild = $(row).children().get(0);
if (firstChild?.name == 'th') {
const childText = $(firstChild).text();
if (capitalize(DAYS_IN_WEEK[todayDayIndex]) === childText) { // Našli jsme dnešek
parsing = true;
} else if (parsing) {
// Narazili jsme na další den - konec parsování
parsing = false;
return; return;
} }
} else if (parsing) { // Jsme aktuálně na dnešním dni })
const children = $(row).children(); if (index === undefined) {
if (children.length === 1) { // Nadpis "Polévka" nebo "Hlavní jídlo" // Pravděpodobně svátek, nebo je zavřeno
const foodType = children.first().text(); result[dayIndex] = [{
if (foodType === 'Polévka') { amount: undefined,
isSoup = true; name: "Pro daný den nebyla nalezena denní nabídka",
} else if (foodType === 'Hlavní jídlo') { price: "",
isSoup = false; isSoup: false,
} else { }];
throw Error("Neočekáváný typ jídla: " + foodType); continue;
}
} else {
if (children.length !== 3) {
throw Error("Neočekávaný počet child elementů pro jídlo: " + children.length + ", očekávány 3");
}
const amount = sanitizeText($(children.get(0)).text());
const name = sanitizeText($(children.get(1)).text());
const price = sanitizeText($(children.get(2)).text()).replace(',-', '').replace(' ', '\xA0');
results.push({
amount,
name,
price,
isSoup,
})
}
} }
})
return results; // Dle dohledaného indexu najdeme správný tabpanel
const rows = $('div.tab-content').children();
if (index >= rows.length) {
throw Error("V HTML nebyl nalezen řádek menu pro index " + index);
}
const tabPanel = $(rows.get(index));
// Opětovná validace, že daný tabpanel je pro vstupní datum
const headers = tabPanel.find('h2');
if (headers.length !== 3) {
throw Error("Neočekávaný počet elementů h2 v menu pro datum " + searchedDayText + ", očekávány 3, ale nalezeno bylo " + headers.length);
}
const dayText = $(headers.get(0)).text().trim();
if (dayText !== searchedDayText) {
throw Error("Neočekávaný datum na řádce nalezeného dne: '" + dayText + "', ale očekáváno bylo '" + searchedDayText + "'");
}
// V tabpanelu očekáváme dvě tabulky - pro polévku a pro hlavní jídlo
const tables = tabPanel.find('table');
if (tables.length !== 2) {
throw Error("Neočekávaný počet tabulek na řádce nalezeného dne: " + tables.length + ", ale očekávány byly 2");
}
const currentDayFood: Food[] = [];
// Polévka - div -> table -> tbody -> tr -> 3x td
const soupCells = $(tables.get(0)).children().first().children().first().children();
if (soupCells.length !== 3) {
throw Error("Neočekávaný počet buněk v tabulce polévky: " + soupCells.length + ", ale očekávány byly 3");
}
currentDayFood.push({
amount: sanitizeText($(soupCells.get(0)).text()),
name: sanitizeText($(soupCells.get(1)).text()),
price: sanitizeText($(soupCells.get(2)).text().replace(' ', '\xA0')),
isSoup: true,
});
// Hlavní jídla - div -> table -> tbody -> 3x tr
const mainCourseRows = $(tables.get(1)).children().first().children();
mainCourseRows.each((i, foodRow) => {
const foodCells = $(foodRow).children();
if (foodCells.length !== 3) {
throw Error("Neočekávaný počet buněk v řádku jídla: " + foodCells.length + ", ale očekávány byly 3");
}
currentDayFood.push({
amount: sanitizeText($(foodCells.get(0)).text()),
name: sanitizeText($(foodCells.get(1)).text()),
price: sanitizeText($(foodCells.get(2)).text().replace(' ', '\xA0')),
isSoup: false,
});
})
result[dayIndex] = currentDayFood;
}
return result;
} }
/** /**
* Získá obědovou nabídku TechTower pro předané datum. * Získá obědovou nabídku restaurace U Motlíků pro jeden týden.
* *
* @param date datum, pro které získat menu * @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data * @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum * @returns seznam jídel pro dané datum
*/ */
export const getMenuTechTower = async (date: Date = new Date(), mock: boolean = false) => { export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) { if (mock) {
return [ return getMenuUMotlikuMock();
{
amount: "-",
name: "Bavorská gulášová polévka s kroupami",
price: "40\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Vepřové výpečky, kedlubnové zelí, bramborový knedlík",
price: "120\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Hambuger Black Angus s čedarem a slaninou, cibulové kroužky",
price: "220\xA0Kč",
isSoup: false,
}
]
} }
const todayDayIndex = getDayOfWeekIndex(date);
if (todayDayIndex == 5 || todayDayIndex == 6) { // Víkend const html = await getHtml(U_MOTLIKU_URL);
return [];
}
const html = await getHtml(TECHTOWER_URL, 'techtower', date);
const $ = load(html); const $ = load(html);
const fonts = $('font.wsw-41');
// Najdeme první tabulku, nad kterou je v H3 datum začínající co nejdřívějším dnem v aktuálním týdnu
const tables = $('table.table.table-hover.Xtable-striped');
let usedTable;
let usedDate = new Date(firstDayOfWeek.getTime());
for (let i = 0; i < 4; i++) {
const dayOfWeekString = `${usedDate.getDate()}.${usedDate.getMonth() + 1}.`;
for (const tableNode of tables) {
const table = $(tableNode);
const h3 = table.parent().prev();
const s1 = h3.text().split("-")[0].split(".");
const foundFirstDayString = `${s1[0]}.${s1[1]}.`;
if (foundFirstDayString === dayOfWeekString) {
usedTable = table;
}
}
if (usedTable != null) {
break;
}
usedDate.setDate(usedDate.getDate() + 1);
}
if (usedTable == null) {
const firstDayOfWeekString = `${firstDayOfWeek.getDate()}.${firstDayOfWeek.getMonth() + 1}.`;
throw Error(`Nepodařilo se najít tabulku pro týden začínající ${firstDayOfWeekString}`);
}
const body = usedTable.children().first();
const rows = body.children();
const result: Food[][] = [];
for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
if (!(dayIndex in result)) {
result[dayIndex] = [];
}
let parsing = false;
let isSoup = false;
rows.each((i, row) => {
const firstChild = $(row).children().get(0);
if (firstChild?.name == 'th') {
const childText = $(firstChild).text();
if (capitalize(DAYS_IN_WEEK[dayIndex]) === childText) {
parsing = true;
} else if (parsing) {
// Narazili jsme na další den - konec parsování
parsing = false;
return;
}
} else if (parsing) {
const children = $(row).children();
if (children.length === 1) { // Nadpis "Polévka" nebo "Hlavní jídlo"
const foodType = children.first().text();
if (foodType === 'Polévka') {
isSoup = true;
} else if (foodType === 'Hlavní jídlo') {
isSoup = false;
} else {
throw Error("Neočekáváný typ jídla: " + foodType);
}
} else {
if (children.length !== 3) {
throw Error("Neočekávaný počet child elementů pro jídlo: " + children.length + ", očekávány 3");
}
const amount = sanitizeText($(children.get(0)).text());
const name = sanitizeText($(children.get(1)).text());
const price = sanitizeText($(children.get(2)).text()).replace(',-', '').replace(' ', '\xA0');
result[dayIndex].push({
amount,
name,
price,
isSoup,
})
}
}
})
}
return result;
}
/**
* Získá obědovou nabídku TechTower pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuTechTowerMock();
}
const html = await getHtml(TECHTOWER_URL);
const $ = load(html);
let secondTry = false;
// První pokus - varianta "Obědy"
let fonts = $('font.wsw-41');
let font = undefined; let font = undefined;
fonts.each((i, f) => { fonts.each((i, f) => {
if ($(f).text().trim().startsWith('Obědy')) { if ($(f).text().trim().startsWith('Obědy')) {
font = f; font = f;
} }
}) })
// Druhý pokus - varianta "Jídelní lístek"
if (!font) {
fonts = $('font.wnd-font-size-90');
fonts.each((i, f) => {
if ($(f).text().trim().startsWith('Jídelní lístek')) {
font = f;
secondTry = true;
}
})
}
if (!font) { if (!font) {
throw Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.'); throw Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
} }
const result: Food[][] = [];
// TODO validovat, že v textu nalezeného <font> je rozsah, do kterého spadá vstupní datum // TODO validovat, že v textu nalezeného <font> je rozsah, do kterého spadá vstupní datum
const siblings = $(font).parent().parent().siblings(); const siblings = secondTry ? $(font).parent().siblings() : $(font).parent().parent().siblings();
let parsing = false; let parsing = false;
const results: Food[] = []; let currentDayIndex = 0;
for (let i = 0; i < siblings.length; i++) { for (let i = 0; i < siblings.length; i++) {
const text = $(siblings.get(i)).text().trim().replace('\t', '').replace('\n', ' '); const text = $(siblings.get(i)).text().trim().replace('\t', '').replace('\n', ' ');
if (DAYS_IN_WEEK.includes(text)) { if (DAYS_IN_WEEK.includes(text.toLocaleLowerCase())) {
if (text === DAYS_IN_WEEK[todayDayIndex]) { // Zjistíme aktuální index
// Našli jsme dnešní den, odtud začínáme parsovat jídla currentDayIndex = DAYS_IN_WEEK.indexOf(text.toLocaleLowerCase());
if (!parsing) {
// Našli jsme libovolný den v týdnu a ještě neparsujeme, tak začneme
parsing = true; parsing = true;
continue
} else if (parsing) {
// Už parsujeme jídla, ale narazili jsme na následující den - končíme
break;
} }
} else if (parsing) { } else if (parsing) {
if (text.length == 0) { if (text.length == 0) {
// Prázdná řádka - končíme (je za pátečním menu TechTower) // Prázdná řádka - bývá na zcela náhodných místech ¯\_(ツ)_/¯
break; continue;
} }
let price = '? Kč'; let price = 'na\xA0váhu';
let name = text; let name = text.replace('•', '');
if (text.toLowerCase().endsWith('kč')) { if (text.toLowerCase().endsWith('kč')) {
const tmp = text.replace('\xA0', ' ').split(' '); const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2)); const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
price = `${split.slice(1)[0]}\xA0Kč` price = `${split.slice(1)[0]}\xA0Kč`
name = split[0] name = split[0].replace('•', '');
} }
results.push({ if (result[currentDayIndex] == null) {
result[currentDayIndex] = [];
}
result[currentDayIndex].push({
amount: '-', amount: '-',
name, name,
price, price,
@@ -361,5 +328,107 @@ export const getMenuTechTower = async (date: Date = new Date(), mock: boolean =
}) })
} }
} }
return results; return result;
} }
/**
* Získá obědovou nabídku ZastavkaUmichala pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuZastavkaUmichala = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuZastavkaUmichalaMock();
}
const nowDate = new Date().getDate();
const headers = {
"Cookie": "_nss=1; PHPSESSID=9e37de17e0326b0942613d6e67a30e69",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
};
const result: Food[][] = [];
for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
const currentDate = new Date(firstDayOfWeek);
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
if (currentDate.getDate() < nowDate || (currentDate.getDate() === nowDate && new Date().getHours() >= 14)) {
result[dayIndex] = [{
amount: undefined,
name: "Pro tento den není uveřejněna nabídka jídel",
price: "",
isSoup: false,
}];
continue;
} else {
const url = (currentDate.getDate() === nowDate) ?
ZASTAVKAUMICHALA_URL : ZASTAVKAUMICHALA_URL + '/?do=dailyMenu-changeDate&dailyMenu-dateString=' + formatDate(currentDate, 'DD.MM.YYYY');
const html = await axios.get(url, {
headers,
}).then(res => res.data).then(content => content);
const $ = load(html);
const currentDayFood: Food[] = [];
$('.foodsList li').each((index, element) => {
currentDayFood.push({
amount: '-',
name: sanitizeText($(element).contents().not('span').text()),
price: sanitizeText($(element).find('span').text()),
isSoup: (index === 0),
});
});
result[dayIndex] = currentDayFood;
}
}
return result;
}
/**
* Získá obědovou nabídku SenkSerikova pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuSenkSerikova = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuSenkSerikovaMock();
}
const decoder = new TextDecoder('windows-1250');
const html = await axios.get(SENKSERIKOVA_URL, {
responseType: 'arraybuffer',
responseEncoding: 'binary'
}).then(res => decoder.decode(new Uint8Array(res.data))).then(content => content);
const $ = load(html);
const nowDate = new Date().getDate();
const currentDate = new Date(firstDayOfWeek);
const result: Food[][] = [];
let dayIndex = 0;
while(currentDate.getDate() < nowDate) {
result[dayIndex] = [{
amount: undefined,
name: "Pro tento den není uveřejněna nabídka jídel",
price: "",
isSoup: false,
}];
dayIndex = dayIndex + 1;
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
}
$('.menicka').each((i, element) => {
const currentDayFood: Food[] = [];
$(element).find('.popup-gallery li').each((j, element) => {
currentDayFood.push({
amount: '-',
name: $(element).children('div.polozka').text(),
price: $(element).children('div.cena').text().replace(/ /g, '\xA0'),
isSoup: $(element).hasClass('polevka'),
});
});
result[dayIndex++] = currentDayFood;
});
return result;
}

View File

@@ -0,0 +1,156 @@
import express, { NextFunction } from "express";
import { getLogin, getTrusted } from "../auth";
import { parseToken } from "../utils";
import path from "path";
import fs from "fs";
import { EasterEgg } from "../../../types";
const EASTER_EGGS_JSON_PATH = path.join(__dirname, "../../.easter-eggs.json");
const IMAGES_PATH = '../../resources/easterEggs';
type EasterEggsJson = {
[key: string]: EasterEgg[]
}
function generateUrl() {
let result = '';
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
let counter = 0;
while (counter < 32) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
counter += 1;
}
return result;
}
/**
* Vrátí náhodně jeden z definovaných easter egg obrázků pro přihlášeného uživatele.
*
* @param req request
* @param res response
* @param next next
* @returns náhodný easter egg obrázek, nebo 404 pokud žádný není definován
*/
function getEasterEggImage(req: any, res: any, next: NextFunction) {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
try {
// TODO vrátit!
// if (trusted) {
if (true) {
if (login in easterEggs) {
const imagePath = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)].path;
res.sendFile(path.join(__dirname, IMAGES_PATH, imagePath));
return;
}
}
res.sendStatus(404);
} catch (e: any) { next(e) }
}
function getRandomPosition(startOffset: number, endOffset: number) {
const choice = Math.floor(Math.random() * 4);
if (choice === 0) {
// Vlevo nahoře
return {
left: `${startOffset}px`,
startLeft: `${startOffset}px`,
"--start-left": `${startOffset}px`,
top: `${startOffset}px`,
startTop: `${startOffset}px`,
"--start-top": `${startOffset}px`,
endLeft: `${endOffset}px`,
"--end-left": `${endOffset}px`,
endTop: `${endOffset}px`,
"--end-top": `${endOffset}px`,
rotate: '135deg',
}
} else if (choice === 1) {
// Vpravo nahoře
return {
right: `${startOffset}px`,
startRight: `${startOffset}px`,
"--start-right": `${startOffset}px`,
top: `${startOffset}px`,
startTop: `${startOffset}px`,
"--start-top": `${startOffset}px`,
endRight: `${endOffset}px`,
"--end-right": `${endOffset}px`,
endTop: `${endOffset}px`,
"--end-top": `${endOffset}px`,
rotate: '-135deg',
}
} else if (choice === 2) {
// Vpravo dole
return {
right: `${startOffset}px`,
startRight: `${startOffset}px`,
"--start-right": `${startOffset}px`,
bottom: `${startOffset}px`,
startBottom: `${startOffset}px`,
"--start-bottom": `${startOffset}px`,
endRight: `${endOffset}px`,
"--end-right": `${endOffset}px`,
endBottom: `${endOffset}px`,
"--end-bottom": `${endOffset}px`,
rotate: '-45deg',
}
} else if (choice === 3) {
// Vlevo dole
return {
left: `${startOffset}px`,
startLeft: `${startOffset}px`,
"--start-left": `${startOffset}px`,
bottom: `${startOffset}px`,
startBottom: `${startOffset}px`,
"--start-bottom": `${startOffset}px`,
endLeft: `${endOffset}px`,
"--end-left": `${endOffset}px`,
endBottom: `${endOffset}px`,
"--end-bottom": `${endOffset}px`,
rotate: '45deg',
}
}
}
const router = express.Router();
let easterEggs: EasterEggsJson;
// Registrace náhodných URL pro všechny existující easter eggy
if (fs.existsSync(EASTER_EGGS_JSON_PATH)) {
const content = fs.readFileSync(EASTER_EGGS_JSON_PATH, 'utf-8');
easterEggs = JSON.parse(content);
for (const [key, eggs] of Object.entries(easterEggs)) {
for (const easterEgg of eggs) {
const url = generateUrl();
easterEgg.url = url;
router.get(`/${url}`, async (req, res, next) => {
return getEasterEggImage(req, res, next);
});
}
}
}
// Získání náhodného easter eggu pro přihlášeného uživatele
router.get("/", async (req, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
try {
// TODO vrátit!
// if (trusted) {
if (true) {
if (easterEggs && login in easterEggs) {
const randomEasterEgg = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)];
const { path, startOffset, endOffset, ...strippedEasterEgg } = randomEasterEgg; // Path klient k ničemu nepotřebuje a nemá ho znát
return res.status(200).json({ ...strippedEasterEgg, ...getRandomPosition(startOffset, endOffset) });
}
}
return res.status(200).send();
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -0,0 +1,153 @@
import express, { Request } from "express";
import { getLogin, getTrusted } from "../auth";
import { addChoice, addVolatileData, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote } from "../service";
import { getDayOfWeekIndex, parseToken } from "../utils";
import { getWebsocket } from "../websocket";
import { callNotifikace } from "../notifikace";
import {
AddChoiceRequest,
ChangeDepartureTimeRequest,
IDayIndex,
JdemeObedRequest,
RemoveChoiceRequest,
RemoveChoicesRequest,
UdalostEnum,
UpdateNoteRequest
} from "../../../types";
/**
* Ověří a vrátí index dne v týdnu z požadavku, za předpokladu, že byl předán, a je zároveň
* roven nebo vyšší indexu dnešního dne.
*
* @param req request
* @returns index dne v týdnu
*/
const parseValidateFutureDayIndex = (req: Request<{}, any, IDayIndex>) => {
if (req.body.dayIndex == null) {
throw Error(`Nebyl předán index dne v týdnu.`);
}
const todayDayIndex = getDayOfWeekIndex(getToday());
const dayIndex = req.body.dayIndex;
if (isNaN(dayIndex)) {
throw Error(`Neplatný index dne v týdnu: ${req.body.dayIndex}`);
}
if (dayIndex < todayDayIndex) {
throw Error(`Předaný index dne v týdnu (${dayIndex}) nesmí být nižší než dnešní den (${todayDayIndex})`);
}
return dayIndex;
}
const router = express.Router();
router.post("/addChoice", async (req: Request<{}, any, AddChoiceRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
getWebsocket().emit("message", await addVolatileData(data));
return res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await removeChoices(login, trusted, req.body.locationKey, date);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/updateNote", async (req: Request<{}, any, UpdateNoteRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
const note = req.body.note;
try {
if (note && note.length > 70) {
throw Error("Poznámka může mít maximálně 70 znaků");
}
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
const data = await updateNote(login, trusted, note, date);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeRequest>, res, next) => {
const login = getLogin(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await updateDepartureTime(login, req.body?.time, date);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/jdemeObed", async (req: Request<{}, any, JdemeObedRequest>, res, next) => {
const login = getLogin(parseToken(req));
try {
await callNotifikace({ user: login, udalost: UdalostEnum.JDEME_OBED, locationKey: req.body.locationKey });
res.status(200).json({});
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -0,0 +1,113 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee } from "../pizza";
import { parseToken } from "../utils";
import { getWebsocket } from "../websocket";
import { addVolatileData } from "../service";
import { AddPizzaRequest, FinishDeliveryRequest, RemovePizzaRequest, UpdatePizzaDayNoteRequest, UpdatePizzaFeeRequest } from "../../../types";
const router = express.Router();
/** Založí pizza day pro aktuální den, za předpokladu že dosud neexistuje. */
router.post("/create", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await createPizzaDay(login);
res.status(200).json(data);
getWebsocket().emit("message", await addVolatileData(data));
});
/** Smaže pizza day pro aktuální den, za předpokladu že existuje. */
router.post("/delete", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await deletePizzaDay(login);
getWebsocket().emit("message", await addVolatileData(data));
});
router.post("/add", async (req: Request<{}, any, AddPizzaRequest>, res) => {
const login = getLogin(parseToken(req));
if (isNaN(req.body?.pizzaIndex)) {
throw Error("Nebyl předán index pizzy");
}
const pizzaIndex = req.body.pizzaIndex;
if (isNaN(req.body?.pizzaSizeIndex)) {
throw Error("Nebyl předán index velikosti pizzy");
}
const pizzaSizeIndex = req.body.pizzaSizeIndex;
let pizzy = await getPizzaList();
if (!pizzy) {
throw Error("Selhalo získání seznamu dostupných pizz.");
}
if (!pizzy[pizzaIndex]) {
throw Error("Neplatný index pizzy: " + pizzaIndex);
}
if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) {
throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
}
const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({});
});
router.post("/remove", async (req: Request<{}, any, RemovePizzaRequest>, res) => {
const login = getLogin(parseToken(req));
if (!req.body?.pizzaOrder) {
throw Error("Nebyla předána objednávka");
}
const data = await removePizzaOrder(login, req.body?.pizzaOrder);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({});
});
router.post("/lock", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await lockPizzaDay(login);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({});
});
router.post("/unlock", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await unlockPizzaDay(login);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({});
});
router.post("/finishOrder", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await finishPizzaOrder(login);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({});
});
router.post("/finishDelivery", async (req: Request<{}, any, FinishDeliveryRequest>, res) => {
const login = getLogin(parseToken(req));
const data = await finishPizzaDelivery(login, req.body.bankAccount, req.body.bankAccountHolder);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({});
});
router.post("/updatePizzaDayNote", async (req: Request<{}, any, UpdatePizzaDayNoteRequest>, res, next) => {
const login = getLogin(parseToken(req));
try {
if (req.body.note && req.body.note.length > 70) {
throw Error("Poznámka může mít maximálně 70 znaků");
}
const data = await updatePizzaDayNote(login, req.body.note);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeRequest>, res, next) => {
const login = getLogin(parseToken(req));
if (!req.body.login) {
return res.status(400).json({ error: "Nebyl předán login cílového uživatele" });
}
try {
const data = await updatePizzaFee(login, req.body.login, req.body.text, req.body.price);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -0,0 +1,26 @@
import express, { Request, Response } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getUserVotes, updateFeatureVote } from "../voting";
import { FeatureRequest, UpdateFeatureVoteRequest } from "../../../types";
const router = express.Router();
router.get("/getVotes", async (req: Request<{}, any, undefined>, res: Response<FeatureRequest[]>) => {
const login = getLogin(parseToken(req));
const data = await getUserVotes(login);
res.status(200).json(data);
});
router.post("/updateVote", async (req: Request<{}, any, UpdateFeatureVoteRequest>, 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) }
});
export default router;

View File

@@ -1,286 +1,411 @@
import { db } from "./database"; import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getIsWeekend, getWeekNumber } from "./utils";
import { formatDate, getHumanDate, getIsWeekend } from "./utils"; import { ClientData, Restaurants, DayMenu, DepartureTime, DayData, WeekMenu, LocationKey } from "../../types";
import { callNotifikace } from "./notifikace"; import getStorage from "./storage";
import { generateQr } from "./qr"; import { getMenuSladovnicka, getMenuTechTower, getMenuUMotliku, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants";
import { ClientData, PizzaDayState, UdalostEnum, Pizza, PizzaSize, Order, PizzaOrder, Locations } from "../../types"; import { getTodayMock } from "./mock";
const storage = getStorage();
const MENU_PREFIX = 'menu';
/** 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í. */
function getToday(): Date { export function getToday(): Date {
if (process.env.MOCK_DATA) { if (process.env.MOCK_DATA === 'true') {
return new Date('2023-05-31'); return getTodayMock();
} }
return new Date(); return new Date();
} }
/** Vrátí "prázdná" (implicitní) data, pokud ještě nikdo nehlasoval. */ /** Vrátí datum v aktuálním týdnu na základě předaného indexu (0 = pondělí). */
function getEmptyData(): ClientData { export const getDateForWeekIndex = (index: number) => {
return { date: getHumanDate(getToday()), isWeekend: getIsWeekend(getToday()), choices: {} }; if (index < 0 || index > 4) {
// Nechceme shodit server, vrátíme dnešek
console.log('Neplatný index dne v týdnu: ' + index);
return getToday();
}
const date = getToday();
date.setDate(date.getDate() - getDayOfWeekIndex(date) + index);
return date;
}
/** Vrátí "prázdná" (implicitní) data pro předaný den. */
function getEmptyData(date?: Date): ClientData {
const usedDate = date || getToday();
return {
date: getHumanDate(usedDate),
isWeekend: getIsWeekend(usedDate),
weekIndex: getDayOfWeekIndex(usedDate),
choices: {},
};
} }
/** /**
* Vrátí veškerá klientská data pro aktuální den. * Přidá k datům "dopočítaná" data, která nejsou přímo uložena v databázi.
*
* @param data data z databáze
* @returns obohacená data
*/ */
export function getData(): ClientData { export async function addVolatileData(data: ClientData): Promise<ClientData> {
const data = db.get(formatDate(getToday())) || getEmptyData(); data.todayWeekIndex = getDayOfWeekIndex(getToday());
return data; return data;
} }
/** /**
* Vytvoří pizza day pro aktuální den a vrátí data pro klienta. * Vrátí veškerá klientská data pro předaný den, nebo aktuální den, pokud není předán.
*/ */
export function createPizzaDay(creator: string): ClientData { export async function getData(date?: Date): Promise<ClientData> {
initIfNeeded(); const targetDate = date ?? getToday();
const today = formatDate(getToday()); const dateString = formatDate(targetDate);
const clientData: ClientData = db.get(today); const data: DayData = await storage.getData(dateString) || getEmptyData(date);
if (clientData.pizzaDay) { let clientData: ClientData = { ...data };
throw Error("Pizza day pro dnešní den již existuje"); clientData.menus = {
[Restaurants.SLADOVNICKA]: await getRestaurantMenu(Restaurants.SLADOVNICKA, date),
// [Restaurants.UMOTLIKU]: await getRestaurantMenu(Restaurants.UMOTLIKU, date),
[Restaurants.TECHTOWER]: await getRestaurantMenu(Restaurants.TECHTOWER, date),
[Restaurants.ZASTAVKAUMICHALA]: await getRestaurantMenu(Restaurants.ZASTAVKAUMICHALA, date),
[Restaurants.SENKSERIKOVA]: await getRestaurantMenu(Restaurants.SENKSERIKOVA, date),
} }
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, ...clientData }; clientData = await addVolatileData(clientData);
db.set(today, data);
callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } })
return data;
}
/**
* Smaže pizza day pro aktuální den.
*/
export function deletePizzaDay(login: string) {
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
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;
db.set(today, clientData);
return clientData; return clientData;
} }
/** /**
* Přidá objednávku pizzy uživateli. * Vrátí klíč, pod kterým je uloženo menu pro předané datum.
*
* @param date datum
* @returns databázový klíč
*/
function getMenuKey(date: Date) {
const weekNumber = getWeekNumber(date);
return `${MENU_PREFIX}_${date.getFullYear()}_${weekNumber}`;
}
/**
* Vrátí menu restaurací pro předané datum, pokud již existují.
*
* @param date datum
* @returns menu restaurací pro předané datum
*/
async function getMenu(date: Date): Promise<WeekMenu | undefined> {
return await storage.getData(getMenuKey(date));
}
// TODO přesun do restaurants.ts
/**
* Vrátí menu dané restaurace pro předaný den.
* Pokud neexistuje, provede stažení menu pro příslušný týden a uložení do DB.
*
* @param restaurant restaurace
* @param date datum, ke kterému získat menu
* @param mock příznak, zda chceme pouze mock data
*/
export async function getRestaurantMenu(restaurant: Restaurants, date?: Date): Promise<DayMenu> {
const usedDate = date ?? getToday();
const dayOfWeekIndex = getDayOfWeekIndex(usedDate);
const now = new Date().getTime();
if (getIsWeekend(usedDate)) {
return {
lastUpdate: now,
closed: true,
food: [],
};
}
let menus = await getMenu(usedDate);
if (menus == null) {
menus = [];
}
for (let i = 0; i < 5; i++) {
if (menus[i] == null) {
menus[i] = {};
}
if (menus[i][restaurant] == null) {
menus[i][restaurant] = {
lastUpdate: now,
closed: false,
food: [],
};
}
}
if (!menus[dayOfWeekIndex][restaurant]?.food?.length) {
const firstDay = getFirstWorkDayOfWeek(usedDate);
const mock = process.env.MOCK_DATA === 'true';
switch (restaurant) {
case Restaurants.SLADOVNICKA:
try {
const sladovnickaFood = await getMenuSladovnicka(firstDay, mock);
for (let i = 0; i < sladovnickaFood.length; i++) {
menus[i][restaurant]!.food = sladovnickaFood[i];
// Velice chatrný a nespolehlivý způsob detekce uzavření...
if (sladovnickaFood[i].length === 1 && sladovnickaFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
menus[i][restaurant]!.closed = true;
}
}
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Sladovnická", e);
}
break;
// case Restaurants.UMOTLIKU:
// try {
// const uMotlikuFood = await getMenuUMotliku(firstDay, mock);
// for (let i = 0; i < uMotlikuFood.length; i++) {
// menus[i][restaurant]!.food = uMotlikuFood[i];
// if (uMotlikuFood[i].length === 1 && uMotlikuFood[i][0].name.toLowerCase() === 'zavřeno') {
// menus[i][restaurant]!.closed = true;
// }
// }
// } catch (e: any) {
// console.error("Selhalo načtení jídel pro podnik U Motlíků", e);
// }
// break;
case Restaurants.TECHTOWER:
try {
const techTowerFood = await getMenuTechTower(firstDay, mock);
for (let i = 0; i < techTowerFood.length; i++) {
menus[i][restaurant]!.food = techTowerFood[i];
if (techTowerFood[i]?.length === 1 && techTowerFood[i][0].name.toLowerCase() === 'svátek') {
menus[i][restaurant]!.closed = true;
}
}
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik TechTower", e);
}
case Restaurants.ZASTAVKAUMICHALA:
try {
const zastavkaUmichalaFood = await getMenuZastavkaUmichala(firstDay, mock);
for (let i = 0; i < zastavkaUmichalaFood.length; i++) {
menus[i][restaurant]!.food = zastavkaUmichalaFood[i];
if (zastavkaUmichalaFood[i]?.length === 1 && zastavkaUmichalaFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
menus[i][restaurant]!.closed = true;
}
}
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Zastávka u Michala", e);
}
case Restaurants.SENKSERIKOVA:
try {
const senkSerikovaFood = await getMenuSenkSerikova(firstDay, mock);
for (let i = 0; i < senkSerikovaFood.length; i++) {
menus[i][restaurant]!.food = senkSerikovaFood[i];
if (senkSerikovaFood[i]?.length === 1 && senkSerikovaFood[i][0].name === 'Pro tento den nebylo zadáno menu.') {
menus[i][restaurant]!.closed = true;
}
}
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Pivovarský šenk Šeříková", e);
}
}
await storage.setData(getMenuKey(usedDate), menus);
}
return menus[dayOfWeekIndex][restaurant]!;
}
/**
* Inicializuje výchozí data pro předané datum, nebo dnešek, pokud není datum předáno.
*
* @param date datum
*/
export async function initIfNeeded(date?: Date) {
const usedDate = formatDate(date ?? getToday());
const hasData = await storage.hasData(usedDate);
if (!hasData) {
await storage.setData(usedDate, getEmptyData(date || getToday()));
}
}
/**
* Odstraní kompletně volbu uživatele (včetně případných podřízených jídel).
* *
* @param login login uživatele * @param login login uživatele
* @param pizza zvolená pizza * @param trusted příznak, zda se jedná o ověřeného uživatele
* @param size zvolená velikost pizzy * @param locationKey vybrané "umístění"
* @param date datum, ke kterému se volba vztahuje
* @returns
*/ */
export function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize) { export async function removeChoices(login: string, trusted: boolean, locationKey: LocationKey, date?: Date) {
const today = formatDate(getToday()); const selectedDay = formatDate(date ?? getToday());
const clientData: ClientData = db.get(today); let data: DayData = await storage.getData(selectedDay);
if (!clientData.pizzaDay) { validateTrusted(data, login, trusted);
throw Error("Pizza day pro dnešní den neexistuje"); if (locationKey in data.choices) {
} if (data.choices[locationKey] && login in data.choices[locationKey]) {
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) { delete data.choices[locationKey][login]
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED); if (Object.keys(data.choices[locationKey]).length === 0) {
} delete data.choices[locationKey]
let order: Order | undefined = clientData.pizzaDay.orders.find(o => o.customer === login);
if (!order) {
order = {
customer: login,
pizzaList: [],
totalPrice: 0,
}
clientData.pizzaDay.orders.push(order);
}
const pizzaOrder: PizzaOrder = {
varId: size.varId,
name: pizza.name,
size: size.size,
price: size.price,
}
order.pizzaList.push(pizzaOrder);
order.totalPrice += pizzaOrder.price;
db.set(today, clientData);
return clientData;
}
/**
* Odstraní danou objednávku pizzy.
*
* @param login login uživatele
* @param pizzaOrder objednávka pizzy
*/
export function removePizzaOrder(login: string, pizzaOrder: PizzaOrder) {
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
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);
if (index < 0) {
throw Error("Objednávka s danými parametry nebyla nalezena");
}
const price = order.pizzaList[index].price;
order.pizzaList.splice(index, 1);
order.totalPrice -= price;
if (order.pizzaList.length == 0) {
clientData.pizzaDay.orders.splice(orderIndex, 1);
}
db.set(today, clientData);
return clientData;
}
/**
* Uzamkne možnost editovat objednávky pizzy.
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export function lockPizzaDay(login: string) {
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED && clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED + " nebo " + PizzaDayState.ORDERED);
}
clientData.pizzaDay.state = PizzaDayState.LOCKED;
db.set(today, clientData);
return clientData;
}
/**
* Odekmne možnost editovat objednávky pizzy.
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export function unlockPizzaDay(login: string) {
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
}
clientData.pizzaDay.state = PizzaDayState.CREATED;
db.set(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 function finishPizzaOrder(login: string) {
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
}
clientData.pizzaDay.state = PizzaDayState.ORDERED;
db.set(today, clientData);
callNotifikace({ input: { udalost: UdalostEnum.OBJEDNANA_PIZZA, user: clientData?.pizzaDay?.creator } })
return clientData;
}
/**
* Nastaví stav pizza day na "pizzy doručeny".
* Vygeneruje QR kódy pro všechny objednatele, pokud objednávající má vyplněné číslo účtu a jméno.
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) {
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.ORDERED);
}
clientData.pizzaDay.state = PizzaDayState.DELIVERED;
// Vygenerujeme QR kód, pokud k tomu máme data
// TODO berka je potřeba počkat na resolve promises z generateQr a až poté volat save do DB
if (bankAccount?.length && bankAccountHolder?.length) {
for (const order of clientData.pizzaDay.orders) {
if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
let message = order.pizzaList.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
const price = order.pizzaList.map(pizza => pizza.price).reduce((partial, a) => partial + a, 0);
generateQr(order.customer, bankAccount, bankAccountHolder, price, message).then(() => order.hasQr = true);
} }
await storage.setData(selectedDay, data);
} }
} }
db.set(today, clientData); return data;
return clientData;
} }
export function initIfNeeded() { /**
const today = formatDate(getToday()); * Odstraní konkrétní volbu jídla uživatele.
if (!db.has(today)) { * Neodstraňuje volbu samotnou, k tomu slouží {@link removeChoices}.
db.set(today, getEmptyData()); *
} * @param login login uživatele
} * @param trusted příznak, zda se jedná o ověřeného uživatele
* @param locationKey vybrané "umístění"
export function removeChoice(login: string, data: ClientData) { * @param foodIndex index jídla v jídelním lístku daného umístění, pokud existuje
for (let key of Object.keys(data.choices)) { * @param date datum, ke kterému se volba vztahuje
if (data.choices[key] && data.choices[key].includes(login)) { * @returns
const index = data.choices[key].indexOf(login); */
data.choices[key].splice(index, 1); export async function removeChoice(login: string, trusted: boolean, locationKey: LocationKey, foodIndex: number, date?: Date) {
if (data.choices[key].length == 0) { const selectedDay = formatDate(date ?? getToday());
delete data.choices[key]; let data: DayData = await storage.getData(selectedDay);
validateTrusted(data, login, trusted);
if (locationKey in data.choices) {
if (data.choices[locationKey] && login in data.choices[locationKey]) {
const index = data.choices[locationKey][login].options.indexOf(foodIndex);
if (index > -1) {
data.choices[locationKey][login].options.splice(index, 1)
await storage.setData(selectedDay, data);
} }
} }
} }
return data; return data;
} }
export function updateChoice(login: string, choice: Locations | null) { /**
initIfNeeded(); * Odstraní kompletně volbu uživatele, vyjma ignoredLocationKey (pokud byla předána a existuje).
const today = formatDate(getToday()); *
let data: ClientData = db.get(today); * @param login login uživatele
data = removeChoice(login, data); * @param date datum, ke kterému se volby vztahují
if (choice !== null) { * @param ignoredLocationKey volba, která nebude odstraněna, pokud existuje
if (!data.choices?.[choice]) { */
data.choices[choice] = []; async function removeChoiceIfPresent(login: string, date: string, ignoredLocationKey?: LocationKey) {
let data: DayData = await storage.getData(date);
for (const key of Object.keys(data.choices)) {
const locationKey = key as LocationKey;
if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
continue;
}
if (data.choices[locationKey] && login in data.choices[locationKey]) {
delete data.choices[locationKey][login];
if (Object.keys(data.choices[locationKey]).length === 0) {
delete data.choices[locationKey];
}
await storage.setData(date, data);
} }
data.choices[choice].push(login);
} }
db.set(today, data);
return data; return data;
} }
export function updateNote(login: string, note?: string) { /**
const today = formatDate(getToday()); * Ověří, zda se neověřený uživatel nepokouší přepsat údaje ověřeného a případně vyhodí chybu.
let clientData: ClientData = db.get(today); *
if (!clientData.pizzaDay) { * @param data aktuální klientská data
throw Error("Pizza day pro dnešní den neexistuje"); * @param login přihlašovací jméno uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
*/
function validateTrusted(data: ClientData, login: string, trusted: boolean) {
const locations = Object.values(data?.choices);
let found = false;
if (!trusted) {
for (const location of locations) {
if (Object.keys(location).includes(login) && location[login].trusted) {
found = true;
}
}
} }
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) { if (!trusted && found) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED); throw new InsufficientPermissions("Nelze změnit volbu ověřeného uživatele");
} }
const myOrder = clientData.pizzaDay.orders.find(o => o.customer === login); }
if (!myOrder || !myOrder.pizzaList.length) {
throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login); /**
* Přidá volbu uživatele.
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param locationKey vybrané "umístění"
* @param foodIndex volitelný index jídla v daném umístění
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param date datum, ke kterému se volba vztahuje
* @returns aktuální data
*/
export async function addChoice(login: string, trusted: boolean, locationKey: LocationKey, foodIndex?: number, date?: Date) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate);
const selectedDate = formatDate(usedDate);
let data: DayData = await storage.getData(selectedDate);
validateTrusted(data, login, trusted);
// Pokud měníme pouze lokaci, mažeme případné předchozí
if (foodIndex == null) {
data = await removeChoiceIfPresent(login, selectedDate);
} else {
// Mažeme případné ostatní volby (měla by být maximálně jedna)
removeChoiceIfPresent(login, selectedDate, locationKey);
}
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
if (!(data.choices[locationKey])) {
data.choices[locationKey] = {}
}
if (!(login in data.choices[locationKey])) {
if (!data.choices[locationKey]) {
data.choices[locationKey] = {}
}
data.choices[locationKey][login] = {
trusted,
options: []
};
}
if (foodIndex != null && !data.choices[locationKey][login].options.includes(foodIndex)) {
data.choices[locationKey][login].options.push(foodIndex);
}
await storage.setData(selectedDate, data);
return data;
}
/**
* Aktualizuje poznámku k aktuálně vybrané možnosti.
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param note poznámka
* @param date datum, ke kterému se volba vztahuje
*/
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate);
const selectedDate = formatDate(usedDate);
let data: DayData = await storage.getData(selectedDate);
validateTrusted(data, login, trusted);
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
if (userEntry) {
if (!note || !note.length) {
delete userEntry[1][login].note;
} else {
userEntry[1][login].note = note;
}
await storage.setData(selectedDate, data);
}
return data;
}
/**
* Aktualizuje preferovaný čas odchodu strávníka.
*
* @param login login uživatele
* @param time preferovaný čas odchodu
* @param date datum, ke kterému se čas vztahuje
*/
export async function updateDepartureTime(login: string, time?: string, date?: Date) {
const selectedDate = formatDate(date ?? getToday());
let clientData: DayData = await storage.getData(selectedDate);
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}`);
}
found[login].departureTime = time;
}
await storage.setData(selectedDate, clientData);
} }
myOrder.note = note;
db.set(today, clientData);
return clientData; return clientData;
} }

View File

@@ -0,0 +1,28 @@
import { ClientData } from "../../../types";
/**
* 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 {
/**
* 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>;
/**
* 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>;
/**
* 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>;
}

View File

@@ -0,0 +1,24 @@
import dotenv from 'dotenv';
import path from 'path';
import { StorageInterface } from "./StorageInterface";
import JsonStorage from "./json";
import RedisStorage from "./redis";
const ENVIRONMENT = process.env.NODE_ENV || 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
const JSON_KEY = 'json';
const REDIS_KEY = 'redis';
let storage: StorageInterface;
if (!process.env.STORAGE || process.env.STORAGE?.toLowerCase() === JSON_KEY) {
storage = new JsonStorage();
} else if (process.env.STORAGE?.toLowerCase() === REDIS_KEY) {
storage = new RedisStorage();
} else {
throw Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json' nebo 'redis'");
}
export default function getStorage(): StorageInterface {
return storage;
}

View File

@@ -0,0 +1,32 @@
import JSONdb from 'simple-json-db';
import { StorageInterface } from "./StorageInterface";
import * as fs from 'fs';
import * as path from 'path';
const dbPath = path.resolve(__dirname, '../../data/db.json');
const dbDir = path.dirname(dbPath);
// Zajistěte, že adresář existuje
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const db = new JSONdb(dbPath);
/**
* Implementace úložiště používající JSON soubor.
*/
export default class JsonStorage implements StorageInterface {
hasData(key: string): Promise<boolean> {
return Promise.resolve(db.has(key));
}
getData<Type>(key: string): Promise<Type> {
return db.get(key);
}
setData<Type>(key: string, data: Type): Promise<void> {
db.set(key, data);
return Promise.resolve();
}
}

View File

@@ -0,0 +1,31 @@
import { RedisClientType, createClient } from 'redis';
import { StorageInterface } from "./StorageInterface";
let client: RedisClientType;
/**
* Implementace úložiště využívající Redis server.
*/
export default class RedisStorage implements StorageInterface {
constructor() {
const HOST = process.env.REDIS_HOST || 'localhost';
const PORT = process.env.REDIS_PORT || 6379;
client = createClient({ url: `redis://${HOST}:${PORT}` });
client.connect();
}
async hasData(key: string) {
const data = await client.json.get(key);
return (data ? true : false);
}
async getData<Type>(key: string) {
const data = await client.json.get(key, { path: '.' });
return data as Type;
}
async setData<Type>(key: string, data: Type) {
await client.json.set(key, '.', data as any);
await client.json.get(key);
}
}

View File

@@ -0,0 +1,159 @@
import { formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getLastWorkDayOfWeek, getWeekNumber } from "../utils";
test('získání indexu dne v týdnu', () => {
let date = new Date("2023-10-01");
expect(getDayOfWeekIndex(date)).toBe(6);
date = new Date("2023-10-02");
expect(getDayOfWeekIndex(date)).toBe(0);
date = new Date("2023-10-03");
expect(getDayOfWeekIndex(date)).toBe(1);
date = new Date("2023-10-04");
expect(getDayOfWeekIndex(date)).toBe(2);
date = new Date("2023-10-05");
expect(getDayOfWeekIndex(date)).toBe(3);
date = new Date("2023-10-06");
expect(getDayOfWeekIndex(date)).toBe(4);
date = new Date("2023-10-07");
expect(getDayOfWeekIndex(date)).toBe(5);
date = new Date("2023-10-08");
expect(getDayOfWeekIndex(date)).toBe(6);
date = new Date("2023-10-09");
expect(getDayOfWeekIndex(date)).toBe(0);
});
test('získání data prvního/posledního dne v týdnu', () => {
let date = new Date("2023-10-02");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-10-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-10-06");
date = new Date("2023-10-03");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-10-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-10-06");
date = new Date("2023-10-04");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-10-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-10-06");
date = new Date("2023-10-05");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-10-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-10-06");
date = new Date("2023-10-06");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-10-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-10-06");
date = new Date("2023-10-07");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-10-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-10-06");
date = new Date("2023-10-08");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-10-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-10-06");
date = new Date("2023-01-01");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2022-12-26");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2022-12-30");
date = new Date("2023-01-02");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-01-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-01-06");
date = new Date("2023-01-03");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-01-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-01-06");
date = new Date("2023-01-04");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-01-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-01-06");
date = new Date("2023-01-05");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-01-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-01-06");
date = new Date("2023-01-06");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-01-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-01-06");
date = new Date("2023-01-07");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-01-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-01-06");
date = new Date("2023-01-08");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-01-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-01-06");
date = new Date("2023-12-25");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-12-25");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-12-29");
date = new Date("2023-12-26");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-12-25");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-12-29");
date = new Date("2023-12-27");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-12-25");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-12-29");
date = new Date("2023-12-28");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-12-25");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-12-29");
date = new Date("2023-12-29");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-12-25");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-12-29");
date = new Date("2023-12-30");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-12-25");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-12-29");
date = new Date("2023-12-31");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-12-25");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-12-29");
});
test('získání čísla týdne v roce', () => {
let date = new Date("2023-10-01");
expect(getWeekNumber(date)).toBe(39);
date = new Date("2023-10-02");
expect(getWeekNumber(date)).toBe(40);
date = new Date("2023-10-03");
expect(getWeekNumber(date)).toBe(40);
date = new Date("2023-10-04");
expect(getWeekNumber(date)).toBe(40);
date = new Date("2023-10-05");
expect(getWeekNumber(date)).toBe(40);
date = new Date("2023-10-06");
expect(getWeekNumber(date)).toBe(40);
date = new Date("2023-10-07");
expect(getWeekNumber(date)).toBe(40);
date = new Date("2023-10-08");
expect(getWeekNumber(date)).toBe(40);
date = new Date("2023-10-09");
expect(getWeekNumber(date)).toBe(41);
date = new Date("2022-01-01");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2022-12-30");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2022-12-31");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-01-01");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-01-02");
expect(getWeekNumber(date)).toBe(1);
date = new Date("2023-01-03");
expect(getWeekNumber(date)).toBe(1);
date = new Date("2023-01-04");
expect(getWeekNumber(date)).toBe(1);
date = new Date("2023-01-05");
expect(getWeekNumber(date)).toBe(1);
date = new Date("2023-01-06");
expect(getWeekNumber(date)).toBe(1);
date = new Date("2023-01-07");
expect(getWeekNumber(date)).toBe(1);
date = new Date("2023-01-08");
expect(getWeekNumber(date)).toBe(1);
date = new Date("2023-01-09");
expect(getWeekNumber(date)).toBe(2);
date = new Date("2023-12-24");
expect(getWeekNumber(date)).toBe(51);
date = new Date("2023-12-25");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-12-26");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-12-27");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-12-28");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-12-29");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-12-30");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-12-31");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2024-01-01");
expect(getWeekNumber(date)).toBe(1);
});

View File

@@ -1,9 +1,13 @@
import {Choices, LocationKey } from "../../types";
/** Vrátí datum v ISO formátu. */ /** Vrátí datum v ISO formátu. */
export function formatDate(date: Date) { export function formatDate(date: Date, format?: string) {
let currentDay = String(date.getDate()).padStart(2, '0'); let day = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0"); let month = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear(); let year = String(date.getFullYear());
return `${currentYear}-${currentMonth}-${currentDay}`;
const f = (format === undefined) ? 'YYYY-MM-DD' : format;
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
} }
/** Vrátí human-readable reprezentaci předaného data pro zobrazení. */ /** Vrátí human-readable reprezentaci předaného data pro zobrazení. */
@@ -15,8 +19,114 @@ export function getHumanDate(date: Date) {
return `${currentDay}.${currentMonth}.${currentYear} (${currentDayOfWeek})`; return `${currentDay}.${currentMonth}.${currentYear} (${currentDayOfWeek})`;
} }
/** Vrátí human-readable reprezentaci předaného času pro zobrazení. */
export function getHumanTime(time: Date) {
let currentHours = String(time.getHours()).padStart(2, '0');
let currentMinutes = String(time.getMinutes()).padStart(2, "0");
return `${currentHours}:${currentMinutes}`;
}
/**
* Vrátí index dne v týdnu, kde pondělí=0, neděle=6
*
* @param date datum
* @returns index dne v týdnu
*/
export const getDayOfWeekIndex = (date: Date) => {
// https://stackoverflow.com/a/4467559
return (((date.getDay() - 1) % 7) + 7) % 7;
}
/** Vrátí true, pokud je předané datum o víkendu. */ /** Vrátí true, pokud je předané datum o víkendu. */
export function getIsWeekend(date: Date) { export function getIsWeekend(date: Date) {
const dayName = date.toLocaleDateString("CZ-cs", { weekday: 'long' }).toLowerCase() const index = getDayOfWeekIndex(date);
return dayName === 'sobota' || dayName === 'neděle' return index == 5 || index == 6;
}
/** Vrátí první pracovní den v týdnu předaného data. */
export function getFirstWorkDayOfWeek(date: Date) {
const firstDay = new Date(date.getTime());
firstDay.setDate(date.getDate() - getDayOfWeekIndex(date));
return firstDay;
}
/** Vrátí poslední pracovní den v týdnu předaného data. */
export function getLastWorkDayOfWeek(date: Date) {
const lastDay = new Date(date.getTime());
lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date)));
return lastDay;
}
/** Vrátí pořadové číslo týdne předaného data v roce dle ISO 8601. */
export function getWeekNumber(inputDate: Date) {
var date = new Date(inputDate.getTime());
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7);
var week1 = new Date(date.getFullYear(), 0, 4);
return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7);
}
/**
* Vrátí JWT token z hlaviček, pokud ho obsahují.
*
* @param req request
* @returns token, pokud ho hlavičky requestu obsahují
*/
export const parseToken = (req: any) => {
if (req?.headers?.authorization) {
return req.headers.authorization.split(' ')[1];
}
}
/**
* Ověří přítomnost (not null) předaných parametrů v URL query.
* V případě nepřítomnosti kteréhokoli parametru vyhodí chybu.
*
* @param req request
* @param paramNames pole názvů požadovaných parametrů
*/
export const checkQueryParams = (req: any, paramNames: string[]) => {
for (const name of paramNames) {
if (req.query[name] == null) {
throw Error(`Nebyl předán parametr '${name}' v query požadavku`);
}
}
}
/**
* Ověří přítomnost (not null) předaných parametrů v těle requestu.
* V případě nepřítomnosti kteréhokoli parametru vyhodí chybu.
*
* @param req request
* @param paramNames pole názvů požadovaných parametrů
*/
export const checkBodyParams = (req: any, paramNames: string[]) => {
for (const name of paramNames) {
if (req.body[name] == null) {
throw Error(`Nebyl předán parametr '${name}' v těle požadavku`);
}
}
}
// TODO umístit do samostatného souboru
export class InsufficientPermissions extends Error { }
export const getUsersByLocation = (choices: Choices, login: string): string[] => {
const result: string[] = [];
for (const location of Object.entries(choices)) {
const locationKey = location[0] as LocationKey;
const locationValue = location[1];
if (locationValue[login]) {
for (const username in choices[locationKey]) {
if (choices[locationKey].hasOwnProperty(username)) {
result.push(username);
}
}
break;
}
}
return result;
} }

56
server/src/voting.ts Normal file
View File

@@ -0,0 +1,56 @@
import { FeatureRequest } from "../../types";
import getStorage from "./storage";
interface VotingData {
[login: string]: FeatureRequest[],
}
const storage = getStorage();
const STORAGE_KEY = 'voting';
/**
* Vrátí pole voleb, pro které uživatel aktuálně hlasuje.
*
* @param login login uživatele
* @returns pole voleb
*/
export async function getUserVotes(login: string) {
const data: VotingData = await storage.getData(STORAGE_KEY);
return data?.[login] || [];
}
/**
* Aktualizuje hlas uživatele pro konkrétní volbu.
*
* @param login login uživatele
* @param option volba
* @param active příznak, zda volbu přidat nebo odebrat
* @returns aktuální data
*/
export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> {
let data: VotingData = await storage.getData(STORAGE_KEY);
if (data == null) {
data = {};
}
if (!(login in data)) {
data[login] = [];
}
const index = data[login].indexOf(option);
if (index > -1) {
if (active) {
throw Error('Pro tuto možnost jste již hlasovali');
} else {
data[login].splice(index, 1);
if (data[login].length === 0) {
delete data[login];
}
}
} else if (active) {
if (data[login].length == 4) {
throw Error('Je možné hlasovat pro maximálně 4 možnosti');
}
data[login].push(option);
}
await storage.setData(STORAGE_KEY, data);
return data;
}

28
server/src/websocket.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Server } from "socket.io";
import { DefaultEventsMap } from "socket.io/dist/typed-events";
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
export const initWebsocket = (server: any) => {
io = new Server(server, {
cors: {
origin: "*",
},
});
io.on("connection", (socket) => {
console.log(`New client connected: ${socket.id}`);
socket.on("message", (message) => {
io.emit("message", message);
});
socket.on("disconnect", () => {
console.log(`Client disconnected: ${socket.id}`);
});
});
return io;
}
export const getWebsocket = () => {
return io;
}

4123
server/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

58
types/RequestTypes.ts Normal file
View File

@@ -0,0 +1,58 @@
import { FeatureRequest, LocationKey, PizzaOrder } from "./Types";
export type ILocationKey = {
locationKey: LocationKey,
}
export type IDayIndex = {
dayIndex?: number,
}
export type AddChoiceRequest = IDayIndex & ILocationKey & {
foodIndex?: number,
}
export type RemoveChoicesRequest = IDayIndex & ILocationKey;
export type RemoveChoiceRequest = IDayIndex & ILocationKey & {
foodIndex: number,
}
export type JdemeObedRequest = ILocationKey;
export type UpdateNoteRequest = IDayIndex & {
note?: string,
}
export type ChangeDepartureTimeRequest = IDayIndex & {
time: string,
}
export type FinishDeliveryRequest = {
bankAccount?: string,
bankAccountHolder?: string,
}
export type AddPizzaRequest = {
pizzaIndex: number,
pizzaSizeIndex: number,
}
export type RemovePizzaRequest = {
pizzaOrder: PizzaOrder,
}
export type UpdatePizzaDayNoteRequest = {
note?: string,
}
export type UpdatePizzaFeeRequest = {
login: string,
text?: string,
price?: number,
}
export type UpdateFeatureVoteRequest = {
option: FeatureRequest,
active: boolean,
}

View File

@@ -1,16 +1,28 @@
/** Výčtový typ pro restaurace, pro které umíme získat a parsovat obědové menu. */ /** Výčtový typ pro restaurace, pro které umíme získat a parsovat obědové menu. */
export enum Restaurants { export enum Restaurants {
SLADOVNICKA = 'sladovnicka', SLADOVNICKA = 'sladovnicka',
UMOTLIKU = 'uMotliku', // UMOTLIKU = 'uMotliku',
TECHTOWER = 'techTower', TECHTOWER = 'techTower',
ZASTAVKAUMICHALA = 'zastavkaUmichala',
SENKSERIKOVA = 'senkSerikova',
} }
export interface Choices { export type FoodChoices = {
[location: string]: string[], trusted: boolean,
options: number[],
departureTime?: string,
note?: string,
}
// TODO okomentovat / rozdělit
export type Choices = {
[location in LocationKey]?: {
[login: string]: FoodChoices
}
} }
/** Velikost konkrétní pizzy */ /** Velikost konkrétní pizzy */
export interface PizzaSize { export type PizzaSize = {
varId: number, // unikátní ID varianty pizzy varId: number, // unikátní ID varianty pizzy
size: string, // velikost pizzy, např. "30cm" size: string, // velikost pizzy, např. "30cm"
pizzaPrice: number, // cena samotné pizzy pizzaPrice: number, // cena samotné pizzy
@@ -19,14 +31,14 @@ export interface PizzaSize {
} }
/** Jedna konkrétní pizza */ /** Jedna konkrétní pizza */
export interface Pizza { export type Pizza = {
name: string, // název pizzy name: string, // název pizzy
ingredients: string[], // seznam ingrediencí ingredients: string[], // seznam ingrediencí
sizes: PizzaSize[], // dostupné velikosti pizzy sizes: PizzaSize[], // dostupné velikosti pizzy
} }
/** Objednávka jedné konkrétní pizzy */ /** Objednávka jedné konkrétní pizzy */
export interface PizzaOrder { export type PizzaOrder = {
varId: number, // unikátní ID varianty pizzy varId: number, // unikátní ID varianty pizzy
name: string, // název pizzy name: string, // název pizzy
size: string, // velikost pizzy jako string (30cm) size: string, // velikost pizzy jako string (30cm)
@@ -34,10 +46,11 @@ export interface PizzaOrder {
} }
/** Celková objednávka jednoho člověka */ /** Celková objednávka jednoho člověka */
export interface Order { export type Order = {
customer: string, // jméno objednatele customer: string, // jméno objednatele
pizzaList: PizzaOrder[], // seznam objednaných pizz pizzaList: PizzaOrder[], // seznam objednaných pizz
totalPrice: number, // celková cena všech objednaných pizz a krabic fee?: { text?: string, price: number }, // příplatek (např. za extra ingredience)
totalPrice: number, // celková cena všech objednaných pizz, krabic a příplatků
hasQr?: boolean, // true, pokud je k objednávce vygenerován QR kód pro platbu hasQr?: boolean, // true, pokud je k objednávce vygenerován QR kód pro platbu
note?: string, // volitelná uživatelská poznámka k objednávce note?: string, // volitelná uživatelská poznámka k objednávce
} }
@@ -58,49 +71,147 @@ interface PizzaDay {
orders: Order[], // seznam objednávek jednotlivých lidí orders: Order[], // seznam objednávek jednotlivých lidí
} }
/** Veškerá data pro zobrazení na klientovi */ /** Týdenní menu jednotlivých restaurací. */
export interface ClientData { export type WeekMenu = {
date: string, // dnešní datum pro zobrazení [dayIndex: number]: {
isWeekend: boolean, // příznak, zda je dnes víkend [restaurant in Restaurants]?: DayMenu
choices: Choices, // seznam voleb }
}
/** Data vztahující se k jednomu konkrétnímu dni. */
export type DayData = {
date: string, // datum dne
isWeekend: boolean, // příznak, zda je datum víkend
weekIndex: number, // index dne v týdnu (0-6)
choices: Choices, // seznam voleb uživatelů
menus?: { [restaurant in Restaurants]?: DayMenu }, // menu jednotlivých restaurací
pizzaDay?: PizzaDay, // pizza day pro dnešní den, pokud existuje pizzaDay?: PizzaDay, // pizza day pro dnešní den, pokud existuje
pizzaList?: Pizza[], // seznam dostupných pizz pro dnešní den
pizzaListLastUpdate?: Date, // datum a čas poslední aktualizace pizz
}
/** Veškerá data pro zobrazení na klientovi. */
export type ClientData = DayData & {
todayWeekIndex?: number, // index dnešního dne v týdnu (0-6)
}
/** Nabídka jídel jednoho podniku pro jeden konkrétní den. */
export type DayMenu = {
lastUpdate: number, // UNIX timestamp poslední aktualizace menu
closed: boolean, // příznak, zda je daný podnik v tento den zavřený
food: Food[], // seznam jídel v menu
} }
/** Jídlo z obědového menu restaurace. */ /** Jídlo z obědového menu restaurace. */
export interface Food { export type Food = {
amount?: string, // množství standardní porce, např. 0,33l nebo 150g amount?: string, // množství standardní porce, např. 0,33l nebo 150g
name: string, // název/popis jídla name: string, // název/popis jídla
price: string, // cena ve formátu '135 Kč' price: string, // cena ve formátu '135 Kč'
isSoup: boolean, // příznak, zda se jedná o polévku isSoup: boolean, // příznak, zda se jedná o polévku
} }
// TODO tohle je dost špatné pojmenování, vzhledem k tomu co to obsahuje
// TODO pokud by se použilo ovládáni výběru obědu kliknutím, pak bych restaurace z tohoto výčtu vyhodil
export enum Locations { export enum Locations {
SLADOVNICKA = 'Sladovnická', SLADOVNICKA = 'Sladovnická',
UMOTLIKU = 'U Motlíků', // UMOTLIKU = 'U Motlíků',
TECHTOWER = 'TechTower', TECHTOWER = 'TechTower',
ZASTAVKAUMICHALA = 'Zastávka u Michala',
SENKSERIKOVA = 'Pivovarský šenk Šeříková',
SPSE = 'SPŠE', SPSE = 'SPŠE',
PIZZA = 'Pizza day', PIZZA = 'Pizza day',
OBJEDNAVAM = 'Budu objednávat', OBJEDNAVAM = 'Budu objednávat',
NEOBEDVAM = 'Mám vlastní/neobědvám', NEOBEDVAM = 'Mám vlastní/neobědvám',
ROZHODUJI = 'Rozhoduji se',
} }
// TODO totéž
export type LocationKey = keyof typeof Locations;
export enum UdalostEnum { export enum UdalostEnum {
ZAHAJENA_PIZZA = "Zahájen pizza day", ZAHAJENA_PIZZA = "Zahájen pizza day",
OBJEDNANA_PIZZA = "Objednána pizza" OBJEDNANA_PIZZA = "Objednána pizza",
JDEME_OBED = "Jdeme na oběd",
} }
export interface NotififaceInput { export type NotififaceInput = {
locationKey?: LocationKey,
udalost: UdalostEnum, udalost: UdalostEnum,
user: string, user: string,
} }
export interface NotifikaceData { export type GotifyServer = {
input: NotififaceInput,
gotify?: boolean,
teams?: boolean,
}
export interface GotifyServer {
server: string; server: string;
api_keys: string[]; api_keys: string[];
}
/** Čas preferovaného odchodu na oběd. */
export enum DepartureTime {
T10_00 = "10:00",
T10_15 = "10:15",
T10_30 = "10:30",
T10_45 = "10:45",
T11_00 = "11:00",
T11_15 = "11:15",
T11_30 = "11:30",
T11_45 = "11:45",
T12_00 = "12:00",
T12_15 = "12:15",
T12_30 = "12:30",
T12_45 = "12:45",
T13_00 = "13:00",
}
export enum FeatureRequest {
CUSTOM_QR = "Ruční generování QR kódů mimo Pizza day (např. při objednávání)",
FAVORITES = "Možnost označovat si jídla jako oblíbená (taková jídla by se uživateli následně zvýrazňovala)",
SINGLE_PAYMENT = "Možnost úhrady v podniku za všechny jednou osobou a následné generování QR ostatním",
NO_WEEKENDS = "Zrušení \"užívejte víkend\", místo toho umožnit zpětně náhled na uplynulý týden",
QR_FOREVER = "Umožnění zobrazení vygenerovaného QR kódu i po následující dny (dokud ho uživatel ručně \"nezavře\", např. tlačítkem \"Zaplatil jsem\")",
PIZZA_PICTURES = "Zobrazování náhledů (fotografií) pizz v rámci Pizza day",
STATISTICS = "Statistiky (nejoblíbenější podnik, nejpopulárnější jídla, nejobjednávanější pizzy, nejčastější uživatelé, ...)",
RESPONSIVITY = "Vylepšení responzivního designu",
SECURITY = "Zvýšení zabezpečení aplikace",
SAFETY = "Zvýšená ochrana proti chybám uživatele (potvrzovací dialogy, překliky, ...)",
UI = "Celkové vylepšení UI/UX",
DEVELOPMENT = "Zlepšení dokumentace/postupů pro ostatní vývojáře"
}
export type EasterEgg = {
path: string;
url: string;
startOffset: number;
endOffset: number;
duration: number;
width?: string;
zIndex?: number;
position?: "absolute";
animationName?: string;
animationDuration?: string;
animationTimingFunction?: string;
}
// TODO aktuálně se k ničemu nepoužívá
export type AnimationPosition = {
left?: string,
startLeft?: string,
"--start-left"?: string,
right?: string,
startRight?: string,
"--start-right"?: string,
top?: string,
startTop?: string,
"--start-top"?: string,
bottom?: string,
startBottom?: string,
"--start-bottom"?: string,
endLeft?: string,
"--end-left"?: string,
endRight?: string,
"--end-right"?: string,
endTop?: string,
"--end-top"?: string,
endBottom?: string,
"--end-bottom"?: string,
rotate?: string,
} }

View File

@@ -1 +1,2 @@
export * from './Types'; export * from './Types';
export * from './RequestTypes';

10139
yarn.lock

File diff suppressed because it is too large Load Diff