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
+}