import { NextRequest, NextResponse } from "next/server"; import { db, recipes, users, eq, and, or, ilike, sql, desc, } from "@epicure/db"; const VALID_DIETARY = ["vegan", "vegetarian", "glutenFree", "dairyFree"] as const; type DietaryTag = (typeof VALID_DIETARY)[number]; export async function GET(req: NextRequest) { const { searchParams } = req.nextUrl; // --- Parse & validate required param --- const q = (searchParams.get("q") ?? "").trim().slice(0, 200); if (!q) { return NextResponse.json( { error: "Query parameter 'q' is required and must not be empty." }, { status: 400 } ); } // --- Optional params --- const difficultyParam = searchParams.get("difficulty"); const difficulty = difficultyParam === "easy" || difficultyParam === "medium" || difficultyParam === "hard" ? (difficultyParam as "easy" | "medium" | "hard") : undefined; const maxMinsRaw = searchParams.get("maxMins"); const maxMins = maxMinsRaw !== null && !Number.isNaN(Number(maxMinsRaw)) ? Number(maxMinsRaw) : undefined; const limitRaw = searchParams.get("limit"); const limit = Math.min( limitRaw !== null && !Number.isNaN(Number(limitRaw)) ? Math.max(1, Number(limitRaw)) : 20, 50 ); const offsetRaw = searchParams.get("offset"); const offset = offsetRaw !== null && !Number.isNaN(Number(offsetRaw)) ? Math.max(0, Number(offsetRaw)) : 0; const dietaryRaw = searchParams.get("dietary"); const dietaryTags: DietaryTag[] = dietaryRaw ? (dietaryRaw .split(",") .map((s) => s.trim()) .filter((s): s is DietaryTag => (VALID_DIETARY as readonly string[]).includes(s) )) : []; // --- Build WHERE conditions --- // Escape ilike wildcard chars (% and _) so user input like "100%" is matched literally. const escapedQ = q.replace(/[\\%_]/g, (c) => `\\${c}`); const conditions = [ eq(recipes.visibility, "public"), eq(users.isPrivate, false), or( ilike(recipes.title, `%${escapedQ}%`), ilike(recipes.description, `%${escapedQ}%`) )!, ]; if (difficulty) { conditions.push(eq(recipes.difficulty, difficulty)); } if (maxMins !== undefined) { conditions.push( sql`(${recipes.prepMins} + ${recipes.cookMins}) <= ${maxMins}` ); } for (const tag of dietaryTags) { // Containment (@>) instead of ->> text extraction so the GIN index on dietaryTags is actually used. conditions.push(sql`${recipes.dietaryTags} @> ${JSON.stringify({ [tag]: true })}::jsonb`); } const where = and(...conditions); // --- Main data query --- const rows = await db .select({ id: recipes.id, title: recipes.title, description: recipes.description, difficulty: recipes.difficulty, baseServings: recipes.baseServings, prepMins: recipes.prepMins, cookMins: recipes.cookMins, authorId: recipes.authorId, authorName: users.name, createdAt: recipes.createdAt, }) .from(recipes) .innerJoin(users, eq(recipes.authorId, users.id)) .where(where) .orderBy(desc(recipes.createdAt)) .limit(limit) .offset(offset); // --- Count query --- const countResult = await db .select({ total: sql`count(*)::int` }) .from(recipes) .innerJoin(users, eq(recipes.authorId, users.id)) .where(where); const total = countResult[0]?.total ?? 0; return NextResponse.json({ data: rows, total, limit, offset }); }