CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 42s
CI / Playwright E2E tests (push) Successful in 1m43s
CI / Build and push Docker image (push) Successful in 48s
CI / Notify (push) Successful in 1s
280 lines
13 KiB
TypeScript
280 lines
13 KiB
TypeScript
import crypto from "crypto";
|
|
import getStorage from "./storage";
|
|
import { getClientData, getToday, initIfNeeded } from "./service";
|
|
import { getStores } from "./stores";
|
|
import { removePendingQrsByGroupId } from "./pizza";
|
|
import { extractTracking } from "./trackingProviders";
|
|
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);
|
|
const data = await getClientData(date, MealSlot.EXTRA);
|
|
data.stores = await getStores();
|
|
return data;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/** České názvy stavů pro chybová hlášení. */
|
|
const STATE_LABEL: Record<GroupState, string> = {
|
|
[GroupState.OPEN]: 'otevřeno',
|
|
[GroupState.LOCKED]: 'uzamčeno',
|
|
[GroupState.ORDERED]: 'objednáno',
|
|
[GroupState.DELIVERED]: 'doručeno',
|
|
};
|
|
|
|
/** Stavy, ve kterých je objednávka již odeslána do podniku a nelze ji tedy upravovat. */
|
|
function isSubmitted(state: GroupState): boolean {
|
|
return state === GroupState.ORDERED || state === GroupState.DELIVERED;
|
|
}
|
|
|
|
/** Ověří, že skupina není v odeslaném stavu (objednáno/doručeno), jinak vyhodí chybu. */
|
|
function assertNotSubmitted(group: OrderGroup): void {
|
|
if (isSubmitted(group.state)) {
|
|
throw new Error(`Skupinu ve stavu "${STATE_LABEL[group.state]}" nelze upravovat`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Vrátí seznam ISO dat (YYYY-MM-DD), pro která existuje alespoň jedna objednávková skupina.
|
|
* Slouží ke zvýraznění dnů v date pickeru na stránce objednávání.
|
|
*/
|
|
export async function getOrderDates(): Promise<string[]> {
|
|
const EXTRA_SUFFIX = '_extra';
|
|
const keys = await storage.listKeys(EXTRA_SUFFIX);
|
|
const dates: string[] = [];
|
|
for (const key of keys) {
|
|
if (!key.endsWith(EXTRA_SUFFIX)) continue;
|
|
const data = await storage.getData<ClientData>(key);
|
|
if (data?.groups && data.groups.length > 0) {
|
|
dates.push(key.slice(0, -EXTRA_SUFFIX.length));
|
|
}
|
|
}
|
|
return dates.sort();
|
|
}
|
|
|
|
/**
|
|
* Vytvoří novou skupinu objednávky.
|
|
*
|
|
* @param creatorLogin login zakladatele
|
|
* @param name název obchodu (musí být v seznamu povolených obchodů)
|
|
* @param date den, ke kterému skupina patří (výchozí dnešní)
|
|
* @param url vybraná URL na nabídku podniku — musí být jednou z URL obchodu.
|
|
* Pokud není předána a obchod má právě jednu URL, dosadí se automaticky.
|
|
*/
|
|
export async function createGroup(creatorLogin: string, name: string, date?: Date, url?: string): Promise<ClientData> {
|
|
const stores = await getStores();
|
|
const store = stores.find(s => s.name.toLowerCase() === name.trim().toLowerCase());
|
|
if (!store) {
|
|
throw new Error('Obchod není v seznamu povolených obchodů');
|
|
}
|
|
const storeUrls = store.urls ?? [];
|
|
const requestedUrl = url?.trim();
|
|
let selectedUrl: string | undefined;
|
|
if (requestedUrl) {
|
|
selectedUrl = storeUrls.find(u => u === requestedUrl);
|
|
if (!selectedUrl) {
|
|
throw new Error('Vybraná URL nepatří k tomuto obchodu');
|
|
}
|
|
} else if (storeUrls.length === 1) {
|
|
// Obchod má jedinou nabídku — vybírat není z čeho
|
|
selectedUrl = storeUrls[0];
|
|
}
|
|
const data = await getExtraData(date);
|
|
const group: OrderGroup = {
|
|
id: crypto.randomUUID(),
|
|
name: store.name,
|
|
creatorLogin,
|
|
state: GroupState.OPEN,
|
|
members: { [creatorLogin]: {} },
|
|
...(selectedUrl ? { url: selectedUrl } : {}),
|
|
};
|
|
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');
|
|
assertNotSubmitted(group);
|
|
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');
|
|
assertNotSubmitted(group);
|
|
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');
|
|
assertNotSubmitted(group);
|
|
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]: [GroupState.LOCKED, GroupState.DELIVERED],
|
|
[GroupState.DELIVERED]: [GroupState.ORDERED],
|
|
};
|
|
|
|
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<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 "${STATE_LABEL[group.state]}" do stavu "${STATE_LABEL[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.qrGenerated = undefined;
|
|
group.trackingProvider = undefined;
|
|
group.trackingCode = undefined;
|
|
group.trackingOrderState = undefined;
|
|
group.trackingCourierState = undefined;
|
|
for (const ml of memberLogins) {
|
|
group.members[ml] = { ...group.members[ml], paid: undefined };
|
|
}
|
|
}
|
|
group.state = newState;
|
|
return saveExtraData(data, date);
|
|
}
|
|
|
|
/**
|
|
* Označí skupinu za vygenerovanou (blokuje opakované generování QR) a zároveň ji
|
|
* překlopí do stavu "doručeno" — generování QR je poslední krok objednávky.
|
|
*/
|
|
export async function markGroupQrGenerated(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('QR kódy může generovat pouze zakladatel');
|
|
if (!isSubmitted(group.state)) throw new Error('QR kódy lze generovat pouze ve stavu "objednáno" nebo "doručeno"');
|
|
group.qrGenerated = true;
|
|
group.state = GroupState.DELIVERED;
|
|
return saveExtraData(data, date);
|
|
}
|
|
|
|
export async function markGroupMemberPaid(login: string, groupId: string, date?: Date): Promise<ClientData | null> {
|
|
const data = await getExtraData(date);
|
|
const group = findGroup(data, groupId);
|
|
if (!group || !group.members[login]) return null;
|
|
group.members[login] = { ...group.members[login], paid: true };
|
|
return saveExtraData(data, date);
|
|
}
|
|
|
|
export async function updateGroupFees(login: string, groupId: string, fees?: number, shipping?: number, tip?: number, discountType?: string, discountValue?: number, 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('Poplatky může měnit pouze zakladatel');
|
|
assertNotSubmitted(group);
|
|
if (fees !== undefined) group.fees = fees > 0 ? fees : undefined;
|
|
if (shipping !== undefined) group.shipping = shipping > 0 ? shipping : undefined;
|
|
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;
|
|
return saveExtraData(data, date);
|
|
}
|
|
|
|
export async function updateGroupTimes(login: string, groupId: string, orderedAt?: string, deliveryAt?: 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('Č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);
|
|
}
|
|
|
|
export async function setGroupTracking(login: string, groupId: string, shareUrl?: 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('Sledování objednávky může nastavit pouze zakladatel');
|
|
if (!shareUrl) {
|
|
group.trackingProvider = undefined;
|
|
group.trackingCode = undefined;
|
|
group.trackingOrderState = undefined;
|
|
group.trackingCourierState = undefined;
|
|
} else {
|
|
if (!isSubmitted(group.state)) throw new Error('Sledování objednávky lze nastavit pouze ve stavu "objednáno" nebo "doručeno"');
|
|
const tracking = extractTracking(shareUrl);
|
|
if (!tracking) throw new Error('Neplatný odkaz na sledování objednávky');
|
|
if (tracking.code !== group.trackingCode || tracking.provider !== group.trackingProvider) {
|
|
group.trackingProvider = tracking.provider;
|
|
group.trackingCode = tracking.code;
|
|
// Stav patří k předchozí objednávce — vyčistíme, doplní ho první poll
|
|
group.trackingOrderState = undefined;
|
|
group.trackingCourierState = undefined;
|
|
}
|
|
}
|
|
return saveExtraData(data, date);
|
|
}
|