Spaces:
Sleeping
Sleeping
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;
}
}
|