feat: podpora high-availability a multi-replica nasazení
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 20s
CI / Build server (push) Successful in 26s
CI / Build client (push) Successful in 35s
CI / Playwright E2E tests (push) Failing after 1m56s
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 1s

- Socket.io Redis adapter pro sdílený stav přes repliky
- graceful shutdown serveru
- WATCH/MULTI v updateData pro race-condition-safe aktualizace
- lease mechanismus pro push reminder (zabrání duplicitnímu odesílání)
- k8s/ manifesty pro testovací kind cluster
- Dockerfile: opraven EXPOSE port na 3001
- .gitignore: ignorovány Claude pracovní soubory
This commit is contained in:
2026-05-20 17:01:33 +02:00
parent a26d6cf85c
commit 67abbf19b5
32 changed files with 1265 additions and 552 deletions
+1
View File
@@ -29,6 +29,7 @@
"typescript": "^5.9.3"
},
"dependencies": {
"@socket.io/redis-adapter": "^8.3.0",
"axios": "^1.13.2",
"cheerio": "^1.1.2",
"cors": "^2.8.5",
+79 -34
View File
@@ -9,9 +9,11 @@ import { getQr } from "./qr";
import { generateToken, getLogin, verify } from "./auth";
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
import { getPendingQrs } from "./pizza";
import { initWebsocket, getWebsocket } from "./websocket";
import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder";
import { initWebsocket, initRedisAdapter, shutdownWebsocketClients, getWebsocket } from "./websocket";
import { startReminderScheduler, stopReminderScheduler, releaseReminderLease, verifyQuickChoiceToken } from "./pushReminder";
import { storageReady } from "./storage";
import getStorage from "./storage";
import { shutdownRedisStorage } from "./storage/redis";
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
import votingRoutes from "./routes/votingRoutes";
@@ -27,23 +29,24 @@ import storeRoutes from "./routes/storeRoutes";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
// Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit
if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
const app = express();
const server = require("http").createServer(app);
// Tune keep-alive timeouts to outlive Traefik's 60s idle timeout.
// headersTimeout must be strictly greater than keepAliveTimeout.
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;
server.requestTimeout = 30_000;
initWebsocket(server);
// Body-parser middleware for parsing JSON
app.use(bodyParser.json());
app.use(cors({ origin: '*' }));
app.use(cors({
origin: '*'
}));
// Zapínatelný login přes hlavičky - pokud je zapnutý nepovolí "basicauth"
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
if (HTTP_REMOTE_USER_ENABLED) {
@@ -51,19 +54,69 @@ if (HTTP_REMOTE_USER_ENABLED) {
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
}
const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim());
//TODO: nevim jak udelat console.log pouze pro "debug"
//console.log("Budu věřit hlavičkám z: " + HTTP_REMOTE_TRUSTED_IPS);
app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS);
console.log('Zapnutý login přes hlavičky z proxy.');
}
// ─── Shutdown state ──────────────────────────────────────────────────────────
// ----------- Metody nevyžadující token --------------
let shuttingDown = false;
async function shutdown(signal: string) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`${signal} received — initiating graceful shutdown`);
// Hard-exit failsafe: fires before terminationGracePeriodSeconds (30s)
setTimeout(() => {
console.error('Graceful shutdown timed out, forcing exit');
process.exit(1);
}, 25_000).unref();
// Disconnect WebSocket clients so they reconnect to another pod
const io = getWebsocket();
io?.disconnectSockets(true);
// Stop accepting new HTTP connections and drain in-flight requests
(server as any).closeIdleConnections?.();
await new Promise<void>(resolve => server.close(() => resolve()));
// Stop reminder scheduler and release leader lease
stopReminderScheduler();
await releaseReminderLease();
// Shut down Redis pub/sub clients (Socket.io adapter)
await shutdownWebsocketClients();
// Shut down main Redis storage client
if (process.env.STORAGE?.toLowerCase() === 'redis') {
await shutdownRedisStorage();
}
console.log('Graceful shutdown complete');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// ─── Routes — no auth required ───────────────────────────────────────────────
/** Liveness probe — cheap, no external deps. */
app.get("/api/health", (_req, res) => {
res.status(200).json({ ok: true });
});
/** Readiness probe — verifies Redis connectivity and rejects traffic during shutdown. */
app.get("/api/health/ready", async (_req, res) => {
if (shuttingDown) {
return res.status(503).json({ ok: false, reason: 'shutting down' });
}
const healthy = await getStorage().healthCheck?.() ?? true;
if (!healthy) return res.status(503).json({ ok: false, reason: 'storage unavailable' });
res.status(200).json({ ok: true });
});
app.get("/api/whoami", (req, res) => {
if (!HTTP_REMOTE_USER_ENABLED) {
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
@@ -76,21 +129,17 @@ app.get("/api/whoami", (req, res) => {
})
app.post("/api/login", (req, res) => {
if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy')
// Autentizace pomocí trusted headers
if (HTTP_REMOTE_USER_ENABLED) {
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
//const remoteName = req.header('remote-name');
if (remoteUser && remoteUser.length > 0) {
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
} else {
throw new Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
}
} else {
// Klasická autentizace loginem
if (!req.body?.login || req.body.login.trim().length === 0) {
throw new Error("Nebyl předán login");
}
// TODO zavést podmínky pro délku loginu (min i max)
res.status(200).json(generateToken(req.body.login, false));
}
});
@@ -111,12 +160,10 @@ app.get("/api/qr", async (req, res) => {
res.end(img);
});
// ----------------------------------------------------
// ─── Semi-public routes ───────────────────────────────────────────────────────
// Přeskočení auth pro refresh dat xd
app.use("/api/food/refresh", refreshMetoda);
// Rychlá akce z push notifikace — autentizace pomocí HMAC tokenu z push payloadu (SW nemá přístup k JWT)
app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
try {
const { login, token } = req.body ?? {};
@@ -132,10 +179,10 @@ app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
} catch (e: any) { next(e); }
});
/** Middleware ověřující JWT token */
// ─── Auth middleware ──────────────────────────────────────────────────────────
app.use("/api/", (req, res, next) => {
if (HTTP_REMOTE_USER_ENABLED) {
// Autentizace pomocí trusted headers
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
delete req.headers["cookie"]
@@ -158,7 +205,8 @@ app.use("/api/", (req, res, next) => {
next();
});
/** Vrátí data pro aktuální den. */
// ─── Authenticated routes ─────────────────────────────────────────────────────
app.get("/api/data", async (req, res) => {
let date = undefined;
if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') {
@@ -167,7 +215,6 @@ app.get("/api/data", async (req, res) => {
date = getDateForWeekIndex(parseInt(req.query.dayIndex));
}
} else if (getIsWeekend(getToday())) {
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
date = getDateForWeekIndex(4);
}
const slotParam = typeof req.query.slot === 'string' ? req.query.slot as MealSlot : undefined;
@@ -175,7 +222,6 @@ app.get("/api/data", async (req, res) => {
return res.status(400).json({ error: 'Neplatný slot' });
}
const data = await getData(date, slotParam);
// Připojíme nevyřízené QR kódy pro přihlášeného uživatele
try {
const login = getLogin(parseToken(req));
const pendingQrs = await getPendingQrs(login);
@@ -188,7 +234,6 @@ app.get("/api/data", async (req, res) => {
res.status(200).json(data);
});
// Ostatní routes
app.use("/api/pizzaDay", pizzaDayRoutes);
app.use("/api/food", foodRoutes);
app.use("/api/voting", votingRoutes);
@@ -206,7 +251,7 @@ app.get('*splat', (_req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
});
// Middleware pro zpracování chyb
// Error handling middleware
app.use((err: any, req: any, res: any, next: any) => {
if (err instanceof InsufficientPermissions) {
res.status(403).send({ error: err.message })
@@ -218,18 +263,18 @@ app.use((err: any, req: any, res: any, next: any) => {
next();
});
// ─── Bootstrap ────────────────────────────────────────────────────────────────
const PORT = process.env.PORT ?? 3001;
const HOST = process.env.HOST ?? '0.0.0.0';
storageReady.then(() => {
storageReady.then(async () => {
// Init Redis adapter after storage is connected (only in Redis mode)
if (process.env.STORAGE?.toLowerCase() === 'redis') {
await initRedisAdapter();
}
server.listen(PORT, () => {
console.log(`Server listening on ${HOST}, port ${PORT}`);
startReminderScheduler();
});
});
// Umožníme vypnutí serveru přes SIGINT, jinak Docker čeká než ho sestřelí
process.on('SIGINT', function () {
console.log("\nSIGINT (Ctrl-C), vypínám server");
process.exit(0);
});
+165 -350
View File
@@ -10,10 +10,6 @@ import crypto from "crypto";
const storage = getStorage();
const PENDING_QR_PREFIX = 'pending_qr';
/**
* Vrátí seznam dostupných pizz pro dnešní den.
* Stáhne je, pokud je pro dnešní den nemá.
*/
export async function getPizzaList(): Promise<Pizza[] | undefined> {
await initIfNeeded();
let clientData = await getClientData(getToday());
@@ -24,25 +20,17 @@ export async function getPizzaList(): Promise<Pizza[] | undefined> {
return Promise.resolve(clientData.pizzaList);
}
/**
* Uloží seznam dostupných pizz pro dnešní den.
*
* @param pizzaList seznam dostupných pizz
*/
export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
await initIfNeeded();
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
clientData.pizzaList = pizzaList;
clientData.pizzaListLastUpdate = formatDate(new Date());
await storage.setData(today, clientData);
return clientData;
return storage.updateData<ClientData>(today, (current) => {
const data = current ?? ({} as ClientData);
data.pizzaList = pizzaList;
data.pizzaListLastUpdate = formatDate(new Date());
return data;
});
}
/**
* Vrátí seznam dostupných salátů pro dnešní den.
* Stáhne je, pokud je pro dnešní den nemá.
*/
export async function getSalatList(): Promise<Salat[] | undefined> {
await initIfNeeded();
let clientData = await getClientData(getToday());
@@ -53,423 +41,250 @@ export async function getSalatList(): Promise<Salat[] | undefined> {
return Promise.resolve(clientData.salatList);
}
/**
* Uloží seznam dostupných salátů pro dnešní den.
*
* @param salatList seznam dostupných salátů
*/
export async function saveSalatList(salatList: Salat[]): Promise<ClientData> {
await initIfNeeded();
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
clientData.salatList = salatList;
await storage.setData(today, clientData);
return clientData;
return storage.updateData<ClientData>(today, (current) => {
const data = current ?? ({} as ClientData);
data.salatList = salatList;
return data;
});
}
/**
* Vytvoří pizza day pro aktuální den a vrátí data pro klienta.
*/
export async function createPizzaDay(creator: string): Promise<ClientData> {
await initIfNeeded();
const clientData = await getClientData(getToday());
if (clientData.pizzaDay) {
throw new Error("Pizza day pro dnešní den již existuje");
}
// TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě!
// Stáhneme pizzy a saláty před samotnou atomickou operací
const [pizzaList, salatList] = await Promise.all([getPizzaList(), getSalatList()]);
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, salatList, ...clientData };
const today = formatDate(getToday());
await storage.setData(today, data);
callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } })
return data;
const result = await storage.updateData<ClientData>(today, (current) => {
if (!current) throw Error("Data pro dnešní den nejsou inicializována");
if (current.pizzaDay) throw Error("Pizza day pro dnešní den již existuje");
return { ...current, pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, salatList };
});
callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } });
return result;
}
/**
* Smaže pizza day pro aktuální den.
*/
export async function deletePizzaDay(login: string): Promise<ClientData> {
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw new Error("Login uživatele se neshoduje se zakladatelem Pizza Day");
}
delete clientData.pizzaDay;
const today = formatDate(getToday());
await storage.setData(today, clientData);
return clientData;
return storage.updateData<ClientData>(today, (current) => {
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
if (current.pizzaDay.creator !== login) throw Error("Login uživatele se neshoduje se zakladatelem Pizza Day");
const data = { ...current };
delete data.pizzaDay;
return data;
});
}
/**
* Přidá objednávku pizzy uživateli.
*
* @param login login uživatele
* @param pizza zvolená pizza
* @param size zvolená velikost pizzy
*/
export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešden neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
}
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
if (!order) {
order = {
customer: login,
pizzaList: [],
totalPrice: 0,
hasQr: false,
return storage.updateData<ClientData>(today, (current) => {
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
let order: PizzaOrder | undefined = current.pizzaDay.orders?.find(o => o.customer === login);
if (!order) {
order = { customer: login, pizzaList: [], totalPrice: 0, hasQr: false };
current.pizzaDay.orders ??= [];
current.pizzaDay.orders.push(order);
}
clientData.pizzaDay.orders ??= [];
clientData.pizzaDay.orders.push(order);
}
const pizzaOrder: PizzaVariant = {
varId: size.varId,
name: pizza.name,
size: size.size,
price: size.price,
}
order.pizzaList ??= [];
order.pizzaList.push(pizzaOrder);
order.totalPrice += pizzaOrder.price;
await storage.setData(today, clientData);
return clientData;
const pizzaOrder: PizzaVariant = { varId: size.varId, name: pizza.name, size: size.size, price: size.price };
order.pizzaList ??= [];
order.pizzaList.push(pizzaOrder);
order.totalPrice += pizzaOrder.price;
return current;
});
}
/**
* Přidá objednávku salátu uživateli.
*
* @param login login uživatele
* @param salat zvolený salát
*/
export async function addSalatOrder(login: string, salat: Salat) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešden neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
}
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
if (!order) {
order = {
customer: login,
pizzaList: [],
totalPrice: 0,
hasQr: false,
return storage.updateData<ClientData>(today, (current) => {
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
let order: PizzaOrder | undefined = current.pizzaDay.orders?.find(o => o.customer === login);
if (!order) {
order = { customer: login, pizzaList: [], totalPrice: 0, hasQr: false };
current.pizzaDay.orders ??= [];
current.pizzaDay.orders.push(order);
}
clientData.pizzaDay.orders ??= [];
clientData.pizzaDay.orders.push(order);
}
const salatOrder: PizzaVariant = {
varId: 0,
name: salat.name,
size: "1 porce",
price: salat.price,
category: 'salat',
}
order.pizzaList ??= [];
order.pizzaList.push(salatOrder);
order.totalPrice += salatOrder.price;
await storage.setData(today, clientData);
return clientData;
const salatOrder: PizzaVariant = { varId: 0, name: salat.name, size: "1 porce", price: salat.price, category: 'salat' };
order.pizzaList ??= [];
order.pizzaList.push(salatOrder);
order.totalPrice += salatOrder.price;
return current;
});
}
/**
* Odstraní všechny pizzy uživatele (celou jeho objednávku).
* Pokud Pizza day neexistuje nebo není ve stavu CREATED, neudělá nic.
*
* @param login login uživatele
* @param date datum, pro které se objednávka odstraňuje (výchozí je dnešek)
* @returns aktuální data pro klienta
*/
export async function removeAllUserPizzas(login: string, date?: Date) {
const usedDate = date ?? getToday();
const today = formatDate(usedDate);
const clientData = await getClientData(usedDate);
if (!clientData.pizzaDay) {
return clientData; // Pizza day neexistuje, není co mazat
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
return clientData; // Pizza day není ve stavu CREATED, nelze mazat
}
const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login);
if (orderIndex >= 0) {
clientData.pizzaDay.orders!.splice(orderIndex, 1);
await storage.setData(today, clientData);
}
return clientData;
return storage.updateData<ClientData>(today, (current) => {
if (!current?.pizzaDay) return current ?? ({} as ClientData);
if (current.pizzaDay.state !== PizzaDayState.CREATED) return current;
const orderIndex = current.pizzaDay.orders!.findIndex(o => o.customer === login);
if (orderIndex >= 0) current.pizzaDay.orders!.splice(orderIndex, 1);
return current;
});
}
/**
* Odstraní danou objednávku pizzy.
*
* @param login login uživatele
* @param pizzaOrder objednávka pizzy
*/
export async function removePizzaOrder(login: string, pizzaOrder: PizzaVariant) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešní den neexistuje");
}
const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login);
if (orderIndex < 0) {
throw new Error("Nebyly nalezeny žádné objednávky pro uživatele " + login);
}
const order = clientData.pizzaDay.orders![orderIndex];
const index = order.pizzaList!.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size);
if (index < 0) {
throw new Error("Objednávka s danými parametry nebyla nalezena");
}
const price = order.pizzaList![index].price;
order.pizzaList!.splice(index, 1);
order.totalPrice -= price;
if (order.pizzaList!.length == 0) {
clientData.pizzaDay.orders!.splice(orderIndex, 1);
}
await storage.setData(today, clientData);
return clientData;
return storage.updateData<ClientData>(today, (current) => {
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
const orderIndex = current.pizzaDay.orders!.findIndex(o => o.customer === login);
if (orderIndex < 0) throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login);
const order = current.pizzaDay.orders![orderIndex];
const index = order.pizzaList!.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size);
if (index < 0) throw Error("Objednávka s danými parametry nebyla nalezena");
const price = order.pizzaList![index].price;
order.pizzaList!.splice(index, 1);
order.totalPrice -= price;
if (order.pizzaList!.length === 0) current.pizzaDay.orders!.splice(orderIndex, 1);
return current;
});
}
/**
* Uzamkne možnost editovat objednávky pizzy.
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export async function lockPizzaDay(login: string) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešden neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw new Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED && clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED + " nebo " + PizzaDayState.ORDERED);
}
clientData.pizzaDay.state = PizzaDayState.LOCKED;
await storage.setData(today, clientData);
return clientData;
return storage.updateData<ClientData>(today, (current) => {
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
if (current.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login);
if (current.pizzaDay.state !== PizzaDayState.CREATED && current.pizzaDay.state !== PizzaDayState.ORDERED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED + " nebo " + PizzaDayState.ORDERED);
}
current.pizzaDay.state = PizzaDayState.LOCKED;
return current;
});
}
/**
* Odekmne možnost editovat objednávky pizzy.
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export async function unlockPizzaDay(login: string) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešden neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw new Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
throw new Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
}
clientData.pizzaDay.state = PizzaDayState.CREATED;
await storage.setData(today, clientData);
return clientData;
return storage.updateData<ClientData>(today, (current) => {
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
if (current.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login);
if (current.pizzaDay.state !== PizzaDayState.LOCKED) throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
current.pizzaDay.state = PizzaDayState.CREATED;
return current;
});
}
/**
* Nastaví stav pizza day na "pizzy objednány".
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export async function finishPizzaOrder(login: string) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešden neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw new Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
throw new Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
}
clientData.pizzaDay.state = PizzaDayState.ORDERED;
await storage.setData(today, clientData);
callNotifikace({ input: { udalost: UdalostEnum.OBJEDNANA_PIZZA, user: clientData?.pizzaDay?.creator } })
return clientData;
const result = await storage.updateData<ClientData>(today, (current) => {
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
if (current.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login);
if (current.pizzaDay.state !== PizzaDayState.LOCKED) throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
current.pizzaDay.state = PizzaDayState.ORDERED;
return current;
});
callNotifikace({ input: { udalost: UdalostEnum.OBJEDNANA_PIZZA, user: result.pizzaDay!.creator! } });
return result;
}
/**
* Nastaví stav pizza day na "pizzy doručeny".
* Vygeneruje QR kódy pro všechny objednatele, pokud objednávající má vyplněné číslo účtu a jméno.
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export async function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) {
const today = formatDate(getToday());
// Načteme aktuální data pro přípravu QR (potřebujeme objednávky)
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešden neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw new Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
throw new Error("Pizza day není ve stavu " + PizzaDayState.ORDERED);
}
clientData.pizzaDay.state = PizzaDayState.DELIVERED;
if (!clientData.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
if (clientData.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login);
if (clientData.pizzaDay.state !== PizzaDayState.ORDERED) throw Error("Pizza day není ve stavu " + PizzaDayState.ORDERED);
// Vygenerujeme QR kód, pokud k tomu máme data
// Generujeme QR kódy před atomickým zápisem
const pendingQrs: Array<{ customer: string; id: string; pendingQr: PendingQr }> = [];
if (bankAccount?.length && bankAccountHolder?.length) {
for (const order of clientData.pizzaDay.orders!) {
if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
if (order.customer !== login) {
const id = crypto.randomUUID();
let message = order.pizzaList!.map(item =>
const message = order.pizzaList!.map(item =>
item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})`
).join(', ');
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice / 100, message, id);
order.hasQr = true;
// Uložíme nevyřízený QR kód pro persistentní zobrazení
await addPendingQr(order.customer, {
id,
date: today,
creator: login,
totalPrice: order.totalPrice,
purpose: message,
pendingQrs.push({
customer: order.customer, id, pendingQr: {
id, date: today, creator: login, totalPrice: order.totalPrice, purpose: message,
},
});
}
}
}
await storage.setData(today, clientData);
return clientData;
const result = await storage.updateData<ClientData>(today, (current) => {
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
current.pizzaDay.state = PizzaDayState.DELIVERED;
for (const { customer } of pendingQrs) {
const order = current.pizzaDay.orders!.find(o => o.customer === customer);
if (order) { order.hasQr = true; }
}
return current;
});
// Uložení nevyřízených QR kódů mimo hlavní transakci (per-user klíče)
for (const { customer, pendingQr } of pendingQrs) {
await addPendingQr(customer, pendingQr);
}
return result;
}
/**
* Aktualizuje poznámku k Pizza day uživatele.
*
* @param login přihlašovací jméno uživatele
* @param note nová poznámka k Pizza day
* @returns aktuální klientská data
*/
export async function updatePizzaDayNote(login: string, note?: string) {
const today = formatDate(getToday());
let clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešden neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
}
const myOrder = clientData.pizzaDay.orders!.find(o => o.customer === login);
if (!myOrder?.pizzaList?.length) {
throw new Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
}
myOrder.note = note;
await storage.setData(today, clientData);
return clientData;
return storage.updateData<ClientData>(today, (current) => {
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
const myOrder = current.pizzaDay.orders!.find(o => o.customer === login);
if (!myOrder?.pizzaList?.length) throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
myOrder.note = note;
return current;
});
}
/**
* Aktualizuje příplatek uživatele k objednávce pizzy.
* V případě nevyplnění ceny je příplatek odebrán.
*
* @param login přihlašovací jméno aktuálního uživatele
* @param targetLogin přihlašovací jméno uživatele, kterému je nastavován příplatek
* @param text text popisující příplatek
* @param price celková cena příplatku
*/
export async function updatePizzaFee(login: string, targetLogin: string, text?: string, price?: number) {
const today = formatDate(getToday());
let clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw new Error("Pizza day pro dnešden neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw new Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`);
}
if (clientData.pizzaDay.creator !== login) {
throw new Error("Příplatky může měnit pouze zakladatel Pizza day");
}
const targetOrder = clientData.pizzaDay.orders!.find(o => o.customer === targetLogin);
if (!targetOrder?.pizzaList?.length) {
throw new Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
}
if (!price) {
delete targetOrder.fee;
} else {
targetOrder.fee = { text, price };
}
// Přepočet ceny
targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price ?? 0);
await storage.setData(today, clientData);
return clientData;
return storage.updateData<ClientData>(today, (current) => {
if (!current?.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
if (current.pizzaDay.state !== PizzaDayState.CREATED) throw Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`);
if (current.pizzaDay.creator !== login) throw Error("Příplatky může měnit pouze zakladatel Pizza day");
const targetOrder = current.pizzaDay.orders!.find(o => o.customer === targetLogin);
if (!targetOrder?.pizzaList?.length) throw Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
if (!price) {
delete targetOrder.fee;
} else {
targetOrder.fee = { text, price };
}
targetOrder.totalPrice = targetOrder.pizzaList.reduce((p, o) => p + o.price, 0) + (targetOrder.fee?.price ?? 0);
return current;
});
}
/**
* Vrátí klíč pro uložení nevyřízených QR kódů uživatele.
*/
function getPendingQrKey(login: string): string {
return `${PENDING_QR_PREFIX}_${login}`;
}
/**
* Přidá nevyřízený QR kód pro uživatele.
*/
export async function addPendingQr(login: string, pendingQr: PendingQr): Promise<void> {
const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? [];
// Deduplikace podle id (ne podle data — jeden den může mít uživatel víc QR kódů)
if (!existing.some(qr => qr.id === pendingQr.id)) {
existing.push(pendingQr);
await storage.setData(key, existing);
}
await storage.updateData<PendingQr[]>(getPendingQrKey(login), (current) => {
const existing = current ?? [];
if (!existing.some(qr => qr.id === pendingQr.id)) existing.push(pendingQr);
return existing;
});
}
/**
* Vrátí nevyřízené QR kódy pro uživatele.
*/
export async function getPendingQrs(login: string): Promise<PendingQr[]> {
return await storage.getData<PendingQr[]>(getPendingQrKey(login)) ?? [];
}
/**
* Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených).
* Vrátí odstraněný QR kód, pokud byl nalezen.
*/
export async function dismissPendingQr(login: string, id: string): Promise<PendingQr | undefined> {
const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? [];
const dismissed = existing.find(qr => qr.id === id);
const filtered = existing.filter(qr => qr.id !== id);
await storage.setData(key, filtered);
let dismissed: PendingQr | undefined;
await storage.updateData<PendingQr[]>(getPendingQrKey(login), (current) => {
const existing = current ?? [];
dismissed = existing.find(qr => qr.id === id);
return existing.filter(qr => qr.id !== id);
});
return dismissed;
}
/**
* Odstraní všechny nevyřízené QR kódy patřící dané skupině objednávky.
*/
export async function removePendingQrsByGroupId(logins: string[], groupId: string): Promise<void> {
for (const login of logins) {
const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? [];
const filtered = existing.filter(qr => qr.groupId !== groupId);
if (filtered.length !== existing.length) {
await storage.setData(key, filtered);
}
await storage.updateData<PendingQr[]>(getPendingQrKey(login), (current) => {
return (current ?? []).filter(qr => qr.groupId !== groupId);
});
}
}
}
+89 -39
View File
@@ -1,12 +1,17 @@
import webpush from 'web-push';
import crypto from 'crypto';
import getStorage from './storage';
import { getRedisClient } from './storage/redis';
import { getClientData, getToday } from './service';
import { getIsWeekend } from './utils';
import { LunchChoices } from '../../types';
const storage = getStorage();
const REGISTRY_KEY = 'push_reminder_registry';
const LEADER_LEASE_KEY = 'luncher:reminder:leader';
const LEASE_TTL_SECONDS = 90;
const POD_ID = process.env.POD_ID ?? `local-${process.pid}`;
interface RegistryEntry {
time: string;
@@ -20,6 +25,8 @@ const lastReminded = new Map<string, number>();
const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami
let reminderInterval: ReturnType<typeof setInterval> | undefined;
function getCurrentTimeHHMM(): string {
const now = new Date();
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
@@ -36,27 +43,76 @@ function userHasChoice(choices: LunchChoices, login: string): boolean {
return false;
}
async function getRegistry(): Promise<Registry> {
return await storage.getData<Registry>(REGISTRY_KEY) ?? {};
/**
* Pokusí se získat nebo obnovit leader lease pro scheduler připomínek.
* Vrátí true pokud tato instance smí spustit připomínky.
* Při non-Redis storage vždy vrací true (single-process, leader election není potřeba).
*/
async function tryAcquireOrRenewLease(): Promise<boolean> {
if (process.env.STORAGE?.toLowerCase() !== 'redis') return true;
try {
const c = getRedisClient();
if (!c) return true;
// Zkusíme získat lease atomicky (SET NX EX)
const acquired = await c.set(LEADER_LEASE_KEY, POD_ID, { NX: true, EX: LEASE_TTL_SECONDS });
if (acquired !== null) return true; // lease čerstvě získána
// Pokud jsme ji nedostali, ověříme zda ji držíme my
const currentHolder = await c.get(LEADER_LEASE_KEY);
if (currentHolder === POD_ID) {
// Naše lease — obnovíme TTL
await c.set(LEADER_LEASE_KEY, POD_ID, { EX: LEASE_TTL_SECONDS });
return true;
}
return false; // lease drží jiná instance
} catch (e) {
console.error('Push reminder: chyba při získávání lease, připomínky budou odeslány', e);
return true; // při chybě raději spustíme, než vynecháme
}
}
async function saveRegistry(registry: Registry): Promise<void> {
await storage.setData(REGISTRY_KEY, registry);
/** Uvolní leader lease při graceful shutdown. */
export async function releaseReminderLease(): Promise<void> {
if (process.env.STORAGE?.toLowerCase() !== 'redis') return;
try {
const c = getRedisClient();
if (!c) return;
const currentHolder = await c.get(LEADER_LEASE_KEY);
if (currentHolder === POD_ID) {
await c.del(LEADER_LEASE_KEY);
console.log('Push reminder: lease uvolněna');
}
} catch (e) {
console.error('Push reminder: chyba při uvolňování lease', e);
}
}
/** Stopne scheduler připomínek. Volá se při graceful shutdown. */
export function stopReminderScheduler(): void {
if (reminderInterval) {
clearInterval(reminderInterval);
reminderInterval = undefined;
}
}
/** Přidá nebo aktualizuje push subscription pro uživatele. */
export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime: string): Promise<void> {
const registry = await getRegistry();
registry[login] = { time: reminderTime, subscription };
await saveRegistry(registry);
await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
const registry = current ?? {};
registry[login] = { time: reminderTime, subscription };
return registry;
});
console.log(`Push reminder: uživatel ${login} přihlášen k připomínkám v ${reminderTime}`);
}
/** Odebere push subscription pro uživatele. */
export async function unsubscribePush(login: string): Promise<void> {
const registry = await getRegistry();
delete registry[login];
await saveRegistry(registry);
await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
const registry = current ?? {};
delete registry[login];
return registry;
});
lastReminded.delete(login);
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
}
@@ -79,23 +135,20 @@ export function verifyQuickChoiceToken(login: string, token: string): boolean {
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex'));
}
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
async function checkAndSendReminders(): Promise<void> {
// Přeskočit víkendy
if (getIsWeekend(getToday())) {
return;
}
if (getIsWeekend(getToday())) return;
const registry = await getRegistry();
// Leader election — pouze jeden pod spouští připomínky
const isLeader = await tryAcquireOrRenewLease();
if (!isLeader) return;
const registry = await storage.getData<Registry>(REGISTRY_KEY) ?? {};
const entries = Object.entries(registry);
if (entries.length === 0) {
return;
}
if (entries.length === 0) return;
const currentTime = getCurrentTimeHHMM();
// Získáme data pro dnešek jednou pro všechny uživatele
let clientData;
try {
clientData = await getClientData(getToday());
@@ -104,24 +157,16 @@ async function checkAndSendReminders(): Promise<void> {
return;
}
const expiredLogins: string[] = [];
for (const [login, entry] of entries) {
// Ještě nedosáhl čas připomínky
if (currentTime < entry.time) {
continue;
}
if (currentTime < entry.time) continue;
// Cooldown — nepřipomínat častěji než jednou za hodinu
const last = lastReminded.get(login) ?? 0;
if (Date.now() - last < REMINDER_COOLDOWN_MS) {
continue;
}
if (Date.now() - last < REMINDER_COOLDOWN_MS) continue;
// Uživatel už má zvolenou možnost
if (clientData.choices && userHasChoice(clientData.choices, login)) {
continue;
}
if (clientData.choices && userHasChoice(clientData.choices, login)) continue;
// Odešleme push notifikaci
try {
await webpush.sendNotification(
entry.subscription,
@@ -136,15 +181,21 @@ async function checkAndSendReminders(): Promise<void> {
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
} catch (error: any) {
if (error.statusCode === 410 || error.statusCode === 404) {
// Subscription expirovala nebo je neplatná — odebereme z registry
console.log(`Push reminder: subscription uživatele ${login} expirovala, odebírám`);
delete registry[login];
await saveRegistry(registry);
expiredLogins.push(login);
} else {
console.error(`Push reminder: chyba při odesílání notifikace uživateli ${login}:`, error);
}
}
}
if (expiredLogins.length > 0) {
await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
const r = current ?? {};
for (const login of expiredLogins) delete r[login];
return r;
});
}
}
/** Spustí scheduler pro kontrolu a odesílání připomínek každou minutu. */
@@ -160,7 +211,6 @@ export function startReminderScheduler(): void {
webpush.setVapidDetails(subject, publicKey, privateKey);
// Spustíme kontrolu každou minutu
setInterval(checkAndSendReminders, 60_000);
console.log('Push reminder: scheduler spuštěn');
reminderInterval = setInterval(checkAndSendReminders, 60_000);
console.log(`Push reminder: scheduler spuštěn (POD_ID=${POD_ID})`);
}
+46 -54
View File
@@ -319,18 +319,17 @@ export async function initIfNeeded(date?: Date, slot?: MealSlot) {
*/
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date, slot?: MealSlot) {
const selectedDay = getDataKey(date ?? getToday(), slot);
let data = await getClientData(date, slot);
validateTrusted(data, login, trusted);
if (locationKey in data.choices) {
if (data.choices[locationKey] && login in data.choices[locationKey]) {
delete data.choices[locationKey][login]
if (Object.keys(data.choices[locationKey]).length === 0) {
delete data.choices[locationKey]
}
await storage.setData(selectedDay, data);
// Validate trusted flag against current data before atomic update
const snapshot = await getClientData(date, slot);
validateTrusted(snapshot, login, trusted);
return storage.updateData<ClientData>(selectedDay, (current) => {
const data = current ?? getEmptyData(date);
if (locationKey in data.choices && data.choices[locationKey] && login in data.choices[locationKey]) {
delete data.choices[locationKey][login];
if (Object.keys(data.choices[locationKey]).length === 0) delete data.choices[locationKey];
}
}
return data;
return data;
});
}
/**
@@ -346,18 +345,16 @@ export async function removeChoices(login: string, trusted: boolean, locationKey
*/
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date, slot?: MealSlot) {
const selectedDay = getDataKey(date ?? getToday(), slot);
let data = await getClientData(date, slot);
validateTrusted(data, login, trusted);
if (locationKey in data.choices) {
if (data.choices[locationKey] && login in data.choices[locationKey]) {
const snapshot = await getClientData(date, slot);
validateTrusted(snapshot, login, trusted);
return storage.updateData<ClientData>(selectedDay, (current) => {
const data = current ?? getEmptyData(date);
if (locationKey in data.choices && data.choices[locationKey] && login in data.choices[locationKey]) {
const index = data.choices[locationKey][login].selectedFoods?.indexOf(foodIndex);
if (index != null && index > -1) {
data.choices[locationKey][login].selectedFoods?.splice(index, 1);
await storage.setData(selectedDay, data);
}
if (index != null && index > -1) data.choices[locationKey][login].selectedFoods?.splice(index, 1);
}
}
return data;
return data;
});
}
/**
@@ -512,18 +509,17 @@ async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, d
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date, slot?: MealSlot) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate, slot);
let data = await getClientData(usedDate, slot);
validateTrusted(data, login, trusted);
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
if (userEntry) {
if (!note?.length) {
delete userEntry[1][login].note;
} else {
userEntry[1][login].note = note;
const snapshot = await getClientData(usedDate, slot);
validateTrusted(snapshot, login, trusted);
return storage.updateData<ClientData>(getDataKey(usedDate, slot), (current) => {
const data = current ?? getEmptyData(date);
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
if (userEntry) {
if (!note?.length) delete userEntry[1][login].note;
else userEntry[1][login].note = note;
}
await storage.setData(getDataKey(usedDate, slot), data);
}
return data;
return data;
});
}
/**
@@ -535,21 +531,18 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
*/
export async function updateDepartureTime(login: string, time?: string, date?: Date) {
const usedDate = date ?? getToday();
let clientData = await getClientData(usedDate);
const found = Object.values(clientData.choices).find(location => login in location);
// TODO validace, že se jedná o restauraci
if (found) {
if (!time?.length) {
delete found[login].departureTime;
} else {
if (!Object.values<string>(DepartureTime).includes(time)) {
throw new Error(`Neplatný čas odchodu ${time}`);
}
found[login].departureTime = time;
}
await storage.setData(getDataKey(usedDate), clientData);
if (time?.length && !Object.values<string>(DepartureTime).includes(time)) {
throw Error(`Neplatný čas odchodu ${time}`);
}
return clientData;
return storage.updateData<ClientData>(getDataKey(usedDate), (current) => {
const data = current ?? getEmptyData(date);
const found = Object.values(data.choices).find(location => login in location);
if (found) {
if (!time?.length) delete found[login].departureTime;
else found[login].departureTime = time;
}
return data;
});
}
/**
@@ -560,14 +553,13 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
*/
export async function updateBuyer(login: string, slot?: MealSlot) {
const usedDate = getToday();
let clientData = await getClientData(usedDate, slot);
const userEntry = clientData.choices?.['OBJEDNAVAM']?.[login];
if (!userEntry) {
throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
}
userEntry.isBuyer = !(userEntry.isBuyer || false);
await storage.setData(getDataKey(usedDate, slot), clientData);
return clientData;
return storage.updateData<ClientData>(getDataKey(usedDate, slot), (current) => {
const data = current ?? getEmptyData();
const userEntry = data.choices?.['OBJEDNAVAM']?.[login];
if (!userEntry) throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
userEntry.isBuyer = !(userEntry.isBuyer || false);
return data;
});
}
/**
+11 -20
View File
@@ -1,32 +1,23 @@
/**
* Interface pro úložiště dat.
*
* Aktuálně pouze "primitivní" has, get a set odrážející původní JSON DB.
* Postupem času lze předělat pro efektivnější využití Redis.
*/
export interface StorageInterface {
/**
* Inicializuje úložiště, pokud je potřeba (např. připojení k databázi).
*/
initialize?(): Promise<void>;
/**
* Vrátí příznak, zda existují data pro předaný klíč.
* @param key klíč, pro který zjišťujeme data (typicky datum)
*/
hasData(key: string): Promise<boolean>;
/**
* Vrátí veškerá data pro předaný klíč.
* @param key klíč, pro který vrátit data (typicky datum)
*/
getData<Type>(key: string): Promise<Type | undefined>;
/**
* Uloží data pod předaný klíč.
* @param key klíč, pod kterým uložit data (typicky datum)
* @param data data pro uložení
*/
setData<Type>(key: string, data: Type): Promise<void>;
}
/**
* Atomicky načte, zmutuje a uloží data pod daným klíčem.
* V Redis implementaci používá WATCH/MULTI/EXEC retry loop.
* Vrátí výslednou hodnotu po aplikaci mutátoru.
*/
updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type>;
/** Ověří dostupnost úložiště. Vrátí false pokud není dostupné. */
healthCheck?(): Promise<boolean>;
}
+12 -2
View File
@@ -6,7 +6,6 @@ import * as path from 'path';
const dbPath = path.resolve(__dirname, '../../data/db.json');
const dbDir = path.dirname(dbPath);
// Zajistěte, že adresář existuje
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
@@ -29,4 +28,15 @@ export default class JsonStorage implements StorageInterface {
db.set(key, data);
return Promise.resolve();
}
}
updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
const current = db.get(key) as Type | undefined;
const next = mutator(current);
db.set(key, next);
return Promise.resolve(next);
}
healthCheck(): Promise<boolean> {
return Promise.resolve(true);
}
}
+11
View File
@@ -24,4 +24,15 @@ export default class MemoryStorage implements StorageInterface {
store.set(key, data);
return Promise.resolve();
}
updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
const current = store.get(key) as Type | undefined;
const next = mutator(current);
store.set(key, next);
return Promise.resolve(next);
}
healthCheck(): Promise<boolean> {
return Promise.resolve(true);
}
}
+36 -3
View File
@@ -10,7 +10,7 @@ export default class RedisStorage implements StorageInterface {
constructor() {
const HOST = process.env.REDIS_HOST ?? 'localhost';
const PORT = process.env.REDIS_PORT ?? 6379;
client = createClient({ url: `redis://${HOST}:${PORT}` });
client = createClient({ url: `redis://${HOST}:${PORT}` }) as RedisClientType;
}
async initialize() {
@@ -29,6 +29,39 @@ export default class RedisStorage implements StorageInterface {
async setData<Type>(key: string, data: Type) {
await client.json.set(key, '.', data as any);
await client.json.get(key);
}
}
async updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
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();
}
+14 -42
View File
@@ -1,4 +1,4 @@
import { FeatureRequest, VotingStats } from "../../types/gen/types.gen";
import { FeatureRequest } from "../../types/gen/types.gen";
import getStorage from "./storage";
interface VotingData {
@@ -12,56 +12,28 @@ export interface VotingStatsResult {
const storage = getStorage();
const STORAGE_KEY = 'voting';
/**
* Vrátí pole voleb, pro které uživatel aktuálně hlasuje.
*
* @param login login uživatele
* @returns pole voleb
*/
export async function getUserVotes(login: string) {
const data = await storage.getData<VotingData>(STORAGE_KEY);
return data?.[login] || [];
}
/**
* Aktualizuje hlas uživatele pro konkrétní volbu.
*
* @param login login uživatele
* @param option volba
* @param active příznak, zda volbu přidat nebo odebrat
* @returns aktuální data
*/
export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> {
let data = await storage.getData<VotingData>(STORAGE_KEY);
data ??= {};
if (!(login in data)) {
data[login] = [];
}
const index = data[login].indexOf(option);
if (index > -1) {
if (active) {
throw new Error('Pro tuto možnost jste již hlasovali');
} else {
return storage.updateData<VotingData>(STORAGE_KEY, (current) => {
const data = current ?? {};
if (!(login in data)) data[login] = [];
const index = data[login].indexOf(option);
if (index > -1) {
if (active) throw Error('Pro tuto možnost jste již hlasovali');
data[login].splice(index, 1);
if (data[login].length === 0) {
delete data[login];
}
if (data[login].length === 0) delete data[login];
} else if (active) {
if (data[login].length === 4) throw Error('Je možné hlasovat pro maximálně 4 možnosti');
data[login].push(option);
}
} else if (active) {
if (data[login].length == 4) {
throw new Error('Je možné hlasovat pro maximálně 4 možnosti');
}
data[login].push(option);
}
await storage.setData(STORAGE_KEY, data);
return data;
return data;
});
}
/**
* Vrátí agregované statistiky hlasování - počet hlasů pro každou funkci.
*
* @returns objekt, kde klíčem je název funkce a hodnotou počet hlasů
*/
export async function getVotingStats(): Promise<VotingStatsResult> {
const data = await storage.getData<VotingData>(STORAGE_KEY);
const stats: VotingStatsResult = {};
@@ -73,4 +45,4 @@ export async function getVotingStats(): Promise<VotingStatsResult> {
}
}
return stats;
}
}
+25 -5
View File
@@ -1,12 +1,15 @@
import { DefaultEventsMap, Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
let pubClient: ReturnType<typeof createClient>;
let subClient: ReturnType<typeof createClient>;
export const initWebsocket = (server: any) => {
io = new Server(server, {
cors: {
origin: "*",
},
cors: { origin: "*" },
transports: ["websocket"],
});
io.on("connection", (socket) => {
console.log(`New client connected: ${socket.id}`);
@@ -26,7 +29,24 @@ export const initWebsocket = (server: any) => {
});
});
return io;
}
};
/** Připojí Redis adapter pro cross-pod broadcasting. Volat až po inicializaci Redis klienta. */
export const initRedisAdapter = async () => {
const HOST = process.env.REDIS_HOST ?? 'localhost';
const PORT = process.env.REDIS_PORT ?? 6379;
const url = `redis://${HOST}:${PORT}`;
pubClient = createClient({ url }) as ReturnType<typeof createClient>;
subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient as any, subClient as any));
console.log('Socket.io: Redis adapter connected');
};
/** Zavře pub/sub Redis klienty adaptéru při graceful shutdown. */
export const shutdownWebsocketClients = async () => {
await Promise.allSettled([pubClient?.quit(), subClient?.quit()]);
};
export const getWebsocket = () => io;
@@ -34,4 +54,4 @@ export const getWebsocket = () => io;
export const emitToUser = (login: string, event: string, data: unknown) => {
if (!io) return;
io.to(`user:${login}`).emit(event, data);
}
};
+26
View File
@@ -1521,6 +1521,15 @@
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==
"@socket.io/redis-adapter@^8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@socket.io/redis-adapter/-/redis-adapter-8.3.0.tgz#bdce1e8f34c07df4a8baf98170bf24dc84eaed4a"
integrity sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==
dependencies:
debug "~4.3.1"
notepack.io "~3.0.1"
uid2 "1.0.0"
"@tsconfig/node10@^1.0.7":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"
@@ -2438,6 +2447,13 @@ debug@^4.1.1:
dependencies:
ms "^2.1.3"
debug@~4.3.1:
version "4.3.7"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52"
integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==
dependencies:
ms "^2.1.3"
dedent@^1.6.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.0.tgz#c1f9445335f0175a96587be245a282ff451446ca"
@@ -3844,6 +3860,11 @@ normalize-path@^3.0.0, normalize-path@~3.0.0:
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
notepack.io@~3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/notepack.io/-/notepack.io-3.0.1.tgz#2c2c9de1bd4e64a79d34e33c413081302a0d4019"
integrity sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==
npm-run-path@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
@@ -4571,6 +4592,11 @@ typescript@^5.9.3:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
uid2@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/uid2/-/uid2-1.0.0.tgz#ef8d95a128d7c5c44defa1a3d052eecc17a06bfb"
integrity sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==
undefsafe@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c"