diff --git a/client/src/components/MenuHintCell.scss b/client/src/components/MenuHintCell.scss index ccaa946..465c70f 100644 --- a/client/src/components/MenuHintCell.scss +++ b/client/src/components/MenuHintCell.scss @@ -53,6 +53,12 @@ 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 { padding: 4px 12px; font-size: 0.7rem; diff --git a/client/src/components/MenuHintCell.tsx b/client/src/components/MenuHintCell.tsx index 64388a8..f941c7b 100644 --- a/client/src/components/MenuHintCell.tsx +++ b/client/src/components/MenuHintCell.tsx @@ -2,7 +2,8 @@ import { useMemo } from "react"; import { OverlayTrigger, Tooltip } from "react-bootstrap"; import SelectSearch, { SelectedOptionValue, SelectSearchOption } from "react-select-search"; 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"; 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. */ export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUsePrice, onRemoveItem }: Readonly) { + const { maxItemQuantity, maxItems } = useGroupLimits(); const options = useMemo(() => { if (!menu?.length) return []; const groups = new Map(); menu.forEach((d, index) => { const category = d.category || 'Ostatní'; 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 })); - }, [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 items = useMemo(() => splitNoteItems(note, menu), [note, menu]); @@ -49,7 +54,10 @@ export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUs return (
- {canEdit && ( + {canEdit && itemLimitReached && ( + Dosažen limit {maxItems} položek — nejdřív něco odeber (×). + )} + {canEdit && !itemLimitReached && ( getToken(), @@ -17,8 +18,9 @@ client.setConfig({ }); // 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 }) => { + setGroupLimits(data?.limits?.group); if (data?.sentry.dsn) { Sentry.init({ dsn: data.sentry.dsn, diff --git a/client/src/pages/OrderGroupsPage.tsx b/client/src/pages/OrderGroupsPage.tsx index 8f55f04..15238e3 100644 --- a/client/src/pages/OrderGroupsPage.tsx +++ b/client/src/pages/OrderGroupsPage.tsx @@ -12,7 +12,7 @@ import { } from '../../../types'; import MenuHintCell from '../components/MenuHintCell'; 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 { getStoreUrls, isHttpUrl, storeUrlLabel } from '../utils/storeUrls'; 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; /** 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 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 }) } })); diff --git a/client/src/utils/appConfig.ts b/client/src/utils/appConfig.ts new file mode 100644 index 0000000..2db43fc --- /dev/null +++ b/client/src/utils/appConfig.ts @@ -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 | 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); +} diff --git a/client/src/utils/menuMatch.ts b/client/src/utils/menuMatch.ts index 1837499..31054a7 100644 --- a/client/src/utils/menuMatch.ts +++ b/client/src/utils/menuMatch.ts @@ -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. */ 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()}`; 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; +} diff --git a/server/.env.template b/server/.env.template index 677cc93..d41bba5 100644 --- a/server/.env.template +++ b/server/.env.template @@ -64,3 +64,19 @@ # SENTRY_CLIENT_DSN=https://...@sentry.example.com/2 # Interní logy Sentry SDK do konzole — pro ladění, když eventy nedorazí do Sentry. # 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 diff --git a/server/src/groups.ts b/server/src/groups.ts index a9caf99..8455303 100644 --- a/server/src/groups.ts +++ b/server/src/groups.ts @@ -240,6 +240,10 @@ export async function updateGroupFees(login: string, groupId: string, fees?: num if (tip !== undefined) group.tip = tip > 0 ? tip : undefined; if (discountType !== undefined) group.discountType = (discountType as any) || 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); } diff --git a/server/src/index.ts b/server/src/index.ts index 7eddfd1..30049cf 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -30,6 +30,7 @@ import groupRoutes from "./routes/groupRoutes"; import storeRoutes from "./routes/storeRoutes"; import butterflyRoutes from "./routes/butterflyRoutes"; import { getConfettiCharacters, getConfettiImage } from "./confetti"; +import { getGroupLimits } from "./limits"; const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; 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, environment: ENVIRONMENT, }, + limits: { + group: getGroupLimits(), + }, }); }); diff --git a/server/src/limits.ts b/server/src/limits.ts new file mode 100644 index 0000000..6287fb9 --- /dev/null +++ b/server/src/limits.ts @@ -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 = { + 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; +} diff --git a/server/src/routes/groupRoutes.ts b/server/src/routes/groupRoutes.ts index 3787976..3203639 100644 --- a/server/src/routes/groupRoutes.ts +++ b/server/src/routes/groupRoutes.ts @@ -5,9 +5,20 @@ import { getWebsocket } from "../websocket"; import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupTracking, getOrderDates } from "../groups"; import { GroupState } from "../../../types/gen/types.gen"; import { checkOrderTracking } from "../orderTracking"; +import { getGroupLimits } from "../limits"; 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) { getWebsocket().emit("message", data); } @@ -25,7 +36,11 @@ router.post("/create", async (req: Request, res, next) => { if (!name || typeof name !== 'string') { 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' }); } 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' }); const patch: Record = {}; if (amount !== undefined) { - if (!Number.isInteger(amount) || amount < 0) { - return res.status(400).json({ error: 'Neplatná částka' }); - } + const error = invalidAmount(amount, 'Částka'); + if (error) return res.status(400).json({ error }); patch.amount = amount; } if (note !== undefined) { 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; } if (surchargeText !== undefined) { 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; } if (surchargeAmount !== undefined) { - if (!Number.isInteger(surchargeAmount) || surchargeAmount < 0) { - return res.status(400).json({ error: 'Neplatná výše příplatku' }); - } + const error = invalidAmount(surchargeAmount, 'Příplatek'); + if (error) return res.status(400).json({ error }); patch.surchargeAmount = surchargeAmount; } try { @@ -124,20 +145,25 @@ router.post("/updateFees", async (req: Request, res, next) => { const login = getLogin(parseToken(req)); const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {}; if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' }); - if (fees !== undefined && (!Number.isInteger(fees) || fees < 0)) { - return res.status(400).json({ error: 'Neplatná výše poplatků' }); - } - 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' }); + for (const [value, label] of [[fees, 'Poplatky'], [shipping, 'Doprava'], [tip, 'Spropitné']] as const) { + const error = value !== undefined ? invalidAmount(value, label) : undefined; + if (error) return res.status(400).json({ error }); } if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) { return res.status(400).json({ error: 'Neplatný typ slevy' }); } - if (discountValue !== undefined && (!Number.isInteger(discountValue) || discountValue < 0)) { - return res.status(400).json({ error: 'Neplatná výše slevy' }); + if (discountValue !== undefined) { + if (!Number.isInteger(discountValue) || discountValue < 0) { + 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 { const data = await updateGroupFees(login, id, fees, shipping, tip, discountType, discountValue); @@ -168,7 +194,7 @@ router.post("/setTracking", async (req: Request, res, next) => { const login = getLogin(parseToken(req)); const { id, shareUrl } = req.body ?? {}; 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' }); } try { diff --git a/server/src/tests/groupRoutes.test.ts b/server/src/tests/groupRoutes.test.ts new file mode 100644 index 0000000..633717f --- /dev/null +++ b/server/src/tests/groupRoutes.test.ts @@ -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 { + 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(); + }); +}); diff --git a/types/paths/config/config.yml b/types/paths/config/config.yml index 467298c..8a76a0a 100644 --- a/types/paths/config/config.yml +++ b/types/paths/config/config.yml @@ -1,6 +1,6 @@ get: 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 responses: "200": @@ -9,8 +9,14 @@ get: application/json: schema: type: object - required: [sentry] + required: [sentry, limits] properties: + limits: + type: object + required: [group] + properties: + group: + $ref: "../../schemas/_index.yml#/GroupLimits" sentry: type: object required: [dsn, environment] diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 3b66ad4..f5883da 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -947,6 +947,41 @@ StoreMenuSource: - HISTORY - 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: description: Dovozová služba, ze které lze stáhnout nabídku podniku type: string