Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 26 additions & 27 deletions app/api/gift-exchanges/[id]/giftSuggestions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions app/types/giftSuggestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export interface IGiftSuggestion {
matchReasons: string[];
matchScore: number;
imageUrl: string | null;
productUrl?: string | null; // direct Amazon product detail page
}
24 changes: 17 additions & 7 deletions components/GiftDetailsView/GiftDetailsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -56,12 +65,13 @@ const GiftDetailsView = ({
onError={handleImageError}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="w-full h-full flex flex-col items-center justify-center">
<GiftIcon
role="img"
className="w-16 h-16 text-gray-300"
aria-label="gift placeholder image"
/>
<span className="mt-2 text-xs text-gray-400">No image available</span>
</div>
)}

Expand Down Expand Up @@ -103,13 +113,13 @@ const GiftDetailsView = ({
<CardFooter className="flex flex-col">
<div className="flex justify-between w-full">
<a
href={handleAmazonLink({ searchTerm: gift.title })}
href={buildAmazonLink(gift)}
target="_blank"
rel="noopener noreferrer"
>
<Button
className="text-sm w-32 h-9 bg-primaryButtonYellow hover:bg-primaryButtonYellow70"
onClick={() => handleAmazonLink({ searchTerm: gift.title })}
onClick={() => {}}
>
<SquareArrowOutUpRight /> View
</Button>
Expand Down
63 changes: 35 additions & 28 deletions lib/drawGiftExchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
103 changes: 72 additions & 31 deletions lib/generateAndStoreSuggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown>;
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;
Expand Down
53 changes: 5 additions & 48 deletions lib/getAmazonImage.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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}` : ''}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

title is just the name of a gift to be searched. This just uses title as if it's a product id with the goal of creating an Amazon product page. But by using /s?k= it just creates a search page instead.

return { imageUrl: null, productUrl };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imageUrl will always be null here.

};
Loading