From 798ac7958d592f928b6bc749efaf4d480d2ec32b Mon Sep 17 00:00:00 2001 From: Ezeh Date: Mon, 1 Jun 2026 14:23:55 +0100 Subject: [PATCH] feat(backend): add combined search and discovery endpoint --- app/app/api/discovery/route.ts | 466 ++++++++++++++++++++++++++++++++ app/tests/api/discovery.test.ts | 240 ++++++++++++++++ 2 files changed, 706 insertions(+) create mode 100644 app/app/api/discovery/route.ts create mode 100644 app/tests/api/discovery.test.ts diff --git a/app/app/api/discovery/route.ts b/app/app/api/discovery/route.ts new file mode 100644 index 0000000..21ac7bc --- /dev/null +++ b/app/app/api/discovery/route.ts @@ -0,0 +1,466 @@ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { prisma } from "@/lib/prisma"; +import { NextRequest } from "next/server"; +import { Prisma } from "@prisma/client"; + +type DiscoveryResultType = "post" | "user" | "topic"; +type DiscoveryRankBy = "relevance" | "recent" | "popular"; + +type DiscoveryResult = { + id: string; + type: DiscoveryResultType; + score: number; + title: string; + subtitle: string | null; + href: string; + imageUrl: string | null; + meta: Record; +}; + +const DEFAULT_LIMIT = 10; +const MAX_LIMIT = 30; +const TOPIC_SCAN_LIMIT = 100; + +function parseRequestedTypes(rawTypes: string | null): DiscoveryResultType[] { + const allowed: DiscoveryResultType[] = ["post", "user", "topic"]; + if (!rawTypes) return allowed; + + const parsed = rawTypes + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter((value): value is DiscoveryResultType => + allowed.includes(value as DiscoveryResultType), + ); + + return parsed.length > 0 ? parsed : allowed; +} + +function normalizeQuery(query: string | null): string | null { + const trimmed = query?.trim(); + return trimmed ? trimmed : null; +} + +function computeTextScore(query: string | null, values: Array) { + if (!query) return 0; + + const loweredQuery = query.toLowerCase(); + let score = 0; + + for (const value of values) { + if (!value) continue; + + const loweredValue = value.toLowerCase(); + + if (loweredValue === loweredQuery) { + score += 120; + continue; + } + + if (loweredValue.startsWith(loweredQuery)) { + score += 80; + } + + if (loweredValue.includes(loweredQuery)) { + score += 40; + } + } + + return score; +} + +function rankResults(results: DiscoveryResult[], rankBy: DiscoveryRankBy) { + return [...results].sort((a, b) => { + if (rankBy === "recent") { + const aCreatedAt = new Date(String(a.meta.createdAt || 0)).getTime(); + const bCreatedAt = new Date(String(b.meta.createdAt || 0)).getTime(); + + if (bCreatedAt !== aCreatedAt) { + return bCreatedAt - aCreatedAt; + } + } + + if (rankBy === "popular") { + const aPopularity = Number(a.meta.popularity ?? a.score); + const bPopularity = Number(b.meta.popularity ?? b.score); + + if (bPopularity !== aPopularity) { + return bPopularity - aPopularity; + } + } + + if (b.score !== a.score) { + return b.score - a.score; + } + + return a.title.localeCompare(b.title); + }); +} + +async function searchPosts(args: { + query: string | null; + limit: number; + postType: string | null; + postStatus: string | null; + rankBy: DiscoveryRankBy; +}) { + const { query, limit, postType, postStatus, rankBy } = args; + + const where: Prisma.PostWhereInput = {}; + + if (query) { + where.OR = [ + { title: { contains: query, mode: "insensitive" } }, + { description: { contains: query, mode: "insensitive" } }, + { category: { equals: query.toLowerCase() as any } }, + ]; + } + + if (postType) { + where.type = postType as any; + } + + if (postStatus) { + where.status = postStatus as any; + } + + const orderBy: Prisma.PostOrderByWithRelationInput[] = + rankBy === "popular" + ? [{ interactions: { _count: "desc" } } as any, { createdAt: "desc" }] + : [{ createdAt: "desc" }]; + + const posts = await prisma.post.findMany({ + where, + take: limit, + orderBy, + include: { + user: { + select: { + id: true, + name: true, + username: true, + avatarUrl: true, + }, + }, + _count: { + select: { + interactions: true, + comments: true, + entries: true, + }, + }, + }, + }); + + return posts.map((post) => { + const popularity = + post._count.interactions * 3 + post._count.comments * 2 + post._count.entries; + const score = + computeTextScore(query, [post.title, post.description, post.category]) + + popularity; + + return { + id: post.id, + type: "post", + score, + title: post.title, + subtitle: post.description || null, + href: `/post/${post.id}`, + imageUrl: post.user.avatarUrl || null, + meta: { + postType: post.type, + category: post.category, + status: post.status, + creatorId: post.user.id, + creatorName: post.user.name, + creatorUsername: post.user.username, + interactions: post._count.interactions, + comments: post._count.comments, + entries: post._count.entries, + popularity, + createdAt: post.createdAt.toISOString(), + endsAt: post.endsAt.toISOString(), + }, + }; + }); +} + +async function searchUsers(args: { + query: string | null; + limit: number; + period: string; + rankBy: DiscoveryRankBy; +}) { + const { query, limit, period, rankBy } = args; + + let dateFilter: Date | undefined; + if (period === "weekly") { + dateFilter = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + } else if (period === "monthly") { + dateFilter = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + } + + const where: Prisma.UserWhereInput = query + ? { + OR: [ + { name: { contains: query, mode: "insensitive" } }, + { username: { contains: query, mode: "insensitive" } }, + { bio: { contains: query, mode: "insensitive" } }, + ], + } + : {}; + + const orderBy = + rankBy === "recent" + ? [{ createdAt: "desc" as const }] + : [{ xp: "desc" as const }, { createdAt: "desc" as const }]; + + const users = await prisma.user.findMany({ + where, + take: limit, + orderBy, + select: { + id: true, + name: true, + username: true, + bio: true, + avatarUrl: true, + xp: true, + createdAt: true, + rank: true, + badges: { + include: { badge: true }, + }, + _count: { + select: { + posts: dateFilter ? { where: { createdAt: { gte: dateFilter } } } : true, + entries: dateFilter + ? { where: { createdAt: { gte: dateFilter } } } + : true, + followers: true, + }, + }, + }, + }); + + return users.map((user) => { + const totalContributions = user._count.posts + user._count.entries; + const popularity = user.xp + totalContributions * 5 + user._count.followers * 3; + const score = + computeTextScore(query, [user.name, user.username, user.bio]) + popularity; + + return { + id: user.id, + type: "user", + score, + title: user.name, + subtitle: user.username ? `@${user.username}` : user.bio || null, + href: `/profile/${user.id}`, + imageUrl: user.avatarUrl || null, + meta: { + username: user.username, + bio: user.bio, + xp: user.xp, + rank: user.rank, + badgeCount: user.badges.length, + badges: user.badges.map((userBadge) => userBadge.badge), + postCount: user._count.posts, + entryCount: user._count.entries, + followerCount: user._count.followers, + totalContributions, + popularity, + createdAt: user.createdAt.toISOString(), + }, + }; + }); +} + +async function searchTopics(args: { + query: string | null; + rankBy: DiscoveryRankBy; +}) { + const { query } = args; + + const posts = await prisma.post.findMany({ + where: { + category: { not: null }, + ...(query + ? { + OR: [ + { title: { contains: query, mode: "insensitive" } }, + { description: { contains: query, mode: "insensitive" } }, + { category: { equals: query.toLowerCase() as any } }, + ], + } + : {}), + }, + take: TOPIC_SCAN_LIMIT, + orderBy: [{ createdAt: "desc" }], + select: { + id: true, + title: true, + category: true, + createdAt: true, + _count: { + select: { + interactions: true, + comments: true, + entries: true, + }, + }, + }, + }); + + const topicMap = new Map< + string, + { + label: string; + postCount: number; + engagement: number; + latestCreatedAt: Date; + sampleTitles: string[]; + score: number; + } + >(); + + for (const post of posts) { + if (!post.category) continue; + + const existing = topicMap.get(post.category) || { + label: post.category, + postCount: 0, + engagement: 0, + latestCreatedAt: post.createdAt, + sampleTitles: [], + score: 0, + }; + + existing.postCount += 1; + existing.engagement += + post._count.interactions * 3 + post._count.comments * 2 + post._count.entries; + existing.latestCreatedAt = + existing.latestCreatedAt > post.createdAt ? existing.latestCreatedAt : post.createdAt; + + if (existing.sampleTitles.length < 3) { + existing.sampleTitles.push(post.title); + } + + existing.score = + computeTextScore(query, [post.category, post.title]) + + existing.engagement + + existing.postCount * 10; + + topicMap.set(post.category, existing); + } + + return [...topicMap.entries()].map(([category, topic]) => ({ + id: `topic:${category}`, + type: "topic", + score: topic.score, + title: category.replace(/_/g, " "), + subtitle: + topic.sampleTitles.length > 0 ? topic.sampleTitles.join(" • ") : null, + href: `/feed?category=${encodeURIComponent(category)}`, + imageUrl: null, + meta: { + category, + postCount: topic.postCount, + engagement: topic.engagement, + popularity: topic.engagement + topic.postCount * 10, + createdAt: topic.latestCreatedAt.toISOString(), + }, + })); +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const query = normalizeQuery(searchParams.get("q")); + const page = parseInt(searchParams.get("page") || "1", 10); + const limit = parseInt(searchParams.get("limit") || String(DEFAULT_LIMIT), 10); + const rankBy = (searchParams.get("rankBy") || "relevance") as DiscoveryRankBy; + const period = searchParams.get("period") || "all-time"; + const postType = searchParams.get("postType"); + const postStatus = searchParams.get("status"); + const requestedTypes = parseRequestedTypes(searchParams.get("types")); + + if (page < 1 || limit < 1 || limit > MAX_LIMIT) { + return apiError("Invalid pagination parameters", 400); + } + + if (!["relevance", "recent", "popular"].includes(rankBy)) { + return apiError("Invalid ranking option", 400); + } + + if (!["all-time", "weekly", "monthly"].includes(period)) { + return apiError("Invalid leaderboard period", 400); + } + + const fetchLimit = Math.min(limit * page * 3, 90); + const tasks: Array> = []; + + if (requestedTypes.includes("post")) { + tasks.push( + searchPosts({ + query, + limit: fetchLimit, + postType, + postStatus, + rankBy, + }), + ); + } + + if (requestedTypes.includes("user")) { + tasks.push( + searchUsers({ + query, + limit: fetchLimit, + period, + rankBy, + }), + ); + } + + if (requestedTypes.includes("topic")) { + tasks.push( + searchTopics({ + query, + rankBy, + }), + ); + } + + const resultSets = await Promise.all(tasks); + const combinedResults = rankResults(resultSets.flat(), rankBy); + const total = combinedResults.length; + const paginatedResults = combinedResults.slice((page - 1) * limit, page * limit); + + const counts = combinedResults.reduce( + (acc, result) => { + acc[result.type] += 1; + return acc; + }, + { post: 0, user: 0, topic: 0 }, + ); + + return apiSuccess({ + query, + results: paginatedResults, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + hasMore: page * limit < total, + }, + ranking: { + rankBy, + period, + }, + counts, + }); + } catch (error) { + console.error("Discovery API error:", error); + return apiError("Failed to fetch discovery results", 500); + } +} diff --git a/app/tests/api/discovery.test.ts b/app/tests/api/discovery.test.ts new file mode 100644 index 0000000..e62764c --- /dev/null +++ b/app/tests/api/discovery.test.ts @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { GET } from "@/app/api/discovery/route"; +import { createMockRequest, parseResponse } from "../helpers/api"; +import { prisma } from "@/lib/prisma"; + +describe("Discovery API", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns combined normalized results from posts, users, and topics", async () => { + prisma.post.findMany = vi + .fn() + .mockResolvedValueOnce([ + { + id: "post_1", + title: "Help with books for school", + description: "Need book donations for the new term.", + category: "books", + type: "request", + status: "open", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + endsAt: new Date("2026-01-05T00:00:00.000Z"), + user: { + id: "user_1", + name: "Alice", + username: "alice", + avatarUrl: "/alice.png", + }, + _count: { + interactions: 4, + comments: 2, + entries: 1, + }, + }, + ]) + .mockResolvedValueOnce([ + { + id: "post_1", + title: "Help with books for school", + category: "books", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + _count: { + interactions: 4, + comments: 2, + entries: 1, + }, + }, + ]); + + prisma.user.findMany = vi.fn().mockResolvedValue([ + { + id: "user_1", + name: "Alice Johnson", + username: "alice", + bio: "Community helper", + avatarUrl: "/alice.png", + xp: 200, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + rank: { id: "gold", title: "Gold", level: 3 }, + badges: [], + _count: { + posts: 3, + entries: 5, + followers: 7, + }, + }, + ]); + + const request = createMockRequest( + "http://localhost:3000/api/discovery?q=books&page=1&limit=10", + ); + const response = await GET(request); + const { status, data } = await parseResponse(response); + + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.data.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "post_1", + type: "post", + title: "Help with books for school", + href: "/post/post_1", + }), + expect.objectContaining({ + id: "user_1", + type: "user", + title: "Alice Johnson", + href: "/profile/user_1", + }), + expect.objectContaining({ + id: "topic:books", + type: "topic", + title: "books", + href: "/feed?category=books", + }), + ]), + ); + expect(data.data.pagination).toMatchObject({ + page: 1, + limit: 10, + total: 3, + totalPages: 1, + hasMore: false, + }); + }); + + it("supports ranking and pagination in the combined payload", async () => { + prisma.post.findMany = vi + .fn() + .mockResolvedValueOnce([ + { + id: "post_high", + title: "Popular books post", + description: "High engagement", + category: "books", + type: "giveaway", + status: "open", + createdAt: new Date("2026-01-03T00:00:00.000Z"), + endsAt: new Date("2026-01-10T00:00:00.000Z"), + user: { + id: "user_2", + name: "Bob", + username: "bob", + avatarUrl: null, + }, + _count: { + interactions: 20, + comments: 4, + entries: 10, + }, + }, + { + id: "post_low", + title: "Quiet books post", + description: "Low engagement", + category: "books", + type: "giveaway", + status: "open", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + endsAt: new Date("2026-01-08T00:00:00.000Z"), + user: { + id: "user_3", + name: "Caro", + username: "caro", + avatarUrl: null, + }, + _count: { + interactions: 1, + comments: 0, + entries: 0, + }, + }, + ]) + .mockResolvedValueOnce([ + { + id: "topic-seed-1", + title: "Popular books post", + category: "books", + createdAt: new Date("2026-01-03T00:00:00.000Z"), + _count: { + interactions: 20, + comments: 4, + entries: 10, + }, + }, + { + id: "topic-seed-2", + title: "Quiet books post", + category: "books", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + _count: { + interactions: 1, + comments: 0, + entries: 0, + }, + }, + ]); + + prisma.user.findMany = vi.fn().mockResolvedValue([ + { + id: "user_mid", + name: "Mid User", + username: "mid", + bio: "steady helper", + avatarUrl: null, + xp: 50, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + rank: null, + badges: [], + _count: { + posts: 2, + entries: 2, + followers: 1, + }, + }, + ]); + + const request = createMockRequest( + "http://localhost:3000/api/discovery?q=books&rankBy=popular&page=1&limit=2", + ); + const response = await GET(request); + const { status, data } = await parseResponse(response); + + expect(status).toBe(200); + expect(data.data.results).toHaveLength(2); + expect(data.data.results[0]).toMatchObject({ + id: "topic:books", + type: "topic", + }); + expect(data.data.results[1]).toMatchObject({ + id: "post_high", + type: "post", + }); + expect(data.data.pagination).toMatchObject({ + page: 1, + limit: 2, + total: 4, + totalPages: 2, + hasMore: true, + }); + expect(data.data.ranking).toMatchObject({ + rankBy: "popular", + period: "all-time", + }); + }); + + it("validates ranking and pagination parameters", async () => { + const request = createMockRequest( + "http://localhost:3000/api/discovery?rankBy=unknown&page=0&limit=99", + ); + const response = await GET(request); + const { status, data } = await parseResponse(response); + + expect(status).toBe(400); + expect(data.success).toBe(false); + }); +});