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
14 changes: 14 additions & 0 deletions frontend/src/app/api/shopify/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,20 @@ export async function GET(req: NextRequest) {
console.error('Error storing shop info:', shopError);
}

// Kick off product sync (fire-and-forget). Ported from the Python backend's
// post-OAuth job; runs in this Next.js deployment so no separate service is
// needed for the catalog to populate. Creator discovery still belongs to the
// Python worker. We don't await so the merchant isn't blocked on the redirect.
try {
void fetch(`${FRONTEND_URL}/api/shopify/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ company_id: companyId }),
}).catch((e) => console.error('Product sync trigger failed:', e));
} catch (e) {
console.error('Failed to trigger product sync:', e);
}

// Redirect to dashboard with success
return NextResponse.redirect(`${FRONTEND_URL}/dashboard?shopify=connected&company_id=${companyId}`);
} catch (error) {
Expand Down
115 changes: 115 additions & 0 deletions frontend/src/app/api/shopify/sync/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { supabaseAdmin } from '@/lib/supabaseAdmin';
import { getProducts } from '@/lib/shopify';
import { NextRequest, NextResponse } from 'next/server';

/**
* Sync a connected store's Shopify catalog into `company_products`.
*
* This ports the product-sync half of the Python backend's post-OAuth job to a
* Next.js route so it runs on Vercel without the backend. It does NOT do creator
* discovery / embeddings — that genuinely needs the long-running Python worker
* (Cloud Run). Products are stored raw so the dashboard's Products page and the
* reels matching have a catalog to work with.
*
* Called fire-and-forget from /api/shopify/callback after a successful connect,
* and exposed directly so the dashboard's "Resync" can reuse it.
*
* Auth: requires the company to already have an active Shopify token row (i.e.
* it has completed OAuth). The token is never accepted from the caller.
*/
export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => ({}));
const companyId: string | undefined = body.company_id;

if (!companyId) {
return NextResponse.json({ error: 'company_id is required' }, { status: 400 });
}

// Look up the store's stored OAuth token (server-side; service role).
const { data: tokenRow, error: tokenError } = await supabaseAdmin
.from('shopify_oauth_tokens')
.select('shop_domain, access_token, is_active')
.eq('company_id', companyId)
.eq('is_active', true)
.order('created_at', { ascending: false })
.limit(1)
.single();

if (tokenError || !tokenRow) {
return NextResponse.json(
{ error: 'No active Shopify connection for this company' },
{ status: 404 }
);
}

const shopDomain: string = tokenRow.shop_domain;
const accessToken: string = tokenRow.access_token;

// Fetch the catalog from Shopify.
let products;
try {
products = await getProducts(shopDomain, accessToken);
} catch (e) {
console.error('Shopify product fetch failed:', e);
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Failed to fetch products from Shopify' },
{ status: 502 }
);
}

if (products.length === 0) {
// Mark the attempt so the UI can show "synced, 0 products" rather than an error.
await supabaseAdmin
.from('shopify_oauth_tokens')
.update({ products_synced: true, last_product_sync: new Date().toISOString(), product_count: 0 })
.eq('company_id', companyId)
.eq('shop_domain', shopDomain);
return NextResponse.json({ success: true, synced: 0, message: 'No products found in store.' });
}

// Replace this company's catalog with the freshly fetched one so re-syncs
// don't accumulate duplicates (no natural unique key on company_products).
await supabaseAdmin.from('company_products').delete().eq('company_id', companyId);

const rows = products.map((p) => ({
company_id: companyId,
shop_domain: shopDomain,
title: p.title,
description: p.description,
image: p.image,
price: p.price,
synced_at: new Date().toISOString(),
}));

const { error: insertError } = await supabaseAdmin.from('company_products').insert(rows);
if (insertError) {
console.error('Error inserting products:', insertError);
return NextResponse.json({ error: 'Failed to store products' }, { status: 500 });
}

// Record sync status on the token row.
await supabaseAdmin
.from('shopify_oauth_tokens')
.update({
products_synced: true,
last_product_sync: new Date().toISOString(),
product_count: rows.length,
})
.eq('company_id', companyId)
.eq('shop_domain', shopDomain);

// Flag the company as ingested so the rest of the app treats it as ready.
await supabaseAdmin.from('companies').update({ ingested: true }).eq('company_id', companyId);

return NextResponse.json({
success: true,
synced: rows.length,
message: `Synced ${rows.length} products.`,
note: 'Creator discovery/embeddings are handled separately by the sync service.',
});
} catch (error) {
console.error('Error in Shopify sync:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
65 changes: 65 additions & 0 deletions frontend/src/lib/shopify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,68 @@ export async function createShopifyDiscount(
return false;
}
}

export interface SimplifiedProduct {
title: string;
description: string;
image: string;
price: number;
}

/**
* Fetch a store's product catalog via the Shopify Admin API.
*
* Ports backend/utils/shopify.py:get_products to TypeScript so product sync can
* run on Vercel without the Python backend. Follows Shopify's Link-header
* cursor pagination so stores with >250 products are fully synced.
*/
export async function getProducts(shop: string, accessToken: string): Promise<SimplifiedProduct[]> {
let shopDomain = shop.replace('https://', '').replace('http://', '');
if (!shopDomain.endsWith('.myshopify.com')) {
shopDomain = `${shopDomain}.myshopify.com`;
}

const products: SimplifiedProduct[] = [];
let url: string | null = `https://${shopDomain}/admin/api/2024-01/products.json?limit=250`;

// Cap pages to avoid an unbounded loop on a misbehaving store.
for (let page = 0; url && page < 40; page++) {
const response: Response = await fetch(url, {
headers: {
'X-Shopify-Access-Token': accessToken,
'Content-Type': 'application/json',
},
});

if (response.status === 401) {
throw new Error('Unauthorized: invalid or expired Shopify access token.');
}
if (!response.ok) {
throw new Error(`Failed to fetch products (status ${response.status}).`);
}

const data = await response.json();
for (const product of data.products || []) {
const variants = product.variants || [];
// Lowest variant price; default 0 if none. (The Python version had an
// inverted guard that indexed an empty array — fixed here.)
const price = variants.length > 0
? Math.min(...variants.map((v: { price?: string }) => parseFloat(v.price || '0') || 0))
: 0;
const image = product.images?.[0]?.src || product.image?.src || '';
products.push({
title: product.title || 'Untitled product',
description: product.body_html || '',
image,
price,
});
}

// Shopify returns the next page in the Link header as rel="next".
const link = response.headers.get('link') || response.headers.get('Link');
const nextMatch = link?.match(/<([^>]+)>;\s*rel="next"/);
url = nextMatch ? nextMatch[1] : null;
}

return products;
}