2025-08-08 19:40:13 +02:00
|
|
|
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;
|
|
|
|
|
|
2025-08-08 22:51:14 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-08 19:40:13 +02:00
|
|
|
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<boolean> {
|
|
|
|
|
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;
|
|
|
|
|
}
|