'use server';
/**
* Admin Invites Server Actions
* Server actions for creating and managing user invites via magic link
*/
import { revalidatePath } from 'next/cache';
import { createAdminClient } from '@/lib/supabase-server';
import { requireFullAdmin } from '@/lib/admin-check';
// Types
export interface AdminInvite {
id: string;
email: string;
name: string | null;
invited_by: string;
inviter_name?: string | null;
inviter_email?: string | null;
status: 'pending' | 'accepted' | 'expired' | 'cancelled';
invite_link: string | null;
created_at: string;
accepted_at: string | null;
expires_at: string;
notes: string | null;
}
// Internal types for database queries
interface InviteRow {
id: string;
email: string;
name: string | null;
invited_by: string;
status: string;
invite_link: string | null;
created_at: string;
accepted_at: string | null;
expires_at: string;
notes: string | null;
inviter_name?: string | null;
inviter_email?: string | null;
}
interface InviterInfo {
id: string;
full_name: string | null;
email: string | null;
}
interface StatusRow {
status: string;
}
export interface InviteFilters {
status?: 'pending' | 'accepted' | 'expired' | 'cancelled' | 'all';
search?: string;
limit?: number;
offset?: number;
}
export interface InviteListResult {
invites: AdminInvite[];
total: number;
}
export interface CreateInviteResult {
success: boolean;
inviteUrl?: string;
inviteId?: string;
error?: string;
}
export interface ActionResult {
success: boolean;
error?: string;
}
/**
* Create an admin invite
* Generates a Supabase invite link and optionally sends email
*/
export async function createAdminInvite(
email: string,
name: string,
sendEmail: boolean = false,
notes?: string
): Promise<CreateInviteResult> {
try {
const { userId: adminId } = await requireFullAdmin();
const adminClient = createAdminClient();
// Normalize email
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail || !name.trim()) {
return { success: false, error: 'Email and name are required' };
}
// Check if email already exists in user_profiles
const { data: existingUser } = await adminClient
.from('user_profiles')
.select('id')
.eq('email', normalizedEmail)
.maybeSingle();
if (existingUser) {
return { success: false, error: 'A user with this email already exists' };
}
// Check for existing pending invite
const { data: existingInvite } = await adminClient
.from('admin_invites')
.select('id')
.eq('email', normalizedEmail)
.eq('status', 'pending')
.maybeSingle();
if (existingInvite) {
return { success: false, error: 'A pending invite already exists for this email. You can resend it instead.' };
}
// Get base URL
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://canadagpt.ca';
// Generate invite using Supabase Admin API
// Note: redirectTo goes to home page, not callback, because invite links use hash fragments
// which the server-side callback can't see. The InviteHashHandler will detect the hash.
const { data: linkData, error: linkError } = await adminClient.auth.admin.generateLink({
type: 'invite',
email: normalizedEmail,
options: {
data: {
full_name: name.trim(),
invited_by_admin: true,
},
redirectTo: `${baseUrl}/en`,
},
});
if (linkError) {
console.error('[AdminInvite] Error generating invite link:', linkError);
return { success: false, error: linkError.message };
}
// The invite URL with the verification token
const inviteUrl = linkData.properties.action_link;
// Store invite in tracking table
const { data: invite, error: insertError } = await adminClient
.from('admin_invites')
.insert({
email: normalizedEmail,
name: name.trim(),
invited_by: adminId,
invite_link: inviteUrl,
notes: notes?.trim() || null,
})
.select('id')
.single();
if (insertError) {
console.error('[AdminInvite] Error storing invite:', insertError);
// Note: User was created in Supabase but tracking failed
// The invite still works, just won't be tracked
}
// Optionally send email
if (sendEmail) {
try {
const { sendInviteEmail } = await import('@/lib/email/admin-invite-email');
await sendInviteEmail({
to: normalizedEmail,
name: name.trim(),
inviteUrl,
});
} catch (emailError) {
console.error('[AdminInvite] Error sending email:', emailError);
// Don't fail the whole operation - return the link so admin can copy it
}
}
revalidatePath('/admin/invites');
return {
success: true,
inviteUrl,
inviteId: invite?.id,
};
} catch (error) {
console.error('[AdminInvite] Create invite error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'An unexpected error occurred',
};
}
}
/**
* Get paginated list of admin invites
*/
export async function getAdminInvites(filters: InviteFilters = {}): Promise<InviteListResult> {
try {
await requireFullAdmin();
const adminClient = createAdminClient();
const {
status = 'all',
search = '',
limit = 50,
offset = 0,
} = filters;
let query = adminClient
.from('admin_invites')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (status !== 'all') {
query = query.eq('status', status);
}
if (search) {
query = query.or(`email.ilike.%${search}%,name.ilike.%${search}%`);
}
const { data, error, count } = await query;
if (error) {
console.error('[AdminInvite] Error fetching invites:', error);
return { invites: [], total: 0 };
}
// Fetch inviter info for each invite
const invites = (data || []) as InviteRow[];
const inviterIds = [...new Set(invites.map((i: InviteRow) => i.invited_by))];
if (inviterIds.length > 0) {
const { data: inviters } = await adminClient
.from('user_profiles')
.select('id, full_name, email')
.in('id', inviterIds);
const inviterMap = new Map<string, InviterInfo>(
(inviters as InviterInfo[] | null)?.map((i: InviterInfo) => [i.id, i]) || []
);
for (const invite of invites) {
const inviter = inviterMap.get(invite.invited_by);
if (inviter) {
invite.inviter_name = inviter.full_name;
invite.inviter_email = inviter.email;
}
}
}
return {
invites: invites as AdminInvite[],
total: count || 0,
};
} catch (error) {
console.error('[AdminInvite] Get invites error:', error);
return { invites: [], total: 0 };
}
}
/**
* Get invite statistics
*/
export async function getInviteStats(): Promise<{
total: number;
pending: number;
accepted: number;
expired: number;
}> {
try {
await requireFullAdmin();
const adminClient = createAdminClient();
const { data, error } = await adminClient
.from('admin_invites')
.select('status');
if (error) {
console.error('[AdminInvite] Error fetching stats:', error);
return { total: 0, pending: 0, accepted: 0, expired: 0 };
}
const statusData = (data || []) as StatusRow[];
const stats = {
total: statusData.length,
pending: statusData.filter((i: StatusRow) => i.status === 'pending').length,
accepted: statusData.filter((i: StatusRow) => i.status === 'accepted').length,
expired: statusData.filter((i: StatusRow) => i.status === 'expired').length,
};
return stats;
} catch (error) {
console.error('[AdminInvite] Get stats error:', error);
return { total: 0, pending: 0, accepted: 0, expired: 0 };
}
}
/**
* Resend an invite (regenerate link and optionally send email)
*/
export async function resendAdminInvite(
inviteId: string,
sendEmail: boolean = false
): Promise<CreateInviteResult> {
try {
await requireFullAdmin();
const adminClient = createAdminClient();
// Get existing invite
const { data: invite, error: fetchError } = await adminClient
.from('admin_invites')
.select('*')
.eq('id', inviteId)
.single();
if (fetchError || !invite) {
return { success: false, error: 'Invite not found' };
}
if (invite.status === 'accepted') {
return { success: false, error: 'Cannot resend an accepted invite' };
}
// Check if user already exists AND has confirmed their email in Supabase Auth
// Note: Supabase creates users in auth.users when generateLink is called, but they're unconfirmed
// We only want to mark as accepted if they've actually completed the signup (email_confirmed_at is set)
const { data: existingUsers } = await adminClient.auth.admin.listUsers();
const existingUser = existingUsers?.users?.find(
(u: { email?: string }) => u.email?.toLowerCase() === invite.email.toLowerCase()
) as { email?: string; email_confirmed_at?: string; created_at?: string } | undefined;
if (existingUser?.email_confirmed_at) {
// User exists AND has confirmed their email - mark the invite as accepted
console.log('[AdminInvite] User already verified, marking invite as accepted:', invite.email);
await adminClient
.from('admin_invites')
.update({
status: 'accepted',
accepted_at: existingUser.email_confirmed_at || new Date().toISOString(),
})
.eq('id', inviteId);
revalidatePath('/admin/invites');
return {
success: false,
error: 'This user has already verified their account. The invite has been marked as accepted.',
};
}
// If user exists but hasn't confirmed, we need to delete them first
// because Supabase's generateLink({ type: 'invite' }) fails if user exists
if (existingUser && !existingUser.email_confirmed_at) {
console.log('[AdminInvite] Deleting unconfirmed user to regenerate invite:', invite.email);
const userId = (existingUser as { id: string }).id;
const { error: deleteError } = await adminClient.auth.admin.deleteUser(userId);
if (deleteError) {
console.error('[AdminInvite] Error deleting unconfirmed user:', deleteError);
// Continue anyway - generateLink might still work
}
}
// Get base URL
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://canadagpt.ca';
// Generate new invite link
// Note: redirectTo goes to home page because invite links use hash fragments
const { data: linkData, error: linkError } = await adminClient.auth.admin.generateLink({
type: 'invite',
email: invite.email,
options: {
data: {
full_name: invite.name,
invited_by_admin: true,
},
redirectTo: `${baseUrl}/en`,
},
});
if (linkError) {
console.error('[AdminInvite] Error regenerating link:', linkError);
// If we still get "already registered", the user must have confirmed since our check
if (linkError.message.includes('already been registered') || linkError.message.includes('already exists')) {
// Re-check if user is now confirmed
const { data: recheckUsers } = await adminClient.auth.admin.listUsers();
const recheckUser = recheckUsers?.users?.find(
(u: { email?: string }) => u.email?.toLowerCase() === invite.email.toLowerCase()
) as { email_confirmed_at?: string } | undefined;
if (recheckUser?.email_confirmed_at) {
// User confirmed between our check and now - mark as accepted
await adminClient
.from('admin_invites')
.update({
status: 'accepted',
accepted_at: recheckUser.email_confirmed_at,
})
.eq('id', inviteId);
revalidatePath('/admin/invites');
return {
success: false,
error: 'This user has already verified their account. The invite has been marked as accepted.',
};
}
// User exists but still unconfirmed - this shouldn't happen after delete
return { success: false, error: 'Unable to regenerate invite. Please try again or contact support.' };
}
return { success: false, error: linkError.message };
}
const inviteUrl = linkData.properties.action_link;
// Update invite record
await adminClient
.from('admin_invites')
.update({
invite_link: inviteUrl,
status: 'pending',
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
})
.eq('id', inviteId);
// Optionally send email
if (sendEmail) {
try {
const { sendInviteEmail } = await import('@/lib/email/admin-invite-email');
await sendInviteEmail({
to: invite.email,
name: invite.name || 'there',
inviteUrl,
});
} catch (emailError) {
console.error('[AdminInvite] Error sending email:', emailError);
}
}
revalidatePath('/admin/invites');
return {
success: true,
inviteUrl,
inviteId,
};
} catch (error) {
console.error('[AdminInvite] Resend error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'An unexpected error occurred',
};
}
}
/**
* Cancel a pending invite
*/
export async function cancelAdminInvite(inviteId: string): Promise<ActionResult> {
try {
await requireFullAdmin();
const adminClient = createAdminClient();
const { error } = await adminClient
.from('admin_invites')
.update({ status: 'cancelled' })
.eq('id', inviteId)
.eq('status', 'pending');
if (error) {
console.error('[AdminInvite] Error cancelling invite:', error);
return { success: false, error: 'Failed to cancel invite' };
}
revalidatePath('/admin/invites');
return { success: true };
} catch (error) {
console.error('[AdminInvite] Cancel error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'An unexpected error occurred',
};
}
}
/**
* Delete an invite (hard delete)
*/
export async function deleteAdminInvite(inviteId: string): Promise<ActionResult> {
try {
await requireFullAdmin();
const adminClient = createAdminClient();
const { error } = await adminClient
.from('admin_invites')
.delete()
.eq('id', inviteId);
if (error) {
console.error('[AdminInvite] Error deleting invite:', error);
return { success: false, error: 'Failed to delete invite' };
}
revalidatePath('/admin/invites');
return { success: true };
} catch (error) {
console.error('[AdminInvite] Delete error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'An unexpected error occurred',
};
}
}
/**
* Mark invite as accepted (called from auth callback)
*/
export async function markInviteAccepted(email: string): Promise<void> {
try {
const adminClient = createAdminClient();
await adminClient
.from('admin_invites')
.update({
status: 'accepted',
accepted_at: new Date().toISOString(),
})
.eq('email', email.toLowerCase())
.eq('status', 'pending');
} catch (error) {
console.error('[AdminInvite] Error marking invite accepted:', error);
// Don't throw - this is a non-critical operation
}
}