feat: /objednani – skupinové objednávky s QR platbou
CI / Generate TypeScript types (push) Successful in 18s
CI / Generate TypeScript types (pull_request) Successful in 9s
CI / Server unit tests (push) Successful in 21s
CI / Build client (push) Successful in 40s
CI / Server unit tests (pull_request) Successful in 21s
CI / Build server (pull_request) Successful in 24s
CI / Build server (push) Has been cancelled
CI / Playwright E2E tests (push) Has been cancelled
CI / Build and push Docker image (push) Has been cancelled
CI / Notify (push) Has been cancelled
CI / Build client (pull_request) Has been cancelled
CI / Playwright E2E tests (pull_request) Has been cancelled
CI / Build and push Docker image (pull_request) Has been cancelled
CI / Notify (pull_request) Has been cancelled

Nahrazuje /vecere novou stránkou /objednani. Místo jednoho
OBJEDNAVAM bucketu umožňuje vytvářet více skupin, kde každá
objednává z jiného obchodu.

- Skupiny mají stavový automat: open → locked → ordered
- Obchody spravuje admin heslem (ADMIN_PASSWORD env var)
  přes modal „Správa obchodů"
- Při stavu ordered zakladatel generuje QR kódy platby
  (nový PayForGroupModal – volné částky bez menu)
- PayForAllModal (oběd) upraven: plátce nyní vidí svůj
  vlastní díl jako informační řádek
- Nové testy: stores.test.ts + groups.test.ts (36 testů)
This commit is contained in:
2026-05-07 07:05:01 +02:00
parent 774be3df6d
commit 936b33cc80
28 changed files with 1641 additions and 242 deletions
+119
View File
@@ -0,0 +1,119 @@
import crypto from "crypto";
import getStorage from "./storage";
import { getClientData, getToday, initIfNeeded } from "./service";
import { getStores } from "./stores";
import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen";
import { formatDate } from "./utils";
const storage = getStorage();
async function getExtraData(date?: Date): Promise<ClientData> {
await initIfNeeded(date, MealSlot.EXTRA);
return getClientData(date, MealSlot.EXTRA);
}
function getExtraKey(date?: Date): string {
return `${formatDate(date ?? getToday())}_extra`;
}
async function saveExtraData(data: ClientData, date?: Date): Promise<ClientData> {
await storage.setData(getExtraKey(date), data);
return data;
}
function findGroup(data: ClientData, id: string): OrderGroup | undefined {
return data.groups?.find(g => g.id === id);
}
export async function createGroup(creatorLogin: string, name: string, date?: Date): Promise<ClientData> {
const stores = await getStores();
if (!stores.some(s => s.toLowerCase() === name.trim().toLowerCase())) {
throw new Error('Obchod není v seznamu povolených obchodů');
}
const data = await getExtraData(date);
const group: OrderGroup = {
id: crypto.randomUUID(),
name: name.trim(),
creatorLogin,
state: GroupState.OPEN,
members: { [creatorLogin]: {} },
};
data.groups = [...(data.groups ?? []), group];
return saveExtraData(data, date);
}
export async function deleteGroup(login: string, groupId: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Skupinu může smazat pouze zakladatel');
data.groups = (data.groups ?? []).filter(g => g.id !== groupId);
return saveExtraData(data, date);
}
export async function addGroupMember(login: string, groupId: string, targetLogin: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
if (login !== group.creatorLogin && login !== targetLogin) {
throw new Error('Přidat jiného uživatele může pouze zakladatel');
}
if (group.state === GroupState.LOCKED && login !== group.creatorLogin) {
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
}
if (group.members[targetLogin]) throw new Error('Uživatel je již ve skupině');
group.members[targetLogin] = {};
return saveExtraData(data, date);
}
export async function removeGroupMember(login: string, groupId: string, targetLogin: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
if (login !== group.creatorLogin && login !== targetLogin) {
throw new Error('Odebrat jiného uživatele může pouze zakladatel');
}
if (group.state === GroupState.LOCKED && login !== group.creatorLogin) {
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
}
if (targetLogin === group.creatorLogin) throw new Error('Zakladatel skupiny nemůže být odebrán');
if (!group.members[targetLogin]) throw new Error('Uživatel není ve skupině');
delete group.members[targetLogin];
return saveExtraData(data, date);
}
export async function updateGroupMember(login: string, groupId: string, targetLogin: string, patch: Partial<OrderGroupMember>, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
const isSelf = login === targetLogin;
const isCreator = login === group.creatorLogin;
if (!isSelf && !isCreator) throw new Error('Upravit jiného uživatele může pouze zakladatel');
if (!isCreator && group.state === GroupState.LOCKED) {
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
}
if (!group.members[targetLogin]) throw new Error('Uživatel není ve skupině');
group.members[targetLogin] = { ...group.members[targetLogin], ...patch };
return saveExtraData(data, date);
}
const VALID_TRANSITIONS: Record<GroupState, GroupState[]> = {
[GroupState.OPEN]: [GroupState.LOCKED],
[GroupState.LOCKED]: [GroupState.OPEN, GroupState.ORDERED],
[GroupState.ORDERED]: [],
};
export async function setGroupState(login: string, groupId: string, newState: GroupState, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Stav může měnit pouze zakladatel');
if (!VALID_TRANSITIONS[group.state].includes(newState)) {
throw new Error(`Nelze přejít ze stavu "${group.state}" do stavu "${newState}"`);
}
group.state = newState;
return saveExtraData(data, date);
}