Compare commits

...

6 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
0257bccc45 feat: Improve email thread display and status updates 2025-10-28 18:45:26 +00:00
gpt-engineer-app[bot]
f13013b8ee Backfill existing submissions 2025-10-28 18:41:25 +00:00
gpt-engineer-app[bot]
6af788d406 Fix email reply access and threading 2025-10-28 18:38:57 +00:00
gpt-engineer-app[bot]
adbb4e5813 Fix: Open reply modal to correct tab 2025-10-28 18:36:30 +00:00
gpt-engineer-app[bot]
6a509dc66a Fix: Recreate ticket number functions 2025-10-28 18:34:51 +00:00
gpt-engineer-app[bot]
ab21dc9c82 feat: Implement ticket system and reply button 2025-10-28 18:33:32 +00:00
7 changed files with 354 additions and 28 deletions

View File

@@ -539,6 +539,7 @@ export type Database = {
status: string
subject: string
thread_id: string | null
ticket_number: string | null
updated_at: string
user_agent: string | null
user_id: string | null
@@ -560,6 +561,7 @@ export type Database = {
status?: string
subject: string
thread_id?: string | null
ticket_number?: string | null
updated_at?: string
user_agent?: string | null
user_id?: string | null
@@ -581,6 +583,7 @@ export type Database = {
status?: string
subject?: string
thread_id?: string | null
ticket_number?: string | null
updated_at?: string
user_agent?: string | null
user_id?: string | null
@@ -3954,6 +3957,7 @@ export type Database = {
}
extract_cf_image_id: { Args: { url: string }; Returns: string }
generate_deletion_confirmation_code: { Args: never; Returns: string }
generate_ticket_number: { Args: never; Returns: string }
get_email_change_status: { Args: never; Returns: Json }
get_filtered_profile: {
Args: { _profile_user_id: string; _viewer_id?: string }

View File

@@ -15,6 +15,9 @@ import {
ArrowDownLeft,
Loader2,
RefreshCw,
Reply,
Copy,
Check,
} from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { Button } from '@/components/ui/button';
@@ -71,6 +74,7 @@ interface ContactSubmission {
thread_id: string;
last_admin_response_at: string | null;
response_count: number;
ticket_number: string;
}
interface EmailThread {
@@ -98,6 +102,9 @@ export default function AdminContact() {
const [showReplyForm, setShowReplyForm] = useState(false);
const [emailThreads, setEmailThreads] = useState<EmailThread[]>([]);
const [loadingThreads, setLoadingThreads] = useState(false);
const [copiedTicket, setCopiedTicket] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string>('details');
const [replyStatus, setReplyStatus] = useState<string>('');
// Fetch contact submissions
const { data: submissions, isLoading } = useQuery({
@@ -137,6 +144,7 @@ export default function AdminContact() {
useEffect(() => {
if (selectedSubmission) {
setLoadingThreads(true);
// Force fresh fetch - no caching
supabase
.from('contact_email_threads')
.select('*')
@@ -155,22 +163,56 @@ export default function AdminContact() {
setAdminNotes(selectedSubmission.admin_notes || '');
setReplyBody('');
setShowReplyForm(false);
setReplyStatus(selectedSubmission.status); // Initialize reply status
} else {
// Reset tab to details when dialog closes
setActiveTab('details');
}
}, [selectedSubmission]);
// Send reply mutation
const sendReplyMutation = useMutation({
mutationFn: async ({ submissionId, body }: { submissionId: string, body: string }) => {
mutationFn: async ({ submissionId, body, newStatus }: { submissionId: string, body: string, newStatus?: string }) => {
const { data, error } = await invokeWithTracking(
'send-admin-email-reply',
{ submissionId, replyBody: body },
undefined
);
if (error) throw error;
// Update status if changed
if (newStatus && selectedSubmission && newStatus !== selectedSubmission.status) {
const updateData: Record<string, unknown> = { status: newStatus };
if (newStatus === 'resolved' || newStatus === 'closed') {
const { data: { user } } = await supabase.auth.getUser();
updateData.resolved_at = new Date().toISOString();
updateData.resolved_by = user?.id;
}
const { error: statusError } = await supabase
.from('contact_submissions')
.update(updateData)
.eq('id', submissionId);
if (statusError) {
logger.error('Failed to update status', { error: statusError });
throw statusError;
}
}
return data;
},
onSuccess: () => {
handleSuccess('Reply Sent', 'Your email response has been sent successfully');
onSuccess: (_data, variables) => {
const submission = submissions?.find(s => s.id === variables.submissionId);
const ticketNumber = submission?.ticket_number || 'Unknown';
let message = `Your email response has been sent for ticket ${ticketNumber}`;
if (variables.newStatus && selectedSubmission && variables.newStatus !== selectedSubmission.status) {
message += ` and status updated to ${variables.newStatus.replace('_', ' ')}`;
}
handleSuccess('Reply Sent', message);
setReplyBody('');
setShowReplyForm(false);
// Refetch threads
@@ -264,6 +306,41 @@ export default function AdminContact() {
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
};
const handleCopyTicket = (ticketNumber: string) => {
navigator.clipboard.writeText(ticketNumber);
setCopiedTicket(ticketNumber);
setTimeout(() => setCopiedTicket(null), 2000);
};
const handleQuickReply = (e: React.MouseEvent, submission: ContactSubmission) => {
e.stopPropagation();
setSelectedSubmission(submission);
setAdminNotes(submission.admin_notes || '');
setActiveTab('thread');
setShowReplyForm(true);
setReplyStatus(submission.status);
};
const handleRefreshThreads = () => {
if (!selectedSubmission) return;
setLoadingThreads(true);
supabase
.from('contact_email_threads')
.select('*')
.eq('submission_id', selectedSubmission.id)
.order('created_at', { ascending: true })
.then(({ data, error }) => {
if (error) {
logger.error('Failed to refresh email threads', { error });
handleError(error, { action: 'Refresh Email Threads' });
} else {
setEmailThreads((data as EmailThread[]) || []);
handleSuccess('Refreshed', 'Email thread updated');
}
setLoadingThreads(false);
});
};
// Show loading state while roles are being fetched
if (rolesLoading) {
return (
@@ -463,6 +540,20 @@ export default function AdminContact() {
<div className="flex-1 space-y-2">
<div className="flex items-center gap-3 flex-wrap">
<h3 className="font-semibold text-lg">{submission.subject}</h3>
<Badge
variant="outline"
className="font-mono cursor-pointer hover:bg-accent transition-colors"
onClick={(e) => {
e.stopPropagation();
handleCopyTicket(submission.ticket_number);
}}
>
{copiedTicket === submission.ticket_number ? (
<><Check className="h-3 w-3 mr-1" /> Copied!</>
) : (
<><Copy className="h-3 w-3 mr-1" /> {submission.ticket_number}</>
)}
</Badge>
{getStatusBadge(submission.status)}
<Badge variant="outline">{getCategoryLabel(submission.category)}</Badge>
{submission.response_count > 0 && (
@@ -489,7 +580,19 @@ export default function AdminContact() {
</div>
<p className="text-sm line-clamp-2">{submission.message}</p>
</div>
<ChevronDown className="h-5 w-5 text-muted-foreground" />
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={(e) => handleQuickReply(e, submission)}
disabled={sendReplyMutation.isPending}
className="flex items-center gap-1"
>
<Reply className="h-4 w-4" />
Reply
</Button>
<ChevronDown className="h-5 w-5 text-muted-foreground" />
</div>
</div>
</CardContent>
</Card>
@@ -520,11 +623,22 @@ export default function AdminContact() {
{selectedSubmission && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<DialogTitle className="flex items-center gap-2 flex-wrap">
<Mail className="h-5 w-5" />
{selectedSubmission.subject}
<Badge
variant="outline"
className="font-mono cursor-pointer hover:bg-accent transition-colors"
onClick={() => handleCopyTicket(selectedSubmission.ticket_number)}
>
{copiedTicket === selectedSubmission.ticket_number ? (
<><Check className="h-3 w-3 mr-1" /> Copied!</>
) : (
<><Copy className="h-3 w-3 mr-1" /> {selectedSubmission.ticket_number}</>
)}
</Badge>
{selectedSubmission.response_count > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
<Badge variant="secondary" className="text-xs">
📧 {selectedSubmission.response_count} {selectedSubmission.response_count === 1 ? 'reply' : 'replies'}
</Badge>
)}
@@ -534,14 +648,25 @@ export default function AdminContact() {
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="details" className="flex-1 overflow-hidden flex flex-col">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 overflow-hidden flex flex-col">
<TabsList className="w-full justify-start">
<TabsTrigger value="details" className="flex-1">
Details
</TabsTrigger>
<TabsTrigger value="thread" className="flex-1">
<TabsTrigger value="thread" className="flex-1 flex items-center gap-2">
Email Thread ({emailThreads.length})
</TabsTrigger>
{activeTab === 'thread' && (
<Button
variant="ghost"
size="sm"
onClick={handleRefreshThreads}
disabled={loadingThreads}
className="ml-auto"
>
<RefreshCw className={`h-3 w-3 ${loadingThreads ? 'animate-spin' : ''}`} />
</Button>
)}
</TabsList>
<TabsContent value="details" className="flex-1 overflow-y-auto">
@@ -654,9 +779,17 @@ export default function AdminContact() {
) : (
<Card className="border-primary/20">
<CardContent className="pt-4">
<Label className="text-sm font-medium mb-2 block">
Reply to {selectedSubmission.name} ({selectedSubmission.email})
</Label>
<div className="mb-3 flex items-center justify-between">
<Label className="text-sm font-medium">
Reply to {selectedSubmission.name} ({selectedSubmission.email})
</Label>
<Badge variant="outline" className="font-mono">
{selectedSubmission.ticket_number}
</Badge>
</div>
<div className="text-xs text-muted-foreground mb-3">
Subject: Re: [{selectedSubmission.ticket_number}] {selectedSubmission.subject}
</div>
<Textarea
value={replyBody}
onChange={(e) => setReplyBody(e.target.value)}
@@ -664,11 +797,34 @@ export default function AdminContact() {
rows={6}
className="mb-3 resize-none"
/>
{/* Status Update Dropdown */}
<div className="mb-3">
<Label className="text-sm mb-2 block">Update Status (Optional)</Label>
<Select value={replyStatus} onValueChange={setReplyStatus}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
{replyStatus !== selectedSubmission.status && (
<p className="text-xs text-muted-foreground mt-1">
Status will be updated to <span className="font-medium">{replyStatus.replace('_', ' ')}</span>
</p>
)}
</div>
<div className="flex gap-2">
<Button
onClick={() => sendReplyMutation.mutate({
submissionId: selectedSubmission.id,
body: replyBody
body: replyBody,
newStatus: replyStatus
})}
disabled={sendReplyMutation.isPending || replyBody.length < 10}
className="flex-1"

View File

@@ -38,12 +38,16 @@ const handler = async (req: Request): Promise<Response> => {
return createErrorResponse({ message: 'Unauthorized' }, 401, corsHeaders);
}
// Verify admin role
const { data: isAdmin, error: roleError } = await supabase
// Verify admin, moderator, or superuser role
const { data: isSuperuser } = await supabase
.rpc('has_role', { _user_id: user.id, _role: 'superuser' });
const { data: isAdmin } = await supabase
.rpc('has_role', { _user_id: user.id, _role: 'admin' });
const { data: isModerator } = await supabase
.rpc('has_role', { _user_id: user.id, _role: 'moderator' });
if (roleError || !isAdmin) {
edgeLogger.warn('Non-admin attempted email reply', {
if (!isSuperuser && !isAdmin && !isModerator) {
edgeLogger.warn('Non-privileged user attempted email reply', {
requestId: tracking.requestId,
userId: user.id
});
@@ -66,7 +70,7 @@ const handler = async (req: Request): Promise<Response> => {
// Fetch submission
const { data: submission, error: fetchError } = await supabase
.from('contact_submissions')
.select('id, email, name, subject, thread_id, response_count')
.select('id, email, name, subject, thread_id, response_count, ticket_number')
.eq('id', submissionId)
.single();
@@ -89,8 +93,9 @@ const handler = async (req: Request): Promise<Response> => {
}, 429, corsHeaders);
}
const messageId = `<${crypto.randomUUID()}@thrillwiki.com>`;
const finalSubject = replySubject || `Re: ${submission.subject}`;
const ticketNumber = submission.ticket_number || 'UNKNOWN';
const messageId = `<${ticketNumber}.${crypto.randomUUID()}@thrillwiki.com>`;
const finalSubject = replySubject || `Re: [${ticketNumber}] ${submission.subject}`;
// Get previous message for threading
const { data: previousMessages } = await supabase
@@ -100,7 +105,13 @@ const handler = async (req: Request): Promise<Response> => {
.order('created_at', { ascending: false })
.limit(1);
const inReplyTo = previousMessages?.[0]?.message_id || `<${submission.thread_id}@thrillwiki.com>`;
const originalMessageId = `<${ticketNumber}.${submission.id}@thrillwiki.com>`;
const inReplyTo = previousMessages?.[0]?.message_id || originalMessageId;
// Build reference chain for threading
const referenceChain = previousMessages?.[0]?.message_id
? [originalMessageId, previousMessages[0].message_id].join(' ')
: originalMessageId;
// Send email via ForwardEmail
const forwardEmailResponse = await fetch('https://api.forwardemail.net/v1/emails', {
@@ -117,8 +128,9 @@ const handler = async (req: Request): Promise<Response> => {
headers: {
'Message-ID': messageId,
'In-Reply-To': inReplyTo,
'References': inReplyTo,
'X-Thread-ID': submission.thread_id
'References': referenceChain,
'X-Thread-ID': submission.thread_id,
'X-Ticket-Number': ticketNumber
}
})
});

View File

@@ -139,7 +139,7 @@ const handler = async (req: Request): Promise<Response> => {
userId = user?.id || null;
}
// Insert contact submission
// Insert contact submission (ticket number auto-generated by trigger)
const { data: submission, error: insertError } = await supabase
.from('contact_submissions')
.insert({
@@ -153,7 +153,7 @@ const handler = async (req: Request): Promise<Response> => {
ip_address_hash: ipHash,
status: 'pending'
})
.select()
.select('*, ticket_number')
.single();
if (insertError) {
@@ -174,6 +174,26 @@ const handler = async (req: Request): Promise<Response> => {
const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com';
const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY');
const ticketNumber = submission.ticket_number || 'PENDING';
const messageId = `<${ticketNumber}.${submission.id}@thrillwiki.com>`;
// Insert initial message into email thread
await supabase
.from('contact_email_threads')
.insert({
submission_id: submission.id,
direction: 'inbound',
from_email: email.trim().toLowerCase(),
to_email: adminEmail,
subject: subject.trim(),
body_text: message.trim(),
message_id: messageId,
metadata: {
category: category,
name: name.trim()
}
});
if (forwardEmailKey) {
// Send admin notification
fetch('https://api.forwardemail.net/v1/emails', {
@@ -185,9 +205,10 @@ const handler = async (req: Request): Promise<Response> => {
body: JSON.stringify({
from: fromEmail,
to: adminEmail,
subject: `New Contact Form Submission - ${category.charAt(0).toUpperCase() + category.slice(1)}`,
subject: `[${ticketNumber}] New Contact - ${category.charAt(0).toUpperCase() + category.slice(1)}`,
text: `A new contact message has been received:
Ticket: ${ticketNumber}
From: ${name} (${email})
Category: ${category.charAt(0).toUpperCase() + category.slice(1)}
Subject: ${subject}
@@ -199,6 +220,10 @@ Reference ID: ${submission.id}
Submitted: ${new Date(submission.created_at).toLocaleString()}
View in admin panel: https://thrillwiki.com/admin/contact`,
headers: {
'Message-ID': messageId,
'X-Ticket-Number': ticketNumber
}
}),
}).catch(err => {
edgeLogger.error('Failed to send admin notification', { requestId, error: err.message });
@@ -214,25 +239,37 @@ View in admin panel: https://thrillwiki.com/admin/contact`,
body: JSON.stringify({
from: fromEmail,
to: email,
subject: "We've received your message - ThrillWiki Support",
subject: `[${ticketNumber}] We've received your message - ThrillWiki Support`,
text: `Hi ${name},
Thank you for contacting ThrillWiki! We've received your message and will respond within 24-48 hours.
Your Message Details:
Ticket Number: ${ticketNumber}
Category: ${category.charAt(0).toUpperCase() + category.slice(1)}
Subject: ${subject}
Reference ID: ${submission.id}
When replying to this email, please keep the ticket number in the subject line to ensure your response is properly tracked.
Our support team will review your message and get back to you as soon as possible.
Best regards,
The ThrillWiki Team`,
headers: {
'Message-ID': messageId,
'X-Ticket-Number': ticketNumber,
'References': messageId
}
}),
}).catch(err => {
edgeLogger.error('Failed to send confirmation email', { requestId, error: err.message });
});
// Update thread_id with ticket number
await supabase
.from('contact_submissions')
.update({ thread_id: `ticket-${ticketNumber}` })
.eq('id', submission.id);
}
const duration = Date.now() - startTime;
@@ -246,7 +283,8 @@ The ThrillWiki Team`,
JSON.stringify({
success: true,
submissionId: submission.id,
message: 'Your message has been received. We will respond within 24-48 hours.'
ticketNumber: ticketNumber,
message: `Your message has been received (Ticket: ${ticketNumber}). We will respond within 24-48 hours.`
}),
{
status: 200,

View File

@@ -0,0 +1,54 @@
-- Add ticket number system to contact submissions
-- Create sequence for ticket numbers starting at 100000
CREATE SEQUENCE IF NOT EXISTS contact_ticket_number_seq START 100000;
-- Add ticket_number column with unique constraint
ALTER TABLE public.contact_submissions
ADD COLUMN IF NOT EXISTS ticket_number TEXT UNIQUE;
-- Create function to generate ticket number in format TW-XXXXXX
CREATE OR REPLACE FUNCTION generate_ticket_number()
RETURNS TEXT AS $$
BEGIN
RETURN 'TW-' || LPAD(nextval('contact_ticket_number_seq')::TEXT, 6, '0');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Create function to auto-set ticket number on insert
CREATE OR REPLACE FUNCTION set_ticket_number()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.ticket_number IS NULL THEN
NEW.ticket_number := generate_ticket_number();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Add trigger to auto-generate ticket numbers
DROP TRIGGER IF EXISTS set_ticket_number_trigger ON public.contact_submissions;
CREATE TRIGGER set_ticket_number_trigger
BEFORE INSERT ON public.contact_submissions
FOR EACH ROW
EXECUTE FUNCTION set_ticket_number();
-- Backfill existing submissions with ticket numbers based on creation order
DO $$
DECLARE
rec RECORD;
BEGIN
FOR rec IN
SELECT id
FROM public.contact_submissions
WHERE ticket_number IS NULL
ORDER BY created_at ASC
LOOP
UPDATE public.contact_submissions
SET ticket_number = generate_ticket_number()
WHERE id = rec.id;
END LOOP;
END $$;
-- Add index for ticket_number lookups
CREATE INDEX IF NOT EXISTS idx_contact_submissions_ticket_number
ON public.contact_submissions(ticket_number);

View File

@@ -0,0 +1,27 @@
-- Fix search_path security warnings for ticket number functions
-- Re-create functions with proper search_path settings
CREATE OR REPLACE FUNCTION generate_ticket_number()
RETURNS TEXT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
RETURN 'TW-' || LPAD(nextval('contact_ticket_number_seq')::TEXT, 6, '0');
END;
$$;
CREATE OR REPLACE FUNCTION set_ticket_number()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
IF NEW.ticket_number IS NULL THEN
NEW.ticket_number := generate_ticket_number();
END IF;
RETURN NEW;
END;
$$;

View File

@@ -0,0 +1,35 @@
-- Backfill existing contact submissions into email threads
-- This is a one-time migration to add initial messages to the email thread
-- New submissions are automatically added by the edge function
INSERT INTO contact_email_threads (
submission_id,
direction,
from_email,
to_email,
subject,
body_text,
message_id,
metadata,
created_at
)
SELECT
cs.id as submission_id,
'inbound' as direction,
cs.email as from_email,
'admin@thrillwiki.com' as to_email,
cs.subject,
cs.message as body_text,
'<' || cs.ticket_number || '.' || cs.id || '@thrillwiki.com>' as message_id,
jsonb_build_object(
'category', cs.category,
'name', cs.name,
'backfilled', true
) as metadata,
cs.created_at
FROM contact_submissions cs
WHERE cs.id NOT IN (
SELECT DISTINCT submission_id
FROM contact_email_threads
)
AND cs.ticket_number IS NOT NULL;