diff --git a/client/src/App.tsx b/client/src/App.tsx index d48e1d9..829ed7b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -414,8 +414,7 @@ function App() { */} Poslední změny: {dayIndex != null && diff --git a/client/src/components/modals/SettingsModal.tsx b/client/src/components/modals/SettingsModal.tsx index 4c4ba07..cd3ab81 100644 --- a/client/src/components/modals/SettingsModal.tsx +++ b/client/src/components/modals/SettingsModal.tsx @@ -1,5 +1,5 @@ -import { useRef } from "react"; -import { Modal, Button } from "react-bootstrap" +import { useRef, useState } from "react"; +import { Modal, Button, Alert } from "react-bootstrap" import { useSettings } from "../../context/settings"; type Props = { @@ -15,6 +15,41 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly(null); const hideSoupsRef = useRef(null); + // Pro refresh jidel + const refreshPassRef = useRef(null); + const refreshTypeRef = useRef(null); + const [refreshLoading, setRefreshLoading] = useState(false); + const [refreshMessage, setRefreshMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null); + + const handleRefresh = async () => { + const password = refreshPassRef.current?.value; + const type = refreshTypeRef.current?.value; + if (!password || !type) { + setRefreshMessage({ type: 'error', text: 'Zadejte heslo a typ refresh.' }); + return; + } + + setRefreshLoading(true); + setRefreshMessage(null); + + try { + const res = await fetch(`/api/food/refresh?type=${type}&heslo=${encodeURIComponent(password)}`); + const data = await res.json(); + if (res.ok) { + setRefreshMessage({ type: 'success', text: 'Uspesny fetch' }); + if (refreshPassRef.current) { + // Clean hesla xd + refreshPassRef.current.value = ''; + } + } else { + setRefreshMessage({ type: 'error', text: data.error || 'Chyba při obnovování jídelníčku.' }); + } + } catch (error) { } + finally { + setRefreshLoading(false); + } + }; + return

Nastavení

@@ -24,6 +59,48 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly Skrýt polévky +
+

Obnovit jídelníček

+

Ruční refresh dat z restaurací.

+ + {refreshMessage && ( + + {refreshMessage.text} + + )} + +
+ Heslo: e.stopPropagation()} + /> +
+ +
+ Typ refreshe: +
+ + +

Bankovní účet

Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.
Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.

Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.

diff --git a/server/src/index.ts b/server/src/index.ts index 3df3678..6758057 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -9,7 +9,7 @@ import { generateToken, verify } from "./auth"; import { InsufficientPermissions } from "./utils"; import { initWebsocket } from "./websocket"; import pizzaDayRoutes from "./routes/pizzaDayRoutes"; -import foodRoutes from "./routes/foodRoutes"; +import foodRoutes, { refreshMetoda } from "./routes/foodRoutes"; import votingRoutes from "./routes/votingRoutes"; import easterEggRoutes from "./routes/easterEggRoutes"; import statsRoutes from "./routes/statsRoutes"; @@ -96,6 +96,9 @@ app.get("/api/qr", (req, res) => { // ---------------------------------------------------- +// Přeskočení auth pro refresh dat xd +app.use("/api/food/refresh", refreshMetoda); + /** Middleware ověřující JWT token */ app.use("/api/", (req, res, next) => { if (HTTP_REMOTE_USER_ENABLED) { diff --git a/server/src/routes/foodRoutes.ts b/server/src/routes/foodRoutes.ts index d2f9635..0f2236c 100644 --- a/server/src/routes/foodRoutes.ts +++ b/server/src/routes/foodRoutes.ts @@ -1,11 +1,52 @@ import express, { Request, Response } from "express"; import { getLogin, getTrusted } from "../auth"; -import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, getRestaurantMenu } from "../service"; +import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, getRestaurantMenu, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu } from "../service"; 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"; + +// RateLimit na refresh endpoint +interface RateLimitEntry { + count: number; + resetTime: number; +} +const rateLimits: Record = {}; +const RATE_LIMIT = 1; // maximální počet požadavků za minutu +const RATE_LIMIT_WINDOW = 30 * 60 * 1000; // je to v ms (x * 1min) + +// Kontrola ratelimitu +function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean { + const now = Date.now(); + + // Vyčištění starých záznamů + Object.keys(rateLimits).forEach(k => { + if (rateLimits[k].resetTime < now) { + delete rateLimits[k]; + } + }); + + // Kontrola, že záznam existuje a platí + if (rateLimits[key] && rateLimits[key].resetTime > now) { + // Záznam platí a kontroluje se limit + if (rateLimits[key].count >= limit) { + return false; // Překročen limit + } + + // ++ xd + rateLimits[key].count++; + return true; + } else { + // + klic + rateLimits[key] = { + count: 1, + resetTime: now + RATE_LIMIT_WINDOW + }; + return true; + } +} + /** * Ověří a vrátí index dne v týdnu z požadavku, za předpokladu, že byl předán, a je zároveň * roven nebo vyšší indexu dnešního dne. @@ -142,26 +183,84 @@ router.post("/jdemeObed", async (req, res, next) => { }); // /api/food/refresh?type=week&heslo=docasnyheslo -router.get("/refresh", async (req: Request, res: Response) => { +export const refreshMetoda = async (req: Request, res: Response) => { const { type, heslo } = req.query as { type?: string; heslo?: string }; - if (heslo !== "docasnyheslo") { + if (heslo !== "docasnyheslo" && heslo !== "tohleheslopavelnesmizjistit123") { return res.status(403).json({ error: "Neplatné heslo" }); } - if (type !== "week") { + if (!checkRateLimit("refresh") && heslo !== "tohleheslopavelnesmizjistit123") { + return res.status(429).json({ error: "Refresh už se zavolal, chvíli počkej :))" }); + } + if (type !== "week" && type !== "day") { return res.status(400).json({ error: "Neznámý typ refresh" }); } + if (type === "day") { + return res.status(400).json({ error: "ještě neumim TODO..." }); + } try { // Pro všechny restaurace refreshni menu na aktuální týden const restaurants = ["SLADOVNICKA", "TECHTOWER", "ZASTAVKAUMICHALA", "SENKSERIKOVA"] as const; const firstDay = getFirstWorkDayOfWeek(getToday()); const results: Record = {}; + const successfulRestaurants: string[] = []; + const failedRestaurants: string[] = []; + + // Nejdříve načíst všechna data bez ukládání for (const rest of restaurants) { - results[rest] = await getRestaurantMenu(rest, firstDay, true); + try { + const weekData = await fetchRestaurantWeekMenuData(rest, firstDay); + results[rest] = weekData; + + // Kontrola validity dat + if (weekData && weekData.length > 0 && + weekData.some(dayMenu => dayMenu && dayMenu.length > 0)) { + successfulRestaurants.push(rest); + } else { + failedRestaurants.push(rest); + results[rest] = { error: "Žádná validní data" }; + } + } catch (error) { + failedRestaurants.push(rest); + results[rest] = { error: `Chyba při načítání: ${error}` }; + } } - res.status(200).json({ ok: true, refreshed: results }); + + // Pokud se nepodařilo načíst žádnou restauraci + if (successfulRestaurants.length === 0) { + return res.status(400).json({ + error: "Nepodařilo se získat validní data z žádné restaurace", + failed: failedRestaurants, + results: results + }); + } + + // Uložit pouze validní data + for (const rest of successfulRestaurants) { + try { + await saveRestaurantWeekMenu(rest as any, firstDay, results[rest]); + } catch (error) { + console.error(`Chyba při ukládání dat pro ${rest}:`, error); + } + } + + // Připravit odpověď + const response: any = { + ok: true, + refreshed: results, + successful: successfulRestaurants + }; + + if (failedRestaurants.length > 0) { + response.warning = `Nepodařilo se načíst: ${failedRestaurants.join(', ')}`; + response.failed = failedRestaurants; + } + + res.status(200).json(response); } catch (e: any) { res.status(500).json({ error: e?.message || "Chyba při refreshi" }); } -}); +} +router.get("/refresh", refreshMetoda); + export default router; \ No newline at end of file diff --git a/server/src/service.ts b/server/src/service.ts index 2bf7887..4de8c60 100644 --- a/server/src/service.ts +++ b/server/src/service.ts @@ -76,13 +76,105 @@ async function getMenu(date: Date): Promise { } // TODO přesun do restaurants.ts +/** + * Načte menu dané restaurace pro celý týden bez ukládání do storage. + * Používá se pro validaci dat před uložením. + * + * @param restaurant restaurace + * @param firstDay první pracovní den týdne + * @returns pole menu pro jednotlivé dny týdne + */ +export async function fetchRestaurantWeekMenuData(restaurant: Restaurant, firstDay: Date): Promise { + return await fetchRestaurantWeekMenu(restaurant, firstDay); +} + +/** + * Uloží týdenní menu restaurace do storage. + * + * @param restaurant restaurace + * @param date datum z týdne, pro který ukládat menu + * @param weekData data týdenního menu + */ +export async function saveRestaurantWeekMenu(restaurant: Restaurant, date: Date, weekData: any[]): Promise { + const now = new Date().getTime(); + let weekMenu = await getMenu(date); + weekMenu ??= [{}, {}, {}, {}, {}]; + + // Inicializace struktury pro restauraci + for (let i = 0; i < 5; i++) { + weekMenu[i] ??= {}; + weekMenu[i][restaurant] ??= { + lastUpdate: now, + closed: false, + 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': + if (weekData[i].length === 1 && weekData[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') { + weekMenu[i][restaurant]!.closed = true; + } + break; + case 'TECHTOWER': + if (weekData[i]?.length === 1 && weekData[i][0].name.toLowerCase() === 'svátek') { + weekMenu[i][restaurant]!.closed = true; + } + break; + case 'ZASTAVKAUMICHALA': + if (weekData[i]?.length === 1 && weekData[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') { + weekMenu[i][restaurant]!.closed = true; + } + break; + case 'SENKSERIKOVA': + if (weekData[i]?.length === 1 && weekData[i][0].name === 'Pro tento den nebylo zadáno menu.') { + weekMenu[i][restaurant]!.closed = true; + } + break; + } + } + + // Uložení do storage + await storage.setData(getMenuKey(date), weekMenu); +} + +/** + * Načte menu dané restaurace pro celý týden bez ukládání do storage. + * + * @param restaurant restaurace + * @param firstDay první pracovní den týdne + * @returns pole menu pro jednotlivé dny týdne + */ +async function fetchRestaurantWeekMenu(restaurant: Restaurant, firstDay: Date): Promise { + const mock = process.env.MOCK_DATA === 'true'; + + switch (restaurant) { + case 'SLADOVNICKA': + return await getMenuSladovnicka(firstDay, mock); + case 'TECHTOWER': + return await getMenuTechTower(firstDay, mock); + case 'ZASTAVKAUMICHALA': + return await getMenuZastavkaUmichala(firstDay, mock); + case 'SENKSERIKOVA': + return await getMenuSenkSerikova(firstDay, mock); + default: + throw new Error(`Nepodporovaná restaurace: ${restaurant}`); + } +} + /** * Vrátí menu dané restaurace pro předaný den. * Pokud neexistuje, provede stažení menu pro příslušný týden a uložení do DB. * * @param restaurant restaurace * @param date datum, ke kterému získat menu - * @param mock příznak, zda chceme pouze mock data + * @param forceRefresh příznak vynuceného obnovení */ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, forceRefresh = false): Promise { const usedDate = date ?? getToday(); @@ -108,83 +200,45 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for } if (forceRefresh || !weekMenu[dayOfWeekIndex][restaurant]?.food?.length) { const firstDay = getFirstWorkDayOfWeek(usedDate); - const mock = process.env.MOCK_DATA === 'true'; - switch (restaurant) { - case 'SLADOVNICKA': - try { - const sladovnickaFood = await getMenuSladovnicka(firstDay, mock); - for (let i = 0; i < sladovnickaFood.length; i++) { - weekMenu[i][restaurant]!.food = sladovnickaFood[i]; - weekMenu[i][restaurant]!.lastUpdate = now; - // Velice chatrný a nespolehlivý způsob detekce uzavření... - if (sladovnickaFood[i].length === 1 && sladovnickaFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') { + + 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': + if (restaurantWeekFood[i].length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') { weekMenu[i][restaurant]!.closed = true; } - } - } catch (e: any) { - console.error("Selhalo načtení jídel pro podnik Sladovnická", e); - } - break; - // case 'UMOTLIKU': - // try { - // const uMotlikuFood = await getMenuUMotliku(firstDay, mock); - // for (let i = 0; i < uMotlikuFood.length; i++) { - // menus[i][restaurant]!.food = uMotlikuFood[i]; - // if (uMotlikuFood[i].length === 1 && uMotlikuFood[i][0].name.toLowerCase() === 'zavřeno') { - // menus[i][restaurant]!.closed = true; - // } - // } - // } catch (e: any) { - // console.error("Selhalo načtení jídel pro podnik U Motlíků", e); - // } - // break; - case 'TECHTOWER': - try { - const techTowerFood = await getMenuTechTower(firstDay, mock); - for (let i = 0; i < techTowerFood.length; i++) { - weekMenu[i][restaurant]!.food = techTowerFood[i]; - weekMenu[i][restaurant]!.lastUpdate = now; - if (techTowerFood[i]?.length === 1 && techTowerFood[i][0].name.toLowerCase() === 'svátek') { + break; + case 'TECHTOWER': + if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'svátek') { weekMenu[i][restaurant]!.closed = true; } - } - } catch (e: any) { - console.error("Selhalo načtení jídel pro podnik TechTower", e); - } - break; - case 'ZASTAVKAUMICHALA': - try { - const zastavkaUmichalaFood = await getMenuZastavkaUmichala(firstDay, mock); - for (let i = 0; i < zastavkaUmichalaFood.length; i++) { - weekMenu[i][restaurant]!.food = zastavkaUmichalaFood[i]; - weekMenu[i][restaurant]!.lastUpdate = now; - if (zastavkaUmichalaFood[i]?.length === 1 && zastavkaUmichalaFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') { + break; + case 'ZASTAVKAUMICHALA': + if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') { weekMenu[i][restaurant]!.closed = true; } - } - } catch (e: any) { - console.error("Selhalo načtení jídel pro podnik Zastávka u Michala", e); - } - break; - case 'SENKSERIKOVA': - try { - const senkSerikovaFood = await getMenuSenkSerikova(firstDay, mock); - for (let i = 0; i < senkSerikovaFood.length; i++) { - if (i >= weekMenu.length) { - break; - } - weekMenu[i][restaurant]!.food = senkSerikovaFood[i]; - weekMenu[i][restaurant]!.lastUpdate = now; - if (senkSerikovaFood[i]?.length === 1 && senkSerikovaFood[i][0].name === 'Pro tento den nebylo zadáno menu.') { + break; + case 'SENKSERIKOVA': + if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name === 'Pro tento den nebylo zadáno menu.') { weekMenu[i][restaurant]!.closed = true; } - } - } catch (e: any) { - console.error("Selhalo načtení jídel pro podnik Pivovarský šenk Šeříková", e); + break; } - break; + } + + // Uložení do storage + await storage.setData(getMenuKey(usedDate), weekMenu); + } catch (e: any) { + console.error(`Selhalo načtení jídel pro podnik ${restaurant}`, e); } - await storage.setData(getMenuKey(usedDate), weekMenu); } return weekMenu[dayOfWeekIndex][restaurant]!; }