346 lines
9.9 KiB
TypeScript
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' },
|
|
});
|
|
}
|
|
});
|