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

This commit is contained in:
2026-09-24 09:18:56 +02:00
parent 076b8db179
commit 103445dba8
15 changed files with 386 additions and 28 deletions
+16
View File
@@ -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
+4
View File
@@ -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);
}
+4
View File
@@ -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(),
},
});
});
+56
View File
@@ -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;
}
+44 -18
View File
@@ -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<string, any> = {};
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 {
+125
View File
@@ -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();
});
});