2 Commits

Author SHA1 Message Date
batmanisko 986c36b677 fix: oprava updateData v Redis storage (node-redis v5 nemá executeIsolated)
CI / Generate TypeScript types (push) Successful in 11s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 33s
CI / Playwright E2E tests (push) Successful in 1m16s
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 2s
2026-05-20 18:47:47 +02:00
batmanisko 67abbf19b5 feat: podpora high-availability a multi-replica nasazení
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 20s
CI / Build server (push) Successful in 26s
CI / Build client (push) Successful in 35s
CI / Playwright E2E tests (push) Failing after 1m56s
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 1s
- 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
2026-05-20 17:16:19 +02:00
32 changed files with 1270 additions and 552 deletions
+3
View File
@@ -4,3 +4,6 @@ types/gen
.mcp.json
.claude/settings.local.json
server/public/
.claude/*.lock
.claude/worktrees
.playwright-mcp
+32
View File
@@ -0,0 +1,32 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Server (ts-node, debug)",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}/server",
"runtimeArgs": ["-r", "ts-node/register"],
"program": "${workspaceFolder}/server/src/index.ts",
"env": { "NODE_ENV": "development" },
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
"preLaunchTask": "types: openapi-ts"
},
{
"name": "Client (vite + Edge)",
"type": "msedge",
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/client",
"preLaunchTask": "client: vite"
}
],
"compounds": [
{
"name": "Dev: server + client",
"configurations": ["Server (ts-node, debug)", "Client (vite + Edge)"],
"stopAll": true
}
]
}
+1 -1
View File
@@ -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" ]
+78
View File
@@ -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ů
+15
View File
@@ -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
+14 -1
View File
@@ -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';
+6 -1
View File
@@ -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<any>): Promise<boolean> => {
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;
};
+1
View File
@@ -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',
+186
View File
@@ -0,0 +1,186 @@
# Kubernetes — Luncher HA
Manifesty pro nasazení Luncheru na Kubernetes s vysokou dostupností (3 repliky, Redis adapter pro Socket.io, WATCH/MULTI atomické zápisy, graceful shutdown).
## Prerekvizity
- kubectl nakonfigurovaný na cílový cluster
- `helm` nainstalovaný
- Redis Stack image přístupný z clusteru (`redis/redis-stack-server:7.2.0-v14`)
- Obraz `luncher:ha-test` načtený do clusteru (viz níže)
## Lokální kind cluster (testik) — setup
### 1. Smazat a znovu vytvořit cluster s port mappings
```powershell
$env:KIND_EXPERIMENTAL_PROVIDER = "nerdctl"
# Přidat nerdctl do PATH (Rancher Desktop)
$env:PATH += ";$env:LOCALAPPDATA\Programs\Rancher Desktop\resources\resources\win32\bin"
kind delete cluster --name testik
kind create cluster --name testik --config k8s/kind/testik.yaml
```
### 2. Sestavit a načíst obraz
```powershell
docker build -t luncher:ha-test .
# Uložit a načíst přes nerdctl (kind + nerdctl provider)
nerdctl save luncher:ha-test -o luncher.tar
kind load image-archive luncher.tar --name testik
Remove-Item luncher.tar
```
### 3. Nainstalovat Traefik (rke2-traefik)
> **Prerekvizita (Rancher Desktop):** Pokud Rancher Desktop běží s `kubernetes.options.traefik=true`,
> host-switch.exe obsadí port 80 dříve než kind. Vypni traefik v k3s:
> ```powershell
> rdctl set --kubernetes.options.traefik=false
> ```
>
> **Prerekvizita — inotify limity:** Čtyř-uzlový kind cluster vyčerpá výchozí
> `fs.inotify.max_user_instances=128`. kube-proxy pak padá s „too many open files".
> Zvyš limit v rancher-desktop WSL2 (přežije restart WSL2, ale ne reboot — přidej do
> `/etc/sysctl.d/99-kind.conf` pro trvalost):
> ```powershell
> wsl -d rancher-desktop -- sysctl -w fs.inotify.max_user_instances=1280
> ```
```powershell
# rke2-traefik je v rke2-charts, ne rancher-charts
helm repo add rke2-charts https://rke2-charts.rancher.io
helm repo update
# Nejdřív CRD chart, pak samotný chart
helm install traefik-crd rke2-charts/rke2-traefik-crd -n kube-system --create-namespace
helm install traefik rke2-charts/rke2-traefik -n kube-system `
--set "tolerations[0].key=node-role.kubernetes.io/control-plane" `
--set "tolerations[0].operator=Exists" `
--set "tolerations[0].effect=NoSchedule"
```
Ověř že Traefik DaemonSet běží na control-plane (má hostPort 80):
```powershell
kubectl get ds -n kube-system traefik-rke2-traefik
kubectl get pods -n kube-system -o wide | Select-String traefik
```
### 4. Nainstalovat Reloader
[stakater/Reloader](https://github.com/stakater/Reloader) sleduje změny Secret a ConfigMap a automaticky spustí rolling restart Deploymentu — odpadá nutnost ručního `kubectl rollout restart` po rotaci `JWT_SECRET` nebo `ADMIN_PASSWORD`.
Manifest je vendorovaný ve verzi v1.4.16 (`k8s/base/reloader.yaml`). Nasadit do `default` namespace:
```powershell
kubectl apply -f k8s/base/reloader.yaml
kubectl rollout status deploy/reloader-reloader
```
Reloader běží cluster-wide díky `ClusterRoleBinding` — nepotřebuje žádnou konfiguraci per-namespace. Deployment Luncheru má anotaci `reloader.stakater.com/auto: "true"`, která říká Reloaderu, ať sleduje všechny Secrety a ConfigMapy odkazované přes `envFrom`.
### 5. Nasadit Luncher
```powershell
# Namespace + Redis
kubectl apply -f k8s/base/namespace.yaml
kubectl apply -f k8s/base/redis-statefulset.yaml
kubectl apply -f k8s/base/redis-service.yaml
# Počkat na Redis
kubectl rollout status statefulset/redis -n luncher
# Server secret (nebo použít šablonu server-secret.yaml)
kubectl create secret generic luncher-secrets -n luncher `
--from-literal=JWT_SECRET=dev-secret-change-me `
--from-literal=ADMIN_PASSWORD=admin
# Server
kubectl apply -f k8s/base/server-configmap.yaml
kubectl apply -f k8s/base/server-deployment.yaml
kubectl apply -f k8s/base/server-service.yaml
kubectl apply -f k8s/base/server-pdb.yaml
kubectl apply -f k8s/base/ingressroute.yaml
# Počkat na server
kubectl rollout status deploy/luncher -n luncher
```
## Testovací scénáře
### Baseline
```powershell
kubectl get pods -n luncher -o wide
# Ověř: 3 pody na 3 různých worker uzlech, status Running
```
### Rolling update bez výpadku
V jednom terminálu posílej provoz:
```powershell
# Nainstaluj hey: go install github.com/rakyll/hey@latest
hey -z 60s -c 20 http://luncher.localhost/api/health
```
Ve druhém terminálu spusť rollout:
```powershell
kubectl rollout restart deploy/luncher -n luncher
```
**Kritérium: 0 non-2xx odpovědí, 0 connection errors.**
### Node drain
```powershell
kubectl cordon testik-worker2
kubectl drain testik-worker2 --ignore-daemonsets --delete-emptydir-data
# PDB zabrání souběžnému drainu druhého nodu
kubectl get pods -n luncher -o wide # pody se přeplánují
kubectl uncordon testik-worker2
```
### Ověření Socket.io cross-pod
1. Otevři dvě záložky prohlížeče na `http://luncher.localhost`
2. Z jednoho podu vyvolej změnu:
```powershell
kubectl exec -it deploy/luncher -n luncher -- curl -s -X POST localhost:3001/api/...
```
3. Ověř, že druhá záložka (pravděpodobně jiný pod) obdrží WebSocket event
### Concurrent write test
1. Otevři stejnou Pizza day objednávku ve dvou záložkách
2. Simuluj souběžné odeslání (otevřít DevTools → síť → odeslat obě požadavky současně)
3. Ověř Redis: `kubectl exec -it redis-0 -n luncher -- redis-cli JSON.GET luncher:<datum>`
— oba zápisy musí být zachovány (WATCH/MULTI retry)
### Auto-rollout při změně Secret / ConfigMap
Reloader automaticky spustí rolling restart, kdykoli se změní `luncher-secrets` nebo `luncher-config`:
```powershell
# Příklad: rotace admin hesla
kubectl -n luncher patch secret luncher-secrets --type=merge `
-p '{"stringData":{"ADMIN_PASSWORD":"nove-heslo"}}'
# Reloader detekuje změnu resourceVersion a patchne pod template
kubectl rollout status deploy/luncher -n luncher
# Ověř anotaci přidanou Reloaderem na pod template
kubectl get deploy luncher -n luncher -o yaml | Select-String "STAKATER"
```
**Kritérium: pody se automaticky vyrolují bez ručního restartu. PDB zajistí, že alespoň jeden pod zůstane dostupný.**
## Pořadí aplikace manifestů
1. `reloader.yaml` (do `default` namespace — musí být před Deployment)
2. `namespace.yaml`
3. `redis-statefulset.yaml` + `redis-service.yaml`
4. `server-configmap.yaml` + `server-secret.yaml`
5. `server-deployment.yaml` + `server-service.yaml` + `server-pdb.yaml`
6. `ingressroute.yaml`
+16
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: luncher
+12
View File
@@ -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
+50
View File
@@ -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
+184
View File
@@ -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
+12
View File
@@ -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"
+85
View File
@@ -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"]
+10
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
# Šablona — hodnoty jsou zástupné symboly.
# Pro kind test vytvoř secret příkazem:
# kubectl create secret generic luncher-secrets -n luncher \
# --from-literal=JWT_SECRET=<your-secret> \
# --from-literal=ADMIN_PASSWORD=<your-password>
apiVersion: v1
kind: Secret
metadata:
name: luncher-secrets
namespace: luncher
type: Opaque
stringData:
JWT_SECRET: CHANGE_ME
ADMIN_PASSWORD: CHANGE_ME
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: Service
metadata:
name: luncher
namespace: luncher
spec:
selector:
app: luncher
ports:
- port: 3001
targetPort: 3001
+16
View File
@@ -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
+1
View File
@@ -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",
+79 -34
View File
@@ -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<void>(resolve => server.close(() => resolve()));
// Stop reminder scheduler and release leader lease
stopReminderScheduler();
await releaseReminderLease();
// Shut down Redis pub/sub clients (Socket.io adapter)
await shutdownWebsocketClients();
// Shut down main Redis storage client
if (process.env.STORAGE?.toLowerCase() === 'redis') {
await shutdownRedisStorage();
}
console.log('Graceful shutdown complete');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// ─── Routes — no auth required ───────────────────────────────────────────────
/** Liveness probe — cheap, no external deps. */
app.get("/api/health", (_req, res) => {
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);
});
+165 -350
View File
@@ -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<Pizza[] | undefined> {
await initIfNeeded();
let clientData = await getClientData(getToday());
@@ -24,25 +20,17 @@ export async function getPizzaList(): Promise<Pizza[] | undefined> {
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;
return storage.updateData<ClientData>(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<Salat[] | undefined> {
await initIfNeeded();
let clientData = await getClientData(getToday());
@@ -53,423 +41,250 @@ export async function getSalatList(): Promise<Salat[] | undefined> {
return Promise.resolve(clientData.salatList);
}
/**
* Uloží seznam dostupných salátů pro dnešní den.
*
* @param salatList seznam dostupných salátů
*/
export async function saveSalatList(salatList: Salat[]): Promise<ClientData> {
await initIfNeeded();
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
clientData.salatList = salatList;
await storage.setData(today, clientData);
return clientData;
return storage.updateData<ClientData>(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<ClientData> {
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<ClientData>(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<ClientData> {
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<ClientData>(today, (current) => {
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
if (current.pizzaDay.creator !== login) throw Error("Login uživatele se neshoduje se zakladatelem Pizza Day");
const data = { ...current };
delete data.pizzaDay;
return data;
});
}
/**
* 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š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<ClientData>(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š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<ClientData>(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<ClientData>(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<ClientData>(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š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<ClientData>(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š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<ClientData>(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š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<ClientData>(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š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<ClientData>(today, (current) => {
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
current.pizzaDay.state = PizzaDayState.DELIVERED;
for (const { customer } of pendingQrs) {
const order = current.pizzaDay.orders!.find(o => o.customer === customer);
if (order) { order.hasQr = true; }
}
return current;
});
// Uložení nevyřízených QR kódů mimo hlavní transakci (per-user klíče)
for (const { customer, pendingQr } of pendingQrs) {
await addPendingQr(customer, pendingQr);
}
return result;
}
/**
* Aktualizuje poznámku k Pizza day uživatele.
*
* @param login přihlašovací jméno uživatele
* @param note nová poznámka k Pizza day
* @returns aktuální klientská data
*/
export async function updatePizzaDayNote(login: string, note?: string) {
const today = formatDate(getToday());
let clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dneš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<ClientData>(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š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<ClientData>(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<void> {
const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(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<PendingQr[]>(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<PendingQr[]> {
return await storage.getData<PendingQr[]>(getPendingQrKey(login)) ?? [];
}
/**
* Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených).
* Vrátí odstraněný QR kód, pokud byl nalezen.
*/
export async function dismissPendingQr(login: string, id: string): Promise<PendingQr | undefined> {
const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(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<PendingQr[]>(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<void> {
for (const login of logins) {
const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? [];
const filtered = existing.filter(qr => qr.groupId !== groupId);
if (filtered.length !== existing.length) {
await storage.setData(key, filtered);
}
await storage.updateData<PendingQr[]>(getPendingQrKey(login), (current) => {
return (current ?? []).filter(qr => qr.groupId !== groupId);
});
}
}
}
+89 -39
View File
@@ -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<string, number>();
const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami
let reminderInterval: ReturnType<typeof setInterval> | 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<Registry> {
return await storage.getData<Registry>(REGISTRY_KEY) ?? {};
/**
* Pokusí se získat nebo obnovit leader lease pro scheduler připomínek.
* Vrátí true pokud tato instance smí spustit připomínky.
* Při non-Redis storage vždy vrací true (single-process, leader election není potřeba).
*/
async function tryAcquireOrRenewLease(): Promise<boolean> {
if (process.env.STORAGE?.toLowerCase() !== 'redis') return true;
try {
const c = getRedisClient();
if (!c) return true;
// Zkusíme získat lease atomicky (SET NX EX)
const acquired = await c.set(LEADER_LEASE_KEY, POD_ID, { NX: true, EX: LEASE_TTL_SECONDS });
if (acquired !== null) return true; // lease čerstvě získána
// Pokud jsme ji nedostali, ověříme zda ji držíme my
const currentHolder = await c.get(LEADER_LEASE_KEY);
if (currentHolder === POD_ID) {
// Naše lease — obnovíme TTL
await c.set(LEADER_LEASE_KEY, POD_ID, { EX: LEASE_TTL_SECONDS });
return true;
}
return false; // lease drží jiná instance
} catch (e) {
console.error('Push reminder: chyba při získávání lease, připomínky budou odeslány', e);
return true; // při chybě raději spustíme, než vynecháme
}
}
async function saveRegistry(registry: Registry): Promise<void> {
await storage.setData(REGISTRY_KEY, registry);
/** Uvolní leader lease při graceful shutdown. */
export async function releaseReminderLease(): Promise<void> {
if (process.env.STORAGE?.toLowerCase() !== 'redis') return;
try {
const c = getRedisClient();
if (!c) return;
const currentHolder = await c.get(LEADER_LEASE_KEY);
if (currentHolder === POD_ID) {
await c.del(LEADER_LEASE_KEY);
console.log('Push reminder: lease uvolněna');
}
} catch (e) {
console.error('Push reminder: chyba při uvolňování lease', e);
}
}
/** Stopne scheduler připomínek. Volá se při graceful shutdown. */
export function stopReminderScheduler(): void {
if (reminderInterval) {
clearInterval(reminderInterval);
reminderInterval = undefined;
}
}
/** Přidá nebo aktualizuje push subscription pro uživatele. */
export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime: string): Promise<void> {
const registry = await getRegistry();
registry[login] = { time: reminderTime, subscription };
await saveRegistry(registry);
await storage.updateData<Registry>(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<void> {
const registry = await getRegistry();
delete registry[login];
await saveRegistry(registry);
await storage.updateData<Registry>(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<void> {
// Přeskočit víkendy
if (getIsWeekend(getToday())) {
return;
}
if (getIsWeekend(getToday())) return;
const registry = await getRegistry();
// Leader election — pouze jeden pod spouští připomínky
const isLeader = await tryAcquireOrRenewLease();
if (!isLeader) return;
const registry = await storage.getData<Registry>(REGISTRY_KEY) ?? {};
const entries = Object.entries(registry);
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<void> {
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<void> {
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>(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})`);
}
+46 -54
View File
@@ -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<ClientData>(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<ClientData>(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<ClientData>(getDataKey(usedDate, slot), (current) => {
const data = current ?? getEmptyData(date);
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
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<string>(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<string>(DepartureTime).includes(time)) {
throw Error(`Neplatný čas odchodu ${time}`);
}
return clientData;
return storage.updateData<ClientData>(getDataKey(usedDate), (current) => {
const data = current ?? getEmptyData(date);
const found = Object.values(data.choices).find(location => login in location);
if (found) {
if (!time?.length) delete found[login].departureTime;
else found[login].departureTime = time;
}
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<ClientData>(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;
});
}
/**
+11 -20
View File
@@ -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<void>;
/**
* Vrátí příznak, zda existují data pro předaný klíč.
* @param key klíč, pro který zjišťujeme data (typicky datum)
*/
hasData(key: string): Promise<boolean>;
/**
* 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>;
}
/**
* Atomicky načte, zmutuje a uloží data pod daným klíčem.
* V Redis implementaci používá WATCH/MULTI/EXEC retry loop.
* Vrátí výslednou hodnotu po aplikaci mutátoru.
*/
updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type>;
/** Ověří dostupnost úložiště. Vrátí false pokud není dostupné. */
healthCheck?(): Promise<boolean>;
}
+12 -2
View File
@@ -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();
}
}
updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
const current = db.get(key) as Type | undefined;
const next = mutator(current);
db.set(key, next);
return Promise.resolve(next);
}
healthCheck(): Promise<boolean> {
return Promise.resolve(true);
}
}
+11
View File
@@ -24,4 +24,15 @@ export default class MemoryStorage implements StorageInterface {
store.set(key, data);
return Promise.resolve();
}
updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
const current = store.get(key) as Type | undefined;
const next = mutator(current);
store.set(key, next);
return Promise.resolve(next);
}
healthCheck(): Promise<boolean> {
return Promise.resolve(true);
}
}
+41 -3
View File
@@ -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,44 @@ export default class RedisStorage implements StorageInterface {
async setData<Type>(key: string, data: Type) {
await client.json.set(key, '.', data as any);
await client.json.get(key);
}
}
async updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
// node-redis v5 nemá executeIsolated — pro WATCH/MULTI potřebujeme dedikované spojení
const c = client.duplicate();
await c.connect();
try {
for (let attempt = 0; attempt < 10; attempt++) {
await c.watch(key);
const current = await c.json.get(key, { path: '.' }) as Type | undefined;
const next = mutator(current);
const multi = c.multi();
multi.json.set(key, '.', next as any);
const result = await multi.exec();
if (result !== null) return next;
}
throw new Error(`updateData: optimistic lock failed after 10 retries for key: ${key}`);
} finally {
await c.disconnect();
}
}
async healthCheck(): Promise<boolean> {
try {
const pong = await client.ping();
return pong === 'PONG';
} catch {
return false;
}
}
}
/** Vrátí hlavní Redis klient — používá se pro lease připomínkovače a shutdown. */
export function getRedisClient(): RedisClientType | undefined {
return client;
}
/** Zavře připojení k Redisu. Volá se při graceful shutdown. */
export async function shutdownRedisStorage(): Promise<void> {
await client?.quit();
}
+14 -42
View File
@@ -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<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);
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<VotingData>(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<VotingStatsResult> {
const data = await storage.getData<VotingData>(STORAGE_KEY);
const stats: VotingStatsResult = {};
@@ -73,4 +45,4 @@ export async function getVotingStats(): Promise<VotingStatsResult> {
}
}
return stats;
}
}
+25 -5
View File
@@ -1,12 +1,15 @@
import { DefaultEventsMap, Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
let pubClient: ReturnType<typeof createClient>;
let subClient: ReturnType<typeof createClient>;
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<typeof createClient>;
subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient as any, subClient as any));
console.log('Socket.io: Redis adapter connected');
};
/** Zavře pub/sub Redis klienty adaptéru při graceful shutdown. */
export const shutdownWebsocketClients = async () => {
await Promise.allSettled([pubClient?.quit(), subClient?.quit()]);
};
export const getWebsocket = () => io;
@@ -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);
}
};
+26
View File
@@ -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"