feat(core): add secure recipe APIs, precise scaling and versioned imports
This commit is contained in:
commit
5d71d32e20
31 files changed
+6293
No files matched your search
@@ -0,0 +1,137 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const text = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.refine((v) => !/<\/?[a-z][^>]*>/i.test(v), 'HTML ist nicht erlaubt.');
|
||||
const id = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/);
|
||||
export const units = ['g', 'kg', 'ml', 'l', 'Stück', 'TL', 'EL', 'Prise', 'Bund', ''] as const;
|
||||
export const ingredientSchema = z.object({
|
||||
id,
|
||||
name: text(160).min(1, 'Zutat benötigt einen Namen.'),
|
||||
amount: z.number().positive().max(1000000).nullable().default(null),
|
||||
unit: z.enum(units).default(''),
|
||||
section: text(80).default(''),
|
||||
note: text(200).default(''),
|
||||
optional: z.boolean().default(false),
|
||||
});
|
||||
export const stepSchema = z.object({
|
||||
id,
|
||||
title: text(160).default(''),
|
||||
text: text(6000).min(1, 'Schritt benötigt eine Anleitung.'),
|
||||
ingredients: z.array(id).max(100).default([]),
|
||||
timers: z
|
||||
.array(z.object({ id, label: text(100).min(1), seconds: z.number().int().min(1).max(172800) }))
|
||||
.max(10)
|
||||
.default([]),
|
||||
});
|
||||
const image = z
|
||||
.string()
|
||||
.regex(
|
||||
/^\/(?:media\/[a-f0-9-]+\.webp|images\/[a-z0-9-]+\.(?:webp|jpg|svg))$/,
|
||||
'Nur lokale Bilder sind erlaubt.',
|
||||
);
|
||||
export const recipeSchema = z
|
||||
.object({
|
||||
id: z.string().uuid().optional(),
|
||||
title: text(180).min(2, 'Titel: mindestens 2 Zeichen.'),
|
||||
subtitle: text(200).default(''),
|
||||
description: text(4000).default(''),
|
||||
image: image.or(z.literal('')).default(''),
|
||||
images: z.array(image).max(20).default([]),
|
||||
categories: z.array(text(60).min(1)).max(20).default([]),
|
||||
tags: z.array(text(60).min(1)).max(30).default([]),
|
||||
cuisine: text(80).default(''),
|
||||
difficulty: z.enum(['Einfach', 'Mittel', 'Anspruchsvoll']).default('Einfach'),
|
||||
prepMinutes: z.number().int().min(0).max(10080).default(0),
|
||||
cookMinutes: z.number().int().min(0).max(10080).default(0),
|
||||
restMinutes: z.number().int().min(0).max(10080).default(0),
|
||||
servings: z.number().positive().max(10000),
|
||||
ingredients: z.array(ingredientSchema).min(1).max(200),
|
||||
steps: z.array(stepSchema).min(1).max(100),
|
||||
tips: text(6000).default(''),
|
||||
storage: text(3000).default(''),
|
||||
nutrition: z
|
||||
.object({
|
||||
kcal: z.number().min(0),
|
||||
protein: z.number().min(0),
|
||||
carbs: z.number().min(0),
|
||||
fat: z.number().min(0),
|
||||
})
|
||||
.nullable()
|
||||
.default(null),
|
||||
published: z.boolean().default(false),
|
||||
createdAt: z.iso.datetime().optional(),
|
||||
updatedAt: z.iso.datetime().optional(),
|
||||
})
|
||||
.superRefine((r, ctx) => {
|
||||
const ids = new Set(r.ingredients.map((i) => i.id));
|
||||
if (ids.size !== r.ingredients.length)
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['ingredients'],
|
||||
message: 'Zutaten-IDs müssen eindeutig sein.',
|
||||
});
|
||||
const stepIds = new Set(r.steps.map((s) => s.id));
|
||||
if (stepIds.size !== r.steps.length)
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['steps'],
|
||||
message: 'Schritt-IDs müssen eindeutig sein.',
|
||||
});
|
||||
const timers = r.steps.flatMap((s) => s.timers.map((t) => t.id));
|
||||
if (new Set(timers).size !== timers.length)
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['steps'],
|
||||
message: 'Timer-IDs müssen eindeutig sein.',
|
||||
});
|
||||
r.steps.forEach((s, n) => {
|
||||
const refs = [
|
||||
...s.ingredients,
|
||||
...Array.from(s.text.matchAll(/\{\{([^}]+)\}\}/g), (m) => m[1]),
|
||||
];
|
||||
if (refs.some((ref) => !ids.has(ref)))
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['steps', n],
|
||||
message: 'Unbekannte Zutatenreferenz.',
|
||||
});
|
||||
});
|
||||
});
|
||||
export const collectionSchema = z.object({
|
||||
version: z.literal(1),
|
||||
recipes: z.array(recipeSchema).min(1).max(100),
|
||||
});
|
||||
export type Ingredient = z.infer<typeof ingredientSchema>;
|
||||
export type Step = z.infer<typeof stepSchema>;
|
||||
export type Recipe = z.infer<typeof recipeSchema>;
|
||||
export type SavedRecipe = Recipe & { id: string; createdAt: string; updatedAt: string };
|
||||
export const totalMinutes = (r: Recipe) => r.prepMinutes + r.cookMinutes + r.restMinutes;
|
||||
export function emptyRecipe(): Recipe {
|
||||
return {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
description: '',
|
||||
image: '',
|
||||
images: [],
|
||||
categories: [],
|
||||
tags: [],
|
||||
cuisine: '',
|
||||
difficulty: 'Einfach',
|
||||
prepMinutes: 10,
|
||||
cookMinutes: 20,
|
||||
restMinutes: 0,
|
||||
servings: 2,
|
||||
ingredients: [
|
||||
{ id: 'i1', name: '', amount: null, unit: 'g', section: '', note: '', optional: false },
|
||||
],
|
||||
steps: [{ id: 's1', title: '', text: '', ingredients: [], timers: [] }],
|
||||
tips: '',
|
||||
storage: '',
|
||||
nutrition: null,
|
||||
published: false,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user