import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts'; import { createErrorResponse, sanitizeError } from '../_shared/errorSanitizer.ts'; const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }; interface MergeTicketsRequest { primaryTicketId: string; mergeTicketIds: string[]; mergeReason?: string; } interface MergeTicketsResponse { success: boolean; primaryTicketNumber: string; mergedCount: number; threadsConsolidated: number; deletedTickets: string[]; } serve(async (req) => { const tracking = startRequest(); if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } try { const authHeader = req.headers.get('Authorization'); if (!authHeader) { throw new Error('Missing authorization header'); } const supabase = createClient( Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_ANON_KEY') ?? '', { global: { headers: { Authorization: authHeader } } } ); // Authenticate user const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) { throw new Error('Unauthorized'); } edgeLogger.info('Merge tickets request started', { requestId: tracking.requestId, userId: user.id, }); // Check if user has moderator/admin role const { data: hasRole, error: roleError } = await supabase.rpc('has_role', { _user_id: user.id, _role: 'moderator' }); const { data: isAdmin, error: adminError } = await supabase.rpc('has_role', { _user_id: user.id, _role: 'admin' }); const { data: isSuperuser, error: superuserError } = await supabase.rpc('has_role', { _user_id: user.id, _role: 'superuser' }); if (roleError || adminError || superuserError || (!hasRole && !isAdmin && !isSuperuser)) { throw new Error('Insufficient permissions. Moderator role required.'); } // Parse request body const { primaryTicketId, mergeTicketIds, mergeReason }: MergeTicketsRequest = await req.json(); // Validation if (!primaryTicketId || !mergeTicketIds || mergeTicketIds.length === 0) { throw new Error('Invalid request: primaryTicketId and mergeTicketIds required'); } if (mergeTicketIds.includes(primaryTicketId)) { throw new Error('Cannot merge a ticket into itself'); } if (mergeTicketIds.length > 10) { throw new Error('Maximum 10 tickets can be merged at once'); } // Start transaction-like operations const allTicketIds = [primaryTicketId, ...mergeTicketIds]; // Fetch all tickets const { data: tickets, error: fetchError } = await supabase .from('contact_submissions') .select('id, ticket_number, admin_notes, merged_ticket_numbers') .in('id', allTicketIds); if (fetchError) throw fetchError; if (!tickets || tickets.length !== allTicketIds.length) { throw new Error('One or more tickets not found'); } const primaryTicket = tickets.find(t => t.id === primaryTicketId); const mergeTickets = tickets.filter(t => mergeTicketIds.includes(t.id)); if (!primaryTicket) { throw new Error('Primary ticket not found'); } // Check if any ticket already has merged_ticket_numbers (prevent re-merging) const alreadyMerged = tickets.find(t => t.merged_ticket_numbers && t.merged_ticket_numbers.length > 0 ); if (alreadyMerged) { throw new Error(`Ticket ${alreadyMerged.ticket_number} has already been used in a merge`); } edgeLogger.info('Starting merge process', { requestId: tracking.requestId, primaryTicket: primaryTicket.ticket_number, mergeTicketCount: mergeTickets.length, }); // Step 1: Move all email threads to primary ticket edgeLogger.info('Step 1: Moving email threads', { requestId: tracking.requestId, fromTickets: mergeTickets.map(t => t.ticket_number) }); const { data: movedThreads, error: moveError } = await supabase .from('contact_email_threads') .update({ submission_id: primaryTicketId }) .in('submission_id', mergeTicketIds) .select('id'); if (moveError) throw moveError; const threadsMovedCount = movedThreads?.length || 0; edgeLogger.info('Threads moved successfully', { requestId: tracking.requestId, threadsMovedCount }); if (threadsMovedCount === 0) { edgeLogger.warn('No email threads found to move', { requestId: tracking.requestId, mergeTicketIds }); } // Step 2: Consolidate admin notes edgeLogger.info('Step 2: Consolidating admin notes', { requestId: tracking.requestId }); let consolidatedNotes = primaryTicket.admin_notes || ''; for (const ticket of mergeTickets) { if (ticket.admin_notes) { consolidatedNotes = consolidatedNotes.trim() ? `${consolidatedNotes}\n\n${ticket.admin_notes}` : ticket.admin_notes; } } // Step 3: Recalculate metadata from consolidated threads edgeLogger.info('Step 3: Recalculating metadata from threads', { requestId: tracking.requestId }); const { data: threadStats, error: statsError } = await supabase .from('contact_email_threads') .select('direction, created_at') .eq('submission_id', primaryTicketId); if (statsError) throw statsError; const outboundCount = threadStats?.filter(t => t.direction === 'outbound').length || 0; const lastAdminResponse = threadStats ?.filter(t => t.direction === 'outbound') .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]?.created_at; const lastUserResponse = threadStats ?.filter(t => t.direction === 'inbound') .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]?.created_at; edgeLogger.info('Metadata recalculated', { requestId: tracking.requestId, outboundCount, lastAdminResponse, lastUserResponse }); // Get merged ticket numbers const mergedTicketNumbers = mergeTickets.map(t => t.ticket_number); // Step 4: Update primary ticket with consolidated data edgeLogger.info('Step 4: Updating primary ticket', { requestId: tracking.requestId }); const { error: updateError } = await supabase .from('contact_submissions') .update({ admin_notes: consolidatedNotes, response_count: outboundCount, last_admin_response_at: lastAdminResponse || null, merged_ticket_numbers: [ ...(primaryTicket.merged_ticket_numbers || []), ...mergedTicketNumbers ], updated_at: new Date().toISOString(), }) .eq('id', primaryTicketId); if (updateError) throw updateError; edgeLogger.info('Primary ticket updated successfully', { requestId: tracking.requestId }); // Step 5: Delete merged tickets edgeLogger.info('Step 5: Deleting merged tickets', { requestId: tracking.requestId, ticketsToDelete: mergeTicketIds.length }); const { error: deleteError } = await supabase .from('contact_submissions') .delete() .in('id', mergeTicketIds); if (deleteError) throw deleteError; edgeLogger.info('Merged tickets deleted successfully', { requestId: tracking.requestId }); // Step 6: Audit log edgeLogger.info('Step 6: Creating audit log', { requestId: tracking.requestId }); const { error: auditError } = await supabase.from('admin_audit_log').insert({ admin_user_id: user.id, target_user_id: user.id, // No specific target user for this action action: 'merge_contact_tickets', details: { primary_ticket_id: primaryTicketId, primary_ticket_number: primaryTicket.ticket_number, merged_ticket_ids: mergeTicketIds, merged_ticket_numbers: mergedTicketNumbers, merge_reason: mergeReason || null, threads_moved: threadsMovedCount, merged_count: mergeTickets.length, } }); if (auditError) { edgeLogger.warn('Failed to create audit log for merge', { requestId: tracking.requestId, error: auditError.message, primaryTicket: primaryTicket.ticket_number }); // Don't throw - merge already succeeded } const duration = endRequest(tracking); edgeLogger.info('Merge tickets completed successfully', { requestId: tracking.requestId, duration, primaryTicket: primaryTicket.ticket_number, mergedCount: mergeTickets.length, }); const response: MergeTicketsResponse = { success: true, primaryTicketNumber: primaryTicket.ticket_number, mergedCount: mergeTickets.length, threadsConsolidated: threadsMovedCount, deletedTickets: mergedTicketNumbers, }; return new Response(JSON.stringify(response), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200, }); } catch (error) { const duration = endRequest(tracking); edgeLogger.error('Merge tickets failed', { requestId: tracking.requestId, duration, error: error instanceof Error ? error.message : 'Unknown error', }); return createErrorResponse(error, 500, corsHeaders, 'merge_contact_tickets'); } });