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 => 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; }