feat: proklik na stránky podniku ze stránky objednávek
CI / Generate TypeScript types (push) Successful in 9s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 25s
CI / Build client (push) Successful in 37s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Successful in 39s
CI / Notify (push) Successful in 2s

This commit is contained in:
2026-06-10 19:09:20 +02:00
parent cc09ddbd2c
commit 88b9e20e2d
11 changed files with 154 additions and 36 deletions
@@ -2,17 +2,19 @@ import { useState } from "react";
import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap"; import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons"; import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { addStore, deleteStore } from "../../../../types"; import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { addStore, deleteStore, Store } from "../../../../types";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
stores: string[]; stores: Store[];
onStoresChanged: (stores: string[]) => void; onStoresChanged: (stores: Store[]) => void;
}; };
export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) { export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) {
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [newUrl, setNewUrl] = useState('');
const [heslo, setHeslo] = useState(''); const [heslo, setHeslo] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -22,12 +24,13 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
const res = await addStore({ body: { name: newName.trim(), heslo } }); const res = await addStore({ body: { name: newName.trim(), url: newUrl.trim() || undefined, heslo } });
if (res.error) { if (res.error) {
setError((res.error as any).error || 'Nastala chyba'); setError((res.error as any).error || 'Nastala chyba');
} else if (res.data) { } else if (res.data) {
onStoresChanged(res.data as string[]); onStoresChanged(res.data as Store[]);
setNewName(''); setNewName('');
setNewUrl('');
} }
} catch (e: any) { } catch (e: any) {
setError(e.message || 'Nastala chyba'); setError(e.message || 'Nastala chyba');
@@ -44,7 +47,7 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
if (res.error) { if (res.error) {
setError((res.error as any).error || 'Nastala chyba'); setError((res.error as any).error || 'Nastala chyba');
} else if (res.data) { } else if (res.data) {
onStoresChanged(res.data as string[]); onStoresChanged(res.data as Store[]);
} }
} catch (e: any) { } catch (e: any) {
setError(e.message || 'Nastala chyba'); setError(e.message || 'Nastala chyba');
@@ -78,12 +81,20 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
<hr /> <hr />
<h6>Přidat obchod</h6> <h6>Přidat obchod</h6>
<Form.Control
className="mb-2"
type="text"
placeholder="Název obchodu"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
/>
<div className="d-flex gap-2 mb-3"> <div className="d-flex gap-2 mb-3">
<Form.Control <Form.Control
type="text" type="url"
placeholder="Název obchodu" placeholder="URL na nabídku (volitelné, např. Bolt Food/Wolt)"
value={newName} value={newUrl}
onChange={e => setNewName(e.target.value)} onChange={e => setNewUrl(e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }} onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
/> />
<Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}> <Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}>
@@ -97,13 +108,20 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
) : ( ) : (
<ListGroup> <ListGroup>
{stores.map(s => ( {stores.map(s => (
<ListGroup.Item key={s} className="d-flex justify-content-between align-items-center"> <ListGroup.Item key={s.name} className="d-flex justify-content-between align-items-center">
{s} <span>
{s.name}
{s.url && /^https?:\/\//i.test(s.url) && (
<a href={s.url} target="_blank" rel="noopener noreferrer" className="ms-2" title="Otevřít nabídku v nové záložce">
<FontAwesomeIcon icon={faUpRightFromSquare} />
</a>
)}
</span>
<FontAwesomeIcon <FontAwesomeIcon
icon={faTrashCan} icon={faTrashCan}
className="action-icon" className="action-icon"
title="Odebrat" title="Odebrat"
onClick={() => handleRemove(s)} onClick={() => handleRemove(s.name)}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
/> />
</ListGroup.Item> </ListGroup.Item>
+14 -2
View File
@@ -404,7 +404,7 @@ export default function OrderGroupsPage() {
style={{ maxWidth: 260 }} style={{ maxWidth: 260 }}
> >
<option value=""> vyberte obchod </option> <option value=""> vyberte obchod </option>
{stores.map(s => <option key={s} value={s}>{s}</option>)} {stores.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
</Form.Select> </Form.Select>
<Button variant="primary" onClick={handleCreate} disabled={creating || !newGroupName}> <Button variant="primary" onClick={handleCreate} disabled={creating || !newGroupName}>
Vytvořit skupinu Vytvořit skupinu
@@ -429,6 +429,10 @@ export default function OrderGroupsPage() {
const isLocked = group.state === GroupState.LOCKED; const isLocked = group.state === GroupState.LOCKED;
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][]; const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
const editingTimes = group.id in editTimes; const editingTimes = group.id in editTimes;
// URL na nabídku podniku (pokud ji má dohledatelný obchod vyplněnou).
// Povolíme jen http(s), aby odkaz nemohl být zneužit (např. javascript:).
const rawStoreUrl = stores.find(s => s.name === group.name)?.url;
const storeUrl = rawStoreUrl && /^https?:\/\//i.test(rawStoreUrl) ? rawStoreUrl : undefined;
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0); const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal). // Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
@@ -442,7 +446,15 @@ export default function OrderGroupsPage() {
<Card key={group.id} className="mb-3 fade-in"> <Card key={group.id} className="mb-3 fade-in">
<Card.Header className="d-flex justify-content-between align-items-center"> <Card.Header className="d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
<strong>{group.name}</strong> {storeUrl ? (
<strong>
<a href={storeUrl} target="_blank" rel="noopener noreferrer" title="Otevřít nabídku v nové záložce">
{group.name}
</a>
</strong>
) : (
<strong>{group.name}</strong>
)}
{stateBadge(group.state)} {stateBadge(group.state)}
<small className="text-muted">zakladatel: {group.creatorLogin}</small> <small className="text-muted">zakladatel: {group.creatorLogin}</small>
</div> </div>
+3
View File
@@ -0,0 +1,3 @@
[
"Proklik na nabídku podniku ze stránky objednávek"
]
+2 -2
View File
@@ -49,11 +49,11 @@ export async function getOrderDates(): Promise<string[]> {
export async function createGroup(creatorLogin: string, name: string, date?: Date): Promise<ClientData> { export async function createGroup(creatorLogin: string, name: string, date?: Date): Promise<ClientData> {
const stores = await getStores(); const stores = await getStores();
if (!stores.some(s => s.toLowerCase() === name.trim().toLowerCase())) { if (!stores.some(s => s.name.toLowerCase() === name.trim().toLowerCase())) {
throw new Error('Obchod není v seznamu povolených obchodů'); throw new Error('Obchod není v seznamu povolených obchodů');
} }
const data = await getExtraData(date); const data = await getExtraData(date);
const canonical = stores.find(s => s.toLowerCase() === name.trim().toLowerCase())!; const canonical = stores.find(s => s.name.toLowerCase() === name.trim().toLowerCase())!.name;
const group: OrderGroup = { const group: OrderGroup = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: canonical, name: canonical,
+5 -2
View File
@@ -11,15 +11,18 @@ router.get("/", async (_req, res, next) => {
}); });
router.post("/add", async (req, res, next) => { router.post("/add", async (req, res, next) => {
const { name, heslo } = req.body ?? {}; const { name, heslo, url } = req.body ?? {};
if (!name || typeof name !== 'string') { if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Nebyl předán název obchodu' }); return res.status(400).json({ error: 'Nebyl předán název obchodu' });
} }
if (!heslo || typeof heslo !== 'string') { if (!heslo || typeof heslo !== 'string') {
return res.status(400).json({ error: 'Nebylo předáno heslo' }); return res.status(400).json({ error: 'Nebylo předáno heslo' });
} }
if (url != null && typeof url !== 'string') {
return res.status(400).json({ error: 'Neplatná URL obchodu' });
}
try { try {
const stores = await addStore(name, heslo); const stores = await addStore(name, heslo, url);
res.status(200).json(stores); res.status(200).json(stores);
} catch (e: any) { } catch (e: any) {
if (e.message === 'UNAUTHORIZED') { if (e.message === 'UNAUTHORIZED') {
+43 -7
View File
@@ -1,13 +1,29 @@
import { Store } from "../../types/gen/types.gen";
import getStorage from "./storage"; import getStorage from "./storage";
const storage = getStorage(); const storage = getStorage();
const STORES_KEY = 'stores'; const STORES_KEY = 'stores';
export async function getStores(): Promise<string[]> { /**
return (await storage.getData<string[]>(STORES_KEY)) ?? []; * Vrátí seznam povolených obchodů. Zachovává zpětnou kompatibilitu se starším
* formátem, kdy byly obchody uloženy jako pole řetězců (převede je na objekty).
*/
export async function getStores(): Promise<Store[]> {
const raw = await storage.getData<(string | Store)[]>(STORES_KEY);
if (!raw) {
return [];
}
return raw.map(s => (typeof s === 'string' ? { name: s } : s));
} }
export async function addStore(name: string, heslo: string): Promise<string[]> { /**
* Přidá nový obchod do seznamu povolených.
*
* @param name název obchodu
* @param heslo admin heslo
* @param url volitelná URL na nabídku podniku
*/
export async function addStore(name: string, heslo: string, url?: string): Promise<Store[]> {
const adminPassword = process.env.ADMIN_PASSWORD; const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword || heslo !== adminPassword) { if (!adminPassword || heslo !== adminPassword) {
throw new Error('UNAUTHORIZED'); throw new Error('UNAUTHORIZED');
@@ -17,21 +33,41 @@ export async function addStore(name: string, heslo: string): Promise<string[]> {
throw new Error('Název obchodu nesmí být prázdný'); throw new Error('Název obchodu nesmí být prázdný');
} }
const stores = await getStores(); const stores = await getStores();
if (stores.some(s => s.toLowerCase() === trimmed.toLowerCase())) { if (stores.some(s => s.name.toLowerCase() === trimmed.toLowerCase())) {
throw new Error('Obchod s tímto názvem již existuje'); throw new Error('Obchod s tímto názvem již existuje');
} }
const updated = [...stores, trimmed]; const trimmedUrl = url?.trim();
if (trimmedUrl) {
// Povolíme pouze http(s), aby URL nemohla být zneužita (např. javascript: → XSS)
let parsed: URL;
try {
parsed = new URL(trimmedUrl);
} catch {
throw new Error('Neplatná URL obchodu');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('URL musí začínat http:// nebo https://');
}
}
const store: Store = trimmedUrl ? { name: trimmed, url: trimmedUrl } : { name: trimmed };
const updated = [...stores, store];
await storage.setData(STORES_KEY, updated); await storage.setData(STORES_KEY, updated);
return updated; return updated;
} }
export async function removeStore(name: string, heslo: string): Promise<string[]> { /**
* Odebere obchod ze seznamu povolených (dle názvu).
*
* @param name název obchodu
* @param heslo admin heslo
*/
export async function removeStore(name: string, heslo: string): Promise<Store[]> {
const adminPassword = process.env.ADMIN_PASSWORD; const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword || heslo !== adminPassword) { if (!adminPassword || heslo !== adminPassword) {
throw new Error('UNAUTHORIZED'); throw new Error('UNAUTHORIZED');
} }
const stores = await getStores(); const stores = await getStores();
const updated = stores.filter(s => s.toLowerCase() !== name.trim().toLowerCase()); const updated = stores.filter(s => s.name.toLowerCase() !== name.trim().toLowerCase());
await storage.setData(STORES_KEY, updated); await storage.setData(STORES_KEY, updated);
return updated; return updated;
} }
+35 -6
View File
@@ -1,8 +1,12 @@
import { resetMemoryStorage } from '../storage/memory'; import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage';
import { getStores, addStore, removeStore } from '../stores'; import { getStores, addStore, removeStore } from '../stores';
const ADMIN_PW = 'testadmin'; const ADMIN_PW = 'testadmin';
/** Vrátí pole názvů obchodů pro snadné porovnání v testech. */
const names = (stores: { name: string }[]) => stores.map(s => s.name);
beforeEach(() => { beforeEach(() => {
resetMemoryStorage(); resetMemoryStorage();
process.env.ADMIN_PASSWORD = ADMIN_PW; process.env.ADMIN_PASSWORD = ADMIN_PW;
@@ -17,12 +21,37 @@ describe('getStores', () => {
const stores = await getStores(); const stores = await getStores();
expect(stores).toEqual([]); expect(stores).toEqual([]);
}); });
test('převede starý formát (pole řetězců) na objekty', async () => {
// Simulace dat uložených ve starším formátu (před zavedením URL)
await getStorage().setData('stores', ['McDonald\'s', 'KFC']);
const stores = await getStores();
expect(stores).toEqual([{ name: 'McDonald\'s' }, { name: 'KFC' }]);
});
}); });
describe('addStore', () => { describe('addStore', () => {
test('přidá obchod se správným heslem', async () => { test('přidá obchod se správným heslem', async () => {
const stores = await addStore('McDonald\'s', ADMIN_PW); const stores = await addStore('McDonald\'s', ADMIN_PW);
expect(stores).toContain('McDonald\'s'); expect(names(stores)).toContain('McDonald\'s');
});
test('uloží volitelnou URL a ořízne mezery', async () => {
const stores = await addStore('Bistro', ADMIN_PW, ' https://wolt.com/bistro ');
expect(stores).toContainEqual({ name: 'Bistro', url: 'https://wolt.com/bistro' });
});
test('bez URL uloží obchod bez pole url', async () => {
const stores = await addStore('KFC', ADMIN_PW, ' ');
expect(stores).toContainEqual({ name: 'KFC' });
});
test('odmítne URL s nepovoleným schématem (javascript:)', async () => {
await expect(addStore('Zlo', ADMIN_PW, 'javascript:alert(1)')).rejects.toThrow('http');
});
test('odmítne nevalidní URL', async () => {
await expect(addStore('Zlo', ADMIN_PW, 'nfdjska')).rejects.toThrow('Neplatná URL');
}); });
test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => { test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => {
@@ -47,8 +76,8 @@ describe('addStore', () => {
await addStore('McDonald\'s', ADMIN_PW); await addStore('McDonald\'s', ADMIN_PW);
const stores = await addStore('KFC', ADMIN_PW); const stores = await addStore('KFC', ADMIN_PW);
expect(stores).toHaveLength(2); expect(stores).toHaveLength(2);
expect(stores).toContain('McDonald\'s'); expect(names(stores)).toContain('McDonald\'s');
expect(stores).toContain('KFC'); expect(names(stores)).toContain('KFC');
}); });
}); });
@@ -59,12 +88,12 @@ describe('removeStore', () => {
test('odebere obchod se správným heslem', async () => { test('odebere obchod se správným heslem', async () => {
const stores = await removeStore('McDonald\'s', ADMIN_PW); const stores = await removeStore('McDonald\'s', ADMIN_PW);
expect(stores).not.toContain('McDonald\'s'); expect(names(stores)).not.toContain('McDonald\'s');
}); });
test('case-insensitive odebrání', async () => { test('case-insensitive odebrání', async () => {
const stores = await removeStore('MCDONALD\'S', ADMIN_PW); const stores = await removeStore('MCDONALD\'S', ADMIN_PW);
expect(stores).not.toContain('McDonald\'s'); expect(names(stores)).not.toContain('McDonald\'s');
}); });
test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => { test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => {
@@ -73,6 +102,6 @@ describe('removeStore', () => {
test('odebrání neexistujícího obchodu nic nerozbije', async () => { test('odebrání neexistujícího obchodu nic nerozbije', async () => {
const stores = await removeStore('Neexistuje', ADMIN_PW); const stores = await removeStore('Neexistuje', ADMIN_PW);
expect(stores).toContain('McDonald\'s'); expect(names(stores)).toContain('McDonald\'s');
}); });
}); });
+4 -1
View File
@@ -14,6 +14,9 @@ post:
name: name:
description: Název obchodu/restaurace description: Název obchodu/restaurace
type: string type: string
url:
description: Volitelná URL na nabídku podniku (např. Bolt Food/Wolt/Foodora)
type: string
heslo: heslo:
description: Admin heslo (ADMIN_PASSWORD) description: Admin heslo (ADMIN_PASSWORD)
type: string type: string
@@ -25,4 +28,4 @@ post:
schema: schema:
type: array type: array
items: items:
type: string $ref: "../../schemas/_index.yml#/Store"
+1 -1
View File
@@ -25,4 +25,4 @@ post:
schema: schema:
type: array type: array
items: items:
type: string $ref: "../../schemas/_index.yml#/Store"
+1 -1
View File
@@ -9,4 +9,4 @@ get:
schema: schema:
type: array type: array
items: items:
type: string $ref: "../../schemas/_index.yml#/Store"
+15 -1
View File
@@ -79,7 +79,7 @@ ClientData:
description: Seznam povolených obchodů/restaurací pro extra objednávky description: Seznam povolených obchodů/restaurací pro extra objednávky
type: array type: array
items: items:
type: string $ref: "#/Store"
# --- OBĚDY --- # --- OBĚDY ---
UserLunchChoice: UserLunchChoice:
@@ -708,6 +708,20 @@ ClearMockDataRequest:
$ref: "#/DayIndex" $ref: "#/DayIndex"
# --- SKUPINOVÉ OBJEDNÁVKY --- # --- SKUPINOVÉ OBJEDNÁVKY ---
Store:
description: Povolený obchod/restaurace pro extra objednávky.
type: object
additionalProperties: false
required:
- name
properties:
name:
description: Název obchodu/restaurace
type: string
url:
description: Volitelná URL na nabídku podniku (např. Bolt Food/Wolt/Foodora)
type: string
GroupState: GroupState:
description: Stav skupiny objednávky description: Stav skupiny objednávky
type: string type: string