From 67abbf19b51aeabd8379f287f3be6b1801910ed7 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 20 May 2026 17:01:33 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20podpora=20high-availability=20a=20multi?= =?UTF-8?q?-replica=20nasazen=C3=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Socket.io Redis adapter pro sdílený stav přes repliky - graceful shutdown serveru - WATCH/MULTI v updateData pro race-condition-safe aktualizace - lease mechanismus pro push reminder (zabrání duplicitnímu odesílání) - k8s/ manifesty pro testovací kind cluster - Dockerfile: opraven EXPOSE port na 3001 - .gitignore: ignorovány Claude pracovní soubory --- .gitignore | 3 + .vscode/launch.json | 32 ++ Dockerfile | 2 +- TODO.md | 78 ++++ client/src/App.tsx | 15 + client/src/context/socket.js | 15 +- client/src/pages/OrderGroupsPage.tsx | 7 +- client/vite.config.ts | 1 + k8s/README.md | 186 +++++++++ k8s/base/ingressroute.yaml | 16 + k8s/base/namespace.yaml | 4 + k8s/base/redis-service.yaml | 12 + k8s/base/redis-statefulset.yaml | 50 +++ k8s/base/reloader.yaml | 184 +++++++++ k8s/base/server-configmap.yaml | 12 + k8s/base/server-deployment.yaml | 85 ++++ k8s/base/server-pdb.yaml | 10 + k8s/base/server-secret.yaml | 14 + k8s/base/server-service.yaml | 11 + k8s/kind/testik.yaml | 16 + server/package.json | 1 + server/src/index.ts | 113 ++++-- server/src/pizza.ts | 515 ++++++++----------------- server/src/pushReminder.ts | 128 ++++-- server/src/service.ts | 100 +++-- server/src/storage/StorageInterface.ts | 31 +- server/src/storage/json.ts | 14 +- server/src/storage/memory.ts | 11 + server/src/storage/redis.ts | 39 +- server/src/voting.ts | 56 +-- server/src/websocket.ts | 30 +- server/yarn.lock | 26 ++ 32 files changed, 1265 insertions(+), 552 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 TODO.md create mode 100644 k8s/README.md create mode 100644 k8s/base/ingressroute.yaml create mode 100644 k8s/base/namespace.yaml create mode 100644 k8s/base/redis-service.yaml create mode 100644 k8s/base/redis-statefulset.yaml create mode 100644 k8s/base/reloader.yaml create mode 100644 k8s/base/server-configmap.yaml create mode 100644 k8s/base/server-deployment.yaml create mode 100644 k8s/base/server-pdb.yaml create mode 100644 k8s/base/server-secret.yaml create mode 100644 k8s/base/server-service.yaml create mode 100644 k8s/kind/testik.yaml diff --git a/.gitignore b/.gitignore index 34dc9f9..0f5f27f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ types/gen .mcp.json .claude/settings.local.json server/public/ +.claude/*.lock +.claude/worktrees +.playwright-mcp \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..f692056 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,32 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Server (ts-node, debug)", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}/server", + "runtimeArgs": ["-r", "ts-node/register"], + "program": "${workspaceFolder}/server/src/index.ts", + "env": { "NODE_ENV": "development" }, + "console": "integratedTerminal", + "skipFiles": ["/**"], + "preLaunchTask": "types: openapi-ts" + }, + { + "name": "Client (vite + Edge)", + "type": "msedge", + "request": "launch", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}/client", + "preLaunchTask": "client: vite" + } + ], + "compounds": [ + { + "name": "Dev: server + client", + "configurations": ["Server (ts-node, debug)", "Client (vite + Edge)"], + "stopAll": true + } + ] +} diff --git a/Dockerfile b/Dockerfile index 5f673bb..8ad4312 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,7 +76,7 @@ WORKDIR /app # Export /data/db.json do složky /data VOLUME ["/data"] -EXPOSE 3000 +EXPOSE 3001 CMD [ "node", "./server/src/index.js" ] diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..a0c2c9d --- /dev/null +++ b/TODO.md @@ -0,0 +1,78 @@ +# TODO + +## HA / multi-replica follow-ups +- [ ] `foodRoutes.ts` per-pod rate limiter (`rateLimits` map) — s více replikami může uživatel překročit limit ~N× rychleji; přesunout do Redis (např. `INCR` + `EXPIRE`) +- [ ] `easterEggRoutes.ts` — náhodně generované URL easter eggů jsou per-pod; URL funguje pouze na podu, který ji vygeneroval; zvážit deterministické seedy nebo sdílení přes Redis +- [ ] `service.ts` — komplexní víceúrovňové funkce (`addChoice`, `removeChoiceIfPresent`) provádějí více po sobě jdoucích zápisů do stejného Redis klíče; pro plnou atomicitu je potřeba per-klíčový distribuovaný zámek (Redlock nebo `SET NX EX`) nebo sloučení logiky do jednoho `updateData` volání +- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli +- [ ] 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ů \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index ec14769..b436129 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -155,6 +155,21 @@ function App() { } }, [auth?.login, socket]); + // Po znovupřipojení socketu znovu vstoupit do místnosti a obnovit data + useEffect(() => { + const onReconnect = () => { + if (auth?.login) socket.emit('join', auth.login); + getData({ query: { dayIndex: dayIndexRef.current } }).then(response => { + if (response.data) { + setData(response.data); + setFood(response.data.menus); + } + }); + }; + socket.io.on('reconnect', onReconnect); + return () => { socket.io.off('reconnect', onReconnect); }; + }, [socket, auth?.login]); + useEffect(() => { if (!auth?.login || !data?.choices) { return diff --git a/client/src/context/socket.js b/client/src/context/socket.js index 92622df..9e060ba 100644 --- a/client/src/context/socket.js +++ b/client/src/context/socket.js @@ -8,12 +8,25 @@ if (process.env.NODE_ENV === 'development') { socketPath = undefined; } else { socketUrl = `${globalThis.location.host}`; - socketPath = `${globalThis.location.pathname}socket.io`; + socketPath = '/socket.io'; } export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] }); export const SocketContext = React.createContext(); +// Prohlížeče throttlují setTimeout v neaktivních tabech, což zdržuje automatické +// znovupřipojení socket.io. Po návratu do tabu nebo focusu okna se připojíme hned. +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && !socket.connected) { + socket.connect(); + } +}); +window.addEventListener('focus', () => { + if (!socket.connected) { + socket.connect(); + } +}); + // Konstanty websocket eventů, musí odpovídat těm na serveru! export const EVENT_CONNECT = 'connect'; export const EVENT_DISCONNECT = 'disconnect'; diff --git a/client/src/pages/OrderGroupsPage.tsx b/client/src/pages/OrderGroupsPage.tsx index 26cd03c..20d0fff 100644 --- a/client/src/pages/OrderGroupsPage.tsx +++ b/client/src/pages/OrderGroupsPage.tsx @@ -73,6 +73,12 @@ export default function OrderGroupsPage() { return () => { socket.off(EVENT_MESSAGE); }; }, [socket]); + useEffect(() => { + const onReconnect = () => fetchData(); + socket.io.on('reconnect', onReconnect); + return () => { socket.io.off('reconnect', onReconnect); }; + }, [socket]); + const refresh = async (fn: () => Promise): Promise => { setPageError(null); const result = await fn(); @@ -85,7 +91,6 @@ export default function OrderGroupsPage() { setData(result.data); socket.emit?.('message', result.data as ClientData); } - await fetchData(); return true; }; diff --git a/client/vite.config.ts b/client/vite.config.ts index 7519bf8..31e4154 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ plugins: [react(), viteTsconfigPaths()], server: { open: true, + host: '0.0.0.0', port: 3000, proxy: { '/api': 'http://localhost:3001', diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000..d90d347 --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,186 @@ +# Kubernetes — Luncher HA + +Manifesty pro nasazení Luncheru na Kubernetes s vysokou dostupností (3 repliky, Redis adapter pro Socket.io, WATCH/MULTI atomické zápisy, graceful shutdown). + +## Prerekvizity + +- kubectl nakonfigurovaný na cílový cluster +- `helm` nainstalovaný +- Redis Stack image přístupný z clusteru (`redis/redis-stack-server:7.2.0-v14`) +- Obraz `luncher:ha-test` načtený do clusteru (viz níže) + +## Lokální kind cluster (testik) — setup + +### 1. Smazat a znovu vytvořit cluster s port mappings + +```powershell +$env:KIND_EXPERIMENTAL_PROVIDER = "nerdctl" +# Přidat nerdctl do PATH (Rancher Desktop) +$env:PATH += ";$env:LOCALAPPDATA\Programs\Rancher Desktop\resources\resources\win32\bin" + +kind delete cluster --name testik +kind create cluster --name testik --config k8s/kind/testik.yaml +``` + +### 2. Sestavit a načíst obraz + +```powershell +docker build -t luncher:ha-test . + +# Uložit a načíst přes nerdctl (kind + nerdctl provider) +nerdctl save luncher:ha-test -o luncher.tar +kind load image-archive luncher.tar --name testik +Remove-Item luncher.tar +``` + +### 3. Nainstalovat Traefik (rke2-traefik) + +> **Prerekvizita (Rancher Desktop):** Pokud Rancher Desktop běží s `kubernetes.options.traefik=true`, +> host-switch.exe obsadí port 80 dříve než kind. Vypni traefik v k3s: +> ```powershell +> rdctl set --kubernetes.options.traefik=false +> ``` +> +> **Prerekvizita — inotify limity:** Čtyř-uzlový kind cluster vyčerpá výchozí +> `fs.inotify.max_user_instances=128`. kube-proxy pak padá s „too many open files". +> Zvyš limit v rancher-desktop WSL2 (přežije restart WSL2, ale ne reboot — přidej do +> `/etc/sysctl.d/99-kind.conf` pro trvalost): +> ```powershell +> wsl -d rancher-desktop -- sysctl -w fs.inotify.max_user_instances=1280 +> ``` + +```powershell +# rke2-traefik je v rke2-charts, ne rancher-charts +helm repo add rke2-charts https://rke2-charts.rancher.io +helm repo update + +# Nejdřív CRD chart, pak samotný chart +helm install traefik-crd rke2-charts/rke2-traefik-crd -n kube-system --create-namespace +helm install traefik rke2-charts/rke2-traefik -n kube-system ` + --set "tolerations[0].key=node-role.kubernetes.io/control-plane" ` + --set "tolerations[0].operator=Exists" ` + --set "tolerations[0].effect=NoSchedule" +``` + +Ověř že Traefik DaemonSet běží na control-plane (má hostPort 80): +```powershell +kubectl get ds -n kube-system traefik-rke2-traefik +kubectl get pods -n kube-system -o wide | Select-String traefik +``` + +### 4. Nainstalovat Reloader + +[stakater/Reloader](https://github.com/stakater/Reloader) sleduje změny Secret a ConfigMap a automaticky spustí rolling restart Deploymentu — odpadá nutnost ručního `kubectl rollout restart` po rotaci `JWT_SECRET` nebo `ADMIN_PASSWORD`. + +Manifest je vendorovaný ve verzi v1.4.16 (`k8s/base/reloader.yaml`). Nasadit do `default` namespace: + +```powershell +kubectl apply -f k8s/base/reloader.yaml +kubectl rollout status deploy/reloader-reloader +``` + +Reloader běží cluster-wide díky `ClusterRoleBinding` — nepotřebuje žádnou konfiguraci per-namespace. Deployment Luncheru má anotaci `reloader.stakater.com/auto: "true"`, která říká Reloaderu, ať sleduje všechny Secrety a ConfigMapy odkazované přes `envFrom`. + +### 5. Nasadit Luncher + +```powershell +# Namespace + Redis +kubectl apply -f k8s/base/namespace.yaml +kubectl apply -f k8s/base/redis-statefulset.yaml +kubectl apply -f k8s/base/redis-service.yaml + +# Počkat na Redis +kubectl rollout status statefulset/redis -n luncher + +# Server secret (nebo použít šablonu server-secret.yaml) +kubectl create secret generic luncher-secrets -n luncher ` + --from-literal=JWT_SECRET=dev-secret-change-me ` + --from-literal=ADMIN_PASSWORD=admin + +# Server +kubectl apply -f k8s/base/server-configmap.yaml +kubectl apply -f k8s/base/server-deployment.yaml +kubectl apply -f k8s/base/server-service.yaml +kubectl apply -f k8s/base/server-pdb.yaml +kubectl apply -f k8s/base/ingressroute.yaml + +# Počkat na server +kubectl rollout status deploy/luncher -n luncher +``` + +## Testovací scénáře + +### Baseline + +```powershell +kubectl get pods -n luncher -o wide +# Ověř: 3 pody na 3 různých worker uzlech, status Running +``` + +### Rolling update bez výpadku + +V jednom terminálu posílej provoz: +```powershell +# Nainstaluj hey: go install github.com/rakyll/hey@latest +hey -z 60s -c 20 http://luncher.localhost/api/health +``` + +Ve druhém terminálu spusť rollout: +```powershell +kubectl rollout restart deploy/luncher -n luncher +``` + +**Kritérium: 0 non-2xx odpovědí, 0 connection errors.** + +### Node drain + +```powershell +kubectl cordon testik-worker2 +kubectl drain testik-worker2 --ignore-daemonsets --delete-emptydir-data +# PDB zabrání souběžnému drainu druhého nodu +kubectl get pods -n luncher -o wide # pody se přeplánují +kubectl uncordon testik-worker2 +``` + +### Ověření Socket.io cross-pod + +1. Otevři dvě záložky prohlížeče na `http://luncher.localhost` +2. Z jednoho podu vyvolej změnu: + ```powershell + kubectl exec -it deploy/luncher -n luncher -- curl -s -X POST localhost:3001/api/... + ``` +3. Ověř, že druhá záložka (pravděpodobně jiný pod) obdrží WebSocket event + +### Concurrent write test + +1. Otevři stejnou Pizza day objednávku ve dvou záložkách +2. Simuluj souběžné odeslání (otevřít DevTools → síť → odeslat obě požadavky současně) +3. Ověř Redis: `kubectl exec -it redis-0 -n luncher -- redis-cli JSON.GET luncher:` + — oba zápisy musí být zachovány (WATCH/MULTI retry) + +### Auto-rollout při změně Secret / ConfigMap + +Reloader automaticky spustí rolling restart, kdykoli se změní `luncher-secrets` nebo `luncher-config`: + +```powershell +# Příklad: rotace admin hesla +kubectl -n luncher patch secret luncher-secrets --type=merge ` + -p '{"stringData":{"ADMIN_PASSWORD":"nove-heslo"}}' + +# Reloader detekuje změnu resourceVersion a patchne pod template +kubectl rollout status deploy/luncher -n luncher + +# Ověř anotaci přidanou Reloaderem na pod template +kubectl get deploy luncher -n luncher -o yaml | Select-String "STAKATER" +``` + +**Kritérium: pody se automaticky vyrolují bez ručního restartu. PDB zajistí, že alespoň jeden pod zůstane dostupný.** + +## Pořadí aplikace manifestů + +1. `reloader.yaml` (do `default` namespace — musí být před Deployment) +2. `namespace.yaml` +3. `redis-statefulset.yaml` + `redis-service.yaml` +4. `server-configmap.yaml` + `server-secret.yaml` +5. `server-deployment.yaml` + `server-service.yaml` + `server-pdb.yaml` +6. `ingressroute.yaml` diff --git a/k8s/base/ingressroute.yaml b/k8s/base/ingressroute.yaml new file mode 100644 index 0000000..3e947d9 --- /dev/null +++ b/k8s/base/ingressroute.yaml @@ -0,0 +1,16 @@ +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: luncher + namespace: luncher + annotations: + kubernetes.io/ingress.class: traefik +spec: + entryPoints: + - web + routes: + - match: Host(`luncher.localhost`) + kind: Rule + services: + - name: luncher + port: 3001 diff --git a/k8s/base/namespace.yaml b/k8s/base/namespace.yaml new file mode 100644 index 0000000..2ccb24e --- /dev/null +++ b/k8s/base/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: luncher diff --git a/k8s/base/redis-service.yaml b/k8s/base/redis-service.yaml new file mode 100644 index 0000000..cce701c --- /dev/null +++ b/k8s/base/redis-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: luncher +spec: + clusterIP: None # headless — StatefulSet pod discovery + selector: + app: redis + ports: + - port: 6379 + targetPort: 6379 diff --git a/k8s/base/redis-statefulset.yaml b/k8s/base/redis-statefulset.yaml new file mode 100644 index 0000000..2a78b78 --- /dev/null +++ b/k8s/base/redis-statefulset.yaml @@ -0,0 +1,50 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: redis + namespace: luncher +spec: + serviceName: redis + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + # Redis Stack je nutný — aplikace používá JSON.GET / JSON.SET (modul RedisJSON) + image: redis/redis-stack-server:7.2.0-v14 + ports: + - containerPort: 6379 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + volumeMounts: + - name: data + mountPath: /data + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 10 + periodSeconds: 10 + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi diff --git a/k8s/base/reloader.yaml b/k8s/base/reloader.yaml new file mode 100644 index 0000000..6d2dc5f --- /dev/null +++ b/k8s/base/reloader.yaml @@ -0,0 +1,184 @@ +# stakater/Reloader v1.4.16 +# Zdroj: https://raw.githubusercontent.com/stakater/Reloader/v1.4.16/deployments/kubernetes/reloader.yaml +# Aktualizace: stáhnout novou verzi ze stejné URL a nahradit tento soubor. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: reloader-reloader + namespace: default +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: reloader-reloader-metadata-role + namespace: default +rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - list + - get + - watch + - create + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: reloader-reloader-role +rules: +- apiGroups: + - "" + resources: + - secrets + - configmaps + verbs: + - list + - get + - watch +- apiGroups: + - apps + resources: + - deployments + - daemonsets + - statefulsets + verbs: + - list + - get + - update + - patch +- apiGroups: + - extensions + resources: + - deployments + - daemonsets + verbs: + - list + - get + - update + - patch +- apiGroups: + - batch + resources: + - cronjobs + verbs: + - list + - get +- apiGroups: + - batch + resources: + - jobs + verbs: + - create + - delete + - list + - get +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: reloader-reloader-metadata-rolebinding + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: reloader-reloader-metadata-role +subjects: +- kind: ServiceAccount + name: reloader-reloader + namespace: default +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: reloader-reloader-role-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: reloader-reloader-role +subjects: +- kind: ServiceAccount + name: reloader-reloader + namespace: default +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: reloader-reloader + namespace: default +spec: + replicas: 1 + revisionHistoryLimit: 2 + selector: + matchLabels: + app: reloader-reloader + template: + metadata: + labels: + app: reloader-reloader + spec: + containers: + - env: + - name: GOMAXPROCS + valueFrom: + resourceFieldRef: + divisor: "1" + resource: limits.cpu + - name: GOMEMLIMIT + valueFrom: + resourceFieldRef: + divisor: "1" + resource: limits.memory + - name: RELOADER_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: RELOADER_DEPLOYMENT_NAME + value: reloader-reloader + image: ghcr.io/stakater/reloader:v1.4.16 + imagePullPolicy: IfNotPresent + livenessProbe: + failureThreshold: 5 + httpGet: + path: /live + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 5 + name: reloader-reloader + ports: + - containerPort: 9090 + name: http + readinessProbe: + failureThreshold: 5 + httpGet: + path: /metrics + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 5 + resources: + limits: + cpu: "1" + memory: 512Mi + requests: + cpu: 10m + memory: 512Mi + securityContext: {} + securityContext: + runAsNonRoot: true + runAsUser: 65534 + seccompProfile: + type: RuntimeDefault + serviceAccountName: reloader-reloader diff --git a/k8s/base/server-configmap.yaml b/k8s/base/server-configmap.yaml new file mode 100644 index 0000000..3959a8d --- /dev/null +++ b/k8s/base/server-configmap.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: luncher-config + namespace: luncher +data: + NODE_ENV: production + STORAGE: redis + REDIS_HOST: redis + REDIS_PORT: "6379" + PORT: "3001" + HOST: "0.0.0.0" diff --git a/k8s/base/server-deployment.yaml b/k8s/base/server-deployment.yaml new file mode 100644 index 0000000..7858dc8 --- /dev/null +++ b/k8s/base/server-deployment.yaml @@ -0,0 +1,85 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: luncher + namespace: luncher +spec: + replicas: 3 + selector: + matchLabels: + app: luncher + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 0 # nelze přidat extra pod — každý worker je obsazen + maxUnavailable: 1 # nejdřív smaž starý pod, pak naplánuj nový + template: + metadata: + labels: + app: luncher + annotations: + reloader.stakater.com/auto: "true" + spec: + terminationGracePeriodSeconds: 30 + + # Rozmístit každý pod na jiný worker uzel + affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchLabels: + app: luncher + topologyKey: kubernetes.io/hostname + + containers: + - name: luncher + image: luncher:ha-test + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3001 + + envFrom: + - configMapRef: + name: luncher-config + - secretRef: + name: luncher-secrets + + env: + # POD_ID pro leader election scheduleru připomínek + - name: POD_ID + valueFrom: + fieldRef: + fieldPath: metadata.name + + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + + # Liveness — levná kontrola bez externích závislostí + livenessProbe: + httpGet: + path: /api/health + port: 3001 + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 3 + + # Readiness — kontroluje Redis; při shutdown vrací 503 + readinessProbe: + httpGet: + path: /api/health/ready + port: 3001 + initialDelaySeconds: 10 + periodSeconds: 5 + failureThreshold: 2 + + # preStop sleep: dá čas kube-proxy a Traefiku odebrat endpoint + # dřív než kontejner začne odmítat nová spojení + lifecycle: + preStop: + exec: + command: ["sleep", "5"] diff --git a/k8s/base/server-pdb.yaml b/k8s/base/server-pdb.yaml new file mode 100644 index 0000000..3f3962d --- /dev/null +++ b/k8s/base/server-pdb.yaml @@ -0,0 +1,10 @@ +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: luncher-pdb + namespace: luncher +spec: + minAvailable: 2 # ze 3 replik, max 1 voluntary disruption najednou + selector: + matchLabels: + app: luncher diff --git a/k8s/base/server-secret.yaml b/k8s/base/server-secret.yaml new file mode 100644 index 0000000..028fa0e --- /dev/null +++ b/k8s/base/server-secret.yaml @@ -0,0 +1,14 @@ +# Šablona — hodnoty jsou zástupné symboly. +# Pro kind test vytvoř secret příkazem: +# kubectl create secret generic luncher-secrets -n luncher \ +# --from-literal=JWT_SECRET= \ +# --from-literal=ADMIN_PASSWORD= +apiVersion: v1 +kind: Secret +metadata: + name: luncher-secrets + namespace: luncher +type: Opaque +stringData: + JWT_SECRET: CHANGE_ME + ADMIN_PASSWORD: CHANGE_ME diff --git a/k8s/base/server-service.yaml b/k8s/base/server-service.yaml new file mode 100644 index 0000000..cb6d6e8 --- /dev/null +++ b/k8s/base/server-service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: luncher + namespace: luncher +spec: + selector: + app: luncher + ports: + - port: 3001 + targetPort: 3001 diff --git a/k8s/kind/testik.yaml b/k8s/kind/testik.yaml new file mode 100644 index 0000000..ba6b559 --- /dev/null +++ b/k8s/kind/testik.yaml @@ -0,0 +1,16 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: + - role: control-plane + # Mapuje porty na Windows localhost — luncher.localhost resolves to 127.0.0.1 + # Traefik na control-plane podu poslouchá na těchto portech přes hostPort + extraPortMappings: + - containerPort: 80 + hostPort: 80 + protocol: TCP + - containerPort: 443 + hostPort: 443 + protocol: TCP + - role: worker + - role: worker + - role: worker diff --git a/server/package.json b/server/package.json index e40af6b..2dfb032 100644 --- a/server/package.json +++ b/server/package.json @@ -29,6 +29,7 @@ "typescript": "^5.9.3" }, "dependencies": { + "@socket.io/redis-adapter": "^8.3.0", "axios": "^1.13.2", "cheerio": "^1.1.2", "cors": "^2.8.5", diff --git a/server/src/index.ts b/server/src/index.ts index efeb1f2..70978d6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -9,9 +9,11 @@ import { getQr } from "./qr"; import { generateToken, getLogin, verify } from "./auth"; import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils"; import { getPendingQrs } from "./pizza"; -import { initWebsocket, getWebsocket } from "./websocket"; -import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder"; +import { initWebsocket, initRedisAdapter, shutdownWebsocketClients, getWebsocket } from "./websocket"; +import { startReminderScheduler, stopReminderScheduler, releaseReminderLease, verifyQuickChoiceToken } from "./pushReminder"; import { storageReady } from "./storage"; +import getStorage from "./storage"; +import { shutdownRedisStorage } from "./storage/redis"; import pizzaDayRoutes from "./routes/pizzaDayRoutes"; import foodRoutes, { refreshMetoda } from "./routes/foodRoutes"; import votingRoutes from "./routes/votingRoutes"; @@ -27,23 +29,24 @@ import storeRoutes from "./routes/storeRoutes"; const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; 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 new Error("Není vyplněna proměnná prostředí JWT_SECRET"); } const app = express(); const server = require("http").createServer(app); + +// Tune keep-alive timeouts to outlive Traefik's 60s idle timeout. +// headersTimeout must be strictly greater than keepAliveTimeout. +server.keepAliveTimeout = 65_000; +server.headersTimeout = 66_000; +server.requestTimeout = 30_000; + initWebsocket(server); -// Body-parser middleware for parsing JSON app.use(bodyParser.json()); +app.use(cors({ origin: '*' })); -app.use(cors({ - origin: '*' -})); - -// Zapínatelný login přes hlavičky - pokud je zapnutý nepovolí "basicauth" const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false; const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user'; if (HTTP_REMOTE_USER_ENABLED) { @@ -51,19 +54,69 @@ if (HTTP_REMOTE_USER_ENABLED) { throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.'); } 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.'); } +// ─── Shutdown state ────────────────────────────────────────────────────────── -// ----------- Metody nevyžadující token -------------- +let shuttingDown = false; +async function shutdown(signal: string) { + if (shuttingDown) return; + shuttingDown = true; + console.log(`${signal} received — initiating graceful shutdown`); + + // Hard-exit failsafe: fires before terminationGracePeriodSeconds (30s) + setTimeout(() => { + console.error('Graceful shutdown timed out, forcing exit'); + process.exit(1); + }, 25_000).unref(); + + // Disconnect WebSocket clients so they reconnect to another pod + const io = getWebsocket(); + io?.disconnectSockets(true); + + // Stop accepting new HTTP connections and drain in-flight requests + (server as any).closeIdleConnections?.(); + await new Promise(resolve => server.close(() => resolve())); + + // Stop reminder scheduler and release leader lease + stopReminderScheduler(); + await releaseReminderLease(); + + // Shut down Redis pub/sub clients (Socket.io adapter) + await shutdownWebsocketClients(); + + // Shut down main Redis storage client + if (process.env.STORAGE?.toLowerCase() === 'redis') { + await shutdownRedisStorage(); + } + + console.log('Graceful shutdown complete'); + process.exit(0); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +// ─── Routes — no auth required ─────────────────────────────────────────────── + +/** Liveness probe — cheap, no external deps. */ app.get("/api/health", (_req, res) => { res.status(200).json({ ok: true }); }); +/** Readiness probe — verifies Redis connectivity and rejects traffic during shutdown. */ +app.get("/api/health/ready", async (_req, res) => { + if (shuttingDown) { + return res.status(503).json({ ok: false, reason: 'shutting down' }); + } + const healthy = await getStorage().healthCheck?.() ?? true; + if (!healthy) return res.status(503).json({ ok: false, reason: 'storage unavailable' }); + res.status(200).json({ ok: true }); +}); + app.get("/api/whoami", (req, res) => { if (!HTTP_REMOTE_USER_ENABLED) { res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' }); @@ -76,21 +129,17 @@ app.get("/api/whoami", (req, res) => { }) app.post("/api/login", (req, res) => { - if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy') - // Autentizace pomocí trusted headers + if (HTTP_REMOTE_USER_ENABLED) { const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME); - //const remoteName = req.header('remote-name'); if (remoteUser && remoteUser.length > 0) { res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true)); } else { throw new Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??"); } } else { - // Klasická autentizace loginem if (!req.body?.login || req.body.login.trim().length === 0) { throw new Error("Nebyl předán login"); } - // TODO zavést podmínky pro délku loginu (min i max) res.status(200).json(generateToken(req.body.login, false)); } }); @@ -111,12 +160,10 @@ app.get("/api/qr", async (req, res) => { res.end(img); }); -// ---------------------------------------------------- +// ─── Semi-public routes ─────────────────────────────────────────────────────── -// Přeskočení auth pro refresh dat xd app.use("/api/food/refresh", refreshMetoda); -// Rychlá akce z push notifikace — autentizace pomocí HMAC tokenu z push payloadu (SW nemá přístup k JWT) app.post("/api/notifications/push/quickChoice", async (req, res, next) => { try { const { login, token } = req.body ?? {}; @@ -132,10 +179,10 @@ app.post("/api/notifications/push/quickChoice", async (req, res, next) => { } catch (e: any) { next(e); } }); -/** Middleware ověřující JWT token */ +// ─── Auth middleware ────────────────────────────────────────────────────────── + app.use("/api/", (req, res, next) => { if (HTTP_REMOTE_USER_ENABLED) { - // Autentizace pomocí trusted headers const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME); if (process.env.ENABLE_HEADERS_LOGGING === 'yes') { delete req.headers["cookie"] @@ -158,7 +205,8 @@ app.use("/api/", (req, res, next) => { next(); }); -/** Vrátí data pro aktuální den. */ +// ─── Authenticated routes ───────────────────────────────────────────────────── + app.get("/api/data", async (req, res) => { let date = undefined; if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') { @@ -167,7 +215,6 @@ app.get("/api/data", async (req, res) => { date = getDateForWeekIndex(parseInt(req.query.dayIndex)); } } else if (getIsWeekend(getToday())) { - // Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend" date = getDateForWeekIndex(4); } const slotParam = typeof req.query.slot === 'string' ? req.query.slot as MealSlot : undefined; @@ -175,7 +222,6 @@ app.get("/api/data", async (req, res) => { return res.status(400).json({ error: 'Neplatný slot' }); } const data = await getData(date, slotParam); - // Připojíme nevyřízené QR kódy pro přihlášeného uživatele try { const login = getLogin(parseToken(req)); const pendingQrs = await getPendingQrs(login); @@ -188,7 +234,6 @@ app.get("/api/data", async (req, res) => { res.status(200).json(data); }); -// Ostatní routes app.use("/api/pizzaDay", pizzaDayRoutes); app.use("/api/food", foodRoutes); app.use("/api/voting", votingRoutes); @@ -206,7 +251,7 @@ app.get('*splat', (_req, res) => { res.sendFile(path.join(process.cwd(), 'public', 'index.html')); }); -// Middleware pro zpracování chyb +// Error handling middleware app.use((err: any, req: any, res: any, next: any) => { if (err instanceof InsufficientPermissions) { res.status(403).send({ error: err.message }) @@ -218,18 +263,18 @@ app.use((err: any, req: any, res: any, next: any) => { next(); }); +// ─── Bootstrap ──────────────────────────────────────────────────────────────── + const PORT = process.env.PORT ?? 3001; const HOST = process.env.HOST ?? '0.0.0.0'; -storageReady.then(() => { +storageReady.then(async () => { + // Init Redis adapter after storage is connected (only in Redis mode) + if (process.env.STORAGE?.toLowerCase() === 'redis') { + await initRedisAdapter(); + } server.listen(PORT, () => { console.log(`Server listening on ${HOST}, port ${PORT}`); startReminderScheduler(); }); }); - -// Umožníme vypnutí serveru přes SIGINT, jinak Docker čeká než ho sestřelí -process.on('SIGINT', function () { - console.log("\nSIGINT (Ctrl-C), vypínám server"); - process.exit(0); -}); \ No newline at end of file diff --git a/server/src/pizza.ts b/server/src/pizza.ts index cebf8e8..26bad03 100644 --- a/server/src/pizza.ts +++ b/server/src/pizza.ts @@ -10,10 +10,6 @@ import crypto from "crypto"; const storage = getStorage(); const PENDING_QR_PREFIX = 'pending_qr'; -/** - * Vrátí seznam dostupných pizz pro dnešní den. - * Stáhne je, pokud je pro dnešní den nemá. - */ export async function getPizzaList(): Promise { await initIfNeeded(); let clientData = await getClientData(getToday()); @@ -24,25 +20,17 @@ export async function getPizzaList(): Promise { 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 { 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; + return storage.updateData(today, (current) => { + const data = current ?? ({} as ClientData); + data.pizzaList = pizzaList; + data.pizzaListLastUpdate = formatDate(new Date()); + return data; + }); } -/** - * Vrátí seznam dostupných salátů pro dnešní den. - * Stáhne je, pokud je pro dnešní den nemá. - */ export async function getSalatList(): Promise { await initIfNeeded(); let clientData = await getClientData(getToday()); @@ -53,423 +41,250 @@ export async function getSalatList(): Promise { return Promise.resolve(clientData.salatList); } -/** - * Uloží seznam dostupných salátů pro dnešní den. - * - * @param salatList seznam dostupných salátů - */ export async function saveSalatList(salatList: Salat[]): Promise { await initIfNeeded(); const today = formatDate(getToday()); - const clientData = await getClientData(getToday()); - clientData.salatList = salatList; - await storage.setData(today, clientData); - return clientData; + return storage.updateData(today, (current) => { + const data = current ?? ({} as ClientData); + data.salatList = salatList; + return data; + }); } -/** - * Vytvoří pizza day pro aktuální den a vrátí data pro klienta. - */ export async function createPizzaDay(creator: string): Promise { await initIfNeeded(); - const clientData = await getClientData(getToday()); - if (clientData.pizzaDay) { - throw new Error("Pizza day pro dnešní den již existuje"); - } - // TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě! + // Stáhneme pizzy a saláty před samotnou atomickou operací const [pizzaList, salatList] = await Promise.all([getPizzaList(), getSalatList()]); - const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, salatList, ...clientData }; const today = formatDate(getToday()); - await storage.setData(today, data); - callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } }) - return data; + const result = await storage.updateData(today, (current) => { + if (!current) throw Error("Data pro dnešní den nejsou inicializována"); + if (current.pizzaDay) throw Error("Pizza day pro dnešní den již existuje"); + return { ...current, pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, salatList }; + }); + callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } }); + return result; } -/** - * Smaže pizza day pro aktuální den. - */ export async function deletePizzaDay(login: string): Promise { - const clientData = await getClientData(getToday()); - if (!clientData.pizzaDay) { - throw new Error("Pizza day pro dnešní den neexistuje"); - } - if (clientData.pizzaDay.creator !== login) { - throw new Error("Login uživatele se neshoduje se zakladatelem Pizza Day"); - } - delete clientData.pizzaDay; const today = formatDate(getToday()); - await storage.setData(today, clientData); - return clientData; + return storage.updateData(today, (current) => { + if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); + if (current.pizzaDay.creator !== login) throw Error("Login uživatele se neshoduje se zakladatelem Pizza Day"); + const data = { ...current }; + delete data.pizzaDay; + return data; + }); } -/** - * 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 new Error("Pizza day pro dnešní den neexistuje"); - } - if (clientData.pizzaDay.state !== PizzaDayState.CREATED) { - throw new 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, + return storage.updateData(today, (current) => { + if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); + if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED); + let order: PizzaOrder | undefined = current.pizzaDay.orders?.find(o => o.customer === login); + if (!order) { + order = { customer: login, pizzaList: [], totalPrice: 0, hasQr: false }; + current.pizzaDay.orders ??= []; + current.pizzaDay.orders.push(order); } - clientData.pizzaDay.orders ??= []; - clientData.pizzaDay.orders.push(order); - } - const pizzaOrder: PizzaVariant = { - varId: size.varId, - name: pizza.name, - size: size.size, - price: size.price, - } - order.pizzaList ??= []; - order.pizzaList.push(pizzaOrder); - order.totalPrice += pizzaOrder.price; - await storage.setData(today, clientData); - return clientData; + const pizzaOrder: PizzaVariant = { varId: size.varId, name: pizza.name, size: size.size, price: size.price }; + order.pizzaList ??= []; + order.pizzaList.push(pizzaOrder); + order.totalPrice += pizzaOrder.price; + return current; + }); } -/** - * Přidá objednávku salátu uživateli. - * - * @param login login uživatele - * @param salat zvolený salát - */ export async function addSalatOrder(login: string, salat: Salat) { const today = formatDate(getToday()); - const clientData = await getClientData(getToday()); - if (!clientData.pizzaDay) { - throw new Error("Pizza day pro dnešní den neexistuje"); - } - if (clientData.pizzaDay.state !== PizzaDayState.CREATED) { - throw new 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, + return storage.updateData(today, (current) => { + if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); + if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED); + let order: PizzaOrder | undefined = current.pizzaDay.orders?.find(o => o.customer === login); + if (!order) { + order = { customer: login, pizzaList: [], totalPrice: 0, hasQr: false }; + current.pizzaDay.orders ??= []; + current.pizzaDay.orders.push(order); } - clientData.pizzaDay.orders ??= []; - clientData.pizzaDay.orders.push(order); - } - const salatOrder: PizzaVariant = { - varId: 0, - name: salat.name, - size: "1 porce", - price: salat.price, - category: 'salat', - } - order.pizzaList ??= []; - order.pizzaList.push(salatOrder); - order.totalPrice += salatOrder.price; - await storage.setData(today, clientData); - return clientData; + const salatOrder: PizzaVariant = { varId: 0, name: salat.name, size: "1 porce", price: salat.price, category: 'salat' }; + order.pizzaList ??= []; + order.pizzaList.push(salatOrder); + order.totalPrice += salatOrder.price; + return current; + }); } -/** - * Odstraní všechny pizzy uživatele (celou jeho objednávku). - * Pokud Pizza day neexistuje nebo není ve stavu CREATED, neudělá nic. - * - * @param login login uživatele - * @param date datum, pro které se objednávka odstraňuje (výchozí je dnešek) - * @returns aktuální data pro klienta - */ export async function removeAllUserPizzas(login: string, date?: Date) { const usedDate = date ?? getToday(); const today = formatDate(usedDate); - const clientData = await getClientData(usedDate); - - if (!clientData.pizzaDay) { - return clientData; // Pizza day neexistuje, není co mazat - } - - if (clientData.pizzaDay.state !== PizzaDayState.CREATED) { - return clientData; // Pizza day není ve stavu CREATED, nelze mazat - } - - const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login); - if (orderIndex >= 0) { - clientData.pizzaDay.orders!.splice(orderIndex, 1); - await storage.setData(today, clientData); - } - - return clientData; + return storage.updateData(today, (current) => { + if (!current?.pizzaDay) return current ?? ({} as ClientData); + if (current.pizzaDay.state !== PizzaDayState.CREATED) return current; + const orderIndex = current.pizzaDay.orders!.findIndex(o => o.customer === login); + if (orderIndex >= 0) current.pizzaDay.orders!.splice(orderIndex, 1); + return current; + }); } -/** - * 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 new Error("Pizza day pro dnešní den neexistuje"); - } - const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login); - if (orderIndex < 0) { - throw new 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 new 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; + return storage.updateData(today, (current) => { + if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); + const orderIndex = current.pizzaDay.orders!.findIndex(o => o.customer === login); + if (orderIndex < 0) throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login); + const order = current.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) current.pizzaDay.orders!.splice(orderIndex, 1); + return current; + }); } -/** - * 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 new Error("Pizza day pro dnešní den neexistuje"); - } - if (clientData.pizzaDay.creator !== login) { - throw new Error("Pizza day není spravován uživatelem " + login); - } - if (clientData.pizzaDay.state !== PizzaDayState.CREATED && clientData.pizzaDay.state !== PizzaDayState.ORDERED) { - throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED + " nebo " + PizzaDayState.ORDERED); - } - clientData.pizzaDay.state = PizzaDayState.LOCKED; - await storage.setData(today, clientData); - return clientData; + return storage.updateData(today, (current) => { + if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); + if (current.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login); + if (current.pizzaDay.state !== PizzaDayState.CREATED && current.pizzaDay.state !== PizzaDayState.ORDERED) { + throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED + " nebo " + PizzaDayState.ORDERED); + } + current.pizzaDay.state = PizzaDayState.LOCKED; + return current; + }); } -/** - * 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 new Error("Pizza day pro dnešní den neexistuje"); - } - if (clientData.pizzaDay.creator !== login) { - throw new Error("Pizza day není spravován uživatelem " + login); - } - if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) { - throw new Error("Pizza day není ve stavu " + PizzaDayState.LOCKED); - } - clientData.pizzaDay.state = PizzaDayState.CREATED; - await storage.setData(today, clientData); - return clientData; + return storage.updateData(today, (current) => { + if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); + if (current.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login); + if (current.pizzaDay.state !== PizzaDayState.LOCKED) throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED); + current.pizzaDay.state = PizzaDayState.CREATED; + return current; + }); } -/** - * 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 new Error("Pizza day pro dnešní den neexistuje"); - } - if (clientData.pizzaDay.creator !== login) { - throw new Error("Pizza day není spravován uživatelem " + login); - } - if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) { - throw new 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; + const result = await storage.updateData(today, (current) => { + if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); + if (current.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login); + if (current.pizzaDay.state !== PizzaDayState.LOCKED) throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED); + current.pizzaDay.state = PizzaDayState.ORDERED; + return current; + }); + callNotifikace({ input: { udalost: UdalostEnum.OBJEDNANA_PIZZA, user: result.pizzaDay!.creator! } }); + return result; } -/** - * 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()); + // Načteme aktuální data pro přípravu QR (potřebujeme objednávky) const clientData = await getClientData(getToday()); - if (!clientData.pizzaDay) { - throw new Error("Pizza day pro dnešní den neexistuje"); - } - if (clientData.pizzaDay.creator !== login) { - throw new Error("Pizza day není spravován uživatelem " + login); - } - if (clientData.pizzaDay.state !== PizzaDayState.ORDERED) { - throw new Error("Pizza day není ve stavu " + PizzaDayState.ORDERED); - } - clientData.pizzaDay.state = PizzaDayState.DELIVERED; + 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); - // Vygenerujeme QR kód, pokud k tomu máme data + // Generujeme QR kódy před atomickým zápisem + const pendingQrs: Array<{ customer: string; id: string; pendingQr: PendingQr }> = []; if (bankAccount?.length && bankAccountHolder?.length) { for (const order of clientData.pizzaDay.orders!) { - if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl + if (order.customer !== login) { const id = crypto.randomUUID(); - let message = order.pizzaList!.map(item => + const message = order.pizzaList!.map(item => item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})` ).join(', '); await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice / 100, message, id); - order.hasQr = true; - // Uložíme nevyřízený QR kód pro persistentní zobrazení - await addPendingQr(order.customer, { - id, - date: today, - creator: login, - totalPrice: order.totalPrice, - purpose: message, + pendingQrs.push({ + customer: order.customer, id, pendingQr: { + id, date: today, creator: login, totalPrice: order.totalPrice, purpose: message, + }, }); } } } - await storage.setData(today, clientData); - return clientData; + + const result = await storage.updateData(today, (current) => { + if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); + current.pizzaDay.state = PizzaDayState.DELIVERED; + for (const { customer } of pendingQrs) { + const order = current.pizzaDay.orders!.find(o => o.customer === customer); + if (order) { order.hasQr = true; } + } + return current; + }); + + // Uložení nevyřízených QR kódů mimo hlavní transakci (per-user klíče) + for (const { customer, pendingQr } of pendingQrs) { + await addPendingQr(customer, pendingQr); + } + + return result; } -/** - * Aktualizuje poznámku k Pizza day uživatele. - * - * @param login přihlašovací jméno uživatele - * @param note nová poznámka k Pizza day - * @returns aktuální klientská data - */ export async function updatePizzaDayNote(login: string, note?: string) { const today = formatDate(getToday()); - let clientData = await getClientData(getToday()); - if (!clientData.pizzaDay) { - throw new Error("Pizza day pro dnešní den neexistuje"); - } - if (clientData.pizzaDay.state !== PizzaDayState.CREATED) { - throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED); - } - const myOrder = clientData.pizzaDay.orders!.find(o => o.customer === login); - if (!myOrder?.pizzaList?.length) { - throw new Error("Pizza day neobsahuje žádné objednávky uživatele " + login); - } - myOrder.note = note; - await storage.setData(today, clientData); - return clientData; + return storage.updateData(today, (current) => { + if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); + if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED); + const myOrder = current.pizzaDay.orders!.find(o => o.customer === login); + if (!myOrder?.pizzaList?.length) throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login); + myOrder.note = note; + return current; + }); } -/** - * 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 new Error("Pizza day pro dnešní den neexistuje"); - } - if (clientData.pizzaDay.state !== PizzaDayState.CREATED) { - throw new Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`); - } - if (clientData.pizzaDay.creator !== login) { - throw new 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 new 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; + return storage.updateData(today, (current) => { + if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje"); + if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`); + if (current.pizzaDay.creator !== login) throw Error("Příplatky může měnit pouze zakladatel Pizza day"); + const targetOrder = current.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 }; + } + targetOrder.totalPrice = targetOrder.pizzaList.reduce((p, o) => p + o.price, 0) + (targetOrder.fee?.price ?? 0); + return current; + }); } -/** - * Vrátí klíč pro uložení nevyřízených QR kódů uživatele. - */ function getPendingQrKey(login: string): string { return `${PENDING_QR_PREFIX}_${login}`; } -/** - * Přidá nevyřízený QR kód pro uživatele. - */ export async function addPendingQr(login: string, pendingQr: PendingQr): Promise { - const key = getPendingQrKey(login); - const existing = await storage.getData(key) ?? []; - // Deduplikace podle id (ne podle data — jeden den může mít uživatel víc QR kódů) - if (!existing.some(qr => qr.id === pendingQr.id)) { - existing.push(pendingQr); - await storage.setData(key, existing); - } + await storage.updateData(getPendingQrKey(login), (current) => { + const existing = current ?? []; + if (!existing.some(qr => qr.id === pendingQr.id)) existing.push(pendingQr); + return existing; + }); } -/** - * Vrátí nevyřízené QR kódy pro uživatele. - */ export async function getPendingQrs(login: string): Promise { return await storage.getData(getPendingQrKey(login)) ?? []; } -/** - * Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených). - * Vrátí odstraněný QR kód, pokud byl nalezen. - */ export async function dismissPendingQr(login: string, id: string): Promise { - const key = getPendingQrKey(login); - const existing = await storage.getData(key) ?? []; - const dismissed = existing.find(qr => qr.id === id); - const filtered = existing.filter(qr => qr.id !== id); - await storage.setData(key, filtered); + let dismissed: PendingQr | undefined; + await storage.updateData(getPendingQrKey(login), (current) => { + const existing = current ?? []; + dismissed = existing.find(qr => qr.id === id); + return existing.filter(qr => qr.id !== id); + }); return dismissed; } -/** - * Odstraní všechny nevyřízené QR kódy patřící dané skupině objednávky. - */ export async function removePendingQrsByGroupId(logins: string[], groupId: string): Promise { for (const login of logins) { - const key = getPendingQrKey(login); - const existing = await storage.getData(key) ?? []; - const filtered = existing.filter(qr => qr.groupId !== groupId); - if (filtered.length !== existing.length) { - await storage.setData(key, filtered); - } + await storage.updateData(getPendingQrKey(login), (current) => { + return (current ?? []).filter(qr => qr.groupId !== groupId); + }); } -} \ No newline at end of file +} diff --git a/server/src/pushReminder.ts b/server/src/pushReminder.ts index 164dd04..bb0f3ee 100644 --- a/server/src/pushReminder.ts +++ b/server/src/pushReminder.ts @@ -1,12 +1,17 @@ import webpush from 'web-push'; import crypto from 'crypto'; import getStorage from './storage'; +import { getRedisClient } from './storage/redis'; import { getClientData, getToday } from './service'; import { getIsWeekend } from './utils'; import { LunchChoices } from '../../types'; const storage = getStorage(); const REGISTRY_KEY = 'push_reminder_registry'; +const LEADER_LEASE_KEY = 'luncher:reminder:leader'; +const LEASE_TTL_SECONDS = 90; + +const POD_ID = process.env.POD_ID ?? `local-${process.pid}`; interface RegistryEntry { time: string; @@ -20,6 +25,8 @@ const lastReminded = new Map(); const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami +let reminderInterval: ReturnType | undefined; + function getCurrentTimeHHMM(): string { const now = new Date(); return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; @@ -36,27 +43,76 @@ function userHasChoice(choices: LunchChoices, login: string): boolean { return false; } -async function getRegistry(): Promise { - return await storage.getData(REGISTRY_KEY) ?? {}; +/** + * Pokusí se získat nebo obnovit leader lease pro scheduler připomínek. + * Vrátí true pokud tato instance smí spustit připomínky. + * Při non-Redis storage vždy vrací true (single-process, leader election není potřeba). + */ +async function tryAcquireOrRenewLease(): Promise { + if (process.env.STORAGE?.toLowerCase() !== 'redis') return true; + try { + const c = getRedisClient(); + if (!c) return true; + + // Zkusíme získat lease atomicky (SET NX EX) + const acquired = await c.set(LEADER_LEASE_KEY, POD_ID, { NX: true, EX: LEASE_TTL_SECONDS }); + if (acquired !== null) return true; // lease čerstvě získána + + // Pokud jsme ji nedostali, ověříme zda ji držíme my + const currentHolder = await c.get(LEADER_LEASE_KEY); + if (currentHolder === POD_ID) { + // Naše lease — obnovíme TTL + await c.set(LEADER_LEASE_KEY, POD_ID, { EX: LEASE_TTL_SECONDS }); + return true; + } + return false; // lease drží jiná instance + } catch (e) { + console.error('Push reminder: chyba při získávání lease, připomínky budou odeslány', e); + return true; // při chybě raději spustíme, než vynecháme + } } -async function saveRegistry(registry: Registry): Promise { - await storage.setData(REGISTRY_KEY, registry); +/** Uvolní leader lease při graceful shutdown. */ +export async function releaseReminderLease(): Promise { + if (process.env.STORAGE?.toLowerCase() !== 'redis') return; + try { + const c = getRedisClient(); + if (!c) return; + const currentHolder = await c.get(LEADER_LEASE_KEY); + if (currentHolder === POD_ID) { + await c.del(LEADER_LEASE_KEY); + console.log('Push reminder: lease uvolněna'); + } + } catch (e) { + console.error('Push reminder: chyba při uvolňování lease', e); + } +} + +/** Stopne scheduler připomínek. Volá se při graceful shutdown. */ +export function stopReminderScheduler(): void { + if (reminderInterval) { + clearInterval(reminderInterval); + reminderInterval = undefined; + } } /** Přidá nebo aktualizuje push subscription pro uživatele. */ export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime: string): Promise { - const registry = await getRegistry(); - registry[login] = { time: reminderTime, subscription }; - await saveRegistry(registry); + await storage.updateData(REGISTRY_KEY, (current) => { + const registry = current ?? {}; + registry[login] = { time: reminderTime, subscription }; + return registry; + }); console.log(`Push reminder: uživatel ${login} přihlášen k připomínkám v ${reminderTime}`); } /** Odebere push subscription pro uživatele. */ export async function unsubscribePush(login: string): Promise { - const registry = await getRegistry(); - delete registry[login]; - await saveRegistry(registry); + await storage.updateData(REGISTRY_KEY, (current) => { + const registry = current ?? {}; + delete registry[login]; + return registry; + }); lastReminded.delete(login); console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`); } @@ -79,23 +135,20 @@ export function verifyQuickChoiceToken(login: string, token: string): boolean { return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex')); } - /** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */ async function checkAndSendReminders(): Promise { - // Přeskočit víkendy - if (getIsWeekend(getToday())) { - return; - } + if (getIsWeekend(getToday())) return; - const registry = await getRegistry(); + // Leader election — pouze jeden pod spouští připomínky + const isLeader = await tryAcquireOrRenewLease(); + if (!isLeader) return; + + const registry = await storage.getData(REGISTRY_KEY) ?? {}; const entries = Object.entries(registry); - if (entries.length === 0) { - return; - } + if (entries.length === 0) return; const currentTime = getCurrentTimeHHMM(); - // Získáme data pro dnešek jednou pro všechny uživatele let clientData; try { clientData = await getClientData(getToday()); @@ -104,24 +157,16 @@ async function checkAndSendReminders(): Promise { return; } + const expiredLogins: string[] = []; + for (const [login, entry] of entries) { - // Ještě nedosáhl čas připomínky - if (currentTime < entry.time) { - continue; - } + if (currentTime < entry.time) continue; - // Cooldown — nepřipomínat častěji než jednou za hodinu const last = lastReminded.get(login) ?? 0; - if (Date.now() - last < REMINDER_COOLDOWN_MS) { - continue; - } + if (Date.now() - last < REMINDER_COOLDOWN_MS) continue; - // Uživatel už má zvolenou možnost - if (clientData.choices && userHasChoice(clientData.choices, login)) { - continue; - } + if (clientData.choices && userHasChoice(clientData.choices, login)) continue; - // Odešleme push notifikaci try { await webpush.sendNotification( entry.subscription, @@ -136,15 +181,21 @@ async function checkAndSendReminders(): Promise { console.log(`Push reminder: odeslána připomínka uživateli ${login}`); } catch (error: any) { if (error.statusCode === 410 || error.statusCode === 404) { - // Subscription expirovala nebo je neplatná — odebereme z registry console.log(`Push reminder: subscription uživatele ${login} expirovala, odebírám`); - delete registry[login]; - await saveRegistry(registry); + expiredLogins.push(login); } else { console.error(`Push reminder: chyba při odesílání notifikace uživateli ${login}:`, error); } } } + + if (expiredLogins.length > 0) { + await storage.updateData(REGISTRY_KEY, (current) => { + const r = current ?? {}; + for (const login of expiredLogins) delete r[login]; + return r; + }); + } } /** Spustí scheduler pro kontrolu a odesílání připomínek každou minutu. */ @@ -160,7 +211,6 @@ export function startReminderScheduler(): void { webpush.setVapidDetails(subject, publicKey, privateKey); - // Spustíme kontrolu každou minutu - setInterval(checkAndSendReminders, 60_000); - console.log('Push reminder: scheduler spuštěn'); + reminderInterval = setInterval(checkAndSendReminders, 60_000); + console.log(`Push reminder: scheduler spuštěn (POD_ID=${POD_ID})`); } diff --git a/server/src/service.ts b/server/src/service.ts index 6480303..3b340fb 100644 --- a/server/src/service.ts +++ b/server/src/service.ts @@ -319,18 +319,17 @@ export async function initIfNeeded(date?: Date, slot?: MealSlot) { */ export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date, slot?: MealSlot) { const selectedDay = getDataKey(date ?? getToday(), slot); - let data = await getClientData(date, slot); - validateTrusted(data, login, trusted); - if (locationKey in data.choices) { - 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(selectedDay, data); + // Validate trusted flag against current data before atomic update + const snapshot = await getClientData(date, slot); + validateTrusted(snapshot, login, trusted); + return storage.updateData(selectedDay, (current) => { + const data = current ?? getEmptyData(date); + if (locationKey in data.choices && 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]; } - } - return data; + return data; + }); } /** @@ -346,18 +345,16 @@ export async function removeChoices(login: string, trusted: boolean, locationKey */ export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date, slot?: MealSlot) { const selectedDay = getDataKey(date ?? getToday(), slot); - let data = await getClientData(date, slot); - validateTrusted(data, login, trusted); - if (locationKey in data.choices) { - if (data.choices[locationKey] && login in data.choices[locationKey]) { + const snapshot = await getClientData(date, slot); + validateTrusted(snapshot, login, trusted); + return storage.updateData(selectedDay, (current) => { + const data = current ?? getEmptyData(date); + if (locationKey in data.choices && data.choices[locationKey] && login in data.choices[locationKey]) { const index = data.choices[locationKey][login].selectedFoods?.indexOf(foodIndex); - if (index != null && index > -1) { - data.choices[locationKey][login].selectedFoods?.splice(index, 1); - await storage.setData(selectedDay, data); - } + if (index != null && index > -1) data.choices[locationKey][login].selectedFoods?.splice(index, 1); } - } - return data; + return data; + }); } /** @@ -512,18 +509,17 @@ async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, d export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date, slot?: MealSlot) { const usedDate = date ?? getToday(); await initIfNeeded(usedDate, slot); - let data = await getClientData(usedDate, slot); - 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 snapshot = await getClientData(usedDate, slot); + validateTrusted(snapshot, login, trusted); + return storage.updateData(getDataKey(usedDate, slot), (current) => { + const data = current ?? getEmptyData(date); + 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; } - await storage.setData(getDataKey(usedDate, slot), data); - } - return data; + return data; + }); } /** @@ -535,21 +531,18 @@ export async function updateNote(login: string, trusted: boolean, note?: string, */ export async function updateDepartureTime(login: string, time?: string, date?: Date) { 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(DepartureTime).includes(time)) { - throw new Error(`Neplatný čas odchodu ${time}`); - } - found[login].departureTime = time; - } - await storage.setData(getDataKey(usedDate), clientData); + if (time?.length && !Object.values(DepartureTime).includes(time)) { + throw Error(`Neplatný čas odchodu ${time}`); } - return clientData; + return storage.updateData(getDataKey(usedDate), (current) => { + const data = current ?? getEmptyData(date); + const found = Object.values(data.choices).find(location => login in location); + if (found) { + if (!time?.length) delete found[login].departureTime; + else found[login].departureTime = time; + } + return data; + }); } /** @@ -560,14 +553,13 @@ export async function updateDepartureTime(login: string, time?: string, date?: D */ export async function updateBuyer(login: string, slot?: MealSlot) { const usedDate = getToday(); - let clientData = await getClientData(usedDate, slot); - const userEntry = clientData.choices?.['OBJEDNAVAM']?.[login]; - if (!userEntry) { - throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\""); - } - userEntry.isBuyer = !(userEntry.isBuyer || false); - await storage.setData(getDataKey(usedDate, slot), clientData); - return clientData; + return storage.updateData(getDataKey(usedDate, slot), (current) => { + const data = current ?? getEmptyData(); + const userEntry = data.choices?.['OBJEDNAVAM']?.[login]; + if (!userEntry) throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\""); + userEntry.isBuyer = !(userEntry.isBuyer || false); + return data; + }); } /** diff --git a/server/src/storage/StorageInterface.ts b/server/src/storage/StorageInterface.ts index 8956456..a2f0382 100644 --- a/server/src/storage/StorageInterface.ts +++ b/server/src/storage/StorageInterface.ts @@ -1,32 +1,23 @@ /** * 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 { - /** - * Inicializuje úložiště, pokud je potřeba (např. připojení k databázi). - */ initialize?(): Promise; - /** - * 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; - /** - * Vrátí veškerá data pro předaný klíč. - * @param key klíč, pro který vrátit data (typicky datum) - */ getData(key: string): Promise; - /** - * Uloží data pod předaný klíč. - * @param key klíč, pod kterým uložit data (typicky datum) - * @param data data pro uložení - */ setData(key: string, data: Type): Promise; -} \ No newline at end of file + + /** + * Atomicky načte, zmutuje a uloží data pod daným klíčem. + * V Redis implementaci používá WATCH/MULTI/EXEC retry loop. + * Vrátí výslednou hodnotu po aplikaci mutátoru. + */ + updateData(key: string, mutator: (current: Type | undefined) => Type): Promise; + + /** Ověří dostupnost úložiště. Vrátí false pokud není dostupné. */ + healthCheck?(): Promise; +} diff --git a/server/src/storage/json.ts b/server/src/storage/json.ts index 30d386b..a2c888c 100644 --- a/server/src/storage/json.ts +++ b/server/src/storage/json.ts @@ -6,7 +6,6 @@ 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 }); } @@ -29,4 +28,15 @@ export default class JsonStorage implements StorageInterface { db.set(key, data); return Promise.resolve(); } -} \ No newline at end of file + + updateData(key: string, mutator: (current: Type | undefined) => Type): Promise { + const current = db.get(key) as Type | undefined; + const next = mutator(current); + db.set(key, next); + return Promise.resolve(next); + } + + healthCheck(): Promise { + return Promise.resolve(true); + } +} diff --git a/server/src/storage/memory.ts b/server/src/storage/memory.ts index b75a41f..1b32cf2 100644 --- a/server/src/storage/memory.ts +++ b/server/src/storage/memory.ts @@ -24,4 +24,15 @@ export default class MemoryStorage implements StorageInterface { store.set(key, data); return Promise.resolve(); } + + updateData(key: string, mutator: (current: Type | undefined) => Type): Promise { + const current = store.get(key) as Type | undefined; + const next = mutator(current); + store.set(key, next); + return Promise.resolve(next); + } + + healthCheck(): Promise { + return Promise.resolve(true); + } } diff --git a/server/src/storage/redis.ts b/server/src/storage/redis.ts index bd158c7..ac20287 100644 --- a/server/src/storage/redis.ts +++ b/server/src/storage/redis.ts @@ -10,7 +10,7 @@ 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 = createClient({ url: `redis://${HOST}:${PORT}` }) as RedisClientType; } async initialize() { @@ -29,6 +29,39 @@ export default class RedisStorage implements StorageInterface { async setData(key: string, data: Type) { await client.json.set(key, '.', data as any); - await client.json.get(key); } -} \ No newline at end of file + + async updateData(key: string, mutator: (current: Type | undefined) => Type): Promise { + return (client as any).executeIsolated(async (c: any) => { + for (let attempt = 0; attempt < 10; attempt++) { + await c.watch(key); + const current = await c.json.get(key, { path: '.' }) as Type | undefined; + const next = mutator(current); + const multi = c.multi(); + multi.json.set(key, '.', next); + const result = await multi.exec(); + if (result !== null) return next; + } + throw new Error(`updateData: optimistic lock failed after 10 retries for key: ${key}`); + }); + } + + async healthCheck(): Promise { + try { + const pong = await client.ping(); + return pong === 'PONG'; + } catch { + return false; + } + } +} + +/** Vrátí hlavní Redis klient — používá se pro lease připomínkovače a shutdown. */ +export function getRedisClient(): RedisClientType | undefined { + return client; +} + +/** Zavře připojení k Redisu. Volá se při graceful shutdown. */ +export async function shutdownRedisStorage(): Promise { + await client?.quit(); +} diff --git a/server/src/voting.ts b/server/src/voting.ts index 30d6bb9..04a3f78 100644 --- a/server/src/voting.ts +++ b/server/src/voting.ts @@ -1,4 +1,4 @@ -import { FeatureRequest, VotingStats } from "../../types/gen/types.gen"; +import { FeatureRequest } from "../../types/gen/types.gen"; import getStorage from "./storage"; interface VotingData { @@ -12,56 +12,28 @@ export interface VotingStatsResult { 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(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 { - let data = await storage.getData(STORAGE_KEY); - data ??= {}; - if (!(login in data)) { - data[login] = []; - } - const index = data[login].indexOf(option); - if (index > -1) { - if (active) { - throw new Error('Pro tuto možnost jste již hlasovali'); - } else { + return storage.updateData(STORAGE_KEY, (current) => { + const data = current ?? {}; + 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'); data[login].splice(index, 1); - if (data[login].length === 0) { - delete data[login]; - } + 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); } - } else if (active) { - if (data[login].length == 4) { - throw new Error('Je možné hlasovat pro maximálně 4 možnosti'); - } - data[login].push(option); - } - await storage.setData(STORAGE_KEY, data); - return data; + return data; + }); } -/** - * Vrátí agregované statistiky hlasování - počet hlasů pro každou funkci. - * - * @returns objekt, kde klíčem je název funkce a hodnotou počet hlasů - */ export async function getVotingStats(): Promise { const data = await storage.getData(STORAGE_KEY); const stats: VotingStatsResult = {}; @@ -73,4 +45,4 @@ export async function getVotingStats(): Promise { } } return stats; -} \ No newline at end of file +} diff --git a/server/src/websocket.ts b/server/src/websocket.ts index fd2cc05..39403cb 100644 --- a/server/src/websocket.ts +++ b/server/src/websocket.ts @@ -1,12 +1,15 @@ import { DefaultEventsMap, Server } from "socket.io"; +import { createAdapter } from "@socket.io/redis-adapter"; +import { createClient } from "redis"; let io: Server; +let pubClient: ReturnType; +let subClient: ReturnType; export const initWebsocket = (server: any) => { io = new Server(server, { - cors: { - origin: "*", - }, + cors: { origin: "*" }, + transports: ["websocket"], }); io.on("connection", (socket) => { console.log(`New client connected: ${socket.id}`); @@ -26,7 +29,24 @@ export const initWebsocket = (server: any) => { }); }); return io; -} +}; + +/** Připojí Redis adapter pro cross-pod broadcasting. Volat až po inicializaci Redis klienta. */ +export const initRedisAdapter = async () => { + const HOST = process.env.REDIS_HOST ?? 'localhost'; + const PORT = process.env.REDIS_PORT ?? 6379; + const url = `redis://${HOST}:${PORT}`; + pubClient = createClient({ url }) as ReturnType; + subClient = pubClient.duplicate(); + await Promise.all([pubClient.connect(), subClient.connect()]); + io.adapter(createAdapter(pubClient as any, subClient as any)); + console.log('Socket.io: Redis adapter connected'); +}; + +/** Zavře pub/sub Redis klienty adaptéru při graceful shutdown. */ +export const shutdownWebsocketClients = async () => { + await Promise.allSettled([pubClient?.quit(), subClient?.quit()]); +}; export const getWebsocket = () => io; @@ -34,4 +54,4 @@ export const getWebsocket = () => io; export const emitToUser = (login: string, event: string, data: unknown) => { if (!io) return; io.to(`user:${login}`).emit(event, data); -} \ No newline at end of file +}; diff --git a/server/yarn.lock b/server/yarn.lock index 719efc3..3fecb88 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -1521,6 +1521,15 @@ resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2" integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA== +"@socket.io/redis-adapter@^8.3.0": + version "8.3.0" + resolved "https://registry.yarnpkg.com/@socket.io/redis-adapter/-/redis-adapter-8.3.0.tgz#bdce1e8f34c07df4a8baf98170bf24dc84eaed4a" + integrity sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA== + dependencies: + debug "~4.3.1" + notepack.io "~3.0.1" + uid2 "1.0.0" + "@tsconfig/node10@^1.0.7": version "1.0.11" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2" @@ -2438,6 +2447,13 @@ debug@^4.1.1: dependencies: ms "^2.1.3" +debug@~4.3.1: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + dedent@^1.6.0: version "1.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.0.tgz#c1f9445335f0175a96587be245a282ff451446ca" @@ -3844,6 +3860,11 @@ normalize-path@^3.0.0, normalize-path@~3.0.0: resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== +notepack.io@~3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/notepack.io/-/notepack.io-3.0.1.tgz#2c2c9de1bd4e64a79d34e33c413081302a0d4019" + integrity sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg== + npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" @@ -4571,6 +4592,11 @@ typescript@^5.9.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== +uid2@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/uid2/-/uid2-1.0.0.tgz#ef8d95a128d7c5c44defa1a3d052eecc17a06bfb" + integrity sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ== + undefsafe@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c"