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"; import { trySignR2Url } from "../_shared/r2_signer.ts"; const ALLOWED_ORIGIN = Deno.env.get("ALLOWED_ORIGIN") || "*"; const corsHeaders = { "Access-Control-Allow-Origin": ALLOWED_ORIGIN, "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", }; interface Profile { id: string; handle: string; display_name: string; } interface Category { id: string; slug: string; name: string; } interface PostMetrics { like_count: number; save_count: number; view_count: number; } interface Post { id: string; body: string; body_format?: string; background_id?: string; created_at: string; category_id: string | null; tone_label: "positive" | "neutral" | "mixed" | "negative"; cis_score: number; author_id: string; author: Profile; category: Category; metrics: PostMetrics | null; allow_chain: boolean; chain_parent_id: string | null; image_url: string | null; tags: string[] | null; visibility?: string; user_liked: { user_id: string }[]; user_saved: { user_id: string }[]; // Sponsored ad fields is_sponsored?: boolean; advertiser_name?: string; advertiser_cta_link?: string; advertiser_cta_text?: string; advertiser_body?: string; advertiser_image_url?: string; } interface TrustState { user_id: string; harmony_score: number; tier: "new" | "standard" | "trusted"; } interface Block { blocked_id: string; } interface Report { target_id: string; reporter_id: string; status: "pending" | "resolved"; } interface PostLike { user_id: string; } interface PostSave { user_id: string; } interface SponsoredPost { id: string; advertiser_name: string; body: string; image_url: string | null; cta_link: string; cta_text: string; } serve(async (req: Request) => { if (req.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); } try { const authHeader = req.headers.get("Authorization"); if (!authHeader) { console.error("Missing authorization header"); return new Response(JSON.stringify({ error: "Missing authorization header" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }); } console.log("Auth header present, length:", authHeader.length); // Extract JWT from header const jwt = authHeader.replace("Bearer ", ""); console.log("JWT extracted, length:", jwt.length); const supabase = createSupabaseClient(authHeader); console.log("Supabase client created"); const serviceClient = createServiceClient(); console.log("Service client created"); // Don't pass JWT explicitly - let the SDK validate using its internal session // The auth header was already used to create the client const { data: { user }, error: authError } = await supabase.auth.getUser(); console.log("getUser result:", { userId: user?.id, error: authError }); if (authError || !user) { console.error("Auth error in feed-sojorn:", authError); console.error("User object:", user); return new Response(JSON.stringify({ error: "Unauthorized", details: authError?.message || "No user returned" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }); } const url = new URL(req.url); const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 100); const offset = parseInt(url.searchParams.get("offset") || "0"); // Check if user has opted into beacon posts const { data: profile } = await serviceClient .from("profiles") .select("beacon_enabled") .eq("id", user.id) .single(); const beaconEnabled = profile?.beacon_enabled || false; // Get user's enabled category IDs for ad targeting const { data: userCategorySettings, error: userCategoryError } = await serviceClient .from("user_category_settings") .select("category_id") .eq("user_id", user.id) .eq("enabled", true); if (userCategoryError) { console.error("Error fetching user category settings:", userCategoryError); } const userCategoryIds = (userCategorySettings || []) .map((uc) => uc.category_id) .filter(Boolean); // Map categories to their slugs for tag matching (normalized lowercase) let userCategorySlugs: string[] = []; if (userCategoryIds.length > 0) { const { data: categories } = await serviceClient .from("categories") .select("id, slug") .in("id", userCategoryIds); userCategorySlugs = (categories || []) .map((c: Category) => (c.slug || "").toLowerCase()) .filter((slug) => slug.length > 0); } const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); // Fetch posts prioritizing those with images first, then by recency // This ensures visual content gets featured prominently // Exclude beacon posts unless user has opted in let postsQuery = serviceClient .from("posts") .select(`id, body, body_format, created_at, category_id, tone_label, cis_score, author_id, image_url, tags, visibility, 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("tone_label", ["positive", "neutral", "mixed"]) .gte("created_at", sevenDaysAgo); // Hybrid matching: legacy categories OR new hashtag tags if (userCategoryIds.length > 0 || userCategorySlugs.length > 0) { const orConditions: string[] = []; if (userCategoryIds.length > 0) { orConditions.push(`category_id.in.(${userCategoryIds.join(",")})`); } if (userCategorySlugs.length > 0) { orConditions.push(`tags.ov.{${userCategorySlugs.join(",")}}`); } if (orConditions.length > 0) { postsQuery = postsQuery.or(orConditions.join(",")); } } // Only filter out beacons if user has NOT opted in if (!beaconEnabled) { postsQuery = postsQuery.eq("is_beacon", false); } const { data: posts, error: postsError } = await postsQuery .order("image_url", { ascending: false }) // Posts WITH images first .order("created_at", { ascending: false }) .limit(1000); // Fetch more to rank, then paginate if (postsError) { console.error("Error fetching posts:", postsError); return new Response(JSON.stringify({ error: "Failed to fetch feed", details: postsError.message, code: postsError.code, hint: postsError.hint, }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }); } const safePosts = posts || []; const authorIds = [...new Set(safePosts.map((p: Post) => p.author_id))]; const trustStates = authorIds.length > 0 ? (await serviceClient.from("trust_state").select("user_id, harmony_score, tier").in("user_id", authorIds)).data : []; const trustMap = new Map(trustStates?.map((t: TrustState) => [t.user_id, t]) || []); const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); const recentBlocks = authorIds.length > 0 ? (await serviceClient.from("blocks").select("blocked_id").in("blocked_id", authorIds).gte("created_at", oneDayAgo)).data : []; const blocksMap = new Map(); recentBlocks?.forEach((block: Block) => { blocksMap.set(block.blocked_id, (blocksMap.get(block.blocked_id) || 0) + 1); }); const postIds = safePosts.map((p: Post) => p.id); const reports = postIds.length > 0 ? (await serviceClient.from("reports").select("target_id, reporter_id, status").eq("target_type", "post").in("target_id", postIds)).data : []; 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); } } // Calculate has_image bonus for ranking const postsForRanking: PostForRanking[] = safePosts.map((post: 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, has_image: post.image_url != null && post.image_url.length > 0, }; }); // Use ranking algorithm const rankedPosts = rankPosts(postsForRanking); const paginatedPosts = rankedPosts.slice(offset, offset + limit); const resultIds = paginatedPosts.map((p) => p.id); let finalPosts: Post[] = []; if (resultIds.length > 0) { const { data, error: finalError } = await serviceClient .from("posts") .select(`id, body, body_format, background_id, created_at, tone_label, allow_chain, chain_parent_id, image_url, tags, visibility, 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), user_liked:post_likes!left (user_id), user_saved:post_saves!left (user_id)`) .in("id", resultIds); if (finalError) { console.error("Error fetching final posts:", finalError); return new Response(JSON.stringify({ error: "Failed to fetch feed", details: finalError.message, code: finalError.code, hint: finalError.hint, }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }); } finalPosts = data || []; } let orderedPosts = resultIds.map((id) => finalPosts.find((p: Post) => p.id === id)).filter(Boolean).map((post: Post) => ({ id: post.id, body: post.body, body_format: post.body_format, background_id: post.background_id, created_at: post.created_at, tone_label: post.tone_label, allow_chain: post.allow_chain, chain_parent_id: post.chain_parent_id, image_url: post.image_url, tags: post.tags, visibility: post.visibility, author: post.author, category: post.category, metrics: post.metrics, user_liked: post.user_liked?.some((l: PostLike) => l.user_id === user.id) || false, user_saved: post.user_saved?.some((s: PostSave) => s.user_id === user.id) || false, })); // ========================================================================= // SILENT AD INJECTION - Check for sponsored content // ========================================================================= // Only inject if user has categories and we have posts to inject into if (userCategoryIds.length > 0 && orderedPosts.length > 0) { try { // Fetch a random active ad that matches user's subscribed categories const { data: sponsoredPosts } = await serviceClient .from("sponsored_posts") .select("id, advertiser_name, body, image_url, cta_link, cta_text") .eq("active", true) .or( `target_categories.cs.{*},target_categories.ov.{${userCategoryIds.join(",")}}`, ) .lt("current_impressions", serviceClient.raw("impression_goal")) // Only show if under goal .limit(1); if (sponsoredPosts && sponsoredPosts.length > 0) { const ad = sponsoredPosts[0] as SponsoredPost; // Create a fake post object that looks like a real post but is marked as sponsored const sponsoredPost: Post = { id: ad.id, body: ad.body, body_format: "markdown", background_id: null, created_at: new Date().toISOString(), tone_label: "neutral", cis_score: 1.0, author_id: "sponsored", author: { id: "sponsored", handle: "sponsored", display_name: ad.advertiser_name, }, category: { id: "sponsored", slug: "sponsored", name: "Sponsored", }, metrics: { like_count: 0, save_count: 0, view_count: 0 }, allow_chain: false, chain_parent_id: null, image_url: ad.image_url, tags: null, user_liked: [], user_saved: [], // Sponsored ad markers is_sponsored: true, advertiser_name: ad.advertiser_name, advertiser_cta_link: ad.cta_link, advertiser_cta_text: ad.cta_text, advertiser_body: ad.body, advertiser_image_url: ad.image_url, }; // Inject at position 4 (5th slot) if we have enough posts const adInjectionIndex = 4; if (orderedPosts.length > adInjectionIndex) { orderedPosts = [ ...orderedPosts.slice(0, adInjectionIndex), sponsoredPost, ...orderedPosts.slice(adInjectionIndex), ]; console.log("Sponsored ad injected at index", adInjectionIndex, "advertiser:", ad.advertiser_name); } else { // If not enough posts, inject at the end orderedPosts = [...orderedPosts, sponsoredPost]; console.log("Sponsored ad injected at end, advertiser:", ad.advertiser_name); } } } catch (error) { console.error("Sponsored ad injection failed:", error); } } // ========================================================================= // END AD INJECTION // ========================================================================= const signedPosts = await Promise.all( orderedPosts.map(async (post) => { if (!post.image_url && !post.advertiser_image_url) { return post; } const nextPost = { ...post }; if (post.image_url) { nextPost.image_url = await trySignR2Url(post.image_url); } if (post.advertiser_image_url) { nextPost.advertiser_image_url = await trySignR2Url(post.advertiser_image_url); } return nextPost; }) ); return new Response(JSON.stringify({ posts: signedPosts, pagination: { limit, offset, returned: orderedPosts.length }, ranking_explanation: "Posts are ranked by: 1) Has image bonus, 2) Author harmony score, 3) Steady appreciation (saves > likes), 4) Recency. Sponsored content may appear periodically.", }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }); } catch (error) { console.error("Unexpected error:", error); return new Response(JSON.stringify({ error: "Internal server error", details: error instanceof Error ? error.message : String(error), }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }); } });