Fix email system and implement OTP confirmation flow

Email System Fixes:
- Fix email sending after payment: handle-order-paid now calls send-notification
  instead of send-email-v2 directly, properly processing template variables
- Fix order_created email timing: sent immediately after order creation,
  before payment QR code generation
- Update email templates to use short order ID (8 chars) instead of full UUID
- Add working "Akses Sekarang" buttons to payment_success and access_granted emails
- Add platform_url column to platform_settings for email links

OTP Verification Flow:
- Create dedicated /confirm-otp page for users who close registration modal
- Add link in checkout modal and email to dedicated OTP page
- Update OTP email template with better copywriting and dedicated page link
- Fix send-auth-otp to fetch platform settings for dynamic brand_name and platform_url
- Auto-login users after OTP verification in checkout flow

Admin Features:
- Add delete user functionality with cascade deletion of all related data
- Update IntegrasiTab to read/write email settings from platform_settings only
- Add test email template for email configuration testing

Cleanup:
- Remove obsolete send-consultation-reminder and send-test-email functions
- Update send-email-v2 to read email config from platform_settings
- Remove footer links (Ubah Preferensi/Unsubscribe) from email templates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-01-03 18:02:25 +07:00
parent 4f9a6f4ae3
commit 053465afa3
21 changed files with 1381 additions and 948 deletions

View File

@@ -1,6 +1,5 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { EmailTemplateRenderer } from "../shared/email-template-renderer.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -12,11 +11,6 @@ interface SendOTPRequest {
email: string;
}
// Generate 6-digit OTP code
function generateOTP(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
serve(async (req: Request) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
@@ -33,191 +27,88 @@ serve(async (req: Request) => {
);
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return new Response(
JSON.stringify({ success: false, message: "Invalid email format" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Initialize Supabase client with service role
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false
}
});
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Generate OTP code
const otpCode = generateOTP();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes from now
// Fetch platform settings for brand name and URL
const { data: platformSettings } = await supabase
.from('platform_settings')
.select('brand_name, platform_url')
.single();
console.log(`Generating OTP for user ${user_id}, email ${email}`);
const platformName = platformSettings?.brand_name || 'ACCESS HUB';
const platformUrl = platformSettings?.platform_url || 'https://access-hub.com';
console.log(`Generating OTP for user ${user_id}`);
// Generate 6-digit OTP code
const otpCode = Math.floor(100000 + Math.random() * 900000).toString();
// Calculate expiration time (15 minutes from now)
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
// Store OTP in database
const { error: otpError } = await supabase
const { error: insertError } = await supabase
.from('auth_otps')
.insert({
user_id,
email,
user_id: user_id,
email: email,
otp_code: otpCode,
expires_at: expiresAt.toISOString(),
expires_at: expiresAt,
});
if (otpError) {
console.error('Error storing OTP:', otpError);
throw new Error(`Failed to store OTP: ${otpError.message}`);
if (insertError) {
console.error('Error storing OTP:', insertError);
throw new Error(`Failed to store OTP: ${insertError.message}`);
}
// Get notification settings
const { data: settings, error: settingsError } = await supabase
.from('notification_settings')
.select('*')
.single();
console.log(`OTP generated and stored: ${otpCode}, expires at: ${expiresAt}`);
if (settingsError || !settings) {
console.error('Error fetching notification settings:', settingsError);
throw new Error('Notification settings not configured');
}
// Get platform settings for brand_name
const { data: platformSettings, error: platformError } = await supabase
.from('platform_settings')
.select('brand_name')
.single();
if (platformError) {
console.error('Error fetching platform settings:', platformError);
// Continue with fallback if platform settings not found
}
const brandName = platformSettings?.brand_name || settings.platform_name || 'ACCESS HUB';
// Get email template
console.log('Fetching email template with key: auth_email_verification');
const { data: template, error: templateError } = await supabase
.from('notification_templates')
.select('*')
.eq('key', 'auth_email_verification')
.single();
console.log('Template query result:', { template, templateError });
if (templateError || !template) {
console.error('Error fetching email template:', templateError);
throw new Error('Email template not found. Please create template with key: auth_email_verification');
}
// Get user data from auth.users
const { data: { user }, error: userError } = await supabase.auth.admin.getUserById(user_id);
if (userError || !user) {
console.error('Error fetching user:', userError);
throw new Error('User not found');
}
// Prepare template variables
const templateVars = {
platform_name: brandName,
nama: user.user_metadata?.name || user.email || 'Pengguna',
email: email,
otp_code: otpCode,
expiry_minutes: '15',
confirmation_link: '', // Not used for OTP
year: new Date().getFullYear().toString(),
};
// Process shortcodes in subject
let subject = template.email_subject;
Object.entries(templateVars).forEach(([key, value]) => {
subject = subject.replace(new RegExp(`{${key}}`, 'g'), value);
});
// Process shortcodes in HTML body content
let htmlContent = template.email_body_html;
Object.entries(templateVars).forEach(([key, value]) => {
htmlContent = htmlContent.replace(new RegExp(`{${key}}`, 'g'), value);
});
// Wrap in master template
const htmlBody = EmailTemplateRenderer.render({
subject: subject,
content: htmlContent,
brandName: brandName,
});
// Send email via send-email-v2
console.log(`Sending OTP email to ${email}`);
console.log('Settings:', {
hasMailketingToken: !!settings.mailketing_api_token,
hasApiToken: !!settings.api_token,
hasFromName: !!settings.from_name,
hasFromEmail: !!settings.from_email,
platformName: settings.platform_name,
});
// Use api_token (not mailketing_api_token)
const apiToken = settings.api_token || settings.mailketing_api_token;
if (!apiToken) {
throw new Error('API token not found in notification_settings');
}
// Log email details (truncate HTML body for readability)
console.log('Email payload:', {
recipient: email,
from_name: settings.from_name || brandName,
from_email: settings.from_email || 'noreply@example.com',
subject: subject,
content_length: htmlBody.length,
content_preview: htmlBody.substring(0, 200),
});
const emailResponse = await fetch(`${supabaseUrl}/functions/v1/send-email-v2`, {
// Send OTP email using send-notification
const notificationUrl = `${supabaseUrl}/functions/v1/send-notification`;
const notificationResponse = await fetch(notificationUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${supabaseServiceKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipient: email,
api_token: apiToken,
from_name: settings.from_name || brandName,
from_email: settings.from_email || 'noreply@example.com',
subject: subject,
content: htmlBody,
template_key: 'auth_email_verification',
recipient_email: email,
recipient_name: email.split('@')[0],
variables: {
nama: email.split('@')[0],
otp_code: otpCode,
email: email,
user_id: user_id,
expiry_minutes: '15',
platform_name: platformName,
platform_url: platformUrl
}
}),
});
if (!emailResponse.ok) {
const errorText = await emailResponse.text();
console.error('Email send error:', emailResponse.status, errorText);
throw new Error(`Failed to send email: ${emailResponse.status} ${errorText}`);
if (!notificationResponse.ok) {
const errorText = await notificationResponse.text();
console.error('Error sending notification email:', notificationResponse.status, errorText);
throw new Error(`Failed to send OTP email: ${notificationResponse.status} ${errorText}`);
}
const emailResult = await emailResponse.json();
console.log('Email sent successfully:', emailResult);
// Note: notification_logs table doesn't exist, skipping logging
const notificationResult = await notificationResponse.json();
console.log('Notification sent successfully:', notificationResult);
return new Response(
JSON.stringify({
success: true,
message: 'OTP sent successfully'
message: "OTP sent successfully"
}),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error: any) {
console.error("Error sending OTP:", error);
// Note: notification_logs table doesn't exist, skipping error logging
return new Response(
JSON.stringify({
success: false,