mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 12:31:26 -05:00
feat: Implement contact page and backend
This commit is contained in:
267
supabase/functions/send-contact-message/index.ts
Normal file
267
supabase/functions/send-contact-message/index.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
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 if authenticated
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
let userId: string | 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;
|
||||
}
|
||||
|
||||
// Insert contact submission
|
||||
const { data: submission, error: insertError } = await supabase
|
||||
.from('contact_submissions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
name: name.trim(),
|
||||
email: email.trim().toLowerCase(),
|
||||
subject: subject.trim(),
|
||||
message: message.trim(),
|
||||
category,
|
||||
user_agent: userAgent,
|
||||
ip_address_hash: ipHash,
|
||||
status: 'pending'
|
||||
})
|
||||
.select()
|
||||
.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');
|
||||
|
||||
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: `New Contact Form Submission - ${category.charAt(0).toUpperCase() + category.slice(1)}`,
|
||||
text: `A new contact message has been received:
|
||||
|
||||
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`,
|
||||
}),
|
||||
}).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: "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:
|
||||
Category: ${category.charAt(0).toUpperCase() + category.slice(1)}
|
||||
Subject: ${subject}
|
||||
|
||||
Reference ID: ${submission.id}
|
||||
|
||||
Our support team will review your message and get back to you as soon as possible.
|
||||
|
||||
Best regards,
|
||||
The ThrillWiki Team`,
|
||||
}),
|
||||
}).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,
|
||||
message: 'Your message has been received. 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);
|
||||
Reference in New Issue
Block a user