4 Commits

Author SHA1 Message Date
batmanisko 2e8db88f07 feat: úhrada za všechny jednou osobou (issue #29, SINGLE_PAYMENT)
Přidává možnost, aby jeden strávník zaplatil celý účet v restauraci a ostatní
obdrželi QR kód pro refundaci.

Prerekvizita — podpora více QR kódů na (příjemce, den):
- PendingQr.id (UUID) nahrazuje deduplikaci podle data; každý QR má vlastní klíč
- QR obrázky uloženy do Redis/storage (base64) místo tmpdir — přežijí redeploy
- GET /api/qr vyžaduje ?id= parametr; dismissQr přijímá {id} místo {date}

Feature:
- Ikona 'Zaplatit za všechny' v choices-table pro každou LunchChoice (kromě
  PIZZA/NEOBEDVAM/ROZHODUJI); viditelná jen při ≥2 strávnících a vyplněném účtu
- PayForAllModal: tabulka strávníků s prefillovanými cenami z menu, příplatky
  per-diner, celkové dýško rozpočtené rovnoměrně, generování QR přes POST /api/qr/generate
- parsePriceCzk() helper pro parsing 'N Kč' → number

Co se nemění: POST /api/qr/generate API kontrakt, PizzaOrder.hasQr boolean

Co se mění v OpenAPI: PendingQr.id (required), getPizzaQr ?id param, dismissQr body

Co-Authored-By: opmrdkazkrtkaus <opmrdkazkrtkaus@melancholik.eu>
2026-04-28 22:35:15 +02:00
batmanisko a1b1eed86d docs: přidána strategie vyhledávání kódu do CLAUDE.md
ci/woodpecker/push/workflow Pipeline was successful
2026-03-05 22:13:19 +01:00
batmanisko f8a65d7177 feat: detekce starého menu TechTower, příznak isStale
Pokud TechTower vrátí menu z jiného týdne, uloží data s příznakem
isStale a zobrazí varování "Data jsou z minulého týdne" místo chybové
hlášky. Odstraněno staré varování o datech starších 24 hodin.
2026-03-05 22:11:45 +01:00
batmanisko 607bcd9bf5 feat: uprava refresh menu hesel
každý může udělat refresh, jen ne tak často, bypass mimo zdrojak
2026-03-05 21:50:17 +01:00
16 changed files with 492 additions and 78 deletions
+12
View File
@@ -97,3 +97,15 @@ cd server && yarn test # Jest (tests in server/src/tests/)
- Czech naming for domain variables and UI strings; English for infrastructure code - Czech naming for domain variables and UI strings; English for infrastructure code
- TypeScript strict mode in both client and server - TypeScript strict mode in both client and server
- Server module resolution: Node16; Client: ESNext/bundler - Server module resolution: Node16; Client: ESNext/bundler
## Code Search Strategy
When searching through the project for information, use the Task tool to spawn
subagents. Each subagent should read the relevant files and return a brief
summary of what it found (not the full file contents). This keeps the main
context window small and saves tokens. Only pull in full file contents once
you've identified the specific files that matter.
When using subagents to search, each subagent should return:
- File path
- Whether it's relevant (yes/no)
- 1-3 sentence summary of what's in the file
Do NOT return full file contents in subagent responses.
+42 -12
View File
@@ -13,12 +13,13 @@ import './App.scss';
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons'; import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
import { useSettings } from './context/settings'; import { useSettings } from './context/settings';
import Footer from './components/Footer'; import Footer from './components/Footer';
import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import Loader from './components/Loader'; import Loader from './components/Loader';
import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils'; import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils';
import NoteModal from './components/modals/NoteModal'; import NoteModal from './components/modals/NoteModal';
import PayForAllModal from './components/modals/PayForAllModal';
import { useEasterEgg } from './context/eggs'; import { useEasterEgg } from './context/eggs';
import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr } from '../../types'; 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 { getLunchChoiceName } from './enums'; import { getLunchChoiceName } from './enums';
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves'; // import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
// import './FallingLeaves.scss'; // import './FallingLeaves.scss';
@@ -74,6 +75,7 @@ function App() {
const [dayIndex, setDayIndex] = useState<number>(); const [dayIndex, setDayIndex] = useState<number>();
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false); const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false); const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null);
const [eggImage, setEggImage] = useState<Blob>(); const [eggImage, setEggImage] = useState<Blob>();
const eggRef = useRef<HTMLImageElement>(null); const eggRef = useRef<HTMLImageElement>(null);
// Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu // Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu
@@ -615,6 +617,18 @@ function App() {
<td> <td>
{locationName} {locationName}
{(locationPickCount ?? 0) > 1 && <span className="ms-1">({locationPickCount})</span>} {(locationPickCount ?? 0) > 1 && <span className="ms-1">({locationPickCount})</span>}
{locationPickCount >= 2 && auth.login && loginObject[auth.login] !== undefined
&& locationKey !== LunchChoice.PIZZA && locationKey !== LunchChoice.NEOBEDVAM && locationKey !== LunchChoice.ROZHODUJI
&& settings?.bankAccount && settings?.holderName && (
<span title='Zaplatit za všechny a vygenerovat QR kódy ostatním' className="ms-2">
<FontAwesomeIcon
icon={faMoneyBillTransfer}
onClick={() => setPayForAllLocationKey(locationKey)}
className='action-icon'
style={{ cursor: 'pointer' }}
/>
</span>
)}
</td> </td>
<td className='p-0'> <td className='p-0'>
<Table className="nested-table"> <Table className="nested-table">
@@ -807,11 +821,15 @@ function App() {
} }
<PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} /> <PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} />
{ {
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr && data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr && (() => {
<div className='qr-code'> const pizzaQr = data.pendingQrs?.find(qr => qr.creator === data.pizzaDay?.creator);
<h3>QR platba</h3> return pizzaQr ? (
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' /> <div className='qr-code'>
</div> <h3>QR platba</h3>
<img src={`/api/qr?login=${auth.login}&id=${pizzaQr.id}`} alt='QR kód' />
</div>
) : null;
})()
} }
</> </>
} }
@@ -821,18 +839,17 @@ function App() {
{data.pendingQrs && data.pendingQrs.length > 0 && {data.pendingQrs && data.pendingQrs.length > 0 &&
<div className='pizza-section fade-in mt-4'> <div className='pizza-section fade-in mt-4'>
<h3>Nevyřízené platby</h3> <h3>Nevyřízené platby</h3>
<p>Máte neuhrazené platby z předchozích dní.</p> <p>Máte neuhrazené platby.</p>
{data.pendingQrs.map(qr => ( {data.pendingQrs.map(qr => (
<div key={qr.date} className='qr-code mb-3'> <div key={qr.id} className='qr-code mb-3'>
<p> <p>
<strong>{formatDateString(qr.date)}</strong> {qr.creator} ({qr.totalPrice} ) <strong>{formatDateString(qr.date)}</strong> {qr.creator} ({qr.totalPrice} )
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>} {qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
</p> </p>
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' /> <img src={`/api/qr?login=${auth.login}&id=${qr.id}`} alt='QR kód' />
<div className='mt-2'> <div className='mt-2'>
<Button variant="success" onClick={async () => { <Button variant="success" onClick={async () => {
await dismissQr({ body: { date: qr.date } }); await dismissQr({ body: { id: qr.id } });
// Přenačteme data pro aktualizaci
const response = await getData({ query: { dayIndex } }); const response = await getData({ query: { dayIndex } });
if (response.data) { if (response.data) {
setData(response.data); setData(response.data);
@@ -853,6 +870,19 @@ function App() {
/> */} /> */}
<Footer /> <Footer />
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} /> <NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
{payForAllLocationKey && data && (
<PayForAllModal
isOpen
onClose={() => setPayForAllLocationKey(null)}
locationKey={payForAllLocationKey}
locationName={getLunchChoiceName(payForAllLocationKey)}
locationChoices={data.choices[payForAllLocationKey as keyof typeof data.choices] as LocationLunchChoicesMap}
menu={food?.[payForAllLocationKey as Restaurant]}
payerLogin={auth.login ?? ''}
bankAccount={settings?.bankAccount ?? ''}
bankAccountHolder={settings?.holderName ?? ''}
/>
)}
</div> </div>
); );
} }
@@ -0,0 +1,308 @@
import { useState, useEffect, useCallback } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { generateQr, LunchChoice, LocationLunchChoicesMap, RestaurantDayMenu, QrRecipient } from "../../../../types";
import { parsePriceCzk } from "../../utils/parsePrice";
type DinerEntry = {
login: string;
selectedFoods: number[];
baseAmount: number;
baseAmountParseFailed: boolean;
surchargeText: string;
surchargeAmount: string;
included: boolean;
};
type Props = {
isOpen: boolean;
onClose: () => void;
locationKey: LunchChoice;
locationName: string;
locationChoices: LocationLunchChoicesMap;
menu: RestaurantDayMenu | undefined;
payerLogin: string;
bankAccount: string;
bankAccountHolder: string;
};
function sanitizeAmount(value: string): string {
return value.replace(/[^0-9.,]/g, '').replace(',', '.');
}
function parseAmount(s: string): number | null {
if (!s || s.trim().length === 0) return null;
const n = parseFloat(s);
if (isNaN(n) || n < 0) return null;
const parts = s.split('.');
if (parts.length === 2 && parts[1].length > 2) return null;
return Math.round(n * 100) / 100;
}
export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
const [diners, setDiners] = useState<DinerEntry[]>([]);
const [tipTotal, setTipTotal] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const hasMenu = !!menu;
useEffect(() => {
if (!isOpen) return;
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
const selectedFoods = choice.selectedFoods ?? [];
let baseAmount = 0;
let baseAmountParseFailed = false;
if (menu) {
for (const idx of selectedFoods) {
const price = parsePriceCzk(menu.food?.[idx]?.price);
if (price === null) {
baseAmountParseFailed = true;
} else {
baseAmount += price;
}
}
}
return {
login,
selectedFoods,
baseAmount,
baseAmountParseFailed,
surchargeText: '',
surchargeAmount: '',
included: login !== payerLogin,
};
});
setDiners(entries);
setTipTotal('');
setError(null);
setSuccess(false);
}, [isOpen, locationChoices, menu, payerLogin]);
const includedDiners = diners.filter(d => d.included && d.login !== payerLogin);
const tipPerPerson = (() => {
if (includedDiners.length === 0) return 0;
const tip = parseAmount(tipTotal);
if (tip === null || tip === 0) return 0;
return Math.round((tip / includedDiners.length) * 100) / 100;
})();
const getTotal = (d: DinerEntry): number => {
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
const tip = d.included && d.login !== payerLogin ? tipPerPerson : 0;
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
};
const handleInclude = useCallback((login: string, checked: boolean) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
}, []);
const handleSurchargeText = useCallback((login: string, value: string) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeText: value } : d));
}, []);
const handleSurchargeAmount = useCallback((login: string, value: string) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeAmount: sanitizeAmount(value) } : d));
}, []);
const handleGenerate = async () => {
setError(null);
const recipients: QrRecipient[] = [];
for (const d of diners) {
if (!d.included || d.login === payerLogin) continue;
const total = getTotal(d);
if (total <= 0) {
setError(`Celková částka pro ${d.login} musí být kladná`);
return;
}
const amountStr = total.toString();
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
setError(`Částka pro ${d.login} má více než 2 desetinná místa`);
return;
}
const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
const purposeBase = `Oběd ${locationName}${foods ? `${foods}` : ''}`;
recipients.push({
login: d.login,
purpose: purposeBase.substring(0, 60),
amount: total,
});
}
if (recipients.length === 0) {
setError("Nebyl vybrán žádný příjemce");
return;
}
setLoading(true);
try {
const response = await generateQr({
body: { recipients, bankAccount, bankAccountHolder },
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
} else {
setSuccess(true);
setTimeout(() => onClose(), 2000);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování QR kódů');
} finally {
setLoading(false);
}
};
const anyParseFailed = diners.some(d => d.baseAmountParseFailed && d.included);
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Zaplatit za všechny {locationName}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci Nevyřízené platby".
</Alert>
) : (
<>
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a dýško, poté vygenerujte QR kódy pro ostatní.</p>
{!hasMenu && (
<Alert variant="info">
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
</Alert>
)}
{anyParseFailed && (
<Alert variant="warning">
U některých jídel se nepodařilo načíst cenu — doplňte ji ručně v sloupci Příplatek.
</Alert>
)}
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<Table striped bordered hover responsive size="sm">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Strávník</th>
<th>Jídla</th>
<th style={{ width: 220 }}>Příplatek</th>
<th style={{ width: 90 }}>Dýško</th>
<th style={{ width: 90 }}>Celkem</th>
</tr>
</thead>
<tbody>
{diners.map(d => {
const isPayer = d.login === payerLogin;
const foodNames = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
const total = getTotal(d);
return (
<tr key={d.login} className={!d.included && !isPayer ? 'text-muted' : ''}>
<td className="text-center">
{isPayer ? (
<small className="text-muted">plátce</small>
) : (
<Form.Check
type="checkbox"
checked={d.included}
onChange={e => handleInclude(d.login, e.target.checked)}
/>
)}
</td>
<td><strong>{d.login}</strong></td>
<td>
<small>
{foodNames || <span className="text-muted">—</span>}
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount} Kč)</span>}
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
</small>
</td>
<td>
{!isPayer && (
<div className="d-flex gap-1">
<Form.Control
type="text"
placeholder="popis"
value={d.surchargeText}
onChange={e => handleSurchargeText(d.login, e.target.value)}
disabled={!d.included}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
<Form.Control
type="text"
placeholder=""
value={d.surchargeAmount}
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
disabled={!d.included}
size="sm"
style={{ width: 70 }}
onKeyDown={e => e.stopPropagation()}
/>
</div>
)}
</td>
<td className="text-end">
{!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
</td>
<td className="text-end fw-bold">
{!isPayer ? `${total} Kč` : '—'}
</td>
</tr>
);
})}
</tbody>
</Table>
<div className="d-flex align-items-center gap-2 mt-2">
<label className="mb-0 text-nowrap">Dýško celkem (Kč):</label>
<Form.Control
type="text"
placeholder="0"
value={tipTotal}
onChange={e => setTipTotal(sanitizeAmount(e.target.value))}
size="sm"
style={{ width: 100 }}
onKeyDown={e => e.stopPropagation()}
/>
<small className="text-muted">
{includedDiners.length > 0 && tipPerPerson > 0
? `(${tipPerPerson} Kč / osoba)`
: ''}
</small>
</div>
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<span className="me-auto text-muted">
Příjemci: {includedDiners.length}
</span>
<Button variant="secondary" onClick={onClose} disabled={loading}>
Storno
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={loading || includedDiners.length === 0}
>
{loading ? 'Generuji...' : 'Vygenerovat QR'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
)}
</Modal.Footer>
</Modal>
);
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Parsuje cenu ve formátu "135 Kč", "135,50 Kč" nebo "135.50 Kč" na číslo.
* Vrátí null při selhání.
*/
export function parsePriceCzk(raw: string | undefined): number | null {
if (!raw) return null;
const m = raw.replace(',', '.').match(/(\d+(?:\.\d+)?)/);
if (!m) return null;
const n = parseFloat(m[1]);
return Number.isFinite(n) ? n : null;
}
+5 -1
View File
@@ -43,4 +43,8 @@
# Vygenerovat pomocí: npx web-push generate-vapid-keys # Vygenerovat pomocí: npx web-push generate-vapid-keys
# VAPID_PUBLIC_KEY= # VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY= # VAPID_PRIVATE_KEY=
# VAPID_SUBJECT=mailto:admin@example.com # VAPID_SUBJECT=mailto:admin@example.com
# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin).
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
# REFRESH_BYPASS_PASSWORD=
+7 -4
View File
@@ -86,12 +86,15 @@ app.post("/api/login", (req, res) => {
} }
}); });
// TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token // QR se zobrazuje přes <img>, nemáme sem jak dostat token
app.get("/api/qr", (req, res) => { app.get("/api/qr", async (req, res) => {
if (!req.query?.login) { if (!req.query?.login) {
throw Error("Nebyl předán login"); return res.status(400).json({ error: "Nebyl předán login" });
} }
const img = getQr(req.query.login as string); if (!req.query?.id) {
return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" });
}
const img = await getQr(req.query.login as string, req.query.id as string);
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'image/png', 'Content-Type': 'image/png',
'Content-Length': img.length 'Content-Length': img.length
+9 -6
View File
@@ -5,6 +5,7 @@ import getStorage from "./storage";
import { downloadPizzy } from "./chefie"; import { downloadPizzy } from "./chefie";
import { getClientData, getToday, initIfNeeded } from "./service"; import { getClientData, getToday, initIfNeeded } from "./service";
import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen"; import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen";
import crypto from "crypto";
const storage = getStorage(); const storage = getStorage();
const PENDING_QR_PREFIX = 'pending_qr'; const PENDING_QR_PREFIX = 'pending_qr';
@@ -269,11 +270,13 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
if (bankAccount?.length && bankAccountHolder?.length) { if (bankAccount?.length && bankAccountHolder?.length) {
for (const order of clientData.pizzaDay.orders!) { for (const order of clientData.pizzaDay.orders!) {
if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
const id = crypto.randomUUID();
let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', '); let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message); await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message, id);
order.hasQr = true; order.hasQr = true;
// Uložíme nevyřízený QR kód pro persistentní zobrazení // Uložíme nevyřízený QR kód pro persistentní zobrazení
await addPendingQr(order.customer, { await addPendingQr(order.customer, {
id,
date: today, date: today,
creator: login, creator: login,
totalPrice: order.totalPrice, totalPrice: order.totalPrice,
@@ -360,8 +363,8 @@ function getPendingQrKey(login: string): string {
export async function addPendingQr(login: string, pendingQr: PendingQr): Promise<void> { export async function addPendingQr(login: string, pendingQr: PendingQr): Promise<void> {
const key = getPendingQrKey(login); const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? []; const existing = await storage.getData<PendingQr[]>(key) ?? [];
// Nepřidáváme duplicity pro stejný den // Deduplikace podle id (ne podle data — jeden den může mít uživatel víc QR kódů)
if (!existing.some(qr => qr.date === pendingQr.date)) { if (!existing.some(qr => qr.id === pendingQr.id)) {
existing.push(pendingQr); existing.push(pendingQr);
await storage.setData(key, existing); await storage.setData(key, existing);
} }
@@ -375,11 +378,11 @@ export async function getPendingQrs(login: string): Promise<PendingQr[]> {
} }
/** /**
* Označí QR kód pro daný den jako uhrazený (odstraní ho ze seznamu nevyřízených). * Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených).
*/ */
export async function dismissPendingQr(login: string, date: string): Promise<void> { export async function dismissPendingQr(login: string, id: string): Promise<void> {
const key = getPendingQrKey(login); const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? []; const existing = await storage.getData<PendingQr[]>(key) ?? [];
const filtered = existing.filter(qr => qr.date !== date); const filtered = existing.filter(qr => qr.id !== id);
await storage.setData(key, filtered); await storage.setData(key, filtered);
} }
+26 -30
View File
@@ -1,19 +1,17 @@
import fs from "fs";
import axios from "axios"; import axios from "axios";
import os from "os";
import path from "path";
import crypto from "crypto"; import crypto from "crypto";
import { formatDate } from "./utils"; import getStorage from "./storage";
const QR_GENERATOR_URL = 'https://api.paylibo.com/paylibo/generator/image'; const QR_GENERATOR_URL = 'https://api.paylibo.com/paylibo/generator/image';
const COUNTRY_CODE = 'CZ'; const COUNTRY_CODE = 'CZ';
const CURRENCY_CODE = 'CZK'; const CURRENCY_CODE = 'CZK';
const QR_PIXEL_SIZE = 256; const QR_PIXEL_SIZE = 256;
const tmpDir = os.tmpdir();
const storage = getStorage();
/** /**
* Převede číslo účtu z BBAN do IBAN. Automaticky dopočítá kontrolní číslice. * Převede číslo účtu z BBAN do IBAN. Automaticky dopočítá kontrolní číslice.
* *
* @param bankAccountNumber číslo účtu ve formátu BBAN (123456-0123456789/0100) * @param bankAccountNumber číslo účtu ve formátu BBAN (123456-0123456789/0100)
*/ */
function convertBbanToIban(bankAccountNumber: string): string { function convertBbanToIban(bankAccountNumber: string): string {
@@ -41,26 +39,23 @@ function convertBbanToIban(bankAccountNumber: string): string {
return iban; return iban;
} }
function createNameHash(customerName: string): string { function createStorageKey(customerName: string, id: string): string {
return crypto.createHash('md5').update(customerName).digest('hex'); const nameHash = crypto.createHash('md5').update(customerName).digest('hex');
} return `qr_${nameHash}_${id}`;
function createFilePath(nameHash: string): string {
const fileName = `${formatDate(new Date())}_${nameHash}.png`;
return path.join(tmpDir, fileName);
} }
/** /**
* Vygeneruje, uloží a vrátí unikátní ID obrázku platebního QR kódu s danými parametry. * Vygeneruje a uloží obrázek platebního QR kódu do storage (Redis/JSON).
* * Data přežijí redeploy — není třeba persistentní filesystém.
*
* @param customerName jméno uživatele, pro kterého je QR kód generován * @param customerName jméno uživatele, pro kterého je QR kód generován
* @param bankAccountNumber číslo cílového bankovního účtu ve formátu BBAN * @param bankAccountNumber číslo cílového bankovního účtu ve formátu BBAN
* @param bankAccountHolder jméno držitele cílového bankovního účtu * @param bankAccountHolder jméno držitele cílového bankovního účtu
* @param amount částka v Kč * @param amount částka v Kč
* @param message zpráva pro příjemce * @param message zpráva pro příjemce
* @returns hash, pomocí kterého lze následně získat vygenerovaný obrázek * @param id unikátní identifikátor (UUID) tohoto QR kódu
*/ */
export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string): Promise<string> { export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string, id: string): Promise<void> {
// Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků // Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků
if (message.indexOf('*') >= 0) { if (message.indexOf('*') >= 0) {
message = message.replace('*', ''); message = message.replace('*', '');
@@ -77,22 +72,23 @@ export async function generateQr(customerName: string, bankAccountNumber: string
branding: false, branding: false,
compress: false, compress: false,
size: QR_PIXEL_SIZE, size: QR_PIXEL_SIZE,
} };
const response = await axios.get(QR_GENERATOR_URL, { responseType: 'stream', params: { ...payload } }); const response = await axios.get(QR_GENERATOR_URL, { responseType: 'arraybuffer', params: { ...payload } });
// Použijeme hash, abychom nemuseli řešit nepovolené znaky ve jménu uživatele const base64 = Buffer.from(response.data).toString('base64');
const nameHash = createNameHash(customerName); await storage.setData(createStorageKey(customerName, id), base64);
const imgPath = createFilePath(nameHash);
response.data.pipe(fs.createWriteStream(imgPath));
return nameHash;
} }
/** /**
* Vrátí obrázek s QR kódem, pokud existuje. * Vrátí obrázek s QR kódem ze storage.
* *
* @param customerName jméno uživatele * @param customerName jméno uživatele
* @param id unikátní identifikátor QR kódu
* @returns data obrázku * @returns data obrázku
*/ */
export function getQr(customerName: string): Buffer { export async function getQr(customerName: string, id: string): Promise<Buffer> {
const imgPath = createFilePath(createNameHash(customerName)); const base64 = await storage.getData<string>(createStorageKey(customerName, id));
return fs.readFileSync(imgPath); if (!base64) {
} throw new Error("QR kód nebyl nalezen");
}
return Buffer.from(base64, 'base64');
}
+16 -1
View File
@@ -4,6 +4,10 @@ import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock, getM
import { formatDate } from "./utils"; import { formatDate } from "./utils";
import { Food } from "../../types/gen/types.gen"; import { Food } from "../../types/gen/types.gen";
export class StaleWeekError extends Error {
constructor(public food: Food[][]) { super('Data jsou z jiného týdne'); }
}
// Fráze v názvech jídel, které naznačují že se jedná o polévku // Fráze v názvech jídel, které naznačují že se jedná o polévku
const SOUP_NAMES = [ const SOUP_NAMES = [
'polévka', 'polévka',
@@ -299,7 +303,6 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
} }
const result: Food[][] = []; const result: Food[][] = [];
// TODO validovat, že v textu nalezeného <font> je rozsah, do kterého spadá vstupní datum
const siblings = secondTry ? $(font).parent().parent().parent().siblings('p') : $(font).parent().parent().siblings(); const siblings = secondTry ? $(font).parent().parent().parent().siblings('p') : $(font).parent().parent().siblings();
let parsing = false; let parsing = false;
let currentDayIndex = 0; let currentDayIndex = 0;
@@ -345,6 +348,18 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
}) })
} }
} }
// Validace, zda text hlavičky obsahuje datum odpovídající požadovanému týdnu
const headerText = $(font).text().trim();
const dateMatch = headerText.match(/(\d{1,2})\.(\d{1,2})\./);
if (dateMatch) {
const foundDay = parseInt(dateMatch[1]);
const foundMonth = parseInt(dateMatch[2]) - 1; // JS months are 0-based
if (foundDay !== firstDayOfWeek.getDate() || foundMonth !== firstDayOfWeek.getMonth()) {
throw new StaleWeekError(result);
}
}
return result; return result;
} }
+11 -4
View File
@@ -191,13 +191,20 @@ router.post("/updateBuyer", async (req, res, next) => {
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
// /api/food/refresh?type=week&heslo=docasnyheslo // /api/food/refresh?type=week (přihlášený uživatel, nebo ?heslo=... pro bypass rate limitu)
export const refreshMetoda = async (req: Request, res: Response) => { export const refreshMetoda = async (req: Request, res: Response) => {
const { type, heslo } = req.query as { type?: string; heslo?: string }; const { type, heslo } = req.query as { type?: string; heslo?: string };
if (heslo !== "docasnyheslo" && heslo !== "tohleheslopavelnesmizjistit123") { const bypassPassword = process.env.REFRESH_BYPASS_PASSWORD;
return res.status(403).json({ error: "Neplatné heslo" }); const isBypass = !!bypassPassword && heslo === bypassPassword;
if (!isBypass) {
try {
getLogin(parseToken(req));
} catch {
return res.status(403).json({ error: "Přihlaste se prosím" });
}
} }
if (!checkRateLimit("refresh") && heslo !== "tohleheslopavelnesmizjistit123") { if (!checkRateLimit("refresh") && !isBypass) {
return res.status(429).json({ error: "Refresh už se zavolal, chvíli počkej :))" }); return res.status(429).json({ error: "Refresh už se zavolal, chvíli počkej :))" });
} }
if (type !== "week" && type !== "day") { if (type !== "week" && type !== "day") {
+3 -3
View File
@@ -112,11 +112,11 @@ router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeData["
/** Označí QR kód jako uhrazený. */ /** Označí QR kód jako uhrazený. */
router.post("/dismissQr", async (req: Request<{}, any, DismissQrData["body"]>, res, next) => { router.post("/dismissQr", async (req: Request<{}, any, DismissQrData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
if (!req.body.date) { if (!req.body.id) {
return res.status(400).json({ error: "Nebyl předán datum" }); return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" });
} }
try { try {
await dismissPendingQr(login, req.body.date); await dismissPendingQr(login, req.body.id);
res.status(200).json({}); res.status(200).json({});
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
+4 -1
View File
@@ -4,6 +4,7 @@ import { parseToken, formatDate } from "../utils";
import { generateQr } from "../qr"; import { generateQr } from "../qr";
import { addPendingQr } from "../pizza"; import { addPendingQr } from "../pizza";
import { GenerateQrData } from "../../../types"; import { GenerateQrData } from "../../../types";
import crypto from "crypto";
const router = express.Router(); const router = express.Router();
@@ -44,10 +45,12 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r
} }
// Vygenerovat QR kód // Vygenerovat QR kód
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose); const id = crypto.randomUUID();
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose, id);
// Uložit jako nevyřízený QR kód // Uložit jako nevyřízený QR kód
await addPendingQr(recipient.login, { await addPendingQr(recipient.login, {
id,
date: today, date: today,
creator: login, creator: login,
totalPrice: recipient.amount, totalPrice: recipient.amount,
+17 -8
View File
@@ -1,6 +1,6 @@
import { InsufficientPermissions, PizzaDayConflictError, 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, StaleWeekError } from "./restaurants";
import { getTodayMock } from "./mock"; import { getTodayMock } from "./mock";
import { removeAllUserPizzas } from "./pizza"; import { removeAllUserPizzas } from "./pizza";
import { ClientData, DepartureTime, LunchChoice, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen"; import { ClientData, DepartureTime, LunchChoice, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
@@ -216,6 +216,7 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for
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;
weekMenu[i][restaurant]!.isStale = false;
// Detekce uzavření pro každou restauraci // Detekce uzavření pro každou restauraci
switch (restaurant) { switch (restaurant) {
@@ -245,22 +246,34 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for
// 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) {
console.error(`Selhalo načtení jídel pro podnik ${restaurant}`, e); if (e instanceof StaleWeekError) {
for (let i = 0; i < e.food.length && i < weekMenu.length; i++) {
weekMenu[i][restaurant]!.food = e.food[i];
weekMenu[i][restaurant]!.lastUpdate = now;
weekMenu[i][restaurant]!.isStale = true;
}
await storage.setData(getMenuKey(usedDate), weekMenu);
} else {
console.error(`Selhalo načtení jídel pro podnik ${restaurant}`, e);
}
} }
} }
const result = weekMenu[dayOfWeekIndex][restaurant]!; const result = weekMenu[dayOfWeekIndex][restaurant]!;
result.warnings = generateMenuWarnings(result, now); result.warnings = generateMenuWarnings(result);
return result; return result;
} }
/** /**
* Generuje varování o kvalitě/úplnosti dat menu restaurace. * Generuje varování o kvalitě/úplnosti dat menu restaurace.
*/ */
function generateMenuWarnings(menu: RestaurantDayMenu, now: number): string[] { function generateMenuWarnings(menu: RestaurantDayMenu): string[] {
const warnings: string[] = []; const warnings: string[] = [];
if (!menu.food?.length || menu.closed) { if (!menu.food?.length || menu.closed) {
return warnings; return warnings;
} }
if (menu.isStale) {
warnings.push('Data jsou z minulého týdne');
}
const hasSoup = menu.food.some(f => f.isSoup); const hasSoup = menu.food.some(f => f.isSoup);
if (!hasSoup) { if (!hasSoup) {
warnings.push('Chybí polévka'); warnings.push('Chybí polévka');
@@ -269,10 +282,6 @@ function generateMenuWarnings(menu: RestaurantDayMenu, now: number): string[] {
if (missingPrice) { if (missingPrice) {
warnings.push('U některých jídel chybí cena'); warnings.push('U některých jídel chybí cena');
} }
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
if (menu.lastUpdate && (now - menu.lastUpdate) > STALE_THRESHOLD_MS) {
warnings.push('Data jsou starší než 24 hodin');
}
return warnings; return warnings;
} }
+7 -1
View File
@@ -1,6 +1,6 @@
get: get:
operationId: getPizzaQr operationId: getPizzaQr
summary: Získání QR kódu pro platbu za Pizza day summary: Získání QR kódu pro platbu
security: [] # Nevyžaduje autentizaci security: [] # Nevyžaduje autentizaci
parameters: parameters:
- in: query - in: query
@@ -9,6 +9,12 @@ get:
type: string type: string
required: true required: true
description: Přihlašovací jméno uživatele, pro kterého bude vrácen QR kód description: Přihlašovací jméno uživatele, pro kterého bude vrácen QR kód
- in: query
name: id
schema:
type: string
required: true
description: Unikátní identifikátor QR kódu (z PendingQr.id)
responses: responses:
"200": "200":
description: Vygenerovaný QR kód pro platbu description: Vygenerovaný QR kód pro platbu
+4 -4
View File
@@ -1,17 +1,17 @@
post: post:
operationId: dismissQr operationId: dismissQr
summary: Označí QR kód pro daný den jako uhrazený (odstraní ho ze seznamu nevyřízených). summary: Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených).
requestBody: requestBody:
required: true required: true
content: content:
application/json: application/json:
schema: schema:
properties: properties:
date: id:
description: Datum Pizza day, ke kterému se QR kód vztahuje description: Unikátní identifikátor QR kódu (z PendingQr.id)
type: string type: string
required: required:
- date - id
responses: responses:
"200": "200":
description: QR kód byl označen jako uhrazený. description: QR kód byl označen jako uhrazený.
+10 -3
View File
@@ -186,6 +186,9 @@ RestaurantDayMenu:
type: array type: array
items: items:
type: string type: string
isStale:
description: Příznak, zda data mohou pocházet z jiného týdne
type: boolean
RestaurantDayMenuMap: RestaurantDayMenuMap:
description: Objekt, kde klíčem je podnik ((#Restaurant)) a hodnotou denní menu daného podniku ((#RestaurantDayMenu)) description: Objekt, kde klíčem je podnik ((#Restaurant)) a hodnotou denní menu daného podniku ((#RestaurantDayMenu))
type: object type: object
@@ -632,19 +635,23 @@ ClearMockDataRequest:
# --- NEVYŘÍZENÉ QR KÓDY --- # --- NEVYŘÍZENÉ QR KÓDY ---
PendingQr: PendingQr:
description: Nevyřízený QR kód pro platbu z předchozího Pizza day description: Nevyřízený QR kód pro platbu
type: object type: object
additionalProperties: false additionalProperties: false
required: required:
- id
- date - date
- creator - creator
- totalPrice - totalPrice
properties: properties:
id:
description: Unikátní identifikátor QR kódu (umožňuje více QR na strávníka na den)
type: string
date: date:
description: Datum Pizza day, ke kterému se QR kód vztahuje description: Datum, ke kterému se QR kód vztahuje
type: string type: string
creator: creator:
description: Jméno zakladatele Pizza day (objednávajícího) description: Jméno uživatele, který QR vygeneroval (příjemce platby)
type: string type: string
totalPrice: totalPrice:
description: Celková cena objednávky v Kč description: Celková cena objednávky v Kč