import type { RegistrationFormData } from '~/utils/types'; import { createKeycloakAdminClient } from '~/server/utils/keycloak-admin'; import { createMember } from '~/server/utils/nocodb'; // Simple NocoDB client wrapper for consistency with existing pattern const createNocoDBClient = () => ({ async findAll(table: string, options: any) { // For registration, we'll use a direct search via the existing members API try { const response = await $fetch(`/api/members`, { method: 'GET', headers: { 'Accept': 'application/json' } }) as any; // Filter by email from the response const members = response?.data || response?.list || []; if (options.where.email) { return members.filter((member: any) => member.email === options.where.email); } return members; } catch (error) { console.warn('[createNocoDBClient.findAll] Error fetching members:', error); return []; } }, async create(table: string, data: any) { return await createMember(data); }, async delete(table: string, id: string) { const { deleteMember } = await import('~/server/utils/nocodb'); return await deleteMember(id); } }); export default defineEventHandler(async (event) => { console.log('[api/registration.post] ========================='); console.log('[api/registration.post] POST /api/registration - Public member registration'); let createdKeycloakId: string | null = null; let createdMemberId: string | null = null; try { const body = await readBody(event) as RegistrationFormData; console.log('[api/registration.post] Registration attempt for:', body.email); // 1. Validate reCAPTCHA if (!body.recaptcha_token) { throw createError({ statusCode: 400, statusMessage: 'reCAPTCHA verification is required' }); } const recaptchaValid = await validateRecaptcha(body.recaptcha_token); if (!recaptchaValid) { throw createError({ statusCode: 400, statusMessage: 'reCAPTCHA verification failed' }); } // 2. Validate form data const validationErrors = validateRegistrationForm(body); if (validationErrors.length > 0) { throw createError({ statusCode: 400, statusMessage: validationErrors.join(', ') }); } // 3. Check for existing user in Keycloak const keycloakAdmin = createKeycloakAdminClient(); const existingUsers = await keycloakAdmin.findUserByEmail(body.email); if (existingUsers.length > 0) { throw createError({ statusCode: 409, statusMessage: 'An account with this email already exists' }); } // 4. Check for existing member record const nocodb = createNocoDBClient(); const existingMembers = await nocodb.findAll('members', { where: { email: body.email } }); if (existingMembers.length > 0) { throw createError({ statusCode: 409, statusMessage: 'A member with this email already exists' }); } // 5. Create Keycloak user with role-based registration console.log('[api/registration.post] Creating Keycloak user with role-based system...'); const membershipData = { membershipStatus: 'Active', duesStatus: 'unpaid' as const, nationality: body.nationality, phone: body.phone, address: body.address, registrationDate: new Date().toISOString(), paymentDueDate: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), membershipTier: 'user' as const }; createdKeycloakId = await keycloakAdmin.createUserWithRoleRegistration({ email: body.email, firstName: body.first_name, lastName: body.last_name, membershipTier: 'user', // All public registrations default to 'user' role membershipData }); // 6. Create member record console.log('[api/registration.post] Creating member record...'); const memberData = { first_name: body.first_name, last_name: body.last_name, email: body.email, phone: body.phone, date_of_birth: body.date_of_birth, address: body.address, nationality: body.nationality, keycloak_id: createdKeycloakId, current_year_dues_paid: 'false', membership_status: 'Active', registration_date: new Date().toISOString(), member_since: new Date().getFullYear().toString(), membership_date_paid: '', payment_due_date: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString() // 3 months from now }; const member = await nocodb.create('members', memberData); createdMemberId = member.Id; // 7. Send welcome/verification email using our custom email system console.log('[api/registration.post] Sending welcome/verification email...'); try { const { getEmailService } = await import('~/server/utils/email'); const { generateEmailVerificationToken } = await import('~/server/utils/email-tokens'); const emailService = getEmailService(); const verificationToken = await generateEmailVerificationToken(createdKeycloakId, body.email); const config = useRuntimeConfig(); const verificationLink = `${config.public.domain}/api/auth/verify-email?token=${verificationToken}`; await emailService.sendWelcomeEmail(body.email, { firstName: body.first_name, lastName: body.last_name, verificationLink, memberId: createdMemberId }); console.log('[api/registration.post] Welcome email sent successfully'); } catch (emailError: any) { console.error('[api/registration.post] Failed to send welcome email:', emailError.message); // Don't fail the registration if email fails - user can resend verification email later } console.log(`[api/registration.post] ✅ Registration successful - Member ID: ${createdMemberId}, Keycloak ID: ${createdKeycloakId}`); return { success: true, message: 'Registration successful! Please check your email to verify your account and set your password.', data: { memberId: createdMemberId, email: body.email } }; } catch (error: any) { console.error('[api/registration.post] ❌ Registration failed:', error); // Rollback operations if needed if (createdKeycloakId || createdMemberId) { console.log('[api/registration.post] Rolling back partial registration...'); // Delete Keycloak user if created if (createdKeycloakId) { try { const keycloakAdmin = createKeycloakAdminClient(); await keycloakAdmin.deleteUser(createdKeycloakId); console.log('[api/registration.post] Keycloak user rolled back'); } catch (rollbackError) { console.error('[api/registration.post] Failed to rollback Keycloak user:', rollbackError); } } // Delete member record if created if (createdMemberId) { try { const nocodb = createNocoDBClient(); await nocodb.delete('members', createdMemberId); console.log('[api/registration.post] Member record rolled back'); } catch (rollbackError) { console.error('[api/registration.post] Failed to rollback member record:', rollbackError); } } } throw error; } }); /** * Validate reCAPTCHA token */ async function validateRecaptcha(token: string): Promise { try { const { getRecaptchaConfig } = await import('~/server/utils/admin-config'); const recaptchaConfig = getRecaptchaConfig(); if (!recaptchaConfig.secretKey) { console.warn('[api/registration.post] reCAPTCHA secret key not configured'); return false; } const response = await fetch('https://www.google.com/recaptcha/api/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ secret: recaptchaConfig.secretKey, response: token }) }); const result = await response.json(); return result.success === true; } catch (error) { console.error('[api/registration.post] reCAPTCHA validation error:', error); return false; } } /** * Validate registration form data */ function validateRegistrationForm(data: RegistrationFormData): string[] { const errors: string[] = []; // Required fields if (!data.first_name?.trim()) errors.push('First name is required'); if (!data.last_name?.trim()) errors.push('Last name is required'); if (!data.email?.trim()) errors.push('Email is required'); if (!data.phone?.trim()) errors.push('Phone number is required'); if (!data.date_of_birth?.trim()) errors.push('Date of birth is required'); if (!data.address?.trim()) errors.push('Address is required'); if (!data.nationality?.trim()) errors.push('Nationality is required'); // Email format validation if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) { errors.push('Invalid email format'); } // Date of birth validation (must be at least 18 years old) if (data.date_of_birth) { const birthDate = new Date(data.date_of_birth); const today = new Date(); const age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { // Haven't had birthday this year yet if (age - 1 < 18) { errors.push('You must be at least 18 years old to register'); } } else { if (age < 18) { errors.push('You must be at least 18 years old to register'); } } } // Name length validation if (data.first_name && data.first_name.trim().length < 2) { errors.push('First name must be at least 2 characters'); } if (data.last_name && data.last_name.trim().length < 2) { errors.push('Last name must be at least 2 characters'); } return errors; }