sojorn/_legacy/supabase/functions/trending/index.ts
2026-02-15 00:33:24 -06:00

346 lines
9.9 KiB
TypeScript

/**
* 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<string, number>();
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<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);
}
}
// 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' },
});
}
});