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:
@@ -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
@@ -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
@@ -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šní den neexistuje");
|
||||
}
|
||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
||||
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
||||
}
|
||||
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
|
||||
if (!order) {
|
||||
order = {
|
||||
customer: login,
|
||||
pizzaList: [],
|
||||
totalPrice: 0,
|
||||
hasQr: false,
|
||||
return storage.updateData<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šní den neexistuje");
|
||||
}
|
||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
||||
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
||||
}
|
||||
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
|
||||
if (!order) {
|
||||
order = {
|
||||
customer: login,
|
||||
pizzaList: [],
|
||||
totalPrice: 0,
|
||||
hasQr: false,
|
||||
return storage.updateData<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šní den neexistuje");
|
||||
}
|
||||
if (clientData.pizzaDay.creator !== login) {
|
||||
throw new Error("Pizza day není spravován uživatelem " + login);
|
||||
}
|
||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED && clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
|
||||
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED + " nebo " + PizzaDayState.ORDERED);
|
||||
}
|
||||
clientData.pizzaDay.state = PizzaDayState.LOCKED;
|
||||
await storage.setData(today, clientData);
|
||||
return clientData;
|
||||
return storage.updateData<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šní den neexistuje");
|
||||
}
|
||||
if (clientData.pizzaDay.creator !== login) {
|
||||
throw new Error("Pizza day není spravován uživatelem " + login);
|
||||
}
|
||||
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
|
||||
throw new Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
|
||||
}
|
||||
clientData.pizzaDay.state = PizzaDayState.CREATED;
|
||||
await storage.setData(today, clientData);
|
||||
return clientData;
|
||||
return storage.updateData<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šní den neexistuje");
|
||||
}
|
||||
if (clientData.pizzaDay.creator !== login) {
|
||||
throw new Error("Pizza day není spravován uživatelem " + login);
|
||||
}
|
||||
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
|
||||
throw new Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
|
||||
}
|
||||
clientData.pizzaDay.state = PizzaDayState.ORDERED;
|
||||
await storage.setData(today, clientData);
|
||||
callNotifikace({ input: { udalost: UdalostEnum.OBJEDNANA_PIZZA, user: clientData?.pizzaDay?.creator } })
|
||||
return clientData;
|
||||
const result = await storage.updateData<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šní den neexistuje");
|
||||
}
|
||||
if (clientData.pizzaDay.creator !== login) {
|
||||
throw new Error("Pizza day není spravován uživatelem " + login);
|
||||
}
|
||||
if (clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
|
||||
throw new Error("Pizza day není ve stavu " + PizzaDayState.ORDERED);
|
||||
}
|
||||
clientData.pizzaDay.state = PizzaDayState.DELIVERED;
|
||||
if (!clientData.pizzaDay) throw Error("Pizza day pro dnešní den neexistuje");
|
||||
if (clientData.pizzaDay.creator !== login) throw Error("Pizza day není spravován uživatelem " + login);
|
||||
if (clientData.pizzaDay.state !== PizzaDayState.ORDERED) throw Error("Pizza day není ve stavu " + PizzaDayState.ORDERED);
|
||||
|
||||
// Vygenerujeme QR kód, pokud k tomu máme data
|
||||
// Generujeme QR kódy před atomickým zápisem
|
||||
const pendingQrs: Array<{ customer: string; id: string; pendingQr: PendingQr }> = [];
|
||||
if (bankAccount?.length && bankAccountHolder?.length) {
|
||||
for (const order of clientData.pizzaDay.orders!) {
|
||||
if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
|
||||
if (order.customer !== login) {
|
||||
const id = crypto.randomUUID();
|
||||
let message = order.pizzaList!.map(item =>
|
||||
const message = order.pizzaList!.map(item =>
|
||||
item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})`
|
||||
).join(', ');
|
||||
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice / 100, message, id);
|
||||
order.hasQr = true;
|
||||
// Uložíme nevyřízený QR kód pro persistentní zobrazení
|
||||
await addPendingQr(order.customer, {
|
||||
id,
|
||||
date: today,
|
||||
creator: login,
|
||||
totalPrice: order.totalPrice,
|
||||
purpose: message,
|
||||
pendingQrs.push({
|
||||
customer: order.customer, id, pendingQr: {
|
||||
id, date: today, creator: login, totalPrice: order.totalPrice, purpose: message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await storage.setData(today, clientData);
|
||||
return clientData;
|
||||
|
||||
const result = await storage.updateData<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šní den neexistuje");
|
||||
}
|
||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
||||
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
||||
}
|
||||
const myOrder = clientData.pizzaDay.orders!.find(o => o.customer === login);
|
||||
if (!myOrder?.pizzaList?.length) {
|
||||
throw new Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
|
||||
}
|
||||
myOrder.note = note;
|
||||
await storage.setData(today, clientData);
|
||||
return clientData;
|
||||
return storage.updateData<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šní den neexistuje");
|
||||
}
|
||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
||||
throw new Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`);
|
||||
}
|
||||
if (clientData.pizzaDay.creator !== login) {
|
||||
throw new Error("Příplatky může měnit pouze zakladatel Pizza day");
|
||||
}
|
||||
const targetOrder = clientData.pizzaDay.orders!.find(o => o.customer === targetLogin);
|
||||
if (!targetOrder?.pizzaList?.length) {
|
||||
throw new Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
|
||||
}
|
||||
if (!price) {
|
||||
delete targetOrder.fee;
|
||||
} else {
|
||||
targetOrder.fee = { text, price };
|
||||
}
|
||||
// Přepočet ceny
|
||||
targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price ?? 0);
|
||||
await storage.setData(today, clientData);
|
||||
return clientData;
|
||||
return storage.updateData<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
@@ -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
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user