55 *
66 * Searches Amazon via Rainforest API and returns the first result's
77 * product URL (with affiliate tag) and image. Used for "Buy on Amazon" links.
8+ *
9+ * Results are cached in Supabase for 30 days to minimize API usage.
810 */
911
1012import { NextRequest , NextResponse } from 'next/server' ;
13+ import { createClient } from '@supabase/supabase-js' ;
1114
12- const RAINFOREST_API_KEY = process . env . RAINFOREST_API_KEY || 'AFE7ECC9F15F44EDB49BE6E9A203CC5F' ;
15+ // Read at runtime to support test env overrides
16+ function getRainforestKey ( ) { return process . env . RAINFOREST_API_KEY || '' ; }
1317const AMAZON_ASSOCIATE_ID = 'media-streamer-20' ;
1418const RAINFOREST_BASE = 'https://api.rainforestapi.com/request' ;
1519
20+ const SUPABASE_URL = process . env . SUPABASE_URL || process . env . NEXT_PUBLIC_SUPABASE_URL || '' ;
21+ const SUPABASE_SERVICE_KEY = process . env . SUPABASE_SERVICE_ROLE_KEY || '' ;
22+
1623/**
1724 * Map content types to Amazon category IDs for more relevant results
1825 */
@@ -23,23 +30,63 @@ const CATEGORY_MAP: Record<string, string> = {
2330 book : '283155' , // Books
2431} ;
2532
33+ function getSupabase ( ) {
34+ if ( ! SUPABASE_URL || ! SUPABASE_SERVICE_KEY ) return null ;
35+ return createClient ( SUPABASE_URL , SUPABASE_SERVICE_KEY ) ;
36+ }
37+
38+ /**
39+ * Generate a normalized cache key from search parameters
40+ */
41+ function getCacheKey ( title : string , contentType : string ) : string {
42+ return `${ title . toLowerCase ( ) . trim ( ) } |${ contentType . toLowerCase ( ) . trim ( ) } ` ;
43+ }
44+
2645export async function GET ( request : NextRequest ) {
2746 const { searchParams } = new URL ( request . url ) ;
2847 const title = searchParams . get ( 'title' ) ?. trim ( ) ;
2948 const contentType = searchParams . get ( 'contentType' ) ?. trim ( ) || '' ;
30- const year = searchParams . get ( 'year' ) ?. trim ( ) || '' ;
3149
3250 if ( ! title ) {
3351 return NextResponse . json ( { error : 'title is required' } , { status : 400 } ) ;
3452 }
3553
54+ const cacheKey = getCacheKey ( title , contentType ) ;
55+ const supabase = getSupabase ( ) ;
56+
57+ // ── 1. Check cache first ──
58+ if ( supabase ) {
59+ try {
60+ const { data : cached } = await supabase
61+ . from ( 'amazon_search_cache' )
62+ . select ( 'result, expires_at' )
63+ . eq ( 'search_key' , cacheKey )
64+ . single ( ) ;
65+
66+ if ( cached ) {
67+ const isExpired = new Date ( cached . expires_at ) < new Date ( ) ;
68+ if ( ! isExpired ) {
69+ // Cache hit — return stored result (may be null = no result)
70+ return NextResponse . json ( { result : cached . result || null } ) ;
71+ }
72+ // Expired — delete and re-fetch
73+ await supabase . from ( 'amazon_search_cache' ) . delete ( ) . eq ( 'search_key' , cacheKey ) ;
74+ }
75+ } catch {
76+ // Cache miss or error — proceed to API
77+ }
78+ }
79+
80+ // ── 2. Fetch from Rainforest API ──
81+ if ( ! getRainforestKey ( ) ) {
82+ return NextResponse . json ( { result : null } ) ;
83+ }
84+
3685 try {
37- // Don't append year — it causes Amazon to match random products with that number
38- // The category filter + title is enough for good results
3986 const searchTerm = title ;
4087
4188 const params = new URLSearchParams ( {
42- api_key : RAINFOREST_API_KEY ,
89+ api_key : getRainforestKey ( ) ,
4390 type : 'search' ,
4491 amazon_domain : 'amazon.com' ,
4592 search_term : searchTerm ,
@@ -54,7 +101,7 @@ export async function GET(request: NextRequest) {
54101 }
55102
56103 const res = await fetch ( `${ RAINFOREST_BASE } ?${ params . toString ( ) } ` , {
57- next : { revalidate : 86400 } , // Cache for 24h
104+ next : { revalidate : 86400 } , // Also use Next.js fetch cache as secondary layer
58105 } ) ;
59106
60107 if ( ! res . ok ) {
@@ -65,22 +112,39 @@ export async function GET(request: NextRequest) {
65112 const data = await res . json ( ) ;
66113 const results = data ?. search_results || [ ] ;
67114
68- if ( results . length === 0 ) {
69- return NextResponse . json ( { result : null } ) ;
70- }
71-
72- // Return the first result
73- const first = results [ 0 ] ;
74- return NextResponse . json ( {
75- result : {
115+ let result = null ;
116+ if ( results . length > 0 ) {
117+ const first = results [ 0 ] ;
118+ result = {
76119 title : first . title || title ,
77120 url : first . link || first . url || null ,
78121 image : first . image || null ,
79122 price : first . price ?. raw || first . price ?. value || null ,
80123 rating : first . rating || null ,
81124 asin : first . asin || null ,
82- } ,
83- } ) ;
125+ } ;
126+ }
127+
128+ // ── 3. Store in cache (including null results to avoid re-querying) ──
129+ if ( supabase ) {
130+ try {
131+ await supabase . from ( 'amazon_search_cache' ) . upsert (
132+ {
133+ search_key : cacheKey ,
134+ title,
135+ content_type : contentType || null ,
136+ result,
137+ expires_at : new Date ( Date . now ( ) + 30 * 24 * 60 * 60 * 1000 ) . toISOString ( ) ,
138+ } ,
139+ { onConflict : 'search_key' }
140+ ) ;
141+ } catch ( err ) {
142+ console . error ( '[Amazon Search] Cache write failed:' , err ) ;
143+ // Non-fatal — still return the result
144+ }
145+ }
146+
147+ return NextResponse . json ( { result } ) ;
84148 } catch ( err ) {
85149 console . error ( '[Amazon Search] Error:' , err ) ;
86150 return NextResponse . json ( { error : 'Internal error' } , { status : 500 } ) ;
0 commit comments