mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 03:11:12 -05:00
feat: Implement ticket merging
This commit is contained in:
@@ -540,6 +540,7 @@ export type Database = {
|
|||||||
id: string
|
id: string
|
||||||
ip_address_hash: string | null
|
ip_address_hash: string | null
|
||||||
last_admin_response_at: string | null
|
last_admin_response_at: string | null
|
||||||
|
merged_ticket_numbers: string[] | null
|
||||||
message: string
|
message: string
|
||||||
name: string
|
name: string
|
||||||
resolved_at: string | null
|
resolved_at: string | null
|
||||||
@@ -567,6 +568,7 @@ export type Database = {
|
|||||||
id?: string
|
id?: string
|
||||||
ip_address_hash?: string | null
|
ip_address_hash?: string | null
|
||||||
last_admin_response_at?: string | null
|
last_admin_response_at?: string | null
|
||||||
|
merged_ticket_numbers?: string[] | null
|
||||||
message: string
|
message: string
|
||||||
name: string
|
name: string
|
||||||
resolved_at?: string | null
|
resolved_at?: string | null
|
||||||
@@ -594,6 +596,7 @@ export type Database = {
|
|||||||
id?: string
|
id?: string
|
||||||
ip_address_hash?: string | null
|
ip_address_hash?: string | null
|
||||||
last_admin_response_at?: string | null
|
last_admin_response_at?: string | null
|
||||||
|
merged_ticket_numbers?: string[] | null
|
||||||
message?: string
|
message?: string
|
||||||
name?: string
|
name?: string
|
||||||
resolved_at?: string | null
|
resolved_at?: string | null
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
Trash2,
|
Trash2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Merge,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
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 { useTheme } from '@/components/theme/ThemeProvider';
|
||||||
import { useUserRole } from '@/hooks/useUserRole';
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||||
@@ -110,6 +115,7 @@ interface ContactSubmission {
|
|||||||
ticket_number: string;
|
ticket_number: string;
|
||||||
archived_at: string | null;
|
archived_at: string | null;
|
||||||
archived_by: string | null;
|
archived_by: string | null;
|
||||||
|
merged_ticket_numbers: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EmailThread {
|
interface EmailThread {
|
||||||
@@ -142,6 +148,13 @@ export default function AdminContact() {
|
|||||||
const [replyStatus, setReplyStatus] = useState<string>('');
|
const [replyStatus, setReplyStatus] = useState<string>('');
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
|
||||||
|
// Merge mode state
|
||||||
|
const [selectedForMerge, setSelectedForMerge] = useState<string[]>([]);
|
||||||
|
const [mergeMode, setMergeMode] = useState(false);
|
||||||
|
const [showMergeDialog, setShowMergeDialog] = useState(false);
|
||||||
|
const [primaryTicketId, setPrimaryTicketId] = useState<string | null>(null);
|
||||||
|
const [mergeReason, setMergeReason] = useState('');
|
||||||
|
|
||||||
// Fetch contact submissions
|
// Fetch contact submissions
|
||||||
const { data: submissions, isLoading } = useQuery({
|
const { data: submissions, isLoading } = useQuery({
|
||||||
queryKey: ['admin-contact-submissions', statusFilter, categoryFilter, searchQuery, showArchived],
|
queryKey: ['admin-contact-submissions', statusFilter, categoryFilter, searchQuery, showArchived],
|
||||||
@@ -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
|
// Show loading state while roles are being fetched
|
||||||
if (rolesLoading) {
|
if (rolesLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -536,6 +600,17 @@ export default function AdminContact() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={mergeMode ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setMergeMode(!mergeMode);
|
||||||
|
setSelectedForMerge([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Merge className="h-4 w-4 mr-2" />
|
||||||
|
{mergeMode ? 'Cancel Merge' : 'Merge Tickets'}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={showArchived ? "default" : "outline"}
|
variant={showArchived ? "default" : "outline"}
|
||||||
onClick={() => setShowArchived(!showArchived)}
|
onClick={() => setShowArchived(!showArchived)}
|
||||||
@@ -657,12 +732,29 @@ export default function AdminContact() {
|
|||||||
key={submission.id}
|
key={submission.id}
|
||||||
className="cursor-pointer hover:border-primary transition-colors"
|
className="cursor-pointer hover:border-primary transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!mergeMode) {
|
||||||
setSelectedSubmission(submission);
|
setSelectedSubmission(submission);
|
||||||
setAdminNotes(submission.admin_notes || '');
|
setAdminNotes(submission.admin_notes || '');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
{mergeMode && (
|
||||||
|
<div className="pt-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedForMerge.includes(submission.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedForMerge(prev =>
|
||||||
|
checked
|
||||||
|
? [...prev, submission.id]
|
||||||
|
: prev.filter(id => id !== submission.id)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<h3 className="font-semibold text-lg">{submission.subject}</h3>
|
<h3 className="font-semibold text-lg">{submission.subject}</h3>
|
||||||
@@ -688,6 +780,12 @@ export default function AdminContact() {
|
|||||||
Archived
|
Archived
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{submission.merged_ticket_numbers && submission.merged_ticket_numbers.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Merge className="h-3 w-3" />
|
||||||
|
Consolidated ({submission.merged_ticket_numbers.length})
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{submission.response_count > 0 && (
|
{submission.response_count > 0 && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
📧 {submission.response_count} {submission.response_count === 1 ? 'reply' : 'replies'}
|
📧 {submission.response_count} {submission.response_count === 1 ? 'reply' : 'replies'}
|
||||||
@@ -712,6 +810,7 @@ export default function AdminContact() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm line-clamp-2">{submission.message}</p>
|
<p className="text-sm line-clamp-2">{submission.message}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{!mergeMode && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -725,6 +824,7 @@ export default function AdminContact() {
|
|||||||
</Button>
|
</Button>
|
||||||
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -741,6 +841,113 @@ export default function AdminContact() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Floating Merge Button */}
|
||||||
|
{mergeMode && selectedForMerge.length >= 2 && (
|
||||||
|
<div className="fixed bottom-6 right-6 z-50">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={() => {
|
||||||
|
setPrimaryTicketId(selectedForMerge[0]); // Default to first selected
|
||||||
|
setShowMergeDialog(true);
|
||||||
|
}}
|
||||||
|
className="shadow-lg"
|
||||||
|
>
|
||||||
|
<Merge className="h-5 w-5 mr-2" />
|
||||||
|
Merge Selected ({selectedForMerge.length})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Merge Confirmation Dialog */}
|
||||||
|
<Dialog open={showMergeDialog} onOpenChange={setShowMergeDialog}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Merge Contact Tickets</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose a primary ticket to keep. All others will be permanently deleted after their data is merged.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* List selected tickets with radio buttons */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedForMerge.map(ticketId => {
|
||||||
|
const ticket = submissions?.find(s => s.id === ticketId);
|
||||||
|
if (!ticket) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={ticketId} className="flex items-start gap-3 p-3 border rounded">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="primary"
|
||||||
|
value={ticketId}
|
||||||
|
checked={primaryTicketId === ticketId}
|
||||||
|
onChange={() => setPrimaryTicketId(ticketId)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{ticket.ticket_number}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{ticket.subject}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{ticket.email} • {ticket.response_count} email threads
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optional merge reason */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="merge-reason">Reason for merge (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="merge-reason"
|
||||||
|
placeholder="e.g., Duplicate submissions about the same issue"
|
||||||
|
value={mergeReason}
|
||||||
|
onChange={(e) => setMergeReason(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Alert */}
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>This will:</strong>
|
||||||
|
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||||||
|
<li>Keep <strong>{submissions?.find(s => s.id === primaryTicketId)?.ticket_number}</strong> active</li>
|
||||||
|
<li>Move all email threads to primary ticket</li>
|
||||||
|
<li>Consolidate admin notes</li>
|
||||||
|
<li><strong className="text-destructive">Permanently delete {selectedForMerge.length - 1} other ticket(s)</strong></li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-2 font-semibold">This action cannot be undone.</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setShowMergeDialog(false);
|
||||||
|
setMergeReason('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirmMerge}
|
||||||
|
disabled={!primaryTicketId || mergeTicketsMutation.isPending}
|
||||||
|
>
|
||||||
|
{mergeTicketsMutation.isPending ? (
|
||||||
|
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Merging...</>
|
||||||
|
) : (
|
||||||
|
'Confirm Merge & Delete'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Submission Detail Dialog */}
|
{/* Submission Detail Dialog */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={!!selectedSubmission}
|
open={!!selectedSubmission}
|
||||||
@@ -930,6 +1137,31 @@ export default function AdminContact() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Merge History Section */}
|
||||||
|
{selectedSubmission.merged_ticket_numbers && selectedSubmission.merged_ticket_numbers.length > 0 && (
|
||||||
|
<div className="mt-6 pt-6 border-t">
|
||||||
|
<Collapsible>
|
||||||
|
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-semibold hover:text-primary transition-colors">
|
||||||
|
<Merge className="h-4 w-4" />
|
||||||
|
Merge History ({selectedSubmission.merged_ticket_numbers.length} tickets consolidated)
|
||||||
|
<ChevronDown className="h-4 w-4 ml-1" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="mt-3 space-y-1 text-sm text-muted-foreground">
|
||||||
|
<p className="mb-2">The following tickets were merged and deleted into this primary ticket:</p>
|
||||||
|
{selectedSubmission.merged_ticket_numbers.map(ticketNum => (
|
||||||
|
<div key={ticketNum} className="flex items-center gap-2 py-1">
|
||||||
|
<XCircle className="h-3 w-3 text-destructive" />
|
||||||
|
<span className="font-mono">{ticketNum}</span>
|
||||||
|
<span className="text-xs">(deleted)</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Danger Zone - Archive/Delete */}
|
{/* Danger Zone - Archive/Delete */}
|
||||||
<div className="mt-8 pt-6 border-t border-destructive/20">
|
<div className="mt-8 pt-6 border-t border-destructive/20">
|
||||||
<h4 className="text-sm font-semibold mb-3 text-destructive flex items-center gap-2">
|
<h4 className="text-sm font-semibold mb-3 text-destructive flex items-center gap-2">
|
||||||
|
|||||||
@@ -57,5 +57,8 @@ verify_jwt = false
|
|||||||
[functions.send-admin-email-reply]
|
[functions.send-admin-email-reply]
|
||||||
verify_jwt = true
|
verify_jwt = true
|
||||||
|
|
||||||
|
[functions.merge-contact-tickets]
|
||||||
|
verify_jwt = true
|
||||||
|
|
||||||
[functions.receive-inbound-email]
|
[functions.receive-inbound-email]
|
||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
239
supabase/functions/merge-contact-tickets/index.ts
Normal file
239
supabase/functions/merge-contact-tickets/index.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user