feat: Implement ticket system and reply button

This commit is contained in:
gpt-engineer-app[bot]
2025-10-28 18:33:32 +00:00
parent 4d21dc4435
commit ab21dc9c82
5 changed files with 172 additions and 20 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,7 @@ 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);
// Fetch contact submissions
const { data: submissions, isLoading } = useQuery({
@@ -169,8 +174,10 @@ export default function AdminContact() {
if (error) throw error;
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';
handleSuccess('Reply Sent', `Your email response has been sent for ticket ${ticketNumber}`);
setReplyBody('');
setShowReplyForm(false);
// Refetch threads
@@ -264,6 +271,19 @@ 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 || '');
setShowReplyForm(true);
};
// Show loading state while roles are being fetched
if (rolesLoading) {
return (
@@ -463,6 +483,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 +523,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 +566,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>
)}
@@ -654,9 +711,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)}

View File

@@ -66,7 +66,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 +89,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 +101,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 +124,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,9 @@ 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>`;
if (forwardEmailKey) {
// Send admin notification
fetch('https://api.forwardemail.net/v1/emails', {
@@ -185,9 +188,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 +203,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 +222,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 +266,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);