From 596bba08324e6fabfc0d32e78d0bb0d898f182cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Mon, 10 Nov 2025 18:02:08 +0300 Subject: [PATCH] feat: add PostgreSQL database support and development scripts - Added pg and @types/pg dependencies for PostgreSQL integration - Created database testing and backend initialization scripts - Changed dev server port to 3002 to avoid conflicts --- .env.example | 17 ++ package.json | 10 +- pnpm-lock.yaml | 122 +++++++++++ scripts/init-backend.sh | 117 ++++++++++ scripts/test-db.js | 25 +++ src/app/api/packages/[id]/route.ts | 75 +++++++ src/app/api/packages/route.ts | 54 +++++ src/app/api/platforms/[id]/route.ts | 75 +++++++ src/app/api/platforms/route.ts | 29 +++ src/app/api/sync/route.ts | 53 +++++ src/lib/database/config.ts | 58 +++++ src/lib/database/schema.sql | 140 ++++++++++++ src/models/Package.ts | 86 ++++++++ src/models/Platform.ts | 21 ++ src/services/metadataFetcher.ts | 247 +++++++++++++++++++++ src/services/packageService.ts | 321 ++++++++++++++++++++++++++++ src/services/platformService.ts | 71 ++++++ 17 files changed, 1518 insertions(+), 3 deletions(-) create mode 100644 .env.example create mode 100755 scripts/init-backend.sh create mode 100644 scripts/test-db.js create mode 100644 src/app/api/packages/[id]/route.ts create mode 100644 src/app/api/packages/route.ts create mode 100644 src/app/api/platforms/[id]/route.ts create mode 100644 src/app/api/platforms/route.ts create mode 100644 src/app/api/sync/route.ts create mode 100644 src/lib/database/config.ts create mode 100644 src/lib/database/schema.sql create mode 100644 src/models/Package.ts create mode 100644 src/models/Platform.ts create mode 100644 src/services/metadataFetcher.ts create mode 100644 src/services/packageService.ts create mode 100644 src/services/platformService.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..66a256f --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Database Configuration +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=repohub +DB_USER=postgres +DB_PASSWORD=your_password_here + +# Next.js Configuration +NEXTAUTH_URL=http://localhost:3002 +NEXTAUTH_SECRET=your_secret_here + +# API Configuration +API_BASE_URL=http://localhost:3000/api + +# Sync Configuration +SYNC_ENABLED=true +SYNC_INTERVAL=3600000 # 1 hour in milliseconds diff --git a/package.json b/package.json index e9ee8d2..a0c1335 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,13 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 3002", "build": "next build", "start": "next start", "lint": "next lint", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test:db": "node scripts/test-db.js", + "init:backend": "chmod +x scripts/init-backend.sh && ./scripts/init-backend.sh" }, "dependencies": { "next": "14.0.4", @@ -25,13 +27,15 @@ "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", "react-query": "^3.39.3", - "zustand": "^4.4.7" + "zustand": "^4.4.7", + "pg": "^8.11.3" }, "devDependencies": { "typescript": "^5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/pg": "^8.10.9", "autoprefixer": "^10.0.1", "postcss": "^8", "tailwindcss": "^3.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7ad249..0190825 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: next: specifier: 14.0.4 version: 14.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + pg: + specifier: ^8.11.3 + version: 8.16.3 react: specifier: ^18 version: 18.3.1 @@ -60,6 +63,9 @@ importers: '@types/node': specifier: ^20 version: 20.19.24 + '@types/pg': + specifier: ^8.10.9 + version: 8.15.6 '@types/react': specifier: ^18 version: 18.3.26 @@ -620,6 +626,9 @@ packages: '@types/node@20.19.24': resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==} + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -1769,6 +1778,40 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pg-cloudflare@1.2.7: + resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} + + pg-connection-string@2.9.1: + resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.10.1: + resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1843,6 +1886,22 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2029,6 +2088,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -2278,6 +2341,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2794,6 +2861,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pg@8.15.6': + dependencies: + '@types/node': 20.19.24 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.26)': @@ -4111,6 +4184,41 @@ snapshots: path-type@4.0.0: {} + pg-cloudflare@1.2.7: + optional: true + + pg-connection-string@2.9.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.10.1(pg@8.16.3): + dependencies: + pg: 8.16.3 + + pg-protocol@1.10.3: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.16.3: + dependencies: + pg-connection-string: 2.9.1 + pg-pool: 3.10.1(pg@8.16.3) + pg-protocol: 1.10.3 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.2.7 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -4166,6 +4274,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.0: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} prop-types@15.8.1: @@ -4371,6 +4489,8 @@ snapshots: source-map-js@1.2.1: {} + split2@4.2.0: {} + stable-hash@0.0.5: {} stop-iteration-iterator@1.1.0: @@ -4719,6 +4839,8 @@ snapshots: wrappy@1.0.2: {} + xtend@4.0.2: {} + yocto-queue@0.1.0: {} zustand@4.5.7(@types/react@18.3.26)(react@18.3.1): diff --git a/scripts/init-backend.sh b/scripts/init-backend.sh new file mode 100755 index 0000000..876f754 --- /dev/null +++ b/scripts/init-backend.sh @@ -0,0 +1,117 @@ +#!/bin/bash + +# RepoHub Backend Initialization Script +# This script sets up the database and initializes the backend + +set -e + +echo "🚀 Initializing RepoHub Backend..." + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +DB_HOST=${DB_HOST:-localhost} +DB_PORT=${DB_PORT:-5432} +DB_NAME=${DB_NAME:-repohub} +DB_USER=${DB_USER:-postgres} +DB_PASSWORD=${DB_PASSWORD:-postgres} + +echo -e "${BLUE}📋 Configuration:${NC}" +echo " Host: $DB_HOST" +echo " Port: $DB_PORT" +echo " Database: $DB_NAME" +echo " User: $DB_USER" + +# Check if PostgreSQL is running +echo -e "\n${YELLOW}🔍 Checking PostgreSQL connection...${NC}" +if ! pg_isready -h $DB_HOST -p $DB_PORT; then + echo -e "${RED}❌ PostgreSQL is not running on $DB_HOST:$DB_PORT${NC}" + echo "Please start PostgreSQL and try again." + exit 1 +fi + +echo -e "${GREEN}✅ PostgreSQL is running${NC}" + +# Check if database exists +echo -e "\n${YELLOW}đŸ—„ī¸ Checking database...${NC}" +if psql -h $DB_HOST -p $DB_PORT -U $DB_USER -lqt | cut -d \| -f 1 | grep -qw $DB_NAME; then + echo -e "${GREEN}✅ Database '$DB_NAME' already exists${NC}" +else + echo -e "${YELLOW}âš ī¸ Database '$DB_NAME' does not exist. Creating...${NC}" + createdb -h $DB_HOST -p $DB_PORT -U $DB_USER $DB_NAME + echo -e "${GREEN}✅ Database '$DB_NAME' created${NC}" +fi + +# Check if tables exist +echo -e "\n${YELLOW}📊 Checking database tables...${NC}" +TABLES_EXIST=$(psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -tAc "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'platforms');") + +if [ "$TABLES_EXIST" = "t" ]; then + echo -e "${GREEN}✅ Database tables already exist${NC}" + read -p "Do you want to reset the database? This will delete all data. (y/N): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo -e "${YELLOW}🔄 Resetting database...${NC}" + psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -f src/lib/database/schema.sql + echo -e "${GREEN}✅ Database reset completed${NC}" + else + echo -e "${BLUE}â„šī¸ Keeping existing data${NC}" + fi +else + echo -e "${YELLOW}âš ī¸ Database tables do not exist. Creating schema...${NC}" + psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -f src/lib/database/schema.sql + echo -e "${GREEN}✅ Database schema created${NC}" +fi + +# Check if node_modules exists +echo -e "\n${YELLOW}đŸ“Ļ Checking dependencies...${NC}" +if [ ! -d "node_modules" ]; then + echo -e "${YELLOW}âš ī¸ Dependencies not found. Installing...${NC}" + pnpm install + echo -e "${GREEN}✅ Dependencies installed${NC}" +else + echo -e "${GREEN}✅ Dependencies already exist${NC}" +fi + +# Check if .env file exists +echo -e "\n${YELLOW}âš™ī¸ Checking environment configuration...${NC}" +if [ ! -f ".env" ]; then + echo -e "${YELLOW}âš ī¸ .env file not found. Creating from example...${NC}" + cp .env.example .env + echo -e "${GREEN}✅ .env file created${NC}" + echo -e "${YELLOW}âš ī¸ Please edit .env file with your database credentials${NC}" + echo " Current configuration:" + echo " DB_HOST=$DB_HOST" + echo " DB_PORT=$DB_PORT" + echo " DB_NAME=$DB_NAME" + echo " DB_USER=$DB_USER" + echo " DB_PASSWORD=$DB_PASSWORD" +else + echo -e "${GREEN}✅ .env file already exists${NC}" +fi + +# Test database connection +echo -e "\n${YELLOW}🔗 Testing database connection...${NC}" +if pnpm run test:db 2>/dev/null; then + echo -e "${GREEN}✅ Database connection successful${NC}" +else + echo -e "${YELLOW}âš ī¸ Database connection test failed. This is expected if test script doesn't exist yet.${NC}" +fi + +# Display next steps +echo -e "\n${GREEN}🎉 Backend initialization completed!${NC}" +echo -e "\n${BLUE}📋 Next steps:${NC}" +echo "1. Review and update .env file if needed" +echo "2. Start the development server:" +echo " ${YELLOW}pnpm dev${NC}" +echo "3. Test the API endpoints:" +echo " ${YELLOW}curl http://localhost:3000/api/platforms${NC}" +echo "4. Sync Ubuntu packages:" +echo " ${YELLOW}curl -X POST http://localhost:3000/api/sync -H 'Content-Type: application/json' -d '{\"platform_id\": \"ubuntu\"}'${NC}" + +echo -e "\n${GREEN}✨ Ready to start development!${NC}" diff --git a/scripts/test-db.js b/scripts/test-db.js new file mode 100644 index 0000000..0aa7cf4 --- /dev/null +++ b/scripts/test-db.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +// Database connection test script +const { testConnection } = require('../src/lib/database/config'); + +async function testDatabaseConnection() { + console.log('🔗 Testing database connection...'); + + try { + const success = await testConnection(); + + if (success) { + console.log('✅ Database connection successful!'); + process.exit(0); + } else { + console.log('❌ Database connection failed!'); + process.exit(1); + } + } catch (error) { + console.error('❌ Database connection error:', error.message); + process.exit(1); + } +} + +testDatabaseConnection(); diff --git a/src/app/api/packages/[id]/route.ts b/src/app/api/packages/[id]/route.ts new file mode 100644 index 0000000..2a54825 --- /dev/null +++ b/src/app/api/packages/[id]/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PackageService } from '@/services/packageService' + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const packageData = await PackageService.getById(params.id) + + if (!packageData) { + return NextResponse.json( + { error: 'Package not found' }, + { status: 404 } + ) + } + + return NextResponse.json(packageData) + } catch (error) { + console.error('Error fetching package:', error) + return NextResponse.json( + { error: 'Failed to fetch package' }, + { status: 500 } + ) + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const body = await request.json() + const packageData = await PackageService.update(params.id, body) + + if (!packageData) { + return NextResponse.json( + { error: 'Package not found' }, + { status: 404 } + ) + } + + return NextResponse.json(packageData) + } catch (error) { + console.error('Error updating package:', error) + return NextResponse.json( + { error: 'Failed to update package' }, + { status: 500 } + ) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const success = await PackageService.delete(params.id) + + if (!success) { + return NextResponse.json( + { error: 'Package not found' }, + { status: 404 } + ) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting package:', error) + return NextResponse.json( + { error: 'Failed to delete package' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/packages/route.ts b/src/app/api/packages/route.ts new file mode 100644 index 0000000..7126928 --- /dev/null +++ b/src/app/api/packages/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PackageService } from '@/services/packageService' + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + + // Parse query parameters + const filter = { + platform_id: searchParams.get('platform_id') || undefined, + category_id: searchParams.get('category_id') ? + parseInt(searchParams.get('category_id')!) : undefined, + type: searchParams.get('type') as 'gui' | 'cli' | undefined, + repository: searchParams.get('repository') as 'official' | 'third-party' | undefined, + search: searchParams.get('search') || undefined, + limit: searchParams.get('limit') ? + parseInt(searchParams.get('limit')!) : undefined, + offset: searchParams.get('offset') ? + parseInt(searchParams.get('offset')!) : undefined, + sort_by: searchParams.get('sort_by') as + 'name' | 'popularity_score' | 'last_updated' | 'downloads_count' | undefined, + sort_order: searchParams.get('sort_order') as 'asc' | 'desc' | undefined + } + + const result = await PackageService.getMany(filter) + + return NextResponse.json({ + packages: result.packages, + total: result.total, + limit: filter.limit, + offset: filter.offset + }) + } catch (error) { + console.error('Error fetching packages:', error) + return NextResponse.json( + { error: 'Failed to fetch packages' }, + { status: 500 } + ) + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const packageData = await PackageService.create(body) + return NextResponse.json(packageData, { status: 201 }) + } catch (error) { + console.error('Error creating package:', error) + return NextResponse.json( + { error: 'Failed to create package' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/platforms/[id]/route.ts b/src/app/api/platforms/[id]/route.ts new file mode 100644 index 0000000..e8df68a --- /dev/null +++ b/src/app/api/platforms/[id]/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PlatformService } from '@/services/platformService' + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const platform = await PlatformService.getById(params.id) + + if (!platform) { + return NextResponse.json( + { error: 'Platform not found' }, + { status: 404 } + ) + } + + return NextResponse.json(platform) + } catch (error) { + console.error('Error fetching platform:', error) + return NextResponse.json( + { error: 'Failed to fetch platform' }, + { status: 500 } + ) + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const body = await request.json() + const platform = await PlatformService.update(params.id, body) + + if (!platform) { + return NextResponse.json( + { error: 'Platform not found' }, + { status: 404 } + ) + } + + return NextResponse.json(platform) + } catch (error) { + console.error('Error updating platform:', error) + return NextResponse.json( + { error: 'Failed to update platform' }, + { status: 500 } + ) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const success = await PlatformService.delete(params.id) + + if (!success) { + return NextResponse.json( + { error: 'Platform not found' }, + { status: 404 } + ) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting platform:', error) + return NextResponse.json( + { error: 'Failed to delete platform' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/platforms/route.ts b/src/app/api/platforms/route.ts new file mode 100644 index 0000000..a7cf53f --- /dev/null +++ b/src/app/api/platforms/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PlatformService } from '@/services/platformService' + +export async function GET() { + try { + const platforms = await PlatformService.getAll() + return NextResponse.json(platforms) + } catch (error) { + console.error('Error fetching platforms:', error) + return NextResponse.json( + { error: 'Failed to fetch platforms' }, + { status: 500 } + ) + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const platform = await PlatformService.create(body) + return NextResponse.json(platform, { status: 201 }) + } catch (error) { + console.error('Error creating platform:', error) + return NextResponse.json( + { error: 'Failed to create platform' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/sync/route.ts b/src/app/api/sync/route.ts new file mode 100644 index 0000000..0ad0c61 --- /dev/null +++ b/src/app/api/sync/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server' +import { MetadataFetcher } from '@/services/metadataFetcher' + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { platform_id, all_platforms } = body + + if (all_platforms) { + // Sync all platforms + await MetadataFetcher.syncAllPlatforms() + return NextResponse.json({ + message: 'All platforms synced successfully', + timestamp: new Date().toISOString() + }) + } else if (platform_id) { + // Sync specific platform + await MetadataFetcher.syncPlatform(platform_id) + return NextResponse.json({ + message: `Platform ${platform_id} synced successfully`, + timestamp: new Date().toISOString() + }) + } else { + return NextResponse.json( + { error: 'Either platform_id or all_platforms must be specified' }, + { status: 400 } + ) + } + } catch (error) { + console.error('Error during sync:', error) + return NextResponse.json( + { error: 'Sync failed', details: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ) + } +} + +export async function GET() { + try { + // Return sync status (would need to implement status tracking) + return NextResponse.json({ + status: 'ready', + last_sync: null, + platforms: ['ubuntu', 'fedora', 'arch', 'windows', 'macos'] + }) + } catch (error) { + console.error('Error getting sync status:', error) + return NextResponse.json( + { error: 'Failed to get sync status' }, + { status: 500 } + ) + } +} diff --git a/src/lib/database/config.ts b/src/lib/database/config.ts new file mode 100644 index 0000000..68653b6 --- /dev/null +++ b/src/lib/database/config.ts @@ -0,0 +1,58 @@ +import { Pool, PoolConfig } from 'pg' + +// Database configuration +const dbConfig: PoolConfig = { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME || 'repohub', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || '', + max: 20, // Maximum number of connections in the pool + idleTimeoutMillis: 30000, // How long a client is allowed to remain idle before being closed + connectionTimeoutMillis: 2000, // How long to wait when connecting a new client +} + +// Create connection pool +const pool = new Pool(dbConfig) + +// Test database connection +export async function testConnection() { + try { + const client = await pool.connect() + await client.query('SELECT NOW()') + client.release() + console.log('✅ Database connection successful') + return true + } catch (error) { + console.error('❌ Database connection failed:', error) + return false + } +} + +// Helper function for queries +export async function query(text: string, params?: any[]) { + const start = Date.now() + try { + const res = await pool.query(text, params) + const duration = Date.now() - start + console.log('📊 Query executed', { text, duration, rows: res.rowCount }) + return res + } catch (error) { + console.error('❌ Query failed', { text, error }) + throw error + } +} + +// Get a single client for transactions +export async function getClient() { + return await pool.connect() +} + +// Close all connections +export async function closePool() { + await pool.end() + console.log('🔌 Database connection pool closed') +} + +export { pool } +export default pool diff --git a/src/lib/database/schema.sql b/src/lib/database/schema.sql new file mode 100644 index 0000000..f9a11f0 --- /dev/null +++ b/src/lib/database/schema.sql @@ -0,0 +1,140 @@ +-- RepoHub Database Schema +-- PostgreSQL schema for package metadata management + +-- Platforms table +CREATE TABLE platforms ( + id VARCHAR(50) PRIMARY KEY, + name VARCHAR(100) NOT NULL, + package_manager VARCHAR(50) NOT NULL, + icon VARCHAR(10), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Categories table +CREATE TABLE categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Licenses table +CREATE TABLE licenses ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + url VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Packages table +CREATE TABLE packages ( + id VARCHAR(100) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + version VARCHAR(50), + platform_id VARCHAR(50) NOT NULL REFERENCES platforms(id), + category_id INTEGER REFERENCES categories(id), + license_id INTEGER REFERENCES licenses(id), + type VARCHAR(10) CHECK (type IN ('gui', 'cli')), + repository VARCHAR(20) CHECK (repository IN ('official', 'third-party')), + homepage_url VARCHAR(255), + download_url VARCHAR(255), + last_updated TIMESTAMP WITH TIME ZONE, + downloads_count INTEGER DEFAULT 0, + popularity_score INTEGER CHECK (popularity_score >= 0 AND popularity_score <= 100), + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Package tags junction table +CREATE TABLE package_tags ( + package_id VARCHAR(50) REFERENCES packages(id) ON DELETE CASCADE, + tag VARCHAR(50) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (package_id, tag) +); + +-- Package dependencies junction table +CREATE TABLE package_dependencies ( + package_id VARCHAR(50) REFERENCES packages(id) ON DELETE CASCADE, + dependency_id VARCHAR(50) REFERENCES packages(id) ON DELETE CASCADE, + dependency_type VARCHAR(20) CHECK (dependency_type IN ('required', 'optional', 'recommended')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (package_id, dependency_id) +); + +-- Metadata sync log table +CREATE TABLE metadata_sync_logs ( + id SERIAL PRIMARY KEY, + platform_id VARCHAR(50) REFERENCES platforms(id), + sync_type VARCHAR(20) CHECK (sync_type IN ('full', 'incremental')), + status VARCHAR(20) CHECK (status IN ('running', 'completed', 'failed')), + packages_processed INTEGER DEFAULT 0, + packages_added INTEGER DEFAULT 0, + packages_updated INTEGER DEFAULT 0, + packages_removed INTEGER DEFAULT 0, + error_message TEXT, + started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + completed_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes for performance +CREATE INDEX idx_packages_platform_id ON packages(platform_id); +CREATE INDEX idx_packages_category_id ON packages(category_id); +CREATE INDEX idx_packages_type ON packages(type); +CREATE INDEX idx_packages_repository ON packages(repository); +CREATE INDEX idx_packages_is_active ON packages(is_active); +CREATE INDEX idx_packages_last_updated ON packages(last_updated); +CREATE INDEX idx_packages_popularity_score ON packages(popularity_score DESC); +CREATE INDEX idx_packages_name ON packages USING gin(to_tsvector('english', name)); +CREATE INDEX idx_packages_description ON packages USING gin(to_tsvector('english', description)); + +-- Trigger to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_platforms_updated_at BEFORE UPDATE ON platforms + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_packages_updated_at BEFORE UPDATE ON packages + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Initial data +INSERT INTO platforms (id, name, package_manager, icon) VALUES +('ubuntu', 'Ubuntu/Debian', 'apt', '🐧'), +('fedora', 'Fedora', 'dnf', '🎩'), +('arch', 'Arch Linux', 'pacman', 'đŸ›ī¸'), +('windows', 'Windows', 'winget', 'đŸĒŸ'), +('macos', 'macOS', 'homebrew', '🍎'); + +INSERT INTO categories (name, description) VALUES +('Development', 'Software development tools and IDEs'), +('Internet', 'Web browsers, email clients, and network tools'), +('Multimedia', 'Audio, video, and graphics applications'), +('System', 'System administration and utilities'), +('Communication', 'Chat, VoIP, and messaging applications'), +('Office', 'Productivity and office applications'), +('Graphics', 'Image editing and design tools'), +('Games', 'Games and gaming-related software'), +('Science', 'Scientific and educational software'), +('Utilities', 'General utility applications'); + +INSERT INTO licenses (name, url) VALUES +('MIT', 'https://opensource.org/licenses/MIT'), +('GPL-2.0', 'https://opensource.org/licenses/GPL-2.0'), +('GPL-3.0', 'https://opensource.org/licenses/GPL-3.0'), +('Apache-2.0', 'https://opensource.org/licenses/Apache-2.0'), +('BSD-2-Clause', 'https://opensource.org/licenses/BSD-2-Clause'), +('BSD-3-Clause', 'https://opensource.org/licenses/BSD-3-Clause'), +('MPL-2.0', 'https://opensource.org/licenses/MPL-2.0'), +('Proprietary', NULL), +('LGPL-2.1', 'https://opensource.org/licenses/LGPL-2.1'), +('LGPL-3.0', 'https://opensource.org/licenses/LGPL-3.0'); diff --git a/src/models/Package.ts b/src/models/Package.ts new file mode 100644 index 0000000..91da092 --- /dev/null +++ b/src/models/Package.ts @@ -0,0 +1,86 @@ +export interface Package { + id: string + name: string + description?: string + version?: string + platform_id: string + category_id?: number + license_id?: number + type?: 'gui' | 'cli' + repository?: 'official' | 'third-party' + homepage_url?: string + download_url?: string + last_updated?: Date + downloads_count?: number + popularity_score?: number + is_active: boolean + created_at: Date + updated_at: Date + + // Joined fields + platform?: Platform + category?: Category + license?: License + tags?: string[] + dependencies?: Package[] +} + +export interface Platform { + id: string + name: string + package_manager: string + icon?: string +} + +export interface Category { + id: number + name: string + description?: string +} + +export interface License { + id: number + name: string + url?: string +} + +export interface CreatePackageInput { + id: string + name: string + description?: string + version?: string + platform_id: string + category_id?: number + license_id?: number + type?: 'gui' | 'cli' + repository?: 'official' | 'third-party' + homepage_url?: string + download_url?: string + popularity_score?: number +} + +export interface UpdatePackageInput { + name?: string + description?: string + version?: string + category_id?: number + license_id?: number + type?: 'gui' | 'cli' + repository?: 'official' | 'third-party' + homepage_url?: string + download_url?: string + popularity_score?: number + is_active?: boolean +} + +export interface PackageFilter { + platform_id?: string + category_id?: number + type?: 'gui' | 'cli' + repository?: 'official' | 'third-party' + search?: string + limit?: number + offset?: number + sort_by?: 'name' | 'popularity_score' | 'last_updated' | 'downloads_count' + sort_order?: 'asc' | 'desc' +} diff --git a/src/models/Platform.ts b/src/models/Platform.ts new file mode 100644 index 0000000..ae2628e --- /dev/null +++ b/src/models/Platform.ts @@ -0,0 +1,21 @@ +export interface Platform { + id: string + name: string + package_manager: string + icon?: string + created_at: Date + updated_at: Date +} + +export interface CreatePlatformInput { + id: string + name: string + package_manager: string + icon?: string +} + +export interface UpdatePlatformInput { + name?: string + package_manager?: string + icon?: string +} diff --git a/src/services/metadataFetcher.ts b/src/services/metadataFetcher.ts new file mode 100644 index 0000000..b840502 --- /dev/null +++ b/src/services/metadataFetcher.ts @@ -0,0 +1,247 @@ +import { exec } from 'child_process' +import { promisify } from 'util' +import { PackageService } from './packageService' +import { PlatformService } from './platformService' +import { CreatePackageInput } from '@/models/Package' + +const execAsync = promisify(exec) + +export class MetadataFetcher { + // Fetch Ubuntu/Debian package metadata + static async fetchUbuntuPackages(): Promise { + console.log('🔄 Starting Ubuntu/Debian package metadata fetch...') + + try { + // Update package lists + console.log('đŸ“Ļ Updating apt package lists...') + await execAsync('apt-get update') + + // Get package list + console.log('📋 Getting package list...') + const { stdout } = await execAsync('apt-cache dumpavail') + + // Parse packages + const packages = this.parseAptPackages(stdout) + console.log(`📊 Found ${packages.length} packages`) + + // Store in database + await this.storePackages('ubuntu', packages) + + console.log('✅ Ubuntu/Debian package metadata fetch completed') + } catch (error) { + console.error('❌ Error fetching Ubuntu packages:', error) + throw error + } + } + + // Parse apt-cache dumpavail output + private static parseAptPackages(output: string): CreatePackageInput[] { + const packages: CreatePackageInput[] = [] + const blocks = output.split('\n\n') + + for (const block of blocks) { + if (!block.trim()) continue + + const pkg: any = {} + const lines = block.split('\n') + + for (const line of lines) { + if (line.startsWith('Package: ')) { + pkg.id = line.substring(9).trim() + pkg.name = pkg.id + } else if (line.startsWith('Description: ')) { + pkg.description = line.substring(13).trim() + } else if (line.startsWith('Version: ')) { + pkg.version = line.substring(9).trim() + } else if (line.startsWith('Homepage: ')) { + pkg.homepage_url = line.substring(10).trim() + } + } + + if (pkg.id && pkg.name) { + packages.push({ + ...pkg, + platform_id: 'ubuntu', + type: this.determinePackageType(pkg.name, pkg.description), + repository: 'official', + popularity_score: Math.floor(Math.random() * 100) // Placeholder + }) + } + } + + return packages + } + + // Determine if package is GUI or CLI + private static determinePackageType(name: string, description?: string): 'gui' | 'cli' { + const guiKeywords = [ + 'gui', 'gtk', 'qt', 'x11', 'desktop', 'window', 'display', + 'graphical', 'visual', 'image', 'video', 'audio', 'media', + 'browser', 'editor', 'viewer', 'player', 'manager' + ] + + const cliKeywords = [ + 'cli', 'command', 'terminal', 'console', 'shell', 'bash', + 'tool', 'utility', 'daemon', 'service', 'server', 'client' + ] + + const searchText = `${name} ${description || ''}`.toLowerCase() + + for (const keyword of guiKeywords) { + if (searchText.includes(keyword)) return 'gui' + } + + for (const keyword of cliKeywords) { + if (searchText.includes(keyword)) return 'cli' + } + + // Default to CLI for system packages + return 'cli' + } + + // Store packages in database + private static async storePackages(platformId: string, packages: CreatePackageInput[]): Promise { + console.log(`💾 Storing ${packages.length} packages for platform ${platformId}...`) + + let added = 0 + let updated = 0 + + for (const pkg of packages) { + try { + // Check if package exists + const existing = await PackageService.getById(pkg.id) + + if (existing) { + // Update existing package + await PackageService.update(pkg.id, { + description: pkg.description, + version: pkg.version, + homepage_url: pkg.homepage_url, + type: pkg.type, + repository: pkg.repository, + popularity_score: pkg.popularity_score + }) + updated++ + } else { + // Create new package + await PackageService.create(pkg) + added++ + } + } catch (error) { + console.error(`Error storing package ${pkg.id}:`, error) + } + } + + console.log(`📈 Added: ${added}, Updated: ${updated}`) + } + + // Fetch package categories from Ubuntu + static async fetchUbuntuCategories(): Promise { + console.log('đŸˇī¸ Fetching Ubuntu package categories...') + + try { + // Get sections from apt-cache + const { stdout } = await execAsync('apt-cache dump | grep "^Section:" | sort | uniq') + + const categories = stdout + .split('\n') + .filter(line => line.trim()) + .map(line => line.replace('Section:', '').trim()) + .filter(category => category && category !== 'unknown') + + console.log(`📋 Found ${categories.length} categories`) + + // Store categories in database (would need CategoryService) + for (const category of categories) { + console.log(` - ${category}`) + } + + } catch (error) { + console.error('❌ Error fetching Ubuntu categories:', error) + } + } + + // Get popular packages from Ubuntu popularity contest + static async getPopularUbuntuPackages(limit: number = 100): Promise { + try { + // Try to get popularity contest data + const { stdout } = await execAsync('popularity-contest -a 2>/dev/null || echo ""') + + if (stdout) { + const packages = stdout + .split('\n') + .filter(line => line.includes('POP-CON-Package')) + .map(line => line.split(' ')[1]) + .slice(0, limit) + + return packages + } + + // Fallback: return some common packages + return [ + 'curl', 'wget', 'git', 'vim', 'nano', 'firefox', 'chromium-browser', + 'libreoffice', 'gimp', 'vlc', 'audacity', 'thunderbird', 'code' + ] + } catch (error) { + console.error('Error getting popular packages:', error) + return [] + } + } + + // Sync metadata for a specific platform + static async syncPlatform(platformId: string): Promise { + console.log(`🔄 Starting metadata sync for platform: ${platformId}`) + + const startTime = Date.now() + + try { + switch (platformId) { + case 'ubuntu': + await this.fetchUbuntuPackages() + break + case 'fedora': + // TODO: Implement Fedora metadata fetching + console.log('âŗ Fedora metadata fetching not yet implemented') + break + case 'arch': + // TODO: Implement Arch metadata fetching + console.log('âŗ Arch metadata fetching not yet implemented') + break + case 'windows': + // TODO: Implement Windows metadata fetching + console.log('âŗ Windows metadata fetching not yet implemented') + break + case 'macos': + // TODO: Implement macOS metadata fetching + console.log('âŗ macOS metadata fetching not yet implemented') + break + default: + throw new Error(`Unsupported platform: ${platformId}`) + } + + const duration = Date.now() - startTime + console.log(`✅ Sync completed for ${platformId} in ${duration}ms`) + + } catch (error) { + console.error(`❌ Sync failed for ${platformId}:`, error) + throw error + } + } + + // Sync all platforms + static async syncAllPlatforms(): Promise { + console.log('🔄 Starting metadata sync for all platforms...') + + const platforms = await PlatformService.getAll() + + for (const platform of platforms) { + try { + await this.syncPlatform(platform.id) + } catch (error) { + console.error(`Failed to sync ${platform.name}:`, error) + } + } + + console.log('✅ All platforms sync completed') + } +} diff --git a/src/services/packageService.ts b/src/services/packageService.ts new file mode 100644 index 0000000..b320300 --- /dev/null +++ b/src/services/packageService.ts @@ -0,0 +1,321 @@ +import { query } from '@/lib/database/config' +import { Package, CreatePackageInput, UpdatePackageInput, PackageFilter } from '@/models/Package' + +export class PackageService { + // Get packages with filtering and pagination + static async getMany(filter: PackageFilter = {}): Promise<{ packages: Package[], total: number }> { + const { + platform_id, + category_id, + type, + repository, + search, + limit = 50, + offset = 0, + sort_by = 'name', + sort_order = 'asc' + } = filter + + // Build WHERE clause + const whereConditions = [] + const values = [] + let paramIndex = 1 + + if (platform_id) { + whereConditions.push(`p.platform_id = $${paramIndex++}`) + values.push(platform_id) + } + + if (category_id) { + whereConditions.push(`p.category_id = $${paramIndex++}`) + values.push(category_id) + } + + if (type) { + whereConditions.push(`p.type = $${paramIndex++}`) + values.push(type) + } + + if (repository) { + whereConditions.push(`p.repository = $${paramIndex++}`) + values.push(repository) + } + + if (search) { + whereConditions.push(`( + to_tsvector('english', p.name) @@ to_tsquery('english', $${paramIndex}) OR + to_tsvector('english', p.description) @@ to_tsquery('english', $${paramIndex}) + )`) + values.push(search.split(' ').join(' & ')) + paramIndex++ + } + + whereConditions.push('p.is_active = true') + + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '' + + // Build ORDER BY clause + const validSortFields = ['name', 'popularity_score', 'last_updated', 'downloads_count'] + const sortField = validSortFields.includes(sort_by || '') ? sort_by : 'name' + const sortDirection = sort_order === 'desc' ? 'DESC' : 'ASC' + const orderClause = `ORDER BY p.${sortField} ${sortDirection}` + + // Get total count + const countQuery = ` + SELECT COUNT(*) as total + FROM packages p + ${whereClause} + ` + const countResult = await query(countQuery, values) + const total = parseInt(countResult.rows[0].total) + + // Get packages with joins + const packagesQuery = ` + SELECT + p.*, + pl.name as platform_name, + pl.package_manager as platform_package_manager, + pl.icon as platform_icon, + c.name as category_name, + l.name as license_name, + l.url as license_url, + COALESCE( + ARRAY_AGG(DISTINCT pt.tag) FILTER (WHERE pt.tag IS NOT NULL), + ARRAY[]::VARCHAR[] + ) as tags + FROM packages p + LEFT JOIN platforms pl ON p.platform_id = pl.id + LEFT JOIN categories c ON p.category_id = c.id + LEFT JOIN licenses l ON p.license_id = l.id + LEFT JOIN package_tags pt ON p.id = pt.package_id + ${whereClause} + GROUP BY p.id, pl.name, pl.package_manager, pl.icon, c.name, l.name, l.url + ${orderClause} + LIMIT $${paramIndex++} OFFSET $${paramIndex++} + ` + + values.push(limit, offset) + const packagesResult = await query(packagesQuery, values) + + // Transform the results + const packages = packagesResult.rows.map((row: any) => ({ + id: row.id, + name: row.name, + description: row.description, + version: row.version, + platform_id: row.platform_id, + category_id: row.category_id, + license_id: row.license_id, + type: row.type, + repository: row.repository, + homepage_url: row.homepage_url, + download_url: row.download_url, + last_updated: row.last_updated, + downloads_count: row.downloads_count, + popularity_score: row.popularity_score, + is_active: row.is_active, + created_at: row.created_at, + updated_at: row.updated_at, + platform: row.platform_name ? { + id: row.platform_id, + name: row.platform_name, + package_manager: row.platform_package_manager, + icon: row.platform_icon + } : undefined, + category: row.category_name ? { + id: row.category_id, + name: row.category_name + } : undefined, + license: row.license_name ? { + id: row.license_id, + name: row.license_name, + url: row.license_url + } : undefined, + tags: row.tags || [] + })) + + return { packages, total } + } + + // Get package by ID + static async getById(id: string): Promise { + const packageQuery = ` + SELECT + p.*, + pl.name as platform_name, + pl.package_manager as platform_package_manager, + pl.icon as platform_icon, + c.name as category_name, + l.name as license_name, + l.url as license_url, + COALESCE( + ARRAY_AGG(DISTINCT pt.tag) FILTER (WHERE pt.tag IS NOT NULL), + ARRAY[]::VARCHAR[] + ) as tags + FROM packages p + LEFT JOIN platforms pl ON p.platform_id = pl.id + LEFT JOIN categories c ON p.category_id = c.id + LEFT JOIN licenses l ON p.license_id = l.id + LEFT JOIN package_tags pt ON p.id = pt.package_id + WHERE p.id = $1 AND p.is_active = true + GROUP BY p.id, pl.name, pl.package_manager, pl.icon, c.name, l.name, l.url + ` + + const result = await query(packageQuery, [id]) + + if (result.rows.length === 0) { + return null + } + + const row = result.rows[0] + return { + id: row.id, + name: row.name, + description: row.description, + version: row.version, + platform_id: row.platform_id, + category_id: row.category_id, + license_id: row.license_id, + type: row.type, + repository: row.repository, + homepage_url: row.homepage_url, + download_url: row.download_url, + last_updated: row.last_updated, + downloads_count: row.downloads_count, + popularity_score: row.popularity_score, + is_active: row.is_active, + created_at: row.created_at, + updated_at: row.updated_at, + platform: row.platform_name ? { + id: row.platform_id, + name: row.platform_name, + package_manager: row.platform_package_manager, + icon: row.platform_icon + } : undefined, + category: row.category_name ? { + id: row.category_id, + name: row.category_name + } : undefined, + license: row.license_name ? { + id: row.license_id, + name: row.license_name, + url: row.license_url + } : undefined, + tags: row.tags || [] + } + } + + // Create new package + static async create(data: CreatePackageInput): Promise { + const { + id, + name, + description, + version, + platform_id, + category_id, + license_id, + type, + repository, + homepage_url, + download_url, + popularity_score + } = data + + const result = await query( + `INSERT INTO packages ( + id, name, description, version, platform_id, category_id, + license_id, type, repository, homepage_url, download_url, + popularity_score + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING *`, + [id, name, description, version, platform_id, category_id, + license_id, type, repository, homepage_url, download_url, + popularity_score] + ) + + const createdPackage = await this.getById(result.rows[0].id) + if (!createdPackage) { + throw new Error('Failed to create package') + } + return createdPackage + } + + // Update package + static async update(id: string, data: UpdatePackageInput): Promise { + const fields = [] + const values = [] + let paramIndex = 1 + + for (const [key, value] of Object.entries(data)) { + if (value !== undefined) { + fields.push(`${key} = $${paramIndex++}`) + values.push(value) + } + } + + if (fields.length === 0) { + return this.getById(id) + } + + values.push(id) + await query( + `UPDATE packages SET ${fields.join(', ')} WHERE id = $${paramIndex}`, + values + ) + + return this.getById(id) + } + + // Delete package (soft delete) + static async delete(id: string): Promise { + const result = await query( + 'UPDATE packages SET is_active = false WHERE id = $1', + [id] + ) + return (result.rowCount ?? 0) > 0 + } + + // Add tags to package + static async addTags(packageId: string, tags: string[]): Promise { + if (tags.length === 0) return + + const values = tags.map((tag, index) => `($1, $${index + 2})`).join(', ') + const params = [packageId, ...tags] + + await query( + `INSERT INTO package_tags (package_id, tag) VALUES ${values} ON CONFLICT DO NOTHING`, + params + ) + } + + // Remove tags from package + static async removeTags(packageId: string, tags: string[]): Promise { + if (tags.length === 0) return + + await query( + 'DELETE FROM package_tags WHERE package_id = $1 AND tag = ANY($2)', + [packageId, tags] + ) + } + + // Get popular packages + static async getPopular(limit: number = 10): Promise { + const result = await this.getMany({ + limit, + sort_by: 'popularity_score', + sort_order: 'desc' + }) + return result.packages + } + + // Get recently updated packages + static async getRecentlyUpdated(limit: number = 10): Promise { + const result = await this.getMany({ + limit, + sort_by: 'last_updated', + sort_order: 'desc' + }) + return result.packages + } +} diff --git a/src/services/platformService.ts b/src/services/platformService.ts new file mode 100644 index 0000000..296ad0b --- /dev/null +++ b/src/services/platformService.ts @@ -0,0 +1,71 @@ +import { query } from '@/lib/database/config' +import { Platform, CreatePlatformInput, UpdatePlatformInput } from '@/models/Platform' + +export class PlatformService { + // Get all platforms + static async getAll(): Promise { + const result = await query('SELECT * FROM platforms ORDER BY name') + return result.rows + } + + // Get platform by ID + static async getById(id: string): Promise { + const result = await query('SELECT * FROM platforms WHERE id = $1', [id]) + return result.rows[0] || null + } + + // Create new platform + static async create(data: CreatePlatformInput): Promise { + const { id, name, package_manager, icon } = data + const result = await query( + `INSERT INTO platforms (id, name, package_manager, icon) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [id, name, package_manager, icon] + ) + return result.rows[0] + } + + // Update platform + static async update(id: string, data: UpdatePlatformInput): Promise { + const fields = [] + const values = [] + let paramIndex = 1 + + if (data.name !== undefined) { + fields.push(`name = $${paramIndex++}`) + values.push(data.name) + } + if (data.package_manager !== undefined) { + fields.push(`package_manager = $${paramIndex++}`) + values.push(data.package_manager) + } + if (data.icon !== undefined) { + fields.push(`icon = $${paramIndex++}`) + values.push(data.icon) + } + + if (fields.length === 0) { + return this.getById(id) + } + + values.push(id) + const result = await query( + `UPDATE platforms SET ${fields.join(', ')} WHERE id = $${paramIndex} RETURNING *`, + values + ) + return result.rows[0] || null + } + + // Delete platform + static async delete(id: string): Promise { + const result = await query('DELETE FROM platforms WHERE id = $1', [id]) + return (result.rowCount ?? 0) > 0 + } + + // Check if platform exists + static async exists(id: string): Promise { + const result = await query('SELECT 1 FROM platforms WHERE id = $1 LIMIT 1', [id]) + return result.rows.length > 0 + } +}