From 774be3df6d78c120ee8642d0137c842f0ef8e652 Mon Sep 17 00:00:00 2001 From: Batmanisko Date: Wed, 6 May 2026 20:37:39 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ve=C4=8De=C5=99e=20(extra=20meal=20slot?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nová stránka /vecere pro evidenci extra jídla (večeře/pozdní oběd) - MealSlot enum (obed/extra), oddělený storage namespace YYYY-MM-DD_extra - slot parametr na všech food endpointech a GET /api/data - Push reminder: přechod na 60min cooldown, login v payloadu místo endpointu - server: slot?: string → slot?: MealSlot, enum konstanty místo literálů - Jest testy izolace extra/obed storage namespace - Aktualizace changelogů (saláty, SINGLE_PAYMENT, večeře) --- client/public/sw.js | 19 +-- client/src/App.tsx | 3 +- client/src/AppRoutes.tsx | 10 ++ client/src/components/Header.tsx | 3 +- client/src/pages/ExtraPage.tsx | 209 ++++++++++++++++++++++++ server/changelogs/2026-04-02.json | 3 + server/changelogs/2026-04-28.json | 3 + server/changelogs/2026-05-06.json | 3 + server/src/index.ts | 7 +- server/src/pushReminder.ts | 30 +--- server/src/routes/devRoutes.ts | 2 +- server/src/routes/foodRoutes.ts | 30 +++- server/src/routes/notificationRoutes.ts | 13 +- server/src/service.ts | 120 +++++++------- server/src/tests/service.slot.test.ts | 60 +++++++ types/paths/food/addChoice.yml | 2 + types/paths/food/removeChoice.yml | 2 + types/paths/food/removeChoices.yml | 2 + types/paths/food/updateBuyer.yml | 8 + types/paths/food/updateNote.yml | 2 + types/paths/getData.yml | 5 + types/schemas/_index.yml | 12 ++ 22 files changed, 441 insertions(+), 107 deletions(-) create mode 100644 client/src/pages/ExtraPage.tsx create mode 100644 server/changelogs/2026-04-02.json create mode 100644 server/changelogs/2026-04-28.json create mode 100644 server/changelogs/2026-05-06.json create mode 100644 server/src/tests/service.slot.test.ts diff --git a/client/public/sw.js b/client/public/sw.js index a86cec5..f357178 100644 --- a/client/public/sw.js +++ b/client/public/sw.js @@ -7,6 +7,7 @@ self.addEventListener('push', (event) => { body: data.body, icon: '/favicon.ico', tag: 'lunch-reminder', + data: { login: data.login }, actions: [ { action: 'neobedvam', title: 'Mám vlastní/neobědvám' }, ], @@ -18,28 +19,26 @@ self.addEventListener('notificationclick', (event) => { event.notification.close(); if (event.action === 'neobedvam') { - event.waitUntil( - self.registration.pushManager.getSubscription().then((subscription) => { - if (!subscription) return; - return fetch('/api/notifications/push/quickChoice', { + const login = event.notification.data?.login; + if (login) { + event.waitUntil( + fetch('/api/notifications/push/quickChoice', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ endpoint: subscription.endpoint }), - }); - }) - ); + body: JSON.stringify({ login }), + }) + ); + } return; } event.waitUntil( self.clients.matchAll({ type: 'window' }).then((clientList) => { - // Pokud je již otevřené okno, zaostříme na něj for (const client of clientList) { if (client.url.includes(self.location.origin) && 'focus' in client) { return client.focus(); } } - // Jinak otevřeme nové return self.clients.openWindow('/'); }) ); diff --git a/client/src/App.tsx b/client/src/App.tsx index b148a14..6117bac 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -19,7 +19,7 @@ import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils'; import NoteModal from './components/modals/NoteModal'; import PayForAllModal from './components/modals/PayForAllModal'; import { useEasterEgg } from './context/eggs'; -import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr, generateQr } from '../../types'; +import { ClientData, Food, MealSlot, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr, generateQr } from '../../types'; import { getLunchChoiceName } from './enums'; // import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves'; // import './FallingLeaves.scss'; @@ -126,6 +126,7 @@ function App() { }); socket.on(EVENT_MESSAGE, (newData: ClientData) => { // console.log("Přijata nová data ze socketu", newData); + if (newData.slot === MealSlot.EXTRA) return; // Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) { setData(newData); diff --git a/client/src/AppRoutes.tsx b/client/src/AppRoutes.tsx index 0e3bb64..601d99b 100644 --- a/client/src/AppRoutes.tsx +++ b/client/src/AppRoutes.tsx @@ -5,14 +5,24 @@ import { SnowOverlay } from 'react-snow-overlay'; import { ToastContainer } from "react-toastify"; import { SocketContext, socket } from "./context/socket"; import StatsPage from "./pages/StatsPage"; +import ExtraPage from "./pages/ExtraPage"; import App from "./App"; export const STATS_URL = '/stats'; +export const VECERE_URL = '/vecere'; export default function AppRoutes() { return ( } /> + + + + + + + } /> diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index 9b279c6..7b0341b 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -10,7 +10,7 @@ import GenerateQrModal from "./modals/GenerateQrModal"; import GenerateMockDataModal from "./modals/GenerateMockDataModal"; import ClearMockDataModal from "./modals/ClearMockDataModal"; import { useNavigate } from "react-router"; -import { STATS_URL } from "../AppRoutes"; +import { STATS_URL, VECERE_URL } from "../AppRoutes"; import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons"; @@ -207,6 +207,7 @@ export default function Header({ choices, dayIndex }: Props) { setPizzaModalOpen(true)}>Pizza kalkulačka Generování QR kódů navigate(STATS_URL)}>Statistiky + navigate(VECERE_URL)}>Večeře { getChangelogs().then(response => { const entries = response.data ?? {}; diff --git a/client/src/pages/ExtraPage.tsx b/client/src/pages/ExtraPage.tsx new file mode 100644 index 0000000..0c89d48 --- /dev/null +++ b/client/src/pages/ExtraPage.tsx @@ -0,0 +1,209 @@ +import { useContext, useEffect, useRef, useState } from 'react'; +import { Button, Table } from 'react-bootstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCircleCheck, faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons'; +import { faBasketShopping, faSearch } from '@fortawesome/free-solid-svg-icons'; +import { + ClientData, LunchChoice, MealSlot, UserLunchChoice, + addChoice, removeChoices, updateNote, setBuyer, getData, +} from '../../../types'; +import { EVENT_MESSAGE, SocketContext } from '../context/socket'; +import { useAuth } from '../context/auth'; +import Login from '../Login'; +import Header from '../components/Header'; +import Footer from '../components/Footer'; +import Loader from '../components/Loader'; +import NoteModal from '../components/modals/NoteModal'; + +const SLOT = MealSlot.EXTRA; + +export default function ExtraPage() { + const auth = useAuth(); + const socket = useContext(SocketContext); + const [data, setData] = useState(); + const [failure, setFailure] = useState(false); + const [noteModalOpen, setNoteModalOpen] = useState(false); + + const fetchData = async () => { + try { + const r = await getData({ query: { slot: SLOT } }); + if (r.data) setData(r.data); + } catch { + setFailure(true); + } + }; + + useEffect(() => { + if (!auth?.login) return; + fetchData(); + }, [auth?.login]); + + useEffect(() => { + socket.on(EVENT_MESSAGE, (newData: ClientData) => { + if (newData.slot === SLOT) setData(newData); + }); + return () => { socket.off(EVENT_MESSAGE); }; + }, [socket]); + + const myChoice = data?.choices?.OBJEDNAVAM?.[auth?.login ?? '']; + const isIn = !!myChoice; + const isBuyer = myChoice?.isBuyer ?? false; + + const joinOrder = async () => { + if (!auth?.login) return; + await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } }); + await fetchData(); + }; + + const joinAndBuy = async () => { + if (!auth?.login) return; + await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } }); + await setBuyer({ body: { slot: SLOT } }); + await fetchData(); + }; + + const leaveOrder = async () => { + if (!auth?.login) return; + await removeChoices({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } }); + await fetchData(); + }; + + const toggleBuyer = async () => { + if (!auth?.login) return; + await setBuyer({ body: { slot: SLOT } }); + await fetchData(); + }; + + const saveNote = async (note?: string) => { + if (!auth?.login) return; + await updateNote({ body: { note, slot: SLOT } }); + setNoteModalOpen(false); + await fetchData(); + }; + + if (!auth?.login) return ; + + if (failure) return ( + + ); + + if (!data) return ( + + ); + + const orderEntries = Object.entries(data.choices?.OBJEDNAVAM ?? {}) as [string, UserLunchChoice][]; + + return ( +
+
+
+

Večeře

+

Extra jídlo pro ty, kdo zůstávají déle

+ +
+
+
+ {!isIn ? ( +
+ + +
+ ) : ( +
+ + {isBuyer ? 'Objednáváš.' : 'Jsi přidán/a k objednávce.'} + + + + +
+ )} +
+ + {orderEntries.length > 0 && ( + + + + + + + +
Budu objednávat / Přidám se + + + {orderEntries.map(([login, payload]) => ( + + + + ))} + +
+
+
+ {payload.trusted && ( + + + + )} + {login} + {payload.note && ( + + ({payload.note}) + + )} +
+
+ {payload.isBuyer && ( + + + + )} + {login === auth.login && ( + <> + + setNoteModalOpen(true)} + className="action-icon" + icon={faNoteSticky} + /> + + + + + + )} +
+
+
+
+ )} +
+
+
+
+ setNoteModalOpen(false)} + onSave={saveNote} + /> +
+ ); +} diff --git a/server/changelogs/2026-04-02.json b/server/changelogs/2026-04-02.json new file mode 100644 index 0000000..fbc3421 --- /dev/null +++ b/server/changelogs/2026-04-02.json @@ -0,0 +1,3 @@ +[ + "Zobrazení nabídky salátů z Pizza Chefie" +] diff --git a/server/changelogs/2026-04-28.json b/server/changelogs/2026-04-28.json new file mode 100644 index 0000000..d3331a8 --- /dev/null +++ b/server/changelogs/2026-04-28.json @@ -0,0 +1,3 @@ +[ + "Možnost úhrady celého účtu jednou osobou s rozesláním QR kódů ostatním (na Pizza day)" +] diff --git a/server/changelogs/2026-05-06.json b/server/changelogs/2026-05-06.json new file mode 100644 index 0000000..e6c4bd5 --- /dev/null +++ b/server/changelogs/2026-05-06.json @@ -0,0 +1,3 @@ +[ + "Evidence večeří a pozdních obědů na samostatné stránce (/vecere)" +] diff --git a/server/src/index.ts b/server/src/index.ts index 4fce188..850ecfe 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,6 +2,7 @@ import express from "express"; import bodyParser from "body-parser"; import cors from 'cors'; import { getData, getDateForWeekIndex, getToday } from "./service"; +import { MealSlot } from "../../types/gen/types.gen"; import dotenv from 'dotenv'; import path from 'path'; import { getQr } from "./qr"; @@ -151,7 +152,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 as MealSlot : undefined; + if (slotParam && slotParam !== MealSlot.OBED && slotParam !== MealSlot.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)); diff --git a/server/src/pushReminder.ts b/server/src/pushReminder.ts index 02dcc37..3e81205 100644 --- a/server/src/pushReminder.ts +++ b/server/src/pushReminder.ts @@ -14,13 +14,10 @@ interface RegistryEntry { type Registry = Record; -/** Mapa login → datum (YYYY-MM-DD), kdy byl uživatel naposledy upozorněn. */ -const remindedToday = new Map(); +/** Mapa login → timestamp (ms) posledního odeslání připomínky. */ +const lastReminded = new Map(); -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 { 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`); } @@ -68,16 +65,6 @@ export function getVapidPublicKey(): string | undefined { return process.env.VAPID_PUBLIC_KEY; } -/** Najde login uživatele podle push subscription endpointu. */ -export async function findLoginByEndpoint(endpoint: string): Promise { - const registry = await getRegistry(); - for (const [login, entry] of Object.entries(registry)) { - if (entry.subscription.endpoint === endpoint) { - return login; - } - } - return undefined; -} /** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */ async function checkAndSendReminders(): Promise { @@ -93,7 +80,6 @@ async function checkAndSendReminders(): Promise { } const currentTime = getCurrentTimeHHMM(); - const todayStr = getTodayDateString(); // Získáme data pro dnešek jednou pro všechny uživatele let clientData; @@ -110,8 +96,9 @@ async function checkAndSendReminders(): Promise { 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 +114,10 @@ async function checkAndSendReminders(): Promise { 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) { diff --git a/server/src/routes/devRoutes.ts b/server/src/routes/devRoutes.ts index 97999cc..05787a7 100644 --- a/server/src/routes/devRoutes.ts +++ b/server/src/routes/devRoutes.ts @@ -189,7 +189,7 @@ router.post("/testPush", async (req, res, next) => { webpush.setVapidDetails(subject, publicKey, privateKey); await webpush.sendNotification( entry.subscription, - JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!' }) + JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!', login }) ); res.status(200).json({ ok: true }); } catch (e: any) { next(e) } diff --git a/server/src/routes/foodRoutes.ts b/server/src/routes/foodRoutes.ts index 2688d7e..0910e4d 100644 --- a/server/src/routes/foodRoutes.ts +++ b/server/src/routes/foodRoutes.ts @@ -4,7 +4,7 @@ import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils"; import { getWebsocket } from "../websocket"; import { callNotifikace } from "../notifikace"; -import { AddChoiceData, ChangeDepartureTimeData, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen"; +import { AddChoiceData, ChangeDepartureTimeData, MealSlot, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen"; // RateLimit na refresh endpoint @@ -69,11 +69,21 @@ const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] return dayIndex; } +const parseSlot = (body: Record): MealSlot | undefined => { + const slot = body?.slot; + if (slot != null && slot !== MealSlot.OBED && slot !== MealSlot.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)); + let slot: MealSlot | undefined; + try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); } let date = undefined; if (req.body.dayIndex != null) { let dayIndex; @@ -85,7 +95,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 +104,8 @@ 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)); + let slot: MealSlot | undefined; + try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); } let date = undefined; if (req.body.dayIndex != null) { let dayIndex; @@ -105,7 +117,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 +126,8 @@ 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)); + let slot: MealSlot | undefined; + try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); } let date = undefined; if (req.body.dayIndex != null) { let dayIndex; @@ -125,7 +139,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 +149,8 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>, const login = getLogin(parseToken(req)); const trusted = getTrusted(parseToken(req)); const note = req.body.note; + let slot: MealSlot | undefined; + try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); } try { if (note && note.length > 70) { throw Error("Poznámka může mít maximálně 70 znaků"); @@ -149,7 +165,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 +200,10 @@ router.post("/jdemeObed", async (req, res, next) => { router.post("/updateBuyer", async (req, res, next) => { const login = getLogin(parseToken(req)); + let slot: MealSlot | undefined; + try { slot = parseSlot(req.body ?? {}); } catch (e: any) { return res.status(400).json({ error: e.message }); } 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) } diff --git a/server/src/routes/notificationRoutes.ts b/server/src/routes/notificationRoutes.ts index 0ab550a..0ad0a30 100644 --- a/server/src/routes/notificationRoutes.ts +++ b/server/src/routes/notificationRoutes.ts @@ -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,17 +66,10 @@ 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 NEOBEDVAM pro přihlášeného uživatele. */ 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); - if (!login) { - return res.status(404).json({ error: "Subscription nenalezena" }); - } + const login = getLogin(parseToken(req)); const data = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined); getWebsocket().emit("message", data); res.status(200).json({}); diff --git a/server/src/service.ts b/server/src/service.ts index 4774662..62206f0 100644 --- a/server/src/service.ts +++ b/server/src/service.ts @@ -3,11 +3,16 @@ import getStorage from "./storage"; import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } 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?: MealSlot): string { + const base = formatDate(date); + return slot === MealSlot.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,14 +48,16 @@ export 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 { - 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?: MealSlot): Promise { + const clientData = await getClientData(date, slot); + if (slot !== MealSlot.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), + } } return clientData; } @@ -290,8 +297,8 @@ function generateMenuWarnings(menu: RestaurantDayMenu): string[] { * * @param date datum */ -export async function initIfNeeded(date?: Date) { - const usedDate = formatDate(date ?? getToday()); +export async function initIfNeeded(date?: Date, slot?: MealSlot) { + const usedDate = getDataKey(date ?? getToday(), slot); const hasData = await storage.hasData(usedDate); if (!hasData) { await storage.setData(usedDate, getEmptyData(date || getToday())); @@ -307,9 +314,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?: 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]) { @@ -334,9 +341,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?: 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]) { @@ -357,9 +364,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?: MealSlot) { 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) { @@ -370,7 +377,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; @@ -409,41 +416,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?: MealSlot) { 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 === MealSlot.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] ??= {}; @@ -459,8 +468,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; } @@ -498,10 +506,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?: MealSlot) { 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) { @@ -510,8 +518,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; } @@ -537,7 +544,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), clientData); } return clientData; } @@ -548,15 +555,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?: MealSlot) { 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; } @@ -566,12 +573,13 @@ 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 { +export async function getClientData(date?: Date, slot?: MealSlot): Promise { const targetDate = date ?? getToday(); - const dateString = formatDate(targetDate); + const dateString = getDataKey(targetDate, slot); const clientData = await storage.getData(dateString) || getEmptyData(date); return { ...clientData, todayDayIndex: getDayOfWeekIndex(getToday()), + ...(slot === MealSlot.EXTRA ? { slot: MealSlot.EXTRA } : {}), } } \ No newline at end of file diff --git a/server/src/tests/service.slot.test.ts b/server/src/tests/service.slot.test.ts new file mode 100644 index 0000000..74e5779 --- /dev/null +++ b/server/src/tests/service.slot.test.ts @@ -0,0 +1,60 @@ +const mockStorageData = new Map(); +jest.mock('../storage', () => ({ + __esModule: true, + default: () => ({ + hasData: async (key: string) => mockStorageData.has(key), + getData: async (key: string) => mockStorageData.get(key) as T, + setData: async (key: string, val: T) => void mockStorageData.set(key, val), + }), + storageReady: Promise.resolve(), +})); + +import { addChoice, getData } from '../service'; +import { LunchChoice, MealSlot } from '../../../types/gen/types.gen'; + +const TODAY = new Date('2025-01-10'); +const TODAY_STR = '2025-01-10'; +const TODAY_EXTRA_STR = '2025-01-10_extra'; + +describe('MealSlot storage isolation', () => { + beforeEach(() => { + mockStorageData.clear(); + jest.useFakeTimers(); + jest.setSystemTime(TODAY); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('addChoice slot=extra writes only to _extra key, not to obed key, and returns slot=EXTRA', async () => { + const result = await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA); + expect(result.slot).toBe(MealSlot.EXTRA); + expect(mockStorageData.has(TODAY_EXTRA_STR)).toBe(true); + expect(mockStorageData.has(TODAY_STR)).toBe(false); + const extraData = mockStorageData.get(TODAY_EXTRA_STR); + expect(extraData.choices.OBJEDNAVAM?.['user1']).toBeDefined(); + }); + + test('getData slot=extra returns slot===MealSlot.EXTRA and no menus', async () => { + await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA); + const result = await getData(TODAY, MealSlot.EXTRA); + expect(result.slot).toBe(MealSlot.EXTRA); + expect(result.menus).toBeUndefined(); + }); + + test('addChoice slot=extra does not modify obed data even when obed has PIZZA choice', async () => { + mockStorageData.set(TODAY_STR, { + choices: { PIZZA: { user1: { selectedFoods: [0], trusted: false } } }, + todayDayIndex: 4, + date: '10. 1. 2025', + isWeekend: false, + dayIndex: 4, + }); + + await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA); + + const obed = mockStorageData.get(TODAY_STR); + expect(obed.choices.PIZZA?.['user1']).toBeDefined(); + }); +}); diff --git a/types/paths/food/addChoice.yml b/types/paths/food/addChoice.yml index 5eb3d54..4617737 100644 --- a/types/paths/food/addChoice.yml +++ b/types/paths/food/addChoice.yml @@ -15,6 +15,8 @@ post: $ref: "../../schemas/_index.yml#/DayIndex" foodIndex: $ref: "../../schemas/_index.yml#/FoodIndex" + slot: + $ref: "../../schemas/_index.yml#/MealSlot" responses: "200": $ref: "../../api.yml#/components/responses/ClientDataResponse" diff --git a/types/paths/food/removeChoice.yml b/types/paths/food/removeChoice.yml index 75762df..0e56f4b 100644 --- a/types/paths/food/removeChoice.yml +++ b/types/paths/food/removeChoice.yml @@ -16,6 +16,8 @@ post: $ref: "../../schemas/_index.yml#/LunchChoice" dayIndex: $ref: "../../schemas/_index.yml#/DayIndex" + slot: + $ref: "../../schemas/_index.yml#/MealSlot" responses: "200": $ref: "../../api.yml#/components/responses/ClientDataResponse" diff --git a/types/paths/food/removeChoices.yml b/types/paths/food/removeChoices.yml index 9858c91..e90d197 100644 --- a/types/paths/food/removeChoices.yml +++ b/types/paths/food/removeChoices.yml @@ -13,6 +13,8 @@ post: $ref: "../../schemas/_index.yml#/LunchChoice" dayIndex: $ref: "../../schemas/_index.yml#/DayIndex" + slot: + $ref: "../../schemas/_index.yml#/MealSlot" responses: "200": $ref: "../../api.yml#/components/responses/ClientDataResponse" diff --git a/types/paths/food/updateBuyer.yml b/types/paths/food/updateBuyer.yml index f03e3df..9705793 100644 --- a/types/paths/food/updateBuyer.yml +++ b/types/paths/food/updateBuyer.yml @@ -1,6 +1,14 @@ post: operationId: setBuyer summary: Nastavení/odnastavení aktuálně přihlášeného uživatele jako objednatele pro stav "Budu objednávat" pro aktuální den. + requestBody: + required: false + content: + application/json: + schema: + properties: + slot: + $ref: "../../schemas/_index.yml#/MealSlot" responses: "200": description: Stav byl úspěšně změněn. diff --git a/types/paths/food/updateNote.yml b/types/paths/food/updateNote.yml index 2eaf1bb..ccfbde0 100644 --- a/types/paths/food/updateNote.yml +++ b/types/paths/food/updateNote.yml @@ -11,6 +11,8 @@ post: $ref: "../../schemas/_index.yml#/DayIndex" note: type: string + slot: + $ref: "../../schemas/_index.yml#/MealSlot" responses: "200": $ref: "../../api.yml#/components/responses/ClientDataResponse" diff --git a/types/paths/getData.yml b/types/paths/getData.yml index be0f920..da5116c 100644 --- a/types/paths/getData.yml +++ b/types/paths/getData.yml @@ -9,6 +9,11 @@ get: type: integer minimum: 0 maximum: 4 + - in: query + name: slot + description: Slot jídla. Pokud není předán, je použit výchozí slot (oběd). + schema: + $ref: "../schemas/_index.yml#/MealSlot" responses: "200": $ref: "../api.yml#/components/responses/ClientDataResponse" diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index f2df7d0..19d7808 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -63,6 +63,9 @@ ClientData: type: array items: $ref: "#/PendingQr" + slot: + description: Slot jídla, ke kterému se tato data vztahují + $ref: "#/MealSlot" # --- OBĚDY --- UserLunchChoice: @@ -135,6 +138,15 @@ LunchChoice: - OBJEDNAVAM - NEOBEDVAM - ROZHODUJI +MealSlot: + description: Slot jídla - oběd nebo extra jídlo (večeře, pozdní oběd) + type: string + enum: + - obed + - extra + x-enum-varnames: + - OBED + - EXTRA DayIndex: description: Index dne v týdnu (0 = pondělí, 4 = pátek) type: integer