From ed55905295752417ac0b34e4fc7bc946e7dd1789 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:36:11 +0000 Subject: [PATCH] feat: Implement contact submission archiving and deletion --- src/integrations/supabase/types.ts | 13 ++ src/pages/admin/AdminContact.tsx | 207 ++++++++++++++++-- ...1_0c37046b-3d85-4d8a-a6a4-b24d79ce5b3b.sql | 31 +++ 3 files changed, 237 insertions(+), 14 deletions(-) create mode 100644 supabase/migrations/20251028193251_0c37046b-3d85-4d8a-a6a4-b24d79ce5b3b.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index c9c5efc6..f0110c9b 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -498,6 +498,13 @@ export type Database = { referencedRelation: "contact_submissions" referencedColumns: ["id"] }, + { + foreignKeyName: "fk_submission_cascade" + columns: ["submission_id"] + isOneToOne: false + referencedRelation: "contact_submissions" + referencedColumns: ["id"] + }, ] } contact_rate_limits: { @@ -524,6 +531,8 @@ export type Database = { contact_submissions: { Row: { admin_notes: string | null + archived_at: string | null + archived_by: string | null assigned_to: string | null category: string created_at: string @@ -549,6 +558,8 @@ export type Database = { } Insert: { admin_notes?: string | null + archived_at?: string | null + archived_by?: string | null assigned_to?: string | null category: string created_at?: string @@ -574,6 +585,8 @@ export type Database = { } Update: { admin_notes?: string | null + archived_at?: string | null + archived_by?: string | null assigned_to?: string | null category?: string created_at?: string diff --git a/src/pages/admin/AdminContact.tsx b/src/pages/admin/AdminContact.tsx index d22b884b..64b601cd 100644 --- a/src/pages/admin/AdminContact.tsx +++ b/src/pages/admin/AdminContact.tsx @@ -21,6 +21,10 @@ import { User, Award, TrendingUp, + Archive, + ArchiveRestore, + Trash2, + AlertTriangle, } from 'lucide-react'; import { supabase } from '@/integrations/supabase/client'; import { Button } from '@/components/ui/button'; @@ -48,6 +52,17 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -93,6 +108,8 @@ interface ContactSubmission { last_admin_response_at: string | null; response_count: number; ticket_number: string; + archived_at: string | null; + archived_by: string | null; } interface EmailThread { @@ -123,16 +140,24 @@ export default function AdminContact() { const [copiedTicket, setCopiedTicket] = useState(null); const [activeTab, setActiveTab] = useState('details'); const [replyStatus, setReplyStatus] = useState(''); + const [showArchived, setShowArchived] = useState(false); // Fetch contact submissions const { data: submissions, isLoading } = useQuery({ - queryKey: ['admin-contact-submissions', statusFilter, categoryFilter, searchQuery], + queryKey: ['admin-contact-submissions', statusFilter, categoryFilter, searchQuery, showArchived], queryFn: async () => { let query = supabase .from('contact_submissions') .select('*') .order('created_at', { ascending: false }); + // Filter archived based on toggle + if (showArchived) { + query = query.not('archived_at', 'is', null); + } else { + query = query.is('archived_at', null); + } + if (statusFilter !== 'all') { query = query.eq('status', statusFilter); } @@ -290,6 +315,73 @@ export default function AdminContact() { }, }); + // Archive submission mutation + const archiveSubmissionMutation = useMutation({ + mutationFn: async (id: string) => { + const { data: { user } } = await supabase.auth.getUser(); + const { error } = await supabase + .from('contact_submissions') + .update({ + archived_at: new Date().toISOString(), + archived_by: user?.id + }) + .eq('id', id); + + if (error) throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); + handleSuccess('Archived', 'Contact submission has been archived'); + setSelectedSubmission(null); + }, + onError: (error) => { + handleError(error, { action: 'archive_contact_submission' }); + } + }); + + // Unarchive submission mutation + const unarchiveSubmissionMutation = useMutation({ + mutationFn: async (id: string) => { + const { error } = await supabase + .from('contact_submissions') + .update({ + archived_at: null, + archived_by: null + }) + .eq('id', id); + + if (error) throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); + handleSuccess('Restored', 'Contact submission has been restored from archive'); + setSelectedSubmission(null); + }, + onError: (error) => { + handleError(error, { action: 'unarchive_contact_submission' }); + } + }); + + // Delete submission mutation + const deleteSubmissionMutation = useMutation({ + mutationFn: async (id: string) => { + const { error } = await supabase + .from('contact_submissions') + .delete() + .eq('id', id); + + if (error) throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); + handleSuccess('Deleted', 'Contact submission has been permanently deleted'); + setSelectedSubmission(null); + }, + onError: (error) => { + handleError(error, { action: 'delete_contact_submission' }); + } + }); + const handleUpdateStatus = (status: string) => { if (!selectedSubmission) return; @@ -426,9 +518,10 @@ export default function AdminContact() { } const stats = { - pending: submissions?.filter((s) => s.status === 'pending').length || 0, - inProgress: submissions?.filter((s) => s.status === 'in_progress').length || 0, - resolved: submissions?.filter((s) => s.status === 'resolved').length || 0, + pending: submissions?.filter((s) => s.status === 'pending' && !s.archived_at).length || 0, + inProgress: submissions?.filter((s) => s.status === 'in_progress' && !s.archived_at).length || 0, + resolved: submissions?.filter((s) => s.status === 'resolved' && !s.archived_at).length || 0, + archived: submissions?.filter((s) => s.archived_at !== null).length || 0, total: submissions?.length || 0, }; @@ -442,19 +535,28 @@ export default function AdminContact() { Manage and respond to user contact form submissions

- +
+ + +
{/* Stats Cards */} -
+
Pending @@ -473,6 +575,12 @@ export default function AdminContact() { {stats.resolved} + + + Archived + {stats.archived} + + Total @@ -574,6 +682,12 @@ export default function AdminContact() { {getStatusBadge(submission.status)} {getCategoryLabel(submission.category)} + {submission.archived_at && ( + + + Archived + + )} {submission.response_count > 0 && ( 📧 {submission.response_count} {submission.response_count === 1 ? 'reply' : 'replies'} @@ -815,6 +929,71 @@ export default function AdminContact() { Save Notes & Status
+ + {/* Danger Zone - Archive/Delete */} +
+

+ + Danger Zone +

+
+ {!showArchived ? ( + <> + + + + + + + + Are you absolutely sure? + + This will permanently delete the contact submission and all associated email threads. + This action cannot be undone. Consider archiving instead. + + + + Cancel + deleteSubmissionMutation.mutate(selectedSubmission.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleteSubmissionMutation.isPending && } + Yes, Delete Permanently + + + + + + ) : ( + + )} +
+
diff --git a/supabase/migrations/20251028193251_0c37046b-3d85-4d8a-a6a4-b24d79ce5b3b.sql b/supabase/migrations/20251028193251_0c37046b-3d85-4d8a-a6a4-b24d79ce5b3b.sql new file mode 100644 index 00000000..b34a91f1 --- /dev/null +++ b/supabase/migrations/20251028193251_0c37046b-3d85-4d8a-a6a4-b24d79ce5b3b.sql @@ -0,0 +1,31 @@ +-- Add archive support columns to contact_submissions +ALTER TABLE contact_submissions + ADD COLUMN archived_at timestamp with time zone, + ADD COLUMN archived_by uuid REFERENCES auth.users(id); + +-- Add comments for clarity +COMMENT ON COLUMN contact_submissions.archived_at IS 'Timestamp when submission was archived (soft deleted)'; +COMMENT ON COLUMN contact_submissions.archived_by IS 'User who archived this submission'; + +-- Index for performance on archived queries +CREATE INDEX idx_contact_submissions_archived ON contact_submissions(archived_at) + WHERE archived_at IS NOT NULL; + +-- Add FK constraint to ensure email threads are deleted with submission (cascade) +ALTER TABLE contact_email_threads + ADD CONSTRAINT fk_submission_cascade + FOREIGN KEY (submission_id) + REFERENCES contact_submissions(id) + ON DELETE CASCADE; + +-- Add comment on constraint +COMMENT ON CONSTRAINT fk_submission_cascade ON contact_email_threads IS + 'Cascade delete email threads when parent submission is deleted'; + +-- Allow moderators to DELETE contact submissions (hard delete with MFA) +CREATE POLICY "Moderators can delete contact submissions" ON contact_submissions + FOR DELETE + TO authenticated + USING ( + is_moderator(auth.uid()) AND has_aal2() + ); \ No newline at end of file