mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 01:31:12 -05:00
Migrate 3 edge functions to wrapper
- Refactor validate-email, receive-inbound-email, and send-admin-email-reply to use createEdgeFunction wrapper with automatic error handling, tracing, and reduced boilerplate. - enrich wrapper to support service-role usage and role-based authorization context for complex flows.
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
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 { corsHeaders } from '../_shared/cors.ts';
|
||||
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts";
|
||||
import { createErrorResponse } from "../_shared/errorSanitizer.ts";
|
||||
import { formatEdgeError } from "../_shared/errorFormatter.ts";
|
||||
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
|
||||
import { addSpanEvent } from '../_shared/logger.ts';
|
||||
|
||||
interface AdminReplyRequest {
|
||||
submissionId: string;
|
||||
@@ -11,233 +9,217 @@ interface AdminReplyRequest {
|
||||
replySubject?: string;
|
||||
}
|
||||
|
||||
const handler = async (req: Request): Promise<Response> => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const tracking = startRequest();
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return createErrorResponse({ message: 'Unauthorized' }, 401, corsHeaders);
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_ANON_KEY')!,
|
||||
{ global: { headers: { Authorization: authHeader } } }
|
||||
);
|
||||
|
||||
const { data: { user }, error: userError } = await supabase.auth.getUser();
|
||||
if (userError || !user) {
|
||||
return createErrorResponse({ message: 'Unauthorized' }, 401, corsHeaders);
|
||||
}
|
||||
|
||||
// Verify admin, moderator, or superuser role
|
||||
const { data: isSuperuser } = await supabase
|
||||
.rpc('has_role', { _user_id: user.id, _role: 'superuser' });
|
||||
const { data: isAdmin } = await supabase
|
||||
.rpc('has_role', { _user_id: user.id, _role: 'admin' });
|
||||
const { data: isModerator } = await supabase
|
||||
.rpc('has_role', { _user_id: user.id, _role: 'moderator' });
|
||||
|
||||
if (!isSuperuser && !isAdmin && !isModerator) {
|
||||
edgeLogger.warn('Non-privileged user attempted email reply', {
|
||||
requestId: tracking.requestId,
|
||||
userId: user.id
|
||||
});
|
||||
return createErrorResponse({ message: 'Admin access required' }, 403, corsHeaders);
|
||||
}
|
||||
|
||||
const body: AdminReplyRequest = await req.json();
|
||||
const { submissionId, replyBody, replySubject } = body;
|
||||
|
||||
if (!submissionId || !replyBody) {
|
||||
return createErrorResponse({ message: 'Missing required fields' }, 400, corsHeaders);
|
||||
}
|
||||
|
||||
if (replyBody.length < 10 || replyBody.length > 5000) {
|
||||
return createErrorResponse({
|
||||
message: 'Reply must be between 10 and 5000 characters'
|
||||
}, 400, corsHeaders);
|
||||
}
|
||||
|
||||
// Get admin email from environment variable
|
||||
const adminEmail = Deno.env.get('ADMIN_EMAIL_ADDRESS') || 'admins@thrillwiki.com';
|
||||
const adminDisplayName = Deno.env.get('ADMIN_EMAIL_DISPLAY_NAME') || 'ThrillWiki Admin';
|
||||
|
||||
// Fetch admin's profile for signature
|
||||
const { data: adminProfile } = await supabase
|
||||
.from('profiles')
|
||||
.select('display_name, username')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
const adminName = adminProfile?.display_name || adminProfile?.username || 'ThrillWiki Team';
|
||||
|
||||
// Fetch submission
|
||||
const { data: submission, error: fetchError } = await supabase
|
||||
.from('contact_submissions')
|
||||
.select('id, email, name, subject, thread_id, response_count, ticket_number')
|
||||
.eq('id', submissionId)
|
||||
.single();
|
||||
|
||||
if (fetchError || !submission) {
|
||||
return createErrorResponse({ message: 'Submission not found' }, 404, corsHeaders);
|
||||
}
|
||||
|
||||
// Fetch email signature from admin settings
|
||||
const { data: signatureSetting } = await supabase
|
||||
.from('admin_settings')
|
||||
.select('setting_value')
|
||||
.eq('setting_key', 'email.signature')
|
||||
.single();
|
||||
|
||||
const emailSignature = signatureSetting?.setting_value?.signature || '';
|
||||
|
||||
// Build signature with admin name + global signature
|
||||
const finalReplyBody = emailSignature
|
||||
? `${replyBody}\n\n---\n${adminName}\n${emailSignature}`
|
||||
: `${replyBody}\n\n---\n${adminName}`;
|
||||
|
||||
// Rate limiting: max 10 replies per hour
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
const { count } = await supabase
|
||||
.from('contact_email_threads')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('submission_id', submissionId)
|
||||
.eq('direction', 'outbound')
|
||||
.gte('created_at', oneHourAgo);
|
||||
|
||||
if (count && count >= 10) {
|
||||
return createErrorResponse({
|
||||
message: 'Rate limit exceeded. Max 10 replies per hour.'
|
||||
}, 429, corsHeaders);
|
||||
}
|
||||
|
||||
const ticketNumber = submission.ticket_number || 'UNKNOWN';
|
||||
const messageId = `<${ticketNumber}.${crypto.randomUUID()}@thrillwiki.com>`;
|
||||
const finalSubject = replySubject || `Re: [${ticketNumber}] ${submission.subject}`;
|
||||
|
||||
// Get previous message for threading
|
||||
const { data: previousMessages } = await supabase
|
||||
.from('contact_email_threads')
|
||||
.select('message_id')
|
||||
.eq('submission_id', submissionId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1);
|
||||
|
||||
const originalMessageId = `<${ticketNumber}.${submission.id}@thrillwiki.com>`;
|
||||
const inReplyTo = previousMessages?.[0]?.message_id || originalMessageId;
|
||||
|
||||
// Build reference chain for threading
|
||||
const referenceChain = previousMessages?.[0]?.message_id
|
||||
? [originalMessageId, previousMessages[0].message_id].join(' ')
|
||||
: originalMessageId;
|
||||
|
||||
// Send email via ForwardEmail
|
||||
const forwardEmailResponse = await fetch('https://api.forwardemail.net/v1/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${btoa(Deno.env.get('FORWARDEMAIL_API_KEY') + ':')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: `${adminDisplayName} <${adminEmail}>`,
|
||||
to: `${submission.name} <${submission.email}>`,
|
||||
subject: finalSubject,
|
||||
text: finalReplyBody,
|
||||
headers: {
|
||||
'Message-ID': messageId,
|
||||
'In-Reply-To': inReplyTo,
|
||||
'References': referenceChain,
|
||||
'X-Thread-ID': submission.thread_id,
|
||||
'X-Ticket-Number': ticketNumber
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!forwardEmailResponse.ok) {
|
||||
const errorText = await forwardEmailResponse.text();
|
||||
edgeLogger.error('ForwardEmail API error', {
|
||||
requestId: tracking.requestId,
|
||||
status: forwardEmailResponse.status,
|
||||
error: errorText
|
||||
});
|
||||
return createErrorResponse({ message: 'Failed to send email' }, 500, corsHeaders);
|
||||
}
|
||||
|
||||
// Insert email thread record
|
||||
const { error: insertError } = await supabase
|
||||
.from('contact_email_threads')
|
||||
.insert({
|
||||
submission_id: submissionId,
|
||||
message_id: messageId,
|
||||
in_reply_to: inReplyTo,
|
||||
reference_chain: [inReplyTo],
|
||||
from_email: adminEmail,
|
||||
to_email: submission.email,
|
||||
subject: finalSubject,
|
||||
body_text: finalReplyBody,
|
||||
direction: 'outbound',
|
||||
sent_by: user.id,
|
||||
metadata: {
|
||||
admin_email: user.email,
|
||||
sent_at: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
edgeLogger.error('Failed to insert email thread', {
|
||||
requestId: tracking.requestId,
|
||||
error: insertError
|
||||
});
|
||||
}
|
||||
|
||||
// Update submission
|
||||
await supabase
|
||||
.from('contact_submissions')
|
||||
.update({
|
||||
last_admin_response_at: new Date().toISOString(),
|
||||
response_count: (submission.response_count || 0) + 1,
|
||||
status: 'in_progress'
|
||||
})
|
||||
.eq('id', submissionId);
|
||||
|
||||
// Audit log
|
||||
await supabase
|
||||
.from('admin_audit_log')
|
||||
.insert({
|
||||
admin_user_id: user.id,
|
||||
target_user_id: user.id,
|
||||
action: 'send_contact_email_reply',
|
||||
details: {
|
||||
submission_id: submissionId,
|
||||
recipient: submission.email,
|
||||
subject: finalSubject
|
||||
}
|
||||
});
|
||||
|
||||
edgeLogger.info('Admin email reply sent successfully', {
|
||||
requestId: tracking.requestId,
|
||||
submissionId,
|
||||
duration: endRequest(tracking)
|
||||
});
|
||||
const handler = async (req: Request, { supabase, user, span, requestId }: EdgeFunctionContext): Promise<Response> => {
|
||||
const body: AdminReplyRequest = await req.json();
|
||||
const { submissionId, replyBody, replySubject } = body;
|
||||
|
||||
// Validate request
|
||||
if (!submissionId || !replyBody) {
|
||||
addSpanEvent(span, 'validation_failed', { reason: 'missing_fields' });
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, messageId }),
|
||||
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
JSON.stringify({ error: 'Missing required fields', requestId }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
edgeLogger.error('Unexpected error in send-admin-email-reply', {
|
||||
requestId: tracking.requestId,
|
||||
error: formatEdgeError(error)
|
||||
});
|
||||
return createErrorResponse(error, 500, corsHeaders);
|
||||
}
|
||||
|
||||
if (replyBody.length < 10 || replyBody.length > 5000) {
|
||||
addSpanEvent(span, 'validation_failed', { reason: 'invalid_length', length: replyBody.length });
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Reply must be between 10 and 5000 characters', requestId }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
addSpanEvent(span, 'request_validated', { submissionId });
|
||||
|
||||
// Get admin email from environment variable
|
||||
const adminEmail = Deno.env.get('ADMIN_EMAIL_ADDRESS') || 'admins@thrillwiki.com';
|
||||
const adminDisplayName = Deno.env.get('ADMIN_EMAIL_DISPLAY_NAME') || 'ThrillWiki Admin';
|
||||
|
||||
// Fetch admin's profile for signature
|
||||
const { data: adminProfile } = await supabase
|
||||
.from('profiles')
|
||||
.select('display_name, username')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
const adminName = adminProfile?.display_name || adminProfile?.username || 'ThrillWiki Team';
|
||||
|
||||
// Fetch submission
|
||||
const { data: submission, error: fetchError } = await supabase
|
||||
.from('contact_submissions')
|
||||
.select('id, email, name, subject, thread_id, response_count, ticket_number')
|
||||
.eq('id', submissionId)
|
||||
.single();
|
||||
|
||||
if (fetchError || !submission) {
|
||||
addSpanEvent(span, 'submission_not_found', { submissionId });
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Submission not found', requestId }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
addSpanEvent(span, 'submission_fetched', {
|
||||
ticketNumber: submission.ticket_number,
|
||||
recipientEmail: submission.email
|
||||
});
|
||||
|
||||
// Fetch email signature from admin settings
|
||||
const { data: signatureSetting } = await supabase
|
||||
.from('admin_settings')
|
||||
.select('setting_value')
|
||||
.eq('setting_key', 'email.signature')
|
||||
.single();
|
||||
|
||||
const emailSignature = signatureSetting?.setting_value?.signature || '';
|
||||
|
||||
// Build signature with admin name + global signature
|
||||
const finalReplyBody = emailSignature
|
||||
? `${replyBody}\n\n---\n${adminName}\n${emailSignature}`
|
||||
: `${replyBody}\n\n---\n${adminName}`;
|
||||
|
||||
// Rate limiting: max 10 replies per hour
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
const { count } = await supabase
|
||||
.from('contact_email_threads')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('submission_id', submissionId)
|
||||
.eq('direction', 'outbound')
|
||||
.gte('created_at', oneHourAgo);
|
||||
|
||||
if (count && count >= 10) {
|
||||
addSpanEvent(span, 'rate_limit_exceeded', { count });
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Rate limit exceeded. Max 10 replies per hour.', requestId }),
|
||||
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const ticketNumber = submission.ticket_number || 'UNKNOWN';
|
||||
const messageId = `<${ticketNumber}.${crypto.randomUUID()}@thrillwiki.com>`;
|
||||
const finalSubject = replySubject || `Re: [${ticketNumber}] ${submission.subject}`;
|
||||
|
||||
// Get previous message for threading
|
||||
const { data: previousMessages } = await supabase
|
||||
.from('contact_email_threads')
|
||||
.select('message_id')
|
||||
.eq('submission_id', submissionId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1);
|
||||
|
||||
const originalMessageId = `<${ticketNumber}.${submission.id}@thrillwiki.com>`;
|
||||
const inReplyTo = previousMessages?.[0]?.message_id || originalMessageId;
|
||||
|
||||
// Build reference chain for threading
|
||||
const referenceChain = previousMessages?.[0]?.message_id
|
||||
? [originalMessageId, previousMessages[0].message_id].join(' ')
|
||||
: originalMessageId;
|
||||
|
||||
addSpanEvent(span, 'sending_email', {
|
||||
messageId,
|
||||
recipient: submission.email,
|
||||
subject: finalSubject
|
||||
});
|
||||
|
||||
// Send email via ForwardEmail
|
||||
const forwardEmailResponse = await fetch('https://api.forwardemail.net/v1/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${btoa(Deno.env.get('FORWARDEMAIL_API_KEY') + ':')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: `${adminDisplayName} <${adminEmail}>`,
|
||||
to: `${submission.name} <${submission.email}>`,
|
||||
subject: finalSubject,
|
||||
text: finalReplyBody,
|
||||
headers: {
|
||||
'Message-ID': messageId,
|
||||
'In-Reply-To': inReplyTo,
|
||||
'References': referenceChain,
|
||||
'X-Thread-ID': submission.thread_id,
|
||||
'X-Ticket-Number': ticketNumber
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!forwardEmailResponse.ok) {
|
||||
const errorText = await forwardEmailResponse.text();
|
||||
addSpanEvent(span, 'email_send_failed', {
|
||||
status: forwardEmailResponse.status,
|
||||
error: errorText
|
||||
});
|
||||
throw new Error(`ForwardEmail API error: ${errorText}`);
|
||||
}
|
||||
|
||||
addSpanEvent(span, 'email_sent', { messageId });
|
||||
|
||||
// Insert email thread record
|
||||
const { error: insertError } = await supabase
|
||||
.from('contact_email_threads')
|
||||
.insert({
|
||||
submission_id: submissionId,
|
||||
message_id: messageId,
|
||||
in_reply_to: inReplyTo,
|
||||
reference_chain: [inReplyTo],
|
||||
from_email: adminEmail,
|
||||
to_email: submission.email,
|
||||
subject: finalSubject,
|
||||
body_text: finalReplyBody,
|
||||
direction: 'outbound',
|
||||
sent_by: user.id,
|
||||
metadata: {
|
||||
admin_email: user.email,
|
||||
sent_at: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
addSpanEvent(span, 'thread_insert_failed', { error: insertError });
|
||||
} else {
|
||||
addSpanEvent(span, 'thread_inserted');
|
||||
}
|
||||
|
||||
// Update submission
|
||||
await supabase
|
||||
.from('contact_submissions')
|
||||
.update({
|
||||
last_admin_response_at: new Date().toISOString(),
|
||||
response_count: (submission.response_count || 0) + 1,
|
||||
status: 'in_progress'
|
||||
})
|
||||
.eq('id', submissionId);
|
||||
|
||||
addSpanEvent(span, 'submission_updated');
|
||||
|
||||
// Audit log
|
||||
await supabase
|
||||
.from('admin_audit_log')
|
||||
.insert({
|
||||
admin_user_id: user.id,
|
||||
target_user_id: user.id,
|
||||
action: 'send_contact_email_reply',
|
||||
details: {
|
||||
submission_id: submissionId,
|
||||
recipient: submission.email,
|
||||
subject: finalSubject
|
||||
}
|
||||
});
|
||||
|
||||
addSpanEvent(span, 'audit_logged');
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, messageId }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
};
|
||||
|
||||
serve(handler);
|
||||
serve(createEdgeFunction({
|
||||
name: 'send-admin-email-reply',
|
||||
requireAuth: true,
|
||||
requiredRoles: ['superuser', 'admin', 'moderator'],
|
||||
corsHeaders,
|
||||
logRequests: true,
|
||||
logResponses: true,
|
||||
}, handler));
|
||||
|
||||
Reference in New Issue
Block a user