203 lines
7.1 KiB
TypeScript
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;
|
|
}
|