dodelej me z pc

This commit is contained in:
2026-05-06 19:56:38 +02:00
parent b6fdf1de98
commit 7e4736b2ce
19 changed files with 4313 additions and 95 deletions
+5 -1
View File
@@ -142,7 +142,11 @@ app.get("/api/data", async (req, res) => {
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
date = getDateForWeekIndex(4);
}
const data = await getData(date);
const slotParam = typeof req.query.slot === 'string' ? req.query.slot : undefined;
if (slotParam && slotParam !== 'obed' && slotParam !== 'extra') {
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));
+9 -11
View File
@@ -14,13 +14,10 @@ interface RegistryEntry {
type Registry = Record<string, RegistryEntry>;
/** Mapa login → datum (YYYY-MM-DD), kdy byl uživatel naposledy upozorněn. */
const remindedToday = new Map<string, string>();
/** Mapa login → timestamp (ms) posledního odeslání připomínky. */
const lastReminded = new Map<string, number>();
function getTodayDateString(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami
function getCurrentTimeHHMM(): string {
const now = new Date();
@@ -59,7 +56,7 @@ export async function unsubscribePush(login: string): Promise<void> {
const registry = await getRegistry();
delete registry[login];
await saveRegistry(registry);
remindedToday.delete(login);
lastReminded.delete(login);
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
}
@@ -93,7 +90,6 @@ async function checkAndSendReminders(): Promise<void> {
}
const currentTime = getCurrentTimeHHMM();
const todayStr = getTodayDateString();
// Získáme data pro dnešek jednou pro všechny uživatele
let clientData;
@@ -110,8 +106,9 @@ async function checkAndSendReminders(): Promise<void> {
continue;
}
// Už jsme dnes připomenuli
if (remindedToday.get(login) === todayStr) {
// Cooldown — nepřipomínat častěji než jednou za hodinu
const last = lastReminded.get(login) ?? 0;
if (Date.now() - last < REMINDER_COOLDOWN_MS) {
continue;
}
@@ -127,9 +124,10 @@ async function checkAndSendReminders(): Promise<void> {
JSON.stringify({
title: 'Luncher',
body: 'Ještě nemáte zvolený oběd!',
login,
})
);
remindedToday.set(login, todayStr);
lastReminded.set(login, Date.now());
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
} catch (error: any) {
if (error.statusCode === 410 || error.statusCode === 404) {
+18 -5
View File
@@ -69,11 +69,20 @@ const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"]
return dayIndex;
}
const parseSlot = (body: Record<string, any>): string | undefined => {
const slot = body?.slot;
if (slot != null && slot !== 'obed' && slot !== 'extra') {
throw Error(`Neplatný slot: ${slot}`);
}
return slot ?? undefined;
};
const router = express.Router();
router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
const slot = parseSlot(req.body);
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
@@ -85,7 +94,7 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date, slot);
getWebsocket().emit("message", data);
return res.status(200).json(data);
} catch (e: any) { next(e) }
@@ -94,6 +103,7 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
const slot = parseSlot(req.body);
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
@@ -105,7 +115,7 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await removeChoices(login, trusted, req.body.locationKey, date);
const data = await removeChoices(login, trusted, req.body.locationKey, date, slot);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
@@ -114,6 +124,7 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
const slot = parseSlot(req.body);
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
@@ -125,7 +136,7 @@ router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date, slot);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
@@ -135,6 +146,7 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
const note = req.body.note;
const slot = parseSlot(req.body);
try {
if (note && note.length > 70) {
throw Error("Poznámka může mít maximálně 70 znaků");
@@ -149,7 +161,7 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
}
date = getDateForWeekIndex(dayIndex);
}
const data = await updateNote(login, trusted, note, date);
const data = await updateNote(login, trusted, note, date, slot);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
@@ -184,8 +196,9 @@ router.post("/jdemeObed", async (req, res, next) => {
router.post("/updateBuyer", async (req, res, next) => {
const login = getLogin(parseToken(req));
const slot = parseSlot(req.body ?? {});
try {
const data = await updateBuyer(login);
const data = await updateBuyer(login, slot);
getWebsocket().emit("message", data);
res.status(200).json({});
} catch (e: any) { next(e) }
+4 -8
View File
@@ -2,7 +2,7 @@ import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
import { subscribePush, unsubscribePush, getVapidPublicKey, findLoginByEndpoint } from "../pushReminder";
import { subscribePush, unsubscribePush, getVapidPublicKey } from "../pushReminder";
import { addChoice } from "../service";
import { getWebsocket } from "../websocket";
import { UpdateNotificationSettingsData } from "../../../types";
@@ -66,16 +66,12 @@ router.post("/push/unsubscribe", async (req, res, next) => {
} catch (e: any) { next(e) }
});
/** Rychlá akce z push notifikace — nastaví volbu bez JWT (identita přes subscription endpoint). */
/** Rychlá akce z push notifikace — nastaví volbu bez JWT (identita přes login v payloadu). */
router.post("/push/quickChoice", async (req, res, next) => {
try {
const { endpoint } = req.body;
if (!endpoint) {
return res.status(400).json({ error: "Nebyl předán endpoint" });
}
const login = await findLoginByEndpoint(endpoint);
const { login } = req.body;
if (!login) {
return res.status(404).json({ error: "Subscription nenalezena" });
return res.status(400).json({ error: "Nebyl předán login" });
}
const data = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
getWebsocket().emit("message", data);
+66 -58
View File
@@ -3,11 +3,16 @@ import getStorage from "./storage";
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants";
import { getTodayMock } from "./mock";
import { removeAllUserPizzas } from "./pizza";
import { ClientData, DepartureTime, LunchChoice, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
import { ClientData, DepartureTime, LunchChoice, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
const storage = getStorage();
const MENU_PREFIX = 'menu';
function getDataKey(date: Date, slot?: string): string {
const base = formatDate(date);
return slot === 'extra' ? `${base}_extra` : base;
}
/** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */
export function getToday(): Date {
if (process.env.MOCK_DATA === 'true') {
@@ -43,15 +48,18 @@ function getEmptyData(date?: Date): ClientData {
/**
* Vrátí veškerá klientská data pro předaný den, nebo aktuální den, pokud není předán.
*/
export async function getData(date?: Date): Promise<ClientData> {
const clientData = await getClientData(date);
clientData.menus = {
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
export async function getData(date?: Date, slot?: string): Promise<ClientData> {
const clientData = await getClientData(date, slot);
if (slot !== 'extra') {
clientData.menus = {
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
}
}
if (slot === 'extra') clientData.slot = MealSlot.EXTRA;
return clientData;
}
@@ -281,8 +289,8 @@ function generateMenuWarnings(menu: RestaurantDayMenu, now: number): string[] {
*
* @param date datum
*/
export async function initIfNeeded(date?: Date) {
const usedDate = formatDate(date ?? getToday());
export async function initIfNeeded(date?: Date, slot?: string) {
const usedDate = getDataKey(date ?? getToday(), slot);
const hasData = await storage.hasData(usedDate);
if (!hasData) {
await storage.setData(usedDate, getEmptyData(date || getToday()));
@@ -298,9 +306,9 @@ export async function initIfNeeded(date?: Date) {
* @param date datum, ke kterému se volba vztahuje
* @returns
*/
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date) {
const selectedDay = formatDate(date ?? getToday());
let data = await getClientData(date);
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date, slot?: string) {
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]) {
@@ -325,9 +333,9 @@ export async function removeChoices(login: string, trusted: boolean, locationKey
* @param date datum, ke kterému se volba vztahuje
* @returns
*/
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date) {
const selectedDay = formatDate(date ?? getToday());
let data = await getClientData(date);
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date, slot?: string) {
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]) {
@@ -348,9 +356,9 @@ export async function removeChoice(login: string, trusted: boolean, locationKey:
* @param date datum, ke kterému se volby vztahují
* @param ignoredLocationKey volba, která nebude odstraněna, pokud existuje
*/
async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: LunchChoice) {
async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: LunchChoice, slot?: string) {
const usedDate = date ?? getToday();
let data = await getClientData(usedDate);
let data = await getClientData(usedDate, slot);
for (const key of Object.keys(data.choices)) {
const locationKey = key as LunchChoice;
if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
@@ -361,7 +369,7 @@ async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocation
if (Object.keys(data.choices[locationKey]).length === 0) {
delete data.choices[locationKey];
}
await storage.setData(formatDate(usedDate), data);
await storage.setData(getDataKey(usedDate, slot), data);
}
}
return data;
@@ -400,41 +408,43 @@ function validateTrusted(data: ClientData, login: string, trusted: boolean) {
* @param date datum, ke kterému se volba vztahuje
* @returns aktuální data
*/
export async function addChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex?: number, date?: Date) {
export async function addChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex?: number, date?: Date, slot?: string) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate);
let data = await getClientData(usedDate);
await initIfNeeded(usedDate, slot);
let data = await getClientData(usedDate, slot);
validateTrusted(data, login, trusted);
await validateFoodIndex(locationKey, foodIndex, date);
// Pokud uživatel měl vybranou PIZZA a mění na něco jiného
const hadPizzaChoice = data.choices.PIZZA && login in data.choices.PIZZA;
if (hadPizzaChoice && locationKey !== LunchChoice.PIZZA && foodIndex == null) {
// Kontrola, zda existuje Pizza day a uživatel je jeho zakladatel
if (data.pizzaDay && data.pizzaDay.creator === login) {
// Pokud Pizza day není ve stavu CREATED, nelze změnit volbu
if (data.pizzaDay.state !== PizzaDayState.CREATED) {
throw new PizzaDayConflictError(
`Nelze změnit volbu. Pizza day je ve stavu "${data.pizzaDay.state}" a musí být nejprve dokončen nebo smazán.`
);
if (!slot || slot === 'obed') {
// Pokud uživatel měl vybranou PIZZA a mění na něco jiného
const hadPizzaChoice = data.choices.PIZZA && login in data.choices.PIZZA;
if (hadPizzaChoice && locationKey !== LunchChoice.PIZZA && foodIndex == null) {
// Kontrola, zda existuje Pizza day a uživatel je jeho zakladatel
if (data.pizzaDay && data.pizzaDay.creator === login) {
// Pokud Pizza day není ve stavu CREATED, nelze změnit volbu
if (data.pizzaDay.state !== PizzaDayState.CREATED) {
throw new PizzaDayConflictError(
`Nelze změnit volbu. Pizza day je ve stavu "${data.pizzaDay.state}" a musí být nejprve dokončen nebo smazán.`
);
}
// Pizza day je ve stavu CREATED - bude smazán frontendem po potvrzení uživatelem
// (frontend volá nejprve deletePizzaDay, pak teprve addChoice)
}
// Pizza day je ve stavu CREATED - bude smazán frontendem po potvrzení uživatelem
// (frontend volá nejprve deletePizzaDay, pak teprve addChoice)
}
// Smažeme pizzy uživatele (pokud Pizza day nebyl založen tímto uživatelem,
// nebo byl již smazán frontendem)
await removeAllUserPizzas(login, usedDate);
// Znovu načteme data, protože removeAllUserPizzas je upravila
data = await getClientData(usedDate);
// Smažeme pizzy uživatele (pokud Pizza day nebyl založen tímto uživatelem,
// nebo byl již smazán frontendem)
await removeAllUserPizzas(login, usedDate);
// Znovu načteme data, protože removeAllUserPizzas je upravila
data = await getClientData(usedDate, slot);
}
}
// Pokud měníme pouze lokaci, mažeme případné předchozí
if (foodIndex == null) {
data = await removeChoiceIfPresent(login, usedDate);
data = await removeChoiceIfPresent(login, usedDate, undefined, slot);
} else {
// Mažeme případné ostatní volby (měla by být maximálně jedna)
data = await removeChoiceIfPresent(login, usedDate, locationKey);
data = await removeChoiceIfPresent(login, usedDate, locationKey, slot);
}
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
data.choices[locationKey] ??= {};
@@ -450,8 +460,7 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
if (foodIndex != null && !data.choices[locationKey][login].selectedFoods?.includes(foodIndex)) {
data.choices[locationKey][login].selectedFoods?.push(foodIndex);
}
const selectedDate = formatDate(usedDate);
await storage.setData(selectedDate, data);
await storage.setData(getDataKey(usedDate, slot), data);
return data;
}
@@ -489,10 +498,10 @@ async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, d
* @param note poznámka
* @param date datum, ke kterému se volba vztahuje
*/
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date) {
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date, slot?: string) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate);
let data = await getClientData(usedDate);
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) {
@@ -501,8 +510,7 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
} else {
userEntry[1][login].note = note;
}
const selectedDate = formatDate(usedDate);
await storage.setData(selectedDate, data);
await storage.setData(getDataKey(usedDate, slot), data);
}
return data;
}
@@ -514,9 +522,9 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
* @param time preferovaný čas odchodu
* @param date datum, ke kterému se čas vztahuje
*/
export async function updateDepartureTime(login: string, time?: string, date?: Date) {
export async function updateDepartureTime(login: string, time?: string, date?: Date, slot?: string) {
const usedDate = date ?? getToday();
let clientData = await getClientData(usedDate);
let clientData = await getClientData(usedDate, slot);
const found = Object.values(clientData.choices).find(location => login in location);
// TODO validace, že se jedná o restauraci
if (found) {
@@ -528,7 +536,7 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
}
found[login].departureTime = time;
}
await storage.setData(formatDate(usedDate), clientData);
await storage.setData(getDataKey(usedDate, slot), clientData);
}
return clientData;
}
@@ -539,15 +547,15 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
*
* @param login přihlašovací jméno uživatele
*/
export async function updateBuyer(login: string) {
export async function updateBuyer(login: string, slot?: string) {
const usedDate = getToday();
let clientData = await getClientData(usedDate);
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(formatDate(usedDate), clientData);
await storage.setData(getDataKey(usedDate, slot), clientData);
return clientData;
}
@@ -557,9 +565,9 @@ export async function updateBuyer(login: string) {
* @param date datum pro který vrátit data, pokud není vyplněno, je použit dnešní den
* @returns data pro klienta
*/
export async function getClientData(date?: Date): Promise<ClientData> {
export async function getClientData(date?: Date, slot?: string): Promise<ClientData> {
const targetDate = date ?? getToday();
const dateString = formatDate(targetDate);
const dateString = getDataKey(targetDate, slot);
const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date);
return {
...clientData,