import { randomUUID } from 'crypto'; import { MealTemplate, MealType } from "../../types/gen/types.gen"; import getStorage from "./storage"; import { addMeal, getDay, isMealType } from "./meals"; import { normalizeName } from "./calories"; import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils"; const storage = getStorage(); /** Maximální délka textových polí. Poznámka je delší, aby se do ní vešel postup. */ const MAX_NAME_LENGTH = 200; const MAX_NOTE_LENGTH = 4000; /** Klíč úložiště se šablonami jídel jednoho uživatele. */ const getKey = (login: string): string => `mealTemplates:${login}`; /** * Vrátí šablony jídel uživatele seřazené podle názvu. * * @param login přihlašovací jméno uživatele */ export const getMealTemplates = async (login: string): Promise => (await storage.getData(getKey(login)) ?? []) .sort((a, b) => a.name.localeCompare(b.name, 'cs')); /** Ořízne a znormalizuje volitelný textový vstup. */ const normalizeText = (value: unknown, name: string, maxLength = MAX_NAME_LENGTH): string | undefined => { if (value == null) return undefined; if (typeof value !== 'string') { throw new BadRequestError(`Pole '${name}' musí být text`); } const trimmed = value.trim(); if (!trimmed.length) return undefined; if (trimmed.length > maxLength) { throw new BadRequestError(`Pole '${name}' může mít nejvýše ${maxLength} znaků`); } return trimmed; }; /** Ověří volitelnou nezápornou celočíselnou hodnotu. */ const normalizeNumber = (value: unknown, name: string): number | undefined => { if (value == null) return undefined; if (typeof value !== 'number' || !Number.isFinite(value)) { throw new BadRequestError(`Pole '${name}' musí být číslo`); } const rounded = Math.round(value); if (rounded < 0) { throw new BadRequestError(`Pole '${name}' nesmí být záporné`); } return rounded; }; /** * Založí novou šablonu jídla, nebo přepíše existující podle předaného id. * * @param login přihlašovací jméno uživatele * @param input data šablony */ export const saveMealTemplate = async (login: string, input: unknown): Promise => { const value = (input ?? {}) as Record; const name = normalizeText(value.name, 'name'); if (!name) { throw new BadRequestError("Nebyl předán název šablony"); } if (value.mealType != null && !isMealType(value.mealType)) { throw new BadRequestError("Nebyl předán platný typ jídla"); } const template: MealTemplate = { id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(), name, ...(value.mealType != null ? { mealType: value.mealType as MealType } : {}), ...(normalizeText(value.source, 'source') != null ? { source: normalizeText(value.source, 'source') } : {}), ...(normalizeText(value.note, 'note', MAX_NOTE_LENGTH) != null ? { note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH) } : {}), ...(normalizeNumber(value.calories, 'calories') != null ? { calories: normalizeNumber(value.calories, 'calories') } : {}), ...(normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') != null ? { caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') } : {}), ...(normalizeNumber(value.weight, 'weight') != null ? { weight: normalizeNumber(value.weight, 'weight') } : {}), ...(normalizeNumber(value.price, 'price') != null ? { price: normalizeNumber(value.price, 'price') } : {}), ...(normalizeNumber(value.pricePer100g, 'pricePer100g') != null ? { pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g') } : {}), }; await storage.updateData(getKey(login), current => { const others = (current ?? []).filter(item => item.id !== template.id); return [...others, template]; }); return getMealTemplates(login); }; /** * Smaže šablonu jídla. * * @param login přihlašovací jméno uživatele * @param id identifikátor šablony */ export const deleteMealTemplate = async (login: string, id: unknown): Promise => { if (typeof id !== 'string' || !id.length) { throw new BadRequestError("Nebyl předán identifikátor šablony"); } let found = false; await storage.updateData(getKey(login), current => { found = (current ?? []).some(item => item.id === id); return (current ?? []).filter(item => item.id !== id); }); if (!found) { throw new NotFoundError("Šablona nebyla nalezena"); } return getMealTemplates(login); }; /** * Založí do zvoleného dne jídlo ze šablony. * * Vznikne z ní běžný záznam jídla, takže jde pak upravit nebo smazat, aniž by * to ovlivnilo šablonu. Gramáž, cenu a kalorie dopočítá `addMeal` stejně jako * u ručně zadaného jídla. * * @param login přihlašovací jméno uživatele * @param id identifikátor šablony * @param date den, do kterého se jídlo založí * @param mealType chod; bez něj se použije chod ze šablony, jinak oběd */ export const applyMealTemplate = async ( login: string, id: unknown, date: unknown, mealType?: unknown, ): Promise => { if (typeof id !== 'string' || !id.length) { throw new BadRequestError("Nebyl předán identifikátor šablony"); } if (!isValidIsoDate(date)) { throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); } if (mealType != null && !isMealType(mealType)) { throw new BadRequestError("Nebyl předán platný typ jídla"); } const template = (await getMealTemplates(login)).find(item => item.id === id); if (!template) { throw new NotFoundError("Šablona nebyla nalezena"); } await addMeal(login, { date, mealType: mealType ?? template.mealType ?? MealType.OBED, name: template.name, source: template.source, weight: template.weight, price: template.price, pricePer100g: template.pricePer100g, calories: template.calories, caloriesPer100g: template.caloriesPer100g, note: template.note, }); return date; }; /** * Vytvoří šablonu jídla z existujícího záznamu v přehledu dne. * * Když už šablona se stejným názvem existuje, přepíše se — opakované "uložit * jako šablonu" u téhož jídla má dát jednu šablonu, ne několik stejných. * * @param login přihlašovací jméno uživatele * @param date den, ve kterém jídlo je * @param id identifikátor jídla */ export const createMealTemplateFromMeal = async ( login: string, date: unknown, id: unknown, ): Promise => { if (typeof id !== 'string' || !id.length) { throw new BadRequestError("Nebyl předán identifikátor jídla"); } if (!isValidIsoDate(date)) { throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); } const meal = (await getDay(login, date)).entries.find(entry => entry.id === id); if (!meal) { throw new NotFoundError("Jídlo nebylo nalezeno"); } const key = normalizeName(meal.name); const existing = (await getMealTemplates(login)).find(item => normalizeName(item.name) === key); return saveMealTemplate(login, { ...(existing ? { id: existing.id } : {}), name: meal.name, source: meal.source, mealType: meal.mealType, weight: meal.weight, price: meal.price, pricePer100g: meal.pricePer100g, // Absolutní kalorie ukládáme jen tam, kde je nejde dopočítat z gramáže calories: meal.caloriesPer100g ? undefined : meal.calories, caloriesPer100g: meal.caloriesPer100g, note: meal.note, }); };