diff --git a/app/api/gift-exchanges/[id]/giftSuggestions/route.ts b/app/api/gift-exchanges/[id]/giftSuggestions/route.ts index ebf3ead6..5f78463a 100644 --- a/app/api/gift-exchanges/[id]/giftSuggestions/route.ts +++ b/app/api/gift-exchanges/[id]/giftSuggestions/route.ts @@ -27,11 +27,11 @@ export async function GET( throw new SupabaseError('User is not authenticated or exists', 500); } - // Get match with full profile info - const { data: match, error: matchError } = await supabase - .from('gift_exchange_members') - .select( - ` + const [matchResult, suggestionsResult] = await Promise.all([ + supabase + .from('gift_exchange_members') + .select( + ` id, recipient_id, recipient:profiles!gift_exchange_members_recipient_id_profiles_fkey ( @@ -48,40 +48,39 @@ export async function GET( avatar ) `, - ) - .eq('gift_exchange_id', id) - .eq('user_id', user.id) - .single(); + ) + .eq('gift_exchange_id', id) + .eq('user_id', user.id) + .single(), + supabase + .from('gift_suggestions') + .select('*') + .eq('gift_exchange_id', id) + .eq('giver_id', user.id), + ]); - if (matchError) { + if (matchResult.error) { throw new SupabaseError( 'Failed to fetch match', - matchError.code, - matchError, + matchResult.error.code, + matchResult.error, ); } - // Get suggestions - const { data: suggestions, error: suggestionsError } = await supabase - .from('gift_suggestions') - .select('*') - .eq('gift_exchange_id', id) - .eq('giver_id', user.id); - - if (suggestionsError) { + if (suggestionsResult.error) { throw new SupabaseError( 'Failed to fetch suggestions', - suggestionsError.code, - suggestionsError, + suggestionsResult.error.code, + suggestionsResult.error, ); } return NextResponse.json({ - match: match.recipient, - suggestions: suggestions.map((s) => ({ - ...s.suggestion, - id: s.id, - created_at: s.created_at, + match: matchResult.data?.recipient, + suggestions: (suggestionsResult.data || []).map((suggestion) => ({ + ...suggestion.suggestion, + id: suggestion.id, + created_at: suggestion.created_at, })), }); } catch (error) { diff --git a/app/types/giftSuggestion.ts b/app/types/giftSuggestion.ts index e9476904..d8e71a40 100644 --- a/app/types/giftSuggestion.ts +++ b/app/types/giftSuggestion.ts @@ -9,4 +9,5 @@ export interface IGiftSuggestion { matchReasons: string[]; matchScore: number; imageUrl: string | null; + productUrl?: string | null; // direct Amazon product detail page } diff --git a/components/GiftDetailsView/GiftDetailsView.tsx b/components/GiftDetailsView/GiftDetailsView.tsx index 0b2a0cdd..dc332f21 100644 --- a/components/GiftDetailsView/GiftDetailsView.tsx +++ b/components/GiftDetailsView/GiftDetailsView.tsx @@ -36,10 +36,19 @@ const GiftDetailsView = ({ setImageError(true); }, []); - const handleAmazonLink = ({ searchTerm }: { searchTerm: string }) => { - const encodedSearch = encodeURIComponent(searchTerm).replace(/%20/g, '+'); - - return `https://www.amazon.com/s?k=${encodedSearch}&tag=${process.env.NEXT_PUBLIC_AMAZON_AFFILIATE_TAG}`; + // If we have a direct product URL from Amazon PAAPI use it; otherwise fall back to search + const buildAmazonLink = (gift: IGiftSuggestion) => { + const affiliateTag = process.env.NEXT_PUBLIC_AMAZON_AFFILIATE_TAG; + if (gift.productUrl && isValidUrl(gift.productUrl)) { + // Attach affiliate tag to product URL if not present + const url = new URL(gift.productUrl); + if (affiliateTag) { + url.searchParams.set('tag', affiliateTag); + } + return url.toString(); + } + const encodedSearch = encodeURIComponent(gift.title).replace(/%20/g, '+'); + return `https://www.amazon.com/s?k=${encodedSearch}${affiliateTag ? `&tag=${affiliateTag}` : ''}`; }; const showImage = gift.imageUrl && isValidUrl(gift.imageUrl) && !imageError; @@ -56,12 +65,13 @@ const GiftDetailsView = ({ onError={handleImageError} /> ) : ( -
+
+ No image available
)} @@ -103,13 +113,13 @@ const GiftDetailsView = ({
diff --git a/lib/drawGiftExchange.ts b/lib/drawGiftExchange.ts index b13113f1..538d2681 100644 --- a/lib/drawGiftExchange.ts +++ b/lib/drawGiftExchange.ts @@ -69,40 +69,47 @@ export async function drawGiftExchange( // Shuffle members to assign const shuffledMembers = [...members].sort(() => Math.random() - 0.5); - // Update assignments - for (let i = 0; i < shuffledMembers.length; i++) { - const giver = shuffledMembers[i]; - // Last person gives to first person, closing the circle - const recipient = shuffledMembers[(i + 1) % shuffledMembers.length]; - - const { error: updateError } = await supabase - .from('gift_exchange_members') - .update({ - recipient_id: recipient.user_id, - has_drawn: true, - }) - .eq('id', giver.id); - - if (updateError) { + const assignments = shuffledMembers.map((member, index) => ({ + giver: member, + recipient: shuffledMembers[(index + 1) % shuffledMembers.length], + })); + + // Perform all member recipient assignments in parallel + const assignmentResults = await Promise.allSettled( + assignments.map((assignment) => + supabase + .from('gift_exchange_members') + .update({ recipient_id: assignment.recipient.user_id, has_drawn: true }) + .eq('id', assignment.giver.id), + ), + ); + + for (const result of assignmentResults) { + if (result.status === 'rejected') { + throw new SupabaseError('Failed to assign recipients', 500); + } + if ('value' in result && result.value.error) { throw new SupabaseError( 'Failed to assign recipients', - updateError.code, - updateError, + result.value.error.code, + result.value.error, ); } - - // Fire and forget suggestions with error handling - // hacky way to avoid waiting for all suggestions to be generated - // avoids timeout issues - await generateAndStoreSuggestions( - supabase, - exchangeId, - giver.user_id, - recipient.user_id, - exchange.budget, - ); } + // Generate suggestions for all members concurrently (do not block one another) + await Promise.allSettled( + assignments.map((assignment) => + generateAndStoreSuggestions( + supabase, + exchangeId, + assignment.giver.user_id, + assignment.recipient.user_id, + exchange.budget, + ), + ), + ); + // Update exchange status to active const { error: statusError } = await supabase .from('gift_exchanges') diff --git a/lib/generateAndStoreSuggestions.ts b/lib/generateAndStoreSuggestions.ts index d60f457e..f47c3ddf 100644 --- a/lib/generateAndStoreSuggestions.ts +++ b/lib/generateAndStoreSuggestions.ts @@ -5,6 +5,10 @@ import { SupabaseClient } from '@supabase/supabase-js'; import { openai } from '../app/api/openaiConfig/config'; import { getAmazonImage } from './getAmazonImage'; import { SupabaseError, OpenAiError } from './errors/CustomErrors'; +import { + IGeneratedSuggestionRaw, + IGeneratedSuggestionNormalized, +} from './interfaces/IGeneratedSuggestionRaw'; /** * Generates and store gift suggestions @@ -99,39 +103,76 @@ export async function generateAndStoreSuggestions( } } - const parsedResponse = JSON.parse(jsonContent); - - // Process each suggestion with Amazon data - for (const suggestion of parsedResponse) { - const amazonData = await getAmazonImage(suggestion.title); - - const cleanSuggestion = { - title: String(suggestion.title), - price: String(suggestion.price), - description: String(suggestion.description), - matchReasons: Array.isArray(suggestion.matchReasons) - ? suggestion.matchReasons.map(String) - : [], - matchScore: Number(suggestion.matchScore), - imageUrl: amazonData.imageUrl || null, + const parsedResponse: IGeneratedSuggestionRaw[] = ((): IGeneratedSuggestionRaw[] => { + const raw = JSON.parse(jsonContent) as unknown[]; + return raw.map((item) => { + const obj = item as Record; + const matchReasonsRaw = obj.matchReasons; + return { + title: String(obj.title ?? ''), + price: + typeof obj.price === 'number' + ? obj.price + : String(obj.price ?? '').trim(), + description: String(obj.description ?? ''), + matchReasons: Array.isArray(matchReasonsRaw) + ? matchReasonsRaw.map(String) + : [], + matchScore: Number(obj.matchScore ?? 0), + }; + }); + })(); + + // Fetch Amazon images in parallel + const imageResults = await Promise.allSettled( + parsedResponse.map((response) => getAmazonImage(String(response.title))), + ); + + const rows = parsedResponse.map((suggestion, idx): { + gift_exchange_id: string; + giver_id: string; + recipient_id: string; + suggestion: IGeneratedSuggestionNormalized; + } => { + const imageResult = imageResults[idx]; + const imageUrl = + imageResult.status === 'fulfilled' && imageResult.value.imageUrl + ? imageResult.value.imageUrl + : null; + const productUrl = + imageResult.status === 'fulfilled' && imageResult.value.productUrl + ? imageResult.value.productUrl + : null; + + return { + gift_exchange_id: exchangeId, + giver_id: giverId, + recipient_id: recipientId, + suggestion: { + title: suggestion.title, + price: + typeof suggestion.price === 'number' + ? String(suggestion.price) + : suggestion.price, + description: suggestion.description, + matchReasons: suggestion.matchReasons, + matchScore: suggestion.matchScore, + imageUrl, + productUrl, + }, }; + }); - const { error: suggestionError } = await supabase - .from('gift_suggestions') - .insert({ - gift_exchange_id: exchangeId, - giver_id: giverId, - recipient_id: recipientId, - suggestion: cleanSuggestion, - }); - - if (suggestionError) { - throw new SupabaseError( - 'Failed to store suggestion', - suggestionError.code, - suggestionError, - ); - } + const { error: insertError } = await supabase + .from('gift_suggestions') + .insert(rows); + + if (insertError) { + throw new SupabaseError( + 'Failed to store suggestions', + insertError.code, + insertError, + ); } } catch (error) { throw error; diff --git a/lib/getAmazonImage.ts b/lib/getAmazonImage.ts index 4fcfff69..a49d1ae5 100644 --- a/lib/getAmazonImage.ts +++ b/lib/getAmazonImage.ts @@ -1,8 +1,6 @@ // Copyright (c) Gridiron Survivor. // Licensed under the MIT License. -import { GoogleError, BackendError } from './errors/CustomErrors'; - /** * Amazon Image * @param {string} title - The title of the search item @@ -11,50 +9,9 @@ import { GoogleError, BackendError } from './errors/CustomErrors'; */ export const getAmazonImage = async ( title: string, -): Promise<{ imageUrl: string | null }> => { - const API_KEY = process.env.GOOGLE_API_KEY; - const CSE_ID = process.env.GOOGLE_CSE_ID; - - const cleanTitle = title - .replace(/[^\w\s-]/g, '') - .replace(/\s+/g, ' ') - .trim(); - - const searchQuery = `${cleanTitle} amazon.com product`; - - try { - const response = await fetch( - `https://customsearch.googleapis.com/customsearch/v1?key=${API_KEY}&cx=${CSE_ID}&q=${encodeURIComponent(searchQuery)}&searchType=image&num=10`, - ); - - if (!response.ok) { - throw new GoogleError( - 'Error fetching Google Search results:', - response.status, - ); - } - - const data = await response.json(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const amazonItems = data.items?.filter((item: any) => { - try { - const domain = new URL(item.image.contextLink).hostname; - return domain.includes('amazon.'); - } catch { - throw new BackendError('Failed to fetch valid Amazon product url', 404); - } - }); - - // Get the first Amazon item if available - if (amazonItems?.length > 0) { - return { - imageUrl: amazonItems[0].link, - }; - } - - throw new BackendError('Failed to fetch Amazon image', 404); - } catch (error) { - throw error; - } +): Promise<{ imageUrl: string | null; productUrl: string | null }> => { + const affiliateTag = process.env.NEXT_PUBLIC_AMAZON_AFFILIATE_TAG; + const encodedSearch = encodeURIComponent(title).replace(/%20/g, '+'); + const productUrl = `https://www.amazon.com/s?k=${encodedSearch}${affiliateTag ? `&tag=${affiliateTag}` : ''}`; + return { imageUrl: null, productUrl }; }; diff --git a/lib/interfaces/IGeneratedSuggestionRaw.ts b/lib/interfaces/IGeneratedSuggestionRaw.ts new file mode 100644 index 00000000..50b6b15a --- /dev/null +++ b/lib/interfaces/IGeneratedSuggestionRaw.ts @@ -0,0 +1,21 @@ +// Copyright (c) Gridiron Survivor. +// Licensed under the MIT License. + +export interface IGeneratedSuggestionRaw { + title: string; + price: string | number; // OpenAI may return number or formatted string + description: string; + matchReasons: string[]; // Ensure array shape + matchScore: number; // 0-100 +} + +// Normalized shape we persist (after coercion / enrichment) +export interface IGeneratedSuggestionNormalized { + title: string; + price: string; // stored consistently as string + description: string; + matchReasons: string[]; + matchScore: number; + imageUrl: string | null; + productUrl: string | null; // direct Amazon product detail page +}