mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 13:31:12 -05:00
Migrate 8 high-priority functions (admin-delete-user, mfa-unenroll, confirm-account-deletion, request-account-deletion, send-contact-message, upload-image, validate-email-backend, process-oauth-profile) to wrapEdgeFunction pattern. Replace manual CORS/auth, add shared validations, integrate standardized error handling, and preserve existing rate limiting where applicable. Update implementations to leverage context span, requestId, and improved logging for consistent error reporting and tracing.
267 lines
9.2 KiB
TypeScript
267 lines
9.2 KiB
TypeScript
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
|
import { corsHeaders } from '../_shared/cors.ts';
|
|
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
|
import { validateString } from '../_shared/typeValidation.ts';
|
|
|
|
interface ContactSubmission {
|
|
name: string;
|
|
email: string;
|
|
subject: string;
|
|
message: string;
|
|
category: 'general' | 'moderation' | 'technical' | 'account' | 'partnership' | 'report' | 'other';
|
|
captchaToken?: string;
|
|
}
|
|
|
|
// Apply standard rate limiting (20 req/min) for contact form submissions
|
|
const handler = createEdgeFunction(
|
|
{
|
|
name: 'send-contact-message',
|
|
requireAuth: false,
|
|
corsHeaders: corsHeaders
|
|
},
|
|
async (req, context) => {
|
|
// Parse request body
|
|
const body: ContactSubmission = await req.json();
|
|
const { name, email, subject, message, category, captchaToken } = body;
|
|
|
|
context.span.setAttribute('action', 'contact_submission');
|
|
context.span.setAttribute('category', category);
|
|
|
|
// Validate required fields using shared utilities
|
|
validateString(name, 'name', { requestId: context.requestId });
|
|
validateString(email, 'email', { requestId: context.requestId });
|
|
validateString(subject, 'subject', { requestId: context.requestId });
|
|
validateString(message, 'message', { requestId: context.requestId });
|
|
validateString(category, 'category', { requestId: context.requestId });
|
|
|
|
// Validate field lengths
|
|
if (name.length < 2 || name.length > 100) {
|
|
throw new Error('Name must be between 2 and 100 characters');
|
|
}
|
|
|
|
if (subject.length < 5 || subject.length > 200) {
|
|
throw new Error('Subject must be between 5 and 200 characters');
|
|
}
|
|
|
|
if (message.length < 20 || message.length > 2000) {
|
|
throw new Error('Message must be between 20 and 2000 characters');
|
|
}
|
|
|
|
// Validate email format
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(email)) {
|
|
throw new Error('Invalid email address');
|
|
}
|
|
|
|
// Validate category
|
|
const validCategories = ['general', 'moderation', 'technical', 'account', 'partnership', 'report', 'other'];
|
|
if (!validCategories.includes(category)) {
|
|
throw new Error('Invalid category');
|
|
}
|
|
|
|
// 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 && recentSubmissions && recentSubmissions.length >= 3) {
|
|
return new Response(
|
|
JSON.stringify({ message: 'Too many submissions. Please wait an hour before submitting again.' }),
|
|
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
|
);
|
|
}
|
|
|
|
// Get user ID and profile if authenticated
|
|
let userId: string | null = context.userId || null;
|
|
let submitterUsername: string | null = null;
|
|
let submitterReputation: number | null = null;
|
|
let submitterProfileData: Record<string, unknown> | null = null;
|
|
|
|
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
|
|
};
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
throw insertError;
|
|
}
|
|
|
|
// 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
|
|
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(() => {
|
|
// Non-blocking email failure
|
|
});
|
|
|
|
// 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(() => {
|
|
// Non-blocking email failure
|
|
});
|
|
}
|
|
|
|
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' },
|
|
}
|
|
);
|
|
}
|
|
);
|
|
|
|
export default withRateLimit(handler, rateLimiters.standard, corsHeaders);
|