Files
food-tracer/server/src/mealTemplates.ts
T

207 lines
7.9 KiB
TypeScript

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<MealTemplate[]> =>
(await storage.getData<MealTemplate[]>(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<MealTemplate[]> => {
const value = (input ?? {}) as Record<string, unknown>;
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<MealTemplate[]>(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<MealTemplate[]> => {
if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor šablony");
}
let found = false;
await storage.updateData<MealTemplate[]>(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<string> => {
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<MealTemplate[]> => {
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,
});
};