51e6722f4c
Two changes to the no-photo cover placeholder shipped last version: 1. Muted the gradient palette (100/40-opacity tints instead of solid -200/ -950 stops) — the original was too saturated next to real cover photos in the same grid, per feedback. 2. New coverIcon/coverColor columns on recipes (nullable text, migration 0048, additive-only) let the author pin a specific color+icon from the recipe editor instead of the automatic per-id pick. getRecipePlaceholder now checks these first, falling back to the deterministic hash pick when unset — existing recipes are unaffected until edited. Wired coverIcon/coverColor through every explicit-column recipe select that feeds a grid card (explore trending/recent, search, feed, for-you) — the relational-query call sites (recipes page, collections, profile pages) already return all columns and needed no changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
175 lines
5.5 KiB
TypeScript
175 lines
5.5 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import {
|
|
db,
|
|
recipes,
|
|
recipeIngredients,
|
|
users,
|
|
eq,
|
|
and,
|
|
or,
|
|
ilike,
|
|
sql,
|
|
desc,
|
|
} from "@epicure/db";
|
|
import { getAvgRatingsByRecipeId } from "@/lib/recipe-ratings";
|
|
import { attachCardExtras } from "@/lib/recipe-card-extras";
|
|
import { getOptionalSession } from "@/lib/api-auth";
|
|
|
|
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}`);
|
|
|
|
// A query also matches if it's a substring of an ingredient's raw name or
|
|
// one of the recipe's free-form tags — not just title/description. Neither
|
|
// recipeIngredients.rawName nor recipes.tags has a supporting index today,
|
|
// so this is a sequential scan; fine at this scale, worth a trigram/GIN
|
|
// index if search ever gets slow.
|
|
const conditions = [
|
|
eq(recipes.visibility, "public"),
|
|
eq(users.isPrivate, false),
|
|
or(
|
|
ilike(recipes.title, `%${escapedQ}%`),
|
|
ilike(recipes.description, `%${escapedQ}%`),
|
|
sql`exists (select 1 from ${recipeIngredients} where ${recipeIngredients.recipeId} = ${recipes.id} and ${recipeIngredients.rawName} ilike ${`%${escapedQ}%`})`,
|
|
sql`exists (select 1 from unnest(${recipes.tags}) as tag where tag ilike ${`%${escapedQ}%`})`
|
|
)!,
|
|
];
|
|
|
|
if (difficulty) {
|
|
conditions.push(eq(recipes.difficulty, difficulty));
|
|
}
|
|
|
|
const recipeTypeParam = searchParams.get("recipeType");
|
|
const recipeType = recipeTypeParam === "dish" || recipeTypeParam === "drink" ? recipeTypeParam : undefined;
|
|
if (recipeType) {
|
|
conditions.push(eq(recipes.recipeType, recipeType));
|
|
}
|
|
|
|
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`);
|
|
}
|
|
|
|
// Exact-tag refinement (filter chips) — distinct from the free-text match
|
|
// above, which only checks whether the query substring appears in a tag.
|
|
const tagsRaw = searchParams.get("tags");
|
|
const tagFilters = tagsRaw
|
|
? tagsRaw.split(",").map((s) => s.trim()).filter(Boolean).slice(0, 5)
|
|
: [];
|
|
for (const tag of tagFilters) {
|
|
conditions.push(sql`${recipes.tags} @> ARRAY[${tag}]::text[]`);
|
|
}
|
|
|
|
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,
|
|
visibility: recipes.visibility,
|
|
tags: recipes.tags,
|
|
isBatchCook: recipes.isBatchCook,
|
|
sourceUrl: recipes.sourceUrl,
|
|
recipeType: recipes.recipeType,
|
|
coverIcon: recipes.coverIcon,
|
|
coverColor: recipes.coverColor,
|
|
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<number>`count(*)::int` })
|
|
.from(recipes)
|
|
.innerJoin(users, eq(recipes.authorId, users.id))
|
|
.where(where);
|
|
|
|
const total = countResult[0]?.total ?? 0;
|
|
|
|
const session = await getOptionalSession();
|
|
const [ratingByRecipe, rowsWithExtras] = await Promise.all([
|
|
getAvgRatingsByRecipeId(rows.map((r) => r.id)),
|
|
attachCardExtras(rows, session?.user.id),
|
|
]);
|
|
const data = rowsWithExtras.map((r) => ({
|
|
...r,
|
|
avgRating: ratingByRecipe.get(r.id)?.avgRating ?? null,
|
|
ratingCount: ratingByRecipe.get(r.id)?.ratingCount ?? 0,
|
|
}));
|
|
|
|
return NextResponse.json({ data, total, limit, offset });
|
|
}
|