diff --git a/client/public/sw.js b/client/public/sw.js
index f357178..01900d5 100644
--- a/client/public/sw.js
+++ b/client/public/sw.js
@@ -7,7 +7,7 @@ self.addEventListener('push', (event) => {
body: data.body,
icon: '/favicon.ico',
tag: 'lunch-reminder',
- data: { login: data.login },
+ data: { login: data.login, token: data.token },
actions: [
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
],
@@ -19,13 +19,13 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'neobedvam') {
- const login = event.notification.data?.login;
- if (login) {
+ const { login, token } = event.notification.data ?? {};
+ if (login && token) {
event.waitUntil(
fetch('/api/notifications/push/quickChoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ login }),
+ body: JSON.stringify({ login, token }),
})
);
}
diff --git a/client/src/components/modals/PayForAllModal.tsx b/client/src/components/modals/PayForAllModal.tsx
index f437625..a14016a 100644
--- a/client/src/components/modals/PayForAllModal.tsx
+++ b/client/src/components/modals/PayForAllModal.tsx
@@ -87,10 +87,16 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
const totalPeople = includedDiners.length + 1; // +1 for payer
return Math.round((tip / totalPeople) * 100) / 100;
})();
+ const payerTipShare = (() => {
+ const tip = parseAmount(tipTotal);
+ if (!tip) return 0;
+ return Math.round((tip - tipPerPerson * includedDiners.length) * 100) / 100;
+ })();
const getTotal = (d: DinerEntry): number => {
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
- return Math.round((d.baseAmount + surcharge + tipPerPerson) * 100) / 100;
+ const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
+ return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
};
const handleInclude = useCallback((login: string, checked: boolean) => {
@@ -248,7 +254,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
- {tipPerPerson > 0 ? `${tipPerPerson} Kč` : '—'}
+ {(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s} Kč` : '—'; })()}
|
{`${total} Kč`}
diff --git a/client/src/components/modals/PayForGroupModal.tsx b/client/src/components/modals/PayForGroupModal.tsx
index 3e5a1e9..99f4a0e 100644
--- a/client/src/components/modals/PayForGroupModal.tsx
+++ b/client/src/components/modals/PayForGroupModal.tsx
@@ -17,6 +17,7 @@ type Props = {
payerLogin: string;
bankAccount: string;
bankAccountHolder: string;
+ groupId?: string;
};
function sanitizeAmount(value: string): string {
@@ -32,7 +33,7 @@ function parseAmount(s: string): number | null {
return Math.round(n * 100) / 100;
}
-export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, bankAccount, bankAccountHolder }: Readonly) {
+export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly) {
const [diners, setDiners] = useState([]);
const [tipTotal, setTipTotal] = useState('');
const [error, setError] = useState(null);
@@ -63,10 +64,16 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
const totalPeople = includedNonPayers.length + 1; // +1 for payer
return Math.round((tip / totalPeople) * 100) / 100;
})();
+ const payerTipShare = (() => {
+ const tip = parseAmount(tipTotal);
+ if (!tip) return 0;
+ return Math.round((tip - tipPerPerson * includedNonPayers.length) * 100) / 100;
+ })();
const getTotal = (d: DinerEntry): number => {
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
- return Math.round((d.baseAmount + surcharge + tipPerPerson) * 100) / 100;
+ const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
+ return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
};
const handleInclude = useCallback((login: string, checked: boolean) => {
@@ -112,7 +119,7 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
setLoading(true);
try {
const response = await generateQr({
- body: { recipients, bankAccount, bankAccountHolder },
+ body: { recipients, bankAccount, bankAccountHolder, ...(groupId ? { groupId } : {}) },
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
@@ -203,7 +210,7 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
- {tipPerPerson > 0 ? `${tipPerPerson} Kč` : '—'}
+ {(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s} Kč` : '—'; })()}
|
{`${total} Kč`}
diff --git a/client/src/pages/OrderGroupsPage.tsx b/client/src/pages/OrderGroupsPage.tsx
index e8d51f0..2d0fef9 100644
--- a/client/src/pages/OrderGroupsPage.tsx
+++ b/client/src/pages/OrderGroupsPage.tsx
@@ -1,11 +1,11 @@
import { useContext, useEffect, useRef, useState } from 'react';
-import { Badge, Button, Card, Form, Table } from 'react-bootstrap';
+import { Alert, Badge, Button, Card, Form, Modal, Table } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons';
+import { 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,
+ getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes,
} from '../../../types';
import { EVENT_MESSAGE, SocketContext } from '../context/socket';
import { useAuth } from '../context/auth';
@@ -14,11 +14,11 @@ 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;
+const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
function stateBadge(state: GroupState) {
const map: Record = {
@@ -39,9 +39,12 @@ export default function OrderGroupsPage() {
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 [editNotes, setEditNotes] = useState>({});
+ const [editTimes, setEditTimes] = useState>({});
const [payModal, setPayModal] = useState(null);
+ const [confirmOrderGroup, setConfirmOrderGroup] = useState(null);
+ const [pageError, setPageError] = useState(null);
const inputRef = useRef(null);
const fetchData = async () => {
@@ -60,61 +63,90 @@ export default function OrderGroupsPage() {
useEffect(() => {
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
- if (newData.slot === SLOT) setData(newData);
+ if (newData.slot === SLOT) setData(prev => ({
+ ...newData,
+ stores: newData.stores ?? prev?.stores,
+ }));
});
return () => { socket.off(EVENT_MESSAGE); };
}, [socket]);
- const refresh = async (fn: () => Promise) => {
+ const refresh = async (fn: () => Promise): Promise => {
+ setPageError(null);
const result = await fn();
+ if (result?.error) {
+ setPageError((result.error as any).error || 'Nastala chyba');
+ await fetchData();
+ return false;
+ }
if (result?.data) {
setData(result.data);
- const ws = result.data as ClientData;
- socket.emit?.('message', ws);
+ socket.emit?.('message', result.data as ClientData);
}
await fetchData();
+ return true;
};
const handleCreate = async () => {
if (!newGroupName || !auth?.login) return;
setCreating(true);
- try {
- await refresh(() => createGroup({ body: { name: newGroupName } }));
- setNewGroupName('');
- } catch { /* swallow */ }
+ const ok = await refresh(() => createGroup({ body: { name: newGroupName } }));
+ if (ok) setNewGroupName('');
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 handleConfirmOrdered = async (group: OrderGroup) => {
+ setConfirmOrderGroup(null);
+ await refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } }));
+ };
+
+ const handleRevertOrdered = (group: OrderGroup) =>
+ refresh(() => setGroupState({ body: { id: group.id, state: GroupState.LOCKED } }));
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; });
+ if (!raw || isNaN(n) || n < 0) {
+ setPageError('Zadejte platnou kladnou částku');
+ return;
+ }
+ const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, amount: n } }));
+ if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
+ };
+
+ const handleSaveNote = async (groupId: string, login: string) => {
+ const key = `${groupId}:${login}`;
+ const note = editNotes[key] ?? '';
+ const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, note } }));
+ if (ok) setEditNotes(prev => { const next = { ...prev }; delete next[key]; return next; });
+ };
+
+ const handleSaveTimes = async (group: OrderGroup) => {
+ const times = editTimes[group.id];
+ if (!times) return;
+ const { orderedAt, deliveryAt } = times;
+ if (orderedAt && !TIME_REGEX.test(orderedAt)) {
+ setPageError('Čas objednání musí být ve formátu HH:MM');
+ return;
+ }
+ if (deliveryAt && !TIME_REGEX.test(deliveryAt)) {
+ setPageError('Čas doručení musí být ve formátu HH:MM');
+ return;
+ }
+ const ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
+ if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
};
const canEditMember = (group: OrderGroup, targetLogin: string) => {
@@ -155,6 +187,12 @@ export default function OrderGroupsPage() {
Skupinové objednávky z obchodů a restaurací
+ {pageError && (
+ setPageError(null)} className="mt-2">
+ {pageError}
+
+ )}
+
{/* Vytvoření nové skupiny */}
@@ -196,6 +234,7 @@ export default function OrderGroupsPage() {
const isOrdered = group.state === GroupState.ORDERED;
const isLocked = group.state === GroupState.LOCKED;
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
+ const editingTimes = group.id in editTimes;
return (
@@ -212,7 +251,7 @@ export default function OrderGroupsPage() {
{isLocked && (
-
>
)}
- {isCreator && isOrdered && settings?.bankAccount && settings?.holderName && (
- setPayModal(group)}>
-
- Generovat QR
-
+ {isCreator && isOrdered && (
+ <>
+ {settings?.bankAccount && settings?.holderName && (
+ setPayModal(group)}>
+
+ Generovat QR
+
+ )}
+ handleRevertOrdered(group)} title="Vrátit na Uzamčeno (smaže QR kódy)">
+
+
+ >
)}
- {!isMember && !isOrdered && (
+ {!isMember && !isOrdered && !isLocked && (
handleJoin(group.id)}>
Přidat se
@@ -242,13 +288,15 @@ export default function OrderGroupsPage() {
Člen |
Částka (Kč) |
Poznámka |
- |
+ |
{memberEntries.map(([memberLogin, member]) => {
const amountKey = `${group.id}:${memberLogin}`;
+ const noteKey = `${group.id}:${memberLogin}`;
const editingAmount = amountKey in editAmounts;
+ const editingNote = noteKey in editNotes;
const canEdit = canEditMember(group, memberLogin);
return (
@@ -269,7 +317,7 @@ export default function OrderGroupsPage() {
size="sm"
value={editAmounts[amountKey]}
onChange={e => setEditAmounts(prev => ({ ...prev, [amountKey]: e.target.value }))}
- onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); }}
+ onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); if (e.key === 'Escape') setEditAmounts(prev => { const n = { ...prev }; delete n[amountKey]; return n; }); }}
style={{ width: 80 }}
autoFocus={memberLogin === login}
/>
@@ -286,19 +334,31 @@ export default function OrderGroupsPage() {
)}
|
- {member.note || '—'}
+ {canEdit && editingNote ? (
+
+ setEditNotes(prev => ({ ...prev, [noteKey]: e.target.value }))}
+ onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveNote(group.id, memberLogin); if (e.key === 'Escape') setEditNotes(prev => { const n = { ...prev }; delete n[noteKey]; return n; }); }}
+ autoFocus
+ />
+ handleSaveNote(group.id, memberLogin)}>✓
+
+ ) : (
+ canEdit && setEditNotes(prev => ({ ...prev, [noteKey]: member.note ?? '' }))}
+ title={canEdit ? 'Klikněte pro úpravu poznámky' : undefined}
+ >
+ {member.note || '—'}
+
+ )}
|
- {memberLogin === login && (
- setNoteModal({ groupId: group.id, login: memberLogin })}
- />
- )}
- {canManageMembers(group) && (memberLogin !== group.creatorLogin) && (
+ {canManageMembers(group) && (isCreator || memberLogin === login) && (memberLogin !== group.creatorLogin) && (
+
+ {/* Časy objednání a doručení */}
+ {isOrdered && (
+
+ {isCreator && editingTimes ? (
+
+
+ Objednáno v:
+ setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], orderedAt: e.target.value } }))}
+ onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
+ style={{ width: 75 }}
+ autoFocus
+ />
+
+
+ Doručení v:
+ setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], deliveryAt: e.target.value } }))}
+ onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
+ style={{ width: 75 }}
+ />
+
+ handleSaveTimes(group)}>Uložit
+ setEditTimes(prev => { const n = { ...prev }; delete n[group.id]; return n; })}>Zrušit
+
+ ) : (
+ isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '' } }))}
+ title={isCreator ? 'Klikněte pro úpravu časů' : undefined}
+ >
+
+ Objednáno v: {group.orderedAt ?? '—'}
+
+
+ Doručení v: {group.deliveryAt ?? '—'}
+
+ {isCreator && (upravit)}
+
+ )}
+
+ )}
);
@@ -322,11 +434,22 @@ export default function OrderGroupsPage() {
- setNoteModal(null)}
- onSave={handleSaveNote}
- />
+ {/* Potvrzovací dialog pro přechod do stavu Objednáno */}
+ setConfirmOrderGroup(null)} centered>
+
+ Potvrdit objednání
+
+
+ Opravdu chcete označit skupinu {confirmOrderGroup?.name} jako objednanou?
+ Tato akce uzavře skupinu a zaznamená čas objednání.
+
+
+ setConfirmOrderGroup(null)}>Zrušit
+ confirmOrderGroup && handleConfirmOrdered(confirmOrderGroup)}>
+ Objednáno
+
+
+
setPayModal(null)}
group={payModal}
+ groupId={payModal.id}
payerLogin={auth.login}
bankAccount={settings.bankAccount}
bankAccountHolder={settings.holderName}
diff --git a/server/src/groups.ts b/server/src/groups.ts
index 5185ce7..94ca5d8 100644
--- a/server/src/groups.ts
+++ b/server/src/groups.ts
@@ -2,6 +2,7 @@ import crypto from "crypto";
import getStorage from "./storage";
import { getClientData, getToday, initIfNeeded } from "./service";
import { getStores } from "./stores";
+import { removePendingQrsByGroupId } from "./pizza";
import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen";
import { formatDate } from "./utils";
@@ -9,7 +10,9 @@ const storage = getStorage();
async function getExtraData(date?: Date): Promise {
await initIfNeeded(date, MealSlot.EXTRA);
- return getClientData(date, MealSlot.EXTRA);
+ const data = await getClientData(date, MealSlot.EXTRA);
+ data.stores = await getStores();
+ return data;
}
function getExtraKey(date?: Date): string {
@@ -31,9 +34,10 @@ export async function createGroup(creatorLogin: string, name: string, date?: Dat
throw new Error('Obchod není v seznamu povolených obchodů');
}
const data = await getExtraData(date);
+ const canonical = stores.find(s => s.toLowerCase() === name.trim().toLowerCase())!;
const group: OrderGroup = {
id: crypto.randomUUID(),
- name: name.trim(),
+ name: canonical,
creatorLogin,
state: GroupState.OPEN,
members: { [creatorLogin]: {} },
@@ -103,9 +107,14 @@ export async function updateGroupMember(login: string, groupId: string, targetLo
const VALID_TRANSITIONS: Record = {
[GroupState.OPEN]: [GroupState.LOCKED],
[GroupState.LOCKED]: [GroupState.OPEN, GroupState.ORDERED],
- [GroupState.ORDERED]: [],
+ [GroupState.ORDERED]: [GroupState.LOCKED],
};
+function getCurrentHHMM(): string {
+ const now = new Date();
+ return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
+}
+
export async function setGroupState(login: string, groupId: string, newState: GroupState, date?: Date): Promise {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
@@ -114,6 +123,25 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
if (!VALID_TRANSITIONS[group.state].includes(newState)) {
throw new Error(`Nelze přejít ze stavu "${group.state}" do stavu "${newState}"`);
}
+ if (newState === GroupState.ORDERED) {
+ group.orderedAt = getCurrentHHMM();
+ }
+ if (group.state === GroupState.ORDERED && newState === GroupState.LOCKED) {
+ const memberLogins = Object.keys(group.members);
+ await removePendingQrsByGroupId(memberLogins, groupId);
+ group.orderedAt = undefined;
+ group.deliveryAt = undefined;
+ }
group.state = newState;
return saveExtraData(data, date);
}
+
+export async function updateGroupTimes(login: string, groupId: string, orderedAt?: string, deliveryAt?: 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('Časy může měnit pouze zakladatel');
+ if (orderedAt !== undefined) group.orderedAt = orderedAt || undefined;
+ if (deliveryAt !== undefined) group.deliveryAt = deliveryAt || undefined;
+ return saveExtraData(data, date);
+}
diff --git a/server/src/index.ts b/server/src/index.ts
index e7dff2b..9cd6007 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -1,7 +1,7 @@
import express from "express";
import bodyParser from "body-parser";
import cors from 'cors';
-import { getData, getDateForWeekIndex, getToday } from "./service";
+import { getData, addChoice, getDateForWeekIndex, getToday } from "./service";
import { MealSlot } from "../../types/gen/types.gen";
import dotenv from 'dotenv';
import path from 'path';
@@ -9,8 +9,8 @@ import { getQr } from "./qr";
import { generateToken, getLogin, verify } from "./auth";
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
import { getPendingQrs } from "./pizza";
-import { initWebsocket } from "./websocket";
-import { startReminderScheduler } from "./pushReminder";
+import { initWebsocket, getWebsocket } from "./websocket";
+import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder";
import { storageReady } from "./storage";
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
@@ -116,6 +116,22 @@ app.get("/api/qr", async (req, res) => {
// Přeskočení auth pro refresh dat xd
app.use("/api/food/refresh", refreshMetoda);
+// Rychlá akce z push notifikace — autentizace pomocí HMAC tokenu z push payloadu (SW nemá přístup k JWT)
+app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
+ try {
+ const { login, token } = req.body ?? {};
+ if (!login || typeof login !== 'string' || !token || typeof token !== 'string') {
+ return res.status(400).json({ error: 'Chybí login nebo token' });
+ }
+ if (!verifyQuickChoiceToken(login, token)) {
+ return res.status(403).json({ error: 'Neplatný token' });
+ }
+ const updatedData = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
+ getWebsocket().emit("message", updatedData);
+ res.status(200).json({});
+ } catch (e: any) { next(e); }
+});
+
/** Middleware ověřující JWT token */
app.use("/api/", (req, res, next) => {
if (HTTP_REMOTE_USER_ENABLED) {
diff --git a/server/src/pizza.ts b/server/src/pizza.ts
index 640c8bf..4a580c2 100644
--- a/server/src/pizza.ts
+++ b/server/src/pizza.ts
@@ -455,4 +455,18 @@ export async function dismissPendingQr(login: string, id: string): Promise
const existing = await storage.getData(key) ?? [];
const filtered = existing.filter(qr => qr.id !== id);
await storage.setData(key, filtered);
+}
+
+/**
+ * Odstraní všechny nevyřízené QR kódy patřící dané skupině objednávky.
+ */
+export async function removePendingQrsByGroupId(logins: string[], groupId: string): Promise {
+ for (const login of logins) {
+ const key = getPendingQrKey(login);
+ const existing = await storage.getData(key) ?? [];
+ const filtered = existing.filter(qr => qr.groupId !== groupId);
+ if (filtered.length !== existing.length) {
+ await storage.setData(key, filtered);
+ }
+ }
}
\ No newline at end of file
diff --git a/server/src/pushReminder.ts b/server/src/pushReminder.ts
index 3e81205..164dd04 100644
--- a/server/src/pushReminder.ts
+++ b/server/src/pushReminder.ts
@@ -1,4 +1,5 @@
import webpush from 'web-push';
+import crypto from 'crypto';
import getStorage from './storage';
import { getClientData, getToday } from './service';
import { getIsWeekend } from './utils';
@@ -65,6 +66,19 @@ export function getVapidPublicKey(): string | undefined {
return process.env.VAPID_PUBLIC_KEY;
}
+function generateQuickChoiceToken(login: string): string {
+ const today = new Date().toISOString().slice(0, 10);
+ const secret = process.env.JWT_SECRET ?? '';
+ return crypto.createHmac('sha256', secret).update(`${login}:${today}`).digest('hex');
+}
+
+/** Ověří jednorázový token z push notifikace. */
+export function verifyQuickChoiceToken(login: string, token: string): boolean {
+ if (!login || !token || token.length !== 64) return false;
+ const expected = generateQuickChoiceToken(login);
+ return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex'));
+}
+
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
async function checkAndSendReminders(): Promise {
@@ -115,6 +129,7 @@ async function checkAndSendReminders(): Promise {
title: 'Luncher',
body: 'Ještě nemáte zvolený oběd!',
login,
+ token: generateQuickChoiceToken(login),
})
);
lastReminded.set(login, Date.now());
diff --git a/server/src/routes/groupRoutes.ts b/server/src/routes/groupRoutes.ts
index 82cb274..4918ac2 100644
--- a/server/src/routes/groupRoutes.ts
+++ b/server/src/routes/groupRoutes.ts
@@ -2,7 +2,7 @@ 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 { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes } from "../groups";
import { GroupState } from "../../../types/gen/types.gen";
const router = express.Router();
@@ -39,6 +39,9 @@ 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' });
+ if (targetLogin !== undefined && (typeof targetLogin !== 'string' || targetLogin.trim() === '')) {
+ return res.status(400).json({ error: 'Neplatný login uživatele' });
+ }
const target = targetLogin ?? login;
try {
const data = await addGroupMember(login, id, target);
@@ -65,10 +68,26 @@ router.post("/updateMember", async (req: Request, res, next) => {
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;
+ if (amount !== undefined) {
+ if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
+ return res.status(400).json({ error: 'Neplatná částka' });
+ }
+ patch.amount = amount;
+ }
+ if (note !== undefined) {
+ if (typeof note !== 'string') return res.status(400).json({ error: 'Neplatná poznámka' });
+ patch.note = note;
+ }
+ if (surchargeText !== undefined) {
+ if (typeof surchargeText !== 'string') return res.status(400).json({ error: 'Neplatný text příplatku' });
+ patch.surchargeText = surchargeText;
+ }
+ if (surchargeAmount !== undefined) {
+ if (typeof surchargeAmount !== 'number' || !Number.isFinite(surchargeAmount) || surchargeAmount < 0) {
+ return res.status(400).json({ error: 'Neplatná výše příplatku' });
+ }
+ patch.surchargeAmount = surchargeAmount;
+ }
try {
const data = await updateGroupMember(login, id, targetLogin, patch);
broadcastExtra(data);
@@ -90,4 +109,22 @@ router.post("/setState", async (req: Request, res, next) => {
} catch (e: any) { next(e); }
});
+router.post("/updateTimes", async (req: Request, res, next) => {
+ const login = getLogin(parseToken(req));
+ const { id, orderedAt, deliveryAt } = req.body ?? {};
+ if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
+ const timeRegex = /^([01]\d|2[0-3]):[0-5]\d$/;
+ if (orderedAt !== undefined && orderedAt !== '' && !timeRegex.test(orderedAt)) {
+ return res.status(400).json({ error: 'Neplatný formát času objednání (očekáváno HH:MM)' });
+ }
+ if (deliveryAt !== undefined && deliveryAt !== '' && !timeRegex.test(deliveryAt)) {
+ return res.status(400).json({ error: 'Neplatný formát času doručení (očekáváno HH:MM)' });
+ }
+ try {
+ const data = await updateGroupTimes(login, id, orderedAt, deliveryAt);
+ broadcastExtra(data);
+ res.status(200).json(data);
+ } catch (e: any) { next(e); }
+});
+
export default router;
diff --git a/server/src/routes/notificationRoutes.ts b/server/src/routes/notificationRoutes.ts
index 0ad0a30..c377829 100644
--- a/server/src/routes/notificationRoutes.ts
+++ b/server/src/routes/notificationRoutes.ts
@@ -3,8 +3,6 @@ import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
import { subscribePush, unsubscribePush, getVapidPublicKey } from "../pushReminder";
-import { addChoice } from "../service";
-import { getWebsocket } from "../websocket";
import { UpdateNotificationSettingsData } from "../../../types";
const router = express.Router();
@@ -66,14 +64,4 @@ router.post("/push/unsubscribe", async (req, res, next) => {
} catch (e: any) { next(e) }
});
-/** Rychlá akce z push notifikace — nastaví volbu NEOBEDVAM pro přihlášeného uživatele. */
-router.post("/push/quickChoice", async (req, res, next) => {
- try {
- const login = getLogin(parseToken(req));
- const data = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
- getWebsocket().emit("message", data);
- res.status(200).json({});
- } catch (e: any) { next(e) }
-});
-
export default router;
diff --git a/server/src/routes/qrRoutes.ts b/server/src/routes/qrRoutes.ts
index e00a69c..684718b 100644
--- a/server/src/routes/qrRoutes.ts
+++ b/server/src/routes/qrRoutes.ts
@@ -14,7 +14,7 @@ const router = express.Router();
router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
try {
- const { recipients, bankAccount, bankAccountHolder } = req.body;
+ const { recipients, bankAccount, bankAccountHolder, groupId } = req.body;
if (!recipients || !Array.isArray(recipients) || recipients.length === 0) {
return res.status(400).json({ error: "Nebyl předán seznam příjemců" });
@@ -55,6 +55,7 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r
creator: login,
totalPrice: recipient.amount,
purpose: recipient.purpose,
+ ...(groupId ? { groupId } : {}),
});
}
diff --git a/types/api.yml b/types/api.yml
index b42998c..1aba664 100644
--- a/types/api.yml
+++ b/types/api.yml
@@ -94,6 +94,8 @@ paths:
$ref: "./paths/groups/updateMember.yml"
/groups/setState:
$ref: "./paths/groups/setState.yml"
+ /groups/updateTimes:
+ $ref: "./paths/groups/updateTimes.yml"
# Správa obchodů (/api/stores)
/stores:
diff --git a/types/paths/groups/updateTimes.yml b/types/paths/groups/updateTimes.yml
new file mode 100644
index 0000000..35a2b27
--- /dev/null
+++ b/types/paths/groups/updateTimes.yml
@@ -0,0 +1,24 @@
+post:
+ operationId: updateGroupTimes
+ summary: Aktualizuje časy objednání a doručení skupiny (pouze zakladatel).
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - id
+ properties:
+ id:
+ description: ID skupiny
+ type: string
+ orderedAt:
+ description: Čas objednání ve formátu HH:MM
+ type: string
+ deliveryAt:
+ description: Očekávaný čas doručení ve formátu HH:MM
+ type: string
+ responses:
+ "200":
+ $ref: "../../api.yml#/components/responses/ClientDataResponse"
diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml
index 5323ffa..95dd880 100644
--- a/types/schemas/_index.yml
+++ b/types/schemas/_index.yml
@@ -658,6 +658,9 @@ GenerateQrRequest:
bankAccountHolder:
description: Jméno držitele bankovního účtu
type: string
+ groupId:
+ description: ID skupiny objednávky (pro propojení QR kódů se skupinou)
+ type: string
# --- DEV MOCK DATA ---
GenerateMockDataRequest:
@@ -740,9 +743,12 @@ OrderGroup:
type: object
additionalProperties:
$ref: "#/OrderGroupMember"
- tipTotal:
- description: Celkové dýško (Kč), vyplněno při přechodu do stavu ordered
- type: number
+ orderedAt:
+ description: Čas objednání ve formátu HH:MM
+ type: string
+ deliveryAt:
+ description: Očekávaný čas doručení ve formátu HH:MM
+ type: string
# --- NEVYŘÍZENÉ QR KÓDY ---
PendingQr:
@@ -770,3 +776,6 @@ PendingQr:
purpose:
description: Účel platby (např. "Pizza prosciutto")
type: string
+ groupId:
+ description: ID skupiny objednávky, ke které QR patří
+ type: string
| |