diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts
index f0110c9b..6631f394 100644
--- a/src/integrations/supabase/types.ts
+++ b/src/integrations/supabase/types.ts
@@ -540,6 +540,7 @@ export type Database = {
id: string
ip_address_hash: string | null
last_admin_response_at: string | null
+ merged_ticket_numbers: string[] | null
message: string
name: string
resolved_at: string | null
@@ -567,6 +568,7 @@ export type Database = {
id?: string
ip_address_hash?: string | null
last_admin_response_at?: string | null
+ merged_ticket_numbers?: string[] | null
message: string
name: string
resolved_at?: string | null
@@ -594,6 +596,7 @@ export type Database = {
id?: string
ip_address_hash?: string | null
last_admin_response_at?: string | null
+ merged_ticket_numbers?: string[] | null
message?: string
name?: string
resolved_at?: string | null
diff --git a/src/pages/admin/AdminContact.tsx b/src/pages/admin/AdminContact.tsx
index 64b601cd..555da808 100644
--- a/src/pages/admin/AdminContact.tsx
+++ b/src/pages/admin/AdminContact.tsx
@@ -25,6 +25,7 @@ import {
ArchiveRestore,
Trash2,
AlertTriangle,
+ Merge,
} from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { Button } from '@/components/ui/button';
@@ -67,6 +68,10 @@ import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { DialogFooter } from '@/components/ui/dialog';
import { useTheme } from '@/components/theme/ThemeProvider';
import { useUserRole } from '@/hooks/useUserRole';
import { handleError, handleSuccess } from '@/lib/errorHandler';
@@ -110,6 +115,7 @@ interface ContactSubmission {
ticket_number: string;
archived_at: string | null;
archived_by: string | null;
+ merged_ticket_numbers: string[] | null;
}
interface EmailThread {
@@ -141,6 +147,13 @@ export default function AdminContact() {
const [activeTab, setActiveTab] = useState('details');
const [replyStatus, setReplyStatus] = useState('');
const [showArchived, setShowArchived] = useState(false);
+
+ // Merge mode state
+ const [selectedForMerge, setSelectedForMerge] = useState([]);
+ const [mergeMode, setMergeMode] = useState(false);
+ const [showMergeDialog, setShowMergeDialog] = useState(false);
+ const [primaryTicketId, setPrimaryTicketId] = useState(null);
+ const [mergeReason, setMergeReason] = useState('');
// Fetch contact submissions
const { data: submissions, isLoading } = useQuery({
@@ -451,6 +464,57 @@ export default function AdminContact() {
});
};
+ // Merge tickets mutation
+ const mergeTicketsMutation = useMutation({
+ mutationFn: async ({ primaryId, mergeIds, reason }: {
+ primaryId: string;
+ mergeIds: string[];
+ reason?: string;
+ }) => {
+ const { data, error } = await invokeWithTracking(
+ 'merge-contact-tickets',
+ {
+ primaryTicketId: primaryId,
+ mergeTicketIds: mergeIds,
+ mergeReason: reason
+ },
+ undefined
+ );
+ if (error) throw error;
+ return data;
+ },
+ onSuccess: (data: any) => {
+ handleSuccess(
+ 'Tickets Merged Successfully',
+ `Merged ${data.mergedCount} tickets into ${data.primaryTicketNumber}. ${data.threadsConsolidated} email threads consolidated.`
+ );
+
+ // Reset merge mode
+ setMergeMode(false);
+ setSelectedForMerge([]);
+ setShowMergeDialog(false);
+ setPrimaryTicketId(null);
+ setMergeReason('');
+
+ // Refresh data
+ queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
+ },
+ onError: (error: Error) => {
+ handleError(error, { action: 'Merge Tickets' });
+ }
+ });
+
+ const handleConfirmMerge = () => {
+ if (!primaryTicketId || selectedForMerge.length < 2) return;
+
+ const mergeIds = selectedForMerge.filter(id => id !== primaryTicketId);
+ mergeTicketsMutation.mutate({
+ primaryId: primaryTicketId,
+ mergeIds,
+ reason: mergeReason || undefined
+ });
+ };
+
// Show loading state while roles are being fetched
if (rolesLoading) {
return (
@@ -536,6 +600,17 @@ export default function AdminContact() {
+
+ {/* Merge History Section */}
+ {selectedSubmission.merged_ticket_numbers && selectedSubmission.merged_ticket_numbers.length > 0 && (
+
+
+
+
+ Merge History ({selectedSubmission.merged_ticket_numbers.length} tickets consolidated)
+
+
+
+
+
The following tickets were merged and deleted into this primary ticket:
+ {selectedSubmission.merged_ticket_numbers.map(ticketNum => (
+
+
+ {ticketNum}
+ (deleted)
+
+ ))}
+
+
+
+
+ )}
+
{/* Danger Zone - Archive/Delete */}
diff --git a/supabase/config.toml b/supabase/config.toml
index de0537ba..9cb6ef3c 100644
--- a/supabase/config.toml
+++ b/supabase/config.toml
@@ -57,5 +57,8 @@ verify_jwt = false
[functions.send-admin-email-reply]
verify_jwt = true
+[functions.merge-contact-tickets]
+verify_jwt = true
+
[functions.receive-inbound-email]
verify_jwt = false
\ No newline at end of file
diff --git a/supabase/functions/merge-contact-tickets/index.ts b/supabase/functions/merge-contact-tickets/index.ts
new file mode 100644
index 00000000..dbd850df
--- /dev/null
+++ b/supabase/functions/merge-contact-tickets/index.ts
@@ -0,0 +1,239 @@
+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
+ 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;
+
+ // Step 2: Consolidate admin notes
+ let consolidatedNotes = primaryTicket.admin_notes || '';
+
+ for (const ticket of mergeTickets) {
+ if (ticket.admin_notes) {
+ consolidatedNotes = consolidatedNotes
+ ? `${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;
+
+ // 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;
+
+ // Step 5: Delete merged tickets
+ const { error: deleteError } = await supabase
+ .from('contact_submissions')
+ .delete()
+ .in('id', mergeTicketIds);
+
+ if (deleteError) throw deleteError;
+
+ // Step 6: Audit log
+ 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,
+ }
+ });
+
+ 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');
+ }
+});
diff --git a/supabase/migrations/20251028224512_bf97dab5-01c6-47f2-91dd-1afa9762236e.sql b/supabase/migrations/20251028224512_bf97dab5-01c6-47f2-91dd-1afa9762236e.sql
new file mode 100644
index 00000000..2d68a49f
--- /dev/null
+++ b/supabase/migrations/20251028224512_bf97dab5-01c6-47f2-91dd-1afa9762236e.sql
@@ -0,0 +1,9 @@
+-- Add merge tracking to contact submissions
+ALTER TABLE contact_submissions
+ADD COLUMN merged_ticket_numbers text[] DEFAULT '{}';
+
+CREATE INDEX idx_contact_submissions_merged_ticket_numbers
+ON contact_submissions USING GIN (merged_ticket_numbers)
+WHERE merged_ticket_numbers IS NOT NULL AND array_length(merged_ticket_numbers, 1) > 0;
+
+COMMENT ON COLUMN contact_submissions.merged_ticket_numbers IS 'Array of ticket numbers that were merged and deleted into this primary ticket';
\ No newline at end of file