- Surface AI review scores across skill pages, browse, and search - Add dedicated /reviewed page with score filtering - Add recommended sort using Meilisearch relevance + AI scores - Fix search sort defaulting to stars instead of recommended - Fix CLI TypeScript null check for aiScore in search command - Adjust cache TTL for reviewed content Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
281 lines
9.2 KiB
TypeScript
281 lines
9.2 KiB
TypeScript
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<string, 'stars' | 'downloads' | 'rating' | 'updated' | 'lastDownloaded' | 'aiScore' | 'recommended'> = {
|
|
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 }
|
|
);
|
|
}
|
|
}
|