feat: Implement contact page and backend

This commit is contained in:
gpt-engineer-app[bot]
2025-10-28 17:01:57 +00:00
parent e2bd71cf24
commit e5f8ecefeb
11 changed files with 1510 additions and 0 deletions

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

View File

@@ -0,0 +1,100 @@
-- Create contact submissions table
CREATE TABLE IF NOT EXISTS public.contact_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Sender Information
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
-- Message Details
subject TEXT NOT NULL,
message TEXT NOT NULL,
category TEXT NOT NULL CHECK (category IN ('general', 'moderation', 'technical', 'account', 'partnership', 'report', 'other')),
-- Admin Management
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'resolved', 'closed')),
assigned_to UUID REFERENCES auth.users(id) ON DELETE SET NULL,
admin_notes TEXT,
resolved_at TIMESTAMPTZ,
resolved_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
-- Metadata
user_agent TEXT,
ip_address_hash TEXT
);
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_contact_submissions_status ON public.contact_submissions(status) WHERE status IN ('pending', 'in_progress');
CREATE INDEX IF NOT EXISTS idx_contact_submissions_created_at ON public.contact_submissions(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_contact_submissions_user_id ON public.contact_submissions(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_contact_submissions_assigned_to ON public.contact_submissions(assigned_to) WHERE assigned_to IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_contact_submissions_email ON public.contact_submissions(email);
-- Enable RLS
ALTER TABLE public.contact_submissions ENABLE ROW LEVEL SECURITY;
-- Anyone can submit contact form (including non-authenticated users)
CREATE POLICY "Anyone can submit contact form"
ON public.contact_submissions
FOR INSERT
WITH CHECK (true);
-- Users can view their own submissions (by user_id or email)
CREATE POLICY "Users can view own contact submissions"
ON public.contact_submissions
FOR SELECT
USING (
user_id = auth.uid()
OR (auth.uid() IS NOT NULL AND email = (SELECT email FROM auth.users WHERE id = auth.uid()))
);
-- Moderators/Admins can view all submissions
CREATE POLICY "Moderators can view all contact submissions"
ON public.contact_submissions
FOR SELECT
USING (is_moderator(auth.uid()));
-- Moderators/Admins can update submissions (for management)
CREATE POLICY "Moderators can update contact submissions"
ON public.contact_submissions
FOR UPDATE
USING (is_moderator(auth.uid()) AND ((NOT has_mfa_enabled(auth.uid())) OR has_aal2()))
WITH CHECK (is_moderator(auth.uid()) AND ((NOT has_mfa_enabled(auth.uid())) OR has_aal2()));
-- Create trigger to update updated_at
CREATE OR REPLACE FUNCTION public.update_contact_submissions_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$;
CREATE TRIGGER update_contact_submissions_updated_at
BEFORE UPDATE ON public.contact_submissions
FOR EACH ROW
EXECUTE FUNCTION public.update_contact_submissions_updated_at();
-- Create contact rate limiting table
CREATE TABLE IF NOT EXISTS public.contact_rate_limits (
email TEXT PRIMARY KEY,
submission_count INTEGER NOT NULL DEFAULT 1,
window_start TIMESTAMPTZ NOT NULL DEFAULT now(),
last_submission_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Enable RLS on rate limits (only system can access)
ALTER TABLE public.contact_rate_limits ENABLE ROW LEVEL SECURITY;
-- No public access to rate limits table
CREATE POLICY "No public access to rate limits"
ON public.contact_rate_limits
FOR ALL
USING (false);