/** * GET /trending * * Design intent: * - Trending reflects calm resonance, not excitement. * - Nothing trends forever. * - Categories do not compete. * * Implementation: * - Category-scoped lists only (no global trending) * - Eligibility: Positive or Neutral tone, High CIS, Low block/report rate * - Rank by calm velocity (steady appreciation > spikes) * - Allow admin editorial override with expiration */ import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts'; import { rankPosts, type PostForRanking } from '../_shared/ranking.ts'; serve(async (req) => { if (req.method === 'OPTIONS') { return new Response(null, { headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }, }); } try { // 1. Validate auth const authHeader = req.headers.get('Authorization'); if (!authHeader) { return new Response(JSON.stringify({ error: 'Missing authorization header' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const supabase = createSupabaseClient(authHeader); const serviceClient = createServiceClient(); const { data: { user }, error: authError, } = await supabase.auth.getUser(); if (authError || !user) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // 2. Parse query params const url = new URL(req.url); const categorySlug = url.searchParams.get('category'); const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50); if (!categorySlug) { return new Response( JSON.stringify({ error: 'Missing category', message: 'Trending is category-scoped. Provide a category slug.', }), { status: 400, headers: { 'Content-Type': 'application/json' }, } ); } // 3. Get category ID const { data: category, error: categoryError } = await supabase .from('categories') .select('id, name, slug') .eq('slug', categorySlug) .single(); if (categoryError || !category) { return new Response(JSON.stringify({ error: 'Category not found' }), { status: 404, headers: { 'Content-Type': 'application/json' }, }); } // 4. Check for editorial overrides (unexpired) const { data: overrides } = await supabase .from('trending_overrides') .select( ` post_id, reason, posts ( id, body, created_at, tone_label, cis_score, allow_chain, chain_parent_id, chain_parent:posts!posts_chain_parent_id_fkey ( id, body, created_at, author:profiles!posts_author_id_fkey ( id, handle, display_name, avatar_url ) ), author:profiles!posts_author_id_fkey ( id, handle, display_name, avatar_url ), category:categories!posts_category_id_fkey ( id, slug, name ), metrics:post_metrics ( like_count, save_count ) ) ` ) .eq('category_id', category.id) .gt('expires_at', new Date().toISOString()) .order('created_at', { ascending: false }); const overridePosts = overrides?.map((o: any) => ({ ...o.posts, is_editorial: true, editorial_reason: o.reason, })) || []; // 5. Fetch candidate posts for algorithmic trending // Eligibility: // - Positive or Neutral tone only // - CIS >= 0.8 (high content integrity) // - Created in last 48 hours (trending is recent) // - Active status const twoDaysAgo = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(); const { data: posts, error: postsError } = await serviceClient .from('posts') .select( ` id, body, created_at, category_id, tone_label, cis_score, author_id, author:profiles!posts_author_id_fkey ( id, handle, display_name, avatar_url ), category:categories!posts_category_id_fkey ( id, slug, name ), metrics:post_metrics ( like_count, save_count, view_count ) ` ) .eq('category_id', category.id) .in('tone_label', ['positive', 'neutral']) .gte('cis_score', 0.8) .gte('created_at', twoDaysAgo) .eq('status', 'active') .limit(100); // Candidate pool if (postsError) { console.error('Error fetching trending posts:', postsError); return new Response(JSON.stringify({ error: 'Failed to fetch trending posts' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } // 6. Enrich posts with safety metrics const authorIds = [...new Set(posts.map((p) => p.author_id))]; const { data: trustStates } = await serviceClient .from('trust_state') .select('user_id, harmony_score, tier') .in('user_id', authorIds); const trustMap = new Map(trustStates?.map((t) => [t.user_id, t]) || []); const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); const { data: recentBlocks } = await serviceClient .from('blocks') .select('blocked_id') .in('blocked_id', authorIds) .gte('created_at', oneDayAgo); const blocksMap = new Map(); recentBlocks?.forEach((block) => { blocksMap.set(block.blocked_id, (blocksMap.get(block.blocked_id) || 0) + 1); }); const postIds = posts.map((p) => p.id); const { data: reports } = await serviceClient .from('reports') .select('target_id, reporter_id') .eq('target_type', 'post') .in('target_id', postIds); const trustedReportMap = new Map(); const totalReportMap = new Map(); for (const report of reports || []) { totalReportMap.set(report.target_id, (totalReportMap.get(report.target_id) || 0) + 1); const reporterTrust = trustMap.get(report.reporter_id); if (reporterTrust && reporterTrust.harmony_score >= 70) { trustedReportMap.set(report.target_id, (trustedReportMap.get(report.target_id) || 0) + 1); } } // 7. Filter out posts with safety issues const safePosts = posts.filter((post) => { const blocksReceived = blocksMap.get(post.author_id) || 0; const trustedReports = trustedReportMap.get(post.id) || 0; // Exclude if: // - Author received 2+ blocks in 24h // - Post has any trusted reports return blocksReceived < 2 && trustedReports === 0; }); // 8. Transform and rank const postsForRanking: PostForRanking[] = safePosts.map((post) => { const authorTrust = trustMap.get(post.author_id); return { id: post.id, created_at: post.created_at, cis_score: post.cis_score || 0.8, tone_label: post.tone_label || 'neutral', save_count: post.metrics?.save_count || 0, like_count: post.metrics?.like_count || 0, view_count: post.metrics?.view_count || 0, author_harmony_score: authorTrust?.harmony_score || 50, author_tier: authorTrust?.tier || 'new', blocks_received_24h: blocksMap.get(post.author_id) || 0, trusted_reports: trustedReportMap.get(post.id) || 0, total_reports: totalReportMap.get(post.id) || 0, }; }); const rankedPosts = rankPosts(postsForRanking); // 9. Take top N algorithmic posts const topPosts = rankedPosts.slice(0, limit - overridePosts.length); // 10. Fetch full data for algorithmic posts const algorithmicIds = topPosts.map((p) => p.id); const { data: algorithmicPosts } = await supabase .from('posts') .select( ` id, body, created_at, tone_label, allow_chain, chain_parent_id, chain_parent:posts!posts_chain_parent_id_fkey ( id, body, created_at, author:profiles!posts_author_id_fkey ( id, handle, display_name, avatar_url ) ), author:profiles!posts_author_id_fkey ( id, handle, display_name, avatar_url ), category:categories!posts_category_id_fkey ( id, slug, name ), metrics:post_metrics ( like_count, save_count ) ` ) .in('id', algorithmicIds); const algorithmicWithFlag = algorithmicPosts?.map((p) => ({ ...p, is_editorial: false })) || []; // 11. Merge editorial overrides first, then algorithmic const trendingPosts = [...overridePosts, ...algorithmicWithFlag]; return new Response( JSON.stringify({ category: { id: category.id, slug: category.slug, name: category.name, }, posts: trendingPosts, explanation: 'Trending shows calm resonance: steady saves and appreciation from trusted accounts. Editorial picks are marked. Nothing trends forever.', }), { status: 200, headers: { 'Content-Type': 'application/json' }, } ); } catch (error) { console.error('Unexpected error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } });