File size: 5,966 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
import { NextRequest, NextResponse } from 'next/server';
import { sendGorseFeedback } 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 PROJECT = 'tcwdbokruvlizkxcpkzj';
const COOKIE_NAME = 'cellex_session_id';

/**
 * Feedback Sync API β€” Low-latency interaction sync pipeline
 *
 * POST /api/feedback
 * Body: {
 *   itemId: string,          // product/video ID
 *   type: 'like' | 'click' | 'view' | 'purchase' | 'save' | 'unsave' | 'unlike' | 'comment' | 'follow',
 *   score?: number,
 *   metadata?: { ... }
 * }
 *
 * This endpoint:
 * 1. Authenticates the user
 * 2. Writes the interaction to the REAL Supabase table (product_view_log, buyers_wishlist, etc.)
 *    so the engagement counts shown in the UI are real, not faked.
 * 3. ALSO fires feedback to Gorse in the background (fire-and-forget) for collaborative filtering.
 * 4. Returns 200 immediately.
 *
 * No more "fake" counts β€” every view/save/like is persisted to the DB.
 */
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 });
  }

  const { itemId, type, score, metadata } = body;

  if (!itemId || !type) {
    return NextResponse.json({ success: false, error: 'itemId and type required' }, { status: 400 });
  }

  // === Authenticate ===
  let userId = 'anonymous';
  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 {}
  }

  // === Write to REAL Supabase tables (non-blocking, best-effort) ===
  // This is what makes the engagement counts in the UI REAL.
  if (userId !== 'anonymous' && SUPABASE_TOKEN) {
    persistFeedbackToSupabase(userId, String(itemId), type, metadata).catch((err) => {
      console.warn('[feedback] Supabase persist failed:', err);
    });
  }

  // === Map interaction types to Gorse feedback types ===
  const gorseTypeMap: Record<string, 'like' | 'click' | 'view' | 'purchase' | 'skip' | 'replay'> = {
    like: 'like',
    unlike: 'skip',       // unliking is a mild negative signal
    click: 'click',
    view: 'view',
    save: 'like',         // saving is a strong positive signal
    unsave: 'skip',
    purchase: 'purchase',
    skip: 'skip',
    replay: 'replay',
    comment: 'like',
    follow: 'like',
    add_to_cart: 'click',
    share: 'click',
  };

  const gorseType = gorseTypeMap[type] || 'view';

  // === Fire and forget to Gorse ===
  sendGorseFeedback(userId, String(itemId), gorseType, score);

  console.log(`[feedback] user=${userId} item=${itemId} type=${type} score=${score ?? 'default'} page=${metadata?.page || 'unknown'}`);

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

/**
 * Persist feedback to the REAL Supabase engagement tables.
 * - view  β†’ INSERT into product_view_log
 * - save  β†’ INSERT into buyers_wishlist (if not already saved)
 * - unsave β†’ DELETE from buyers_wishlist
 * - like  β†’ (videos use the dedicated videos edge function; products don't have a likes table yet)
 *
 * Uses the Supabase management SQL API (service-role token) to write directly.
 * All writes are non-blocking from the caller's perspective (caller doesn't await).
 */
async function persistFeedbackToSupabase(
  userId: string,
  itemId: string,
  type: string,
  metadata: any,
): Promise<void> {
  const safeUserId = userId.replace(/'/g, "''");
  const safeItemId = itemId.replace(/'/g, "''");
  const safeSource = (metadata?.page || 'feed').replace(/'/g, "''").slice(0, 50);

  let query = '';

  if (type === 'view') {
    // Insert a view row (no dedup β€” multiple views by same user are allowed and meaningful)
    query = `INSERT INTO product_view_log (product_id, user_id, source) VALUES (${safeItemId}::bigint, '${safeUserId}'::uuid, '${safeSource}');`;
  } else if (type === 'save') {
    // Insert wishlist row, ignore if already exists (UNIQUE constraint on user_id+product_id if present)
    query = `INSERT INTO buyers_wishlist (user_id, product_id) VALUES ('${safeUserId}'::uuid, ${safeItemId}::bigint) ON CONFLICT DO NOTHING;`;
  } else if (type === 'unsave') {
    query = `DELETE FROM buyers_wishlist WHERE user_id = '${safeUserId}'::uuid AND product_id = ${safeItemId}::bigint;`;
  } else {
    // Other types (like, click, etc.) don't have a dedicated table β€” only Gorse gets them
    return;
  }

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 3000);

  try {
    const resp = await fetch(`https://api.supabase.com/v1/projects/${PROJECT}/database/query`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${SUPABASE_TOKEN}`,
        'Content-Type': 'application/json',
        'User-Agent': 'cellex-feedback',
      },
      body: JSON.stringify({ query }),
      signal: controller.signal,
    });
    clearTimeout(timeout);
    if (!resp.ok) {
      const errText = await resp.text().catch(() => '');
      console.warn(`[feedback] Supabase write failed: HTTP ${resp.status} ${errText.slice(0, 200)}`);
    }
  } catch (err) {
    clearTimeout(timeout);
    throw err;
  }
}