From ac6727efa58f2a156540ba69c4ef795b8963dff7 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Tue, 10 Feb 2026 23:59:58 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20vylep=C5=A1en=C3=AD=20Pizza=20day?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.tsx | 123 ++++++++++++++++++++++++++++++++++-------- server/src/index.ts | 10 ++-- server/src/pizza.ts | 30 +++++++++++ server/src/service.ts | 46 ++++++++++++---- server/src/utils.ts | 2 + 5 files changed, 175 insertions(+), 36 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index cfa66cb..a274b1f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -175,6 +175,11 @@ function App() { } }, [auth?.login, data?.pizzaDay?.orders]) + // Kontrola, zda má uživatel vybranou volbu PIZZA + const userHasPizzaChoice = useMemo(() => { + return auth?.login ? data?.choices?.PIZZA?.[auth.login] != null : false; + }, [data?.choices?.PIZZA, auth?.login]); + useEffect(() => { if (choiceRef?.current?.value && choiceRef.current.value !== "") { const locationKey = choiceRef.current.value as LunchChoice; @@ -226,10 +231,65 @@ function App() { } }, [auth?.login, easterEgg?.duration, easterEgg?.url, eggImage]); + // Pomocná funkce pro kontrolu a potvrzení změny volby při existujícím Pizza day + const checkPizzaDayBeforeChange = async (newLocationKey: LunchChoice): Promise => { + if (!auth?.login || !data) return false; + + // Kontrola, zda uživatel má vybranou PIZZA a mění na něco jiného + const hasPizzaChoice = data.choices?.PIZZA?.[auth.login] != null; + const isCreator = data.pizzaDay?.creator === auth.login; + const isPizzaDayCreated = data.pizzaDay?.state === PizzaDayState.CREATED; + + // Pokud není vybraná PIZZA nebo přepínáme na PIZZA, není potřeba kontrolovat + if (!hasPizzaChoice || newLocationKey === LunchChoice.PIZZA) { + return true; + } + + // Pokud uživatel není zakladatel Pizza day, není potřeba dialogu + if (!isCreator || !data?.pizzaDay) { + return true; + } + + // Uživatel je zakladatel Pizza day a mění volbu z PIZZA + if (!isPizzaDayCreated) { + // Pizza day není ve stavu CREATED, nelze změnit volbu + alert(`Nelze změnit volbu. Pizza day je ve stavu "${data.pizzaDay.state}" a musí být nejprve dokončen.`); + return false; + } + + // Pizza day je CREATED, zobrazit potvrzovací dialog + const confirmed = window.confirm( + 'Jsi zakladatel aktivního Pizza day. Změna volby smaže celý Pizza day včetně všech objednávek. Pokračovat?' + ); + + if (!confirmed) { + return false; + } + + // Uživatel potvrdil, smazat Pizza day + try { + await deletePizzaDay(); + return true; + } catch (error: any) { + alert(`Chyba při mazání Pizza day: ${error.message || error}`); + return false; + } + }; + const doAddClickFoodChoice = async (location: LunchChoice, foodIndex?: number) => { if (document.getSelection()?.type !== 'Range') { // pouze pokud se nejedná o výběr textu if (canChangeChoice && auth?.login) { - await addChoice({ body: { locationKey: location, foodIndex, dayIndex } }); + // Kontrola Pizza day před změnou volby + const canProceed = await checkPizzaDayBeforeChange(location); + if (!canProceed) { + return; + } + + try { + await addChoice({ body: { locationKey: location, foodIndex, dayIndex } }); + } catch (error: any) { + alert(`Chyba při změně volby: ${error.message || error}`); + } } } } @@ -237,11 +297,32 @@ function App() { const doAddChoice = async (event: React.ChangeEvent) => { const locationKey = event.target.value as LunchChoice; if (canChangeChoice && auth?.login) { - await addChoice({ body: { locationKey, dayIndex } }); - if (foodChoiceRef.current?.value) { - foodChoiceRef.current.value = ""; + // Kontrola Pizza day před změnou volby + const canProceed = await checkPizzaDayBeforeChange(locationKey); + if (!canProceed) { + // Uživatel zrušil akci nebo došlo k chybě, reset výběru zpět na PIZZA + if (choiceRef.current) { + choiceRef.current.value = LunchChoice.PIZZA; + } + return; + } + + try { + await addChoice({ body: { locationKey, dayIndex } }); + if (foodChoiceRef.current?.value) { + foodChoiceRef.current.value = ""; + } + choiceRef.current?.blur(); + } catch (error: any) { + alert(`Chyba při změně volby: ${error.message || error}`); + // Reset výběru zpět + const hasPizzaChoice = data?.choices?.PIZZA?.[auth.login] != null; + if (choiceRef.current && hasPizzaChoice) { + choiceRef.current.value = LunchChoice.PIZZA; + } else if (choiceRef.current) { + choiceRef.current.value = ""; + } } - choiceRef.current?.blur(); } } @@ -556,28 +637,28 @@ function App() {
{login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && { - markAsBuyer(); - }} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{cursor: 'pointer'}} /> + markAsBuyer(); + }} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{ cursor: 'pointer' }} /> } {login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && { - copyNote(userPayload.note!); - }} icon={faBasketShopping} className='buyer-icon' /> + copyNote(userPayload.note!); + }} icon={faBasketShopping} className='buyer-icon' /> } {login !== auth.login && canChangeChoice && userPayload?.note?.length && { - copyNote(userPayload.note!); - }} className='action-icon' icon={faComment} /> + copyNote(userPayload.note!); + }} className='action-icon' icon={faComment} /> } {login === auth.login && canChangeChoice && { - setNoteModalOpen(true); - }} className='action-icon' icon={faNoteSticky} /> + setNoteModalOpen(true); + }} className='action-icon' icon={faNoteSticky} /> } {login === auth.login && canChangeChoice && { - doRemoveChoices(key as LunchChoice); - }} className='action-icon' icon={faTrashCan} /> + doRemoveChoices(key as LunchChoice); + }} className='action-icon' icon={faTrashCan} /> }
@@ -589,11 +670,11 @@ function App() { return
{foodName} {login === auth.login && canChangeChoice && - - { - doRemoveFoodChoice(restaurantKey, foodIndex); - }} className='action-icon' icon={faTrashCan} /> - } + + { + doRemoveFoodChoice(restaurantKey, foodIndex); + }} className='action-icon' icon={faTrashCan} /> + }
})} @@ -613,7 +694,7 @@ function App() { :
Zatím nikdo nehlasoval...
} - {dayIndex === data.todayDayIndex && + {dayIndex === data.todayDayIndex && userHasPizzaChoice &&
{!data.pizzaDay && <> diff --git a/server/src/index.ts b/server/src/index.ts index bab23cc..bc6a931 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,7 +6,7 @@ import dotenv from 'dotenv'; import path from 'path'; import { getQr } from "./qr"; import { generateToken, getLogin, verify } from "./auth"; -import { getIsWeekend, InsufficientPermissions, parseToken } from "./utils"; +import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils"; import { getPendingQrs } from "./pizza"; import { initWebsocket } from "./websocket"; import pizzaDayRoutes from "./routes/pizzaDayRoutes"; @@ -56,7 +56,7 @@ app.get("/api/whoami", (req, res) => { if (!HTTP_REMOTE_USER_ENABLED) { res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' }); } - if(process.env.ENABLE_HEADERS_LOGGING === 'yes'){ + if (process.env.ENABLE_HEADERS_LOGGING === 'yes') { delete req.headers["cookie"] console.log(req.headers) } @@ -68,7 +68,7 @@ app.post("/api/login", (req, res) => { // Autentizace pomocí trusted headers const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME); //const remoteName = req.header('remote-name'); - if (remoteUser && remoteUser.length > 0 ) { + if (remoteUser && remoteUser.length > 0) { res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true)); } else { throw Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??"); @@ -106,7 +106,7 @@ 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'){ + if (process.env.ENABLE_HEADERS_LOGGING === 'yes') { delete req.headers["cookie"] console.log(req.headers) } @@ -168,6 +168,8 @@ app.use(express.static('public')); app.use((err: any, req: any, res: any, next: any) => { if (err instanceof InsufficientPermissions) { res.status(403).send({ error: err.message }) + } else if (err instanceof PizzaDayConflictError) { + res.status(409).send({ error: err.message }) } else { res.status(500).send({ error: err.message }) } diff --git a/server/src/pizza.ts b/server/src/pizza.ts index 8ffdf69..6e3166d 100644 --- a/server/src/pizza.ts +++ b/server/src/pizza.ts @@ -113,6 +113,36 @@ export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize return clientData; } +/** + * 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; +} + /** * Odstraní danou objednávku pizzy. * diff --git a/server/src/service.ts b/server/src/service.ts index c73477a..ff6780b 100644 --- a/server/src/service.ts +++ b/server/src/service.ts @@ -1,8 +1,9 @@ -import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getIsWeekend, getWeekNumber } from "./utils"; +import { InsufficientPermissions, PizzaDayConflictError, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getIsWeekend, getWeekNumber } from "./utils"; import getStorage from "./storage"; import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants"; import { getTodayMock } from "./mock"; -import { ClientData, DepartureTime, LunchChoice, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen"; +import { removeAllUserPizzas } from "./pizza"; +import { ClientData, DepartureTime, LunchChoice, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen"; const storage = getStorage(); const MENU_PREFIX = 'menu'; @@ -99,7 +100,7 @@ export async function saveRestaurantWeekMenu(restaurant: Restaurant, date: Date, const now = new Date().getTime(); let weekMenu = await getMenu(date); weekMenu ??= [{}, {}, {}, {}, {}]; - + // Inicializace struktury pro restauraci for (let i = 0; i < 5; i++) { weekMenu[i] ??= {}; @@ -109,12 +110,12 @@ export async function saveRestaurantWeekMenu(restaurant: Restaurant, date: Date, food: [], }; } - + // Uložení dat pro všechny dny for (let i = 0; i < weekData.length && i < weekMenu.length; i++) { weekMenu[i][restaurant]!.food = weekData[i]; weekMenu[i][restaurant]!.lastUpdate = now; - + // Detekce uzavření pro každou restauraci switch (restaurant) { case 'SLADOVNICKA': @@ -139,7 +140,7 @@ export async function saveRestaurantWeekMenu(restaurant: Restaurant, date: Date, break; } } - + // Uložení do storage await storage.setData(getMenuKey(date), weekMenu); } @@ -153,7 +154,7 @@ export async function saveRestaurantWeekMenu(restaurant: Restaurant, date: Date, */ async function fetchRestaurantWeekMenu(restaurant: Restaurant, firstDay: Date): Promise { const mock = process.env.MOCK_DATA === 'true'; - + switch (restaurant) { case 'SLADOVNICKA': return await getMenuSladovnicka(firstDay, mock); @@ -207,15 +208,15 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for (!existingMenu?.food?.length && !existingMenu?.closed && lastFetchExpired); if (shouldFetch) { const firstDay = getFirstWorkDayOfWeek(usedDate); - + try { const restaurantWeekFood = await fetchRestaurantWeekMenu(restaurant, firstDay); - + // Aktualizace menu pro všechny dny for (let i = 0; i < restaurantWeekFood.length && i < weekMenu.length; i++) { weekMenu[i][restaurant]!.food = restaurantWeekFood[i]; weekMenu[i][restaurant]!.lastUpdate = now; - + // Detekce uzavření pro každou restauraci switch (restaurant) { case 'SLADOVNICKA': @@ -240,7 +241,7 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for break; } } - + // Uložení do storage await storage.setData(getMenuKey(usedDate), weekMenu); } catch (e: any) { @@ -405,6 +406,29 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu let data = await getClientData(usedDate); 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.` + ); + } + // 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); + } + // Pokud měníme pouze lokaci, mažeme případné předchozí if (foodIndex == null) { data = await removeChoiceIfPresent(login, usedDate); diff --git a/server/src/utils.ts b/server/src/utils.ts index df1dd46..41f70d4 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -114,6 +114,8 @@ export const checkBodyParams = (req: any, paramNames: string[]) => { // TODO umístit do samostatného souboru export class InsufficientPermissions extends Error { } +export class PizzaDayConflictError extends Error { } + export const getUsersByLocation = (choices: LunchChoices, login?: string): string[] => { const result: string[] = [];