Files
gpt-engineer-app[bot] e28dc97d71 Migrate Phase 1 Functions
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.
2025-11-11 03:03:26 +00:00

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);