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
This commit is contained in:
Yusuf İpek
2025-11-10 18:02:08 +03:00
parent e82366e5e8
commit 596bba0832
17 changed files with 1518 additions and 3 deletions
+75
View File
@@ -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 }
)
}
}
+54
View File
@@ -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 }
)
}
}
+75
View File
@@ -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 }
)
}
}
+29
View File
@@ -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 }
)
}
}
+53
View File
@@ -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 }
)
}
}
+58
View File
@@ -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
+140
View File
@@ -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');
+86
View File
@@ -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'
}
+21
View File
@@ -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
}
+247
View File
@@ -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<void> {
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<void> {
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<void> {
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<string[]> {
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<void> {
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<void> {
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')
}
}
+321
View File
@@ -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<Package | null> {
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<Package> {
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<Package | null> {
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<boolean> {
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<void> {
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<void> {
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<Package[]> {
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<Package[]> {
const result = await this.getMany({
limit,
sort_by: 'last_updated',
sort_order: 'desc'
})
return result.packages
}
}
+71
View File
@@ -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<Platform[]> {
const result = await query('SELECT * FROM platforms ORDER BY name')
return result.rows
}
// Get platform by ID
static async getById(id: string): Promise<Platform | null> {
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<Platform> {
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<Platform | null> {
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<boolean> {
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<boolean> {
const result = await query('SELECT 1 FROM platforms WHERE id = $1 LIMIT 1', [id])
return result.rows.length > 0
}
}