diff --git a/client/src/components/modals/StoreAdminModal.tsx b/client/src/components/modals/StoreAdminModal.tsx index e416344..e4020ca 100644 --- a/client/src/components/modals/StoreAdminModal.tsx +++ b/client/src/components/modals/StoreAdminModal.tsx @@ -2,8 +2,8 @@ import { useState } from "react"; import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faTrashCan } from "@fortawesome/free-regular-svg-icons"; -import { faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons"; -import { addStore, deleteStore, Store } from "../../../../types"; +import { faPen, faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { addStore, deleteStore, updateStore, Store } from "../../../../types"; import { getStoreUrls, storeUrlLabel } from "../../utils/storeUrls"; type Props = { @@ -13,21 +13,47 @@ type Props = { onStoresChanged: (stores: Store[]) => void; }; +/** Rozpracovaná editace obchodu — původní název slouží k jeho identifikaci na serveru. */ +type EditState = { + originalName: string; + name: string; + urls: string[]; +}; + +/** Vstupní pole pro URL nabídek — vždy alespoň jedno prázdné, aby bylo kam psát. */ +function toUrlInputs(urls: string[]): string[] { + return urls.length > 0 ? urls : ['']; +} + export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly) { const [newName, setNewName] = useState(''); // Jeden podnik může být dostupný přes více dovozových služeb — proto seznam URL const [newUrls, setNewUrls] = useState(['']); + const [edit, setEdit] = useState(null); const [heslo, setHeslo] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const setUrlAt = (index: number, value: string) => - setNewUrls(prev => prev.map((u, i) => (i === index ? value : u))); + const addUrlRow = (urls: string[]) => [...urls, '']; - const addUrlRow = () => setNewUrls(prev => [...prev, '']); + const removeUrlRow = (urls: string[], index: number) => + urls.length === 1 ? [''] : urls.filter((_, i) => i !== index); - const removeUrlRow = (index: number) => - setNewUrls(prev => (prev.length === 1 ? [''] : prev.filter((_, i) => i !== index))); + const setUrlAt = (urls: string[], index: number, value: string) => + urls.map((u, i) => (i === index ? value : u)); + + /** Zpracuje odpověď API — vrací true při úspěchu. */ + const applyResult = (res: { data?: unknown; error?: unknown }): boolean => { + if (res.error) { + setError((res.error as any).error || 'Nastala chyba'); + return false; + } + if (res.data) { + onStoresChanged(res.data as Store[]); + return true; + } + return false; + }; const handleAdd = async () => { if (!newName.trim()) return; @@ -36,10 +62,7 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang try { const urls = newUrls.map(u => u.trim()).filter(Boolean); const res = await addStore({ body: { name: newName.trim(), urls: urls.length > 0 ? urls : undefined, heslo } }); - if (res.error) { - setError((res.error as any).error || 'Nastala chyba'); - } else if (res.data) { - onStoresChanged(res.data as Store[]); + if (applyResult(res)) { setNewName(''); setNewUrls(['']); } @@ -50,15 +73,41 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang } }; + const startEdit = (store: Store) => { + setError(null); + setEdit({ originalName: store.name, name: store.name, urls: toUrlInputs(store.urls ?? []) }); + }; + + const handleSaveEdit = async () => { + if (!edit || !edit.name.trim()) return; + setError(null); + setLoading(true); + try { + const res = await updateStore({ + body: { + name: edit.originalName, + newName: edit.name.trim(), + urls: edit.urls.map(u => u.trim()).filter(Boolean), + heslo, + }, + }); + if (applyResult(res)) { + setEdit(null); + } + } catch (e: any) { + setError(e.message || 'Nastala chyba'); + } finally { + setLoading(false); + } + }; + const handleRemove = async (name: string) => { setError(null); setLoading(true); try { const res = await deleteStore({ body: { name, heslo } }); - if (res.error) { - setError((res.error as any).error || 'Nastala chyba'); - } else if (res.data) { - onStoresChanged(res.data as Store[]); + if (applyResult(res) && edit?.originalName === name) { + setEdit(null); } } catch (e: any) { setError(e.message || 'Nastala chyba'); @@ -67,6 +116,37 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang } }; + /** Řádky se vstupy pro URL — používá se pro přidání i pro editaci obchodu. */ + const renderUrlInputs = (urls: string[], onChange: (urls: string[]) => void, onSubmit: () => void) => ( + <> + {urls.map((url, index) => ( +
+ onChange(setUrlAt(urls, index, e.target.value))} + onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') onSubmit(); }} + /> + +
+ ))} + + + ); + return ( @@ -100,32 +180,8 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang onChange={e => setNewName(e.target.value)} onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }} /> - {newUrls.map((url, index) => ( -
- setUrlAt(index, e.target.value)} - onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }} - /> - -
- ))} -
- + {renderUrlInputs(newUrls, setNewUrls, handleAdd)} +
@@ -138,6 +194,37 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang {stores.map(s => { const urls = getStoreUrls(s); + const isEditing = edit?.originalName === s.name; + + if (isEditing) { + return ( + + setEdit({ ...edit, name: e.target.value })} + onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveEdit(); }} + /> + {renderUrlInputs(edit.urls, next => setEdit({ ...edit, urls: next }), handleSaveEdit)} +
+ + +
+
+ ); + } + return (
@@ -159,13 +246,22 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang )}
- handleRemove(s.name)} - style={{ cursor: 'pointer' }} - /> +
+ startEdit(s)} + style={{ cursor: 'pointer' }} + /> + handleRemove(s.name)} + style={{ cursor: 'pointer' }} + /> +
); })} diff --git a/server/changelogs/2026-08-25.json b/server/changelogs/2026-08-25.json index f2bca89..9a608b4 100644 --- a/server/changelogs/2026-08-25.json +++ b/server/changelogs/2026-08-25.json @@ -1,5 +1,6 @@ [ "Statistiky se nově načtou i pro aktuální týden, který ještě neskončil", "Podnik může mít více odkazů na nabídku (např. Bolt Food, Wolt i Foodora)", - "Při vytváření skupiny lze vybrat, přes kterou službu se bude objednávat — odkaz v záhlaví skupiny pak vede přímo na ni" + "Při vytváření skupiny lze vybrat, přes kterou službu se bude objednávat — odkaz v záhlaví skupiny pak vede přímo na ni", + "Existující podnik lze upravit — přejmenovat i změnit odkazy na nabídku, bez nutnosti smazat a znovu přidat" ] diff --git a/server/src/routes/storeRoutes.ts b/server/src/routes/storeRoutes.ts index 727a6d3..8b1264c 100644 --- a/server/src/routes/storeRoutes.ts +++ b/server/src/routes/storeRoutes.ts @@ -1,5 +1,5 @@ import express from "express"; -import { getStores, addStore, removeStore } from "../stores"; +import { getStores, addStore, updateStore, removeStore } from "../stores"; const router = express.Router(); @@ -32,6 +32,31 @@ router.post("/add", async (req, res, next) => { } }); +router.post("/update", async (req, res, next) => { + const { name, newName, heslo, urls } = req.body ?? {}; + if (!name || typeof name !== 'string') { + return res.status(400).json({ error: 'Nebyl předán název obchodu' }); + } + if (!heslo || typeof heslo !== 'string') { + return res.status(400).json({ error: 'Nebylo předáno heslo' }); + } + if (newName != null && typeof newName !== 'string') { + return res.status(400).json({ error: 'Neplatný název obchodu' }); + } + if (urls != null && (!Array.isArray(urls) || urls.some((u: unknown) => typeof u !== 'string'))) { + return res.status(400).json({ error: 'Neplatný seznam URL obchodu' }); + } + try { + const stores = await updateStore(name, heslo, newName ?? undefined, urls ?? undefined); + res.status(200).json(stores); + } catch (e: any) { + if (e.message === 'UNAUTHORIZED') { + return res.status(403).json({ error: 'Nesprávné heslo' }); + } + next(e); + } +}); + router.post("/delete", async (req, res, next) => { const { name, heslo } = req.body ?? {}; if (!name || typeof name !== 'string') { diff --git a/server/src/stores.ts b/server/src/stores.ts index 2b1e2db..6f11e17 100644 --- a/server/src/stores.ts +++ b/server/src/stores.ts @@ -95,6 +95,46 @@ export async function addStore(name: string, heslo: string, urls?: string[]): Pr return updated; } +/** + * Upraví existující obchod — název a/nebo seznam URL na nabídku. + * + * @param name aktuální název obchodu (identifikuje upravovaný obchod) + * @param heslo admin heslo + * @param newName nový název; pokud není předán, název zůstane nezměněn + * @param urls nový seznam URL (nahradí stávající); pokud není předán, URL zůstanou nezměněné + */ +export async function updateStore(name: string, heslo: string, newName?: string, urls?: string[]): Promise { + const adminPassword = process.env.ADMIN_PASSWORD; + if (!adminPassword || heslo !== adminPassword) { + throw new Error('UNAUTHORIZED'); + } + const stores = await getStores(); + const index = stores.findIndex(s => s.name.toLowerCase() === name.trim().toLowerCase()); + if (index < 0) { + throw new Error('Obchod nebyl nalezen'); + } + const current = stores[index]; + + let finalName = current.name; + if (newName !== undefined) { + const trimmedName = newName.trim(); + if (!trimmedName) { + throw new Error('Název obchodu nesmí být prázdný'); + } + // Nový název nesmí kolidovat s jiným obchodem (shoda se sebou samým je v pořádku) + if (stores.some((s, i) => i !== index && s.name.toLowerCase() === trimmedName.toLowerCase())) { + throw new Error('Obchod s tímto názvem již existuje'); + } + finalName = trimmedName; + } + + const finalUrls = urls !== undefined ? normalizeUrls(urls) : (current.urls ?? []); + const updatedStore: Store = finalUrls.length > 0 ? { name: finalName, urls: finalUrls } : { name: finalName }; + const updated = stores.map((s, i) => (i === index ? updatedStore : s)); + await storage.setData(STORES_KEY, updated); + return updated; +} + /** * Odebere obchod ze seznamu povolených (dle názvu). * diff --git a/server/src/tests/stores.test.ts b/server/src/tests/stores.test.ts index 8e180a0..7ed3de3 100644 --- a/server/src/tests/stores.test.ts +++ b/server/src/tests/stores.test.ts @@ -1,6 +1,6 @@ import { resetMemoryStorage } from '../storage/memory'; import getStorage from '../storage'; -import { getStores, addStore, removeStore } from '../stores'; +import { getStores, addStore, updateStore, removeStore } from '../stores'; const ADMIN_PW = 'testadmin'; @@ -113,6 +113,82 @@ describe('addStore', () => { }); }); +describe('updateStore', () => { + const WOLT_URL = 'https://wolt.com/bistro'; + const BOLT_URL = 'https://food.bolt.eu/bistro'; + + beforeEach(async () => { + await addStore('Bistro', ADMIN_PW, [WOLT_URL]); + }); + + test('přidá další URL k existujícímu obchodu', async () => { + const stores = await updateStore('Bistro', ADMIN_PW, undefined, [WOLT_URL, BOLT_URL]); + expect(stores).toContainEqual({ name: 'Bistro', urls: [WOLT_URL, BOLT_URL] }); + }); + + test('přejmenuje obchod a zachová URL', async () => { + const stores = await updateStore('Bistro', ADMIN_PW, ' Bistro U Nás '); + expect(stores).toContainEqual({ name: 'Bistro U Nás', urls: [WOLT_URL] }); + }); + + test('prázdné pole URL odstraní všechny URL', async () => { + const stores = await updateStore('Bistro', ADMIN_PW, undefined, []); + expect(stores).toContainEqual({ name: 'Bistro' }); + }); + + test('bez předaných URL zůstanou URL nezměněné', async () => { + const stores = await updateStore('Bistro', ADMIN_PW, 'Bistro 2'); + expect(stores).toContainEqual({ name: 'Bistro 2', urls: [WOLT_URL] }); + }); + + test('zachová pozici obchodu v seznamu', async () => { + await addStore('KFC', ADMIN_PW); + const stores = await updateStore('Bistro', ADMIN_PW, 'Bistro 2'); + expect(names(stores)).toEqual(['Bistro 2', 'KFC']); + }); + + test('funguje case-insensitive dle názvu', async () => { + const stores = await updateStore('BISTRO', ADMIN_PW, undefined, [BOLT_URL]); + expect(stores).toContainEqual({ name: 'Bistro', urls: [BOLT_URL] }); + }); + + test('ponechání stejného názvu není bráno jako duplikát', async () => { + const stores = await updateStore('Bistro', ADMIN_PW, 'bistro'); + expect(names(stores)).toEqual(['bistro']); + }); + + test('odmítne název kolidující s jiným obchodem', async () => { + await addStore('KFC', ADMIN_PW); + await expect(updateStore('Bistro', ADMIN_PW, 'kfc')).rejects.toThrow('existuje'); + }); + + test('odmítne prázdný název', async () => { + await expect(updateStore('Bistro', ADMIN_PW, ' ')).rejects.toThrow('prázdný'); + }); + + test('odmítne nevalidní URL', async () => { + await expect(updateStore('Bistro', ADMIN_PW, undefined, ['javascript:alert(1)'])).rejects.toThrow('http'); + }); + + test('vyhodí chybu u neexistujícího obchodu', async () => { + await expect(updateStore('Neexistuje', ADMIN_PW, 'Nový')).rejects.toThrow('nebyl nalezen'); + }); + + test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => { + await expect(updateStore('Bistro', 'spatne', 'Hacknuto')).rejects.toThrow('UNAUTHORIZED'); + }); + + test('vyhodí UNAUTHORIZED pokud ADMIN_PASSWORD není nastaven', async () => { + delete process.env.ADMIN_PASSWORD; + await expect(updateStore('Bistro', '', 'Hacknuto')).rejects.toThrow('UNAUTHORIZED'); + }); + + test('při nesprávném heslu se obchod nezmění', async () => { + await expect(updateStore('Bistro', 'spatne', 'Hacknuto')).rejects.toThrow('UNAUTHORIZED'); + expect(names(await getStores())).toEqual(['Bistro']); + }); +}); + describe('removeStore', () => { beforeEach(async () => { await addStore('McDonald\'s', ADMIN_PW); diff --git a/types/api.yml b/types/api.yml index dfb63bf..1011680 100644 --- a/types/api.yml +++ b/types/api.yml @@ -152,6 +152,8 @@ paths: $ref: "./paths/stores/listStores.yml" /stores/add: $ref: "./paths/stores/addStore.yml" + /stores/update: + $ref: "./paths/stores/updateStore.yml" /stores/delete: $ref: "./paths/stores/deleteStore.yml" diff --git a/types/paths/stores/updateStore.yml b/types/paths/stores/updateStore.yml new file mode 100644 index 0000000..981937c --- /dev/null +++ b/types/paths/stores/updateStore.yml @@ -0,0 +1,36 @@ +post: + operationId: updateStore + summary: Upraví existující obchod — název a/nebo seznam URL na nabídku (vyžaduje admin heslo). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - heslo + properties: + name: + description: Aktuální název obchodu (identifikuje upravovaný obchod) + type: string + newName: + description: Nový název obchodu. Pokud není předán, název zůstane nezměněn. + type: string + urls: + description: Nový seznam URL na nabídku podniku — nahradí stávající. Prázdné pole URL odstraní. Pokud není předán, URL zůstanou nezměněné. + type: array + items: + type: string + heslo: + description: Admin heslo (ADMIN_PASSWORD) + type: string + responses: + "200": + description: Obchod byl upraven + content: + application/json: + schema: + type: array + items: + $ref: "../../schemas/_index.yml#/Store"