/**
* Email Digest Server Actions
* Generates and sends digest emails to users
*/
'use server';
import { createServerClient } from '@/lib/supabase-server';
import { sendDigestEmail } from '@/lib/email/resend-client';
import {
DigestEmailTemplate,
getPlainTextDigest,
type DigestPost,
type DigestBill,
} from '@/lib/email/digest-template';
import { render } from '@react-email/components';
import type { ApiResponse } from '@/types/forum';
interface DigestRecipient {
user_id: string;
email: string;
display_name: string | null;
}
/**
* Generate and send digest to a single user
* Called by the Cloud Run job for each user
*/
export async function generateAndSendDigest(
userId: string,
digestType: 'daily' | 'weekly'
): Promise<ApiResponse<{ emailId?: string }>> {
try {
const supabase = await createServerClient();
// 1. Get user info
const { data: user, error: userError } = await supabase
.from('user_profiles')
.select('display_name')
.eq('id', userId)
.single();
if (userError || !user) {
throw new Error(`User not found: ${userId}`);
}
// Get user email from auth.users
const { data: authData, error: authError } = await supabase.auth.admin.getUserById(userId);
if (authError || !authData.user?.email) {
throw new Error(`User email not found: ${userId}`);
}
const userEmail = authData.user.email;
const userName = user.display_name || 'there';
// 2. Check if user is due for digest
const { data: isDue, error: dueError } = await supabase.rpc('is_user_due_for_digest', {
p_user_id: userId,
p_digest_type: digestType,
});
if (dueError) throw dueError;
if (!isDue) {
return {
success: true,
data: { emailId: 'skipped' },
error: 'User not due for digest',
};
}
// 3. Fetch digest content
const timeRange = digestType === 'daily' ? 1 : 7; // days
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - timeRange);
// Fetch trending posts (high engagement in time range)
const { data: trendingPosts, error: trendingError } = await supabase
.from('forum_posts')
.select('id, title, content, author_name, upvotes_count, downvotes_count, reply_count, category:forum_categories(name)')
.eq('is_deleted', false)
.gte('created_at', cutoffDate.toISOString())
.order('upvotes_count', { ascending: false })
.limit(5);
if (trendingError) throw trendingError;
// Fetch controversial posts (high vote activity, close up/down ratio)
const { data: controversialPosts, error: controversialError } = await supabase
.from('forum_posts')
.select('id, title, content, author_name, upvotes_count, downvotes_count, reply_count')
.eq('is_deleted', false)
.gte('created_at', cutoffDate.toISOString())
.gt('downvotes_count', 5) // At least 5 downvotes to be considered controversial
.order('downvotes_count', { ascending: false })
.limit(3);
if (controversialError) throw controversialError;
// 4. Format posts for email template
const formatPost = (post: {
id: string;
title: string | null;
content: string | null;
author_name: string | null;
upvotes_count: number;
downvotes_count: number;
reply_count: number;
category?: { name: string }[] | null;
}): DigestPost => {
const excerpt = post.content
?.replace(/[#*_~`>[\]]/g, '') // Remove markdown
.substring(0, 150);
return {
id: post.id,
title: post.title || '',
url: `https://canadagpt.ca/en/forum/posts/${post.id}`,
author: post.author_name || 'Anonymous',
upvotes: post.upvotes_count,
downvotes: post.downvotes_count,
replies: post.reply_count,
excerpt,
category: post.category?.[0]?.name,
};
};
const formattedTrending = (trendingPosts || []).map(formatPost);
const formattedControversial = (controversialPosts || []).map((post) => formatPost({ ...post, category: undefined }));
// 5. Fetch new bills (placeholder - would need Neo4j integration or Supabase bills table)
// For now, using empty array until bills are in Supabase
const newBills: DigestBill[] = [];
// 6. Render email
const emailHtml = await render(
DigestEmailTemplate({
userName,
digestType,
trendingPosts: formattedTrending,
controversialPosts: formattedControversial,
newBills,
unsubscribeUrl: 'https://canadagpt.ca/en/settings/notifications',
preferencesUrl: 'https://canadagpt.ca/en/settings/notifications',
})
);
const emailText = getPlainTextDigest({
userName,
digestType,
trendingPosts: formattedTrending,
controversialPosts: formattedControversial,
newBills,
});
// 7. Send email
const subject = digestType === 'daily'
? '🍁 Your daily parliamentary digest'
: '🍁 Your weekly parliamentary digest';
const sendResult = await sendDigestEmail({
to: userEmail,
subject,
html: emailHtml,
text: emailText,
});
if (!sendResult.success) {
throw new Error(sendResult.error || 'Failed to send email');
}
// 8. Log to email_digest_log
const totalPosts = formattedTrending.length + formattedControversial.length + newBills.length;
const { error: logError } = await supabase.from('email_digest_log').insert({
user_id: userId,
digest_type: digestType,
sent_at: new Date().toISOString(),
post_count: totalPosts,
email_to: userEmail,
status: 'sent',
});
if (logError) {
console.error('Failed to log digest send:', logError);
// Don't throw - email was sent successfully
}
return {
success: true,
data: { emailId: sendResult.data?.data?.id },
};
} catch (error) {
console.error('Error generating digest:', error);
// Log failure
try {
const supabase = await createServerClient();
await supabase.from('email_digest_log').insert({
user_id: userId,
digest_type: digestType,
sent_at: new Date().toISOString(),
post_count: 0,
email_to: '',
status: 'failed',
error_message: error instanceof Error ? error.message : 'Unknown error',
});
} catch (logError) {
console.error('Failed to log error:', logError);
}
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to generate digest',
};
}
}
/**
* Get all users who should receive a digest
* Called by Cloud Run job to get batch of recipients
*/
export async function getUsersForDigest(
digestType: 'daily' | 'weekly',
limit: number = 100,
offset: number = 0
): Promise<ApiResponse<DigestRecipient[]>> {
try {
const supabase = await createServerClient();
// Get users with matching email_digest_frequency preference
const { data: profiles, error: profilesError } = await supabase
.from('user_profiles')
.select('id, display_name')
.eq('email_notifications', true)
.order('id')
.range(offset, offset + limit - 1);
if (profilesError) throw profilesError;
if (!profiles || profiles.length === 0) {
return { success: true, data: [] };
}
// Get notification preferences for these users
const userIds = profiles.map((p) => p.id);
const { data: prefs, error: prefsError } = await supabase
.from('notification_preferences')
.select('user_id')
.eq('email_digest_frequency', digestType)
.in('user_id', userIds);
if (prefsError) throw prefsError;
// Filter to only users who want this digest type
const eligibleUserIds = new Set(prefs?.map((p) => p.user_id) || []);
const eligibleProfiles = profiles.filter((p) => eligibleUserIds.has(p.id));
// Get emails from auth.users (requires service role)
const recipients: DigestRecipient[] = [];
for (const profile of eligibleProfiles) {
try {
const { data: authData } = await supabase.auth.admin.getUserById(profile.id);
if (authData.user?.email) {
recipients.push({
user_id: profile.id,
email: authData.user.email,
display_name: profile.display_name,
});
}
} catch (error) {
console.error(`Failed to get email for user ${profile.id}:`, error);
}
}
return { success: true, data: recipients };
} catch (error) {
console.error('Error getting digest recipients:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get recipients',
};
}
}
/**
* Send a test digest to a specific email
* Useful for previewing digests before sending
*/
export async function sendTestDigest(
email: string,
digestType: 'daily' | 'weekly'
): Promise<ApiResponse<{ emailId?: string }>> {
try {
const supabase = await createServerClient();
// Fetch sample content (same logic as generateAndSendDigest but with fixed sample data)
const timeRange = digestType === 'daily' ? 1 : 7;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - timeRange);
const { data: trendingPosts } = await supabase
.from('forum_posts')
.select('id, title, content, author_name, upvotes_count, downvotes_count, reply_count, category:forum_categories(name)')
.eq('is_deleted', false)
.gte('created_at', cutoffDate.toISOString())
.order('upvotes_count', { ascending: false })
.limit(3);
const formattedTrending: DigestPost[] = (trendingPosts || []).map((post) => ({
id: post.id,
title: post.title || 'Untitled Discussion',
url: `https://canadagpt.ca/en/forum/posts/${post.id}`,
author: post.author_name || 'Anonymous',
upvotes: post.upvotes_count,
downvotes: post.downvotes_count,
replies: post.reply_count,
excerpt: post.content?.substring(0, 150),
category: (post.category as any)?.name,
}));
const emailHtml = await render(
DigestEmailTemplate({
userName: 'Preview User',
digestType,
trendingPosts: formattedTrending,
controversialPosts: [],
newBills: [],
unsubscribeUrl: 'https://canadagpt.ca/en/settings/notifications',
preferencesUrl: 'https://canadagpt.ca/en/settings/notifications',
})
);
const emailText = getPlainTextDigest({
userName: 'Preview User',
digestType,
trendingPosts: formattedTrending,
controversialPosts: [],
newBills: [],
});
const subject = `[TEST] ${digestType === 'daily' ? 'Daily' : 'Weekly'} Parliamentary Digest`;
const sendResult = await sendDigestEmail({
to: email,
subject,
html: emailHtml,
text: emailText,
});
if (!sendResult.success) {
throw new Error(sendResult.error || 'Failed to send test email');
}
return {
success: true,
data: { emailId: sendResult.data?.data?.id },
};
} catch (error) {
console.error('Error sending test digest:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to send test digest',
};
}
}