199 Commits

Author SHA1 Message Date
61f13d2132 Validace TypeScript typů při sestavení klienta
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-03-05 21:05:40 +01:00
d69e09afee Migrace na OpenAPI - TypeScript typy 2025-03-05 21:05:21 +01:00
d144c55bf7 feat: #11 je tohle feat?, pridani poctu lidi k restauraci
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-05 18:56:21 +01:00
999a517404 Oprava lokalizace datumu
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-03 10:20:41 +01:00
68bafa808c Oprava #8
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:46:50 +01:00
a34614c8db Oprava #6
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-02-27 21:36:21 +01:00
f4e31cea36 Oprava #4, #5
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:29:43 +01:00
8dda6b1014 Oprava #7
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:19:38 +01:00
f9c7d647f7 Migrace Node v18 -> v22
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 00:28:14 +01:00
ca400638d1 Přidání základních statistik
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-02-27 00:22:34 +01:00
0af78e72d9 Nastavení časové zóny
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-24 12:06:29 +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
bc6035862a Oprava jazyka v Dockerfile 2023-07-23 00:14:47 +02:00
839f51d8a3 Oprava URL pro websockety 2023-07-23 00:04:53 +02:00
1c1a8b7111 Oprava parseru, nedělitelné mezery v cenách 2023-07-22 19:58:28 +02:00
4d0096c064 Zastavení serveru pomocí SIGINT 2023-07-22 19:39:13 +02:00
347cbc7228 Úprava socket.io pro relativní URL serveru 2023-07-22 19:38:44 +02:00
3c0e8b2297 Deduplikace typů a sloučení kontejnerů
- Zavedení yarn workspaces
- Sloučení klienta a serveru do jednoho Docker kontejneru
- Společný dockerfile, builder
- Zbavení se nginx (není již potřeba)
2023-07-22 19:37:03 +02:00
0d0c5cb946 Aktualizace .gitignore 2023-07-22 19:30:31 +02:00
c5e3c76cc1 Podpora nedělitelných mezer u názvů v TechTower 2023-07-04 10:24:43 +02:00
13f3c1178f Opravy parseru pro Sladovnickou 2023-07-04 10:19:42 +02:00
814aa98721 Narovnání závislostí 2023-07-04 10:16:11 +02:00
c22863b6aa Merge pull request 'pridani portu do dockerfile + traefik example' (#2) from user/batmanisko/traefikiseasier into master
Reviewed-on: mates/Luncher#2
2023-06-29 21:03:55 +02:00
ab57acdcd1 pridani portu do dockerfile + traefik example 2023-06-29 20:56:29 +02:00
26609ebd6b Dočasná oprava zobrazování QR kódů 2023-06-29 12:05:43 +02:00
d21291c643 Revert "NOMERGE: Příprava výběrů více možností"
This reverts commit c68141575f.
2023-06-29 07:35:08 +02:00
4c9a868d6b Začištění použití remote-user hlavičky 2023-06-29 07:33:05 +02:00
c68141575f NOMERGE: Příprava výběrů více možností 2023-06-28 20:15:20 +02:00
1a45d40cba yarn startReload pro nodemon (live reload serveru) 2023-06-28 19:05:50 +02:00
24805d2aa0 cteni remote-user hlavicky z forwardauth 2023-06-28 19:04:40 +02:00
bcd9199206 Oprava po merge 2023-06-28 16:59:51 +02:00
e81c7d09a3 Přesun autentizace na server 2023-06-28 07:24:26 +02:00
47fbe4173d Negenerovat QR kód pro objednávajícího 2023-06-18 18:24:20 +02:00
325ff0ee12 Aktualizace .gitignore 2023-06-18 18:13:02 +02:00
9090b156ce Zbavení se Food API, zahrnutí do serveru 2023-06-18 18:10:38 +02:00
0d6020d1a0 Oprava deprecated použití Cheerio 2023-06-18 18:05:03 +02:00
83e88a641c Aktualizace TODO v README.md 2023-06-17 11:02:05 +02:00
38641758c0 Zobrazení celkové ceny za Pizza day 2023-06-17 10:59:59 +02:00
a4368c4619 Úpravy možností výběru oběda 2023-06-17 10:51:15 +02:00
67242c48df Možnost přidat k objednávce poznámku 2023-06-17 10:44:12 +02:00
26337121de Oprava validace přítomnosti QR kódu 2023-06-17 10:00:07 +02:00
8d8e7c8af2 Aktualizace TODO v README.md 2023-06-17 09:42:02 +02:00
55b9d1681e Funkční generování QR kódů 2023-06-17 09:32:41 +02:00
45c2f9e264 Oprava zobrazování textu, narovnání code style 2023-06-17 09:29:15 +02:00
89fc27b087 Oprava pádu při chybějící konfiguraci Gotify 2023-06-16 21:12:30 +02:00
09264720d1 Oprava překlepu 2023-06-16 21:05:36 +02:00
b2c8b312c3 Validace čísla účtu, stylování dialogu 2023-06-16 20:02:56 +02:00
d0cf7d1e8e Důležitá aktualizace 2023-06-15 22:43:03 +02:00
c63025220d Aktualizace README.MD 2023-06-13 22:46:44 +02:00
4e8ff4d8a3 Podpora načítání .env na serveru dle prostředí 2023-06-13 22:30:55 +02:00
97aa41549b rozběhání gotify notifikací podle dat z env 2023-06-13 21:30:00 +02:00
0b2edeaac3 Ošetření pádu aplikace při selhání food.api 2023-06-13 21:28:30 +02:00
78b858120e Aktualizace TODO v README.md 2023-06-13 21:16:53 +02:00
1a53bacfce Podpora vývoje na Windows 2023-06-13 21:12:25 +02:00
783340bf06 Podpora Food API na Windows 2023-06-13 21:12:25 +02:00
108 changed files with 12018 additions and 11079 deletions

26
.gitignore vendored
View File

@@ -1,26 +1,2 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules node_modules
/.pnp types/gen
.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*
__pycache__
venv

56
.woodpecker/workflow.yaml Normal file
View File

@@ -0,0 +1,56 @@
variables:
- &node_image 'node:22-alpine'
- &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}}"

92
Dockerfile Normal file
View File

@@ -0,0 +1,92 @@
ARG NODE_VERSION="node:22-alpine"
# Builder
FROM ${NODE_VERSION} AS builder
WORKDIR /build
# Zkopírování závislostí - OpenAPI generátor
COPY types/package.json ./types/
COPY types/yarn.lock ./types/
COPY types/api.yml ./types/
COPY types/openapi-ts.config.ts ./types/
# Zkopírování závislostí - server
COPY server/package.json ./server/
COPY server/yarn.lock ./server/
# Zkopírování závislostí - klient
COPY client/package.json ./client/
COPY client/yarn.lock ./client/
# Instalace závislostí - OpenAPI generátor
WORKDIR /build/types
RUN yarn install --frozen-lockfile
# Instalace závislostí - server
WORKDIR /build/server
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/src ./server/src/
# Zkopírování build závislostí - klient
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/public ./client/public
# Zkopírování společných typů
COPY types/RequestTypes.ts ./types/
COPY types/index.ts ./types/
# Vygenerování společných typů z OpenAPI
WORKDIR /build/types
RUN yarn openapi-ts
# Sestavení serveru
WORKDIR /build/server
RUN yarn build
# Sestavení klienta
WORKDIR /build/client
RUN yarn build
# Runner
FROM ${NODE_VERSION}
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
WORKDIR /app
# Vykopírování sestaveného serveru
COPY --from=builder /build/server/node_modules ./server/node_modules
COPY --from=builder /build/server/dist ./
# 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
# 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
CMD [ "node", "./server/src/index.js" ]

26
Dockerfile-Woodpecker Normal file
View File

@@ -0,0 +1,26 @@
ARG NODE_VERSION="node:22-alpine"
FROM ${NODE_VERSION}
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
WORKDIR /app
# Vykopírování sestaveného serveru
COPY ./server/node_modules ./server/node_modules
COPY ./server/dist ./
# TODO tohle není dobře, má to být součástí serveru
# COPY ./server/resources ./resources
# Vykopírování sestaveného klienta
COPY ./client/dist ./public
# Zkopírování 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,30 +1,29 @@
# Luncher # Luncher
Aplikace pro profesionální management obědů. Aplikace pro profesionální management obědů.
Aplikace sestává ze tří (čtyř) modulů. Aplikace sestává ze tří modulů.
- food_api - types
- Python scraper/parser pro zpracování obědových menu restaurací - OpenAPI definice společných typů, generované přes [openapi-ts](https://github.com/hey-api/openapi-ts)
- server - server
- backend psaný v [node.js](https://nodejs.dev) - backend psaný v [node.js](https://nodejs.dev)
- client - client
- frontend psaný v [React.js](https://react.dev) - frontend psaný v [React.js](https://react.dev)
- [nginx](https://nginx.org)
- proxy pro snadné propojení Docker kontejnerů pod jednou URL
## Spuštění pro vývoj ## Spuštění pro vývoj
### Závislosti ### Závislosti
#### Food API
- [Python 3](https://www.python.org)
- [pip](https://pypi.org/project/pip)
#### Klient/server #### Klient/server
- [Node.js 18.x](https://nodejs.dev) - [Node.js 22.x (>= 22.11)](https://nodejs.dev)
- [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com) - [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com)
### Spuštění na *nix platformách ### Spuštění na *nix platformách
- Nainstalovat závislosti viz předchozí bod - Nainstalovat závislosti viz předchozí bod
- Zkopírovat `client/.env.template` do `client/.env.development` a upravit dle potřeby
- Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby - Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby
- Spustit `./run_dev.sh`. Na jiných platformách se lze inspirovat jeho obsahem, postup by měl být víceméně stejný. - Vygenerovat společné TypeScript typy
- `cd types && yarn install && yarn openapi-ts`
- Server
- `cd server && yarn install && export NODE_ENV=development && yarn startReload`
- Klient
- `cd client && yarn install && yarn start`
## Sestavení a spuštění produkční verze v Docker ## Sestavení a spuštění produkční verze v Docker
### Závislosti ### Závislosti
@@ -34,30 +33,8 @@ Aplikace sestává ze tří (čtyř) modulů.
### Spuštění ### Spuštění
- `docker compose up --build -d` - `docker compose up --build -d`
### Spuštení s traefik
- `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
- [ ] Automatické generování a zobrazení QR kódů
- [ ] https://qr-platba.cz/pro-vyvojare/restful-api/
- [ ] Zobrazovat celkovou cenu objednávky pod tabulkou objednávek
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
- [ ] Umožnit přidat k objednávce poznámku (např. "bez oliv")
- [ ] 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)
- [ ] Vylepšit dokumentaci projektu
- [ ] Popsat Food API, nginx
- [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
- [ ] 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()
- [ ] Přesunout autentizaci na server (JWT?)
- [x] Zavést .env.template a přidat .env do .gitignore
- [ ] Zkrášlit dialog pro vyplnění čísla účtu, vypadá mizerně
- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)

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ů

View File

@@ -1,3 +0,0 @@
**/node_modules
**/npm-debug.log
build

View File

@@ -1,3 +0,0 @@
# Veřejná URL, na které bude dostupný klient (typicky přes proxy).
# Pro vývoj není potřeba, bude použita výchozí hodnota http://localhost:3001
# PUBLIC_URL=http://example:3001

2
client/.gitignore vendored
View File

@@ -1,2 +1,2 @@
build build
.env.production dist

View File

@@ -1,23 +0,0 @@
FROM node:18-alpine3.18 AS builder
COPY package.json .
COPY yarn.lock .
COPY tsconfig.json .
COPY .env.production .
RUN yarn install
COPY ./src ./src
COPY ./public ./public
RUN yarn build
FROM node:18-alpine3.18
ENV NODE_ENV production
WORKDIR /app
COPY --from=builder /build .
RUN yarn global add serve && yarn
CMD ["serve", "-s", "."]

View File

@@ -1,2 +0,0 @@
#!/bin/bash
docker build -t luncher-client .

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

@@ -1,42 +1,45 @@
{ {
"name": "luncher-client", "name": "@luncher/client",
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"type": "module",
"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-modal": "^3.16.1", "react-modal": "^3.16.1",
"react-scripts": "5.0.1", "react-router": "^7.2.0",
"react-router-dom": "^7.2.0",
"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",
"recharts": "^2.15.1",
"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": {
"start": "react-scripts start", "start": "yarn vite",
"build": "react-scripts build", "build": "tsc --noEmit && yarn vite build"
"test": "react-scripts test",
"eject": "react-scripts eject"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
"react-app", "react-app"
"react-app/jest"
] ]
}, },
"browserslist": { "browserslist": {
@@ -52,6 +55,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,67 +0,0 @@
import { PizzaOrder } from "./Types";
import { getBaseUrl } from "./Utils";
async function request<TResponse>(
url: string,
config: RequestInit = {}
): Promise<TResponse> {
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 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 (creator) => {
return await api.post<any, any>('/api/createPizzaDay', JSON.stringify({ creator }));
}
export const deletePizzaDay = async (login) => {
return await api.post<any, any>('/api/deletePizzaDay', JSON.stringify({ login }));
}
export const lockPizzaDay = async (login) => {
return await api.post<any, any>('/api/lockPizzaDay', JSON.stringify({ login }));
}
export const unlockPizzaDay = async (login) => {
return await api.post<any, any>('/api/unlockPizzaDay', JSON.stringify({ login }));
}
export const finishOrder = async (login) => {
return await api.post<any, any>('/api/finishOrder', JSON.stringify({ login }));
}
export const finishDelivery = async (login) => {
return await api.post<any, any>('/api/finishDelivery', JSON.stringify({ login }));
}
export const updateChoice = async (name: string, choice: number | null) => {
return await api.post<any, any>('/api/updateChoice', JSON.stringify({ name, choice }));
}
export const addPizza = async (login: string, pizzaIndex: number, pizzaSizeIndex: number) => {
return await api.post<any, any>('/api/addPizza', JSON.stringify({ login, pizzaIndex, pizzaSizeIndex }));
}
export const removePizza = async (login: string, pizzaOrder: PizzaOrder) => {
return await api.post<any, any>('/api/removePizza', JSON.stringify({ login, pizzaOrder }));
}

View File

@@ -1,74 +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;
}

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,44 +1,97 @@
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, lockPizzaDay, removePizza, unlockPizzaDay, updateChoice } 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 { Locations, ClientData, Pizza, PizzaOrder, State } from './Types';
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 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 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';
import { Link } from 'react-router';
import { STATS_URL } from './AppRoutes';
import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LunchChoices, UserLunchChoice, PizzaVariant } from '../../types';
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 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<any>(); const [food, setFood] = useState<RestaurantDayMenuMap>();
const [pizzy, setPizzy] = useState<Pizza[]>(); const [myOrder, setMyOrder] = useState<PizzaOrder>();
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 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);
// Prvotní načtení aktuálního stavu // Načtení dat po přihlášení
useEffect(() => { useEffect(() => {
getPizzy().then(pizzy => { if (!auth || !auth.login) {
setPizzy(pizzy); return
}); }
getData().then(data => { getData().then(({ data }) => {
if (data) {
setData(data);
setDayIndex(data.weekIndex);
dayIndexRef.current = data.weekIndex;
setFood(data.menus);
}
}).catch(e => {
setFailure(true);
})
}, [auth, auth?.login]);
// Přenačtení pro zvolený den
useEffect(() => {
if (!auth || !auth.login) {
return
}
getData(dayIndex).then((data: ClientData) => {
setData(data); setData(data);
setFood(data.menus);
}).catch(e => {
setFailure(true);
}) })
getFood().then(food => { }, [dayIndex, auth]);
setFood(food);
})
}, []);
// Registrace socket eventů // Registrace socket eventů
useEffect(() => { useEffect(() => {
@@ -51,9 +104,11 @@ function App() {
setIsConnected(false); setIsConnected(false);
}); });
socket.on(EVENT_MESSAGE, (newData: ClientData) => { socket.on(EVENT_MESSAGE, (newData: ClientData) => {
// const data: any = JSON.parse(payload);
// 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.dayIndex === dayIndexRef.current) {
setData(newData);
}
}); });
return () => { return () => {
@@ -68,37 +123,147 @@ 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)) {
choiceRef.current.value = Object.values(Locations)[entry[0]] // if (entry[1].includes(auth.login)) {
} // 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])
const changeChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => { // Reference na mojí objednávku
const index = Object.values(Locations).indexOf(event.target.value as unknown as Locations); useEffect(() => {
if (auth?.login) { if (data?.pizzaDay?.orders) {
await updateChoice(auth.login, index > -1 ? index : null); const myOrder = data.pizzaDay.orders.find(o => o.customer === auth?.login);
setMyOrder(myOrder);
}
}, [auth?.login, data?.pizzaDay?.orders])
useEffect(() => {
if (choiceRef?.current?.value && choiceRef.current.value !== "") {
const locationKey = choiceRef.current.value as keyof typeof LunchChoice;
const restaurantKey = Object.keys(Restaurant).indexOf(locationKey);
if (restaurantKey > -1 && food) {
const restaurant = Object.keys(Restaurant)[restaurantKey] as keyof typeof Restaurant;
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: keyof typeof LunchChoice, foodIndex?: number) => {
if (document.getSelection()?.type !== 'Range') { // pouze pokud se nejedná o výběr textu
if (auth?.login) {
await errorHandler(() => addChoice(location, foodIndex, dayIndex));
}
} }
} }
const removeChoice = async (key: string) => { const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const locationKey = event.target.value as keyof typeof LunchChoice;
if (auth?.login) { if (auth?.login) {
await updateChoice(auth.login, null); await errorHandler(() => addChoice(locationKey, undefined, dayIndex));
if (foodChoiceRef.current?.value) {
foodChoiceRef.current.value = "";
}
}
}
const doJdemeObed = async () => {
if (auth?.login) {
await jdemeObed();
}
}
const doAddFoodChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (event.target.value && foodChoiceList?.length && choiceRef.current?.value) {
const locationKey = choiceRef.current.value as keyof typeof LunchChoice;
if (auth?.login) {
await errorHandler(() => addChoice(locationKey, Number(event.target.value), dayIndex));
}
}
}
const doRemoveChoices = async (locationKey: keyof typeof LunchChoice) => {
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) { if (choiceRef?.current?.value) {
choiceRef.current.value = ""; choiceRef.current.value = "";
} }
if (foodChoiceRef?.current?.value) {
foodChoiceRef.current.value = "";
}
}
}
const doRemoveFoodChoice = async (locationKey: keyof typeof LunchChoice, 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č)`;
@@ -108,21 +273,30 @@ function App() {
suggestions.push(group); suggestions.push(group);
}) })
return suggestions; return suggestions;
}, [pizzy]); }, [data?.pizzaList]);
const handlePizzaChange = async (value) => { const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => {
if (auth?.login && pizzy) { if (auth?.login && data?.pizzaList) {
if (!(typeof value === 'string')) {
throw Error('Nepodporovaný typ hodnoty');
}
const s = value.split('|'); const s = value.split('|');
const pizzaIndex = Number.parseInt(s[0]); const pizzaIndex = Number.parseInt(s[0]);
const pizzaSizeIndex = Number.parseInt(s[1]); const pizzaSizeIndex = Number.parseInt(s[1]);
await addPizza(auth.login, pizzaIndex, pizzaSizeIndex); await addPizza(pizzaIndex, pizzaSizeIndex);
} }
} }
const handlePizzaDelete = (pizzaOrder: PizzaOrder) => { const handlePizzaDelete = async (pizzaOrder: PizzaVariant) => {
if (auth?.login) { await removePizza(pizzaOrder);
removePizza(auth?.login, pizzaOrder); }
const handlePizzaPoznamkaChange = async () => {
if (pizzaPoznamkaRef.current?.value && pizzaPoznamkaRef.current.value.length > 70) {
alert("Poznámka může mít maximálně 70 znaků");
return;
} }
updatePizzaDayNote(pizzaPoznamkaRef.current?.value);
} }
// const addToCart = async () => { // const addToCart = async () => {
@@ -149,20 +323,51 @@ function App() {
// } // }
// } // }
const renderFoodTable = (name, 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: keyof typeof Restaurant, menu: RestaurantDayMenu) => {
let content;
if (menu?.closed) {
content = <h3>Zavřeno</h3>
} else if (menu?.food?.length && menu.food.length > 0) {
const hideSoups = settings?.hideSoups;
content = <Table striped bordered hover>
<tbody style={{ cursor: 'pointer' }}>
{menu.food.map((f: Food, index: number) =>
(!hideSoups || !f.isSoup) &&
<tr key={f.name} onClick={() => doAddClickFoodChoice(location, 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>
} }
@@ -170,145 +375,292 @@ 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.todayDayIndex == null || dayIndex >= data.todayDayIndex;
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>Podpora Gotify</li> <li>Možnost výběru restaurace a jídel kliknutím v tabulce</li>
<li>Vylepšení dokumentace pro vývojáře</li> <li><Link to={STATS_URL}>Statistiky</Link></li>
<li>Potíže usnout i v pozdních nočních hodinách</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.todayDayIndex ? '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.sladovnicka)} {/* TODO zjednodušit, stačí iterovat klíče typu Restaurant */}
{renderFoodTable('U Motlíků', food.uMotliku)} {food['SLADOVNICKA'] && renderFoodTable('SLADOVNICKA', food['SLADOVNICKA'])}
{renderFoodTable('TechTower', food.techTower)} {/* {food['UMOTLIKU'] && renderFoodTable('UMOTLIKU', food['UMOTLIKU'])} */}
{food['TECHTOWER'] && renderFoodTable('TECHTOWER', food['TECHTOWER'])}
{food['ZASTAVKAUMICHALA'] && renderFoodTable('ZASTAVKAUMICHALA', food['ZASTAVKAUMICHALA'])}
{food['SENKSERIKOVA'] && renderFoodTable('SENKSERIKOVA', food['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.todayDayIndex ? '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.keys(Restaurant)
<option value={Locations.TECHTOWER}>TechTower</option> .filter(entry => {
<option value={Locations.SPSE}>SPŠE</option> const locationKey = entry as keyof typeof Restaurant;
<option value={Locations.VLASTNI}>Mám vlastní</option> return !food[locationKey]?.closed;
<option value={Locations.OBJEDNAVAM}>Budu objednávat</option> })
<option value={Locations.NEOBEDVAM}>Nebudu obědvat</option> .map(entry => <option key={entry[0]} value={entry[0]}>{entry[1]}</option>)}
</Form.Select> </Form.Select>
<p style={{ fontSize: "12px", marginTop: "5px" }}> <small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small>
Aktuálně je možné vybrat pouze jednu variantu. {foodChoiceList && !closed && <>
</p> <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 keyof typeof LunchChoice;
<td>{Object.values(Locations)[Number(key)]}</td> const locationName = LunchChoice[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 locationPickCount = locationLoginList.length
)} return (
</ul> <tr key={key}>
</td> {(locationPickCount ?? 0) > 1 ? (
</tr> <td>{locationName} ({locationPickCount})</td>
) : (
<td>{locationName}</td>)}
<td className='p-0'>
<Table>
<tbody>
{locationLoginList.map((entry: [string, UserLunchChoice], index) => {
const login = entry[0];
const userPayload = entry[1];
const userChoices = userPayload?.selectedFoods;
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 keyof typeof LunchChoice);
}} 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 => {
const restaurantKey = key as keyof typeof Restaurant;
const foodName = food[restaurantKey]?.food?.[foodIndex].name;
return <li key={foodIndex}>
{foodName}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => {
doRemoveFoodChoice(restaurantKey, foodIndex);
}} title={`Odstranit ${foodName}`} className='action-icon' icon={faTrashCan} />}
</li>
})}
</ul>
</td> : null}
</tr>
}
)}
</tbody>
</Table>
</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.todayDayIndex &&
{!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(auth.login);
}}>Založit Pizza day</Button>
</div>
}
{data.pizzaDay &&
<div>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
{ <p>Pro dnešní den není aktuálně založen Pizza day.</p>
data.pizzaDay.state === State.CREATED && data.pizzaDay.creator === auth.login && {loadingPizzaDay ?
<div> <span>
<p>Pizza Day je založen a spravován uživatelem {data.pizzaDay.creator}.<br />Můžete upravovat své objednávky.</p> <FontAwesomeIcon icon={faGear} className='fa-spin' /> Zjišťujeme dostupné pizzy
<Button className='danger mb-3' title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={async () => { </span>
await deletePizzaDay(auth.login); :
}}>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 () => { <Button onClick={async () => {
await lockPizzaDay(auth.login); setLoadingPizzaDay(true);
}}>Uzamknout</Button> await createPizzaDay().then(() => setLoadingPizzaDay(false));
</div> }}>Založit Pizza day</Button>
} <Button onClick={doJdemeObed} style={{ marginLeft: "14px" }}>Jdeme na oběd !</Button>
{ </>
data.pizzaDay.state === State.LOCKED && data.pizzaDay.creator === auth.login &&
<div>
<p>Objednávky jsou uzamčeny uživatelem {data.pizzaDay.creator}</p>
<Button className='danger mb-3' title="Umožní znovu editovat objednávky." onClick={async () => {
await unlockPizzaDay(auth.login);
}}>Odemknout</Button>
{/* <Button className='danger mb-3' style={{ marginLeft: '20px' }} onClick={async () => {
await addToCart();
}}>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 () => {
await finishOrder(auth.login);
}}>Objednáno</Button>
</div>
}
{
data.pizzaDay.state === State.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(auth.login);
}}>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(auth.login);
}}>Doručeno</Button>
</div>
}
</div>
}
{
data.pizzaDay.state === State.DELIVERED &&
<div>
<p>Pizzy byly doručeny.</p>
</div>
} }
</div> </div>
<SelectSearch }
search={true} {data.pizzaDay &&
options={pizzaSuggestions} <div>
placeholder='Vyhledat pizzu...' <div style={{ textAlign: 'center' }}>
onChange={handlePizzaChange} <h3>Pizza day</h3>
disabled={data.pizzaDay.state !== State.CREATED} {
/> data.pizzaDay.state === PizzaDayState.CREATED &&
<PizzaOrderList state={data.pizzaDay.state} orders={data.pizzaDay.orders} onDelete={handlePizzaDelete} /> <div>
</div> <p>
} Pizza Day je založen a spravován uživatelem {data.pizzaDay.creator}.<br />
</div> 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();
}}>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 () => {
await finishOrder();
}}>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>
}
<PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} />
{
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr ?
<div className='qr-code'>
<h3>QR platba</h3>
<img src={getQrUrl(auth.login)} alt='QR kód' />
</div> : null
}
</div>
}
</div>
}
</div> </div>
</>} </>}
</div> </div>
<Footer />
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
</> </>
); );
} }

33
client/src/AppRoutes.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { Routes, Route } from "react-router-dom";
import { ProvideSettings } from "./context/settings";
import Snowfall from "react-snowfall";
import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage";
import App from "./App";
export const STATS_URL = '/stats';
export default function AppRoutes() {
return (
<Routes>
<Route path={STATS_URL} element={<StatsPage />} />
<Route path="/" element={
<ProvideSettings>
<SocketContext.Provider value={socket}>
<>
<Snowfall style={{
zIndex: 2,
position: 'fixed',
width: '100vw',
height: '100vh'
}} />
<App />
</>
<ToastContainer />
</SocketContext.Provider>
</ProvideSettings>
} />
</Routes>
);
}

View File

@@ -1,6 +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/Api';
import './Login.css'; import './Login.css';
/** /**
@@ -10,10 +11,27 @@ export default function Login() {
const auth = useAuth(); const auth = useAuth();
const loginRef = useRef<HTMLInputElement>(null); const loginRef = useRef<HTMLInputElement>(null);
const doLogin = useCallback(() => { 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 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) {
auth?.setLogin(loginRef.current.value); // TODO odchytávat cokoliv mimo 200
const token = await login(loginRef.current?.value);
if (token) {
auth?.setToken(token);
}
} }
}, [auth]); }, [auth]);

View File

@@ -1,67 +0,0 @@
// TODO všechno v tomto souboru jsou duplicity se serverem, ale aktuálně nevím jaký je nejlepší způsob jejich sdílení
export interface PizzaSize {
varId: number, // unikátní ID varianty pizzy
size: string, // velikost pizzy, např. "30cm"
pizzaPrice: number, // cena samotné pizzy
boxPrice: number, // cena krabice
price: number, // celková cena (pizza + krabice)
}
/** Jedna konkrétní pizza */
export interface Pizza {
name: string, // název pizzy
ingredients: string[], // seznam ingrediencí
sizes: PizzaSize[], // dostupné velikosti pizzy
}
/** Objednávka jedné konkrétní pizzy */
export interface PizzaOrder {
varId: number, // unikátní ID varianty pizzy
name: string, // název pizzy
size: string, // velikost pizzy jako string (30cm)
price: number, // cena pizzy v Kč, včetně krabice
}
/** Celková objednávka jednoho člověka */
export interface Order {
customer: string, // jméno objednatele
pizzaList: PizzaOrder[], // seznam objednaných pizz
totalPrice: number, // celková cena všech objednaných pizz a krabic
}
export interface Choices {
[location: string]: string[],
}
/** Údaje o Pizza day. */
export interface PizzaDay {
state: State,
creator: string,
orders: Order[]
}
export interface ClientData {
date: string, // dnešní datum pro zobrazení
isWeekend: boolean, // příznak zda je dnešní den víkend
choices: Choices, // seznam voleb
pizzaDay?: PizzaDay, // údaje o pizza day, pokud je pro dnešek založen
}
export enum Locations {
SLADOVNICKA = 'Sladovnická',
UMOTLIKU = 'U Motlíků',
TECHTOWER = 'TechTower',
SPSE = 'SPŠE',
VLASTNI = 'Mám vlastní',
OBJEDNAVAM = 'Objednávám',
NEOBEDVAM = 'Neobědvám',
}
export enum State {
NOT_CREATED, // Pizza day nebyl založen
CREATED, // Pizza day je založen
LOCKED, // Objednávky uzamčeny
ORDERED, // Objednáno
DELIVERED, // Doručeno
}

View File

@@ -1,38 +1,106 @@
import { DepartureTime } from "../../types";
const TOKEN_KEY = "token";
/** /**
* Vrátí kořenovou URL serveru na základě aktuálního prostředí (vývojovou či produkční). * Uloží token do local storage prohlížeče.
* *
* @returns kořenová URL serveru * @param token token
*/ */
export const getBaseUrl = (): string => { export const storeToken = (token: string) => {
if (process.env.PUBLIC_URL) { localStorage.setItem(TOKEN_KEY, token);
return process.env.PUBLIC_URL; }
/**
* Vrátí token z local storage, pokud tam je.
*
* @returns token nebo null
*/
export const getToken = (): string | undefined => {
return localStorage.getItem(TOKEN_KEY) ?? undefined;
}
/**
* Odstraní token z local storage, pokud tam je.
*/
export const deleteToken = () => {
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}`;
} }
return 'http://localhost:3001';
} }
const LOGIN_KEY = "login"; /**
* 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;
}
/** /**
* Uloží login do local storage prohlížeče. * Vrátí index dne v týdnu, kde pondělí=0, neděle=6
* *
* @param login login * @param date datum
* @returns index dne v týdnu
*/ */
export const storeLogin = (login: string) => { export const getDayOfWeekIndex = (date: Date) => {
localStorage.setItem(LOGIN_KEY, login); // https://stackoverflow.com/a/4467559
return (((date.getDay() - 1) % 7) + 7) % 7;
} }
/** /** Vrátí první pracovní den v týdnu předaného data. */
* Vrátí login z local storage, pokud tam je. export function getFirstWorkDayOfWeek(date: Date) {
* const firstDay = new Date(date.getTime());
* @returns login nebo null firstDay.setDate(date.getDate() - getDayOfWeekIndex(date));
*/ return firstDay;
export const getLogin = (): string | null => {
return localStorage.getItem(LOGIN_KEY);
} }
/** /** Vrátí poslední pracovní den v týdnu předaného data. */
* Odstraní login z local storage, pokud tam je. export function getLastWorkDayOfWeek(date: Date) {
*/ const lastDay = new Date(date.getTime());
export const deleteLogin = () => { lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date)));
localStorage.removeItem(LOGIN_KEY); return lastDay;
}
/** Vrátí datum v ISO formátu. */
export function formatDate(date: Date, format?: string) {
let day = String(date.getDate()).padStart(2, '0');
let month = String(date.getMonth() + 1).padStart(2, "0");
let year = String(date.getFullYear());
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í. */
export function getHumanDate(date: Date) {
let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear();
return `${currentDay}.${currentMonth}.${currentYear}`;
} }

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 });
}

8
client/src/api/Client.ts Normal file
View File

@@ -0,0 +1,8 @@
import { client } from '../../../types/gen/client.gen';
import { getToken } from '../Utils';
client.setConfig({
auth: () => getToken(),
});
export default client

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}`);
}

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

@@ -0,0 +1,29 @@
import { AddChoiceRequest, ChangeDepartureTimeRequest, RemoveChoiceRequest, RemoveChoicesRequest, UpdateNoteRequest } from "../../../types";
import { LunchChoice } from "../../../types";
import { api } from "./Api";
const FOOD_API_PREFIX = '/api/food';
export const addChoice = async (locationKey: keyof typeof LunchChoice, foodIndex?: number, dayIndex?: number) => {
return await api.post<AddChoiceRequest, void>(`${FOOD_API_PREFIX}/addChoice`, { locationKey, foodIndex, dayIndex });
}
export const removeChoices = async (locationKey: keyof typeof LunchChoice, dayIndex?: number) => {
return await api.post<RemoveChoicesRequest, void>(`${FOOD_API_PREFIX}/removeChoices`, { locationKey, dayIndex });
}
export const removeChoice = async (locationKey: keyof typeof LunchChoice, 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 () => {
return await api.post<undefined, void>(`${FOOD_API_PREFIX}/jdemeObed`);
}

View File

@@ -0,0 +1,45 @@
import { AddPizzaRequest, FinishDeliveryRequest, RemovePizzaRequest, UpdatePizzaDayNoteRequest, UpdatePizzaFeeRequest } from "../../../types";
import { PizzaVariant } 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: PizzaVariant) => {
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,8 @@
import { WeeklyStats } from "../../../types";
import { api } from "./Api";
const STATS_API_PREFIX = '/api/stats';
export const getStats = async (startDate: string, endDate: string) => {
return await api.get<WeeklyStats>(`${STATS_API_PREFIX}?startDate=${startDate}&endDate=${endDate}`);
}

View File

@@ -0,0 +1,13 @@
import { UpdateFeatureVoteRequest } from "../../../types";
import { FeatureRequest } 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,59 +1,131 @@
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 { errorHandler } from "../api/Api";
import { getFeatureVotes, updateFeatureVote } from "../api/VotingApi";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import { useNavigate } from "react-router";
import { STATS_URL } from "../AppRoutes";
import { FeatureRequest } from "../../../types";
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 navigate = useNavigate();
const bankAccountRef = useRef<HTMLInputElement>(null); const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const nameRef = useRef<HTMLInputElement>(null); const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
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 save = () => { const closePizzaModal = () => {
// TODO validace na modulo 11 setPizzaModalOpen(false);
bank?.setBankAccountNumber(bankAccountRef.current?.value); }
bank?.setBankAccountHolderName(nameRef.current?.value);
closeModal(); const isValidInteger = (str: string) => {
str = str.trim();
if (!str) {
return false;
}
str = str.replace(/^0+/, "") || "0";
const n = Math.floor(Number(str));
return n !== Infinity && String(n) === str && n >= 0;
}
const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => {
if (bankAccountNumber) {
try {
// Validace kódu banky
if (bankAccountNumber.indexOf('/') < 0) {
throw Error("Číslo účtu neobsahuje lomítko/kód banky")
}
const split = bankAccountNumber.split("/");
if (split[1].length !== 4) {
throw Error("Kód banky musí být 4 číslice")
}
if (!isValidInteger(split[1])) {
throw Error("Kód banky není číslo")
}
// Validace čísla a předčíslí
let cislo = split[0];
if (cislo.indexOf('-') > 0) {
cislo = cislo.replace('-', '');
}
if (!isValidInteger(cislo)) {
throw Error("Předčíslí nebo číslo účtu neobsahuje pouze číslice")
}
if (cislo.length < 16) {
cislo = cislo.padStart(16, '0');
}
let sum = 0;
for (var i = 0; i < cislo.length; i++) {
const char = cislo.charAt(i);
const order = (cislo.length - 1) - i;
const weight = (2 ** order) % 11;
sum += Number.parseInt(char) * weight
}
if (sum % 11 !== 0) {
throw Error("Číslo účtu je neplatné")
}
} catch (e: any) {
alert(e.message)
return
}
}
settings?.setBankAccountNumber(bankAccountNumber);
settings?.setBankAccountHolderName(bankAccountHolderName);
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">
<Navbar.Brand>Luncher</Navbar.Brand> <Navbar.Brand href="/">Luncher</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" /> <Navbar.Toggle aria-controls="basic-navbar-nav" />
<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={auth?.clearLogin}>Odhlásit se</NavDropdown.Item> <NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Divider />
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
</NavDropdown> </NavDropdown>
</Nav> </Nav>
</Navbar.Collapse> </Navbar.Collapse>
<Modal show={modalOpen} onHide={closeModal}> <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 />Poznámka: Číslo účtu není aktuálně nijak validováno. Ověřte si jeho správnost.</p>
Číslo účtu: <input ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" /> <br />
Název příjemce (nepovinné): <input ref={nameRef} type="text" placeholder="Jan Novák" />
</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,41 +1,46 @@
import React from "react";
import { Table } from "react-bootstrap"; import { Table } from "react-bootstrap";
import { Order, PizzaOrder, State } from "../Types"; import PizzaOrderRow from "./PizzaOrderRow";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { updatePizzaFee } from "../api/PizzaDayApi";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons"; import { PizzaDayState, PizzaOrder, PizzaVariant } from "../../../types";
import { useAuth } from "../context/auth";
export default function PizzaOrderList({ state, orders, onDelete }: { state: State, orders: Order[], onDelete: (pizzaOrder: PizzaOrder) => void }) { type Props = {
const auth = useAuth(); state: PizzaDayState,
orders: PizzaOrder[],
onDelete: (pizzaOrder: PizzaVariant) => void,
creator: string,
}
if (!orders?.length) { export default function PizzaOrderList({ state, orders, onDelete, creator }: Props) {
return <p><i>Zatím žádné objednávky...</i></p> const saveFees = async (customer: string, text?: string, price?: number) => {
await updatePizzaFee(customer, text, price);
} }
return <Table className="mt-3" striped bordered hover> if (!orders?.length) {
<thead> return <p className="mt-3"><i>Zatím žádné objednávky...</i></p>
<tr> }
<th>Jméno</th>
<th>Objednávka</th> const total = orders.reduce((total, order) => total + order.totalPrice, 0);
<th>Cena</th>
</tr> return <>
</thead> <Table className="mt-3" striped bordered hover>
<tbody> <thead>
{orders.map(order => <tr key={order.customer}> <tr>
<td>{order.customer}</td> <th>Jméno</th>
<td>{order.pizzaList.map<React.ReactNode>((pizzaOrder, index) => <th>Objednávka</th>
<span key={index}> <th>Poznámka</th>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`} <th>Příplatek</th>
{auth?.login === order.customer && state === State.CREATED && <th>Cena</th>
<FontAwesomeIcon onClick={() => { </tr>
onDelete(pizzaOrder); </thead>
}} title='Odstranit' className='trash-icon' icon={faTrashCan} /> <tbody>
} {orders.map(order => <tr key={order.customer}>
</span>) <PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])} </tr>)}
</td> <tr style={{ fontWeight: 'bold' }}>
<td>{order.totalPrice} </td> <td colSpan={4}>Celkem</td>
</tr>)} <td>{`${total}`}</td>
</tbody> </tr>
</Table> </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 PizzaAdditionalFeeModal from "./modals/PizzaAdditionalFeeModal";
import { PizzaDayState, PizzaOrder, PizzaVariant } from "../../../types";
type Props = {
creator: string,
order: PizzaOrder,
state: PizzaDayState,
onDelete: (order: PizzaVariant) => 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

@@ -1,12 +1,13 @@
import React, { ReactNode, useContext, useState } from "react" import React, { ReactNode, useContext, useState } from "react"
import { useEffect } from "react" import { useEffect } from "react"
import { useJwt } from "react-jwt";
const LOGIN_KEY = 'login'; import { deleteToken, getToken, storeToken } from "../Utils";
export type AuthContextProps = { export type AuthContextProps = {
login?: string, login?: string,
setLogin: (name: string) => void, trusted?: boolean,
clearLogin: () => void, setToken: (name: string) => void,
logout: () => void,
} }
type ContextProps = { type ContextProps = {
@@ -26,33 +27,43 @@ 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 | undefined>(getToken());
const { decodedToken } = useJwt(token || '');
useEffect(() => { useEffect(() => {
const login = localStorage.getItem(LOGIN_KEY); if (token && token.length > 0) {
if (login) { storeToken(token);
setLogin(login);
}
}, [])
useEffect(() => {
if (loginName) {
localStorage.setItem(LOGIN_KEY, loginName)
} else { } else {
localStorage.removeItem(LOGIN_KEY); deleteToken();
} }
}, [loginName]); }, [token]);
function setLogin(login: string) { useEffect(() => {
setLoginName(login); if (decodedToken) {
} setLoginName((decodedToken as any).login);
setTrusted((decodedToken as any).trusted);
} else {
setLoginName(undefined);
setTrusted(undefined);
}
}, [decodedToken]);
function clearLogin() { function logout() {
const trusted = (decodedToken as any).trusted;
const logoutUrl = (decodedToken as any).logoutUrl;
setToken(undefined);
setLoginName(undefined); setLoginName(undefined);
setTrusted(undefined);
if (trusted && logoutUrl?.length) {
window.location.replace(logoutUrl);
}
} }
return { return {
login: loginName, login: loginName,
setLogin, trusted,
clearLogin setToken,
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

@@ -1,9 +1,17 @@
import React from 'react'; import React from 'react';
import socketio from "socket.io-client"; import socketio from "socket.io-client";
import { getBaseUrl } from "../Utils";
// Záměrně omezeno jen na websocket, aby se případně odhalilo chybné nastavení proxy serveru let socketUrl;
export const socket = socketio.connect(getBaseUrl(), { transports: ["websocket"] }); let socketPath;
if (process.env.NODE_ENV === 'development') {
socketUrl = `http://localhost:3001`;
socketPath = undefined;
} else {
socketUrl = `${window.location.host}`;
socketPath = `${window.location.pathname}socket.io`;
}
export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] });
export const SocketContext = React.createContext(); export const SocketContext = React.createContext();
// Konstanty websocket eventů, musí odpovídat těm na serveru! // Konstanty websocket eventů, musí odpovídat těm na serveru!

View File

@@ -1,25 +1,20 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App';
import { SocketContext, socket } from './context/socket';
import { ProvideAuth } from './context/auth'; import { ProvideAuth } from './context/auth';
import { ToastContainer } from 'react-toastify';
import { ProvideBank } from './context/bank';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import './index.css'; import './index.css';
import AppRoutes from './AppRoutes';
import { BrowserRouter } from 'react-router';
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement document.getElementById('root') as HTMLElement
); );
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<ProvideAuth> <BrowserRouter>
<ProvideBank> <ProvideAuth>
<SocketContext.Provider value={socket}> <AppRoutes />
<App /> </ProvideAuth>
<ToastContainer /> </BrowserRouter>
</SocketContext.Provider>
</ProvideBank>
</ProvideAuth>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -0,0 +1,16 @@
.stats-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
.week-navigator {
display: flex;
align-items: center;
font-size: xx-large;
.date-range {
margin: 5px 20px;
}
}
}

View File

@@ -0,0 +1,124 @@
import { useCallback, useEffect, useState } from "react";
import Footer from "../components/Footer";
import Header from "../components/Header";
import { useAuth } from "../context/auth";
import Login from "../Login";
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
import { getStats } from "../api/StatsApi";
import { WeeklyStats, LunchChoice } from "../../../types";
import Loader from "../components/Loader";
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import './StatsPage.scss';
const CHART_WIDTH = 1400;
const CHART_HEIGHT = 700;
const STROKE_WIDTH = 2.5;
const COLORS = [
// Komentáře jsou kvůli vizualizaci barev ve VS Code
'#ff1493', // #ff1493
'#1e90ff', // #1e90ff
'#c5a700', // #c5a700
'#006400', // #006400
'#b300ff', // #b300ff
'#ff4500', // #ff4500
'#bc8f8f', // #bc8f8f
'#00ff00', // #00ff00
'#7c7c7c', // #7c7c7c
]
export default function StatsPage() {
const auth = useAuth();
const [dateRange, setDateRange] = useState<Date[]>();
const [data, setData] = useState<WeeklyStats>();
// Prvotní nastavení aktuálního týdne
useEffect(() => {
const today = new Date();
setDateRange([getFirstWorkDayOfWeek(today), getLastWorkDayOfWeek(today)]);
}, []);
// Přenačtení pro zvolený týden
useEffect(() => {
if (dateRange) {
getStats(formatDate(dateRange[0]), formatDate(dateRange[1])).then(setData);
}
}, [dateRange]);
const renderLine = (location: LunchChoice) => {
const index = Object.values(LunchChoice).indexOf(location);
const key = Object.keys(LunchChoice)[index];
return <Line key={location} name={location} type="monotone" dataKey={data => data.locations[key] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
}
const handlePreviousWeek = () => {
if (dateRange) {
const previousStartDate = new Date(dateRange[0]);
previousStartDate.setDate(previousStartDate.getDate() - 7);
const previousEndDate = new Date(previousStartDate);
previousEndDate.setDate(previousEndDate.getDate() + 4);
setDateRange([previousStartDate, previousEndDate]);
}
}
const handleNextWeek = () => {
if (dateRange) {
const nextStartDate = new Date(dateRange[0]);
nextStartDate.setDate(nextStartDate.getDate() + 7);
const nextEndDate = new Date(nextStartDate);
nextEndDate.setDate(nextEndDate.getDate() + 4);
setDateRange([nextStartDate, nextEndDate]);
}
}
const handleKeyDown = useCallback((e: any) => {
if (e.keyCode === 37) {
handlePreviousWeek();
} else if (e.keyCode === 39) {
handleNextWeek()
}
}, [dateRange]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
}
}, [handleKeyDown]);
if (!auth?.login) {
return <Login />;
}
if (!dateRange) {
return <Loader
icon={faGear}
description={'Načítám data...'}
animation={'fa-bounce'}
/>
}
return (
<>
<Header />
<div className="stats-page">
<h1>Statistiky</h1>
<div className="week-navigator">
<FontAwesomeIcon title="Předchozí týden" icon={faChevronLeft} style={{ cursor: "pointer" }} onClick={handlePreviousWeek} />
<h2 className="date-range">{getHumanDate(dateRange[0])} - {getHumanDate(dateRange[1])}</h2>
<FontAwesomeIcon title="Následující týden" icon={faChevronRight} style={{ cursor: "pointer" }} onClick={handleNextWeek} />
</div>
<LineChart width={CHART_WIDTH} height={CHART_HEIGHT} data={data}>
{Object.values(LunchChoice).map(location => renderLine(location))}
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
</LineChart>
</div>
<Footer />
</>
);
}

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,
@@ -19,8 +22,5 @@
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx" "jsx": "react-jsx"
}, }
"include": [
"client/src"
]
} }

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',
}
},
})

File diff suppressed because it is too large Load Diff

51
compose-traefik.yml Normal file
View File

@@ -0,0 +1,51 @@
version: "3"
networks:
proxy:
name: traefik_proxy
services:
traefik:
image: "traefik:latest"
container_name: "traefik"
command:
# - "--log.level=DEBUG"
#- "--log.filePath=/log/traefik.log"
- "--accesslog=true"
#- "--accessLog.filePath=/log/access.log"
- "--api.insecure=false" # pokud chci dashboard
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
#- "--entryPoints.websecure.address=:443"
restart: unless-stopped
networks:
- proxy
ports:
- "${HTTP_PORT:-80}:80"
#- "443:443"
volumes:
#- "./traefik/log:/log"
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "/etc/timezone:/etc/timezone:ro"
environment:
- "TZ=Europe/Prague"
server:
build:
context: ./server
networks:
- proxy
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.server.rule=Host(`${DOMAIN:-localhost}`) && (PathPrefix(`/socket.io`) || PathPrefix(`/api`))'
client:
build:
context: ./client
ports:
- 3000:3000
networks:
- proxy
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.client.rule=Host(`${DOMAIN:-localhost}`)'

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,29 +0,0 @@
version: '3.8'
services:
food_api:
build:
context: ./food_api
# ports:
# - "3002:80"
server:
depends_on:
- food_api
build:
context: ./server
# ports:
# - "3001:3001"
client:
build:
context: ./client
# ports:
# - "3000:3000"
nginx:
depends_on:
- server
- client
restart: always
build:
context: ./nginx
ports:
- 3005:80

View File

@@ -1,10 +0,0 @@
FROM python:3.9
WORKDIR /app
COPY ./requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt
COPY ./food_service.py /app
COPY ./food_api.py /app
CMD ["uvicorn", "food_api:app", "--host", "0.0.0.0", "--port", "3002"]

View File

@@ -1,43 +0,0 @@
# TODO
Následující informace jsou neaktuální. Už nemáme Flask, místo WSGI jedeme přes ASGI apod. Místo tohoto dokumentu využijte nadřazený README.md.
# POMPSZČPS
POMPSZČPS, neboli Parser Obědových Menu Plzeňských Stravovacích Zařízení v Části Plzeň-Slovany, je Python aplikace poskytující na jednom místě aktuální obědové menu pro několik stravovacích zařízení v městské části Plzeň 2-Slovany. Aktuálně podporuje následující podniky:
- [Pivnice Sladovnická](https://sladovnicka.unasplzenchutna.cz)
- [Restaurace U Motlíků](https://www.umotliku.cz)
- [Restaurace TechTower](https://www.equifarm.cz/restaurace-techtower)
Pro tyto podniky umožňuje získání aktuálního obědového menu, a to buďto barevným výpisem do konzole (přímým spuštěním `food_service.py`) nebo v podobě [WSGI](https://cs.wikipedia.org/wiki/Web_Server_Gateway_Interface) endpointu (`wsgi.py`), který vrací zmíněná menu jako strukturovaný JSON objekt pro další použití v jiných aplikacích.
## Závislosti
- [Python 3.x](https://www.python.org)
Pro použití jako konzolová aplikace
- [beautifulsoup4](https://pypi.org/project/beautifulsoup4)
Pro použití jako API endpoint
- [beautifulsoup4](https://pypi.org/project/beautifulsoup4)
- [Flask](https://pypi.org/project/Flask)
- [gunicorn](https://pypi.org/project/gunicorn)
## Použití
```bash
python -m venv venv
(Unix): source venv/bin/activate
(Windows): venv\Scripts\activate.bat
pip install -r requirements.txt
```
- Jako konzolová aplikace: `python food_service.py`
- Vypíše přehledně pod sebe menu všech aktuálně integrovaných podniků
- Jako JSON API endpoint
- TODO
## TODO
- Umožnit zadat a zobrazit menu pro jiné dny
- umožnit zadání datumem nebo názvem dne v týdnu
- validace - žádná sobota, neděle
- validace - datum musí být tento týden
- minimálně pro Motlíky to znamená úpravu URL a parseru
- Otestovat rozchození - vytvoření venv, instalace requirements, spuštění jako konzole
- Umožnit konfiguračně určit pro které podniky se bude menu získávat a zobrazovat (vyberu si jen ty, které mě zajímají)
- Umožnit konfiguračně nastavit výrazy pro detekci polévky

View File

@@ -1,20 +0,0 @@
from food_service import getMenuSladovnicka, getMenuTechTower, getMenuUMotliku
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
def read_root(mock: bool = False):
return {
'sladovnicka': getMenuSladovnicka(mock),
'uMotliku': getMenuUMotliku(mock),
'techTower': getMenuTechTower(mock)
}

View File

@@ -1,278 +0,0 @@
#!/usr/bin/env python3
import datetime
from typing import List
from bs4 import BeautifulSoup
import tempfile
import sys
import os
import urllib.request
from datetime import date, timedelta
URL_SLADOVNICKA = "https://sladovnicka.unasplzenchutna.cz/cz/denni-nabidka"
URL_MOTLICI = "https://www.umotliku.cz"
URL_TECHTOWER = "https://www.equifarm.cz/restaurace-techtower"
DAY_NAMES = ['pondělí', 'úterý', 'středa',
'čtvrtek', 'pátek', 'sobota', 'neděle']
# Fráze v názvech jídel, které naznačují že se jedná o polévku
SOUP_NAMES = ['polévka', 'česnečka', 'česnekový krém', 'cibulačka', 'vývar']
class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
class Food:
name = None
amount = None
price = None
is_soup = False
def __init__(self, name, amount, price, is_soup=False) -> None:
self.name = name
self.amount = amount
self.price = price
self.is_soup = is_soup
def getOrDownloadHtml(prefix: str, url: str):
'''Vrátí HTML pro daný prefix pro aktuální den.
Pokud v tempu neexistuje, provede jeho stažení z předané URL a uložení.'''
filename = prefix + "_" + date.today().strftime("%Y_%m_%d") + ".html"
filepath = os.path.join(tempfile.gettempdir(), filename)
if not os.path.isfile(filepath):
urllib.request.urlretrieve(url, filepath)
file = open(filepath, "r")
contents = file.read()
file.close()
return contents
def isNameOfDay(text: str):
'''Vrátí True, pokud předaný text představuje název dne v týdnu (např. "pondělí")'''
return text.strip().lower() in DAY_NAMES
def getDayNameOfDate(date: datetime.datetime):
'''Vrátí název dne v týdnu - např. pondělí, úterý, ...'''
return DAY_NAMES[date.weekday()]
def getStartOfWeekDate():
'''Vrátí datetime představující pondělí v aktuálním týdnu.'''
today = datetime.datetime.now()
return today - timedelta(days=today.weekday())
def isTextSoupName(text: str):
'''Vrátí True, pokud se předaný text jeví jako název polévky.
Používá se tam, kde nemáme lepší způsob detekce (TechTower).'''
for name in SOUP_NAMES:
if name in text.lower():
return True
return False
def printMenu(name: str, foodList: List[Food]):
'''Vytiskne jídelní lístek na obrazovku.'''
print(f"{bcolors.OKGREEN}{name}{bcolors.ENDC}\n---------------------------------------------------------------------------------")
maxLength = 0
for jidlo in foodList:
if len(jidlo.name) > maxLength:
maxLength = len(jidlo.name)
for jidlo in foodList:
barva = bcolors.HEADER if jidlo.is_soup else bcolors.WARNING
print(f"{barva}{jidlo.amount}\t{jidlo.name.ljust(maxLength)}\t{bcolors.ENDC}{bcolors.OKCYAN}{jidlo.price}{bcolors.ENDC}")
print('\n')
def getMenuSladovnicka(mock: bool = False) -> List[Food]:
if mock:
foodList: List[Food] = []
foodList.append(Food("Zelná polévka s klobásou", "0,25l", "35 Kč", True))
foodList.append(Food("Hovězí na česneku s bramborovým knedlíkem", "150g", "135 Kč"))
foodList.append(Food("Přírodní holandský řízek s bramborovou kaší, rajčatový salát", "250g", "135 Kč"))
foodList.append(Food("Bagel s vinnou klobásou, cibulový konfit, kysané zelí, slanina a hořčicová mayo, hranolky, curry omáčka", "350g", "135 Kč"))
return foodList
if getDayNameOfDate(date.today()).lower() == 'sobota' or getDayNameOfDate(date.today()).lower() == 'neděle':
return []
html = getOrDownloadHtml('sladovnicka', URL_SLADOVNICKA)
soup = BeautifulSoup(html, "html.parser")
div = soup.select_one("div.tab-pane.fade.in.active")
datumDen = div.find("h2").text
split = datumDen.split(".")
denMesic = split[0] + "." + split[1] + "."
# nazevDen = split[2]
dnesniDatum = date.today().strftime("%-d.%-m.")
if denMesic != dnesniDatum:
print('Chyba: neočekávané datum na stránce Sladovnické (' +
denMesic + '), očekáváno ' + dnesniDatum, file=sys.stderr)
sys.exit(1)
tables = div.find_all("table", {"class": "simple"})
if len(tables) != 2:
print('Chyba: neočekávaný počet tabulek na stránce Sladovnické (' +
str(len(tables)) + '), očekávány 2', file=sys.stderr)
sys.exit(1)
foodList: List[Food] = []
polevkaValues = tables[0].find_all("td")
amount = polevkaValues[0].text.strip()
name = polevkaValues[1].text.strip()
price = polevkaValues[2].text.strip()
foodList.append(Food(name, amount, price, True))
foodTables = tables[1].find_all("tr")
for food in foodTables:
rows = food.find_all("td")
if (len(rows) != 3):
print("Neočekávaný počet řádek hlavního jídla Sladovnické (" +
str(len(rows)) + ", očekávány 3, přeskakuji...")
continue
amount = rows[0].text.strip()
name = rows[1].text.strip()
price = rows[2].text.strip()
foodList.append(Food(name, amount, price))
return foodList
def getMenuUMotliku(mock: bool = False) -> List[Food]:
if mock:
foodList: List[Food] = []
foodList.append(Food("Hovězí vývar s nudlemi", "0,33l", "35 Kč", True))
foodList.append(Food("Opečený párek, čočka, sázené vejce, okurka", "150g", "135 Kč"))
foodList.append(Food("Hovězí líčka na červeném víně, bramborová kaše", "150g", "145 Kč"))
foodList.append(Food("Tortilla s trhaným kuřecím masem, uzeným sýrem, dipem a kukuřicí, míchaný salát", "150g", "135 Kč"))
return foodList
if getDayNameOfDate(date.today()).lower() == 'sobota' or getDayNameOfDate(date.today()).lower() == 'neděle':
return []
html = getOrDownloadHtml('u_motliku', URL_MOTLICI)
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table", {"class": "Xtable-striped"})
rows = table.find_all("tr")
if len(rows) < 4:
print('Chyba: neočekávaný celkový počet řádek tabulky (' +
str(len(rows)) + '), očekáváno 4 a více', file=sys.stderr)
sys.exit(1)
foodList: List[Food] = []
if rows[0].td.text.strip() == 'Polévka':
tds = rows[1].find_all("td")
if len(tds) != 3:
print('Chyba: neočekávaný počet <td> elementů v řádce polévky (' +
str(len(tds)) + '), očekáváno 3', file=sys.stderr)
sys.exit(1)
amount = tds[0].text.strip()
name = tds[1].text.strip()
price = tds[2].text.strip().replace(',-', '')
foodList.append(Food(name, amount, price, True))
rows = rows[2:]
if rows[0].td.text.strip() == 'Hlavní jídlo':
for i in range(1, len(rows)):
tds = rows[i].find_all("td")
if len(tds) != 3:
print("Neočekávaný počet <td> elementů (" + str(len(tds)
) + ") pro hlavní jídlo " + str(i) + ", přeskakuji")
continue
amount = tds[0].text.strip()
name = tds[1].text.strip()
price = tds[2].text.strip().replace(',-', '')
foodList.append(Food(name, amount, price))
return foodList
def getMenuTechTower(mock: bool = False) -> List[Food]:
if mock:
foodList: List[Food] = []
foodList.append(Food("Bavorská gulášová polévka s kroupami", "-", "40 Kč", True))
foodList.append(Food("Vepřové výpečky, kedlubnové zelí, bramborový knedlík", "-", "120 Kč"))
foodList.append(Food("Hambuger Black Angus s čedarem a slaninou, cibulové kroužky", "-", "220 Kč"))
return foodList
if getDayNameOfDate(date.today()).lower() == 'sobota' or getDayNameOfDate(date.today()).lower() == 'neděle':
return []
html = getOrDownloadHtml('techtower', URL_TECHTOWER)
soup = BeautifulSoup(html, "html.parser")
fonts = soup.find_all("font", {"class": ["wsw-41"]})
font = None
for f in fonts:
if (f.text.strip().startswith("Obědy")):
font = f
if font is None:
print('Chyba: nenalezen <font> pro obědy v HTML Techtower.', file=sys.stderr)
sys.exit(1)
siblings = font.parent.parent.find_next_siblings("p")
# dayNumber = date.today().strftime("%w")
currentDayName = getDayNameOfDate(datetime.datetime.now())
foodList: List[Food] = []
doParse = False
for i in range(0, len(siblings)):
text = siblings[i].text.strip().replace('\t', '').replace('\n', ' ')
if isNameOfDay(text):
if text == currentDayName:
# Našli jsme dnešní den, odtud začínáme parsovat jídla
doParse = True
elif doParse == True:
# Už parsujeme jídla, ale narazili jsme na následující den - končíme
break
elif doParse:
if len(text.strip()) == 0:
# Prázdná řádka - končíme (je za pátečním menu TechTower)
break
price = '? Kč'
if text.endswith(''):
split = text.rsplit(' ', 2)
price = " ".join(split[1:])
text = split[0]
foodList.append(Food(text, '-', price, isTextSoupName(text)))
return foodList
if __name__ == "__main__":
if len(sys.argv) > 1:
input = sys.argv[1].lower()
selectedDate = None
if input[0].isalpha():
matches = []
for day in DAY_NAMES:
if day.startswith(input):
matches.append(day)
if len(matches) == 1:
print("Match - den v týdnu - " + matches[0])
selectedDate = getStartOfWeekDate(
) + timedelta(DAY_NAMES.index(matches[0]))
elif len(matches) == 0:
# TODO zkusit v, z (včera, zítra)
if 'zítra'.startswith(input):
print("Match - zítra")
selectedDate = datetime.datetime.now() + timedelta(days=1)
elif 'včera'.startswith(input):
print("Match - včera")
selectedDate = datetime.datetime.now() + timedelta(days=-1)
elif 'dneska'.startswith(input):
print("Match - dnes")
selectedDate = datetime.datetime.now()
else:
print('Nejasný parametr "' + input +
'" - může znamenat jednu z možností: ' + ', '.join(matches), file=sys.stderr)
sys.exit(1)
else:
# TODO implementovat zadání datem
print('Zadání datem není aktuálně implementováno', file=sys.stderr)
sys.exit(1)
print("Datum: " + selectedDate.strftime('%d.%m.%Y'))
print("Den: " + getDayNameOfDate(selectedDate))
# printMenu('Sladovnická', getMenuSladovnicka())
# printMenu('U Motlíků', getMenuUMotliku())
# printMenu('TechTower', getMenuTechTower())

View File

@@ -1,3 +0,0 @@
beautifulsoup4==4.12.2
fastapi==0.95.2
uvicorn==0.22.0

View File

@@ -1,8 +0,0 @@
#!/bin/bash
dir="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
cd $dir
python3 -m venv venv
source venv/bin/activate
pip3 install -r requirements.txt
uvicorn food_api:app --port 3002 --reload

View File

@@ -1,2 +0,0 @@
FROM nginx
COPY ./default.conf /etc/nginx/conf.d/default.conf

View File

@@ -1,47 +0,0 @@
upstream client {
server client:3000;
}
upstream server {
server server:3001;
}
upstream food_api {
server food_api:3002;
}
server {
listen 80;
location / {
proxy_pass http://client;
}
location /static {
proxy_pass http://client;
}
location /sockjs-node {
proxy_pass http://client;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location /socket.io {
proxy_pass http://server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location /api/food {
rewrite /api/food(.*) /$1 break;
proxy_pass http://food_api;
}
location /api {
# rewrite /api/(.*) /$1 break;
proxy_pass http://server;
}
}

View File

@@ -1,5 +1,5 @@
export NODE_ENV=development export NODE_ENV=development
./food_api/run_dev.sh & cd types && yarn install && yarn openapi-ts
cd server && yarn install && yarn start & cd server && yarn install && yarn start &
cd client && yarn install && yarn start & cd client && yarn install && yarn start &
wait wait

View File

@@ -1,8 +1,40 @@
# URL na kterém je dostupný Food API parser. # Secret pro podepisování JWT tokenů. Minimální délka 32 znaků.
# Pro vývoj není potřeba, bude použita výchozí hodnota http://localhost:3002 # JWT_SECRET='CHANGE_ME'
# FOOD_API_URL=http://nginx/api/food
# Zapne režim mockování jídelních lístků. # 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.
# 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 Food API 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.
# MOCK_DATA=true # MOCK_DATA=true
# Určuje servery Gotify a příslušné klíče API.
# Formát je pole objektů, kde každý objekt obsahuje adresu serveru a pole klíčů 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.
# 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

7
server/.gitignore vendored
View File

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

View File

@@ -1,16 +0,0 @@
FROM node:18-alpine3.18
ENV LANG cs_CZ.UTF-8
WORKDIR /app
COPY package.json .
COPY yarn.lock .
COPY .env.production .
COPY tsconfig.json .
COPY src ./src
RUN yarn install --frozen-lockfile
RUN yarn build
CMD [ "node", "/app/dist/index.js" ]

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

@@ -1,3 +0,0 @@
#!/bin/bash
yarn install --frozen-lockfile && yarn build
docker build -t luncher-server .

View File

@@ -1,17 +1,27 @@
{ {
"name": "luncher-server", "name": "@luncher/server",
"version": "1.0.0", "version": "1.0.0",
"main": "src/index.ts", "main": "src/index.ts",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "ts-node src/index.ts", "start": "ts-node src/index.ts",
"build": "tsc -p ." "startReload": "nodemon --watch src src/index.ts",
"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/node": "^20.2.5", "@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.11.20",
"@types/request-promise": "^4.1.48", "@types/request-promise": "^4.1.48",
"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"
}, },
@@ -19,9 +29,11 @@
"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",
"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"
} }
} }

71
server/src/auth.ts Normal file
View File

@@ -0,0 +1,71 @@
import jwt from 'jsonwebtoken';
/**
* Vygeneruje a vrátí podepsaný JWT token pro daný login.
*
* @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
*/
export function generateToken(login?: string, trusted?: boolean): string {
if (!process.env.JWT_SECRET) {
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
if (process.env.JWT_SECRET.length < 32) {
throw Error("Proměnná prostředí JWT_SECRET musí být minimálně 32 znaků");
}
if (!login || login.trim().length === 0) {
throw Error("Nebyl předán login");
}
const payload = { login, trusted: trusted || false, logoutUrl: process.env.LOGOUT_URL };
return jwt.sign(payload, process.env.JWT_SECRET);
}
/**
* Vrátí true, pokud je předaný JWT token platný.
*
* @param token JWT token
*/
export function verify(token: string): boolean {
if (!process.env.JWT_SECRET) {
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
try {
jwt.verify(token, process.env.JWT_SECRET);
return true;
} catch (err) {
return false;
}
}
/**
* Vrátí login z daného JWT tokenu, pokud je token platný.
*
* @param token JWT token
*/
export function getLogin(token?: string): string {
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.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,8 @@
import $ from 'cheerio';
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 { getPizzaListMock } from './mock';
// TODO přesunout do types
type PizzaSize = { type PizzaSize = {
varId: number, varId: number,
size: string, size: string,
@@ -12,6 +11,7 @@ type PizzaSize = {
price: number price: number
} }
// TODO přesunout do types
type Pizza = { type Pizza = {
name: string, name: string,
ingredients: string[], ingredients: string[],
@@ -36,11 +36,18 @@ 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 links = $('.vypisproduktu > div > h4 > a', html) const $ = load(html);
const links = $('.vypisproduktu > div > h4 > a');
const urls = []; const urls = [];
for (let i = 0; i < links.length; i++) { for (let i = 0; i < links.length; i++) {
if (links[i].name === 'a' && links[i].attribs?.href) { if (links[i].name === 'a' && links[i].attribs?.href) {
@@ -77,29 +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 date = ("0" + date_ob.getDate()).slice(-2);
const month = ("0" + (date_ob.getMonth() + 1)).slice(-2);
const year = date_ob.getFullYear();
const dateStr = year + "-" + month + "-" + date;
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,163 +1,151 @@
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 } from "./service"; import { getData, getDateForWeekIndex } from "./service";
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { fetchMenus } from "./restaurants"; import { getQr } from "./qr";
import { generateToken, verify } from "./auth";
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";
import statsRoutes from "./routes/statsRoutes";
const ENVIRONMENT = process.env.NODE_ENV || 'production' const ENVIRONMENT = process.env.NODE_ENV || 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); dotenv.config({ path: path.resolve(__dirname, `./.env.${ENVIRONMENT}`) });
// Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit
if (!process.env.JWT_SECRET) {
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
const app = express(); const app = express();
const server = require("http").createServer(app); const server = require("http").createServer(app);
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: '*'
})); }));
// Zapínatelný login přes hlavičky - pokud je zapnutý nepovolí "basicauth"
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME || 'remote-user';
if (HTTP_REMOTE_USER_ENABLED) {
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 --------------
app.get("/api/whoami", (req, res) => {
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) => {
if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy')
// Autentizace pomocí trusted headers
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
const remoteName = req.header('remote-name');
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));
}
});
// TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token
app.get("/api/qr", (req, res) => {
// const login = getLogin(parseToken(req));
if (!req.query?.login) {
throw Error("Nebyl předán login");
}
const img = getQr(req.query.login as string);
res.writeHead(200, {
'Content-Type': 'image/png',
'Content-Length': img.length
});
res.end(img);
});
// ----------------------------------------------------
/** Middleware ověřující JWT token */
app.use("/api/", (req, res, next) => {
if (HTTP_REMOTE_USER_ENABLED) {
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) {
return res.status(401).json({ error: 'Nebyl předán autentizační token' });
}
const token = req.headers.authorization.split(' ')[1];
if (!verify(token)) {
return res.status(403).json({ error: 'Neplatný autentizační token' });
}
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", (req, res) => { date = getDateForWeekIndex(parseInt(req.query.dayIndex));
fetchMenus().then(food => {
res.status(200).json(food);
})
});
/** 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) => {
if (!req.body?.creator) {
throw Error("Nebyl předán název zakládajícího");
}
const data = createPizzaDay(req.body.creator);
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) => {
if (!req.body?.login) {
throw Error("Nebyl předán login uživatele");
}
const data = deletePizzaDay(req.body.login);
io.emit("message", data);
});
app.post("/api/addPizza", (req, res) => {
if (!req.body?.login) {
throw Error("Nebyl předán login");
}
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); res.status(200).json(await getData(date));
}
const data = addPizzaOrder(req.body.login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
io.emit("message", data);
res.status(200).json({});
})
}); });
app.post("/api/removePizza", (req, res) => { // Ostatní routes
if (!req.body?.login) { app.use("/api/pizzaDay", pizzaDayRoutes);
throw Error("Nebyl předán login"); app.use("/api/food", foodRoutes);
app.use("/api/voting", votingRoutes);
app.use("/api/easterEggs", easterEggRoutes);
app.use("/api/stats", statsRoutes);
app.use('/stats', express.static('public'));
app.use(express.static('public'));
// Middleware pro zpracování chyb
app.use((err: any, req: any, res: any, next: any) => {
if (err instanceof InsufficientPermissions) {
res.status(403).send({ error: err.message })
} else {
res.status(500).send({ error: err.message })
} }
if (!req.body?.pizzaOrder) { next();
throw Error("Nebyla předána objednávka");
}
const data = removePizzaOrder(req.body.login, req.body?.pizzaOrder);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/lockPizzaDay", (req, res) => {
if (!req.body?.login) {
throw Error("Nebyl předán login");
}
const data = lockPizzaDay(req.body.login);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/unlockPizzaDay", (req, res) => {
if (!req.body?.login) {
throw Error("Nebyl předán login");
}
const data = unlockPizzaDay(req.body.login);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/finishOrder", (req, res) => {
if (!req.body?.login) {
throw Error("Nebyl předán login");
}
const data = finishPizzaOrder(req.body.login);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/finishDelivery", (req, res) => {
if (!req.body?.login) {
throw Error("Nebyl předán login");
}
const data = finishPizzaDelivery(req.body.login);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/updateChoice", (req, res) => {
if (!req.body.hasOwnProperty('name')) {
res.status(400).json({});
}
const data = updateChoice(req.body.name, req.body.choice);
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;
@@ -167,4 +155,8 @@ server.listen(PORT, () => {
console.log(`Server listening on ${HOST}, port ${PORT}`); console.log(`Server listening on ${HOST}, port ${PORT}`);
}); });
console.log(process.env.API_KEY) // Umožníme vypnutí serveru přes SIGINT, jinak Docker čeká než ho sestřelí
process.on('SIGINT', function () {
console.log("\nSIGINT (Ctrl-C), vypínám server");
process.exit(0);
});

1411
server/src/mock.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +1,159 @@
/** Notifikace pro gotify*/ /** Notifikace */
import axios, {AxiosError, AxiosResponse} from 'axios'; import axios from 'axios';
import {NotififaceInput, NotifikaceData, UdalostEnum} from "./types"; import dotenv from 'dotenv';
import path from 'path';
import { getClientData, getToday } from "./service";
import { getUsersByLocation, getHumanTime } from "./utils";
import getStorage from "./storage";
import { NotifikaceData, NotifikaceInput } from '../../types';
const url = 'your_URL_here'; // Place your URL here const storage = getStorage();
const ENVIRONMENT = process.env.NODE_ENV || 'production'
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
export const gotifyCall = async (data: NotififaceInput): Promise<void> => { // const gotifyDataRaw = process.env.GOTIFY_SERVERS_AND_KEYS || "{}";
// 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;
// };
const options = { export const ntfyCall = async (data: NotifikaceInput) => {
headers: {"Content-Type": "application/json"}, const url = process.env.NTFY_HOST
data: { const username = process.env.NTFY_USERNAME;
title: "Luncher", const password = process.env.NTFY_PASSWD;
message: `${data.udalost} - spustil:${data.user}`, if (!url) {
priority: 7, console.log("NTFY_HOST není definován v env")
}, return
}
if (!username) {
console.log("NTFY_USERNAME není definován v env")
return
}
if (!password) {
console.log("NTFY_PASSWD není definován v env")
return
}
let clientData = await getClientData(getToday());
const userByCLocation = getUsersByLocation(clientData.choices, data.user)
const token = Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
const promises = userByCLocation.map(async user => {
try {
// 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: NotifikaceInput) => {
const url = process.env.TEAMS_WEBHOOK_URL;
const title = data.udalost;
let time = new Date();
time.setTime(time.getTime() + 1000 * 60);
const message = 'Odcházíme v ' + getHumanTime(time) + ', ' + data.user;
const card = {
'@type': 'MessageCard',
'@context': 'http://schema.org/extensions',
'themeColor': "0072C6", // light blue
summary: 'Summary description',
sections: [
{
activityTitle: title,
text: message,
},
],
}; };
try { if (!url) {
const response = await axios.post(url, options); console.log("TEAMS_WEBHOOK_URL není definován v env")
response.data = { return
success: true,
message: "Notifikace doručena",
};
console.log(response)
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.response) {
axiosError.response.data = {
success: false,
message: "fail",
};
console.log(axiosError.response);
}
}
// Handle unknown error without a response
console.log("unkown error")
} }
};
try {
const response = await axios.post(url, card, {
headers: {
'content-type': 'application/vnd.microsoft.teams.card.o365connector'
},
});
return `${response.status} - ${response.statusText}`;
} catch (err) {
return err;
}
}
/** Zavolá notifikace na všechny konfigurované způsoby notifikace, přetížení proměných na false pro jednotlivé způsoby je vypne*/ /** Zavolá notifikace na všechny konfigurované způsoby notifikace, přetížení proměných na false pro jednotlivé způsoby je vypne*/
export const callNotifikace = async ({input, teams = true, gotify = true}: NotifikaceData) => { export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy = true }: NotifikaceData) => {
const notifications = []; const notifications = [];
if (gotify) { if (ntfy) {
notifications.push(gotifyCall(input)); const ntfyPromises = await ntfyCall(input);
if (ntfyPromises) {
notifications.push(...ntfyPromises);
}
} }
/* Zatím není
if (teams) { if (teams) {
notifications.push(teamsCall(input)); const teamsPromises = await teamsCall(input);
}*/ if (teamsPromises) {
notifications.push(teamsPromises);
// Add more notifications as necessary }
}
// gotify bych řekl, že už je deprecated
// if (gotify) {
// const gotifyPromises = await gotifyCall(input, gotifyData);
// notifications.push(...gotifyPromises);
// }
try { try {
const results = await Promise.all(notifications); const results = await Promise.all(notifications);

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

@@ -0,0 +1,314 @@
import { formatDate } from "./utils";
import { callNotifikace } from "./notifikace";
import { generateQr } from "./qr";
import getStorage from "./storage";
import { downloadPizzy } from "./chefie";
import { getClientData, getToday, initIfNeeded } from "./service";
import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum } from "../../types";
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();
let clientData = await getClientData(getToday());
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 = await getClientData(getToday());
clientData.pizzaList = pizzaList;
clientData.pizzaListLastUpdate = formatDate(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 clientData = await getClientData(getToday());
if (clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den již existuje");
}
// TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě!
const pizzaList = await getPizzaList();
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, ...clientData };
const today = formatDate(getToday());
await storage.setData(today, data);
callNotifikace({ input: { 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 clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Login uživatele se neshoduje se zakladatelem Pizza Day");
}
delete clientData.pizzaDay;
const today = formatDate(getToday());
await storage.setData(today, clientData);
return clientData;
}
/**
* Přidá objednávku pizzy uživateli.
*
* @param login login uživatele
* @param pizza zvolená pizza
* @param size zvolená velikost pizzy
*/
export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
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: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
if (!order) {
order = {
customer: login,
pizzaList: [],
totalPrice: 0,
hasQr: false,
}
if (!clientData.pizzaDay.orders) {
clientData.pizzaDay.orders = [];
}
clientData.pizzaDay.orders.push(order);
}
const pizzaOrder: PizzaVariant = {
varId: size.varId,
name: pizza.name,
size: size.size,
price: size.price,
}
if (!order.pizzaList) {
order.pizzaList = [];
}
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: PizzaVariant) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
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 = await getClientData(getToday());
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 = await getClientData(getToday());
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 = await getClientData(getToday());
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({ input: { udalost: UdalostEnum.OBJEDNANA_PIZZA, user: clientData?.pizzaDay?.creator } })
return clientData;
}
/**
* Nastaví stav pizza day na "pizzy doručeny".
* Vygeneruje QR kódy pro všechny objednatele, pokud objednávající má vyplněné číslo účtu a jméno.
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export async function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
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 = await getClientData(getToday());
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?.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 = await getClientData(getToday());
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?.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;
}

98
server/src/qr.ts Normal file
View File

@@ -0,0 +1,98 @@
import fs from "fs";
import axios from "axios";
import os from "os";
import path from "path";
import crypto from "crypto";
import { formatDate } from "./utils";
const QR_GENERATOR_URL = 'https://api.paylibo.com/paylibo/generator/image';
const COUNTRY_CODE = 'CZ';
const CURRENCY_CODE = 'CZK';
const QR_PIXEL_SIZE = 256;
const tmpDir = os.tmpdir();
/**
* Převede číslo účtu z BBAN do IBAN. Automaticky dopočítá kontrolní číslice.
*
* @param bankAccountNumber číslo účtu ve formátu BBAN (123456-0123456789/0100)
*/
function convertBbanToIban(bankAccountNumber: string): string {
// TODO validovat číslo účtu stejně jako na klientovi, pro případ že sem někdo pošle nesmysl
let prefix: string = '';
let accountNumber: string = bankAccountNumber;
if (bankAccountNumber.indexOf('-') >= 0) {
const split = bankAccountNumber.split('-');
prefix = split[0];
accountNumber = split[1];
}
prefix = prefix.padStart(6, '0');
const split = accountNumber.split('/');
accountNumber = split[0].padStart(10, '0');
const bankCode = split[1].padStart(4, '0');
let iban = `${bankCode}${prefix}${accountNumber}${COUNTRY_CODE}00`;
// Zatím napevno, nemá smysl řešit nic jiného než CZ
iban = iban.replace('C', '12').replace('Z', '35');
const remainder = BigInt(iban) % BigInt(97);
const checkDigits = BigInt(98) - remainder;
iban = `${COUNTRY_CODE}${checkDigits.toString()}${bankCode}${prefix}${accountNumber}`;
if (iban.length !== 24) {
throw Error("Neplatná délka sestaveného IBAN: " + iban.length + ", očekáváno 24");
}
return iban;
}
function createNameHash(customerName: string): string {
return crypto.createHash('md5').update(customerName).digest('hex');
}
function createFilePath(nameHash: string): string {
const fileName = `${formatDate(new Date())}_${nameHash}.png`;
return path.join(tmpDir, fileName);
}
/**
* Vygeneruje, uloží a vrátí unikátní ID obrázku platebního QR kódu s danými parametry.
*
* @param customerName jméno uživatele, pro kterého je QR kód generován
* @param bankAccountNumber číslo cílového bankovního účtu ve formátu BBAN
* @param bankAccountHolder jméno držitele cílového bankovního účtu
* @param amount částka v Kč
* @param message zpráva pro příjemce
* @returns hash, pomocí kterého lze následně získat vygenerovaný obrázek
*/
export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string): Promise<string> {
// Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků
if (message.indexOf('*') >= 0) {
message = message.replace('*', '');
}
if (message.length > 60) {
message = message.substring(0, 60);
}
const payload = {
iban: convertBbanToIban(bankAccountNumber),
amount,
currency: CURRENCY_CODE,
message,
recipientName: bankAccountHolder,
branding: false,
compress: false,
size: QR_PIXEL_SIZE,
}
const response = await axios.get(QR_GENERATOR_URL, { responseType: 'stream', params: { ...payload } });
// Použijeme hash, abychom nemuseli řešit nepovolené znaky ve jménu uživatele
const nameHash = createNameHash(customerName);
const imgPath = createFilePath(nameHash);
response.data.pipe(fs.createWriteStream(imgPath));
return nameHash;
}
/**
* Vrátí obrázek s QR kódem, pokud existuje.
*
* @param customerName jméno uživatele
* @returns data obrázku
*/
export function getQr(customerName: string): Buffer {
const imgPath = createFilePath(createNameHash(customerName));
return fs.readFileSync(imgPath);
}

View File

@@ -1,13 +1,434 @@
import axios from "axios"; import axios from "axios";
import { load } from 'cheerio';
import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock, getMenuZastavkaUmichalaMock, getMenuSenkSerikovaMock } from "./mock";
import { formatDate } from "./utils";
import { Food } from "../../types";
// URL na Food API - získání jídelních lístků restaurací // Fráze v názvech jídel, které naznačují že se jedná o polévku
const foodUrl = process.env.FOOD_API_URL || 'http://localhost:3002'; 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'];
export const fetchMenus = async () => { // URL na týdenní menu jednotlivých restaurací
try { const SLADOVNICKA_URL = 'https://sladovnicka.unasplzenchutna.cz/cz/denni-nabidka';
return await axios.get(foodUrl, { params: { mock: !!process.env.MOCK_DATA } }).then(res => res.data); const U_MOTLIKU_URL = 'https://www.umotliku.cz/menu';
} catch (error) { const TECHTOWER_URL = 'https://www.equifarm.cz/restaurace-techtower';
console.error("Chyba při volání Food API", error); const ZASTAVKAUMICHALA_URL = 'https://www.zastavkaumichala.cz';
return {}; 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.
* Využito tam, kde nelze polévku identifikovat lepším způsobem (TechTower).
*
* @param text vstupní text
* @returns true, pokud text představuje polévku
*/
const isTextSoupName = (text: string): boolean => {
for (const name of SOUP_NAMES) {
if (text.toLowerCase().includes(name)) {
return true;
}
} }
} return false;
}
const capitalize = (word: string): string => {
return word.charAt(0).toUpperCase() + word.slice(1);
}
const sanitizeText = (text: string): string => {
return text.replace('\t', '').replace(' , ', ', ').trim();
}
/**
* Stáhne a vrátí aktuální HTML z dané URL.
*
* @param url URL pro stažení
* @returns stažené HTML
*/
const getHtml = async (url: string): Promise<any> => {
return await axios.get(url).then(res => res.data).then(content => content);
}
/**
* Získá obědovou nabídku Sladovnické 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ý týden
*/
export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuSladovnickaMock();
}
const html = await getHtml(SLADOVNICKA_URL);
const $ = load(html);
const list = $('ul.tab-links').children();
const result: Food[][] = [];
for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
const currentDate = new Date(firstDayOfWeek);
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
const searchedDayText = `${currentDate.getDate()}.${currentDate.getMonth() + 1}.${capitalize(DAYS_IN_WEEK[dayIndex])}`;
// 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)
let index = undefined;
list.each((i, dayRow) => {
const rowText = $(dayRow).first().text().trim();
if (rowText === searchedDayText) {
index = i;
return;
}
})
if (index === undefined) {
// Pravděpodobně svátek, nebo je zavřeno
result[dayIndex] = [{
amount: undefined,
name: "Pro daný den nebyla nalezena denní nabídka",
price: "",
isSoup: false,
}];
continue;
}
// 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 restaurace U Motlíků 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 getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuUMotlikuMock();
}
const html = await getHtml(U_MOTLIKU_URL);
const $ = load(html);
// 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;
fonts.each((i, f) => {
if ($(f).text().trim().startsWith('Obědy')) {
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) {
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
const siblings = secondTry ? $(font).parent().siblings() : $(font).parent().parent().siblings();
let parsing = false;
let currentDayIndex = 0;
for (let i = 0; i < siblings.length; i++) {
const text = $(siblings.get(i)).text().trim().replace('\t', '').replace('\n', ' ');
if (DAYS_IN_WEEK.includes(text.toLocaleLowerCase())) {
// Zjistíme aktuální index
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;
}
} else if (parsing) {
if (text.length == 0) {
// Prázdná řádka - bývá na zcela náhodných místech ¯\_(ツ)_/¯
continue;
}
let price = 'na\xA0váhu';
let name = text.replace('•', '');
if (text.toLowerCase().endsWith('kč')) {
const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
price = `${split.slice(1)[0]}\xA0Kč`
name = split[0].replace('•', '');
}
if (result[currentDayIndex] == null) {
result[currentDayIndex] = [];
}
result[currentDayIndex].push({
amount: '-',
name,
price,
isSoup: isTextSoupName(name),
})
}
}
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,145 @@
import express, { Request } from "express";
import { getLogin, getTrusted } from "../auth";
import { addChoice, 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, RemoveChoiceRequest, RemoveChoicesRequest, UpdateNoteRequest } from "../../../types";
import { UdalostEnum } 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", 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", 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", 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", 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", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/jdemeObed", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
await callNotifikace({ input: { user: login, udalost: UdalostEnum.JDEME_NA_OBED }, gotify: false })
res.status(200).json({});
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -0,0 +1,112 @@
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 { 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -0,0 +1,22 @@
import express, { Request, Response } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getStats } from "../stats";
import { WeeklyStats } from "../../../types";
const router = express.Router();
router.get("/", async (req: Request<{}, any, undefined>, res: Response<WeeklyStats>) => {
getLogin(parseToken(req));
if (typeof req.query.startDate === 'string' && typeof req.query.endDate === 'string') {
try {
const data = await getStats(req.query.startDate, req.query.endDate);
return res.status(200).json(data);
} catch (e) {
// necháme to zatím spadnout na 400
}
}
res.sendStatus(400);
});
export default router;

View File

@@ -0,0 +1,27 @@
import express, { Request, Response } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getUserVotes, updateFeatureVote } from "../voting";
import { UpdateFeatureVoteRequest } from "../../../types";
import { FeatureRequest } 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,254 +1,441 @@
import {ClientData, Locations, Order, Pizza, PizzaDayState, PizzaOrder, PizzaSize, UdalostEnum} from "./types"; import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getIsWeekend, getWeekNumber } from "./utils";
import {db} from "./database"; import getStorage from "./storage";
import {formatDate, getHumanDate, getIsWeekend} from "./utils"; import { getMenuSladovnicka, getMenuTechTower, getMenuUMotliku, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants";
import {callNotifikace} from "./notifikace"; import { getTodayMock } from "./mock";
import { ClientData, DepartureTime, LunchChoice, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types";
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 {
todayDayIndex: getDayOfWeekIndex(getToday()),
date: getHumanDate(usedDate),
isWeekend: getIsWeekend(usedDate),
dayIndex: getDayOfWeekIndex(usedDate),
choices: {},
};
} }
/** /**
* Vrátí veškerá klientská data pro aktuální den. * Vrátí veškerá klientská data pro předaný den, nebo aktuální den, pokud není předán.
*/ */
export function getData(): ClientData { export async function getData(date?: Date): Promise<ClientData> {
const data = db.get(formatDate(getToday())) || getEmptyData(); const clientData = await getClientData(date);
return data; clientData.menus = {
} SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
/** TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
* Vytvoří pizza day pro aktuální den a vrátí data pro klienta. ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
*/ SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
export function createPizzaDay(creator: string): ClientData {
initIfNeeded();
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
if (clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den již existuje");
} }
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, ...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 týden příslušící předanému datu.
*
* @param date datum
* @returns databázový klíč
*/
function getMenuKey(date: Date) {
const weekNumber = getWeekNumber(date);
return `${MENU_PREFIX}_${date.getFullYear()}_${weekNumber}`;
}
/**
* Vrátí menu všech podniků pro celý týden do kterého spadá předané datum, pokud již existují.
*
* @param date datum
* @returns menu restaurací pro týden příslušící předanému datu
*/
async function getMenu(date: Date): Promise<WeekMenu | undefined> {
return await storage.getData<WeekMenu | undefined>(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: keyof typeof Restaurant, date?: Date): Promise<RestaurantDayMenu> {
const usedDate = date ?? getToday();
const dayOfWeekIndex = getDayOfWeekIndex(usedDate);
const now = new Date().getTime();
if (getIsWeekend(usedDate)) {
return {
lastUpdate: now,
closed: true,
food: [],
};
}
let weekMenu = await getMenu(usedDate);
if (weekMenu == null) {
weekMenu = [{}, {}, {}, {}, {}];
}
for (let i = 0; i < 5; i++) {
if (weekMenu[i] == null) {
weekMenu[i] = {};
}
if (weekMenu[i][restaurant] == null) {
weekMenu[i][restaurant] = {
lastUpdate: now,
closed: false,
food: [],
};
}
}
if (!weekMenu[dayOfWeekIndex][restaurant]?.food?.length) {
const firstDay = getFirstWorkDayOfWeek(usedDate);
const mock = process.env.MOCK_DATA === 'true';
switch (restaurant) {
case 'SLADOVNICKA':
try {
const sladovnickaFood = await getMenuSladovnicka(firstDay, mock);
for (let i = 0; i < sladovnickaFood.length; i++) {
weekMenu[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') {
weekMenu[i][restaurant]!.closed = true;
}
}
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Sladovnická", e);
}
break;
// case '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 'TECHTOWER':
try {
const techTowerFood = await getMenuTechTower(firstDay, mock);
for (let i = 0; i < techTowerFood.length; i++) {
weekMenu[i][restaurant]!.food = techTowerFood[i];
if (techTowerFood[i]?.length === 1 && techTowerFood[i][0].name.toLowerCase() === 'svátek') {
weekMenu[i][restaurant]!.closed = true;
}
}
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik TechTower", e);
}
case 'ZASTAVKAUMICHALA':
try {
const zastavkaUmichalaFood = await getMenuZastavkaUmichala(firstDay, mock);
for (let i = 0; i < zastavkaUmichalaFood.length; i++) {
weekMenu[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.') {
weekMenu[i][restaurant]!.closed = true;
}
}
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Zastávka u Michala", e);
}
case 'SENKSERIKOVA':
try {
const senkSerikovaFood = await getMenuSenkSerikova(firstDay, mock);
for (let i = 0; i < senkSerikovaFood.length; i++) {
weekMenu[i][restaurant]!.food = senkSerikovaFood[i];
if (senkSerikovaFood[i]?.length === 1 && senkSerikovaFood[i][0].name === 'Pro tento den nebylo zadáno menu.') {
weekMenu[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), weekMenu);
}
return weekMenu[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: keyof typeof LunchChoice, date?: Date) {
const today = formatDate(getToday()); const selectedDay = formatDate(date ?? getToday());
const clientData: ClientData = db.get(today); let data = await getClientData(date);
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) { await storage.setData(selectedDay, data);
order = {
customer: login,
pizzaList: [],
totalPrice: 0,
} }
clientData.pizzaDay.orders.push(order);
} }
const pizzaOrder: PizzaOrder = { return data;
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. * Odstraní konkrétní volbu jídla uživatele.
* * Neodstraňuje volbu samotnou, k tomu slouží {@link removeChoices}.
*
* @param login login uživatele * @param login login uživatele
* @param pizzaOrder objednávka pizzy * @param trusted příznak, zda se jedná o ověřeného uživatele
* @param locationKey vybrané "umístění"
* @param foodIndex index jídla v jídelním lístku daného umístění, pokud existuje
* @param date datum, ke kterému se volba vztahuje
* @returns
*/ */
export function removePizzaOrder(login: string, pizzaOrder: PizzaOrder) { export async function removeChoice(login: string, trusted: boolean, locationKey: keyof typeof LunchChoice, foodIndex: number, date?: Date) {
const today = formatDate(getToday()); const selectedDay = formatDate(date ?? getToday());
const clientData: ClientData = db.get(today); let data = await getClientData(date);
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]) {
const orderIndex = clientData.pizzaDay.orders.findIndex(o => o.customer === login); const index = data.choices[locationKey][login].selectedFoods?.indexOf(foodIndex);
if (orderIndex < 0) { if (index && index > -1) {
throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login); data.choices[locationKey][login].selectedFoods?.splice(index, 1);
} await storage.setData(selectedDay, data);
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.ZAHAJENA_PIZZA,user:clientData?.pizzaDay?.creator}})
return clientData;
}
/**
* Nastaví stav pizza day na "pizzy doručeny".
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export function finishPizzaDelivery(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.ORDERED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.ORDERED);
}
clientData.pizzaDay.state = PizzaDayState.DELIVERED;
db.set(today, clientData);
return clientData;
}
export function initIfNeeded() {
const today = formatDate(getToday());
if (!db.has(today)) {
db.set(today, getEmptyData());
}
}
export function removeChoice(login: string, data: ClientData) {
for (let key of Object.keys(data.choices)) {
if (data.choices[key] && data.choices[key].includes(login)) {
const index = data.choices[key].indexOf(login);
data.choices[key].splice(index, 1);
if (data.choices[key].length == 0) {
delete data.choices[key];
} }
} }
} }
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?: Date, ignoredLocationKey?: keyof typeof LunchChoice) {
const usedDate = date ?? getToday();
let data = await getClientData(usedDate);
for (const key of Object.keys(data.choices)) {
const locationKey = key as keyof typeof LunchChoice;
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(formatDate(usedDate), data);
} }
data.choices[choice].push(login);
} }
db.set(today, data);
return data; return data;
}
/**
* Ověří, zda se neověřený uživatel nepokouší přepsat údaje ověřeného a případně vyhodí chybu.
*
* @param data aktuální klientská data
* @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 (!trusted && found) {
throw new InsufficientPermissions("Nelze změnit volbu ověřeného uživatele");
}
}
/**
* 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: keyof typeof LunchChoice, foodIndex?: number, date?: Date) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate);
let data = await getClientData(usedDate);
validateTrusted(data, login, trusted);
await validateFoodIndex(locationKey, foodIndex, date);
// Pokud měníme pouze lokaci, mažeme případné předchozí
if (foodIndex == null) {
data = await removeChoiceIfPresent(login, usedDate);
} else {
// Mažeme případné ostatní volby (měla by být maximálně jedna)
removeChoiceIfPresent(login, usedDate, 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,
selectedFoods: []
};
}
if (foodIndex != null && !data.choices[locationKey][login].selectedFoods?.includes(foodIndex)) {
data.choices[locationKey][login].selectedFoods?.push(foodIndex);
}
const selectedDate = formatDate(usedDate);
await storage.setData(selectedDate, data);
return data;
}
/**
* Zvaliduje platnost indexu jídla pro vybranou lokalitu a datum.
*
* @param locationKey vybraná lokalita
* @param foodIndex index jídla pro danou lokalitu
* @param date datum, pro které je validace prováděna
*/
async function validateFoodIndex(locationKey: keyof typeof LunchChoice, foodIndex?: number, date?: Date) {
if (foodIndex != null) {
if (typeof foodIndex !== 'number') {
throw Error(`Neplatný index ${foodIndex} typu ${typeof foodIndex}`);
}
if (foodIndex < 0) {
throw Error(`Neplatný index ${foodIndex}`);
}
if (!Object.keys(Restaurant).includes(locationKey)) {
throw Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey} nepodporující indexy`);
}
const usedDate = date ?? getToday();
const menu = await getRestaurantMenu(locationKey as keyof typeof Restaurant, usedDate);
if (menu.food?.length && foodIndex > (menu.food.length - 1)) {
throw new Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey}`);
}
}
}
/**
* 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);
let data = await getClientData(usedDate);
validateTrusted(data, login, trusted);
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
if (userEntry) {
if (!note?.length) {
delete userEntry[1][login].note;
} else {
userEntry[1][login].note = note;
}
const selectedDate = formatDate(usedDate);
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 usedDate = date ?? getToday();
let clientData = await getClientData(usedDate);
const found = Object.values(clientData.choices).find(location => login in location);
// TODO validace, že se jedná o restauraci
if (found) {
if (!time?.length) {
delete found[login].departureTime;
} else {
if (!Object.values<string>(DepartureTime).includes(time)) {
throw Error(`Neplatný čas odchodu ${time}`);
}
found[login].departureTime = time;
}
await storage.setData(formatDate(usedDate), clientData);
}
return clientData;
}
/**
* Vrátí data pro klienta pro předaný nebo aktuální den.
*
* @param date datum pro který vrátit data, pokud není vyplněno, je použit dnešní den
* @returns data pro klienta
*/
export async function getClientData(date?: Date): Promise<ClientData> {
const targetDate = date ?? getToday();
const dateString = formatDate(targetDate);
const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date);
return {
...clientData,
todayDayIndex: getDayOfWeekIndex(getToday()),
}
} }

49
server/src/stats.ts Normal file
View File

@@ -0,0 +1,49 @@
import { DailyStats, LunchChoice, WeeklyStats } from "../../types";
import { getStatsMock } from "./mock";
import { getClientData } from "./service";
import getStorage from "./storage";
import { formatDate } from "./utils";
const storage = getStorage();
/**
* Vypočte a vrátí statistiky jednotlivých možností pro předaný rozsah dat.
*
* @param startDate počáteční datum
* @param endDate koncové datum
* @returns statistiky pro zadaný rozsah dat
*/
export async function getStats(startDate: string, endDate: string): Promise<WeeklyStats> {
if (process.env.MOCK_DATA === 'true') {
return getStatsMock();
}
const start = new Date(startDate);
const end = new Date(endDate);
// Dočasná validace, aby to někdo ručně neshodil
const daysDiff = ((end as any) - (start as any)) / (1000 * 60 * 60 * 24);
if (daysDiff > 4) {
throw Error('Neplatný rozsah');
}
const result = [];
for (const date = start; date <= end; date.setDate(date.getDate() + 1)) {
const locationsStats: DailyStats = {
// TODO vytáhnout do utils funkce
date: `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.`,
locations: {}
}
const data = await getClientData(date);
if (data?.choices) {
Object.keys(data.choices).forEach(locationKey => {
if (!locationsStats.locations) {
locationsStats.locations = {}
}
// TODO dořešit, tohle je zmatek a té hlášce Sonaru nerozumím
locationsStats.locations[locationKey as keyof typeof LunchChoice] = Object.keys(data.choices[locationKey as keyof typeof LunchChoice]!).length;
})
}
result.push(locationsStats);
}
return result as WeeklyStats;
}

View File

@@ -0,0 +1,26 @@
/**
* 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 | undefined>;
/**
* Uloží data pod předaný klíč.
* @param key klíč, pod kterým uložit data (typicky datum)
* @param data data pro uložení
*/
setData<Type>(key: string, data: Type): Promise<void>;
}

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,85 +0,0 @@
import exp from "constants";
export interface Choices {
[location: string]: string[],
}
/** Velikost konkrétní pizzy */
export interface PizzaSize {
varId: number, // unikátní ID varianty pizzy
size: string, // velikost pizzy, např. "30cm"
pizzaPrice: number, // cena samotné pizzy
boxPrice: number, // cena krabice
price: number, // celková cena (pizza + krabice)
}
/** Jedna konkrétní pizza */
export interface Pizza {
name: string, // název pizzy
ingredients: string[], // seznam ingrediencí
sizes: PizzaSize[], // dostupné velikosti pizzy
}
/** Objednávka jedné konkrétní pizzy */
export interface PizzaOrder {
varId: number, // unikátní ID varianty pizzy
name: string, // název pizzy
size: string, // velikost pizzy jako string (30cm)
price: number, // cena pizzy v Kč, včetně krabice
}
/** Celková objednávka jednoho člověka */
export interface Order {
customer: string, // jméno objednatele
pizzaList: PizzaOrder[], // seznam objednaných pizz
totalPrice: number, // celková cena všech objednaných pizz a krabic
}
/** Stav pizza dne */
export enum PizzaDayState {
NOT_CREATED, // Pizza day nebyl založen
CREATED, // Pizza day je založen
LOCKED, // Objednávky uzamčeny
ORDERED, // Pizzy objednány
DELIVERED // Pizzy doručeny
}
/** Informace o pizza day pro dnešní den */
interface PizzaDay {
state: PizzaDayState, // stav pizza dne
creator: string, // jméno zakladatele
orders: Order[], // seznam objednávek jednotlivých lidí
}
/** Veškerá data pro zobrazení na klientovi */
export interface ClientData {
date: string, // dnešní datum pro zobrazení
isWeekend: boolean, // příznak, zda je dnes víkend
choices: Choices, // seznam voleb
pizzaDay?: PizzaDay, // pizza day pro dnešní den, pokud existuje
}
export enum Locations {
SLADOVNICKA = 'Sladovnická',
UMOTLIKU = 'U Motlíků',
TECHTOWER = 'TechTower',
SPSE = 'SPŠE',
VLASTNI = 'Mám vlastní',
OBJEDNAVAM = 'Objednávám',
NEOBEDVAM = 'Neobědvám',
}
export enum UdalostEnum{
ZAHAJENA_PIZZA="Zahájen pizza day",
OBJEDNANA_PIZZA="Objednána pizza"
}
export interface NotififaceInput{
udalost:UdalostEnum,
user:string,
}
export interface NotifikaceData{
input:NotififaceInput,
gotify?:boolean,
teams?:boolean,
}

View File

@@ -1,9 +1,15 @@
import { LunchChoice, LunchChoices } from "../../types";
const DAY_OF_WEEK_FORMAT = new Intl.DateTimeFormat(undefined, { weekday: 'long' });
/** 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í. */
@@ -11,12 +17,118 @@ export function getHumanDate(date: Date) {
let currentDay = String(date.getDate()).padStart(2, '0'); let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0"); let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear(); let currentYear = date.getFullYear();
let currentDayOfWeek = date.toLocaleDateString("CZ-cs", { weekday: 'long' }); let currentDayOfWeek = DAY_OF_WEEK_FORMAT.format(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: LunchChoices, login?: string): string[] => {
const result: string[] = [];
for (const location of Object.entries(choices)) {
const locationKey = location[0] as keyof typeof LunchChoice;
const locationValue = location[1];
if (login && 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 = await storage.getData<VotingData>(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 = await storage.getData<VotingData>(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;
}

View File

@@ -1,4 +1,8 @@
{ {
"include": [
"src/**/*",
"../types/**/*"
],
"compilerOptions": { "compilerOptions": {
"target": "ES2016", "target": "ES2016",
"module": "CommonJS", "module": "CommonJS",
@@ -6,7 +10,7 @@
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "../",
"strict": true, "strict": true
} }
} }

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