File size: 14,537 Bytes
bea55e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
import { NextRequest, NextResponse } from 'next/server';
import { api, API_BASE } from '@/lib/api';
import {
  getGorseRecommendations,
  getGorseItemNeighbors,
  sendGorseFeedback,
  GORSE_URL,
  fetchRealProductRankingFromSupabase,
  getChromaPersonalizedRecommendations,
  upsertProductToChroma,
  deleteProductFromChroma,
} from '@/lib/ai';

const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || '';
const SUPABASE_TOKEN = process.env.SUPABASE_TOKEN || process.env.SUPABASE_SERVICE_KEY || '';
const EDGE_FUNCTIONS_URL = 'https://tcwdbokruvlizkxcpkzj.supabase.co/functions/v1';
const COOKIE_NAME = 'cellex_session_id';
const PROJECT = 'tcwdbokruvlizkxcpkzj';

/**
 * Recommendation API β€” Dynamic AI-driven feeds (replaces hard-coded feeds)
 *
 * POST /api/recommend
 * Body: {
 *   op: 'home' | 'category' | 'shorts' | 'neighbors' | 'feedback'
 *             | 'product_embed' | 'product_delete',
 *   userId?: string,
 *   category?: string,
 *   itemId?: string,
 *   limit?: number,
 *   feedback?: { itemId, type, score? },
 *   product?: { id, name, category, description, price, image_url },  // for product_embed
 *   productId?: string | number,                                      // for product_delete
 * }
 *
 * Ranking strategy (in priority order):
 * 1. If GORSE_URL is configured AND returns IDs β†’ use Gorse (collaborative filtering)
 * 2. Else if user is logged in AND has engagement history β†’ use Chroma semantic similarity
 *    (find products similar to what they've viewed/liked/saved)
 * 3. Else β†’ use real trending score from Supabase
 *    (units_sold*4 + views*0.5 + wishlist*3 + reviews*2 + recency bonus)
 *
 * No more silent Supabase "fallback" that masks a missing Gorse deployment.
 * The source field in the response tells you which path was used.
 */
export async function POST(request: NextRequest) {
  if (!SUPABASE_ANON_KEY) {
    return NextResponse.json({ success: false, error: 'SUPABASE_ANON_KEY not set' }, { status: 500 });
  }

  const sessionId = request.cookies.get(COOKIE_NAME)?.value || '';
  let body: any;
  try { body = await request.json(); } catch {
    return NextResponse.json({ success: false, error: 'Invalid JSON' }, { status: 400 });
  }

  // === AUTH ===
  let userId = '';
  if (sessionId) {
    try {
      const authResp = await fetch(`${EDGE_FUNCTIONS_URL}/auth`, {
        method: 'POST',
        headers: {
          'apikey': SUPABASE_ANON_KEY,
          'Authorization': `Bearer ${sessionId}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ op: 'session' }),
      });
      const authData = await authResp.json();
      if (authData.success && authData.user) {
        userId = authData.user.id;
      }
    } catch {}
  }

  const effectiveUserId = body.userId || userId || 'anonymous';

  switch (body.op) {
    case 'home':            return await handleHome(effectiveUserId, body.limit || 20);
    case 'category':        return await handleCategory(effectiveUserId, body.category || '', body.limit || 30);
    case 'shorts':          return await handleShorts(effectiveUserId, body.limit || 15);
    case 'neighbors':       return await handleNeighbors(body.itemId || '', body.limit || 10);
    case 'feedback':        return await handleFeedback(effectiveUserId, body.feedback);
    case 'product_embed':   return await handleProductEmbed(body.product);
    case 'product_delete':  return await handleProductDelete(body.productId);
    default:
      return NextResponse.json({ success: false, error: `Unknown op: ${body.op}` }, { status: 400 });
  }
}

/**
 * Homepage Feed β€” REAL AI-driven ranking, no hardcoded Supabase fallback.
 *
 * Strategy:
 *   1. Gorse (if configured) β€” collaborative filtering across all users
 *   2. Chroma personalization (if logged-in user has engagement history) β€”
 *      semantic similarity to products they've viewed/liked/saved
 *   3. Real trending (always available) β€” Supabase engagement score
 */
async function handleHome(userId: string, limit: number) {
  const startTime = Date.now();
  const sources: string[] = [];

  // 1. Try Gorse first (only if configured β€” no silent fallback)
  if (GORSE_URL && GORSE_URL !== 'http://localhost:8088') {
    const gorseIds = await getGorseRecommendations(userId, { limit });
    if (gorseIds.length > 0) {
      const hydrated = await hydrateProducts(gorseIds);
      if (hydrated.length > 0) {
        return NextResponse.json({
          success: true,
          source: 'gorse',
          products: hydrated,
          latencyMs: Date.now() - startTime,
        });
      }
    }
    sources.push('gorse:empty');
  }

  // 2. Try Chroma personalization (real AI β€” NVIDIA embeddings + similarity)
  if (userId && userId !== 'anonymous') {
    const chromaIds = await getChromaPersonalizedRecommendations(userId, limit);
    if (chromaIds.length > 0) {
      const hydrated = await hydrateProducts(chromaIds);
      if (hydrated.length > 0) {
        return NextResponse.json({
          success: true,
          source: 'chroma-personalized',
          products: hydrated,
          latencyMs: Date.now() - startTime,
        });
      }
    }
    sources.push('chroma:empty-or-no-history');
  }

  // 3. Real trending β€” Supabase engagement score (units_sold, views, wishlist, reviews)
  const ranked = await fetchRealProductRankingFromSupabase(limit);
  if (ranked.length > 0) {
    const hydrated = await hydrateProducts(ranked.map((r) => r.id));
    // Attach real engagement scores to the hydrated products
    const scoreMap = new Map(ranked.map((r) => [r.id, r]));
    const enriched = hydrated.map((p: any) => ({
      ...p,
      _engagement_score: scoreMap.get(String(p.id))?.score || 0,
      _views_count: scoreMap.get(String(p.id))?.views_count || 0,
    }));
    return NextResponse.json({
      success: true,
      source: 'trending-real',
      products: enriched,
      latencyMs: Date.now() - startTime,
      debug: { sourcesTried: sources },
    });
  }

  // 4. Last resort β€” return empty (DO NOT silently fall back to a hardcoded list)
  return NextResponse.json({
    success: true,
    source: 'empty',
    products: [],
    latencyMs: Date.now() - startTime,
    debug: { sourcesTried: sources },
  });
}

/**
 * Category Page Feed β€” blend category filters with personalization
 */
async function handleCategory(userId: string, category: string, limit: number) {
  const startTime = Date.now();

  if (GORSE_URL && GORSE_URL !== 'http://localhost:8088') {
    const gorseIds = await getGorseRecommendations(userId, { category, limit });
    if (gorseIds.length > 0) {
      const hydrated = await hydrateProducts(gorseIds);
      if (hydrated.length > 0) {
        return NextResponse.json({
          success: true,
          source: 'gorse',
          products: hydrated,
          latencyMs: Date.now() - startTime,
        });
      }
    }
  }

  // Fallback: real category products from Supabase (filtered by category, ranked by engagement)
  const ranked = await fetchRealProductRankingFromSupabase(limit * 3);
  const rankedIds = ranked.map((r) => r.id);
  if (rankedIds.length > 0) {
    const hydrated = await hydrateProducts(rankedIds);
    // Filter by category and re-rank by engagement score
    const scoreMap = new Map(ranked.map((r) => [r.id, r]));
    const filtered = hydrated
      .filter((p: any) => (p.category || '').toLowerCase() === (category || '').toLowerCase())
      .map((p: any) => ({
        ...p,
        _engagement_score: scoreMap.get(String(p.id))?.score || 0,
      }))
      .slice(0, limit);
    if (filtered.length > 0) {
      return NextResponse.json({
        success: true,
        source: 'category-real',
        products: filtered,
        latencyMs: Date.now() - startTime,
      });
    }
  }

  return NextResponse.json({
    success: true,
    source: 'empty',
    products: [],
    latencyMs: Date.now() - startTime,
  });
}

/**
 * Shorts Page Feed β€” hyper-engaging video content, personalized
 */
async function handleShorts(userId: string, limit: number) {
  const startTime = Date.now();

  if (GORSE_URL && GORSE_URL !== 'http://localhost:8088') {
    const gorseIds = await getGorseRecommendations(userId, { limit });
    if (gorseIds.length > 0) {
      const hydrated = await hydrateVideos(gorseIds);
      if (hydrated.length > 0) {
        return NextResponse.json({
          success: true,
          source: 'gorse',
          videos: hydrated,
          latencyMs: Date.now() - startTime,
        });
      }
    }
  }

  // Fallback: existing Supabase video feed (real videos, ranked by recency)
  const fallbackResp = await fetch(`${EDGE_FUNCTIONS_URL}/videos`, {
    method: 'POST',
    headers: { 'apikey': SUPABASE_ANON_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ op: 'feed', limit }),
  }).then((r) => r.json()).catch(() => ({ success: false }));

  return NextResponse.json({
    ...fallbackResp,
    source: 'videos-feed-real',
    latencyMs: Date.now() - startTime,
  });
}

/**
 * Product Detail "Users Also Viewed" β€” item-to-item collaborative filtering
 */
async function handleNeighbors(itemId: string, limit: number) {
  const startTime = Date.now();

  // Try Gorse neighbors
  if (GORSE_URL && GORSE_URL !== 'http://localhost:8088') {
    const neighborIds = await getGorseItemNeighbors(itemId, limit);
    if (neighborIds.length > 0) {
      const hydrated = await hydrateProducts(neighborIds);
      if (hydrated.length > 0) {
        return NextResponse.json({
          success: true,
          source: 'gorse',
          products: hydrated,
          latencyMs: Date.now() - startTime,
        });
      }
    }
  }

  // Fallback: Chroma semantic similarity (same model that powers smart-search)
  // Reuse the query-time embedding flow β€” embed the item's text, query Chroma for neighbors.
  // We do this by calling the smart-search internals indirectly: fetch product, embed, query.
  // For simplicity here, we just return empty if no Gorse; the smart-search endpoint already
  // does Chroma similarity for ad-hoc queries.
  return NextResponse.json({
    success: true,
    source: 'empty',
    products: [],
    latencyMs: Date.now() - startTime,
  });
}

/**
 * Feedback Sync β€” non-blocking, fires to Gorse in background
 */
async function handleFeedback(userId: string, feedback: any) {
  if (!feedback || !feedback.itemId || !feedback.type) {
    return NextResponse.json({ success: false, error: 'Missing feedback fields' }, { status: 400 });
  }

  sendGorseFeedback(userId, feedback.itemId, feedback.type, feedback.score);

  return NextResponse.json({
    success: true,
    message: 'Feedback received',
  });
}

/**
 * Incremental Chroma sync β€” embed a product on create/update.
 * Called by /api/seller-products when a seller creates/edits a product.
 * Non-blocking from the user's perspective β€” the seller's product is saved
 * to Supabase first, then this is fired in the background.
 */
async function handleProductEmbed(product: any) {
  if (!product || !product.id) {
    return NextResponse.json({ success: false, error: 'Missing product.id' }, { status: 400 });
  }

  // Fire and forget β€” we don't block the seller's request on Chroma/NVIDIA
  upsertProductToChroma(product.id, product).then((ok) => {
    if (!ok) console.warn(`[recommend] product_embed failed for ${product.id}`);
  });

  return NextResponse.json({
    success: true,
    message: 'Embedding queued',
    productId: product.id,
  });
}

/**
 * Incremental Chroma sync β€” delete a product's embedding on product delete.
 */
async function handleProductDelete(productId: string | number) {
  if (!productId) {
    return NextResponse.json({ success: false, error: 'Missing productId' }, { status: 400 });
  }

  deleteProductFromChroma(productId).then((ok) => {
    if (!ok) console.warn(`[recommend] product_delete failed for ${productId}`);
  });

  return NextResponse.json({
    success: true,
    message: 'Delete queued',
    productId,
  });
}

/**
 * Hydrate product IDs with full product data from Supabase.
 */
async function hydrateProducts(productIds: string[]): Promise<any[]> {
  if (!productIds.length) return [];

  const sqlHeaders: Record<string, string> = {
    'Authorization': `Bearer ${SUPABASE_TOKEN}`,
    'Content-Type': 'application/json',
    'User-Agent': 'Mozilla/5.0',
  };

  try {
    const ids = productIds.map((id) => `'${String(id).replace(/'/g, "''")}'`).join(',');
    const resp = await fetch(`https://api.supabase.com/v1/projects/${PROJECT}/database/query`, {
      method: 'POST',
      headers: sqlHeaders,
      body: JSON.stringify({
        query: `SELECT id, name, price, image_url, category, seller_id, units_sold, description, created_at FROM products WHERE id IN (${ids});`,
      }),
    });

    const data = await resp.json();
    if (!Array.isArray(data)) return [];

    // Sort by the order they were returned (most relevant first)
    const productMap = new Map(data.map((p: any) => [String(p.id), p]));
    return productIds
      .map((id) => productMap.get(id))
      .filter(Boolean);
  } catch (err) {
    console.error('[recommend] hydrateProducts failed:', err);
    return [];
  }
}

/**
 * Hydrate video IDs with full video data from Supabase.
 */
async function hydrateVideos(videoIds: string[]): Promise<any[]> {
  if (!videoIds.length) return [];

  const sqlHeaders: Record<string, string> = {
    'Authorization': `Bearer ${SUPABASE_TOKEN}`,
    'Content-Type': 'application/json',
    'User-Agent': 'Mozilla/5.0',
  };

  try {
    const ids = videoIds.map((id) => `'${String(id).replace(/'/g, "''")}'`).join(',');
    const resp = await fetch(`https://api.supabase.com/v1/projects/${PROJECT}/database/query`, {
      method: 'POST',
      headers: sqlHeaders,
      body: JSON.stringify({
        query: `SELECT v.id, v.video_url, v.caption, v.views_count, v.likes_count, v.created_at, v.product_id, p.name as product_name, p.price, p.image_url, s.business_name as seller_name, s.profile_image as seller_image FROM videos v LEFT JOIN products p ON v.product_id = p.id LEFT JOIN sellers s ON v.seller_id = s.id WHERE v.id IN (${ids});`,
      }),
    });

    const data = await resp.json();
    if (!Array.isArray(data)) return [];

    const videoMap = new Map(data.map((v: any) => [String(v.id), v]));
    return videoIds
      .map((id) => videoMap.get(id))
      .filter(Boolean);
  } catch (err) {
    console.error('[recommend] hydrateVideos failed:', err);
    return [];
  }
}