feat: podpora high-availability a multi-replica nasazení

- 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
committed by batmanisko
parent 17132d4124
commit df5423511f
31 changed files with 1252 additions and 510 deletions
+46 -54
View File
@@ -320,18 +320,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;
});
}
/**
@@ -347,18 +346,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;
});
}
/**
@@ -513,18 +510,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;
});
}
/**
@@ -536,21 +532,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;
});
}
/**
@@ -561,14 +554,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;
});
}
/**