diff --git a/api/migrate.ts b/api/migrate.ts new file mode 100644 index 0000000..521094b --- /dev/null +++ b/api/migrate.ts @@ -0,0 +1,155 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { neon } from '@neondatabase/serverless'; +import cardsData from '../src/data/cards.json'; + +// Initialize Neon client +const sql = neon(process.env.DATABASE_URL!); + +interface JsonCard { + id: number; + name: string; + set_name?: string; + set_code?: string; + card_number?: string; + rarity?: string; + game: string; + mana_cost?: string; + cmc?: number; + card_type?: string; + colors?: string[] | null; + oracle_text?: string; + flavor_text?: string | null; + power?: string | null; + toughness?: string | null; + loyalty?: string | null; + artist?: string | null; + image_url?: string; + stock_image_url?: string; + artwork_crop_coords?: any; + current_price?: number; + market_price?: number; + low_price?: number | null; + high_price?: number | null; + price_last_updated?: string | null; + ocr_confidence?: number | null; + ocr_raw_text?: string | null; + scryfall_id?: string | null; + tcg_player_id?: string | null; + verified: number; // JSON has number, but we want boolean + created_at?: string; + updated_at?: string; +} + +export default async function handler(req: NextRequest) { + // Only allow POST requests for security + if (req.method !== 'POST') { + return new NextResponse(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }); + } + + try { + console.log('Starting card migration to Neon database...'); + + // Check if cards already exist to prevent duplicate migration + const existingCards = await sql`SELECT COUNT(*) as count FROM cards`; + const cardCount = existingCards[0]?.count || 0; + + if (cardCount > 0) { + return new NextResponse(JSON.stringify({ + message: `Database already contains ${cardCount} cards. Migration skipped.`, + cards_count: cardCount + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Migrate cards from JSON + const cards = cardsData as JsonCard[]; + console.log(`Migrating ${cards.length} cards...`); + + let migratedCount = 0; + const errors: string[] = []; + + for (const card of cards) { + try { + await sql` + INSERT INTO cards ( + name, set_name, set_code, card_number, rarity, game, mana_cost, cmc, + card_type, colors, oracle_text, flavor_text, power, toughness, loyalty, + artist, image_url, stock_image_url, artwork_crop_coords, current_price, + market_price, low_price, high_price, price_last_updated, ocr_confidence, + ocr_raw_text, scryfall_id, tcg_player_id, verified, created_at, updated_at + ) VALUES ( + ${card.name}, + ${card.set_name || null}, + ${card.set_code || null}, + ${card.card_number || null}, + ${card.rarity || null}, + ${card.game}, + ${card.mana_cost || null}, + ${card.cmc || null}, + ${card.card_type || null}, + ${card.colors ? JSON.stringify(card.colors) : null}, + ${card.oracle_text || null}, + ${card.flavor_text || null}, + ${card.power || null}, + ${card.toughness || null}, + ${card.loyalty || null}, + ${card.artist || null}, + ${card.image_url || null}, + ${card.stock_image_url || null}, + ${card.artwork_crop_coords ? JSON.stringify(card.artwork_crop_coords) : null}, + ${card.current_price || null}, + ${card.market_price || null}, + ${card.low_price || null}, + ${card.high_price || null}, + ${card.price_last_updated ? new Date(card.price_last_updated) : null}, + ${card.ocr_confidence || null}, + ${card.ocr_raw_text || null}, + ${card.scryfall_id || null}, + ${card.tcg_player_id || null}, + ${Boolean(card.verified)}, + ${card.created_at ? new Date(card.created_at) : new Date()}, + ${card.updated_at ? new Date(card.updated_at) : new Date()} + ) + `; + migratedCount++; + } catch (error) { + const errorMsg = `Error migrating card ${card.name}: ${error.message}`; + console.error(errorMsg); + errors.push(errorMsg); + } + } + + // Get final count to verify + const finalCount = await sql`SELECT COUNT(*) as count FROM cards`; + const finalCardCount = finalCount[0]?.count || 0; + + console.log(`✅ Successfully migrated ${migratedCount} cards to Neon database!`); + + return new NextResponse(JSON.stringify({ + success: true, + message: `Successfully migrated ${migratedCount} out of ${cards.length} cards`, + cards_migrated: migratedCount, + total_cards_in_db: finalCardCount, + errors: errors.length > 0 ? errors : undefined + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + } catch (error) { + console.error('Migration failed:', error); + return new NextResponse(JSON.stringify({ + success: false, + error: 'Migration failed', + details: error.message + }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} \ No newline at end of file diff --git a/api/v1/cards/[id].ts b/api/v1/cards/[id].ts index 44a2629..d711945 100644 --- a/api/v1/cards/[id].ts +++ b/api/v1/cards/[id].ts @@ -1,5 +1,8 @@ import { NextRequest, NextResponse } from 'next/server' -import cardsData from '../../../src/data/cards.json' +import { neon } from '@neondatabase/serverless' + +// Initialize Neon client +const sql = neon(process.env.DATABASE_URL!) interface Card { id: number @@ -40,12 +43,23 @@ export default async function handler(req: NextRequest) { return new NextResponse(null, { status: 200, headers }) } - try { - const url = new URL(req.url) - const pathSegments = url.pathname.split('/') - const cardId = parseInt(pathSegments[pathSegments.length - 1]) + if (req.method !== 'GET') { + return new NextResponse(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { + ...headers, + 'Content-Type': 'application/json', + }, + }) + } - if (isNaN(cardId)) { + try { + // Extract card ID from URL + const url = new URL(req.url) + const pathParts = url.pathname.split('/') + const cardId = pathParts[pathParts.length - 1] + + if (!cardId || isNaN(parseInt(cardId))) { return new NextResponse(JSON.stringify({ error: 'Invalid card ID' }), { status: 400, headers: { @@ -55,10 +69,10 @@ export default async function handler(req: NextRequest) { }) } - const cards: Card[] = cardsData as Card[] - const card = cards.find(c => c.id === cardId) + // Query the database for the specific card + const result = await sql`SELECT * FROM cards WHERE id = ${parseInt(cardId)}` - if (!card) { + if (result.length === 0) { return new NextResponse(JSON.stringify({ error: 'Card not found' }), { status: 404, headers: { @@ -68,6 +82,14 @@ export default async function handler(req: NextRequest) { }) } + // Transform the result to match expected format + const card = { + ...result[0], + colors: result[0].colors ? (typeof result[0].colors === 'string' ? JSON.parse(result[0].colors) : result[0].colors) : null, + artwork_crop_coords: result[0].artwork_crop_coords ? + (typeof result[0].artwork_crop_coords === 'string' ? JSON.parse(result[0].artwork_crop_coords) : result[0].artwork_crop_coords) : null + } + return new NextResponse(JSON.stringify(card), { status: 200, headers: { @@ -78,7 +100,10 @@ export default async function handler(req: NextRequest) { } catch (error) { console.error('API Error:', error) - return new NextResponse(JSON.stringify({ error: 'Internal server error' }), { + return new NextResponse(JSON.stringify({ + error: 'Internal server error', + details: process.env.NODE_ENV === 'development' ? error.message : undefined + }), { status: 500, headers: { ...headers, diff --git a/api/v1/cards/index.ts b/api/v1/cards/index.ts index 95cc6aa..1f2bb22 100644 --- a/api/v1/cards/index.ts +++ b/api/v1/cards/index.ts @@ -1,5 +1,8 @@ import { NextRequest, NextResponse } from 'next/server' -import cardsData from '../../../src/data/cards.json' +import { neon } from '@neondatabase/serverless' + +// Initialize Neon client +const sql = neon(process.env.DATABASE_URL!) interface Card { id: number @@ -51,30 +54,51 @@ export default async function handler(req: NextRequest) { const game = searchParams.get('game') const set_name = searchParams.get('set_name') - let cards: Card[] = cardsData as Card[] + // Build the query dynamically using template literals + let baseQuery = 'SELECT * FROM cards WHERE 1=1' + let queryConditions: string[] = [] + let queryValues: any[] = [] - // Apply filters if (game) { - cards = cards.filter(card => card.game === game) + queryConditions.push(`game = '${game.replace(/'/g, "''")}'`) } if (set_name) { - cards = cards.filter(card => card.set_name === set_name) + queryConditions.push(`set_name = '${set_name.replace(/'/g, "''")}'`) } if (search) { - const searchLower = search.toLowerCase() - cards = cards.filter(card => - card.name.toLowerCase().includes(searchLower) || - (card.oracle_text && card.oracle_text.toLowerCase().includes(searchLower)) || - (card.card_type && card.card_type.toLowerCase().includes(searchLower)) - ) + const escapedSearch = search.replace(/'/g, "''") + queryConditions.push(`(name ILIKE '%${escapedSearch}%' OR oracle_text ILIKE '%${escapedSearch}%' OR card_type ILIKE '%${escapedSearch}%')`) } - // Apply pagination - const paginatedCards = cards.slice(skip, skip + limit) + // Combine conditions + if (queryConditions.length > 0) { + baseQuery += ' AND ' + queryConditions.join(' AND ') + } - return new NextResponse(JSON.stringify(paginatedCards), { + baseQuery += ' ORDER BY name' + + if (limit > 0) { + baseQuery += ` LIMIT ${limit}` + } + + if (skip > 0) { + baseQuery += ` OFFSET ${skip}` + } + + // Execute the query using unsafe (for dynamic queries) + const result = await sql.unsafe(baseQuery) as unknown as any[] + + // Transform the result to match expected format + const cards = result.map((row: any) => ({ + ...row, + colors: row.colors ? (typeof row.colors === 'string' ? JSON.parse(row.colors) : row.colors) : null, + artwork_crop_coords: row.artwork_crop_coords ? + (typeof row.artwork_crop_coords === 'string' ? JSON.parse(row.artwork_crop_coords) : row.artwork_crop_coords) : null + })) + + return new NextResponse(JSON.stringify(cards), { status: 200, headers: { ...headers, @@ -84,7 +108,10 @@ export default async function handler(req: NextRequest) { } catch (error) { console.error('API Error:', error) - return new NextResponse(JSON.stringify({ error: 'Internal server error' }), { + return new NextResponse(JSON.stringify({ + error: 'Internal server error', + details: process.env.NODE_ENV === 'development' ? error.message : undefined + }), { status: 500, headers: { ...headers,