diff --git a/client/src/AppRoutes.tsx b/client/src/AppRoutes.tsx index 601d99b..1c37683 100644 --- a/client/src/AppRoutes.tsx +++ b/client/src/AppRoutes.tsx @@ -5,20 +5,20 @@ import { SnowOverlay } from 'react-snow-overlay'; import { ToastContainer } from "react-toastify"; import { SocketContext, socket } from "./context/socket"; import StatsPage from "./pages/StatsPage"; -import ExtraPage from "./pages/ExtraPage"; +import OrderGroupsPage from "./pages/OrderGroupsPage"; import App from "./App"; export const STATS_URL = '/stats'; -export const VECERE_URL = '/vecere'; +export const OBJEDNANI_URL = '/objednani'; export default function AppRoutes() { return ( } /> - - + diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index 7b0341b..9bcecf3 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -10,7 +10,7 @@ import GenerateQrModal from "./modals/GenerateQrModal"; import GenerateMockDataModal from "./modals/GenerateMockDataModal"; import ClearMockDataModal from "./modals/ClearMockDataModal"; import { useNavigate } from "react-router"; -import { STATS_URL, VECERE_URL } from "../AppRoutes"; +import { STATS_URL, OBJEDNANI_URL } from "../AppRoutes"; import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons"; @@ -207,7 +207,7 @@ export default function Header({ choices, dayIndex }: Props) { setPizzaModalOpen(true)}>Pizza kalkulačka Generování QR kódů navigate(STATS_URL)}>Statistiky - navigate(VECERE_URL)}>Večeře + navigate(OBJEDNANI_URL)}>Objednání { getChangelogs().then(response => { const entries = response.data ?? {}; diff --git a/client/src/components/modals/PayForAllModal.tsx b/client/src/components/modals/PayForAllModal.tsx index e1b65bb..29e7d78 100644 --- a/client/src/components/modals/PayForAllModal.tsx +++ b/client/src/components/modals/PayForAllModal.tsx @@ -225,35 +225,33 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location - {!isPayer && ( -
- handleSurchargeText(d.login, e.target.value)} - disabled={!d.included} - size="sm" - onKeyDown={e => e.stopPropagation()} - /> - handleSurchargeAmount(d.login, e.target.value)} - disabled={!d.included} - size="sm" - style={{ width: 70 }} - onKeyDown={e => e.stopPropagation()} - /> -
- )} +
+ handleSurchargeText(d.login, e.target.value)} + disabled={!isPayer && !d.included} + size="sm" + onKeyDown={e => e.stopPropagation()} + /> + handleSurchargeAmount(d.login, e.target.value)} + disabled={!isPayer && !d.included} + size="sm" + style={{ width: 70 }} + onKeyDown={e => e.stopPropagation()} + /> +
{!isPayer && d.included ? `${tipPerPerson} Kč` : '—'} - {!isPayer ? `${total} Kč` : '—'} + {`${total} Kč`} ); diff --git a/client/src/components/modals/PayForGroupModal.tsx b/client/src/components/modals/PayForGroupModal.tsx new file mode 100644 index 0000000..9d8db2d --- /dev/null +++ b/client/src/components/modals/PayForGroupModal.tsx @@ -0,0 +1,261 @@ +import { useState, useEffect, useCallback } from "react"; +import { Modal, Button, Form, Table, Alert } from "react-bootstrap"; +import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types"; + +type DinerEntry = { + login: string; + baseAmount: number; + surchargeText: string; + surchargeAmount: string; + included: boolean; +}; + +type Props = { + isOpen: boolean; + onClose: () => void; + group: OrderGroup; + 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 PayForGroupModal({ isOpen, onClose, group, payerLogin, bankAccount, bankAccountHolder }: Readonly) { + const [diners, setDiners] = useState([]); + const [tipTotal, setTipTotal] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + useEffect(() => { + if (!isOpen) return; + const entries: DinerEntry[] = (Object.entries(group.members) as [string, OrderGroupMember][]).map(([login, member]) => ({ + login, + baseAmount: member.amount ?? 0, + surchargeText: member.surchargeText ?? '', + surchargeAmount: member.surchargeAmount != null ? String(member.surchargeAmount) : '', + included: login !== payerLogin, + })); + setDiners(entries); + setTipTotal(''); + setError(null); + setSuccess(false); + }, [isOpen, group, payerLogin]); + + const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin); + + const tipPerPerson = (() => { + if (includedNonPayers.length === 0) return 0; + const tip = parseAmount(tipTotal); + if (tip === null || tip === 0) return 0; + return Math.round((tip / includedNonPayers.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; + } + recipients.push({ + login: d.login, + purpose: `Objednávka ${group.name}`.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); + } + }; + + return ( + + +

Zaplatit za skupinu — {group.name}

+
+ + {success ? ( + + QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci „Nevyřízené platby". + + ) : ( + <> +

Zaplatili jste za skupinu. Nastavte příplatky a dýško, poté vygenerujte QR kódy pro ostatní.

+ + {error && ( + setError(null)} dismissible> + {error} + + )} + + + + + + + + + + + + + + {diners.map(d => { + const isPayer = d.login === payerLogin; + const total = getTotal(d); + return ( + + + + + + + + + ); + })} + +
ČlenZáklad (Kč)PříplatekDýškoCelkem
+ {isPayer ? ( + plátce + ) : ( + handleInclude(d.login, e.target.checked)} + /> + )} + {d.login} + {d.baseAmount > 0 ? `${d.baseAmount} Kč` : } + +
+ handleSurchargeText(d.login, e.target.value)} + disabled={!isPayer && !d.included} + size="sm" + onKeyDown={e => e.stopPropagation()} + /> + handleSurchargeAmount(d.login, e.target.value)} + disabled={!isPayer && !d.included} + size="sm" + style={{ width: 70 }} + onKeyDown={e => e.stopPropagation()} + /> +
+
+ {!isPayer && d.included ? `${tipPerPerson} Kč` : '—'} + + {`${total} Kč`} +
+ +
+ + setTipTotal(sanitizeAmount(e.target.value))} + size="sm" + style={{ width: 100 }} + onKeyDown={e => e.stopPropagation()} + /> + + {includedNonPayers.length > 0 && tipPerPerson > 0 + ? `(${tipPerPerson} Kč / osoba)` + : ''} + +
+ + )} +
+ + {!success && ( + <> + + Příjemci: {includedNonPayers.length} + + + + + )} + {success && ( + + )} + +
+ ); +} diff --git a/client/src/components/modals/StoreAdminModal.tsx b/client/src/components/modals/StoreAdminModal.tsx new file mode 100644 index 0000000..3d68378 --- /dev/null +++ b/client/src/components/modals/StoreAdminModal.tsx @@ -0,0 +1,119 @@ +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 { addStore, deleteStore } from "../../../../types"; + +type Props = { + isOpen: boolean; + onClose: () => void; + stores: string[]; + onStoresChanged: (stores: string[]) => void; +}; + +export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly) { + const [newName, setNewName] = useState(''); + const [heslo, setHeslo] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleAdd = async () => { + if (!newName.trim()) return; + setError(null); + setLoading(true); + try { + const res = await addStore({ body: { name: newName.trim(), heslo } }); + if (res.error) { + setError((res.error as any).error || 'Nastala chyba'); + } else if (res.data) { + onStoresChanged(res.data as string[]); + setNewName(''); + } + } 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 string[]); + } + } catch (e: any) { + setError(e.message || 'Nastala chyba'); + } finally { + setLoading(false); + } + }; + + return ( + + +

Správa obchodů

+
+ + {error && ( + setError(null)} dismissible> + {error} + + )} + + + Admin heslo + setHeslo(e.target.value)} + onKeyDown={e => e.stopPropagation()} + /> + + +
+
Přidat obchod
+
+ setNewName(e.target.value)} + onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }} + /> + +
+ +
Aktuální seznam
+ {stores.length === 0 ? ( +

Žádné obchody v seznamu

+ ) : ( + + {stores.map(s => ( + + {s} + handleRemove(s)} + style={{ cursor: 'pointer' }} + /> + + ))} + + )} +
+ + + +
+ ); +} diff --git a/client/src/pages/ExtraPage.tsx b/client/src/pages/ExtraPage.tsx deleted file mode 100644 index 0c89d48..0000000 --- a/client/src/pages/ExtraPage.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { useContext, useEffect, useRef, useState } from 'react'; -import { Button, Table } from 'react-bootstrap'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCircleCheck, faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons'; -import { faBasketShopping, faSearch } from '@fortawesome/free-solid-svg-icons'; -import { - ClientData, LunchChoice, MealSlot, UserLunchChoice, - addChoice, removeChoices, updateNote, setBuyer, getData, -} from '../../../types'; -import { EVENT_MESSAGE, SocketContext } from '../context/socket'; -import { useAuth } from '../context/auth'; -import Login from '../Login'; -import Header from '../components/Header'; -import Footer from '../components/Footer'; -import Loader from '../components/Loader'; -import NoteModal from '../components/modals/NoteModal'; - -const SLOT = MealSlot.EXTRA; - -export default function ExtraPage() { - const auth = useAuth(); - const socket = useContext(SocketContext); - const [data, setData] = useState(); - const [failure, setFailure] = useState(false); - const [noteModalOpen, setNoteModalOpen] = useState(false); - - const fetchData = async () => { - try { - const r = await getData({ query: { slot: SLOT } }); - if (r.data) setData(r.data); - } catch { - setFailure(true); - } - }; - - useEffect(() => { - if (!auth?.login) return; - fetchData(); - }, [auth?.login]); - - useEffect(() => { - socket.on(EVENT_MESSAGE, (newData: ClientData) => { - if (newData.slot === SLOT) setData(newData); - }); - return () => { socket.off(EVENT_MESSAGE); }; - }, [socket]); - - const myChoice = data?.choices?.OBJEDNAVAM?.[auth?.login ?? '']; - const isIn = !!myChoice; - const isBuyer = myChoice?.isBuyer ?? false; - - const joinOrder = async () => { - if (!auth?.login) return; - await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } }); - await fetchData(); - }; - - const joinAndBuy = async () => { - if (!auth?.login) return; - await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } }); - await setBuyer({ body: { slot: SLOT } }); - await fetchData(); - }; - - const leaveOrder = async () => { - if (!auth?.login) return; - await removeChoices({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } }); - await fetchData(); - }; - - const toggleBuyer = async () => { - if (!auth?.login) return; - await setBuyer({ body: { slot: SLOT } }); - await fetchData(); - }; - - const saveNote = async (note?: string) => { - if (!auth?.login) return; - await updateNote({ body: { note, slot: SLOT } }); - setNoteModalOpen(false); - await fetchData(); - }; - - if (!auth?.login) return ; - - if (failure) return ( - - ); - - if (!data) return ( - - ); - - const orderEntries = Object.entries(data.choices?.OBJEDNAVAM ?? {}) as [string, UserLunchChoice][]; - - return ( -
-
-
-

Večeře

-

Extra jídlo pro ty, kdo zůstávají déle

- -
-
-
- {!isIn ? ( -
- - -
- ) : ( -
- - {isBuyer ? 'Objednáváš.' : 'Jsi přidán/a k objednávce.'} - - - - -
- )} -
- - {orderEntries.length > 0 && ( - - - - - - - -
Budu objednávat / Přidám se - - - {orderEntries.map(([login, payload]) => ( - - - - ))} - -
-
-
- {payload.trusted && ( - - - - )} - {login} - {payload.note && ( - - ({payload.note}) - - )} -
-
- {payload.isBuyer && ( - - - - )} - {login === auth.login && ( - <> - - setNoteModalOpen(true)} - className="action-icon" - icon={faNoteSticky} - /> - - - - - - )} -
-
-
-
- )} -
-
-
-
- setNoteModalOpen(false)} - onSave={saveNote} - /> -
- ); -} diff --git a/client/src/pages/OrderGroupsPage.tsx b/client/src/pages/OrderGroupsPage.tsx new file mode 100644 index 0000000..e8d51f0 --- /dev/null +++ b/client/src/pages/OrderGroupsPage.tsx @@ -0,0 +1,350 @@ +import { useContext, useEffect, useRef, useState } from 'react'; +import { Badge, Button, Card, Form, Table } from 'react-bootstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons'; +import { faBasketShopping, faGear, faLock, faLockOpen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons'; +import { + ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, + getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, +} from '../../../types'; +import { EVENT_MESSAGE, SocketContext } from '../context/socket'; +import { useAuth } from '../context/auth'; +import { useSettings } from '../context/settings'; +import Login from '../Login'; +import Header from '../components/Header'; +import Footer from '../components/Footer'; +import Loader from '../components/Loader'; +import NoteModal from '../components/modals/NoteModal'; +import StoreAdminModal from '../components/modals/StoreAdminModal'; +import PayForGroupModal from '../components/modals/PayForGroupModal'; + +const SLOT = MealSlot.EXTRA; + +function stateBadge(state: GroupState) { + const map: Record = { + [GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' }, + [GroupState.LOCKED]: { bg: 'warning', label: 'Uzamčeno' }, + [GroupState.ORDERED]: { bg: 'secondary', label: 'Objednáno' }, + }; + const { bg, label } = map[state] ?? { bg: 'light', label: state }; + return {label}; +} + +export default function OrderGroupsPage() { + const auth = useAuth(); + const settings = useSettings(); + const socket = useContext(SocketContext); + const [data, setData] = useState(); + const [failure, setFailure] = useState(false); + const [newGroupName, setNewGroupName] = useState(''); + const [creating, setCreating] = useState(false); + const [adminModalOpen, setAdminModalOpen] = useState(false); + const [noteModal, setNoteModal] = useState<{ groupId: string; login: string } | null>(null); + const [editAmounts, setEditAmounts] = useState>({}); + const [payModal, setPayModal] = useState(null); + const inputRef = useRef(null); + + const fetchData = async () => { + try { + const r = await getData({ query: { slot: SLOT } }); + if (r.data) setData(r.data); + } catch { + setFailure(true); + } + }; + + useEffect(() => { + if (!auth?.login) return; + fetchData(); + }, [auth?.login]); + + useEffect(() => { + socket.on(EVENT_MESSAGE, (newData: ClientData) => { + if (newData.slot === SLOT) setData(newData); + }); + return () => { socket.off(EVENT_MESSAGE); }; + }, [socket]); + + const refresh = async (fn: () => Promise) => { + const result = await fn(); + if (result?.data) { + setData(result.data); + const ws = result.data as ClientData; + socket.emit?.('message', ws); + } + await fetchData(); + }; + + const handleCreate = async () => { + if (!newGroupName || !auth?.login) return; + setCreating(true); + try { + await refresh(() => createGroup({ body: { name: newGroupName } })); + setNewGroupName(''); + } catch { /* swallow */ } + setCreating(false); + }; + + const handleJoin = (groupId: string) => + refresh(() => addGroupMember({ body: { id: groupId } })); + + const handleLeave = (groupId: string) => + refresh(() => removeGroupMember({ body: { id: groupId, login: auth?.login ?? '' } })); + + const handleToggleLock = (group: OrderGroup) => { + const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN; + return refresh(() => setGroupState({ body: { id: group.id, state: next } })); + }; + + const handleMarkOrdered = (group: OrderGroup) => + refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } })); + + const handleDelete = (groupId: string) => + refresh(() => deleteGroup({ body: { id: groupId } })); + + const handleSaveNote = async (note?: string) => { + if (!noteModal || !auth?.login) return; + await refresh(() => updateGroupMember({ body: { id: noteModal.groupId, login: noteModal.login, note } })); + setNoteModal(null); + }; + + const handleSaveAmount = async (groupId: string, login: string) => { + const key = `${groupId}:${login}`; + const raw = editAmounts[key]; + const n = parseFloat(raw ?? ''); + const amount = isNaN(n) || n < 0 ? undefined : n; + await refresh(() => updateGroupMember({ body: { id: groupId, login, amount } })); + setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; }); + }; + + const canEditMember = (group: OrderGroup, targetLogin: string) => { + if (group.state === GroupState.ORDERED) return false; + if (auth?.login === group.creatorLogin) return true; + if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true; + return false; + }; + + const canManageMembers = (group: OrderGroup) => { + if (group.state === GroupState.ORDERED) return false; + if (auth?.login === group.creatorLogin) return true; + return group.state === GroupState.OPEN; + }; + + if (!auth?.login) return ; + + if (failure) return ( + + ); + + if (!data) return ( + + ); + + const stores = data.stores ?? []; + const groups = data.groups ?? []; + + return ( +
+
+
+
+

Objednání

+ +
+

Skupinové objednávky z obchodů a restaurací

+ +
+
+ {/* Vytvoření nové skupiny */} +
+
Vytvořit skupinu
+ {stores.length === 0 ? ( +

+ Nejsou přidány žádné obchody.{' '} + +

+ ) : ( +
+ setNewGroupName(e.target.value)} + style={{ maxWidth: 260 }} + > + + {stores.map(s => )} + + +
+ )} +
+ + {/* Seznam skupin */} + {groups.length === 0 && ( +

Zatím žádné skupiny pro dnešní den.

+ )} + + {groups.map(group => { + const login = auth!.login ?? ''; + const isCreator = login === group.creatorLogin; + const isMember = login in group.members; + const isOrdered = group.state === GroupState.ORDERED; + const isLocked = group.state === GroupState.LOCKED; + const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][]; + + return ( + + +
+ {group.name} + {stateBadge(group.state)} + zakladatel: {group.creatorLogin} +
+
+ {isCreator && !isOrdered && ( + <> + + {isLocked && ( + + )} + + + )} + {isCreator && isOrdered && settings?.bankAccount && settings?.holderName && ( + + )} + {!isMember && !isOrdered && ( + + )} +
+
+ + + + + + + + + + + + {memberEntries.map(([memberLogin, member]) => { + const amountKey = `${group.id}:${memberLogin}`; + const editingAmount = amountKey in editAmounts; + const canEdit = canEditMember(group, memberLogin); + return ( + + + + + + + ); + })} + +
ČlenČástka (Kč)Poznámka
+ + {memberLogin} + {memberLogin === group.creatorLogin && ( + + )} + + + {canEdit && editingAmount ? ( +
+ setEditAmounts(prev => ({ ...prev, [amountKey]: e.target.value }))} + onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); }} + style={{ width: 80 }} + autoFocus={memberLogin === login} + /> + +
+ ) : ( + canEdit && setEditAmounts(prev => ({ ...prev, [amountKey]: String(member.amount ?? '') }))} + title={canEdit ? 'Klikněte pro úpravu' : undefined} + > + {member.amount != null ? `${member.amount} Kč` : } + + )} +
+ {member.note || '—'} + +
+ {memberLogin === login && ( + setNoteModal({ groupId: group.id, login: memberLogin })} + /> + )} + {canManageMembers(group) && (memberLogin !== group.creatorLogin) && ( + refresh(() => removeGroupMember({ body: { id: group.id, login: memberLogin } }))} + /> + )} +
+
+
+
+ ); + })} +
+
+
+
+ + setNoteModal(null)} + onSave={handleSaveNote} + /> + + setAdminModalOpen(false)} + stores={stores} + onStoresChanged={updated => setData(prev => prev ? { ...prev, stores: updated } : prev)} + /> + + {payModal && settings?.bankAccount && settings?.holderName && ( + setPayModal(null)} + group={payModal} + payerLogin={auth.login} + bankAccount={settings.bankAccount} + bankAccountHolder={settings.holderName} + /> + )} +
+ ); +} diff --git a/server/.env.template b/server/.env.template index 52e80be..da52252 100644 --- a/server/.env.template +++ b/server/.env.template @@ -47,4 +47,8 @@ # 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= \ No newline at end of file +# REFRESH_BYPASS_PASSWORD= + +# Admin heslo pro správu seznamu obchodů na stránce /objednani. +# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403). +# ADMIN_PASSWORD= \ No newline at end of file diff --git a/server/src/groups.ts b/server/src/groups.ts new file mode 100644 index 0000000..5185ce7 --- /dev/null +++ b/server/src/groups.ts @@ -0,0 +1,119 @@ +import crypto from "crypto"; +import getStorage from "./storage"; +import { getClientData, getToday, initIfNeeded } from "./service"; +import { getStores } from "./stores"; +import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen"; +import { formatDate } from "./utils"; + +const storage = getStorage(); + +async function getExtraData(date?: Date): Promise { + await initIfNeeded(date, MealSlot.EXTRA); + return getClientData(date, MealSlot.EXTRA); +} + +function getExtraKey(date?: Date): string { + return `${formatDate(date ?? getToday())}_extra`; +} + +async function saveExtraData(data: ClientData, date?: Date): Promise { + await storage.setData(getExtraKey(date), data); + return data; +} + +function findGroup(data: ClientData, id: string): OrderGroup | undefined { + return data.groups?.find(g => g.id === id); +} + +export async function createGroup(creatorLogin: string, name: string, date?: Date): Promise { + const stores = await getStores(); + if (!stores.some(s => s.toLowerCase() === name.trim().toLowerCase())) { + throw new Error('Obchod není v seznamu povolených obchodů'); + } + const data = await getExtraData(date); + const group: OrderGroup = { + id: crypto.randomUUID(), + name: name.trim(), + creatorLogin, + state: GroupState.OPEN, + members: { [creatorLogin]: {} }, + }; + data.groups = [...(data.groups ?? []), group]; + return saveExtraData(data, date); +} + +export async function deleteGroup(login: string, groupId: string, date?: Date): Promise { + const data = await getExtraData(date); + const group = findGroup(data, groupId); + if (!group) throw new Error('Skupina nebyla nalezena'); + if (group.creatorLogin !== login) throw new Error('Skupinu může smazat pouze zakladatel'); + data.groups = (data.groups ?? []).filter(g => g.id !== groupId); + return saveExtraData(data, date); +} + +export async function addGroupMember(login: string, groupId: string, targetLogin: string, date?: Date): Promise { + const data = await getExtraData(date); + const group = findGroup(data, groupId); + if (!group) throw new Error('Skupina nebyla nalezena'); + if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat'); + if (login !== group.creatorLogin && login !== targetLogin) { + throw new Error('Přidat jiného uživatele může pouze zakladatel'); + } + if (group.state === GroupState.LOCKED && login !== group.creatorLogin) { + throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel'); + } + if (group.members[targetLogin]) throw new Error('Uživatel je již ve skupině'); + group.members[targetLogin] = {}; + return saveExtraData(data, date); +} + +export async function removeGroupMember(login: string, groupId: string, targetLogin: string, date?: Date): Promise { + const data = await getExtraData(date); + const group = findGroup(data, groupId); + if (!group) throw new Error('Skupina nebyla nalezena'); + if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat'); + if (login !== group.creatorLogin && login !== targetLogin) { + throw new Error('Odebrat jiného uživatele může pouze zakladatel'); + } + if (group.state === GroupState.LOCKED && login !== group.creatorLogin) { + throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel'); + } + if (targetLogin === group.creatorLogin) throw new Error('Zakladatel skupiny nemůže být odebrán'); + if (!group.members[targetLogin]) throw new Error('Uživatel není ve skupině'); + delete group.members[targetLogin]; + return saveExtraData(data, date); +} + +export async function updateGroupMember(login: string, groupId: string, targetLogin: string, patch: Partial, date?: Date): Promise { + const data = await getExtraData(date); + const group = findGroup(data, groupId); + if (!group) throw new Error('Skupina nebyla nalezena'); + if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat'); + const isSelf = login === targetLogin; + const isCreator = login === group.creatorLogin; + if (!isSelf && !isCreator) throw new Error('Upravit jiného uživatele může pouze zakladatel'); + if (!isCreator && group.state === GroupState.LOCKED) { + throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel'); + } + if (!group.members[targetLogin]) throw new Error('Uživatel není ve skupině'); + group.members[targetLogin] = { ...group.members[targetLogin], ...patch }; + return saveExtraData(data, date); +} + +const VALID_TRANSITIONS: Record = { + [GroupState.OPEN]: [GroupState.LOCKED], + [GroupState.LOCKED]: [GroupState.OPEN, GroupState.ORDERED], + [GroupState.ORDERED]: [], +}; + +export async function setGroupState(login: string, groupId: string, newState: GroupState, date?: Date): Promise { + const data = await getExtraData(date); + const group = findGroup(data, groupId); + if (!group) throw new Error('Skupina nebyla nalezena'); + if (group.creatorLogin !== login) throw new Error('Stav může měnit pouze zakladatel'); + if (!VALID_TRANSITIONS[group.state].includes(newState)) { + throw new Error(`Nelze přejít ze stavu "${group.state}" do stavu "${newState}"`); + } + group.state = newState; + return saveExtraData(data, date); +} diff --git a/server/src/index.ts b/server/src/index.ts index 850ecfe..e7dff2b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -21,6 +21,8 @@ import notificationRoutes from "./routes/notificationRoutes"; import qrRoutes from "./routes/qrRoutes"; import devRoutes from "./routes/devRoutes"; import changelogRoutes from "./routes/changelogRoutes"; +import groupRoutes from "./routes/groupRoutes"; +import storeRoutes from "./routes/storeRoutes"; const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); @@ -180,6 +182,8 @@ app.use("/api/notifications", notificationRoutes); app.use("/api/qr", qrRoutes); app.use("/api/dev", devRoutes); app.use("/api/changelogs", changelogRoutes); +app.use("/api/groups", groupRoutes); +app.use("/api/stores", storeRoutes); app.use('/stats', express.static('public')); app.use(express.static('public')); diff --git a/server/src/routes/foodRoutes.ts b/server/src/routes/foodRoutes.ts index 0910e4d..6f9fadf 100644 --- a/server/src/routes/foodRoutes.ts +++ b/server/src/routes/foodRoutes.ts @@ -71,7 +71,7 @@ const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] const parseSlot = (body: Record): MealSlot | undefined => { const slot = body?.slot; - if (slot != null && slot !== MealSlot.OBED && slot !== MealSlot.EXTRA) { + if (slot != null && slot !== MealSlot.OBED) { throw Error(`Neplatný slot: ${slot}`); } return slot ?? undefined; diff --git a/server/src/routes/groupRoutes.ts b/server/src/routes/groupRoutes.ts new file mode 100644 index 0000000..82cb274 --- /dev/null +++ b/server/src/routes/groupRoutes.ts @@ -0,0 +1,93 @@ +import express, { Request } from "express"; +import { getLogin } from "../auth"; +import { parseToken } from "../utils"; +import { getWebsocket } from "../websocket"; +import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState } from "../groups"; +import { GroupState } from "../../../types/gen/types.gen"; + +const router = express.Router(); + +function broadcastExtra(data: any) { + getWebsocket().emit("message", data); +} + +router.post("/create", async (req: Request, res, next) => { + const login = getLogin(parseToken(req)); + const { name } = req.body ?? {}; + if (!name || typeof name !== 'string') { + return res.status(400).json({ error: 'Nebyl předán název skupiny' }); + } + try { + const data = await createGroup(login, name); + broadcastExtra(data); + res.status(200).json(data); + } catch (e: any) { next(e); } +}); + +router.post("/delete", async (req: Request, res, next) => { + const login = getLogin(parseToken(req)); + const { id } = req.body ?? {}; + if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' }); + try { + const data = await deleteGroup(login, id); + broadcastExtra(data); + res.status(200).json(data); + } catch (e: any) { next(e); } +}); + +router.post("/addMember", async (req: Request, res, next) => { + const login = getLogin(parseToken(req)); + const { id, login: targetLogin } = req.body ?? {}; + if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' }); + const target = targetLogin ?? login; + try { + const data = await addGroupMember(login, id, target); + broadcastExtra(data); + res.status(200).json(data); + } catch (e: any) { next(e); } +}); + +router.post("/removeMember", async (req: Request, res, next) => { + const login = getLogin(parseToken(req)); + const { id, login: targetLogin } = req.body ?? {}; + if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' }); + if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' }); + try { + const data = await removeGroupMember(login, id, targetLogin); + broadcastExtra(data); + res.status(200).json(data); + } catch (e: any) { next(e); } +}); + +router.post("/updateMember", async (req: Request, res, next) => { + const login = getLogin(parseToken(req)); + const { id, login: targetLogin, amount, note, surchargeText, surchargeAmount } = req.body ?? {}; + if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' }); + if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' }); + const patch: Record = {}; + if (amount !== undefined) patch.amount = amount; + if (note !== undefined) patch.note = note; + if (surchargeText !== undefined) patch.surchargeText = surchargeText; + if (surchargeAmount !== undefined) patch.surchargeAmount = surchargeAmount; + try { + const data = await updateGroupMember(login, id, targetLogin, patch); + broadcastExtra(data); + res.status(200).json(data); + } catch (e: any) { next(e); } +}); + +router.post("/setState", async (req: Request, res, next) => { + const login = getLogin(parseToken(req)); + const { id, state } = req.body ?? {}; + if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' }); + if (!state || !Object.values(GroupState).includes(state)) { + return res.status(400).json({ error: 'Neplatný stav skupiny' }); + } + try { + const data = await setGroupState(login, id, state as GroupState); + broadcastExtra(data); + res.status(200).json(data); + } catch (e: any) { next(e); } +}); + +export default router; diff --git a/server/src/routes/storeRoutes.ts b/server/src/routes/storeRoutes.ts new file mode 100644 index 0000000..5526b73 --- /dev/null +++ b/server/src/routes/storeRoutes.ts @@ -0,0 +1,51 @@ +import express from "express"; +import { getStores, addStore, removeStore } from "../stores"; + +const router = express.Router(); + +router.get("/", async (_req, res, next) => { + try { + const stores = await getStores(); + res.status(200).json(stores); + } catch (e: any) { next(e); } +}); + +router.post("/add", async (req, res, next) => { + const { name, heslo } = 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' }); + } + try { + const stores = await addStore(name, heslo); + 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') { + 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' }); + } + try { + const stores = await removeStore(name, heslo); + res.status(200).json(stores); + } catch (e: any) { + if (e.message === 'UNAUTHORIZED') { + return res.status(403).json({ error: 'Nesprávné heslo' }); + } + next(e); + } +}); + +export default router; diff --git a/server/src/service.ts b/server/src/service.ts index 62206f0..9b2b76e 100644 --- a/server/src/service.ts +++ b/server/src/service.ts @@ -3,6 +3,7 @@ import getStorage from "./storage"; import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants"; import { getTodayMock } from "./mock"; import { removeAllUserPizzas } from "./pizza"; +import { getStores } from "./stores"; import { ClientData, DepartureTime, LunchChoice, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen"; const storage = getStorage(); @@ -50,7 +51,9 @@ export function getEmptyData(date?: Date): ClientData { */ export async function getData(date?: Date, slot?: MealSlot): Promise { const clientData = await getClientData(date, slot); - if (slot !== MealSlot.EXTRA) { + if (slot === MealSlot.EXTRA) { + clientData.stores = await getStores(); + } else { clientData.menus = { SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date), // UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date), diff --git a/server/src/stores.ts b/server/src/stores.ts new file mode 100644 index 0000000..345ba85 --- /dev/null +++ b/server/src/stores.ts @@ -0,0 +1,37 @@ +import getStorage from "./storage"; + +const storage = getStorage(); +const STORES_KEY = 'stores'; + +export async function getStores(): Promise { + return (await storage.getData(STORES_KEY)) ?? []; +} + +export async function addStore(name: string, heslo: string): Promise { + const adminPassword = process.env.ADMIN_PASSWORD; + if (!adminPassword || heslo !== adminPassword) { + throw new Error('UNAUTHORIZED'); + } + const trimmed = name.trim(); + if (!trimmed) { + throw new Error('Název obchodu nesmí být prázdný'); + } + const stores = await getStores(); + if (stores.some(s => s.toLowerCase() === trimmed.toLowerCase())) { + throw new Error('Obchod s tímto názvem již existuje'); + } + const updated = [...stores, trimmed]; + await storage.setData(STORES_KEY, updated); + return updated; +} + +export async function removeStore(name: string, heslo: string): Promise { + const adminPassword = process.env.ADMIN_PASSWORD; + if (!adminPassword || heslo !== adminPassword) { + throw new Error('UNAUTHORIZED'); + } + const stores = await getStores(); + const updated = stores.filter(s => s.toLowerCase() !== name.trim().toLowerCase()); + await storage.setData(STORES_KEY, updated); + return updated; +} diff --git a/server/src/tests/groups.test.ts b/server/src/tests/groups.test.ts new file mode 100644 index 0000000..5f9d86d --- /dev/null +++ b/server/src/tests/groups.test.ts @@ -0,0 +1,195 @@ +import { resetMemoryStorage } from '../storage/memory'; +import { getStores, addStore } from '../stores'; +import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState } from '../groups'; +import { GroupState } from '../../../types/gen/types.gen'; + +const CREATOR = 'tomas'; +const USER = 'petr'; +const ADMIN_PW = 'testadmin'; +const STORE = 'McDonald\'s'; +const TODAY = new Date('2025-01-10'); + +beforeEach(async () => { + resetMemoryStorage(); + process.env.ADMIN_PASSWORD = ADMIN_PW; + await addStore(STORE, ADMIN_PW); +}); + +afterEach(() => { + delete process.env.ADMIN_PASSWORD; +}); + +describe('createGroup', () => { + test('vytvoří skupinu, creator je člen', async () => { + const data = await createGroup(CREATOR, STORE, TODAY); + expect(data.groups).toHaveLength(1); + const group = data.groups![0]; + expect(group.name).toBe(STORE); + expect(group.creatorLogin).toBe(CREATOR); + expect(group.state).toBe(GroupState.OPEN); + expect(group.members[CREATOR]).toBeDefined(); + }); + + test('odmítne název mimo seznam obchodů', async () => { + await expect(createGroup(CREATOR, 'Neznámý obchod', TODAY)).rejects.toThrow('seznam'); + }); + + test('vygeneruje unikátní ID', async () => { + const d1 = await createGroup(CREATOR, STORE, TODAY); + const d2 = await createGroup(USER, STORE, TODAY); + expect(d2.groups).toHaveLength(2); + expect(d2.groups![1].id).not.toBe(d2.groups![0].id); + }); +}); + +describe('deleteGroup', () => { + test('creator může smazat skupinu', async () => { + const d = await createGroup(CREATOR, STORE, TODAY); + const groupId = d.groups![0].id; + const result = await deleteGroup(CREATOR, groupId, TODAY); + expect(result.groups).toHaveLength(0); + }); + + test('nečlen nemůže smazat skupinu', async () => { + const d = await createGroup(CREATOR, STORE, TODAY); + const groupId = d.groups![0].id; + await expect(deleteGroup(USER, groupId, TODAY)).rejects.toThrow('zakladatel'); + }); + + test('smazání neexistující skupiny vyhodí chybu', async () => { + await expect(deleteGroup(CREATOR, 'nonexistent', TODAY)).rejects.toThrow('nalezena'); + }); +}); + +describe('addGroupMember', () => { + let groupId: string; + + beforeEach(async () => { + const d = await createGroup(CREATOR, STORE, TODAY); + groupId = d.groups![0].id; + }); + + test('uživatel se může přidat sám (open)', async () => { + const d = await addGroupMember(USER, groupId, USER, TODAY); + expect(d.groups![0].members[USER]).toBeDefined(); + }); + + test('creator může přidat jiného uživatele', async () => { + const d = await addGroupMember(CREATOR, groupId, USER, TODAY); + expect(d.groups![0].members[USER]).toBeDefined(); + }); + + test('nečlen nemůže přidat jiného uživatele', async () => { + await expect(addGroupMember(USER, groupId, 'dalsi', TODAY)).rejects.toThrow('zakladatel'); + }); + + test('nelze přidat do skupiny ve stavu ordered', async () => { + await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY); + await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY); + await expect(addGroupMember(USER, groupId, USER, TODAY)).rejects.toThrow('objednáno'); + }); + + test('nelze přidat existujícího člena', async () => { + await expect(addGroupMember(CREATOR, groupId, CREATOR, TODAY)).rejects.toThrow('již'); + }); +}); + +describe('removeGroupMember', () => { + let groupId: string; + + beforeEach(async () => { + const d = await createGroup(CREATOR, STORE, TODAY); + groupId = d.groups![0].id; + await addGroupMember(CREATOR, groupId, USER, TODAY); + }); + + test('člen se může odhlásit sám', async () => { + const d = await removeGroupMember(USER, groupId, USER, TODAY); + expect(d.groups![0].members[USER]).toBeUndefined(); + }); + + test('creator může odebrat jiného člena', async () => { + const d = await removeGroupMember(CREATOR, groupId, USER, TODAY); + expect(d.groups![0].members[USER]).toBeUndefined(); + }); + + test('nelze odebrat zakladatele', async () => { + await expect(removeGroupMember(CREATOR, groupId, CREATOR, TODAY)).rejects.toThrow('Zakladatel'); + }); + + test('nečlen nemůže odebrat jiného', async () => { + await expect(removeGroupMember(USER, groupId, CREATOR, TODAY)).rejects.toThrow('zakladatel'); + }); +}); + +describe('updateGroupMember', () => { + let groupId: string; + + beforeEach(async () => { + const d = await createGroup(CREATOR, STORE, TODAY); + groupId = d.groups![0].id; + await addGroupMember(CREATOR, groupId, USER, TODAY); + }); + + test('člen může upravit svá data (open)', async () => { + const d = await updateGroupMember(USER, groupId, USER, { amount: 150, note: 'Big Mac' }, TODAY); + expect(d.groups![0].members[USER].amount).toBe(150); + expect(d.groups![0].members[USER].note).toBe('Big Mac'); + }); + + test('creator může upravit data jiného člena', async () => { + const d = await updateGroupMember(CREATOR, groupId, USER, { amount: 200 }, TODAY); + expect(d.groups![0].members[USER].amount).toBe(200); + }); + + test('člen nemůže upravit data jiného (locked)', async () => { + await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY); + await expect(updateGroupMember(USER, groupId, USER, { amount: 100 }, TODAY)).rejects.toThrow('uzamčeno'); + }); + + test('nikdo nemůže upravit při stavu ordered', async () => { + await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY); + await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY); + await expect(updateGroupMember(CREATOR, groupId, USER, { amount: 100 }, TODAY)).rejects.toThrow('objednáno'); + }); +}); + +describe('setGroupState', () => { + let groupId: string; + + beforeEach(async () => { + const d = await createGroup(CREATOR, STORE, TODAY); + groupId = d.groups![0].id; + }); + + test('open → locked', async () => { + const d = await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY); + expect(d.groups![0].state).toBe(GroupState.LOCKED); + }); + + test('locked → open (odemčení)', async () => { + await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY); + const d = await setGroupState(CREATOR, groupId, GroupState.OPEN, TODAY); + expect(d.groups![0].state).toBe(GroupState.OPEN); + }); + + test('locked → ordered', async () => { + await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY); + const d = await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY); + expect(d.groups![0].state).toBe(GroupState.ORDERED); + }); + + test('open → ordered není povoleno', async () => { + await expect(setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY)).rejects.toThrow('Nelze přejít'); + }); + + test('ordered je terminální stav', async () => { + await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY); + await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY); + await expect(setGroupState(CREATOR, groupId, GroupState.OPEN, TODAY)).rejects.toThrow('Nelze přejít'); + }); + + test('nečlen nemůže měnit stav', async () => { + await expect(setGroupState(USER, groupId, GroupState.LOCKED, TODAY)).rejects.toThrow('zakladatel'); + }); +}); diff --git a/server/src/tests/stores.test.ts b/server/src/tests/stores.test.ts new file mode 100644 index 0000000..016d5c4 --- /dev/null +++ b/server/src/tests/stores.test.ts @@ -0,0 +1,78 @@ +import { resetMemoryStorage } from '../storage/memory'; +import { getStores, addStore, removeStore } from '../stores'; + +const ADMIN_PW = 'testadmin'; + +beforeEach(() => { + resetMemoryStorage(); + process.env.ADMIN_PASSWORD = ADMIN_PW; +}); + +afterEach(() => { + delete process.env.ADMIN_PASSWORD; +}); + +describe('getStores', () => { + test('vrátí prázdné pole, pokud seznam neexistuje', async () => { + const stores = await getStores(); + expect(stores).toEqual([]); + }); +}); + +describe('addStore', () => { + test('přidá obchod se správným heslem', async () => { + const stores = await addStore('McDonald\'s', ADMIN_PW); + expect(stores).toContain('McDonald\'s'); + }); + + test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => { + await expect(addStore('KFC', 'spatne')).rejects.toThrow('UNAUTHORIZED'); + }); + + test('vyhodí UNAUTHORIZED pokud ADMIN_PASSWORD není nastaven', async () => { + delete process.env.ADMIN_PASSWORD; + await expect(addStore('KFC', '')).rejects.toThrow('UNAUTHORIZED'); + }); + + test('odmítne prázdný název', async () => { + await expect(addStore(' ', ADMIN_PW)).rejects.toThrow('prázdný'); + }); + + test('odmítne duplikát (case-insensitive)', async () => { + await addStore('McDonald\'s', ADMIN_PW); + await expect(addStore('mcdonald\'s', ADMIN_PW)).rejects.toThrow('existuje'); + }); + + test('vrátí aktualizovaný seznam', async () => { + await addStore('McDonald\'s', ADMIN_PW); + const stores = await addStore('KFC', ADMIN_PW); + expect(stores).toHaveLength(2); + expect(stores).toContain('McDonald\'s'); + expect(stores).toContain('KFC'); + }); +}); + +describe('removeStore', () => { + beforeEach(async () => { + await addStore('McDonald\'s', ADMIN_PW); + }); + + test('odebere obchod se správným heslem', async () => { + const stores = await removeStore('McDonald\'s', ADMIN_PW); + expect(stores).not.toContain('McDonald\'s'); + }); + + test('case-insensitive odebrání', async () => { + const stores = await removeStore('MCDONALD\'S', ADMIN_PW); + expect(stores).not.toContain('McDonald\'s'); + }); + + test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => { + await expect(removeStore('McDonald\'s', 'spatne')).rejects.toThrow('UNAUTHORIZED'); + }); + + test('odebrání neexistujícího obchodu nic nerozbije', async () => { + const stores = await removeStore('Neexistuje', ADMIN_PW); + expect(stores).toContain('McDonald\'s'); + }); +}); diff --git a/types/api.yml b/types/api.yml index 8b5a570..b42998c 100644 --- a/types/api.yml +++ b/types/api.yml @@ -81,6 +81,28 @@ paths: /changelogs: $ref: "./paths/changelogs/getChangelogs.yml" + # Skupiny objednávek (/api/groups) + /groups/create: + $ref: "./paths/groups/createGroup.yml" + /groups/delete: + $ref: "./paths/groups/deleteGroup.yml" + /groups/addMember: + $ref: "./paths/groups/addMember.yml" + /groups/removeMember: + $ref: "./paths/groups/removeMember.yml" + /groups/updateMember: + $ref: "./paths/groups/updateMember.yml" + /groups/setState: + $ref: "./paths/groups/setState.yml" + + # Správa obchodů (/api/stores) + /stores: + $ref: "./paths/stores/listStores.yml" + /stores/add: + $ref: "./paths/stores/addStore.yml" + /stores/delete: + $ref: "./paths/stores/deleteStore.yml" + # DEV endpointy (/api/dev) /dev/generate: $ref: "./paths/dev/generate.yml" diff --git a/types/paths/groups/addMember.yml b/types/paths/groups/addMember.yml new file mode 100644 index 0000000..ca6f11b --- /dev/null +++ b/types/paths/groups/addMember.yml @@ -0,0 +1,21 @@ +post: + operationId: addGroupMember + summary: Přidá uživatele do skupiny (sebe, nebo jiného jako zakladatel). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + description: ID skupiny + type: string + login: + description: Login uživatele (volitelné — pokud není zadán, přidá přihlášeného uživatele) + type: string + responses: + "200": + $ref: "../../api.yml#/components/responses/ClientDataResponse" diff --git a/types/paths/groups/createGroup.yml b/types/paths/groups/createGroup.yml new file mode 100644 index 0000000..582fde2 --- /dev/null +++ b/types/paths/groups/createGroup.yml @@ -0,0 +1,18 @@ +post: + operationId: createGroup + summary: Vytvoří novou skupinu objednávky pro aktuální den. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + description: Název obchodu/restaurace (musí být v seznamu povolených obchodů) + type: string + responses: + "200": + $ref: "../../api.yml#/components/responses/ClientDataResponse" diff --git a/types/paths/groups/deleteGroup.yml b/types/paths/groups/deleteGroup.yml new file mode 100644 index 0000000..572a396 --- /dev/null +++ b/types/paths/groups/deleteGroup.yml @@ -0,0 +1,18 @@ +post: + operationId: deleteGroup + summary: Smaže skupinu objednávky (pouze zakladatel). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + description: ID skupiny + type: string + responses: + "200": + $ref: "../../api.yml#/components/responses/ClientDataResponse" diff --git a/types/paths/groups/removeMember.yml b/types/paths/groups/removeMember.yml new file mode 100644 index 0000000..d0a2c66 --- /dev/null +++ b/types/paths/groups/removeMember.yml @@ -0,0 +1,22 @@ +post: + operationId: removeGroupMember + summary: Odebere uživatele ze skupiny (sebe, nebo jiného jako zakladatel). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + - login + properties: + id: + description: ID skupiny + type: string + login: + description: Login uživatele k odebrání + type: string + responses: + "200": + $ref: "../../api.yml#/components/responses/ClientDataResponse" diff --git a/types/paths/groups/setState.yml b/types/paths/groups/setState.yml new file mode 100644 index 0000000..c808803 --- /dev/null +++ b/types/paths/groups/setState.yml @@ -0,0 +1,21 @@ +post: + operationId: setGroupState + summary: Změní stav skupiny (pouze zakladatel). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + - state + properties: + id: + description: ID skupiny + type: string + state: + $ref: "../../schemas/_index.yml#/GroupState" + responses: + "200": + $ref: "../../api.yml#/components/responses/ClientDataResponse" diff --git a/types/paths/groups/updateMember.yml b/types/paths/groups/updateMember.yml new file mode 100644 index 0000000..a8e5a66 --- /dev/null +++ b/types/paths/groups/updateMember.yml @@ -0,0 +1,34 @@ +post: + operationId: updateGroupMember + summary: Aktualizuje data člena skupiny (částka, poznámka, příplatek). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + - login + properties: + id: + description: ID skupiny + type: string + login: + description: Login člena ke změně + type: string + amount: + description: Částka k úhradě v Kč + type: number + note: + description: Poznámka + type: string + surchargeText: + description: Popis příplatku + type: string + surchargeAmount: + description: Výše příplatku v Kč + type: number + responses: + "200": + $ref: "../../api.yml#/components/responses/ClientDataResponse" diff --git a/types/paths/stores/addStore.yml b/types/paths/stores/addStore.yml new file mode 100644 index 0000000..e88ccce --- /dev/null +++ b/types/paths/stores/addStore.yml @@ -0,0 +1,28 @@ +post: + operationId: addStore + summary: Přidá obchod do seznamu povolených (vyžaduje admin heslo). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - heslo + properties: + name: + description: Název obchodu/restaurace + type: string + heslo: + description: Admin heslo (ADMIN_PASSWORD) + type: string + responses: + "200": + description: Obchod byl přidán + content: + application/json: + schema: + type: array + items: + type: string diff --git a/types/paths/stores/deleteStore.yml b/types/paths/stores/deleteStore.yml new file mode 100644 index 0000000..1549b3d --- /dev/null +++ b/types/paths/stores/deleteStore.yml @@ -0,0 +1,28 @@ +post: + operationId: deleteStore + summary: Odebere obchod ze seznamu povolených (vyžaduje admin heslo). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - heslo + properties: + name: + description: Název obchodu/restaurace k odebrání + type: string + heslo: + description: Admin heslo (ADMIN_PASSWORD) + type: string + responses: + "200": + description: Obchod byl odebrán + content: + application/json: + schema: + type: array + items: + type: string diff --git a/types/paths/stores/listStores.yml b/types/paths/stores/listStores.yml new file mode 100644 index 0000000..ac58796 --- /dev/null +++ b/types/paths/stores/listStores.yml @@ -0,0 +1,12 @@ +get: + operationId: listStores + summary: Vrátí seznam povolených obchodů/restaurací. + responses: + "200": + description: Seznam obchodů + content: + application/json: + schema: + type: array + items: + type: string diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 19d7808..05191ee 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -66,6 +66,16 @@ ClientData: slot: description: Slot jídla, ke kterému se tato data vztahují $ref: "#/MealSlot" + groups: + description: Skupiny objednávajících pro extra slot + type: array + items: + $ref: "#/OrderGroup" + stores: + description: Seznam povolených obchodů/restaurací pro extra objednávky + type: array + items: + type: string # --- OBĚDY --- UserLunchChoice: @@ -674,6 +684,68 @@ ClearMockDataRequest: description: Index dne v týdnu (0 = pondělí, 4 = pátek). Pokud není zadán, použije se aktuální den. $ref: "#/DayIndex" +# --- SKUPINOVÉ OBJEDNÁVKY --- +GroupState: + description: Stav skupiny objednávky + type: string + enum: + - open + - locked + - ordered + x-enum-varnames: + - OPEN + - LOCKED + - ORDERED + +OrderGroupMember: + description: Data člena skupiny objednávky + type: object + additionalProperties: false + properties: + amount: + description: Částka k úhradě v Kč + type: number + note: + description: Volitelná poznámka (např. co si objednává) + type: string + surchargeText: + description: Popis příplatku + type: string + surchargeAmount: + description: Výše příplatku v Kč + type: number + +OrderGroup: + description: Skupina uživatelů objednávajících z jednoho místa + type: object + additionalProperties: false + required: + - id + - name + - creatorLogin + - state + - members + properties: + id: + description: Unikátní identifikátor skupiny + type: string + name: + description: Název obchodu/restaurace + type: string + creatorLogin: + description: Login zakladatele skupiny + type: string + state: + $ref: "#/GroupState" + members: + description: Členové skupiny + type: object + additionalProperties: + $ref: "#/OrderGroupMember" + tipTotal: + description: Celkové dýško (Kč), vyplněno při přechodu do stavu ordered + type: number + # --- NEVYŘÍZENÉ QR KÓDY --- PendingQr: description: Nevyřízený QR kód pro platbu