'use server';
/**
* User Mentions Server Actions
*
* Handles @username mentions in forum posts and bill comments.
* Creates entries in user_mentions table for notification/feed purposes.
* Also handles @everyone broadcast mentions for admins/moderators in any category.
*/
import { createAdminClient, createServerClient } from '@/lib/supabase-server';
import { auth } from '@/auth';
/**
* Context types for mentions
*/
export type MentionContextType = 'forum_post' | 'forum_reply' | 'bill_comment';
/**
* Extract @username mentions from text
*
* Matches @username patterns where username:
* - Starts with a letter
* - Contains 3-30 characters (letters, numbers, underscores, hyphens)
* - Does not include a colon (to avoid @type:id entity mentions)
*/
function extractUserMentions(text: string): string[] {
// Match @username but not @type:id patterns
const mentionRegex = /@([a-z][a-z0-9_-]{2,29})(?!:)/gi;
const matches: string[] = [];
let match: RegExpExecArray | null;
while ((match = mentionRegex.exec(text)) !== null) {
const username = match[1].toLowerCase();
if (!matches.includes(username)) {
matches.push(username);
}
}
return matches;
}
/**
* Create user mention records for a post
*
* @param postId - The forum post ID where the mention occurred
* @param content - The post content to scan for @username mentions
* @param mentionerUserId - The user who wrote the post (mentioner)
* @param contextType - Type of content (forum_post, forum_reply, bill_comment)
* @returns Object with success status and count of mentions created
*/
export async function createUserMentions(
postId: string,
content: string,
mentionerUserId: string,
contextType: MentionContextType
): Promise<{ success: boolean; mentionsCreated: number; error?: string }> {
try {
const supabase = await createServerClient();
const adminClient = createAdminClient();
// Extract @username mentions from content
const usernames = extractUserMentions(content);
if (usernames.length === 0) {
return { success: true, mentionsCreated: 0 };
}
// Look up user IDs for the mentioned usernames
const { data: users, error: lookupError } = await supabase
.from('user_profiles')
.select('id, username')
.in('username', usernames);
if (lookupError) {
console.error('Error looking up mentioned users:', lookupError);
return { success: false, mentionsCreated: 0, error: lookupError.message };
}
if (!users || users.length === 0) {
return { success: true, mentionsCreated: 0 };
}
// Filter out self-mentions (user mentioning themselves)
const mentionedUsers = users.filter((u) => u.id !== mentionerUserId);
if (mentionedUsers.length === 0) {
return { success: true, mentionsCreated: 0 };
}
// Create mention records
const mentionRecords = mentionedUsers.map((user) => ({
mentioned_user_id: user.id,
mentioner_user_id: mentionerUserId,
post_id: postId,
context_type: contextType,
context_id: postId, // For forum posts, context_id = post_id
}));
// Insert mentions using admin client (bypasses RLS for insert)
const { error: insertError } = await adminClient
.from('user_mentions')
.insert(mentionRecords);
if (insertError) {
console.error('Error inserting user mentions:', insertError);
return { success: false, mentionsCreated: 0, error: insertError.message };
}
return { success: true, mentionsCreated: mentionedUsers.length };
} catch (error) {
console.error('Error in createUserMentions:', error);
return {
success: false,
mentionsCreated: 0,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Mark a mention as read
*
* @param mentionId - The user_mentions record ID
* @returns Success status
*/
export async function markMentionAsRead(
mentionId: string
): Promise<{ success: boolean; error?: string }> {
try {
const supabase = await createServerClient();
const { error } = await supabase
.from('user_mentions')
.update({ read_at: new Date().toISOString() })
.eq('id', mentionId);
if (error) {
console.error('Error marking mention as read:', error);
return { success: false, error: error.message };
}
return { success: true };
} catch (error) {
console.error('Error in markMentionAsRead:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Mark all mentions as read for a user
*
* @returns Success status and count of mentions marked
*/
export async function markAllMentionsAsRead(): Promise<{
success: boolean;
count: number;
error?: string;
}> {
try {
const supabase = await createServerClient();
const { data, error } = await supabase
.from('user_mentions')
.update({ read_at: new Date().toISOString() })
.is('read_at', null)
.select('id');
if (error) {
console.error('Error marking all mentions as read:', error);
return { success: false, count: 0, error: error.message };
}
return { success: true, count: data?.length || 0 };
} catch (error) {
console.error('Error in markAllMentionsAsRead:', error);
return {
success: false,
count: 0,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Get unread mention count for current user
*
* @returns Count of unread mentions
*/
export async function getUnreadMentionCount(): Promise<{
success: boolean;
count: number;
error?: string;
}> {
try {
const supabase = await createServerClient();
const { count, error } = await supabase
.from('user_mentions')
.select('*', { count: 'exact', head: true })
.is('read_at', null);
if (error) {
console.error('Error getting unread mention count:', error);
return { success: false, count: 0, error: error.message };
}
return { success: true, count: count || 0 };
} catch (error) {
console.error('Error in getUnreadMentionCount:', error);
return {
success: false,
count: 0,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
// ============================================
// @EVERYONE MENTION HANDLING
// ============================================
/**
* Check if a user is an admin or moderator
*/
async function isAdminOrModerator(userId: string): Promise<boolean> {
const supabase = await createServerClient();
const { data, error } = await supabase
.from('user_profiles')
.select('is_admin, is_moderator')
.eq('id', userId)
.single();
if (error || !data) {
return false;
}
return data.is_admin === true || data.is_moderator === true;
}
/**
* Get users who have posted or replied in a specific category within the past 30 days
* Excludes the mentioner to avoid self-notification
*/
async function getActiveCategoryUsers(categoryId: string, excludeUserId: string): Promise<string[]> {
const supabase = await createServerClient();
// Calculate 30 days ago
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Find all users who posted/replied in this category in the past 30 days
const { data: posts, error: postsError } = await supabase
.from('forum_posts')
.select('author_id')
.eq('category_id', categoryId)
.eq('is_deleted', false)
.gte('created_at', thirtyDaysAgo.toISOString());
if (postsError || !posts) {
console.error('Error fetching category posts:', postsError);
return [];
}
// Get unique user IDs, excluding the mentioner
const uniqueUserIds = [...new Set(posts.map((p) => p.author_id))].filter(
(id) => id !== excludeUserId
);
return uniqueUserIds;
}
/**
* Get category name by ID
*/
async function getCategoryName(categoryId: string): Promise<string | null> {
const supabase = await createServerClient();
const { data, error } = await supabase
.from('forum_categories')
.select('name')
.eq('id', categoryId)
.single();
if (error || !data) {
return null;
}
return data.name;
}
/**
* Handle @everyone mention - validates permissions and creates notifications
*
* This function checks if:
* 1. The content contains @everyone
* 2. The user is an admin or moderator
* 3. The post is in a valid category
*
* If all checks pass, it creates notifications for all users who have been
* active (posted or replied) in that category within the past 30 days.
*
* @param postId - The forum post ID
* @param content - The post content
* @param mentionerUserId - The user ID of the person who posted
* @param categoryId - The category ID of the post
* @returns Result object with success status and recipient count
*/
export async function handleEveryoneMention(
postId: string,
content: string,
mentionerUserId: string,
categoryId: string | null
): Promise<{ success: boolean; recipientsNotified: number; error?: string }> {
try {
// Check if @everyone is in the content
if (!/@everyone\b/i.test(content)) {
return { success: true, recipientsNotified: 0 };
}
// Validate user is admin or moderator
const hasPermission = await isAdminOrModerator(mentionerUserId);
if (!hasPermission) {
return {
success: false,
recipientsNotified: 0,
error: 'Only administrators and moderators can use @everyone',
};
}
// Validate category exists
if (!categoryId) {
return {
success: false,
recipientsNotified: 0,
error: '@everyone can only be used in a category',
};
}
// Get category name for the notification message
const categoryName = await getCategoryName(categoryId);
if (!categoryName) {
return {
success: false,
recipientsNotified: 0,
error: 'Could not find category',
};
}
// Get active users in this category
const recipientIds = await getActiveCategoryUsers(categoryId, mentionerUserId);
if (recipientIds.length === 0) {
// No users to notify, but that's not an error
return { success: true, recipientsNotified: 0 };
}
// Get mentioner's display name for the notification
const adminClient = createAdminClient();
const { data: mentioner } = await adminClient
.from('user_profiles')
.select('display_name, username')
.eq('id', mentionerUserId)
.single();
const actorName =
mentioner?.display_name || mentioner?.username || 'A moderator';
// Create notifications in batches
const BATCH_SIZE = 100;
let insertedCount = 0;
for (let i = 0; i < recipientIds.length; i += BATCH_SIZE) {
const batch = recipientIds.slice(i, i + BATCH_SIZE);
const notifications = batch.map((userId) => ({
user_id: userId,
type: 'mention', // Must match DB check constraint: 'new_message' | 'new_follower' | 'mention' | 'reply' | 'like' | 'comment' | 'system'
title: `${categoryName} Announcement`,
message: `${actorName} mentioned @everyone in ${categoryName}`,
related_entity_type: 'post', // Must match DB check constraint: 'message' | 'user' | 'post' | 'comment' | 'bill' | 'debate'
related_entity_id: postId,
actor_id: mentionerUserId,
}));
const { error } = await adminClient
.from('notifications')
.insert(notifications);
if (!error) {
insertedCount += batch.length;
} else {
console.error('Error inserting @everyone notifications:', error);
}
}
return { success: true, recipientsNotified: insertedCount };
} catch (error) {
console.error('Error in handleEveryoneMention:', error);
return {
success: false,
recipientsNotified: 0,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
// ============================================
// @BETA MENTION HANDLING
// ============================================
/**
* Get all beta testers (users with is_beta_tester = true)
* Excludes the mentioner to avoid self-notification
*/
async function getBetaTesters(excludeUserId: string): Promise<string[]> {
const supabase = await createServerClient();
const { data: users, error } = await supabase
.from('user_profiles')
.select('id')
.eq('is_beta_tester', true);
if (error || !users) {
console.error('Error fetching beta testers:', error);
return [];
}
// Exclude the mentioner
return users.map((u) => u.id).filter((id) => id !== excludeUserId);
}
/**
* Handle @beta mention - validates permissions and creates notifications for all beta testers
*
* This function checks if:
* 1. The content contains @beta
* 2. The user is an admin or moderator
*
* If all checks pass, it creates notifications for all beta testers.
*
* @param postId - The forum post ID
* @param content - The post content
* @param mentionerUserId - The user ID of the person who posted
* @returns Result object with success status and recipient count
*/
export async function handleBetaMention(
postId: string,
content: string,
mentionerUserId: string
): Promise<{ success: boolean; recipientsNotified: number; error?: string }> {
try {
// Check if @beta is in the content
if (!/@beta\b/i.test(content)) {
return { success: true, recipientsNotified: 0 };
}
// Validate user is admin or moderator
const hasPermission = await isAdminOrModerator(mentionerUserId);
if (!hasPermission) {
return {
success: false,
recipientsNotified: 0,
error: 'Only administrators and moderators can use @beta',
};
}
// Get all beta testers
const recipientIds = await getBetaTesters(mentionerUserId);
if (recipientIds.length === 0) {
// No users to notify, but that's not an error
return { success: true, recipientsNotified: 0 };
}
// Get mentioner's display name for the notification
const adminClient = createAdminClient();
const { data: mentioner } = await adminClient
.from('user_profiles')
.select('display_name, username')
.eq('id', mentionerUserId)
.single();
const actorName =
mentioner?.display_name || mentioner?.username || 'A moderator';
// Create notifications in batches
const BATCH_SIZE = 100;
let insertedCount = 0;
for (let i = 0; i < recipientIds.length; i += BATCH_SIZE) {
const batch = recipientIds.slice(i, i + BATCH_SIZE);
const notifications = batch.map((userId) => ({
user_id: userId,
type: 'mention',
title: 'Beta Tester Announcement',
message: `${actorName} mentioned @beta in a discussion`,
related_entity_type: 'post',
related_entity_id: postId,
actor_id: mentionerUserId,
}));
const { error } = await adminClient
.from('notifications')
.insert(notifications);
if (!error) {
insertedCount += batch.length;
} else {
console.error('Error inserting @beta notifications:', error);
}
}
return { success: true, recipientsNotified: insertedCount };
} catch (error) {
console.error('Error in handleBetaMention:', error);
return {
success: false,
recipientsNotified: 0,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}