'use server';
/**
* Forum Server Actions
* Next.js Server Actions for forum CRUD operations
*/
import { unstable_noStore as noStore } from 'next/cache';
import { createServerClient, createAdminClient } from '@/lib/supabase-server';
import { auth } from '@/auth';
import type {
ForumPost,
ForumCategory,
CreatePostInput,
UpdatePostInput,
GetPostsParams,
ApiResponse,
PaginatedResponse,
VoteType,
} from '@/types/forum';
import { createUserMentions, type MentionContextType } from './mentions';
import { extractLeadingBillMention, getSectionFromBillMention } from '@/lib/mentions/mentionParser';
// ============================================
// CATEGORIES
// ============================================
export async function getCategories(): Promise<ApiResponse<ForumCategory[]>> {
try {
const supabase = await createServerClient();
const { data, error } = await supabase
.from('forum_categories')
.select('*')
.eq('is_active', true)
.neq('slug', 'beta') // Exclude beta category from regular listing
.order('display_order', { ascending: true });
if (error) throw error;
return { success: true, data: data || [] };
} catch (error) {
console.error('Error fetching categories:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch categories',
};
}
}
/**
* Get or create the beta testing category ID
* This category is only visible to beta testers
*/
export async function getBetaCategoryId(): Promise<ApiResponse<string>> {
try {
const supabase = await createServerClient();
// First, try to find existing beta category
const { data: existing, error: findError } = await supabase
.from('forum_categories')
.select('id')
.eq('slug', 'beta')
.maybeSingle();
if (findError) throw findError;
if (existing) {
return { success: true, data: existing.id };
}
// Create beta category if it doesn't exist - use admin client to bypass RLS
const adminClient = await createAdminClient();
const { data: created, error: createError } = await adminClient
.from('forum_categories')
.insert({
name: 'Beta Testing',
slug: 'beta',
description: 'Exclusive discussions for beta testers. Share feedback, report bugs, and help shape new features.',
color: '#8b5cf6', // Purple color
is_active: true,
display_order: 999, // Put at the end
})
.select('id')
.single();
if (createError) throw createError;
return { success: true, data: created.id };
} catch (error) {
console.error('Error getting beta category ID:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get beta category',
};
}
}
export async function getCategoryBySlug(
slug: string
): Promise<ApiResponse<ForumCategory>> {
try {
const supabase = await createServerClient();
const { data, error } = await supabase
.from('forum_categories')
.select('*')
.eq('slug', slug)
.eq('is_active', true)
.single();
if (error) throw error;
if (!data) throw new Error('Category not found');
return { success: true, data };
} catch (error) {
console.error('Error fetching category:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Category not found',
};
}
}
// ============================================
// POSTS - READ
// ============================================
export async function getPosts(
params: GetPostsParams = {}
): Promise<ApiResponse<PaginatedResponse<ForumPost>>> {
// Prevent Next.js from caching this response
noStore();
try {
// Use regular server client (RLS policy allows public reads after migration)
const supabase = await createServerClient();
const session = await auth();
const {
post_type,
category_id,
bill_number,
bill_session,
section_ref,
author_id,
limit = 20,
offset = 0,
sort = 'recent',
sort_by,
include_replies = false,
search,
date_from,
date_to,
min_upvotes,
has_replies,
time_range,
} = params;
// Support both 'sort' and 'sort_by' parameters
// Prefer sort_by if explicitly provided, otherwise fall back to sort
const sortOption = sort_by || sort || 'recent';
console.log('getPosts params:', { post_type, bill_number, section_ref, sort_by, sort, sortOption });
// Note: author data is denormalized in author_name and author_avatar_url columns
// No need to join with user_profiles
let query = supabase
.from('forum_posts')
.select('*, category:forum_categories(name, slug, color)', { count: 'exact' })
.eq('is_deleted', false);
// Only filter by depth=0 if we don't want replies
if (!include_replies) {
query = query.eq('depth', 0);
}
// Post type filter
if (post_type) {
query = query.eq('post_type', post_type);
}
// Basic filters
if (category_id) {
query = query.eq('category_id', category_id);
}
if (bill_number && bill_session) {
query = query.eq('bill_number', bill_number).eq('bill_session', bill_session);
}
// Filter by section_ref in entity_metadata JSONB column
// - section_ref = "s2.1" → filter to posts with that section
// - section_ref = null → filter to posts WITHOUT a section (general discussion)
// - section_ref = undefined → don't filter by section
if (section_ref !== undefined) {
console.log('Applying section_ref filter:', section_ref);
if (section_ref === null) {
// General discussion: posts where section_ref is null or doesn't exist
// In PostgreSQL: entity_metadata IS NULL OR (entity_metadata->>'section_ref') IS NULL
// The ->> operator returns NULL if key doesn't exist OR if value is JSON null
query = query.or('entity_metadata.is.null,entity_metadata->>section_ref.is.null');
} else {
// Specific section: posts where section_ref matches
// Use text extraction (->>`) for string comparison
query = query.eq('entity_metadata->>section_ref', section_ref);
}
}
if (author_id) {
query = query.eq('author_id', author_id);
}
// Advanced filters
if (search) {
query = query.textSearch('search_vector', search);
}
if (date_from) {
query = query.gte('created_at', date_from);
}
if (date_to) {
query = query.lte('created_at', date_to);
}
if (min_upvotes !== undefined) {
query = query.gte('upvotes_count', min_upvotes);
}
if (has_replies !== undefined) {
if (has_replies) {
query = query.gt('reply_count', 0);
} else {
query = query.eq('reply_count', 0);
}
}
// Time range filter for top sorting
if (time_range && sort === 'top') {
const now = new Date();
let dateThreshold: Date;
switch (time_range) {
case '24h':
dateThreshold = new Date(now.getTime() - 24 * 60 * 60 * 1000);
break;
case 'week':
dateThreshold = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case 'month':
dateThreshold = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
default:
dateThreshold = new Date(0); // All time
}
query = query.gte('created_at', dateThreshold.toISOString());
}
// Sorting
// Note: 'engagement' sort is handled after fetching since it requires computed column
const needsPostFetchSort = sortOption === 'engagement';
switch (sortOption) {
case 'hot':
// Recent activity wins
query = query.order('last_reply_at', { ascending: false });
break;
case 'trending':
// High engagement in last 24h (upvotes + replies, recent posts get boost)
// Note: This is approximated by sorting by upvotes then recent
query = query
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString())
.order('upvotes_count', { ascending: false })
.order('reply_count', { ascending: false });
break;
case 'top':
// Sort by net score (upvotes - downvotes) approximated by upvotes first
// Then by most recent as tie-breaker
query = query
.order('upvotes_count', { ascending: false })
.order('created_at', { ascending: false });
break;
case 'controversial':
// Sort by engagement score: high downvotes first, then high upvotes
// This captures comments with both strong opposition AND support
query = query
.order('downvotes_count', { ascending: false })
.order('upvotes_count', { ascending: false });
break;
case 'engagement':
// Total engagement (upvotes + downvotes) - sorted after fetch
// For now, order by upvotes as a reasonable approximation for the query
query = query
.order('upvotes_count', { ascending: false })
.order('downvotes_count', { ascending: false });
break;
case 'unanswered':
query = query
.eq('reply_count', 0)
.order('created_at', { ascending: false });
break;
case 'recent':
default:
query = query.order('created_at', { ascending: false });
break;
}
// Pagination
query = query.range(offset, offset + limit - 1);
const { data, error, count } = await query;
if (error) throw error;
// Add user votes if authenticated
let postsWithVotes = data || [];
if (session?.user?.id && postsWithVotes.length > 0) {
const postIds = postsWithVotes.map((p) => p.id);
const { data: votesData } = await supabase
.from('forum_votes')
.select('post_id, vote_type')
.in('post_id', postIds)
.eq('user_id', session.user.id);
if (votesData) {
const voteMap = new Map(votesData.map((v) => [v.post_id, v.vote_type]));
postsWithVotes = postsWithVotes.map((post) => ({
...post,
user_vote: voteMap.get(post.id) || null,
}));
}
// Add new reply counts (batch query)
const { data: replyCounts } = await supabase.rpc('get_new_reply_counts_batch', {
post_ids: postIds,
p_user_id: session.user.id,
});
if (replyCounts) {
const replyCountMap = new Map(replyCounts.map((r: any) => [r.post_id, r.new_count]));
postsWithVotes = postsWithVotes.map((post) => ({
...post,
new_reply_count: replyCountMap.get(post.id) || 0,
}));
}
}
// Post-fetch sorting for engagement (total votes = upvotes + downvotes)
if (needsPostFetchSort && sortOption === 'engagement') {
postsWithVotes.sort((a, b) => {
const aEngagement = (a.upvotes_count || 0) + (a.downvotes_count || 0);
const bEngagement = (b.upvotes_count || 0) + (b.downvotes_count || 0);
return bEngagement - aEngagement;
});
}
return {
success: true,
data: {
data: postsWithVotes,
total: count || 0,
limit,
offset,
has_more: count ? offset + limit < count : false,
},
};
} catch (error) {
console.error('Error fetching posts:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch posts',
};
}
}
export async function getPost(postId: string): Promise<ApiResponse<ForumPost>> {
// Prevent Next.js from caching this response
noStore();
try {
const supabase = await createServerClient();
const session = await auth();
const { data, error } = await supabase
.from('forum_posts')
.select('*, category:forum_categories(*)')
.eq('id', postId)
.single();
// Note: author data is denormalized in author_name and author_avatar_url columns
if (error) throw error;
if (!data) throw new Error('Post not found');
// Get user's vote if authenticated
if (session?.user?.id) {
const { data: voteData } = await supabase
.from('forum_votes')
.select('vote_type')
.eq('post_id', postId)
.eq('user_id', session.user.id)
.maybeSingle();
if (voteData) {
data.user_vote = voteData.vote_type;
}
}
return { success: true, data };
} catch (error) {
console.error('Error fetching post:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Post not found',
};
}
}
export async function getPostThread(
postId: string,
maxDepth: number = 10
): Promise<ApiResponse<ForumPost[]>> {
// Prevent Next.js from caching this response
noStore();
try {
const supabase = await createServerClient();
const session = await auth();
// Get the root post
const rootPost = await getPost(postId);
if (!rootPost.success || !rootPost.data) {
throw new Error('Post not found');
}
// Get all replies in the thread
// For root posts, thread_root_id is NULL, so we need to fetch both:
// 1. The root post itself (id = postId)
// 2. All replies that point to this thread (thread_root_id = postId)
const threadRootId = rootPost.data.thread_root_id || postId;
const { data, error } = await supabase
.from('forum_posts')
.select('*')
.or(`id.eq.${threadRootId},thread_root_id.eq.${threadRootId}`)
.lte('depth', maxDepth)
.order('created_at', { ascending: true });
// Note: author data is denormalized in author_name and author_avatar_url columns
if (error) throw error;
// Get user votes if authenticated
if (session?.user?.id && data) {
const postIds = data.map((p) => p.id);
const { data: votes } = await supabase
.from('forum_votes')
.select('post_id, vote_type')
.eq('user_id', session.user.id)
.in('post_id', postIds);
if (votes) {
const voteMap = new Map(votes.map((v) => [v.post_id, v.vote_type]));
data.forEach((post) => {
post.user_vote = voteMap.get(post.id) || null;
});
}
}
// Build threaded structure
const postsWithReplies = buildThreadTree(data || []);
return { success: true, data: postsWithReplies };
} catch (error) {
console.error('Error fetching thread:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch thread',
};
}
}
// Helper function to build thread tree
function buildThreadTree(posts: ForumPost[]): ForumPost[] {
const postMap = new Map<string, ForumPost>();
const rootPosts: ForumPost[] = [];
// Initialize all posts with empty replies array
posts.forEach((post) => {
postMap.set(post.id, { ...post, replies: [] });
});
// Build tree structure
posts.forEach((post) => {
const postWithReplies = postMap.get(post.id)!;
if (post.parent_post_id) {
const parent = postMap.get(post.parent_post_id);
if (parent) {
parent.replies = parent.replies || [];
parent.replies.push(postWithReplies);
} else {
// Orphaned reply - parent not in the fetched set
// Add to root level so it's not lost
rootPosts.push(postWithReplies);
}
} else {
rootPosts.push(postWithReplies);
}
});
return rootPosts;
}
// ============================================
// POSTS - CREATE
// ============================================
export async function createPost(
input: CreatePostInput
): Promise<ApiResponse<ForumPost>> {
try {
const supabase = await createServerClient();
const session = await auth();
if (!session?.user?.id) {
throw new Error('You must be logged in to create a post');
}
const userId = session.user.id;
// Get user profile for denormalized data
const { data: profile } = await supabase
.from('user_profiles')
.select('display_name, avatar_url')
.eq('id', userId)
.maybeSingle();
// Calculate depth and thread_root_id for replies
let depth = 0;
let thread_root_id: string | null = null;
if (input.parent_post_id) {
const { data: parentPost } = await supabase
.from('forum_posts')
.select('depth, thread_root_id, id, is_locked')
.eq('id', input.parent_post_id)
.single();
if (!parentPost) {
throw new Error('Parent post not found');
}
if (parentPost.is_locked) {
throw new Error('This post is locked and cannot receive replies');
}
depth = parentPost.depth + 1;
thread_root_id = parentPost.thread_root_id || parentPost.id;
if (depth > 10) {
throw new Error('Maximum reply depth exceeded');
}
}
// Validate required fields
if (depth === 0 && !input.title) {
throw new Error('Title is required for top-level posts');
}
if (input.post_type === 'discussion' && !input.category_id) {
throw new Error('Category is required for discussion posts');
}
if (
input.post_type === 'bill_comment' &&
(!input.bill_number || !input.bill_session)
) {
throw new Error('Bill number and session are required for bill comments');
}
// Check rate limit
const { data: canPost, error: rateLimitError } = await supabase.rpc('check_post_rate_limit', {
p_user_id: userId,
});
if (rateLimitError) {
console.error('Rate limit check error:', rateLimitError);
throw new Error(`Rate limit check failed: ${rateLimitError.message}`);
}
if (canPost === false) {
throw new Error(
'Rate limit exceeded. You can only create 20 posts per hour. Please try again later.'
);
}
// Create post
const postData: any = {
post_type: input.post_type,
content: input.content,
author_id: userId,
author_name: profile?.display_name || session.user.email?.split('@')[0] || 'Anonymous',
author_avatar_url: profile?.avatar_url,
depth,
thread_root_id,
parent_post_id: input.parent_post_id || null,
};
if (input.title) postData.title = input.title;
if (input.category_id) postData.category_id = input.category_id;
if (input.bill_number) postData.bill_number = input.bill_number;
if (input.bill_session) postData.bill_session = input.bill_session;
if (input.entity_metadata) postData.entity_metadata = input.entity_metadata;
// For bill_comment posts, parse the leading bill mention to sync section_ref
// This ensures entity_metadata.section_ref matches the mention in content
if (input.post_type === 'bill_comment' && input.bill_number) {
const leadingMention = extractLeadingBillMention(input.content);
if (leadingMention && leadingMention.type === 'bill') {
const mentionBillNumber = leadingMention.id.toLowerCase();
const inputBillNumber = input.bill_number.toLowerCase();
if (mentionBillNumber === inputBillNumber) {
// Extract section from the mention
const sectionRef = getSectionFromBillMention(leadingMention);
// Sync entity_metadata.section_ref from the mention
postData.entity_metadata = {
...(postData.entity_metadata || {}),
section_ref: sectionRef || null,
};
}
} else {
// No valid bill mention at start - ensure section_ref is null (general discussion)
postData.entity_metadata = {
...(postData.entity_metadata || {}),
section_ref: null,
};
}
}
console.log('Attempting to insert post with data:', postData);
// Use admin client to bypass RLS since we've already verified auth + rate limits
const adminClient = createAdminClient();
const { data, error } = await adminClient
.from('forum_posts')
.insert(postData)
.select()
.single();
if (error) {
console.error('Database insert error:', error);
throw new Error(`Database error: ${error.message} (Code: ${error.code}, Details: ${error.details || 'none'})`);
}
// Track @username mentions in the post content
if (data) {
const contextType: MentionContextType = depth === 0 ? 'forum_post' : 'forum_reply';
const mentionResult = await createUserMentions(
data.id,
input.content,
userId,
contextType
);
if (!mentionResult.success) {
console.warn('Failed to track user mentions:', mentionResult.error);
// Don't fail the post creation if mention tracking fails
}
}
return { success: true, data };
} catch (error) {
console.error('Error creating post:', error);
// Return the actual error message instead of generic one
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create post',
};
}
}
// ============================================
// POSTS - UPDATE
// ============================================
export async function updatePost(
postId: string,
input: UpdatePostInput
): Promise<ApiResponse<ForumPost>> {
try {
const supabase = await createServerClient();
const session = await auth();
if (!session?.user?.id) {
throw new Error('You must be logged in to update a post');
}
const userId = session.user.id;
// Check ownership
const { data: post } = await supabase
.from('forum_posts')
.select('author_id, is_locked')
.eq('id', postId)
.single();
if (!post) {
throw new Error('Post not found');
}
if (post.author_id !== userId) {
throw new Error('You can only edit your own posts');
}
if (post.is_locked) {
throw new Error('This post is locked and cannot be edited');
}
// Update post
const { data, error } = await supabase
.from('forum_posts')
.update(input)
.eq('id', postId)
.select()
.single();
if (error) throw error;
return { success: true, data };
} catch (error) {
console.error('Error updating post:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update post',
};
}
}
// ============================================
// POSTS - DELETE (Soft Delete)
// ============================================
export async function deletePost(postId: string): Promise<ApiResponse> {
try {
const supabase = await createServerClient();
const session = await auth();
if (!session?.user?.id) {
throw new Error('You must be logged in to delete a post');
}
const userId = session.user.id;
// Check ownership
const { data: post } = await supabase
.from('forum_posts')
.select('author_id')
.eq('id', postId)
.single();
if (!post) {
throw new Error('Post not found');
}
if (post.author_id !== userId) {
throw new Error('You can only delete your own posts');
}
// Use admin client to bypass RLS (auth is verified above via NextAuth)
const adminClient = createAdminClient();
// Collect all descendant post IDs recursively
const collectDescendantIds = async (parentId: string): Promise<string[]> => {
const { data: children } = await adminClient
.from('forum_posts')
.select('id')
.eq('parent_post_id', parentId)
.eq('is_deleted', false);
if (!children || children.length === 0) {
return [];
}
const childIds = children.map((c: { id: string }) => c.id);
const grandchildIds = await Promise.all(
childIds.map((id: string) => collectDescendantIds(id))
);
return [...childIds, ...grandchildIds.flat()];
};
// Get all descendant IDs
const descendantIds = await collectDescendantIds(postId);
const allIdsToDelete = [postId, ...descendantIds];
// Soft delete the post and all its descendants
const { error } = await adminClient
.from('forum_posts')
.update({
is_deleted: true,
deleted_at: new Date().toISOString(),
deleted_by: userId,
})
.in('id', allIdsToDelete);
if (error) throw error;
return { success: true };
} catch (error) {
console.error('Error deleting post:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete post',
};
}
}
// ============================================
// VOTING
// ============================================
export async function votePost(
postId: string,
voteType: VoteType
): Promise<ApiResponse<{ upvotes: number; downvotes: number; userVote: VoteType | null }>> {
try {
const supabase = await createServerClient();
const session = await auth();
if (!session?.user?.id) {
throw new Error('You must be logged in to vote');
}
const userId = session.user.id;
// Check if post exists
const { data: post } = await supabase
.from('forum_posts')
.select('id')
.eq('id', postId)
.single();
if (!post) {
throw new Error('Post not found');
}
// Use admin client to bypass RLS (auth is verified above via NextAuth)
const adminClient = createAdminClient();
// Check existing vote
const { data: existingVote } = await adminClient
.from('forum_votes')
.select('vote_type')
.eq('post_id', postId)
.eq('user_id', userId)
.maybeSingle();
let newUserVote: VoteType | null = null;
if (existingVote) {
if (existingVote.vote_type === voteType) {
// Remove vote (toggle off)
const { error: deleteError } = await adminClient
.from('forum_votes')
.delete()
.eq('post_id', postId)
.eq('user_id', userId);
if (deleteError) {
console.error('Error deleting vote:', deleteError);
throw new Error('Failed to remove vote');
}
newUserVote = null;
} else {
// Change vote
const { error: updateError } = await adminClient
.from('forum_votes')
.update({ vote_type: voteType })
.eq('post_id', postId)
.eq('user_id', userId);
if (updateError) {
console.error('Error updating vote:', updateError);
throw new Error('Failed to update vote');
}
newUserVote = voteType;
}
} else {
// Create new vote
const { error: insertError } = await adminClient.from('forum_votes').insert({
post_id: postId,
user_id: userId,
vote_type: voteType,
});
if (insertError) {
console.error('Error inserting vote:', insertError);
throw new Error('Failed to create vote');
}
newUserVote = voteType;
}
// Get updated vote counts
const { data: updatedPost } = await supabase
.from('forum_posts')
.select('upvotes_count, downvotes_count')
.eq('id', postId)
.single();
return {
success: true,
data: {
upvotes: updatedPost?.upvotes_count || 0,
downvotes: updatedPost?.downvotes_count || 0,
userVote: newUserVote,
},
};
} catch (error) {
console.error('Error voting:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to vote',
};
}
}
// ============================================
// USER PROFILE
// ============================================
export async function getUserProfile(
userId: string
): Promise<ApiResponse<any>> {
try {
const supabase = await createServerClient();
const { data, error } = await supabase
.from('user_profiles')
.select('*')
.eq('id', userId)
.maybeSingle();
if (error) throw error;
return { success: true, data };
} catch (error) {
console.error('Error fetching profile:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch profile',
};
}
}
export async function ensureUserProfile(): Promise<ApiResponse> {
try {
const supabase = await createServerClient();
const session = await auth();
if (!session?.user?.id) {
throw new Error('Not authenticated');
}
const userId = session.user.id;
// Check if profile exists
const { data: existingProfile } = await supabase
.from('user_profiles')
.select('id')
.eq('id', userId)
.maybeSingle();
if (!existingProfile) {
// Create profile
const { error } = await supabase.from('user_profiles').insert({
id: userId,
display_name: session.user.email?.split('@')[0] || 'User',
});
if (error) throw error;
}
return { success: true };
} catch (error) {
console.error('Error ensuring profile:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create profile',
};
}
}
// ============================================
// REPORTING
// ============================================
// Note: Report functionality has been moved to @/actions/moderation
// Use: import { reportPost } from '@/actions/moderation'
// ============================================
// VISIT TRACKING
// ============================================
/**
* Track that a user visited a post
* Updates last_visited_at timestamp for calculating new reply counts
*/
export async function trackPostVisit(postId: string): Promise<ApiResponse<void>> {
try {
const supabase = await createServerClient();
const session = await auth();
if (!session?.user?.id) {
// Not authenticated - skip tracking
return { success: true };
}
const { error } = await supabase.rpc('track_post_visit', {
p_post_id: postId,
});
if (error) throw error;
return { success: true };
} catch (error) {
console.error('Error tracking post visit:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to track visit',
};
}
}
// ============================================
// BILL DISCUSSIONS AGGREGATION
// ============================================
import { createApolloClient } from '@/lib/apollo-client';
import { gql } from '@apollo/client';
export interface BillWithDiscussionCount {
bill_number: string;
bill_session: string;
bill_title?: string;
bill_status?: string;
post_count: number;
latest_post_at: string | null;
}
export interface BillDiscussionSummary extends BillWithDiscussionCount {
trending_posts: ForumPost[];
}
const GET_SESSION_BILLS = gql`
query GetSessionBills($session: String!, $limit: Int) {
searchBills(session: $session, limit: $limit) {
number
session
title
status
}
}
`;
const GET_AVAILABLE_SESSIONS = gql`
query GetAvailableSessions {
bills {
session
}
}
`;
/**
* Get all available bill sessions from Neo4j
* Returns sessions sorted by parliament and session number (latest first)
*/
export async function getAvailableBillSessions(): Promise<ApiResponse<string[]>> {
noStore();
try {
const apolloClient = createApolloClient();
const { data } = await apolloClient.query({
query: GET_AVAILABLE_SESSIONS,
});
// Extract unique sessions
const sessions = new Set<string>();
for (const bill of data?.bills || []) {
if (bill.session) {
sessions.add(bill.session);
}
}
// Sort sessions (latest first): 46-1 > 45-2 > 45-1 > 44-1
const sortedSessions = [...sessions].sort((a, b) => {
const [parlA, sessA] = a.split('-').map(Number);
const [parlB, sessB] = b.split('-').map(Number);
if (parlB !== parlA) return parlB - parlA;
return sessB - sessA;
});
return { success: true, data: sortedSessions };
} catch (error) {
console.error('Error fetching available sessions:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch available sessions',
};
}
}
/**
* Get bills with their discussion counts
* @param limit - Maximum number of bills to return
* @param session - Optional session filter. If not provided, fetches bills from all sessions.
*/
export async function getBillsWithDiscussions(
limit: number = 100,
session?: string
): Promise<ApiResponse<BillWithDiscussionCount[]>> {
noStore();
try {
const apolloClient = createApolloClient();
const supabase = await createServerClient();
let allBills: any[] = [];
if (session) {
// Fetch bills from a specific session
const { data: billsData } = await apolloClient.query({
query: GET_SESSION_BILLS,
variables: { session, limit: 200 },
});
allBills = billsData?.searchBills || [];
} else {
// Fetch bills from all sessions
const sessionsResult = await getAvailableBillSessions();
if (!sessionsResult.success || !sessionsResult.data) {
throw new Error('Failed to fetch available sessions');
}
// Fetch bills for each session
for (const sess of sessionsResult.data) {
const { data: billsData } = await apolloClient.query({
query: GET_SESSION_BILLS,
variables: { session: sess, limit: 200 },
});
allBills = [...allBills, ...(billsData?.searchBills || [])];
}
}
// Fetch discussion counts from Supabase
let postsQuery = supabase
.from('forum_posts')
.select('bill_number, bill_session, created_at')
.eq('post_type', 'bill_comment')
.eq('is_deleted', false)
.eq('depth', 0);
// Only filter by session if provided
if (session) {
postsQuery = postsQuery.eq('bill_session', session);
}
const { data: postsData, error } = await postsQuery;
if (error) throw error;
// Aggregate post counts by bill (keyed by session + number for uniqueness)
const postCountMap = new Map<string, { count: number; latest: string | null }>();
for (const post of postsData || []) {
const key = `${post.bill_session}-${post.bill_number?.toLowerCase()}`;
if (!post.bill_number) continue;
const existing = postCountMap.get(key);
if (existing) {
existing.count++;
if (post.created_at && (!existing.latest || post.created_at > existing.latest)) {
existing.latest = post.created_at;
}
} else {
postCountMap.set(key, { count: 1, latest: post.created_at });
}
}
// Merge bills with their discussion counts
const billsWithCounts: BillWithDiscussionCount[] = allBills
.filter((bill: any) => bill.title) // Only include bills with titles
.map((bill: any) => {
const key = `${bill.session}-${bill.number?.toLowerCase()}`;
const postData = postCountMap.get(key);
return {
bill_number: bill.number,
bill_session: bill.session,
bill_title: bill.title,
bill_status: bill.status,
post_count: postData?.count || 0,
latest_post_at: postData?.latest || null,
};
});
// Sort: bills with posts first (by post count desc), then bills without posts (alphabetically)
billsWithCounts.sort((a, b) => {
if (a.post_count > 0 && b.post_count === 0) return -1;
if (a.post_count === 0 && b.post_count > 0) return 1;
if (a.post_count > 0 && b.post_count > 0) {
return b.post_count - a.post_count;
}
// Both have 0 posts - sort alphabetically by bill number
return a.bill_number.localeCompare(b.bill_number);
});
return { success: true, data: billsWithCounts.slice(0, limit) };
} catch (error) {
console.error('Error fetching bills with discussions:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch bills with discussions',
};
}
}
/**
* Get trending posts for a specific bill
*/
export async function getBillTrendingPosts(
billNumber: string,
billSession: string,
limit: number = 3
): Promise<ApiResponse<ForumPost[]>> {
noStore();
try {
const supabase = await createServerClient();
const session = await auth();
// Get trending posts (high engagement in recent time)
const { data, error } = await supabase
.from('forum_posts')
.select('*, category:forum_categories(name, slug, color)')
.eq('post_type', 'bill_comment')
.eq('bill_number', billNumber)
.eq('bill_session', billSession)
.eq('is_deleted', false)
.eq('depth', 0) // Only top-level posts
.order('upvotes_count', { ascending: false })
.order('reply_count', { ascending: false })
.order('created_at', { ascending: false })
.limit(limit);
if (error) throw error;
// Add user votes if authenticated
let postsWithVotes = data || [];
if (session?.user?.id && postsWithVotes.length > 0) {
const postIds = postsWithVotes.map((p) => p.id);
const { data: votesData } = await supabase
.from('forum_votes')
.select('post_id, vote_type')
.in('post_id', postIds)
.eq('user_id', session.user.id);
if (votesData) {
const voteMap = new Map(votesData.map((v) => [v.post_id, v.vote_type]));
postsWithVotes = postsWithVotes.map((post) => ({
...post,
user_vote: voteMap.get(post.id) || null,
}));
}
}
return { success: true, data: postsWithVotes };
} catch (error) {
console.error('Error fetching bill trending posts:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch trending posts',
};
}
}
export async function getBillControversialPosts(
billNumber: string,
billSession: string,
limit: number = 3
): Promise<ApiResponse<ForumPost[]>> {
noStore();
try {
const supabase = await createServerClient();
const session = await auth();
// Get controversial posts (high downvotes, then high engagement)
const { data, error } = await supabase
.from('forum_posts')
.select('*, category:forum_categories(name, slug, color)')
.eq('post_type', 'bill_comment')
.eq('bill_number', billNumber)
.eq('bill_session', billSession)
.eq('is_deleted', false)
.eq('depth', 0) // Only top-level posts
.order('downvotes_count', { ascending: false })
.order('upvotes_count', { ascending: false })
.order('created_at', { ascending: false })
.limit(limit);
if (error) throw error;
// Add user votes if authenticated
let postsWithVotes = data || [];
if (session?.user?.id && postsWithVotes.length > 0) {
const postIds = postsWithVotes.map((p) => p.id);
const { data: votesData } = await supabase
.from('forum_votes')
.select('post_id, vote_type')
.in('post_id', postIds)
.eq('user_id', session.user.id);
if (votesData) {
const voteMap = new Map(votesData.map((v) => [v.post_id, v.vote_type]));
postsWithVotes = postsWithVotes.map((post) => ({
...post,
user_vote: voteMap.get(post.id) || null,
}));
}
}
return { success: true, data: postsWithVotes };
} catch (error) {
console.error('Error fetching bill controversial posts:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch controversial posts',
};
}
}
/**
* Get forum statistics for the forum home page
* Returns total posts, active members, and posts today
*/
export interface ForumStats {
totalPosts: number;
activeMembers: number;
postsToday: number;
}
export async function getForumStats(): Promise<ApiResponse<ForumStats>> {
noStore();
try {
const supabase = await createServerClient();
// Get start of today in UTC
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const todayISO = today.toISOString();
// Run all three queries in parallel
const [totalPostsResult, activeMembersResult, postsTodayResult] = await Promise.all([
// Total non-deleted posts (including replies)
supabase
.from('forum_posts')
.select('*', { count: 'exact', head: true })
.eq('is_deleted', false),
// Unique authors who have posted (active members)
supabase
.from('forum_posts')
.select('author_id')
.eq('is_deleted', false),
// Posts created today
supabase
.from('forum_posts')
.select('*', { count: 'exact', head: true })
.eq('is_deleted', false)
.gte('created_at', todayISO),
]);
if (totalPostsResult.error) throw totalPostsResult.error;
if (activeMembersResult.error) throw activeMembersResult.error;
if (postsTodayResult.error) throw postsTodayResult.error;
// Count unique authors
const uniqueAuthors = new Set(
(activeMembersResult.data || []).map((p) => p.author_id)
);
return {
success: true,
data: {
totalPosts: totalPostsResult.count || 0,
activeMembers: uniqueAuthors.size,
postsToday: postsTodayResult.count || 0,
},
};
} catch (error) {
console.error('Error fetching forum stats:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch forum stats',
};
}
}
/**
* Get total post count for all bills discussions
* Efficient count query for the Bills & Legislation category card
*/
export async function getBillsTotalPostCount(): Promise<ApiResponse<number>> {
noStore();
try {
const supabase = await createServerClient();
// Count only top-level posts (parent_post_id is null) that have a bill_number
// This matches what users see as "discussions" in the Bills & Legislation category
const { count, error } = await supabase
.from('forum_posts')
.select('*', { count: 'exact', head: true })
.not('bill_number', 'is', null)
.is('parent_post_id', null);
if (error) throw error;
return { success: true, data: count || 0 };
} catch (error) {
console.error('Error fetching bills total post count:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch bills post count',
};
}
}