Files
thrilltrack-explorer/supabase/functions/merge-contact-tickets/index.ts
2025-10-28 22:59:40 +00:00

290 lines
9.4 KiB
TypeScript

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');
}
});