mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 03:11:12 -05:00
Migrate Phase 2 admin edges
Migrate five admin/moderator edge functions (merge-contact-tickets, send-escalation-notification, notify-moderators-report, notify-moderators-submission, send-password-added-email) to use createEdgeFunction wrapper. Remove manual CORS, auth, service-client setup, logging, and error handling. Implement handler with EdgeFunctionContext, apply appropriate wrapper config (requireAuth, requiredRoles/useServiceRole, corsEnabled, enableTracing, rateLimitTier). Replace edgeLogger with span events, maintain core business logic and retry/email integration patterns.
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
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 { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
|
||||
import { corsHeaders } from '../_shared/cors.ts';
|
||||
import { edgeLogger, startRequest, endRequest, logSpanToDatabase, startSpan, endSpan } from '../_shared/logger.ts';
|
||||
import { createErrorResponse, sanitizeError } from '../_shared/errorSanitizer.ts';
|
||||
import { addSpanEvent } from '../_shared/logger.ts';
|
||||
|
||||
interface MergeTicketsRequest {
|
||||
primaryTicketId: string;
|
||||
@@ -18,273 +17,189 @@ interface MergeTicketsResponse {
|
||||
deletedTickets: string[];
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
const tracking = startRequest();
|
||||
const handler = async (req: Request, { supabase, user, span, requestId }: EdgeFunctionContext) => {
|
||||
// Parse request body
|
||||
const { primaryTicketId, mergeTicketIds, mergeReason }: MergeTicketsRequest = await req.json();
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
// Validation
|
||||
if (!primaryTicketId || !mergeTicketIds || mergeTicketIds.length === 0) {
|
||||
throw new Error('Invalid request: primaryTicketId and mergeTicketIds required');
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
// Persist error to database for monitoring
|
||||
const errorSpan = startSpan('merge-contact-tickets-error', 'SERVER');
|
||||
endSpan(errorSpan, 'error', error);
|
||||
logSpanToDatabase(errorSpan, tracking.requestId);
|
||||
|
||||
return createErrorResponse(error, 500, corsHeaders, 'merge_contact_tickets');
|
||||
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');
|
||||
}
|
||||
|
||||
addSpanEvent(span, 'merge_tickets_started', {
|
||||
primaryTicketId,
|
||||
mergeCount: mergeTicketIds.length
|
||||
});
|
||||
|
||||
// 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
|
||||
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`);
|
||||
}
|
||||
|
||||
addSpanEvent(span, 'tickets_validated', {
|
||||
primaryTicket: primaryTicket.ticket_number,
|
||||
mergeTicketCount: mergeTickets.length
|
||||
});
|
||||
|
||||
// Step 1: Move all email threads to primary ticket
|
||||
addSpanEvent(span, 'moving_email_threads', {
|
||||
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;
|
||||
|
||||
addSpanEvent(span, 'threads_moved', { threadsMovedCount });
|
||||
|
||||
if (threadsMovedCount === 0) {
|
||||
addSpanEvent(span, 'no_threads_found', { mergeTicketIds });
|
||||
}
|
||||
|
||||
// Step 2: Consolidate admin notes
|
||||
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
|
||||
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;
|
||||
|
||||
addSpanEvent(span, 'metadata_recalculated', {
|
||||
outboundCount,
|
||||
lastAdminResponse,
|
||||
lastUserResponse
|
||||
});
|
||||
|
||||
// Get merged ticket numbers
|
||||
const mergedTicketNumbers = mergeTickets.map(t => t.ticket_number);
|
||||
|
||||
// Step 4: Update primary ticket with consolidated data
|
||||
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;
|
||||
|
||||
addSpanEvent(span, 'primary_ticket_updated', { primaryTicket: primaryTicket.ticket_number });
|
||||
|
||||
// Step 5: Delete merged tickets
|
||||
const { error: deleteError } = await supabase
|
||||
.from('contact_submissions')
|
||||
.delete()
|
||||
.in('id', mergeTicketIds);
|
||||
|
||||
if (deleteError) throw deleteError;
|
||||
|
||||
addSpanEvent(span, 'merged_tickets_deleted', { count: mergeTicketIds.length });
|
||||
|
||||
// Step 6: Audit log
|
||||
const { error: auditError } = await supabase.from('admin_audit_log').insert({
|
||||
admin_user_id: user.id,
|
||||
target_user_id: user.id,
|
||||
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) {
|
||||
addSpanEvent(span, 'audit_log_failed', { error: auditError.message });
|
||||
}
|
||||
|
||||
addSpanEvent(span, 'merge_completed', {
|
||||
primaryTicket: primaryTicket.ticket_number,
|
||||
mergedCount: mergeTickets.length
|
||||
});
|
||||
|
||||
const response: MergeTicketsResponse = {
|
||||
success: true,
|
||||
primaryTicketNumber: primaryTicket.ticket_number,
|
||||
mergedCount: mergeTickets.length,
|
||||
threadsConsolidated: threadsMovedCount,
|
||||
deletedTickets: mergedTicketNumbers,
|
||||
};
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
serve(createEdgeFunction({
|
||||
name: 'merge-contact-tickets',
|
||||
requireAuth: true,
|
||||
requiredRoles: ['superuser', 'admin', 'moderator'],
|
||||
corsHeaders,
|
||||
enableTracing: true,
|
||||
logRequests: true,
|
||||
}, handler));
|
||||
|
||||
Reference in New Issue
Block a user