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
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
This commit is contained in:
@@ -4,3 +4,6 @@ types/gen
|
|||||||
.mcp.json
|
.mcp.json
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
server/public/
|
server/public/
|
||||||
|
.claude/*.lock
|
||||||
|
.claude/worktrees
|
||||||
|
.playwright-mcp
|
||||||
Vendored
+32
@@ -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
@@ -76,7 +76,7 @@ WORKDIR /app
|
|||||||
# Export /data/db.json do složky /data
|
# Export /data/db.json do složky /data
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3001
|
||||||
|
|
||||||
CMD [ "node", "./server/src/index.js" ]
|
CMD [ "node", "./server/src/index.js" ]
|
||||||
|
|
||||||
|
|||||||
@@ -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ů
|
||||||
@@ -155,6 +155,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [auth?.login, socket]);
|
}, [auth?.login, socket]);
|
||||||
|
|
||||||
|
// Po znovupřipojení socketu znovu vstoupit do místnosti a obnovit data
|
||||||
|
useEffect(() => {
|
||||||
|
const onReconnect = () => {
|
||||||
|
if (auth?.login) socket.emit('join', auth.login);
|
||||||
|
getData({ query: { dayIndex: dayIndexRef.current } }).then(response => {
|
||||||
|
if (response.data) {
|
||||||
|
setData(response.data);
|
||||||
|
setFood(response.data.menus);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
socket.io.on('reconnect', onReconnect);
|
||||||
|
return () => { socket.io.off('reconnect', onReconnect); };
|
||||||
|
}, [socket, auth?.login]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth?.login || !data?.choices) {
|
if (!auth?.login || !data?.choices) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -8,12 +8,25 @@ if (process.env.NODE_ENV === 'development') {
|
|||||||
socketPath = undefined;
|
socketPath = undefined;
|
||||||
} else {
|
} else {
|
||||||
socketUrl = `${globalThis.location.host}`;
|
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 socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] });
|
||||||
export const SocketContext = React.createContext();
|
export const SocketContext = React.createContext();
|
||||||
|
|
||||||
|
// Prohlížeče throttlují setTimeout v neaktivních tabech, což zdržuje automatické
|
||||||
|
// znovupřipojení socket.io. Po návratu do tabu nebo focusu okna se připojíme hned.
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible' && !socket.connected) {
|
||||||
|
socket.connect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('focus', () => {
|
||||||
|
if (!socket.connected) {
|
||||||
|
socket.connect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Konstanty websocket eventů, musí odpovídat těm na serveru!
|
// Konstanty websocket eventů, musí odpovídat těm na serveru!
|
||||||
export const EVENT_CONNECT = 'connect';
|
export const EVENT_CONNECT = 'connect';
|
||||||
export const EVENT_DISCONNECT = 'disconnect';
|
export const EVENT_DISCONNECT = 'disconnect';
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ export default function OrderGroupsPage() {
|
|||||||
return () => { socket.off(EVENT_MESSAGE); };
|
return () => { socket.off(EVENT_MESSAGE); };
|
||||||
}, [socket]);
|
}, [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> => {
|
const refresh = async (fn: () => Promise<any>): Promise<boolean> => {
|
||||||
setPageError(null);
|
setPageError(null);
|
||||||
const result = await fn();
|
const result = await fn();
|
||||||
@@ -85,7 +91,6 @@ export default function OrderGroupsPage() {
|
|||||||
setData(result.data);
|
setData(result.data);
|
||||||
socket.emit?.('message', result.data as ClientData);
|
socket.emit?.('message', result.data as ClientData);
|
||||||
}
|
}
|
||||||
await fetchData();
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
|||||||
plugins: [react(), viteTsconfigPaths()],
|
plugins: [react(), viteTsconfigPaths()],
|
||||||
server: {
|
server: {
|
||||||
open: true,
|
open: true,
|
||||||
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:3001',
|
'/api': 'http://localhost:3001',
|
||||||
|
|||||||
+186
@@ -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`
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: luncher
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: luncher
|
||||||
|
namespace: luncher
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: luncher
|
||||||
|
ports:
|
||||||
|
- port: 3001
|
||||||
|
targetPort: 3001
|
||||||
@@ -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
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
+79
-34
@@ -9,9 +9,11 @@ import { getQr } from "./qr";
|
|||||||
import { generateToken, getLogin, verify } from "./auth";
|
import { generateToken, getLogin, verify } from "./auth";
|
||||||
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
|
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
|
||||||
import { getPendingQrs } from "./pizza";
|
import { getPendingQrs } from "./pizza";
|
||||||
import { initWebsocket, getWebsocket } from "./websocket";
|
import { initWebsocket, initRedisAdapter, shutdownWebsocketClients, getWebsocket } from "./websocket";
|
||||||
import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder";
|
import { startReminderScheduler, stopReminderScheduler, releaseReminderLease, verifyQuickChoiceToken } from "./pushReminder";
|
||||||
import { storageReady } from "./storage";
|
import { storageReady } from "./storage";
|
||||||
|
import getStorage from "./storage";
|
||||||
|
import { shutdownRedisStorage } from "./storage/redis";
|
||||||
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
||||||
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
||||||
import votingRoutes from "./routes/votingRoutes";
|
import votingRoutes from "./routes/votingRoutes";
|
||||||
@@ -27,23 +29,24 @@ import storeRoutes from "./routes/storeRoutes";
|
|||||||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||||
|
|
||||||
// Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit
|
|
||||||
if (!process.env.JWT_SECRET) {
|
if (!process.env.JWT_SECRET) {
|
||||||
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = require("http").createServer(app);
|
const server = require("http").createServer(app);
|
||||||
|
|
||||||
|
// Tune keep-alive timeouts to outlive Traefik's 60s idle timeout.
|
||||||
|
// headersTimeout must be strictly greater than keepAliveTimeout.
|
||||||
|
server.keepAliveTimeout = 65_000;
|
||||||
|
server.headersTimeout = 66_000;
|
||||||
|
server.requestTimeout = 30_000;
|
||||||
|
|
||||||
initWebsocket(server);
|
initWebsocket(server);
|
||||||
|
|
||||||
// Body-parser middleware for parsing JSON
|
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
|
app.use(cors({ origin: '*' }));
|
||||||
|
|
||||||
app.use(cors({
|
|
||||||
origin: '*'
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Zapínatelný login přes hlavičky - pokud je zapnutý nepovolí "basicauth"
|
|
||||||
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
|
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
|
||||||
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
|
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
|
||||||
if (HTTP_REMOTE_USER_ENABLED) {
|
if (HTTP_REMOTE_USER_ENABLED) {
|
||||||
@@ -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.');
|
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
|
||||||
}
|
}
|
||||||
const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim());
|
const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim());
|
||||||
//TODO: nevim jak udelat console.log pouze pro "debug"
|
|
||||||
//console.log("Budu věřit hlavičkám z: " + HTTP_REMOTE_TRUSTED_IPS);
|
|
||||||
app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS);
|
app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS);
|
||||||
console.log('Zapnutý login přes hlavičky z proxy.');
|
console.log('Zapnutý login přes hlavičky z proxy.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Shutdown state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ----------- 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) => {
|
app.get("/api/health", (_req, res) => {
|
||||||
res.status(200).json({ ok: true });
|
res.status(200).json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Readiness probe — verifies Redis connectivity and rejects traffic during shutdown. */
|
||||||
|
app.get("/api/health/ready", async (_req, res) => {
|
||||||
|
if (shuttingDown) {
|
||||||
|
return res.status(503).json({ ok: false, reason: 'shutting down' });
|
||||||
|
}
|
||||||
|
const healthy = await getStorage().healthCheck?.() ?? true;
|
||||||
|
if (!healthy) return res.status(503).json({ ok: false, reason: 'storage unavailable' });
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/whoami", (req, res) => {
|
app.get("/api/whoami", (req, res) => {
|
||||||
if (!HTTP_REMOTE_USER_ENABLED) {
|
if (!HTTP_REMOTE_USER_ENABLED) {
|
||||||
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
|
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
|
||||||
@@ -76,21 +129,17 @@ app.get("/api/whoami", (req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.post("/api/login", (req, res) => {
|
app.post("/api/login", (req, res) => {
|
||||||
if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy')
|
if (HTTP_REMOTE_USER_ENABLED) {
|
||||||
// Autentizace pomocí trusted headers
|
|
||||||
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
|
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
|
||||||
//const remoteName = req.header('remote-name');
|
|
||||||
if (remoteUser && remoteUser.length > 0) {
|
if (remoteUser && remoteUser.length > 0) {
|
||||||
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
|
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
|
throw new Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Klasická autentizace loginem
|
|
||||||
if (!req.body?.login || req.body.login.trim().length === 0) {
|
if (!req.body?.login || req.body.login.trim().length === 0) {
|
||||||
throw new Error("Nebyl předán login");
|
throw 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));
|
res.status(200).json(generateToken(req.body.login, false));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -111,12 +160,10 @@ app.get("/api/qr", async (req, res) => {
|
|||||||
res.end(img);
|
res.end(img);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ─── Semi-public routes ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Přeskočení auth pro refresh dat xd
|
|
||||||
app.use("/api/food/refresh", refreshMetoda);
|
app.use("/api/food/refresh", refreshMetoda);
|
||||||
|
|
||||||
// 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) => {
|
app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { login, token } = req.body ?? {};
|
const { login, token } = req.body ?? {};
|
||||||
@@ -132,10 +179,10 @@ app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
|
|||||||
} catch (e: any) { next(e); }
|
} catch (e: any) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Middleware ověřující JWT token */
|
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.use("/api/", (req, res, next) => {
|
app.use("/api/", (req, res, next) => {
|
||||||
if (HTTP_REMOTE_USER_ENABLED) {
|
if (HTTP_REMOTE_USER_ENABLED) {
|
||||||
// Autentizace pomocí trusted headers
|
|
||||||
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
|
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
|
||||||
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
|
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
|
||||||
delete req.headers["cookie"]
|
delete req.headers["cookie"]
|
||||||
@@ -158,7 +205,8 @@ app.use("/api/", (req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Vrátí data pro aktuální den. */
|
// ─── Authenticated routes ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get("/api/data", async (req, res) => {
|
app.get("/api/data", async (req, res) => {
|
||||||
let date = undefined;
|
let date = undefined;
|
||||||
if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') {
|
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));
|
date = getDateForWeekIndex(parseInt(req.query.dayIndex));
|
||||||
}
|
}
|
||||||
} else if (getIsWeekend(getToday())) {
|
} else if (getIsWeekend(getToday())) {
|
||||||
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
|
|
||||||
date = getDateForWeekIndex(4);
|
date = getDateForWeekIndex(4);
|
||||||
}
|
}
|
||||||
const slotParam = typeof req.query.slot === 'string' ? req.query.slot as MealSlot : undefined;
|
const 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' });
|
return res.status(400).json({ error: 'Neplatný slot' });
|
||||||
}
|
}
|
||||||
const data = await getData(date, slotParam);
|
const data = await getData(date, slotParam);
|
||||||
// Připojíme nevyřízené QR kódy pro přihlášeného uživatele
|
|
||||||
try {
|
try {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const pendingQrs = await getPendingQrs(login);
|
const pendingQrs = await getPendingQrs(login);
|
||||||
@@ -188,7 +234,6 @@ app.get("/api/data", async (req, res) => {
|
|||||||
res.status(200).json(data);
|
res.status(200).json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ostatní routes
|
|
||||||
app.use("/api/pizzaDay", pizzaDayRoutes);
|
app.use("/api/pizzaDay", pizzaDayRoutes);
|
||||||
app.use("/api/food", foodRoutes);
|
app.use("/api/food", foodRoutes);
|
||||||
app.use("/api/voting", votingRoutes);
|
app.use("/api/voting", votingRoutes);
|
||||||
@@ -206,7 +251,7 @@ app.get('*splat', (_req, res) => {
|
|||||||
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
|
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) => {
|
app.use((err: any, req: any, res: any, next: any) => {
|
||||||
if (err instanceof InsufficientPermissions) {
|
if (err instanceof InsufficientPermissions) {
|
||||||
res.status(403).send({ error: err.message })
|
res.status(403).send({ error: err.message })
|
||||||
@@ -218,18 +263,18 @@ app.use((err: any, req: any, res: any, next: any) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Bootstrap ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PORT = process.env.PORT ?? 3001;
|
const PORT = process.env.PORT ?? 3001;
|
||||||
const HOST = process.env.HOST ?? '0.0.0.0';
|
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||||
|
|
||||||
storageReady.then(() => {
|
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, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`Server listening on ${HOST}, port ${PORT}`);
|
console.log(`Server listening on ${HOST}, port ${PORT}`);
|
||||||
startReminderScheduler();
|
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);
|
|
||||||
});
|
|
||||||
+164
-349
@@ -10,10 +10,6 @@ import crypto from "crypto";
|
|||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
const PENDING_QR_PREFIX = 'pending_qr';
|
const PENDING_QR_PREFIX = 'pending_qr';
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí seznam dostupných pizz pro dnešní den.
|
|
||||||
* Stáhne je, pokud je pro dnešní den nemá.
|
|
||||||
*/
|
|
||||||
export async function getPizzaList(): Promise<Pizza[] | undefined> {
|
export async function getPizzaList(): Promise<Pizza[] | undefined> {
|
||||||
await initIfNeeded();
|
await initIfNeeded();
|
||||||
let clientData = await getClientData(getToday());
|
let clientData = await getClientData(getToday());
|
||||||
@@ -24,25 +20,17 @@ export async function getPizzaList(): Promise<Pizza[] | undefined> {
|
|||||||
return Promise.resolve(clientData.pizzaList);
|
return Promise.resolve(clientData.pizzaList);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Uloží seznam dostupných pizz pro dnešní den.
|
|
||||||
*
|
|
||||||
* @param pizzaList seznam dostupných pizz
|
|
||||||
*/
|
|
||||||
export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
|
export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
|
||||||
await initIfNeeded();
|
await initIfNeeded();
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
clientData.pizzaList = pizzaList;
|
const data = current ?? ({} as ClientData);
|
||||||
clientData.pizzaListLastUpdate = formatDate(new Date());
|
data.pizzaList = pizzaList;
|
||||||
await storage.setData(today, clientData);
|
data.pizzaListLastUpdate = formatDate(new Date());
|
||||||
return clientData;
|
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> {
|
export async function getSalatList(): Promise<Salat[] | undefined> {
|
||||||
await initIfNeeded();
|
await initIfNeeded();
|
||||||
let clientData = await getClientData(getToday());
|
let clientData = await getClientData(getToday());
|
||||||
@@ -53,423 +41,250 @@ export async function getSalatList(): Promise<Salat[] | undefined> {
|
|||||||
return Promise.resolve(clientData.salatList);
|
return Promise.resolve(clientData.salatList);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Uloží seznam dostupných salátů pro dnešní den.
|
|
||||||
*
|
|
||||||
* @param salatList seznam dostupných salátů
|
|
||||||
*/
|
|
||||||
export async function saveSalatList(salatList: Salat[]): Promise<ClientData> {
|
export async function saveSalatList(salatList: Salat[]): Promise<ClientData> {
|
||||||
await initIfNeeded();
|
await initIfNeeded();
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
clientData.salatList = salatList;
|
const data = current ?? ({} as ClientData);
|
||||||
await storage.setData(today, clientData);
|
data.salatList = salatList;
|
||||||
return clientData;
|
return data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Vytvoří pizza day pro aktuální den a vrátí data pro klienta.
|
|
||||||
*/
|
|
||||||
export async function createPizzaDay(creator: string): Promise<ClientData> {
|
export async function createPizzaDay(creator: string): Promise<ClientData> {
|
||||||
await initIfNeeded();
|
await initIfNeeded();
|
||||||
const clientData = await getClientData(getToday());
|
// Stáhneme pizzy a saláty před samotnou atomickou operací
|
||||||
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ě!
|
|
||||||
const [pizzaList, salatList] = await Promise.all([getPizzaList(), getSalatList()]);
|
const [pizzaList, salatList] = await Promise.all([getPizzaList(), getSalatList()]);
|
||||||
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, salatList, ...clientData };
|
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
await storage.setData(today, data);
|
const result = await storage.updateData<ClientData>(today, (current) => {
|
||||||
callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } })
|
if (!current) throw Error("Data pro dnešní den nejsou inicializována");
|
||||||
return data;
|
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> {
|
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());
|
const today = formatDate(getToday());
|
||||||
await storage.setData(today, clientData);
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
return clientData;
|
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) {
|
export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw new 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 (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
if (!order) {
|
||||||
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
order = { customer: login, pizzaList: [], totalPrice: 0, hasQr: false };
|
||||||
}
|
current.pizzaDay.orders ??= [];
|
||||||
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
|
current.pizzaDay.orders.push(order);
|
||||||
if (!order) {
|
|
||||||
order = {
|
|
||||||
customer: login,
|
|
||||||
pizzaList: [],
|
|
||||||
totalPrice: 0,
|
|
||||||
hasQr: false,
|
|
||||||
}
|
}
|
||||||
clientData.pizzaDay.orders ??= [];
|
const pizzaOrder: PizzaVariant = { varId: size.varId, name: pizza.name, size: size.size, price: size.price };
|
||||||
clientData.pizzaDay.orders.push(order);
|
order.pizzaList ??= [];
|
||||||
}
|
order.pizzaList.push(pizzaOrder);
|
||||||
const pizzaOrder: PizzaVariant = {
|
order.totalPrice += pizzaOrder.price;
|
||||||
varId: size.varId,
|
return current;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Přidá objednávku salátu uživateli.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @param salat zvolený salát
|
|
||||||
*/
|
|
||||||
export async function addSalatOrder(login: string, salat: Salat) {
|
export async function addSalatOrder(login: string, salat: Salat) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw new 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 (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
if (!order) {
|
||||||
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
order = { customer: login, pizzaList: [], totalPrice: 0, hasQr: false };
|
||||||
}
|
current.pizzaDay.orders ??= [];
|
||||||
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
|
current.pizzaDay.orders.push(order);
|
||||||
if (!order) {
|
|
||||||
order = {
|
|
||||||
customer: login,
|
|
||||||
pizzaList: [],
|
|
||||||
totalPrice: 0,
|
|
||||||
hasQr: false,
|
|
||||||
}
|
}
|
||||||
clientData.pizzaDay.orders ??= [];
|
const salatOrder: PizzaVariant = { varId: 0, name: salat.name, size: "1 porce", price: salat.price, category: 'salat' };
|
||||||
clientData.pizzaDay.orders.push(order);
|
order.pizzaList ??= [];
|
||||||
}
|
order.pizzaList.push(salatOrder);
|
||||||
const salatOrder: PizzaVariant = {
|
order.totalPrice += salatOrder.price;
|
||||||
varId: 0,
|
return current;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Odstraní všechny pizzy uživatele (celou jeho objednávku).
|
|
||||||
* Pokud Pizza day neexistuje nebo není ve stavu CREATED, neudělá nic.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @param date datum, pro které se objednávka odstraňuje (výchozí je dnešek)
|
|
||||||
* @returns aktuální data pro klienta
|
|
||||||
*/
|
|
||||||
export async function removeAllUserPizzas(login: string, date?: Date) {
|
export async function removeAllUserPizzas(login: string, date?: Date) {
|
||||||
const usedDate = date ?? getToday();
|
const usedDate = date ?? getToday();
|
||||||
const today = formatDate(usedDate);
|
const today = formatDate(usedDate);
|
||||||
const clientData = await getClientData(usedDate);
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
|
if (!current?.pizzaDay) return current ?? ({} as ClientData);
|
||||||
if (!clientData.pizzaDay) {
|
if (current.pizzaDay.state !== PizzaDayState.CREATED) return current;
|
||||||
return clientData; // Pizza day neexistuje, není co mazat
|
const orderIndex = current.pizzaDay.orders!.findIndex(o => o.customer === login);
|
||||||
}
|
if (orderIndex >= 0) current.pizzaDay.orders!.splice(orderIndex, 1);
|
||||||
|
return current;
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
});
|
||||||
return clientData; // Pizza day není ve stavu CREATED, nelze mazat
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login);
|
|
||||||
if (orderIndex >= 0) {
|
|
||||||
clientData.pizzaDay.orders!.splice(orderIndex, 1);
|
|
||||||
await storage.setData(today, clientData);
|
|
||||||
}
|
|
||||||
|
|
||||||
return clientData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Odstraní danou objednávku pizzy.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @param pizzaOrder objednávka pizzy
|
|
||||||
*/
|
|
||||||
export async function removePizzaOrder(login: string, pizzaOrder: PizzaVariant) {
|
export async function removePizzaOrder(login: string, pizzaOrder: PizzaVariant) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw new 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 orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login);
|
const order = current.pizzaDay.orders![orderIndex];
|
||||||
if (orderIndex < 0) {
|
const index = order.pizzaList!.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size);
|
||||||
throw new Error("Nebyly nalezeny žádné objednávky pro uživatele " + login);
|
if (index < 0) throw Error("Objednávka s danými parametry nebyla nalezena");
|
||||||
}
|
const price = order.pizzaList![index].price;
|
||||||
const order = clientData.pizzaDay.orders![orderIndex];
|
order.pizzaList!.splice(index, 1);
|
||||||
const index = order.pizzaList!.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size);
|
order.totalPrice -= price;
|
||||||
if (index < 0) {
|
if (order.pizzaList!.length === 0) current.pizzaDay.orders!.splice(orderIndex, 1);
|
||||||
throw new Error("Objednávka s danými parametry nebyla nalezena");
|
return current;
|
||||||
}
|
});
|
||||||
const price = order.pizzaList![index].price;
|
|
||||||
order.pizzaList!.splice(index, 1);
|
|
||||||
order.totalPrice -= price;
|
|
||||||
if (order.pizzaList!.length == 0) {
|
|
||||||
clientData.pizzaDay.orders!.splice(orderIndex, 1);
|
|
||||||
}
|
|
||||||
await storage.setData(today, clientData);
|
|
||||||
return clientData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Uzamkne možnost editovat objednávky pizzy.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @returns aktuální data pro uživatele
|
|
||||||
*/
|
|
||||||
export async function lockPizzaDay(login: string) {
|
export async function lockPizzaDay(login: string) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw new 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) {
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED + " nebo " + PizzaDayState.ORDERED);
|
||||||
throw new Error("Pizza day není spravován uživatelem " + login);
|
}
|
||||||
}
|
current.pizzaDay.state = PizzaDayState.LOCKED;
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED && clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
|
return current;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Odekmne možnost editovat objednávky pizzy.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @returns aktuální data pro uživatele
|
|
||||||
*/
|
|
||||||
export async function unlockPizzaDay(login: string) {
|
export async function unlockPizzaDay(login: string) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw new 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);
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
current.pizzaDay.state = PizzaDayState.CREATED;
|
||||||
throw new Error("Pizza day není spravován uživatelem " + login);
|
return current;
|
||||||
}
|
});
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Nastaví stav pizza day na "pizzy objednány".
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @returns aktuální data pro uživatele
|
|
||||||
*/
|
|
||||||
export async function finishPizzaOrder(login: string) {
|
export async function finishPizzaOrder(login: string) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
const result = await storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw new 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);
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
current.pizzaDay.state = PizzaDayState.ORDERED;
|
||||||
throw new Error("Pizza day není spravován uživatelem " + login);
|
return current;
|
||||||
}
|
});
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
|
callNotifikace({ input: { udalost: UdalostEnum.OBJEDNANA_PIZZA, user: result.pizzaDay!.creator! } });
|
||||||
throw new Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
|
return result;
|
||||||
}
|
|
||||||
clientData.pizzaDay.state = PizzaDayState.ORDERED;
|
|
||||||
await storage.setData(today, clientData);
|
|
||||||
callNotifikace({ input: { udalost: UdalostEnum.OBJEDNANA_PIZZA, user: clientData?.pizzaDay?.creator } })
|
|
||||||
return clientData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Nastaví stav pizza day na "pizzy doručeny".
|
|
||||||
* Vygeneruje QR kódy pro všechny objednatele, pokud objednávající má vyplněné číslo účtu a jméno.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @returns aktuální data pro uživatele
|
|
||||||
*/
|
|
||||||
export async function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) {
|
export async function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
|
// Načteme aktuální data pro přípravu QR (potřebujeme objednávky)
|
||||||
const clientData = await getClientData(getToday());
|
const clientData = await getClientData(getToday());
|
||||||
if (!clientData.pizzaDay) {
|
if (!clientData.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw new 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);
|
||||||
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;
|
|
||||||
|
|
||||||
// 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) {
|
if (bankAccount?.length && bankAccountHolder?.length) {
|
||||||
for (const order of clientData.pizzaDay.orders!) {
|
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();
|
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})`
|
item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})`
|
||||||
).join(', ');
|
).join(', ');
|
||||||
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice / 100, message, id);
|
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice / 100, message, id);
|
||||||
order.hasQr = true;
|
pendingQrs.push({
|
||||||
// Uložíme nevyřízený QR kód pro persistentní zobrazení
|
customer: order.customer, id, pendingQr: {
|
||||||
await addPendingQr(order.customer, {
|
id, date: today, creator: login, totalPrice: order.totalPrice, purpose: message,
|
||||||
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) {
|
export async function updatePizzaDayNote(login: string, note?: string) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
let clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw new 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 (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
if (!myOrder?.pizzaList?.length) throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
|
||||||
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
myOrder.note = note;
|
||||||
}
|
return current;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Aktualizuje příplatek uživatele k objednávce pizzy.
|
|
||||||
* V případě nevyplnění ceny je příplatek odebrán.
|
|
||||||
*
|
|
||||||
* @param login přihlašovací jméno aktuálního uživatele
|
|
||||||
* @param targetLogin přihlašovací jméno uživatele, kterému je nastavován příplatek
|
|
||||||
* @param text text popisující příplatek
|
|
||||||
* @param price celková cena příplatku
|
|
||||||
*/
|
|
||||||
export async function updatePizzaFee(login: string, targetLogin: string, text?: string, price?: number) {
|
export async function updatePizzaFee(login: string, targetLogin: string, text?: string, price?: number) {
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
let clientData = await getClientData(getToday());
|
return storage.updateData<ClientData>(today, (current) => {
|
||||||
if (!clientData.pizzaDay) {
|
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||||
throw new 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");
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
const targetOrder = current.pizzaDay.orders!.find(o => o.customer === targetLogin);
|
||||||
throw new Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`);
|
if (!targetOrder?.pizzaList?.length) throw Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
|
||||||
}
|
if (!price) {
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
delete targetOrder.fee;
|
||||||
throw new Error("Příplatky může měnit pouze zakladatel Pizza day");
|
} else {
|
||||||
}
|
targetOrder.fee = { text, price };
|
||||||
const targetOrder = clientData.pizzaDay.orders!.find(o => o.customer === targetLogin);
|
}
|
||||||
if (!targetOrder?.pizzaList?.length) {
|
targetOrder.totalPrice = targetOrder.pizzaList.reduce((p, o) => p + o.price, 0) + (targetOrder.fee?.price ?? 0);
|
||||||
throw new Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
|
return current;
|
||||||
}
|
});
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí klíč pro uložení nevyřízených QR kódů uživatele.
|
|
||||||
*/
|
|
||||||
function getPendingQrKey(login: string): string {
|
function getPendingQrKey(login: string): string {
|
||||||
return `${PENDING_QR_PREFIX}_${login}`;
|
return `${PENDING_QR_PREFIX}_${login}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Přidá nevyřízený QR kód pro uživatele.
|
|
||||||
*/
|
|
||||||
export async function addPendingQr(login: string, pendingQr: PendingQr): Promise<void> {
|
export async function addPendingQr(login: string, pendingQr: PendingQr): Promise<void> {
|
||||||
const key = getPendingQrKey(login);
|
await storage.updateData<PendingQr[]>(getPendingQrKey(login), (current) => {
|
||||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
const existing = current ?? [];
|
||||||
// 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);
|
||||||
if (!existing.some(qr => qr.id === pendingQr.id)) {
|
return existing;
|
||||||
existing.push(pendingQr);
|
});
|
||||||
await storage.setData(key, existing);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí nevyřízené QR kódy pro uživatele.
|
|
||||||
*/
|
|
||||||
export async function getPendingQrs(login: string): Promise<PendingQr[]> {
|
export async function getPendingQrs(login: string): Promise<PendingQr[]> {
|
||||||
return await storage.getData<PendingQr[]>(getPendingQrKey(login)) ?? [];
|
return await storage.getData<PendingQr[]>(getPendingQrKey(login)) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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> {
|
export async function dismissPendingQr(login: string, id: string): Promise<PendingQr | undefined> {
|
||||||
const key = getPendingQrKey(login);
|
let dismissed: PendingQr | undefined;
|
||||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
await storage.updateData<PendingQr[]>(getPendingQrKey(login), (current) => {
|
||||||
const dismissed = existing.find(qr => qr.id === id);
|
const existing = current ?? [];
|
||||||
const filtered = existing.filter(qr => qr.id !== id);
|
dismissed = existing.find(qr => qr.id === id);
|
||||||
await storage.setData(key, filtered);
|
return existing.filter(qr => qr.id !== id);
|
||||||
|
});
|
||||||
return dismissed;
|
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> {
|
export async function removePendingQrsByGroupId(logins: string[], groupId: string): Promise<void> {
|
||||||
for (const login of logins) {
|
for (const login of logins) {
|
||||||
const key = getPendingQrKey(login);
|
await storage.updateData<PendingQr[]>(getPendingQrKey(login), (current) => {
|
||||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
return (current ?? []).filter(qr => qr.groupId !== groupId);
|
||||||
const filtered = existing.filter(qr => qr.groupId !== groupId);
|
});
|
||||||
if (filtered.length !== existing.length) {
|
|
||||||
await storage.setData(key, filtered);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+89
-39
@@ -1,12 +1,17 @@
|
|||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import getStorage from './storage';
|
import getStorage from './storage';
|
||||||
|
import { getRedisClient } from './storage/redis';
|
||||||
import { getClientData, getToday } from './service';
|
import { getClientData, getToday } from './service';
|
||||||
import { getIsWeekend } from './utils';
|
import { getIsWeekend } from './utils';
|
||||||
import { LunchChoices } from '../../types';
|
import { LunchChoices } from '../../types';
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
const REGISTRY_KEY = 'push_reminder_registry';
|
const REGISTRY_KEY = 'push_reminder_registry';
|
||||||
|
const LEADER_LEASE_KEY = 'luncher:reminder:leader';
|
||||||
|
const LEASE_TTL_SECONDS = 90;
|
||||||
|
|
||||||
|
const POD_ID = process.env.POD_ID ?? `local-${process.pid}`;
|
||||||
|
|
||||||
interface RegistryEntry {
|
interface RegistryEntry {
|
||||||
time: string;
|
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
|
const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami
|
||||||
|
|
||||||
|
let reminderInterval: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
function getCurrentTimeHHMM(): string {
|
function getCurrentTimeHHMM(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
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;
|
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> {
|
/** Uvolní leader lease při graceful shutdown. */
|
||||||
await storage.setData(REGISTRY_KEY, registry);
|
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. */
|
/** Přidá nebo aktualizuje push subscription pro uživatele. */
|
||||||
export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime: string): Promise<void> {
|
export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime: string): Promise<void> {
|
||||||
const registry = await getRegistry();
|
await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
|
||||||
registry[login] = { time: reminderTime, subscription };
|
const registry = current ?? {};
|
||||||
await saveRegistry(registry);
|
registry[login] = { time: reminderTime, subscription };
|
||||||
|
return registry;
|
||||||
|
});
|
||||||
console.log(`Push reminder: uživatel ${login} přihlášen k připomínkám v ${reminderTime}`);
|
console.log(`Push reminder: uživatel ${login} přihlášen k připomínkám v ${reminderTime}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Odebere push subscription pro uživatele. */
|
/** Odebere push subscription pro uživatele. */
|
||||||
export async function unsubscribePush(login: string): Promise<void> {
|
export async function unsubscribePush(login: string): Promise<void> {
|
||||||
const registry = await getRegistry();
|
await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
|
||||||
delete registry[login];
|
const registry = current ?? {};
|
||||||
await saveRegistry(registry);
|
delete registry[login];
|
||||||
|
return registry;
|
||||||
|
});
|
||||||
lastReminded.delete(login);
|
lastReminded.delete(login);
|
||||||
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
|
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
|
||||||
}
|
}
|
||||||
@@ -79,23 +135,20 @@ export function verifyQuickChoiceToken(login: string, token: string): boolean {
|
|||||||
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex'));
|
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
|
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
|
||||||
async function checkAndSendReminders(): Promise<void> {
|
async function checkAndSendReminders(): Promise<void> {
|
||||||
// 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);
|
const entries = Object.entries(registry);
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTime = getCurrentTimeHHMM();
|
const currentTime = getCurrentTimeHHMM();
|
||||||
|
|
||||||
// Získáme data pro dnešek jednou pro všechny uživatele
|
|
||||||
let clientData;
|
let clientData;
|
||||||
try {
|
try {
|
||||||
clientData = await getClientData(getToday());
|
clientData = await getClientData(getToday());
|
||||||
@@ -104,24 +157,16 @@ async function checkAndSendReminders(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expiredLogins: string[] = [];
|
||||||
|
|
||||||
for (const [login, entry] of entries) {
|
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;
|
const last = lastReminded.get(login) ?? 0;
|
||||||
if (Date.now() - last < REMINDER_COOLDOWN_MS) {
|
if (Date.now() - last < REMINDER_COOLDOWN_MS) continue;
|
||||||
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 {
|
try {
|
||||||
await webpush.sendNotification(
|
await webpush.sendNotification(
|
||||||
entry.subscription,
|
entry.subscription,
|
||||||
@@ -136,15 +181,21 @@ async function checkAndSendReminders(): Promise<void> {
|
|||||||
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
|
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.statusCode === 410 || error.statusCode === 404) {
|
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||||
// Subscription expirovala nebo je neplatná — odebereme z registry
|
|
||||||
console.log(`Push reminder: subscription uživatele ${login} expirovala, odebírám`);
|
console.log(`Push reminder: subscription uživatele ${login} expirovala, odebírám`);
|
||||||
delete registry[login];
|
expiredLogins.push(login);
|
||||||
await saveRegistry(registry);
|
|
||||||
} else {
|
} else {
|
||||||
console.error(`Push reminder: chyba při odesílání notifikace uživateli ${login}:`, error);
|
console.error(`Push reminder: chyba při odesílání notifikace uživateli ${login}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (expiredLogins.length > 0) {
|
||||||
|
await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
|
||||||
|
const r = current ?? {};
|
||||||
|
for (const login of expiredLogins) delete r[login];
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Spustí scheduler pro kontrolu a odesílání připomínek každou minutu. */
|
/** Spustí scheduler pro kontrolu a odesílání připomínek každou minutu. */
|
||||||
@@ -160,7 +211,6 @@ export function startReminderScheduler(): void {
|
|||||||
|
|
||||||
webpush.setVapidDetails(subject, publicKey, privateKey);
|
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||||
|
|
||||||
// Spustíme kontrolu každou minutu
|
reminderInterval = setInterval(checkAndSendReminders, 60_000);
|
||||||
setInterval(checkAndSendReminders, 60_000);
|
console.log(`Push reminder: scheduler spuštěn (POD_ID=${POD_ID})`);
|
||||||
console.log('Push reminder: scheduler spuštěn');
|
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-54
@@ -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) {
|
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date, slot?: MealSlot) {
|
||||||
const selectedDay = getDataKey(date ?? getToday(), slot);
|
const selectedDay = getDataKey(date ?? getToday(), slot);
|
||||||
let data = await getClientData(date, slot);
|
// Validate trusted flag against current data before atomic update
|
||||||
validateTrusted(data, login, trusted);
|
const snapshot = await getClientData(date, slot);
|
||||||
if (locationKey in data.choices) {
|
validateTrusted(snapshot, login, trusted);
|
||||||
if (data.choices[locationKey] && login in data.choices[locationKey]) {
|
return storage.updateData<ClientData>(selectedDay, (current) => {
|
||||||
delete data.choices[locationKey][login]
|
const data = current ?? getEmptyData(date);
|
||||||
if (Object.keys(data.choices[locationKey]).length === 0) {
|
if (locationKey in data.choices && data.choices[locationKey] && login in data.choices[locationKey]) {
|
||||||
delete 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);
|
|
||||||
}
|
}
|
||||||
}
|
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) {
|
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date, slot?: MealSlot) {
|
||||||
const selectedDay = getDataKey(date ?? getToday(), slot);
|
const selectedDay = getDataKey(date ?? getToday(), slot);
|
||||||
let data = await getClientData(date, slot);
|
const snapshot = await getClientData(date, slot);
|
||||||
validateTrusted(data, login, trusted);
|
validateTrusted(snapshot, login, trusted);
|
||||||
if (locationKey in data.choices) {
|
return storage.updateData<ClientData>(selectedDay, (current) => {
|
||||||
if (data.choices[locationKey] && login in data.choices[locationKey]) {
|
const data = current ?? getEmptyData(date);
|
||||||
|
if (locationKey in data.choices && data.choices[locationKey] && login in data.choices[locationKey]) {
|
||||||
const index = data.choices[locationKey][login].selectedFoods?.indexOf(foodIndex);
|
const index = data.choices[locationKey][login].selectedFoods?.indexOf(foodIndex);
|
||||||
if (index != null && index > -1) {
|
if (index != null && index > -1) data.choices[locationKey][login].selectedFoods?.splice(index, 1);
|
||||||
data.choices[locationKey][login].selectedFoods?.splice(index, 1);
|
|
||||||
await storage.setData(selectedDay, data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
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) {
|
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date, slot?: MealSlot) {
|
||||||
const usedDate = date ?? getToday();
|
const usedDate = date ?? getToday();
|
||||||
await initIfNeeded(usedDate, slot);
|
await initIfNeeded(usedDate, slot);
|
||||||
let data = await getClientData(usedDate, slot);
|
const snapshot = await getClientData(usedDate, slot);
|
||||||
validateTrusted(data, login, trusted);
|
validateTrusted(snapshot, login, trusted);
|
||||||
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
|
return storage.updateData<ClientData>(getDataKey(usedDate, slot), (current) => {
|
||||||
if (userEntry) {
|
const data = current ?? getEmptyData(date);
|
||||||
if (!note?.length) {
|
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
|
||||||
delete userEntry[1][login].note;
|
if (userEntry) {
|
||||||
} else {
|
if (!note?.length) delete userEntry[1][login].note;
|
||||||
userEntry[1][login].note = 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) {
|
export async function updateDepartureTime(login: string, time?: string, date?: Date) {
|
||||||
const usedDate = date ?? getToday();
|
const usedDate = date ?? getToday();
|
||||||
let clientData = await getClientData(usedDate);
|
if (time?.length && !Object.values<string>(DepartureTime).includes(time)) {
|
||||||
const found = Object.values(clientData.choices).find(location => login in location);
|
throw Error(`Neplatný čas odchodu ${time}`);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
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) {
|
export async function updateBuyer(login: string, slot?: MealSlot) {
|
||||||
const usedDate = getToday();
|
const usedDate = getToday();
|
||||||
let clientData = await getClientData(usedDate, slot);
|
return storage.updateData<ClientData>(getDataKey(usedDate, slot), (current) => {
|
||||||
const userEntry = clientData.choices?.['OBJEDNAVAM']?.[login];
|
const data = current ?? getEmptyData();
|
||||||
if (!userEntry) {
|
const userEntry = data.choices?.['OBJEDNAVAM']?.[login];
|
||||||
throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
|
if (!userEntry) throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
|
||||||
}
|
userEntry.isBuyer = !(userEntry.isBuyer || false);
|
||||||
userEntry.isBuyer = !(userEntry.isBuyer || false);
|
return data;
|
||||||
await storage.setData(getDataKey(usedDate, slot), clientData);
|
});
|
||||||
return clientData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,32 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Interface pro úložiště dat.
|
* Interface pro úložiště dat.
|
||||||
*
|
|
||||||
* Aktuálně pouze "primitivní" has, get a set odrážející původní JSON DB.
|
|
||||||
* Postupem času lze předělat pro efektivnější využití Redis.
|
|
||||||
*/
|
*/
|
||||||
export interface StorageInterface {
|
export interface StorageInterface {
|
||||||
|
|
||||||
/**
|
|
||||||
* Inicializuje úložiště, pokud je potřeba (např. připojení k databázi).
|
|
||||||
*/
|
|
||||||
initialize?(): Promise<void>;
|
initialize?(): Promise<void>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí příznak, zda existují data pro předaný klíč.
|
|
||||||
* @param key klíč, pro který zjišťujeme data (typicky datum)
|
|
||||||
*/
|
|
||||||
hasData(key: string): Promise<boolean>;
|
hasData(key: string): Promise<boolean>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí veškerá data pro předaný klíč.
|
|
||||||
* @param key klíč, pro který vrátit data (typicky datum)
|
|
||||||
*/
|
|
||||||
getData<Type>(key: string): Promise<Type | undefined>;
|
getData<Type>(key: string): Promise<Type | undefined>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Uloží data pod předaný klíč.
|
|
||||||
* @param key klíč, pod kterým uložit data (typicky datum)
|
|
||||||
* @param data data pro uložení
|
|
||||||
*/
|
|
||||||
setData<Type>(key: string, data: Type): Promise<void>;
|
setData<Type>(key: string, data: Type): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>;
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,6 @@ import * as path from 'path';
|
|||||||
const dbPath = path.resolve(__dirname, '../../data/db.json');
|
const dbPath = path.resolve(__dirname, '../../data/db.json');
|
||||||
const dbDir = path.dirname(dbPath);
|
const dbDir = path.dirname(dbPath);
|
||||||
|
|
||||||
// Zajistěte, že adresář existuje
|
|
||||||
if (!fs.existsSync(dbDir)) {
|
if (!fs.existsSync(dbDir)) {
|
||||||
fs.mkdirSync(dbDir, { recursive: true });
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -29,4 +28,15 @@ export default class JsonStorage implements StorageInterface {
|
|||||||
db.set(key, data);
|
db.set(key, data);
|
||||||
return Promise.resolve();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -24,4 +24,15 @@ export default class MemoryStorage implements StorageInterface {
|
|||||||
store.set(key, data);
|
store.set(key, data);
|
||||||
return Promise.resolve();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default class RedisStorage implements StorageInterface {
|
|||||||
constructor() {
|
constructor() {
|
||||||
const HOST = process.env.REDIS_HOST ?? 'localhost';
|
const HOST = process.env.REDIS_HOST ?? 'localhost';
|
||||||
const PORT = process.env.REDIS_PORT ?? 6379;
|
const PORT = process.env.REDIS_PORT ?? 6379;
|
||||||
client = createClient({ url: `redis://${HOST}:${PORT}` });
|
client = createClient({ url: `redis://${HOST}:${PORT}` }) as RedisClientType;
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
@@ -29,6 +29,39 @@ export default class RedisStorage implements StorageInterface {
|
|||||||
|
|
||||||
async setData<Type>(key: string, data: Type) {
|
async setData<Type>(key: string, data: Type) {
|
||||||
await client.json.set(key, '.', data as any);
|
await client.json.set(key, '.', data as any);
|
||||||
await client.json.get(key);
|
}
|
||||||
|
|
||||||
|
async updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
|
||||||
|
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<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();
|
||||||
|
}
|
||||||
|
|||||||
+13
-41
@@ -1,4 +1,4 @@
|
|||||||
import { FeatureRequest, VotingStats } from "../../types/gen/types.gen";
|
import { FeatureRequest } from "../../types/gen/types.gen";
|
||||||
import getStorage from "./storage";
|
import getStorage from "./storage";
|
||||||
|
|
||||||
interface VotingData {
|
interface VotingData {
|
||||||
@@ -12,56 +12,28 @@ export interface VotingStatsResult {
|
|||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
const STORAGE_KEY = 'voting';
|
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) {
|
export async function getUserVotes(login: string) {
|
||||||
const data = await storage.getData<VotingData>(STORAGE_KEY);
|
const data = await storage.getData<VotingData>(STORAGE_KEY);
|
||||||
return data?.[login] || [];
|
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> {
|
export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> {
|
||||||
let data = await storage.getData<VotingData>(STORAGE_KEY);
|
return storage.updateData<VotingData>(STORAGE_KEY, (current) => {
|
||||||
data ??= {};
|
const data = current ?? {};
|
||||||
if (!(login in data)) {
|
if (!(login in data)) data[login] = [];
|
||||||
data[login] = [];
|
const index = data[login].indexOf(option);
|
||||||
}
|
if (index > -1) {
|
||||||
const index = data[login].indexOf(option);
|
if (active) throw Error('Pro tuto možnost jste již hlasovali');
|
||||||
if (index > -1) {
|
|
||||||
if (active) {
|
|
||||||
throw new Error('Pro tuto možnost jste již hlasovali');
|
|
||||||
} else {
|
|
||||||
data[login].splice(index, 1);
|
data[login].splice(index, 1);
|
||||||
if (data[login].length === 0) {
|
if (data[login].length === 0) delete data[login];
|
||||||
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) {
|
return data;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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> {
|
export async function getVotingStats(): Promise<VotingStatsResult> {
|
||||||
const data = await storage.getData<VotingData>(STORAGE_KEY);
|
const data = await storage.getData<VotingData>(STORAGE_KEY);
|
||||||
const stats: VotingStatsResult = {};
|
const stats: VotingStatsResult = {};
|
||||||
|
|||||||
+25
-5
@@ -1,12 +1,15 @@
|
|||||||
import { DefaultEventsMap, Server } from "socket.io";
|
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 io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
|
||||||
|
let pubClient: ReturnType<typeof createClient>;
|
||||||
|
let subClient: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
export const initWebsocket = (server: any) => {
|
export const initWebsocket = (server: any) => {
|
||||||
io = new Server(server, {
|
io = new Server(server, {
|
||||||
cors: {
|
cors: { origin: "*" },
|
||||||
origin: "*",
|
transports: ["websocket"],
|
||||||
},
|
|
||||||
});
|
});
|
||||||
io.on("connection", (socket) => {
|
io.on("connection", (socket) => {
|
||||||
console.log(`New client connected: ${socket.id}`);
|
console.log(`New client connected: ${socket.id}`);
|
||||||
@@ -26,7 +29,24 @@ export const initWebsocket = (server: any) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
return io;
|
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;
|
export const getWebsocket = () => io;
|
||||||
|
|
||||||
@@ -34,4 +54,4 @@ export const getWebsocket = () => io;
|
|||||||
export const emitToUser = (login: string, event: string, data: unknown) => {
|
export const emitToUser = (login: string, event: string, data: unknown) => {
|
||||||
if (!io) return;
|
if (!io) return;
|
||||||
io.to(`user:${login}`).emit(event, data);
|
io.to(`user:${login}`).emit(event, data);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1521,6 +1521,15 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
|
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
|
||||||
integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==
|
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":
|
"@tsconfig/node10@^1.0.7":
|
||||||
version "1.0.11"
|
version "1.0.11"
|
||||||
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"
|
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"
|
||||||
@@ -2438,6 +2447,13 @@ debug@^4.1.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms "^2.1.3"
|
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:
|
dedent@^1.6.0:
|
||||||
version "1.7.0"
|
version "1.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.0.tgz#c1f9445335f0175a96587be245a282ff451446ca"
|
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"
|
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
|
||||||
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
|
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:
|
npm-run-path@^4.0.1:
|
||||||
version "4.0.1"
|
version "4.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
|
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"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
|
||||||
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
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:
|
undefsafe@^2.0.5:
|
||||||
version "2.0.5"
|
version "2.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c"
|
resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c"
|
||||||
|
|||||||
Reference in New Issue
Block a user