Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 207 additions & 27 deletions apps/marketplace/app/api/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import { NextRequest, NextResponse } from "next/server";
import { supabase } from "@/lib/supabase";
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";

type SearchType = "component" | "profile";
const VALID_SCOPES = new Set(["component", "profile"]);
const VALID_COMPONENT_TYPES = new Set([
"skill", "agent", "hook", "script", "knowledge", "rules", "plugin",
]);

export async function GET(request: NextRequest) {
const startTime = performance.now();

const { searchParams } = request.nextUrl;
const query = searchParams.get("q") ?? "";
const type = (searchParams.get("type") as SearchType) ?? "component";

if (!query) {
return NextResponse.json(
Expand All @@ -16,6 +20,59 @@ export async function GET(request: NextRequest) {
);
}

if (query.length > 200) {
return NextResponse.json(
{ error: "Query too long (max 200 characters)" },
{ status: 400 },
);
}

const scopeParam = searchParams.get("scope") ?? "component";
if (!VALID_SCOPES.has(scopeParam)) {
return NextResponse.json(
{ error: "Invalid scope. Must be 'component' or 'profile'" },
{ status: 400 },
);
}
const scope = scopeParam as "component" | "profile";

// Pagination parameters
const page = parseInt(searchParams.get("page") ?? "1", 10);
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20", 10), 100);

if (page < 1) {
return NextResponse.json(
{ error: "Page must be >= 1" },
{ status: 400 },
);
}

// Filter parameters — validated before use
const componentTypeParam = searchParams.get("type");
if (componentTypeParam !== null && !VALID_COMPONENT_TYPES.has(componentTypeParam)) {
return NextResponse.json(
{ error: `Invalid type filter. Must be one of: ${[...VALID_COMPONENT_TYPES].join(", ")}` },
{ status: 400 },
);
}
const componentType = componentTypeParam as string | null;

const category = searchParams.get("category");

// Parse and validate rating at the boundary
let minRating: number | null = null;
const ratingParam = searchParams.get("rating");
if (ratingParam !== null) {
const parsed = parseFloat(ratingParam);
if (isNaN(parsed) || parsed < 1 || parsed > 5) {
return NextResponse.json(
{ error: "Rating must be a number between 1 and 5" },
{ status: 400 },
);
}
minRating = parsed;
}

// 60 searches per minute per IP
const ip = getClientIp(request);
const { allowed, retryAfter } = checkRateLimit(`search:${ip}`, {
Expand Down Expand Up @@ -44,14 +101,15 @@ export async function GET(request: NextRequest) {
}

const tsquery = sanitizedTokens.join(" & ");
const offset = (page - 1) * limit;

if (type === "profile") {
if (scope === "profile") {
const { data, error } = await supabase
.from("profiles")
.select("*")
.textSearch("fts", tsquery)
.order("name", { ascending: true })
.limit(20);
.range(offset, offset + limit); // fetch limit+1 to detect hasMore

if (error) {
// Fallback to ilike if FTS fails — use parameterized filters
Expand All @@ -60,45 +118,167 @@ export async function GET(request: NextRequest) {
.select("*")
.or(`name.ilike.%${query.replace(/[,().%_\\]/g, "")}%,description.ilike.%${query.replace(/[,().%_\\]/g, "")}%`)
.order("name", { ascending: true })
.limit(20);
.range(offset, offset + limit); // fetch limit+1 to detect hasMore

if (fallbackError) {
return NextResponse.json(
{ error: `Search failed: ${fallbackError.message}` },
{ status: 500 },
);
console.error("[search] profile fallback failed", fallbackError);
return NextResponse.json({ error: "Search failed" }, { status: 500 });
}
return NextResponse.json({ type: "profile", results: fallback ?? [] });

const results = fallback ?? [];
return jsonResponse(startTime, {
type: "profile",
results: results.slice(0, limit),
pagination: { page, limit, hasMore: results.length > limit },
});
}

return NextResponse.json({ type: "profile", results: data ?? [] });
const results = data ?? [];
return jsonResponse(startTime, {
type: "profile",
results: results.slice(0, limit),
pagination: { page, limit, hasMore: results.length > limit },
});
}

// Default: search components using full-text search
const { data, error } = await supabase
// Default: search components using full-text search with filters
try {
const results = await searchComponents(tsquery, query, componentType, category, minRating, page, limit);
return jsonResponse(startTime, {
type: "component",
results: results.data,
pagination: { page, limit, hasMore: results.hasMore },
});
} catch (err) {
console.error("[search] component search failed", err);
return NextResponse.json({ error: "Search failed" }, { status: 500 });
}
}

function jsonResponse(startTime: number, body: object) {
const responseTime = ((performance.now() - startTime) / 1000).toFixed(3);
return NextResponse.json(body, {
headers: { "X-Response-Time": `${responseTime}s` },
});
}

async function searchComponents(
tsquery: string,
rawQuery: string,
componentType: string | null,
category: string | null,
minRating: number | null,
page: number,
limit: number,
): Promise<{ data: any[]; hasMore: boolean }> {
const offset = (page - 1) * limit;

// Step 1: Resolve category filter to component IDs (if specified)
let categoryComponentIds: string[] | null = null;
if (category) {
const { data: categoryData, error: categoryError } = await supabase
.from("component_categories")
.select("component_id, category:categories!inner(slug)")
.eq("categories.slug", category);

if (categoryError) throw categoryError;
categoryComponentIds = categoryData?.map((cc: any) => cc.component_id) ?? [];

if (categoryComponentIds.length === 0) {
return { data: [], hasMore: false };
}
}

// Step 2: Build and execute the FTS query
let componentQuery = supabase
.from("components")
.select("*")
.textSearch("fts", tsquery)
.textSearch("fts", tsquery);

if (componentType) {
componentQuery = componentQuery.eq("type", componentType);
}

if (categoryComponentIds) {
componentQuery = componentQuery.in("id", categoryComponentIds);
}

// When a rating filter is active, over-fetch to compensate for post-filter attrition
const fetchLimit = minRating !== null ? Math.max(limit * 3, 100) : limit + 1;

const { data: components, error } = await componentQuery
.order("install_count", { ascending: false })
.limit(20);
.range(offset, offset + fetchLimit - 1);

if (error) {
// Fallback to ilike if FTS fails — sanitize PostgREST filter metacharacters
const { data: fallback, error: fallbackError } = await supabase
// Fallback to ilike if FTS fails
let fallbackQuery = supabase
.from("components")
.select("*")
.or(`name.ilike.%${query.replace(/[,().%_\\]/g, "")}%,description.ilike.%${query.replace(/[,().%_\\]/g, "")}%`)
.order("install_count", { ascending: false })
.limit(20);
.or(`name.ilike.%${rawQuery.replace(/[,().%_\\]/g, "")}%,description.ilike.%${rawQuery.replace(/[,().%_\\]/g, "")}%`);

if (fallbackError) {
return NextResponse.json(
{ error: `Search failed: ${fallbackError.message}` },
{ status: 500 },
);
if (componentType) {
fallbackQuery = fallbackQuery.eq("type", componentType);
}
return NextResponse.json({ type: "component", results: fallback ?? [] });

if (categoryComponentIds) {
fallbackQuery = fallbackQuery.in("id", categoryComponentIds);
}

const { data: fallback, error: fallbackError } = await fallbackQuery
.order("install_count", { ascending: false })
.range(offset, offset + fetchLimit - 1);

if (fallbackError) throw fallbackError;

return applyRatingFilter(fallback ?? [], minRating, limit);
}

return NextResponse.json({ type: "component", results: data ?? [] });
return applyRatingFilter(components ?? [], minRating, limit);
}

async function applyRatingFilter(
components: any[],
minRating: number | null,
limit: number,
): Promise<{ data: any[]; hasMore: boolean }> {
if (minRating === null) {
const hasMore = components.length > limit;
return { data: components.slice(0, limit), hasMore };
}

const filtered = await filterByRating(components, minRating);
const hasMore = filtered.length > limit;
return { data: filtered.slice(0, limit), hasMore };
}

async function filterByRating(components: any[], minRating: number): Promise<any[]> {
if (components.length === 0) return [];

const componentIds = components.map(c => c.id);

const { data: ratings } = await supabase
.from("ratings")
.select("component_id, rating")
.in("component_id", componentIds);

if (!ratings || ratings.length === 0) {
return [];
}

// Compute average rating per component
const ratingSum = new Map<string, number>();
const ratingCount = new Map<string, number>();

for (const r of ratings) {
ratingSum.set(r.component_id, (ratingSum.get(r.component_id) ?? 0) + r.rating);
ratingCount.set(r.component_id, (ratingCount.get(r.component_id) ?? 0) + 1);
}

return components.filter(component => {
const sum = ratingSum.get(component.id);
const count = ratingCount.get(component.id);
if (sum === undefined || count === undefined) return false;
return sum / count >= minRating;
});
}
Loading
Loading