mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 16:51:12 -05:00
345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
|
import { edgeLogger } from "../_shared/logger.ts";
|
|
import { createErrorResponse } from "../_shared/errorSanitizer.ts";
|
|
|
|
const corsHeaders = {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
};
|
|
|
|
interface ContactSubmission {
|
|
name: string;
|
|
email: string;
|
|
subject: string;
|
|
message: string;
|
|
category: 'general' | 'moderation' | 'technical' | 'account' | 'partnership' | 'report' | 'other';
|
|
captchaToken?: string;
|
|
}
|
|
|
|
const handler = async (req: Request): Promise<Response> => {
|
|
// Handle CORS preflight requests
|
|
if (req.method === 'OPTIONS') {
|
|
return new Response(null, { headers: corsHeaders });
|
|
}
|
|
|
|
const requestId = crypto.randomUUID();
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
// Parse request body
|
|
const body: ContactSubmission = await req.json();
|
|
const { name, email, subject, message, category, captchaToken } = body;
|
|
|
|
edgeLogger.info('Contact form submission received', {
|
|
requestId,
|
|
email,
|
|
category
|
|
});
|
|
|
|
// Validate required fields
|
|
if (!name || !email || !subject || !message || !category) {
|
|
return createErrorResponse(
|
|
{ message: 'Missing required fields' },
|
|
400,
|
|
corsHeaders
|
|
);
|
|
}
|
|
|
|
// Validate field lengths
|
|
if (name.length < 2 || name.length > 100) {
|
|
return createErrorResponse(
|
|
{ message: 'Name must be between 2 and 100 characters' },
|
|
400,
|
|
corsHeaders
|
|
);
|
|
}
|
|
|
|
if (subject.length < 5 || subject.length > 200) {
|
|
return createErrorResponse(
|
|
{ message: 'Subject must be between 5 and 200 characters' },
|
|
400,
|
|
corsHeaders
|
|
);
|
|
}
|
|
|
|
if (message.length < 20 || message.length > 2000) {
|
|
return createErrorResponse(
|
|
{ message: 'Message must be between 20 and 2000 characters' },
|
|
400,
|
|
corsHeaders
|
|
);
|
|
}
|
|
|
|
// Validate email format
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(email)) {
|
|
return createErrorResponse(
|
|
{ message: 'Invalid email address' },
|
|
400,
|
|
corsHeaders
|
|
);
|
|
}
|
|
|
|
// Validate category
|
|
const validCategories = ['general', 'moderation', 'technical', 'account', 'partnership', 'report', 'other'];
|
|
if (!validCategories.includes(category)) {
|
|
return createErrorResponse(
|
|
{ message: 'Invalid category' },
|
|
400,
|
|
corsHeaders
|
|
);
|
|
}
|
|
|
|
// Get user agent and create IP hash
|
|
const userAgent = req.headers.get('user-agent') || 'Unknown';
|
|
const clientIP = req.headers.get('x-forwarded-for') || 'Unknown';
|
|
const ipHash = clientIP !== 'Unknown'
|
|
? await crypto.subtle.digest('SHA-256', new TextEncoder().encode(clientIP + 'thrillwiki_salt'))
|
|
.then(buf => Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''))
|
|
: null;
|
|
|
|
// Initialize Supabase client with service role for rate limiting and insertion
|
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
|
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
|
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
|
|
|
// Check rate limiting (max 3 submissions per email per hour)
|
|
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
|
|
|
const { data: recentSubmissions, error: rateLimitError } = await supabase
|
|
.from('contact_submissions')
|
|
.select('id')
|
|
.eq('email', email)
|
|
.gte('created_at', oneHourAgo);
|
|
|
|
if (rateLimitError) {
|
|
edgeLogger.error('Rate limit check failed', { requestId, error: rateLimitError.message });
|
|
} else if (recentSubmissions && recentSubmissions.length >= 3) {
|
|
edgeLogger.warn('Rate limit exceeded', { requestId, email });
|
|
return createErrorResponse(
|
|
{ message: 'Too many submissions. Please wait an hour before submitting again.' },
|
|
429,
|
|
corsHeaders
|
|
);
|
|
}
|
|
|
|
// Get user ID and profile if authenticated
|
|
const authHeader = req.headers.get('Authorization');
|
|
let userId: string | null = null;
|
|
let submitterUsername: string | null = null;
|
|
let submitterReputation: number | null = null;
|
|
let submitterProfileData: Record<string, unknown> | null = null;
|
|
|
|
if (authHeader) {
|
|
const supabaseClient = createClient(
|
|
supabaseUrl,
|
|
Deno.env.get('SUPABASE_ANON_KEY')!,
|
|
{ global: { headers: { Authorization: authHeader } } }
|
|
);
|
|
|
|
const { data: { user } } = await supabaseClient.auth.getUser();
|
|
userId = user?.id || null;
|
|
|
|
// Fetch user profile for enhanced context
|
|
if (userId) {
|
|
const { data: profile } = await supabase
|
|
.from('profiles')
|
|
.select('username, display_name, reputation_score, ride_count, coaster_count, park_count, review_count, created_at, avatar_url')
|
|
.eq('user_id', userId)
|
|
.single();
|
|
|
|
if (profile) {
|
|
submitterUsername = profile.username;
|
|
submitterReputation = profile.reputation_score || 0;
|
|
submitterProfileData = {
|
|
display_name: profile.display_name,
|
|
member_since: profile.created_at,
|
|
stats: {
|
|
rides: profile.ride_count || 0,
|
|
coasters: profile.coaster_count || 0,
|
|
parks: profile.park_count || 0,
|
|
reviews: profile.review_count || 0,
|
|
},
|
|
reputation: profile.reputation_score || 0,
|
|
avatar_url: profile.avatar_url
|
|
};
|
|
|
|
edgeLogger.info('Enhanced submission with user profile', {
|
|
requestId,
|
|
username: submitterUsername,
|
|
reputation: submitterReputation
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Insert contact submission (ticket number auto-generated by trigger)
|
|
const { data: submission, error: insertError } = await supabase
|
|
.from('contact_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submitter_username: submitterUsername,
|
|
submitter_reputation: submitterReputation,
|
|
submitter_profile_data: submitterProfileData,
|
|
name: name.trim(),
|
|
email: email.trim().toLowerCase(),
|
|
subject: subject.trim(),
|
|
message: message.trim(),
|
|
category,
|
|
user_agent: userAgent,
|
|
ip_address_hash: ipHash,
|
|
status: 'pending'
|
|
})
|
|
.select('*, ticket_number')
|
|
.single();
|
|
|
|
if (insertError) {
|
|
edgeLogger.error('Failed to insert contact submission', {
|
|
requestId,
|
|
error: insertError.message
|
|
});
|
|
return createErrorResponse(insertError, 500, corsHeaders);
|
|
}
|
|
|
|
edgeLogger.info('Contact submission created successfully', {
|
|
requestId,
|
|
submissionId: submission.id
|
|
});
|
|
|
|
// Send notification email to admin (async, don't wait)
|
|
const adminEmail = Deno.env.get('ADMIN_EMAIL_ADDRESS') || 'admin@thrillwiki.com';
|
|
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()
|
|
}
|
|
});
|
|
|
|
// Update thread_id with Message-ID format (always, not just when email is sent)
|
|
const threadId = `${ticketNumber}.${submission.id}`;
|
|
await supabase
|
|
.from('contact_submissions')
|
|
.update({ thread_id: threadId })
|
|
.eq('id', submission.id);
|
|
|
|
if (forwardEmailKey) {
|
|
// Send admin notification
|
|
fetch('https://api.forwardemail.net/v1/emails', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
from: fromEmail,
|
|
to: adminEmail,
|
|
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}
|
|
|
|
Message:
|
|
${message}
|
|
|
|
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 });
|
|
});
|
|
|
|
// Send user confirmation email
|
|
fetch('https://api.forwardemail.net/v1/emails', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
from: fromEmail,
|
|
to: email,
|
|
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}
|
|
|
|
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 });
|
|
});
|
|
}
|
|
|
|
const duration = Date.now() - startTime;
|
|
edgeLogger.info('Contact submission processed successfully', {
|
|
requestId,
|
|
duration,
|
|
submissionId: submission.id
|
|
});
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: true,
|
|
submissionId: submission.id,
|
|
ticketNumber: ticketNumber,
|
|
message: `Your message has been received (Ticket: ${ticketNumber}). We will respond within 24-48 hours.`
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
|
}
|
|
);
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
edgeLogger.error('Contact submission failed', {
|
|
requestId,
|
|
duration,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
return createErrorResponse(error, 500, corsHeaders);
|
|
}
|
|
};
|
|
|
|
serve(handler);
|