import { NextResponse, type NextRequest } from 'next/server'; import { createDb, skillQueries, type skills, isMeilisearchHealthy, searchSkills as meilisearchSearch } from '@skillhub/db'; import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit'; import { getCached, setCache, hashSearchParams, cacheKeys } from '@/lib/cache'; import { captureException, log } from '@/lib/sentry'; // Create database connection const db = createDb(); type Skill = typeof skills.$inferSelect; /** * Restore skill ID from Meilisearch format * Converts sanitized IDs back to original format: * "anthropics__skills__pdf" -> "anthropics/skills/pdf" * "bdmorin___dot_claude__git" -> "bdmorin/.claude/git" * "user__repo_dot_name__skill" -> "user/repo.name/skill" */ function restoreIdFromMeili(meiliId: string): string { return meiliId .replace(/_dot_/g, '.') // _dot_ -> dot (do this FIRST) .replace(/__/g, '/'); // double underscore -> slash } export async function GET(request: NextRequest) { // Apply rate limiting (search is more expensive, use lower limit) const rateLimitResult = await withRateLimit(request, 'search'); if (!rateLimitResult.allowed) { return createRateLimitResponse(rateLimitResult); } try { const searchParams = request.nextUrl.searchParams; const query = searchParams.get('q') || undefined; const platform = searchParams.get('platform') || undefined; const category = searchParams.get('category') || undefined; const format = searchParams.get('format') || 'skill.md'; const verified = searchParams.get('verified') === 'true'; // Parse and validate numeric parameters const minStarsRaw = parseInt(searchParams.get('minStars') || '0'); const minStars = isNaN(minStarsRaw) || minStarsRaw < 0 ? 0 : minStarsRaw; const sort = searchParams.get('sort') || 'stars'; const pageRaw = parseInt(searchParams.get('page') || '1'); const page = isNaN(pageRaw) || pageRaw < 1 ? 1 : pageRaw; const limitRaw = parseInt(searchParams.get('limit') || '20'); const limit = isNaN(limitRaw) || limitRaw < 1 ? 20 : Math.min(limitRaw, 100); // Max 100 per page const offset = (page - 1) * limit; // Create cache key from search parameters const searchHash = hashSearchParams({ q: query, category, platform, format, verified: verified ? 'true' : undefined, sort, page, limit, minStars: minStars > 0 ? minStars : undefined, }); const cacheKey = cacheKeys.searchSkills(searchHash); // Check cache first const cached = await getCached<{ skills: Skill[]; total: number; searchEngine: string; }>(cacheKey); if (cached) { return NextResponse.json( { skills: cached.skills, pagination: { page, limit, total: cached.total, totalPages: Math.ceil(cached.total / limit), }, searchEngine: 'cache', cachedFrom: cached.searchEngine, }, { headers: createRateLimitHeaders(rateLimitResult), } ); } // Try Meilisearch for text search queries if (query) { const useMeilisearch = await isMeilisearchHealthy(); if (useMeilisearch) { // For 'recommended' sort, fetch more results from Meilisearch (by relevance), // then post-process to boost ai-reviewed skills to the top const isRecommended = sort === 'recommended'; const meiliSort = isRecommended ? undefined : sort as 'stars' | 'downloads' | 'rating' | 'recent'; // Fetch extra results for recommended so we have enough after re-ranking const meiliLimit = isRecommended ? Math.min(limit * 3, 60) : limit; const meiliOffset = isRecommended ? 0 : offset; const meiliResult = await meilisearchSearch({ query, filters: { platforms: platform && platform !== 'all' ? [platform] : undefined, minStars: minStars > 0 ? minStars : undefined, verified: verified ? true : undefined, }, sort: meiliSort, limit: meiliLimit, offset: meiliOffset, }); if (meiliResult) { let skills = meiliResult.hits.map((hit) => ({ id: restoreIdFromMeili(hit.id), name: hit.name, description: hit.description, githubOwner: hit.githubOwner, githubRepo: hit.githubRepo, githubStars: hit.githubStars, downloadCount: hit.downloadCount, securityScore: hit.securityScore, securityStatus: hit.securityStatus || null, rating: hit.rating, ratingCount: null, // Not available in Meilisearch yet isVerified: hit.isVerified, compatibility: { platforms: hit.platforms }, reviewStatus: hit.reviewStatus || null, aiScore: hit.aiScore || null, })); // For recommended sort: boost ai-reviewed skills with score >= 75 to top, // preserving Meilisearch relevance order within each group if (isRecommended) { const reviewed = skills.filter(s => s.aiScore && s.aiScore >= 75 && (s.reviewStatus === 'ai-reviewed' || s.reviewStatus === 'verified') ); const rest = skills.filter(s => !(s.aiScore && s.aiScore >= 75 && (s.reviewStatus === 'ai-reviewed' || s.reviewStatus === 'verified')) ); // Sort reviewed by score desc (relevance already handled by position) reviewed.sort((a, b) => (b.aiScore || 0) - (a.aiScore || 0)); skills = [...reviewed, ...rest].slice(offset, offset + limit); } // Cache the result (5 minutes TTL) await setCache( cacheKey, { skills, total: meiliResult.estimatedTotalHits, searchEngine: 'meilisearch', }, 5 * 60 ); return NextResponse.json({ skills, pagination: { page, limit, total: meiliResult.estimatedTotalHits, totalPages: Math.ceil(meiliResult.estimatedTotalHits / limit), }, searchEngine: 'meilisearch', processingTimeMs: meiliResult.processingTimeMs, }, { headers: createRateLimitHeaders(rateLimitResult), }); } // If Meilisearch search failed, fall through to PostgreSQL } } // Fall back to PostgreSQL search // Map sort parameter to database column const sortByMap: Record = { stars: 'stars', downloads: 'downloads', rating: 'rating', recent: 'updated', lastDownloaded: 'lastDownloaded', aiScore: 'aiScore', recommended: 'recommended', security: 'stars', // Use stars as fallback }; const sortBy = sortByMap[sort] || 'downloads'; // Build filter options for database query const filterOptions = { query, category: category || undefined, platform: platform && platform !== 'all' ? platform : undefined, sourceFormat: format, minStars, verified: verified || undefined, }; // Get paginated results directly from database (no in-memory filtering) const paginatedResults = await skillQueries.search(db, { ...filterOptions, limit, offset, sortBy, sortOrder: 'desc', }); // Get total count for pagination const total = await skillQueries.count(db, filterOptions); const skills = paginatedResults.map((skill: Skill) => ({ id: skill.id, name: skill.name, description: skill.description, githubOwner: skill.githubOwner, githubRepo: skill.githubRepo, skillPath: skill.skillPath, version: skill.version, license: skill.license, githubStars: skill.githubStars, downloadCount: skill.downloadCount, securityScore: skill.securityScore, securityStatus: skill.securityStatus, rating: skill.rating, ratingCount: skill.ratingCount, isVerified: skill.isVerified, compatibility: skill.compatibility, updatedAt: skill.updatedAt, reviewStatus: skill.reviewStatus, aiScore: skill.latestAiScore, })); // Cache the result (5 minutes TTL) await setCache( cacheKey, { skills, total, searchEngine: 'postgresql', }, 5 * 60 ); return NextResponse.json({ skills, pagination: { page, limit, total, totalPages: Math.ceil(total / limit), }, searchEngine: 'postgresql', }, { headers: createRateLimitHeaders(rateLimitResult), }); } catch (error) { // Log and report error to Sentry log.error('Error fetching skills', { error: error instanceof Error ? error.message : String(error), }); captureException(error, { tags: { route: '/api/skills' }, extra: { searchParams: Object.fromEntries(request.nextUrl.searchParams) }, }); return NextResponse.json( { error: 'Failed to fetch skills' }, { status: 500 } ); } }