/**
* Recommendation Algorithm for Forum Posts
* Generates personalized post recommendations based on user activity
*/
import { createServerClient } from '@/lib/supabase-server';
import type { ForumPost } from '@/types/forum';
interface RecommendationFactors {
categoryMatch: number; // 50 points - User's active categories
billMatch: number; // 30 points - Bookmarked bills
engagementMatch: number; // 20 points - Similar voting patterns
recencyBoost: number; // 10 points - Recent posts get boost
diversityPenalty: number; // -10 points - Avoid same author repeatedly
}
interface ScoredPost {
post: ForumPost;
score: number;
factors: RecommendationFactors;
}
interface UserProfile {
topCategories: string[]; // Top 3 categories by vote count
bookmarkedBills: string[]; // Bill numbers user has bookmarked
votedPostIds: string[]; // Posts user has already voted on
recentAuthors: string[]; // Authors user has recently seen
}
const SCORING_WEIGHTS = {
CATEGORY_MATCH: 50,
BILL_MATCH: 30,
ENGAGEMENT_MATCH: 20,
RECENCY_BOOST: 10,
DIVERSITY_PENALTY: -10,
};
const LOOKBACK_DAYS = 7; // Only recommend posts from last 7 days
const MIN_SCORE_THRESHOLD = 30; // Minimum score to recommend
const CACHE_TTL_SECONDS = 3600; // 1 hour cache
/**
* Generate personalized post recommendations for a user
*/
export async function generateRecommendations(
userId: string,
limit: number = 5
): Promise<ForumPost[]> {
try {
const supabase = await createServerClient();
// Step 1: Build user profile from activity
const userProfile = await buildUserProfile(userId);
// Step 2: Fetch candidate posts (last 7 days, not voted on, not deleted)
const candidatePosts = await fetchCandidatePosts(userId, userProfile);
if (candidatePosts.length === 0) {
// No candidates, return trending as fallback
return await getTrendingFallback(limit);
}
// Step 3: Score each candidate post
const scoredPosts = candidatePosts.map((post) => {
const factors = calculateScoreFactors(post, userProfile);
const score = calculateTotalScore(factors);
return {
post,
score,
factors,
};
});
// Step 4: Filter by minimum threshold and sort by score
const qualifiedPosts = scoredPosts
.filter((sp) => sp.score >= MIN_SCORE_THRESHOLD)
.sort((a, b) => b.score - a.score);
// Step 5: Return top N posts
if (qualifiedPosts.length === 0) {
// No qualified recommendations, return trending
return await getTrendingFallback(limit);
}
return qualifiedPosts.slice(0, limit).map((sp) => sp.post);
} catch (error) {
console.error('Error generating recommendations:', error);
// Fallback to trending on error
return await getTrendingFallback(limit);
}
}
/**
* Build user profile from activity history
*/
async function buildUserProfile(userId: string): Promise<UserProfile> {
const supabase = await createServerClient();
// Get user's votes (top 3 categories by vote count)
const { data: votes } = await supabase
.from('forum_votes')
.select(
`
post_id,
forum_posts!inner(
id,
category_id,
bill_number,
author_id
)
`
)
.eq('user_id', userId)
.limit(100);
// Aggregate category votes
const categoryVotes: Record<string, number> = {};
const votedPostIds: string[] = [];
const recentAuthors: string[] = [];
const billInteractions: Set<string> = new Set();
if (votes) {
votes.forEach((vote: any) => {
const post = vote.forum_posts;
if (post) {
votedPostIds.push(post.id);
if (post.category_id) {
categoryVotes[post.category_id] = (categoryVotes[post.category_id] || 0) + 1;
}
if (post.bill_number) {
billInteractions.add(post.bill_number);
}
if (post.author_id && !recentAuthors.includes(post.author_id)) {
recentAuthors.push(post.author_id);
}
}
});
}
// Sort categories by vote count and take top 3
const topCategories = Object.entries(categoryVotes)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([categoryId]) => categoryId);
// TODO: Get bookmarked bills from user preferences
// For now, use bills they've interacted with
const bookmarkedBills = Array.from(billInteractions);
return {
topCategories,
bookmarkedBills,
votedPostIds,
recentAuthors: recentAuthors.slice(-10), // Last 10 authors
};
}
/**
* Fetch candidate posts for recommendation
*/
async function fetchCandidatePosts(
userId: string,
userProfile: UserProfile
): Promise<ForumPost[]> {
const supabase = await createServerClient();
// Calculate date threshold (7 days ago)
const dateThreshold = new Date();
dateThreshold.setDate(dateThreshold.getDate() - LOOKBACK_DAYS);
// Fetch posts from last 7 days that user hasn't voted on
const { data: posts } = await supabase
.from('forum_posts')
.select(
`
*,
category:forum_categories(*),
author:user_profiles(*)
`
)
.gte('created_at', dateThreshold.toISOString())
.is('is_deleted', false)
.is('parent_post_id', null) // Only top-level posts
.not('id', 'in', `(${userProfile.votedPostIds.join(',') || "''"})`)
.order('created_at', { ascending: false })
.limit(50); // Fetch more candidates than needed
return (posts || []) as ForumPost[];
}
/**
* Calculate recommendation score factors for a post
*/
function calculateScoreFactors(post: ForumPost, userProfile: UserProfile): RecommendationFactors {
let categoryMatch = 0;
let billMatch = 0;
let engagementMatch = 0;
let recencyBoost = 0;
let diversityPenalty = 0;
// Category match (50 points max)
if (post.category_id && userProfile.topCategories.includes(post.category_id)) {
const categoryRank = userProfile.topCategories.indexOf(post.category_id);
categoryMatch = SCORING_WEIGHTS.CATEGORY_MATCH * (1 - categoryRank * 0.2); // 50, 40, 30 points
}
// Bill match (30 points)
if (post.bill_number && userProfile.bookmarkedBills.includes(post.bill_number)) {
billMatch = SCORING_WEIGHTS.BILL_MATCH;
}
// Engagement match (20 points) - Posts with good engagement
const engagementScore = post.upvotes_count - post.downvotes_count + post.reply_count;
if (engagementScore >= 10) {
engagementMatch = SCORING_WEIGHTS.ENGAGEMENT_MATCH;
} else if (engagementScore >= 5) {
engagementMatch = SCORING_WEIGHTS.ENGAGEMENT_MATCH * 0.5;
}
// Recency boost (10 points max, decays over 7 days)
const postAge = Date.now() - new Date(post.created_at).getTime();
const ageInDays = postAge / (1000 * 60 * 60 * 24);
recencyBoost = SCORING_WEIGHTS.RECENCY_BOOST * Math.max(0, 1 - ageInDays / LOOKBACK_DAYS);
// Diversity penalty (-10 points) - Avoid same author repeatedly
if (post.author_id && userProfile.recentAuthors.includes(post.author_id)) {
diversityPenalty = SCORING_WEIGHTS.DIVERSITY_PENALTY;
}
return {
categoryMatch,
billMatch,
engagementMatch,
recencyBoost,
diversityPenalty,
};
}
/**
* Calculate total score from factors
*/
function calculateTotalScore(factors: RecommendationFactors): number {
return (
factors.categoryMatch +
factors.billMatch +
factors.engagementMatch +
factors.recencyBoost +
factors.diversityPenalty
);
}
/**
* Fallback to trending posts when recommendations fail
*/
async function getTrendingFallback(limit: number): Promise<ForumPost[]> {
const supabase = await createServerClient();
// Get trending posts (high engagement in last 7 days)
const dateThreshold = new Date();
dateThreshold.setDate(dateThreshold.getDate() - LOOKBACK_DAYS);
const { data: posts } = await supabase
.from('forum_posts')
.select(
`
*,
category:forum_categories(*),
author:user_profiles(*)
`
)
.gte('created_at', dateThreshold.toISOString())
.is('is_deleted', false)
.is('parent_post_id', null)
.order('upvotes_count', { ascending: false })
.limit(limit);
return (posts || []) as ForumPost[];
}