import { NextRequest, NextResponse } from "next/server"; import { db, recipes, users, favorites, ratings, userFollows, eq, and, or, ne, gte, notInArray, inArray, desc, isNotNull } from "@epicure/db"; import { requireSessionOrApiKey } from "@/lib/api-auth"; import { buildPreferenceMap, rankForYou } from "@/lib/for-you-ranking"; import { attachCardExtras } from "@/lib/recipe-card-extras"; export async function GET(req: NextRequest) { const { session, response } = await requireSessionOrApiKey(req); if (response) return response; const userId = session!.user.id; const { searchParams } = new URL(req.url); const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 50); const offsetRaw = parseInt(searchParams.get("offset") ?? "0"); const offset = Number.isNaN(offsetRaw) ? 0 : Math.max(0, offsetRaw); // Recipes the user has favorited, or rated 4+, define their taste profile. const [favoritedRows, highRatedRows] = await Promise.all([ db.select({ recipeId: favorites.recipeId }).from(favorites).where(eq(favorites.userId, userId)), db.select({ recipeId: ratings.recipeId }).from(ratings).where(and(eq(ratings.userId, userId), gte(ratings.score, 4))), ]); const likedIds = [...new Set([...favoritedRows.map((r) => r.recipeId), ...highRatedRows.map((r) => r.recipeId)])]; const likedRecipes = likedIds.length > 0 ? await db.select({ tags: recipes.tags, dietaryTags: recipes.dietaryTags }).from(recipes).where(inArray(recipes.id, likedIds)) : []; const preferences = buildPreferenceMap(likedRecipes); const excludeIds = likedIds.length > 0 ? likedIds : ["__none__"]; const candidates = await db .select({ id: recipes.id, title: recipes.title, description: recipes.description, baseServings: recipes.baseServings, prepMins: recipes.prepMins, cookMins: recipes.cookMins, difficulty: recipes.difficulty, visibility: recipes.visibility, aiGenerated: recipes.aiGenerated, createdAt: recipes.createdAt, authorId: recipes.authorId, authorName: users.name, authorUsername: users.username, authorAvatarUrl: users.avatarUrl, tags: recipes.tags, dietaryTags: recipes.dietaryTags, isBatchCook: recipes.isBatchCook, sourceUrl: recipes.sourceUrl, recipeType: recipes.recipeType, coverIcon: recipes.coverIcon, coverColor: recipes.coverColor, }) .from(recipes) .innerJoin(users, eq(recipes.authorId, users.id)) .leftJoin( userFollows, and(eq(userFollows.followingId, recipes.authorId), eq(userFollows.followerId, userId)) ) .where(and( eq(recipes.visibility, "public"), ne(recipes.authorId, userId), notInArray(recipes.id, excludeIds), // Private authors are excluded from discovery unless the viewer already follows them. or(eq(users.isPrivate, false), isNotNull(userFollows.followerId)) )) .orderBy(desc(recipes.createdAt)) .limit(200); // score a bounded recent window rather than the whole table const ranked = preferences.size > 0 ? rankForYou(candidates, preferences) : [...candidates].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); const page = ranked.slice(offset, offset + limit).map(({ dietaryTags: _dietaryTags, ...r }) => ({ ...r, createdAt: r.createdAt.toISOString(), })); const data = await attachCardExtras(page, userId); // `ranked` is capped to a bounded recent window (see `candidates` query above), so this // total reflects the size of that ranked window rather than every eligible recipe. return NextResponse.json({ data, total: ranked.length, limit, offset }); }