monacousa-portal/src/routes/(app)/admin/settings/+page.server.ts

739 lines
22 KiB
TypeScript

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<string, Record<string, any>> = {};
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<string>();
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<string, Record<string, string>> = {
// 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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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!` };
}
};