364 lines
15 KiB
TypeScript
364 lines
15 KiB
TypeScript
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<string, TrustState>(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<string, number>();
|
|
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<string, number>();
|
|
const totalReportMap = new Map<string, number>();
|
|
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" } });
|
|
}
|
|
});
|