import { fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { testSmtpConnection, sendTemplatedEmail } from '$lib/server/email'; import { testS3Connection, clearS3ClientCache } from '$lib/server/storage'; import { supabaseAdmin } from '$lib/server/supabase'; import * as poste from '$lib/server/poste'; export const load: PageServerLoad = async ({ locals }) => { // Load all configurable data const [ { data: membershipStatuses }, { data: membershipTypes }, { data: eventTypes }, { data: documentCategories }, { data: appSettings }, { data: emailTemplates } ] = await Promise.all([ locals.supabase.from('membership_statuses').select('*').order('sort_order', { ascending: true }), locals.supabase.from('membership_types').select('*').order('sort_order', { ascending: true }), locals.supabase.from('event_types').select('*').order('sort_order', { ascending: true }), locals.supabase.from('document_categories').select('*').order('sort_order', { ascending: true }), locals.supabase.from('app_settings').select('*'), locals.supabase.from('email_templates').select('template_key, template_name, category').eq('is_active', true).order('category').order('template_name') ]); // Convert settings to object by category const settings: Record> = {}; for (const setting of appSettings || []) { if (!settings[setting.category]) { settings[setting.category] = {}; } settings[setting.category][setting.setting_key] = setting.setting_value; } return { membershipStatuses: membershipStatuses || [], membershipTypes: membershipTypes || [], eventTypes: eventTypes || [], documentCategories: documentCategories || [], settings, emailTemplates: emailTemplates || [] }; }; export const actions: Actions = { // Membership Status actions createStatus: async ({ request, locals }) => { const formData = await request.formData(); const name = formData.get('name') as string; const displayName = formData.get('display_name') as string; const color = formData.get('color') as string; const description = formData.get('description') as string; if (!name || !displayName) { return fail(400, { error: 'Name and display name are required' }); } const { error } = await locals.supabase.from('membership_statuses').insert({ name: name.toLowerCase().replace(/\s+/g, '_'), display_name: displayName, color: color || '#6b7280', description: description || null }); if (error) { console.error('Create status error:', error); return fail(500, { error: 'Failed to create status' }); } return { success: 'Status created successfully!' }; }, deleteStatus: async ({ request, locals }) => { const formData = await request.formData(); const id = formData.get('id') as string; const { error } = await locals.supabase.from('membership_statuses').delete().eq('id', id); if (error) { console.error('Delete status error:', error); return fail(500, { error: 'Failed to delete status' }); } return { success: 'Status deleted!' }; }, // Membership Type actions createType: async ({ request, locals }) => { const formData = await request.formData(); const name = formData.get('name') as string; const displayName = formData.get('display_name') as string; const annualDues = formData.get('annual_dues') as string; const description = formData.get('description') as string; if (!name || !displayName || !annualDues) { return fail(400, { error: 'Name, display name, and annual dues are required' }); } const { error } = await locals.supabase.from('membership_types').insert({ name: name.toLowerCase().replace(/\s+/g, '_'), display_name: displayName, annual_dues: parseFloat(annualDues), description: description || null }); if (error) { console.error('Create type error:', error); return fail(500, { error: 'Failed to create membership type' }); } return { success: 'Membership type created successfully!' }; }, deleteType: async ({ request, locals }) => { const formData = await request.formData(); const id = formData.get('id') as string; const { error } = await locals.supabase.from('membership_types').delete().eq('id', id); if (error) { console.error('Delete type error:', error); return fail(500, { error: 'Failed to delete membership type' }); } return { success: 'Membership type deleted!' }; }, // Event Type actions createEventType: async ({ request, locals }) => { const formData = await request.formData(); const name = formData.get('name') as string; const displayName = formData.get('display_name') as string; const color = formData.get('color') as string; if (!name || !displayName) { return fail(400, { error: 'Name and display name are required' }); } const { error } = await locals.supabase.from('event_types').insert({ name: name.toLowerCase().replace(/\s+/g, '_'), display_name: displayName, color: color || '#3b82f6' }); if (error) { console.error('Create event type error:', error); return fail(500, { error: 'Failed to create event type' }); } return { success: 'Event type created successfully!' }; }, deleteEventType: async ({ request, locals }) => { const formData = await request.formData(); const id = formData.get('id') as string; const { error } = await locals.supabase.from('event_types').delete().eq('id', id); if (error) { console.error('Delete event type error:', error); return fail(500, { error: 'Failed to delete event type' }); } return { success: 'Event type deleted!' }; }, // Document Category actions createCategory: async ({ request, locals }) => { const formData = await request.formData(); const name = formData.get('name') as string; const displayName = formData.get('display_name') as string; const description = formData.get('description') as string; if (!name || !displayName) { return fail(400, { error: 'Name and display name are required' }); } const { error } = await locals.supabase.from('document_categories').insert({ name: name.toLowerCase().replace(/\s+/g, '_'), display_name: displayName, description: description || null }); if (error) { console.error('Create category error:', error); return fail(500, { error: 'Failed to create document category' }); } return { success: 'Document category created successfully!' }; }, deleteCategory: async ({ request, locals }) => { const formData = await request.formData(); const id = formData.get('id') as string; const { error } = await locals.supabase.from('document_categories').delete().eq('id', id); if (error) { console.error('Delete category error:', error); return fail(500, { error: 'Failed to delete document category' }); } return { success: 'Document category deleted!' }; }, // Update app settings updateSettings: async ({ request, locals }) => { const { member } = await locals.safeGetSession(); if (!member || member.role !== 'admin') { return fail(403, { error: 'Unauthorized' }); } const formData = await request.formData(); const category = formData.get('category') as string; if (!category) { return fail(400, { error: 'Category is required' }); } try { // Get existing boolean settings for this category to handle unchecked checkboxes // Use supabaseAdmin to bypass RLS const { data: existingSettings, error: fetchError } = await supabaseAdmin .from('app_settings') .select('setting_key, setting_type') .eq('category', category); if (fetchError) { console.error('Error fetching existing settings:', fetchError); return fail(500, { error: 'Failed to load existing settings' }); } const existingBooleanKeys = new Set( (existingSettings || []) .filter(s => s.setting_type === 'boolean') .map(s => s.setting_key) ); // Get all settings from form data const settingsToUpdate: Array<{ key: string; value: any; type: string }> = []; const processedKeys = new Set(); for (const [key, value] of formData.entries()) { if (key !== 'category' && key.startsWith('setting_')) { const settingKey = key.replace('setting_', ''); processedKeys.add(settingKey); // Handle checkbox values - they come as 'on' when checked const isCheckbox = value === 'on' || existingBooleanKeys.has(settingKey); settingsToUpdate.push({ key: settingKey, value: isCheckbox ? (value === 'on' || value === 'true') : value, type: isCheckbox ? 'boolean' : 'text' }); } } // Handle unchecked checkboxes - they don't send any value // For any existing boolean setting NOT in the form data, set to false for (const booleanKey of existingBooleanKeys) { if (!processedKeys.has(booleanKey)) { settingsToUpdate.push({ key: booleanKey, value: false, type: 'boolean' }); } } // Update or insert each setting using supabaseAdmin (bypasses RLS) for (const setting of settingsToUpdate) { // Try to update first const { data: existing } = await supabaseAdmin .from('app_settings') .select('id') .eq('category', category) .eq('setting_key', setting.key) .single(); if (existing) { // Update existing const { error: updateError } = await supabaseAdmin .from('app_settings') .update({ setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value), updated_at: new Date().toISOString(), updated_by: member.id }) .eq('category', category) .eq('setting_key', setting.key); if (updateError) { console.error('Error updating setting:', setting.key, updateError); } } else { // Insert new setting const { error: insertError } = await supabaseAdmin .from('app_settings') .insert({ category, setting_key: setting.key, setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value), setting_type: setting.type, display_name: setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), updated_at: new Date().toISOString(), updated_by: member.id }); if (insertError) { console.error('Error inserting setting:', setting.key, insertError); } } } // Clear caches if storage settings were updated if (category === 'storage') { clearS3ClientCache(); } return { success: 'Settings updated successfully!' }; } catch (err) { console.error('Unexpected error updating settings:', err); return fail(500, { error: 'An unexpected error occurred while saving settings' }); } }, // Test SMTP connection testSmtp: async ({ request, locals }) => { const { member } = await locals.safeGetSession(); const formData = await request.formData(); const testEmail = formData.get('test_email') as string; // Use the member's email if no test email is provided const recipientEmail = testEmail || member?.email; if (!recipientEmail) { return fail(400, { error: 'No email address provided for test' }); } // Test SMTP connection and send a test email const result = await testSmtpConnection(recipientEmail, member?.id); if (!result.success) { return fail(400, { error: result.error || 'SMTP test failed' }); } return { success: `Test email sent successfully to ${recipientEmail}! Check your inbox.` }; }, // Test S3/MinIO connection testS3: async () => { // Clear cache to ensure fresh settings are used clearS3ClientCache(); // Test S3 connection using the actual client const result = await testS3Connection(); if (!result.success) { return fail(400, { error: result.error || 'S3 connection test failed' }); } return { success: 'S3/MinIO connection successful! Bucket is accessible.' }; }, // Test email template testEmailTemplate: async ({ request, locals, url }) => { const { member } = await locals.safeGetSession(); if (!member?.email) { return fail(400, { error: 'No email address found for your account' }); } const formData = await request.formData(); const templateKey = formData.get('template_key') as string; if (!templateKey) { return fail(400, { error: 'Template key is required' }); } // Get full member details including member_id const { data: fullMember } = await locals.supabase .from('members') .select('member_id') .eq('id', member.id) .single(); const memberId = fullMember?.member_id || 'MUSA-0001'; // Create sample variables for each template type const sampleVariables: Record> = { // Welcome/Auth templates welcome: { first_name: member.first_name || 'Test', last_name: member.last_name || 'User', member_id: memberId, portal_url: url.origin }, password_reset: { first_name: member.first_name || 'Test', reset_link: `${url.origin}/reset-password?token=sample-token` }, email_verification: { first_name: member.first_name || 'Test', verification_link: `${url.origin}/verify?token=sample-token` }, // Event templates rsvp_confirmation: { first_name: member.first_name || 'Test', event_title: 'Monaco USA Annual Gala', event_date: 'Saturday, March 15, 2026', event_time: '7:00 PM', event_location: 'Hotel Hermitage, Monaco', guest_count: '2', portal_url: `${url.origin}/events` }, waitlist_promotion: { first_name: member.first_name || 'Test', event_title: 'Monaco USA Annual Gala', event_date: 'Saturday, March 15, 2026', event_location: 'Hotel Hermitage, Monaco', portal_url: `${url.origin}/events` }, event_reminder_24hr: { first_name: member.first_name || 'Test', event_title: 'Monaco USA Monthly Meetup', event_date: 'Tomorrow, January 25, 2026', event_time: '6:30 PM', event_location: 'Stars\'n\'Bars, Monaco', guest_count: '1', portal_url: `${url.origin}/events/sample-event-id` }, // Payment/Dues templates payment_received: { first_name: member.first_name || 'Test', amount: '€50.00', payment_date: 'January 24, 2026', payment_method: 'Bank Transfer', new_due_date: 'January 24, 2027', member_id: memberId }, dues_reminder_30: { first_name: member.first_name || 'Test', due_date: 'February 24, 2026', amount: '€50.00', member_id: memberId, account_holder: 'Monaco USA Association', bank_name: 'CMB Monaco', iban: 'MC00 0000 0000 0000 0000 0000 000', portal_url: `${url.origin}/payments` }, dues_reminder_7: { first_name: member.first_name || 'Test', due_date: 'January 31, 2026', amount: '€50.00', member_id: memberId, iban: 'MC00 0000 0000 0000 0000 0000 000', portal_url: `${url.origin}/payments` }, dues_reminder_1: { first_name: member.first_name || 'Test', due_date: 'January 25, 2026', amount: '€50.00', member_id: memberId, iban: 'MC00 0000 0000 0000 0000 0000 000', portal_url: `${url.origin}/payments` }, dues_overdue: { first_name: member.first_name || 'Test', due_date: 'January 15, 2026', amount: '€50.00', days_overdue: '9', grace_days_remaining: '21', member_id: memberId, account_holder: 'Monaco USA Association', iban: 'MC00 0000 0000 0000 0000 0000 000', portal_url: `${url.origin}/payments` }, dues_grace_warning: { first_name: member.first_name || 'Test', due_date: 'December 24, 2025', amount: '€50.00', days_overdue: '31', grace_days_remaining: '7', grace_end_date: 'February 1, 2026', member_id: memberId, iban: 'MC00 0000 0000 0000 0000 0000 000', portal_url: `${url.origin}/payments` }, dues_inactive_notice: { first_name: member.first_name || 'Test', amount: '€50.00', member_id: memberId, account_holder: 'Monaco USA Association', iban: 'MC00 0000 0000 0000 0000 0000 000', portal_url: `${url.origin}/payments` } }; // Get variables for this template, or use defaults const variables = sampleVariables[templateKey] || { first_name: member.first_name || 'Test', last_name: member.last_name || 'User', portal_url: url.origin }; // Send test email const result = await sendTemplatedEmail(templateKey, member.email, variables, { recipientId: member.id, recipientName: `${member.first_name} ${member.last_name}`, baseUrl: url.origin }); if (!result.success) { return fail(400, { error: result.error || 'Failed to send test email' }); } return { success: `Test email "${templateKey}" sent to ${member.email}` }; }, // ============================================ // Poste Mail Server Actions // ============================================ testPoste: async ({ locals }) => { // Get Poste settings const { data: settings } = await locals.supabase .from('app_settings') .select('setting_key, setting_value') .eq('category', 'poste'); if (!settings || settings.length === 0) { return fail(400, { error: 'Poste mail server not configured. Please save settings first.' }); } const config: Record = {}; for (const s of settings) { let value = s.setting_value; if (typeof value === 'string') { value = value.replace(/^"|"$/g, ''); } config[s.setting_key] = value as string; } if (!config.poste_api_host || !config.poste_admin_email || !config.poste_admin_password) { return fail(400, { error: 'Poste configuration incomplete. Host, admin email, and password are required.' }); } const result = await poste.testConnection({ host: config.poste_api_host, adminEmail: config.poste_admin_email, adminPassword: config.poste_admin_password }); if (!result.success) { return fail(400, { error: result.error || 'Connection test failed' }); } return { success: 'Connection to Poste mail server successful!' }; }, listMailboxes: async ({ locals }) => { // Get Poste settings const { data: settings } = await locals.supabase .from('app_settings') .select('setting_key, setting_value') .eq('category', 'poste'); if (!settings || settings.length === 0) { return fail(400, { error: 'Poste not configured' }); } const config: Record = {}; for (const s of settings) { let value = s.setting_value; if (typeof value === 'string') { value = value.replace(/^"|"$/g, ''); } config[s.setting_key] = value as string; } const result = await poste.listMailboxes({ host: config.poste_api_host, adminEmail: config.poste_admin_email, adminPassword: config.poste_admin_password }); if (!result.success) { return fail(400, { error: result.error }); } return { mailboxes: result.mailboxes }; }, createMailbox: async ({ request, locals }) => { const formData = await request.formData(); const emailPrefix = formData.get('email_prefix') as string; const displayName = formData.get('display_name') as string; const password = formData.get('password') as string; if (!emailPrefix || !displayName) { return fail(400, { error: 'Email prefix and display name are required' }); } // Get Poste settings const { data: settings } = await locals.supabase .from('app_settings') .select('setting_key, setting_value') .eq('category', 'poste'); const config: Record = {}; for (const s of settings || []) { let value = s.setting_value; if (typeof value === 'string') { value = value.replace(/^"|"$/g, ''); } config[s.setting_key] = value as string; } const domain = config.poste_domain || 'monacousa.org'; const fullEmail = `${emailPrefix}@${domain}`; const actualPassword = password || poste.generatePassword(); const result = await poste.createMailbox( { host: config.poste_api_host, adminEmail: config.poste_admin_email, adminPassword: config.poste_admin_password }, { email: fullEmail, name: displayName, password: actualPassword } ); if (!result.success) { return fail(400, { error: result.error }); } return { success: `Mailbox ${fullEmail} created successfully!`, generatedPassword: password ? undefined : actualPassword }; }, updateMailbox: async ({ request, locals }) => { const formData = await request.formData(); const email = formData.get('email') as string; const displayName = formData.get('display_name') as string; const newPassword = formData.get('new_password') as string; const disabled = formData.get('disabled') === 'true'; if (!email) { return fail(400, { error: 'Email is required' }); } // Get Poste settings const { data: settings } = await locals.supabase .from('app_settings') .select('setting_key, setting_value') .eq('category', 'poste'); const config: Record = {}; for (const s of settings || []) { let value = s.setting_value; if (typeof value === 'string') { value = value.replace(/^"|"$/g, ''); } config[s.setting_key] = value as string; } const updates: { name?: string; password?: string; disabled?: boolean } = {}; if (displayName) updates.name = displayName; if (newPassword) updates.password = newPassword; updates.disabled = disabled; const result = await poste.updateMailbox( { host: config.poste_api_host, adminEmail: config.poste_admin_email, adminPassword: config.poste_admin_password }, email, updates ); if (!result.success) { return fail(400, { error: result.error }); } return { success: `Mailbox ${email} updated successfully!` }; }, deleteMailbox: async ({ request, locals }) => { const formData = await request.formData(); const email = formData.get('email') as string; if (!email) { return fail(400, { error: 'Email is required' }); } // Get Poste settings const { data: settings } = await locals.supabase .from('app_settings') .select('setting_key, setting_value') .eq('category', 'poste'); const config: Record = {}; for (const s of settings || []) { let value = s.setting_value; if (typeof value === 'string') { value = value.replace(/^"|"$/g, ''); } config[s.setting_key] = value as string; } const result = await poste.deleteMailbox( { host: config.poste_api_host, adminEmail: config.poste_admin_email, adminPassword: config.poste_admin_password }, email ); if (!result.success) { return fail(400, { error: result.error }); } return { success: `Mailbox ${email} deleted successfully!` }; } };