feat: vylepšení Pizza day
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful

This commit is contained in:
2026-02-10 23:59:58 +01:00
parent f13cd4ffa9
commit ac6727efa5
5 changed files with 175 additions and 36 deletions

View File

@@ -175,6 +175,11 @@ function App() {
} }
}, [auth?.login, data?.pizzaDay?.orders]) }, [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(() => { useEffect(() => {
if (choiceRef?.current?.value && choiceRef.current.value !== "") { if (choiceRef?.current?.value && choiceRef.current.value !== "") {
const locationKey = choiceRef.current.value as LunchChoice; const locationKey = choiceRef.current.value as LunchChoice;
@@ -226,10 +231,65 @@ function App() {
} }
}, [auth?.login, easterEgg?.duration, easterEgg?.url, eggImage]); }, [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<boolean> => {
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) => { const doAddClickFoodChoice = async (location: LunchChoice, foodIndex?: number) => {
if (document.getSelection()?.type !== 'Range') { // pouze pokud se nejedná o výběr textu if (document.getSelection()?.type !== 'Range') { // pouze pokud se nejedná o výběr textu
if (canChangeChoice && auth?.login) { 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<HTMLSelectElement>) => { const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const locationKey = event.target.value as LunchChoice; const locationKey = event.target.value as LunchChoice;
if (canChangeChoice && auth?.login) { if (canChangeChoice && auth?.login) {
await addChoice({ body: { locationKey, dayIndex } }); // Kontrola Pizza day před změnou volby
if (foodChoiceRef.current?.value) { const canProceed = await checkPizzaDayBeforeChange(locationKey);
foodChoiceRef.current.value = ""; 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() {
<div className="user-actions"> <div className="user-actions">
{login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'> {login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
markAsBuyer(); markAsBuyer();
}} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{cursor: 'pointer'}} /> }} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{ cursor: 'pointer' }} />
</span>} </span>}
{login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'> {login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
copyNote(userPayload.note!); copyNote(userPayload.note!);
}} icon={faBasketShopping} className='buyer-icon' /> }} icon={faBasketShopping} className='buyer-icon' />
</span>} </span>}
{login !== auth.login && canChangeChoice && userPayload?.note?.length && <span title='Převzít poznámku'> {login !== auth.login && canChangeChoice && userPayload?.note?.length && <span title='Převzít poznámku'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
copyNote(userPayload.note!); copyNote(userPayload.note!);
}} className='action-icon' icon={faComment} /> }} className='action-icon' icon={faComment} />
</span>} </span>}
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'> {login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
setNoteModalOpen(true); setNoteModalOpen(true);
}} className='action-icon' icon={faNoteSticky} /> }} className='action-icon' icon={faNoteSticky} />
</span>} </span>}
{login === auth.login && canChangeChoice && <span title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`}> {login === auth.login && canChangeChoice && <span title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`}>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
doRemoveChoices(key as LunchChoice); doRemoveChoices(key as LunchChoice);
}} className='action-icon' icon={faTrashCan} /> }} className='action-icon' icon={faTrashCan} />
</span>} </span>}
</div> </div>
</div> </div>
@@ -589,11 +670,11 @@ function App() {
return <div key={foodIndex} className="food-choice-item"> return <div key={foodIndex} className="food-choice-item">
<span className="food-choice-name">{foodName}</span> <span className="food-choice-name">{foodName}</span>
{login === auth.login && canChangeChoice && {login === auth.login && canChangeChoice &&
<span title={`Odstranit ${foodName}`}> <span title={`Odstranit ${foodName}`}>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
doRemoveFoodChoice(restaurantKey, foodIndex); doRemoveFoodChoice(restaurantKey, foodIndex);
}} className='action-icon' icon={faTrashCan} /> }} className='action-icon' icon={faTrashCan} />
</span>} </span>}
</div> </div>
})} })}
</div> </div>
@@ -613,7 +694,7 @@ function App() {
: <div className='no-votes mt-4'>Zatím nikdo nehlasoval...</div> : <div className='no-votes mt-4'>Zatím nikdo nehlasoval...</div>
} }
</div> </div>
{dayIndex === data.todayDayIndex && {dayIndex === data.todayDayIndex && userHasPizzaChoice &&
<div className='pizza-section fade-in'> <div className='pizza-section fade-in'>
{!data.pizzaDay && {!data.pizzaDay &&
<> <>

View File

@@ -6,7 +6,7 @@ import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { getQr } from "./qr"; import { getQr } from "./qr";
import { generateToken, getLogin, verify } from "./auth"; import { generateToken, getLogin, verify } from "./auth";
import { getIsWeekend, InsufficientPermissions, parseToken } from "./utils"; import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
import { getPendingQrs } from "./pizza"; import { getPendingQrs } from "./pizza";
import { initWebsocket } from "./websocket"; import { initWebsocket } from "./websocket";
import pizzaDayRoutes from "./routes/pizzaDayRoutes"; import pizzaDayRoutes from "./routes/pizzaDayRoutes";
@@ -56,7 +56,7 @@ app.get("/api/whoami", (req, res) => {
if (!HTTP_REMOTE_USER_ENABLED) { if (!HTTP_REMOTE_USER_ENABLED) {
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' }); 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"] delete req.headers["cookie"]
console.log(req.headers) console.log(req.headers)
} }
@@ -68,7 +68,7 @@ app.post("/api/login", (req, res) => {
// Autentizace pomocí trusted headers // Autentizace pomocí trusted headers
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME); const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
//const remoteName = req.header('remote-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)); res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
} else { } else {
throw Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??"); 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) { if (HTTP_REMOTE_USER_ENABLED) {
// Autentizace pomocí trusted headers // Autentizace pomocí trusted headers
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME); 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"] delete req.headers["cookie"]
console.log(req.headers) console.log(req.headers)
} }
@@ -168,6 +168,8 @@ app.use(express.static('public'));
app.use((err: any, req: any, res: any, next: any) => { app.use((err: any, req: any, res: any, next: any) => {
if (err instanceof InsufficientPermissions) { if (err instanceof InsufficientPermissions) {
res.status(403).send({ error: err.message }) res.status(403).send({ error: err.message })
} else if (err instanceof PizzaDayConflictError) {
res.status(409).send({ error: err.message })
} else { } else {
res.status(500).send({ error: err.message }) res.status(500).send({ error: err.message })
} }

View File

@@ -113,6 +113,36 @@ export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize
return clientData; 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. * Odstraní danou objednávku pizzy.
* *

View File

@@ -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 getStorage from "./storage";
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants"; import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants";
import { getTodayMock } from "./mock"; 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 storage = getStorage();
const MENU_PREFIX = 'menu'; const MENU_PREFIX = 'menu';
@@ -99,7 +100,7 @@ export async function saveRestaurantWeekMenu(restaurant: Restaurant, date: Date,
const now = new Date().getTime(); const now = new Date().getTime();
let weekMenu = await getMenu(date); let weekMenu = await getMenu(date);
weekMenu ??= [{}, {}, {}, {}, {}]; weekMenu ??= [{}, {}, {}, {}, {}];
// Inicializace struktury pro restauraci // Inicializace struktury pro restauraci
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
weekMenu[i] ??= {}; weekMenu[i] ??= {};
@@ -109,12 +110,12 @@ export async function saveRestaurantWeekMenu(restaurant: Restaurant, date: Date,
food: [], food: [],
}; };
} }
// Uložení dat pro všechny dny // Uložení dat pro všechny dny
for (let i = 0; i < weekData.length && i < weekMenu.length; i++) { for (let i = 0; i < weekData.length && i < weekMenu.length; i++) {
weekMenu[i][restaurant]!.food = weekData[i]; weekMenu[i][restaurant]!.food = weekData[i];
weekMenu[i][restaurant]!.lastUpdate = now; weekMenu[i][restaurant]!.lastUpdate = now;
// Detekce uzavření pro každou restauraci // Detekce uzavření pro každou restauraci
switch (restaurant) { switch (restaurant) {
case 'SLADOVNICKA': case 'SLADOVNICKA':
@@ -139,7 +140,7 @@ export async function saveRestaurantWeekMenu(restaurant: Restaurant, date: Date,
break; break;
} }
} }
// Uložení do storage // Uložení do storage
await storage.setData(getMenuKey(date), weekMenu); 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<any[]> { async function fetchRestaurantWeekMenu(restaurant: Restaurant, firstDay: Date): Promise<any[]> {
const mock = process.env.MOCK_DATA === 'true'; const mock = process.env.MOCK_DATA === 'true';
switch (restaurant) { switch (restaurant) {
case 'SLADOVNICKA': case 'SLADOVNICKA':
return await getMenuSladovnicka(firstDay, mock); return await getMenuSladovnicka(firstDay, mock);
@@ -207,15 +208,15 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for
(!existingMenu?.food?.length && !existingMenu?.closed && lastFetchExpired); (!existingMenu?.food?.length && !existingMenu?.closed && lastFetchExpired);
if (shouldFetch) { if (shouldFetch) {
const firstDay = getFirstWorkDayOfWeek(usedDate); const firstDay = getFirstWorkDayOfWeek(usedDate);
try { try {
const restaurantWeekFood = await fetchRestaurantWeekMenu(restaurant, firstDay); const restaurantWeekFood = await fetchRestaurantWeekMenu(restaurant, firstDay);
// Aktualizace menu pro všechny dny // Aktualizace menu pro všechny dny
for (let i = 0; i < restaurantWeekFood.length && i < weekMenu.length; i++) { for (let i = 0; i < restaurantWeekFood.length && i < weekMenu.length; i++) {
weekMenu[i][restaurant]!.food = restaurantWeekFood[i]; weekMenu[i][restaurant]!.food = restaurantWeekFood[i];
weekMenu[i][restaurant]!.lastUpdate = now; weekMenu[i][restaurant]!.lastUpdate = now;
// Detekce uzavření pro každou restauraci // Detekce uzavření pro každou restauraci
switch (restaurant) { switch (restaurant) {
case 'SLADOVNICKA': case 'SLADOVNICKA':
@@ -240,7 +241,7 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for
break; break;
} }
} }
// Uložení do storage // Uložení do storage
await storage.setData(getMenuKey(usedDate), weekMenu); await storage.setData(getMenuKey(usedDate), weekMenu);
} catch (e: any) { } catch (e: any) {
@@ -405,6 +406,29 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
let data = await getClientData(usedDate); let data = await getClientData(usedDate);
validateTrusted(data, login, trusted); validateTrusted(data, login, trusted);
await validateFoodIndex(locationKey, foodIndex, date); 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í // Pokud měníme pouze lokaci, mažeme případné předchozí
if (foodIndex == null) { if (foodIndex == null) {
data = await removeChoiceIfPresent(login, usedDate); data = await removeChoiceIfPresent(login, usedDate);

View File

@@ -114,6 +114,8 @@ export const checkBodyParams = (req: any, paramNames: string[]) => {
// TODO umístit do samostatného souboru // TODO umístit do samostatného souboru
export class InsufficientPermissions extends Error { } export class InsufficientPermissions extends Error { }
export class PizzaDayConflictError extends Error { }
export const getUsersByLocation = (choices: LunchChoices, login?: string): string[] => { export const getUsersByLocation = (choices: LunchChoices, login?: string): string[] => {
const result: string[] = []; const result: string[] = [];