feat: limity skupinových objednávek nastavitelné přes env
CI / Generate TypeScript types (push) Successful in 12s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 30s
CI / Build client (push) Successful in 39s
CI / Playwright E2E tests (push) Successful in 3m55s
CI / Build and push Docker image (push) Successful in 49s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 12s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 30s
CI / Build client (push) Successful in 39s
CI / Playwright E2E tests (push) Successful in 3m55s
CI / Build and push Docker image (push) Successful in 49s
CI / Notify (push) Successful in 2s
This commit is contained in:
@@ -53,6 +53,12 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jídlo na limitu kusů — nejde přidat
|
||||||
|
.select-search-option:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.select-search-group-header {
|
.select-search-group-header {
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useMemo } from "react";
|
|||||||
import { OverlayTrigger, Tooltip } from "react-bootstrap";
|
import { OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||||
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from "react-select-search";
|
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from "react-select-search";
|
||||||
import { StoreMenuDish } from "../../../types";
|
import { StoreMenuDish } from "../../../types";
|
||||||
import { matchNoteToMenu, splitNoteItems } from "../utils/menuMatch";
|
import { countNoteItems, matchNoteToMenu, noteItemQuantity, splitNoteItems } from "../utils/menuMatch";
|
||||||
|
import { useGroupLimits } from "../hooks/useGroupLimits";
|
||||||
import "./MenuHintCell.scss";
|
import "./MenuHintCell.scss";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -25,16 +26,20 @@ const formatDish = (d: StoreMenuDish) => d.price != null ? `${d.name} (${d.price
|
|||||||
* (doplní poznámku i částku) a kontrola ceny rozpoznané z poznámky proti zadané částce.
|
* (doplní poznámku i částku) a kontrola ceny rozpoznané z poznámky proti zadané částce.
|
||||||
*/
|
*/
|
||||||
export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUsePrice, onRemoveItem }: Readonly<Props>) {
|
export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUsePrice, onRemoveItem }: Readonly<Props>) {
|
||||||
|
const { maxItemQuantity, maxItems } = useGroupLimits();
|
||||||
const options = useMemo<SelectSearchOption[]>(() => {
|
const options = useMemo<SelectSearchOption[]>(() => {
|
||||||
if (!menu?.length) return [];
|
if (!menu?.length) return [];
|
||||||
const groups = new Map<string, SelectSearchOption[]>();
|
const groups = new Map<string, SelectSearchOption[]>();
|
||||||
menu.forEach((d, index) => {
|
menu.forEach((d, index) => {
|
||||||
const category = d.category || 'Ostatní';
|
const category = d.category || 'Ostatní';
|
||||||
if (!groups.has(category)) groups.set(category, []);
|
if (!groups.has(category)) groups.set(category, []);
|
||||||
groups.get(category)!.push({ name: formatDish(d), value: String(index) });
|
// Jídlo, kterého už je v objednávce maximum kusů, nejde přidat znovu
|
||||||
|
const atLimit = noteItemQuantity(note, d.name, menu) >= maxItemQuantity;
|
||||||
|
groups.get(category)!.push({ name: atLimit ? `${formatDish(d)} — max ${maxItemQuantity} ks` : formatDish(d), value: String(index), disabled: atLimit });
|
||||||
});
|
});
|
||||||
return [...groups.entries()].map(([name, items]) => ({ name, type: 'group', items }));
|
return [...groups.entries()].map(([name, items]) => ({ name, type: 'group', items }));
|
||||||
}, [menu]);
|
}, [menu, note, maxItemQuantity]);
|
||||||
|
const itemLimitReached = useMemo(() => countNoteItems(note, menu ?? []) >= maxItems, [note, menu, maxItems]);
|
||||||
|
|
||||||
const match = useMemo(() => menu ? matchNoteToMenu(note, menu) : undefined, [menu, note]);
|
const match = useMemo(() => menu ? matchNoteToMenu(note, menu) : undefined, [menu, note]);
|
||||||
const items = useMemo(() => splitNoteItems(note, menu), [note, menu]);
|
const items = useMemo(() => splitNoteItems(note, menu), [note, menu]);
|
||||||
@@ -49,7 +54,10 @@ export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUs
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="menu-hint-cell">
|
<div className="menu-hint-cell">
|
||||||
{canEdit && (
|
{canEdit && itemLimitReached && (
|
||||||
|
<small className="text-muted">Dosažen limit {maxItems} položek — nejdřív něco odeber (×).</small>
|
||||||
|
)}
|
||||||
|
{canEdit && !itemLimitReached && (
|
||||||
<SelectSearch
|
<SelectSearch
|
||||||
search
|
search
|
||||||
options={options}
|
options={options}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { getGroupLimits, subscribeGroupLimits } from "../utils/appConfig";
|
||||||
|
|
||||||
|
/** Aktuální limity skupinových objednávek (překreslí se po načtení konfigurace ze serveru). */
|
||||||
|
export function useGroupLimits() {
|
||||||
|
return useSyncExternalStore(subscribeGroupLimits, getGroupLimits);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { getConfig } from '../../types/gen/sdk.gen';
|
|||||||
import { getToken } from './Utils';
|
import { getToken } from './Utils';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import * as Sentry from '@sentry/react';
|
import * as Sentry from '@sentry/react';
|
||||||
|
import { setGroupLimits } from './utils/appConfig';
|
||||||
|
|
||||||
client.setConfig({
|
client.setConfig({
|
||||||
auth: () => getToken(),
|
auth: () => getToken(),
|
||||||
@@ -17,8 +18,9 @@ client.setConfig({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Sentry se inicializuje až podle runtime konfigurace ze serveru (bez DSN zůstává vypnuté).
|
// Sentry se inicializuje až podle runtime konfigurace ze serveru (bez DSN zůstává vypnuté).
|
||||||
// Klient je statický build, takže DSN nejde zapéct při buildu — server ho zná z env.
|
// Klient je statický build, takže DSN ani limity nejde zapéct při buildu — server je zná z env.
|
||||||
getConfig().then(({ data }) => {
|
getConfig().then(({ data }) => {
|
||||||
|
setGroupLimits(data?.limits?.group);
|
||||||
if (data?.sentry.dsn) {
|
if (data?.sentry.dsn) {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: data.sentry.dsn,
|
dsn: data.sentry.dsn,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
import MenuHintCell from '../components/MenuHintCell';
|
import MenuHintCell from '../components/MenuHintCell';
|
||||||
import EaterBadge from '../components/EaterBadge';
|
import EaterBadge from '../components/EaterBadge';
|
||||||
import { addNoteItem, countNoteItems, removeNoteItem } from '../utils/menuMatch';
|
import { addItemLimitError, addNoteItem, countNoteItems, removeNoteItem } from '../utils/menuMatch';
|
||||||
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
||||||
import { getStoreUrls, isHttpUrl, storeUrlLabel } from '../utils/storeUrls';
|
import { getStoreUrls, isHttpUrl, storeUrlLabel } from '../utils/storeUrls';
|
||||||
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
||||||
@@ -333,7 +333,12 @@ export default function OrderGroupsPage() {
|
|||||||
Object.entries(storeMenus).find(([name]) => name.toLowerCase() === group.name.toLowerCase())?.[1] ?? undefined;
|
Object.entries(storeMenus).find(([name]) => name.toLowerCase() === group.name.toLowerCase())?.[1] ?? undefined;
|
||||||
|
|
||||||
/** Výběr položky z nabídky — připojí ji k poznámce (opakovaný výběr zvýší počet „2x") a cenu přičte k částce. */
|
/** Výběr položky z nabídky — připojí ji k poznámce (opakovaný výběr zvýší počet „2x") a cenu přičte k částce. */
|
||||||
const handlePickDish = (group: OrderGroup, login: string, member: OrderGroupMember, dish: StoreMenuDish) => {
|
const handlePickDish = async (group: OrderGroup, login: string, member: OrderGroupMember, dish: StoreMenuDish) => {
|
||||||
|
const limitError = addItemLimitError(member.note, dish.name, getGroupMenu(group) ?? []);
|
||||||
|
if (limitError) {
|
||||||
|
setPageError(limitError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const note = addNoteItem(member.note, dish.name, getGroupMenu(group) ?? []);
|
const note = addNoteItem(member.note, dish.name, getGroupMenu(group) ?? []);
|
||||||
const amount = dish.price != null ? (member.amount ?? 0) + Math.round(dish.price * 100) : member.amount;
|
const amount = dish.price != null ? (member.amount ?? 0) + Math.round(dish.price * 100) : member.amount;
|
||||||
return refresh(() => updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } }));
|
return refresh(() => updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } }));
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { GroupLimits } from "../../../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime konfigurace ze serveru (GET /api/config). Do načtení (nebo když endpoint
|
||||||
|
* neodpoví) platí výchozí hodnoty — stejné jako výchozí hodnoty na serveru (server/src/limits.ts).
|
||||||
|
*/
|
||||||
|
export const DEFAULT_GROUP_LIMITS: GroupLimits = {
|
||||||
|
maxNoteLength: 300,
|
||||||
|
maxSurchargeTextLength: 100,
|
||||||
|
maxNameLength: 100,
|
||||||
|
maxUrlLength: 500,
|
||||||
|
maxAmountKc: 10_000,
|
||||||
|
maxItemQuantity: 10,
|
||||||
|
maxItems: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
let groupLimits: GroupLimits = DEFAULT_GROUP_LIMITS;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function getGroupLimits(): GroupLimits {
|
||||||
|
return groupLimits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nastaví limity ze serveru (chybějící hodnoty doplní výchozími) a upozorní odběratele. */
|
||||||
|
export function setGroupLimits(limits: Partial<GroupLimits> | undefined): void {
|
||||||
|
if (!limits) return;
|
||||||
|
groupLimits = { ...DEFAULT_GROUP_LIMITS, ...limits };
|
||||||
|
listeners.forEach(l => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeGroupLimits(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { StoreMenuDish } from "../../../types";
|
import type { GroupLimits, StoreMenuDish } from "../../../types";
|
||||||
|
import { getGroupLimits } from "./appConfig";
|
||||||
|
|
||||||
/** Normalizace pro porovnání: malá písmena, bez diakritiky, emoji, čísel položek („28." / „13A.") a interpunkce. */
|
/** Normalizace pro porovnání: malá písmena, bez diakritiky, emoji, čísel položek („28." / „13A.") a interpunkce. */
|
||||||
export function normalizeDishName(text: string): string {
|
export function normalizeDishName(text: string): string {
|
||||||
@@ -121,3 +122,26 @@ export function addNoteItem(note: string | undefined, dishName: string, menu: St
|
|||||||
parts[index] = `${existing.quantity + 1}x ${existing.rest.trim()}`;
|
parts[index] = `${existing.quantity + 1}x ${existing.rest.trim()}`;
|
||||||
return parts.join(' + ');
|
return parts.join(' + ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Kolik kusů daného jídla už poznámka obsahuje (0 = není tam). */
|
||||||
|
export function noteItemQuantity(note: string | undefined, dishName: string, menu: StoreMenuDish[] = []): number {
|
||||||
|
const target = normalizeDishName(dishName);
|
||||||
|
const part = splitNoteItems(note, menu).find(p => normalizeDishName(p) === target || normalizeDishName(parseQuantity(p).rest) === target);
|
||||||
|
if (!part) return 0;
|
||||||
|
return normalizeDishName(part) === target ? 1 : parseQuantity(part).quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí důvod, proč jídlo nejde přidat (limit kusů/položek — pojistky proti překlepům,
|
||||||
|
* hodnoty z konfigurace serveru), jinak undefined.
|
||||||
|
*/
|
||||||
|
export function addItemLimitError(note: string | undefined, dishName: string, menu: StoreMenuDish[] = [], limits: GroupLimits = getGroupLimits()): string | undefined {
|
||||||
|
if (noteItemQuantity(note, dishName, menu) >= limits.maxItemQuantity) {
|
||||||
|
return `Jedné položky lze přidat nejvýše ${limits.maxItemQuantity} kusů`;
|
||||||
|
}
|
||||||
|
if (countNoteItems(note, menu) >= limits.maxItems) {
|
||||||
|
return `Objednávka může mít nejvýše ${limits.maxItems} položek`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,3 +64,19 @@
|
|||||||
# SENTRY_CLIENT_DSN=https://...@sentry.example.com/2
|
# SENTRY_CLIENT_DSN=https://...@sentry.example.com/2
|
||||||
# Interní logy Sentry SDK do konzole — pro ladění, když eventy nedorazí do Sentry.
|
# Interní logy Sentry SDK do konzole — pro ladění, když eventy nedorazí do Sentry.
|
||||||
# SENTRY_DEBUG=true
|
# SENTRY_DEBUG=true
|
||||||
|
|
||||||
|
# Limity skupinových objednávek (pojistky proti překlepům, ne ochrana proti zlému úmyslu).
|
||||||
|
# Nevyplněné nebo neplatné hodnoty (ne celé kladné číslo) = výchozí hodnota. Klient je dostává přes GET /api/config.
|
||||||
|
# Max. délka poznámky člena (co si objednává), výchozí 300 znaků.
|
||||||
|
# GROUP_MAX_NOTE_LENGTH=300
|
||||||
|
# Max. délka popisu příplatku, výchozí 100 znaků.
|
||||||
|
# GROUP_MAX_SURCHARGE_TEXT_LENGTH=100
|
||||||
|
# Max. délka názvu skupiny, výchozí 100 znaků.
|
||||||
|
# GROUP_MAX_NAME_LENGTH=100
|
||||||
|
# Max. délka URL nabídky / odkazu na sledování, výchozí 500 znaků.
|
||||||
|
# GROUP_MAX_URL_LENGTH=500
|
||||||
|
# Max. výše jedné částky (útrata člena, příplatek, poplatky, doprava, spropitné, pevná sleva) v Kč, výchozí 10000.
|
||||||
|
# GROUP_MAX_AMOUNT_KC=10000
|
||||||
|
# Výběr z nabídky: max. kusů jedné položky (výchozí 10) a max. položek celkem (výchozí 20).
|
||||||
|
# GROUP_MAX_ITEM_QUANTITY=10
|
||||||
|
# GROUP_MAX_ITEMS=20
|
||||||
|
|||||||
@@ -240,6 +240,10 @@ export async function updateGroupFees(login: string, groupId: string, fees?: num
|
|||||||
if (tip !== undefined) group.tip = tip > 0 ? tip : undefined;
|
if (tip !== undefined) group.tip = tip > 0 ? tip : undefined;
|
||||||
if (discountType !== undefined) group.discountType = (discountType as any) || undefined;
|
if (discountType !== undefined) group.discountType = (discountType as any) || undefined;
|
||||||
if (discountValue !== undefined) group.discountValue = discountValue > 0 ? discountValue : undefined;
|
if (discountValue !== undefined) group.discountValue = discountValue > 0 ? discountValue : undefined;
|
||||||
|
// Pojistka i pro změnu jen hodnoty při již nastavené procentní slevě — nad 100 % by vyšly záporné částky
|
||||||
|
if (group.discountType === 'percent' && (group.discountValue ?? 0) > 100) {
|
||||||
|
throw new Error('Procentní sleva může být nejvýše 100 %');
|
||||||
|
}
|
||||||
return saveExtraData(data, date);
|
return saveExtraData(data, date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import groupRoutes from "./routes/groupRoutes";
|
|||||||
import storeRoutes from "./routes/storeRoutes";
|
import storeRoutes from "./routes/storeRoutes";
|
||||||
import butterflyRoutes from "./routes/butterflyRoutes";
|
import butterflyRoutes from "./routes/butterflyRoutes";
|
||||||
import { getConfettiCharacters, getConfettiImage } from "./confetti";
|
import { getConfettiCharacters, getConfettiImage } from "./confetti";
|
||||||
|
import { getGroupLimits } from "./limits";
|
||||||
|
|
||||||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||||
@@ -136,6 +137,9 @@ app.get("/api/config", (_req, res) => {
|
|||||||
dsn: process.env.SENTRY_CLIENT_DSN ?? process.env.SENTRY_DSN ?? null,
|
dsn: process.env.SENTRY_CLIENT_DSN ?? process.env.SENTRY_DSN ?? null,
|
||||||
environment: ENVIRONMENT,
|
environment: ENVIRONMENT,
|
||||||
},
|
},
|
||||||
|
limits: {
|
||||||
|
group: getGroupLimits(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { GroupLimits } from "../../types/gen/types.gen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Výchozí limity skupinových objednávek — pojistky proti překlepům (dvojklik, omylem vložený text),
|
||||||
|
* ne ochrana proti zlému úmyslu. Lze je přepsat proměnnými prostředí (viz .env.template).
|
||||||
|
*/
|
||||||
|
export const DEFAULT_GROUP_LIMITS: GroupLimits = {
|
||||||
|
maxNoteLength: 300,
|
||||||
|
maxSurchargeTextLength: 100,
|
||||||
|
maxNameLength: 100,
|
||||||
|
maxUrlLength: 500,
|
||||||
|
maxAmountKc: 10_000,
|
||||||
|
maxItemQuantity: 10,
|
||||||
|
maxItems: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Proměnná prostředí a rozumný strop pro každý limit. */
|
||||||
|
const ENV: Record<keyof GroupLimits, { name: string, max: number }> = {
|
||||||
|
maxNoteLength: { name: 'GROUP_MAX_NOTE_LENGTH', max: 5_000 },
|
||||||
|
maxSurchargeTextLength: { name: 'GROUP_MAX_SURCHARGE_TEXT_LENGTH', max: 1_000 },
|
||||||
|
maxNameLength: { name: 'GROUP_MAX_NAME_LENGTH', max: 1_000 },
|
||||||
|
maxUrlLength: { name: 'GROUP_MAX_URL_LENGTH', max: 5_000 },
|
||||||
|
maxAmountKc: { name: 'GROUP_MAX_AMOUNT_KC', max: 1_000_000 },
|
||||||
|
maxItemQuantity: { name: 'GROUP_MAX_ITEM_QUANTITY', max: 1_000 },
|
||||||
|
maxItems: { name: 'GROUP_MAX_ITEMS', max: 1_000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
let cached: GroupLimits | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí limity skupinových objednávek (výchozí hodnoty přepsané z env). Čte se líně při prvním
|
||||||
|
* použití — moduly s routami se importují dřív, než dotenv načte .env soubor.
|
||||||
|
* Neplatná hodnota (ne celé kladné číslo, nad stropem) se ignoruje s varováním.
|
||||||
|
*/
|
||||||
|
export function getGroupLimits(): GroupLimits {
|
||||||
|
if (cached) return cached;
|
||||||
|
const limits = { ...DEFAULT_GROUP_LIMITS };
|
||||||
|
for (const key of Object.keys(ENV) as (keyof GroupLimits)[]) {
|
||||||
|
const { name, max } = ENV[key];
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (raw === undefined || raw.trim() === '') continue;
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isInteger(value) || value < 1 || value > max) {
|
||||||
|
console.warn(`Limity: neplatná hodnota ${name}="${raw}" (povoleno 1–${max}), používám výchozí ${limits[key]}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
limits[key] = value;
|
||||||
|
}
|
||||||
|
cached = limits;
|
||||||
|
return limits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pro testy — znovu načte limity z env při dalším volání. */
|
||||||
|
export function resetGroupLimits(): void {
|
||||||
|
cached = undefined;
|
||||||
|
}
|
||||||
@@ -5,9 +5,20 @@ import { getWebsocket } from "../websocket";
|
|||||||
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupTracking, getOrderDates } from "../groups";
|
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupTracking, getOrderDates } from "../groups";
|
||||||
import { GroupState } from "../../../types/gen/types.gen";
|
import { GroupState } from "../../../types/gen/types.gen";
|
||||||
import { checkOrderTracking } from "../orderTracking";
|
import { checkOrderTracking } from "../orderTracking";
|
||||||
|
import { getGroupLimits } from "../limits";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Pojistky proti překlepům (dvojklik, omylem vložený text) — limity viz limits.ts (lze přepsat env)
|
||||||
|
|
||||||
|
/** Vrátí chybovou hlášku, pokud částka (v haléřích) není celé číslo v rozsahu 0 až limit. */
|
||||||
|
function invalidAmount(value: unknown, label: string): string | undefined {
|
||||||
|
const { maxAmountKc } = getGroupLimits();
|
||||||
|
if (!Number.isInteger(value) || (value as number) < 0) return `Neplatná výše: ${label}`;
|
||||||
|
if ((value as number) > maxAmountKc * 100) return `${label} může být nejvýše ${maxAmountKc} Kč`;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function broadcastExtra(data: any) {
|
function broadcastExtra(data: any) {
|
||||||
getWebsocket().emit("message", data);
|
getWebsocket().emit("message", data);
|
||||||
}
|
}
|
||||||
@@ -25,7 +36,11 @@ router.post("/create", async (req: Request, res, next) => {
|
|||||||
if (!name || typeof name !== 'string') {
|
if (!name || typeof name !== 'string') {
|
||||||
return res.status(400).json({ error: 'Nebyl předán název skupiny' });
|
return res.status(400).json({ error: 'Nebyl předán název skupiny' });
|
||||||
}
|
}
|
||||||
if (url != null && typeof url !== 'string') {
|
const limits = getGroupLimits();
|
||||||
|
if (name.length > limits.maxNameLength) {
|
||||||
|
return res.status(400).json({ error: `Název skupiny může mít nejvýše ${limits.maxNameLength} znaků` });
|
||||||
|
}
|
||||||
|
if (url != null && (typeof url !== 'string' || url.length > limits.maxUrlLength)) {
|
||||||
return res.status(400).json({ error: 'Neplatná URL nabídky' });
|
return res.status(400).json({ error: 'Neplatná URL nabídky' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -80,23 +95,29 @@ router.post("/updateMember", async (req: Request, res, next) => {
|
|||||||
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
|
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
|
||||||
const patch: Record<string, any> = {};
|
const patch: Record<string, any> = {};
|
||||||
if (amount !== undefined) {
|
if (amount !== undefined) {
|
||||||
if (!Number.isInteger(amount) || amount < 0) {
|
const error = invalidAmount(amount, 'Částka');
|
||||||
return res.status(400).json({ error: 'Neplatná částka' });
|
if (error) return res.status(400).json({ error });
|
||||||
}
|
|
||||||
patch.amount = amount;
|
patch.amount = amount;
|
||||||
}
|
}
|
||||||
if (note !== undefined) {
|
if (note !== undefined) {
|
||||||
if (typeof note !== 'string') return res.status(400).json({ error: 'Neplatná poznámka' });
|
if (typeof note !== 'string') return res.status(400).json({ error: 'Neplatná poznámka' });
|
||||||
|
const { maxNoteLength } = getGroupLimits();
|
||||||
|
if (note.length > maxNoteLength) {
|
||||||
|
return res.status(400).json({ error: `Poznámka může mít nejvýše ${maxNoteLength} znaků` });
|
||||||
|
}
|
||||||
patch.note = note;
|
patch.note = note;
|
||||||
}
|
}
|
||||||
if (surchargeText !== undefined) {
|
if (surchargeText !== undefined) {
|
||||||
if (typeof surchargeText !== 'string') return res.status(400).json({ error: 'Neplatný text příplatku' });
|
if (typeof surchargeText !== 'string') return res.status(400).json({ error: 'Neplatný text příplatku' });
|
||||||
|
const { maxSurchargeTextLength } = getGroupLimits();
|
||||||
|
if (surchargeText.length > maxSurchargeTextLength) {
|
||||||
|
return res.status(400).json({ error: `Popis příplatku může mít nejvýše ${maxSurchargeTextLength} znaků` });
|
||||||
|
}
|
||||||
patch.surchargeText = surchargeText;
|
patch.surchargeText = surchargeText;
|
||||||
}
|
}
|
||||||
if (surchargeAmount !== undefined) {
|
if (surchargeAmount !== undefined) {
|
||||||
if (!Number.isInteger(surchargeAmount) || surchargeAmount < 0) {
|
const error = invalidAmount(surchargeAmount, 'Příplatek');
|
||||||
return res.status(400).json({ error: 'Neplatná výše příplatku' });
|
if (error) return res.status(400).json({ error });
|
||||||
}
|
|
||||||
patch.surchargeAmount = surchargeAmount;
|
patch.surchargeAmount = surchargeAmount;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -124,21 +145,26 @@ router.post("/updateFees", async (req: Request, res, next) => {
|
|||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {};
|
const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {};
|
||||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
if (fees !== undefined && (!Number.isInteger(fees) || fees < 0)) {
|
for (const [value, label] of [[fees, 'Poplatky'], [shipping, 'Doprava'], [tip, 'Spropitné']] as const) {
|
||||||
return res.status(400).json({ error: 'Neplatná výše poplatků' });
|
const error = value !== undefined ? invalidAmount(value, label) : undefined;
|
||||||
}
|
if (error) return res.status(400).json({ error });
|
||||||
if (shipping !== undefined && (!Number.isInteger(shipping) || shipping < 0)) {
|
|
||||||
return res.status(400).json({ error: 'Neplatná výše dopravy' });
|
|
||||||
}
|
|
||||||
if (tip !== undefined && (!Number.isInteger(tip) || tip < 0)) {
|
|
||||||
return res.status(400).json({ error: 'Neplatná výše spropitného' });
|
|
||||||
}
|
}
|
||||||
if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) {
|
if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) {
|
||||||
return res.status(400).json({ error: 'Neplatný typ slevy' });
|
return res.status(400).json({ error: 'Neplatný typ slevy' });
|
||||||
}
|
}
|
||||||
if (discountValue !== undefined && (!Number.isInteger(discountValue) || discountValue < 0)) {
|
if (discountValue !== undefined) {
|
||||||
|
if (!Number.isInteger(discountValue) || discountValue < 0) {
|
||||||
return res.status(400).json({ error: 'Neplatná výše slevy' });
|
return res.status(400).json({ error: 'Neplatná výše slevy' });
|
||||||
}
|
}
|
||||||
|
// Procentní sleva nad 100 % by vedla k záporným částkám
|
||||||
|
if (discountType === 'percent' && discountValue > 100) {
|
||||||
|
return res.status(400).json({ error: 'Procentní sleva může být nejvýše 100 %' });
|
||||||
|
}
|
||||||
|
const { maxAmountKc } = getGroupLimits();
|
||||||
|
if (discountType !== 'percent' && discountValue > maxAmountKc * 100) {
|
||||||
|
return res.status(400).json({ error: `Sleva může být nejvýše ${maxAmountKc} Kč` });
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await updateGroupFees(login, id, fees, shipping, tip, discountType, discountValue);
|
const data = await updateGroupFees(login, id, fees, shipping, tip, discountType, discountValue);
|
||||||
broadcastExtra(data);
|
broadcastExtra(data);
|
||||||
@@ -168,7 +194,7 @@ router.post("/setTracking", async (req: Request, res, next) => {
|
|||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const { id, shareUrl } = req.body ?? {};
|
const { id, shareUrl } = req.body ?? {};
|
||||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
if (shareUrl !== undefined && typeof shareUrl !== 'string') {
|
if (shareUrl !== undefined && (typeof shareUrl !== 'string' || shareUrl.length > getGroupLimits().maxUrlLength)) {
|
||||||
return res.status(400).json({ error: 'Neplatný odkaz na sledování objednávky' });
|
return res.status(400).json({ error: 'Neplatný odkaz na sledování objednávky' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import bodyParser from 'body-parser';
|
||||||
|
import { generateToken } from '../auth';
|
||||||
|
import { resetMemoryStorage } from '../storage/memory';
|
||||||
|
import getStorage from '../storage';
|
||||||
|
import groupRouter from '../routes/groupRoutes';
|
||||||
|
import { DEFAULT_GROUP_LIMITS, getGroupLimits, resetGroupLimits } from '../limits';
|
||||||
|
|
||||||
|
const MAX_NOTE_LENGTH = DEFAULT_GROUP_LIMITS.maxNoteLength;
|
||||||
|
const MAX_SURCHARGE_TEXT_LENGTH = DEFAULT_GROUP_LIMITS.maxSurchargeTextLength;
|
||||||
|
/** Maximum částky v haléřích */
|
||||||
|
const MAX_AMOUNT = DEFAULT_GROUP_LIMITS.maxAmountKc * 100;
|
||||||
|
|
||||||
|
jest.mock('../websocket', () => ({ getWebsocket: () => ({ emit: jest.fn() }) }));
|
||||||
|
|
||||||
|
const CREATOR = 'kreator';
|
||||||
|
const TOKEN = `Bearer ${generateToken(CREATOR)}`;
|
||||||
|
|
||||||
|
function buildApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
app.use('/api/groups', groupRouter);
|
||||||
|
app.use((err: any, _req: any, res: any, _next: any) => {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Založí skupinu a vrátí její ID. */
|
||||||
|
async function createTestGroup(app: express.Express): Promise<string> {
|
||||||
|
const res = await request(app).post('/api/groups/create').set('Authorization', TOKEN).send({ name: 'Bistro' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
return res.body.groups[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetMemoryStorage();
|
||||||
|
resetGroupLimits();
|
||||||
|
await getStorage().setData('stores', [{ name: 'Bistro' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('limity vstupů skupinových objednávek', () => {
|
||||||
|
test('poznámka a popis příplatku mají maximální délku', async () => {
|
||||||
|
const app = buildApp();
|
||||||
|
const id = await createTestGroup(app);
|
||||||
|
const post = (body: object) => request(app).post('/api/groups/updateMember').set('Authorization', TOKEN).send({ id, login: CREATOR, ...body });
|
||||||
|
|
||||||
|
expect((await post({ note: 'x'.repeat(MAX_NOTE_LENGTH) })).status).toBe(200);
|
||||||
|
const tooLong = await post({ note: 'x'.repeat(MAX_NOTE_LENGTH + 1) });
|
||||||
|
expect(tooLong.status).toBe(400);
|
||||||
|
expect(tooLong.body.error).toContain(`${MAX_NOTE_LENGTH} znaků`);
|
||||||
|
expect((await post({ surchargeText: 'x'.repeat(MAX_SURCHARGE_TEXT_LENGTH + 1) })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('částka a příplatek mají maximální výši', async () => {
|
||||||
|
const app = buildApp();
|
||||||
|
const id = await createTestGroup(app);
|
||||||
|
const post = (body: object) => request(app).post('/api/groups/updateMember').set('Authorization', TOKEN).send({ id, login: CREATOR, ...body });
|
||||||
|
|
||||||
|
expect((await post({ amount: MAX_AMOUNT })).status).toBe(200);
|
||||||
|
const tooMuch = await post({ amount: MAX_AMOUNT + 1 });
|
||||||
|
expect(tooMuch.status).toBe(400);
|
||||||
|
expect(tooMuch.body.error).toContain('10000 Kč');
|
||||||
|
expect((await post({ surchargeAmount: MAX_AMOUNT + 1 })).status).toBe(400);
|
||||||
|
expect((await post({ amount: -1 })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('poplatky mají maximum a procentní sleva nesmí přesáhnout 100 %', async () => {
|
||||||
|
const app = buildApp();
|
||||||
|
const id = await createTestGroup(app);
|
||||||
|
const post = (body: object) => request(app).post('/api/groups/updateFees').set('Authorization', TOKEN).send({ id, ...body });
|
||||||
|
|
||||||
|
expect((await post({ shipping: MAX_AMOUNT + 1 })).status).toBe(400);
|
||||||
|
expect((await post({ discountType: 'percent', discountValue: 101 })).status).toBe(400);
|
||||||
|
expect((await post({ discountType: 'percent', discountValue: 100 })).status).toBe(200);
|
||||||
|
// Jen hodnota při již nastavené procentní slevě — pojistka v groups.ts
|
||||||
|
const onlyValue = await post({ discountValue: 150 });
|
||||||
|
expect(onlyValue.status).toBe(500);
|
||||||
|
expect(onlyValue.body.error).toContain('100 %');
|
||||||
|
expect((await post({ discountType: 'fixed', discountValue: MAX_AMOUNT + 1 })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('název skupiny a odkaz na sledování mají maximální délku', async () => {
|
||||||
|
const app = buildApp();
|
||||||
|
expect((await request(app).post('/api/groups/create').set('Authorization', TOKEN).send({ name: 'x'.repeat(101) })).status).toBe(400);
|
||||||
|
const id = await createTestGroup(app);
|
||||||
|
const res = await request(app).post('/api/groups/setTracking').set('Authorization', TOKEN).send({ id, shareUrl: 'https://x.cz/' + 'a'.repeat(600) });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('limity z proměnných prostředí', () => {
|
||||||
|
const ENV_KEYS = ['GROUP_MAX_NOTE_LENGTH', 'GROUP_MAX_AMOUNT_KC', 'GROUP_MAX_ITEMS'];
|
||||||
|
afterEach(() => {
|
||||||
|
ENV_KEYS.forEach(k => delete process.env[k]);
|
||||||
|
resetGroupLimits();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('env přepíše výchozí hodnoty a limity se uplatní v API', async () => {
|
||||||
|
process.env.GROUP_MAX_NOTE_LENGTH = '20';
|
||||||
|
process.env.GROUP_MAX_AMOUNT_KC = '500';
|
||||||
|
process.env.GROUP_MAX_ITEMS = '5';
|
||||||
|
resetGroupLimits();
|
||||||
|
expect(getGroupLimits()).toEqual({ ...DEFAULT_GROUP_LIMITS, maxNoteLength: 20, maxAmountKc: 500, maxItems: 5 });
|
||||||
|
|
||||||
|
const app = buildApp();
|
||||||
|
const id = await createTestGroup(app);
|
||||||
|
const post = (body: object) => request(app).post('/api/groups/updateMember').set('Authorization', TOKEN).send({ id, login: CREATOR, ...body });
|
||||||
|
expect((await post({ note: 'x'.repeat(21) })).body.error).toContain('20 znaků');
|
||||||
|
expect((await post({ amount: 50001 })).body.error).toContain('500 Kč');
|
||||||
|
expect((await post({ amount: 50000 })).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neplatná hodnota v env se ignoruje (zůstane výchozí)', () => {
|
||||||
|
const warn = jest.spyOn(console, 'warn').mockImplementation(() => { });
|
||||||
|
process.env.GROUP_MAX_NOTE_LENGTH = 'hodně';
|
||||||
|
process.env.GROUP_MAX_AMOUNT_KC = '-5';
|
||||||
|
process.env.GROUP_MAX_ITEMS = '999999';
|
||||||
|
resetGroupLimits();
|
||||||
|
expect(getGroupLimits()).toEqual(DEFAULT_GROUP_LIMITS);
|
||||||
|
expect(warn).toHaveBeenCalledTimes(3);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
get:
|
get:
|
||||||
operationId: getConfig
|
operationId: getConfig
|
||||||
summary: Vrátí veřejnou runtime konfiguraci pro klienta (např. Sentry DSN). Nevyžaduje autentizaci.
|
summary: Vrátí veřejnou runtime konfiguraci pro klienta (např. Sentry DSN, limity objednávek). Nevyžaduje autentizaci.
|
||||||
security: [] # Nevyžaduje autentizaci
|
security: [] # Nevyžaduje autentizaci
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -9,8 +9,14 @@ get:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [sentry]
|
required: [sentry, limits]
|
||||||
properties:
|
properties:
|
||||||
|
limits:
|
||||||
|
type: object
|
||||||
|
required: [group]
|
||||||
|
properties:
|
||||||
|
group:
|
||||||
|
$ref: "../../schemas/_index.yml#/GroupLimits"
|
||||||
sentry:
|
sentry:
|
||||||
type: object
|
type: object
|
||||||
required: [dsn, environment]
|
required: [dsn, environment]
|
||||||
|
|||||||
@@ -947,6 +947,41 @@ StoreMenuSource:
|
|||||||
- HISTORY
|
- HISTORY
|
||||||
- NONE
|
- NONE
|
||||||
|
|
||||||
|
GroupLimits:
|
||||||
|
description: Limity skupinových objednávek (pojistky proti překlepům). Výchozí hodnoty lze přepsat env proměnnými GROUP_MAX_*.
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- maxNoteLength
|
||||||
|
- maxSurchargeTextLength
|
||||||
|
- maxNameLength
|
||||||
|
- maxUrlLength
|
||||||
|
- maxAmountKc
|
||||||
|
- maxItemQuantity
|
||||||
|
- maxItems
|
||||||
|
properties:
|
||||||
|
maxNoteLength:
|
||||||
|
description: Max. délka poznámky člena (znaky)
|
||||||
|
type: integer
|
||||||
|
maxSurchargeTextLength:
|
||||||
|
description: Max. délka popisu příplatku (znaky)
|
||||||
|
type: integer
|
||||||
|
maxNameLength:
|
||||||
|
description: Max. délka názvu skupiny (znaky)
|
||||||
|
type: integer
|
||||||
|
maxUrlLength:
|
||||||
|
description: Max. délka URL nabídky / sledování (znaky)
|
||||||
|
type: integer
|
||||||
|
maxAmountKc:
|
||||||
|
description: Max. výše jedné částky (útrata, příplatek, poplatky, pevná sleva) v Kč
|
||||||
|
type: integer
|
||||||
|
maxItemQuantity:
|
||||||
|
description: Max. počet kusů jedné položky při výběru z nabídky
|
||||||
|
type: integer
|
||||||
|
maxItems:
|
||||||
|
description: Max. celkový počet položek objednávky při výběru z nabídky
|
||||||
|
type: integer
|
||||||
|
|
||||||
StoreMenuProvider:
|
StoreMenuProvider:
|
||||||
description: Dovozová služba, ze které lze stáhnout nabídku podniku
|
description: Dovozová služba, ze které lze stáhnout nabídku podniku
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
Reference in New Issue
Block a user