diff --git a/apps/web/package.json b/apps/web/package.json index 1143268..3f142f6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,7 @@ "clean": "rm -rf .next .turbo" }, "dependencies": { + "@tpmjs/db": "workspace:*", "@tpmjs/env": "workspace:*", "@tpmjs/types": "workspace:*", "@tpmjs/ui": "workspace:*", diff --git a/apps/web/src/app/api/stats/route.ts b/apps/web/src/app/api/stats/route.ts new file mode 100644 index 0000000..e2dccc5 --- /dev/null +++ b/apps/web/src/app/api/stats/route.ts @@ -0,0 +1,90 @@ +import { prisma } from '@tpmjs/db'; +import { NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * GET /api/stats + * Get aggregated statistics about tools in the registry + * + * Returns: + * - totalTools: Total number of tools + * - officialTools: Number of official tools (with tpmjs-tool keyword) + * - categories: Breakdown by category with counts + * - recentTools: Count of tools added in last 7 days + * - totalDownloads: Sum of all npm downloads + */ +export async function GET() { + try { + // Run all aggregations in parallel + const [totalTools, officialTools, categoryStats, recentCount, downloadSum] = await Promise.all([ + // Total tools count + prisma.tool.count(), + + // Official tools count + prisma.tool.count({ + where: { isOfficial: true }, + }), + + // Group by category + prisma.tool.groupBy({ + by: ['category'], + _count: { + id: true, + }, + orderBy: { + _count: { + id: 'desc', + }, + }, + }), + + // Recent tools (last 7 days) + prisma.tool.count({ + where: { + createdAt: { + gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + }, + }, + }), + + // Sum of all downloads + prisma.tool.aggregate({ + _sum: { + npmDownloadsLastMonth: true, + }, + }), + ]); + + // Format category stats + const categories = categoryStats.reduce>((acc, stat) => { + if (stat.category) { + acc[stat.category] = stat._count.id; + } + return acc; + }, {}); + + return NextResponse.json({ + success: true, + data: { + totalTools, + officialTools, + categories, + recentTools: recentCount, + totalDownloads: downloadSum._sum.npmDownloadsLastMonth || 0, + }, + }); + } catch (error) { + console.error('Error fetching stats:', error); + + return NextResponse.json( + { + success: false, + error: 'Failed to fetch stats', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/tools/[id]/route.ts b/apps/web/src/app/api/tools/[id]/route.ts new file mode 100644 index 0000000..858d87a --- /dev/null +++ b/apps/web/src/app/api/tools/[id]/route.ts @@ -0,0 +1,62 @@ +import { prisma } from '@tpmjs/db'; +import { type NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * GET /api/tools/[id] + * Get tool details by ID or package name + * + * Params: + * - id: Tool ID (number) or NPM package name (string) + */ +export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + + if (!id) { + return NextResponse.json( + { + success: false, + error: 'Missing ID parameter', + }, + { status: 400 } + ); + } + + // Try to find by ID first (cuid), then by package name + const tool = await prisma.tool.findFirst({ + where: { + OR: [{ id }, { npmPackageName: id }], + }, + }); + + if (!tool) { + return NextResponse.json( + { + success: false, + error: 'Tool not found', + message: `No tool found with ID or package name: ${id}`, + }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + data: tool, + }); + } catch (error) { + console.error('Error fetching tool details:', error); + + return NextResponse.json( + { + success: false, + error: 'Failed to fetch tool details', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/tools/route.ts b/apps/web/src/app/api/tools/route.ts new file mode 100644 index 0000000..d22892d --- /dev/null +++ b/apps/web/src/app/api/tools/route.ts @@ -0,0 +1,106 @@ +import { type Prisma, prisma } from '@tpmjs/db'; +import { type NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * GET /api/tools + * Search and list tools with filtering, sorting, and pagination + * + * Query params: + * - q: Search query (searches name, description, tags) + * - category: Filter by category + * - official: Filter by official status (true/false) + * - limit: Results per page (default: 20, max: 100) + * - offset: Pagination offset (default: 0) + */ +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + + // Parse query parameters + const query = searchParams.get('q'); + const category = searchParams.get('category'); + const officialParam = searchParams.get('official'); + const limitParam = searchParams.get('limit'); + const offsetParam = searchParams.get('offset'); + + // Validate and set defaults + const limit = Math.min( + Number.parseInt(limitParam || '20', 10), + 100 // Max 100 results per page + ); + const offset = Math.max(Number.parseInt(offsetParam || '0', 10), 0); + + // Build where clause + const where: Prisma.ToolWhereInput = {}; + + // Search filter (case-insensitive partial match) + if (query) { + where.OR = [ + { npmPackageName: { contains: query, mode: 'insensitive' } }, + { description: { contains: query, mode: 'insensitive' } }, + { + tags: { + hasSome: [query], + }, + }, + ]; + } + + // Category filter + if (category) { + where.category = category; + } + + // Official filter + if (officialParam !== null) { + where.isOfficial = officialParam === 'true'; + } + + // Execute query with pagination + const [tools, totalCount] = await Promise.all([ + prisma.tool.findMany({ + where, + orderBy: [ + { qualityScore: 'desc' }, + { npmDownloadsLastMonth: 'desc' }, + { createdAt: 'desc' }, + ], + take: limit, + skip: offset, + }), + prisma.tool.count({ where }), + ]); + + // Calculate pagination metadata + const hasMore = offset + limit < totalCount; + const totalPages = Math.ceil(totalCount / limit); + const currentPage = Math.floor(offset / limit) + 1; + + return NextResponse.json({ + success: true, + data: tools, + pagination: { + total: totalCount, + limit, + offset, + hasMore, + totalPages, + currentPage, + }, + }); + } catch (error) { + console.error('Error fetching tools:', error); + + return NextResponse.json( + { + success: false, + error: 'Failed to fetch tools', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/tools/validate/route.ts b/apps/web/src/app/api/tools/validate/route.ts new file mode 100644 index 0000000..9b101b2 --- /dev/null +++ b/apps/web/src/app/api/tools/validate/route.ts @@ -0,0 +1,72 @@ +import { validateTpmjsField } from '@tpmjs/types/tpmjs'; +import { type NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; + +/** + * POST /api/tools/validate + * Validate a tpmjs field and determine its tier + * + * Body: JSON object representing the tpmjs field + * + * Returns: + * - valid: boolean indicating if the field is valid + * - tier: 'minimal' | 'rich' | null + * - data: validated data if valid + * - errors: validation errors if invalid + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Validate the tpmjs field + const result = validateTpmjsField(body); + + // Format errors for better readability + if (!result.valid && result.errors) { + return NextResponse.json( + { + success: false, + valid: false, + tier: null, + errors: result.errors.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + code: issue.code, + })), + }, + { status: 400 } + ); + } + + return NextResponse.json({ + success: true, + valid: result.valid, + tier: result.tier, + data: result.data, + }); + } catch (error) { + console.error('Error validating tpmjs field:', error); + + // Handle JSON parsing errors + if (error instanceof SyntaxError) { + return NextResponse.json( + { + success: false, + error: 'Invalid JSON', + message: 'The request body must be valid JSON', + }, + { status: 400 } + ); + } + + return NextResponse.json( + { + success: false, + error: 'Validation failed', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +}