recipe-manager/src/backend/routes/recipes.ts

203 lines
7.1 KiB
TypeScript

import { Router } from 'express';
import { z } from 'zod';
import type { Database } from 'sql.js';
import { RecipeService } from '../services/RecipeService.js';
import { SearchIndex } from '../services/SearchIndex.js';
import { asyncHandler } from '../middleware.js';
const createRecipeSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
servings: z.number().int().positive().optional(),
prep_time_minutes: z.number().int().positive().optional(),
cook_time_minutes: z.number().int().positive().optional(),
source_url: z.string().url().optional().or(z.literal('')),
image_url: z.string().url().optional().or(z.literal('')),
ingredients: z.array(z.object({
quantity: z.string().optional(),
unit: z.string().optional(),
item: z.string().min(1, 'Ingredient required'),
notes: z.string().optional(),
})).min(1, 'At least one ingredient is required'),
steps: z.array(z.object({
instruction: z.string().min(1, 'Instruction required'),
})).min(1, 'At least one step is required'),
tagIds: z.array(z.number().int().positive()).optional(),
});
const updateRecipeSchema = z.object({
title: z.string().min(1).optional(),
description: z.string().optional().nullable(),
servings: z.number().int().positive().optional().nullable(),
prep_time_minutes: z.number().int().positive().optional().nullable(),
cook_time_minutes: z.number().int().positive().optional().nullable(),
source_url: z.string().url().optional().nullable().or(z.literal('')),
image_url: z.string().url().optional().nullable().or(z.literal('')),
ingredients: z.array(z.object({
quantity: z.string().optional(),
unit: z.string().optional(),
item: z.string().min(1).optional(),
notes: z.string().optional(),
})).optional(),
steps: z.array(z.object({ instruction: z.string().min(1).optional() })).optional(),
tagIds: z.array(z.number().int().positive()).optional(),
});
const recipeFiltersSchema = z.object({
search: z.string().optional(),
offset: z.coerce.number().int().nonnegative().optional(),
limit: z.coerce.number().int().positive().max(100).optional(),
tagId: z.coerce.number().int().positive().optional(),
tagIds: z.preprocess(
(value) => {
if (typeof value === 'string') {
if (!value.trim()) return [];
return value
.split(',')
.map((part) => Number(part.trim()))
.filter((num) => Number.isInteger(num) && num > 0);
}
if (Array.isArray(value)) {
return value
.map((part) => Number(part))
.filter((num) => Number.isInteger(num) && num > 0);
}
return undefined;
},
z.array(z.number().int().positive()).optional(),
),
});
export function createRecipeRoutes(db: Database): Router {
const router = Router();
const searchIndex = SearchIndex.getInstance(db);
// Initialize search index in the background (non-blocking)
// Note: We don't await here to avoid delaying route setup; index will be ready soon
searchIndex.initialize().catch(console.error);
const recipeService = new RecipeService(db, searchIndex);
router.get('/', asyncHandler(async (req, res) => {
const parsedFilters = recipeFiltersSchema.parse(req.query);
const normalizedTagIds = parsedFilters.tagIds && parsedFilters.tagIds.length > 0
? parsedFilters.tagIds
: parsedFilters.tagId
? [parsedFilters.tagId]
: undefined;
const filters = {
...parsedFilters,
tagIds: normalizedTagIds,
};
const result = recipeService.list(filters);
const offset = filters.offset || 0;
const limit = filters.limit || 50;
const baseUrl = `${req.protocol}://${req.get('host')}${req.baseUrl}`;
const meta: any = {
total: result.total,
offset,
limit,
};
// Pagination links
if (offset + limit < result.total) {
meta.next = `${baseUrl}?offset=${offset + limit}&limit=${limit}`;
}
if (offset > 0) {
meta.prev = `${baseUrl}?offset=${Math.max(0, offset - limit)}&limit=${limit}`;
}
res.json({
success: true,
data: result.recipes,
meta,
error: null,
});
}));
router.get('/search', asyncHandler(async (req, res) => {
const { q: query, offset = 0, limit = 20 } = req.query;
if (!query || typeof query !== 'string' || query.trim().length === 0) {
return res.status(400).json({
success: false,
data: null,
error: 'Query parameter "q" is required',
});
}
await searchIndex.initialize();
const resultIds = searchIndex.search(query, Math.min(Number(limit) * 2, 100));
const total = resultIds.length;
const paginatedIds = resultIds.slice(Number(offset), Number(offset) + Number(limit));
const hasMore = total > Number(offset) + Number(limit);
const recipes = paginatedIds
.map(id => recipeService.get(id))
.filter((recipe): recipe is NonNullable<typeof recipe> => recipe !== null);
const baseUrl = `${req.protocol}://${req.get('host')}${req.baseUrl}/search`;
const meta: any = {
total,
offset: Number(offset),
limit: Number(limit),
has_more: hasMore,
next: hasMore ? `${baseUrl}?q=${encodeURIComponent(query)}&offset=${Number(offset) + Number(limit)}&limit=${Number(limit)}` : null,
prev: Number(offset) > 0 ? `${baseUrl}?q=${encodeURIComponent(query)}&offset=${Math.max(0, Number(offset) - Number(limit))}&limit=${Number(limit)}` : null,
};
res.json({ success: true, data: recipes, meta, error: null });
}));
router.get('/:id', asyncHandler(async (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
res.status(400).json({ success: false, data: null, error: 'Invalid recipe ID' });
return;
}
const recipe = recipeService.get(id);
if (!recipe) {
res.status(404).json({ success: false, data: null, error: 'Recipe not found' });
return;
}
res.json({ success: true, data: recipe, error: null });
}));
router.post('/', asyncHandler(async (req, res) => {
const data = createRecipeSchema.parse(req.body);
const recipe = recipeService.create(data);
res.status(201).json({ success: true, data: recipe, error: null });
}));
router.put('/:id', asyncHandler(async (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
res.status(400).json({ success: false, data: null, error: 'Invalid recipe ID' });
return;
}
const data = updateRecipeSchema.parse(req.body);
const recipe = recipeService.update(id, data);
if (!recipe) {
res.status(404).json({ success: false, data: null, error: 'Recipe not found' });
return;
}
res.json({ success: true, data: recipe, error: null });
}));
router.delete('/:id', asyncHandler(async (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
res.status(400).json({ success: false, data: null, error: 'Invalid recipe ID' });
return;
}
const deleted = recipeService.delete(id);
if (!deleted) {
res.status(404).json({ success: false, data: null, error: 'Recipe not found' });
return;
}
res.json({ success: true, data: true, error: null });
}));
return router;
}