import { createMember, handleNocoDbError } from '~/server/utils/nocodb'; import { createSessionManager } from '~/server/utils/session'; import { generateMemberID } from '~/server/utils/member-id'; import type { Member, MembershipStatus } from '~/utils/types'; export default defineEventHandler(async (event) => { console.log('[api/members.post] ========================='); console.log('[api/members.post] POST /api/members - Create new member'); console.log('[api/members.post] Request from:', getClientIP(event)); try { // Validate session and require Board+ privileges const sessionManager = createSessionManager(); const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined; const session = sessionManager.getSession(cookieHeader); if (!session?.user) { throw createError({ statusCode: 401, statusMessage: 'Authentication required' }); } const userTier = session.user.tier; if (userTier !== 'board' && userTier !== 'admin') { throw createError({ statusCode: 403, statusMessage: 'Board member privileges required to create members' }); } console.log('[api/members.post] Authorized user:', session.user.email, 'Tier:', userTier); // Get and validate request body const body = await readBody(event); console.log('[api/members.post] Request body fields:', Object.keys(body)); console.log('[api/members.post] Raw body data:', JSON.stringify(body, null, 2)); // Map display names to snake_case field names (fallback for client issues) const normalizedBody = normalizeFieldNames(body); console.log('[api/members.post] Normalized fields:', Object.keys(normalizedBody)); // Validate required fields const validationErrors = validateMemberData(normalizedBody); if (validationErrors.length > 0) { console.error('[api/members.post] Validation errors:', validationErrors); throw createError({ statusCode: 400, statusMessage: `Validation failed: ${validationErrors.join(', ')}` }); } // Sanitize and prepare data const memberData = await sanitizeMemberData(normalizedBody); console.log('[api/members.post] Sanitized data fields:', Object.keys(memberData)); // Create member in NocoDB const newMember = await createMember(memberData); console.log('[api/members.post] ✅ Member created successfully with ID:', newMember.Id); // Return processed member const processedMember = { ...newMember, FullName: `${newMember.first_name || ''} ${newMember.last_name || ''}`.trim(), FormattedPhone: formatPhoneNumber(newMember.phone) }; return { success: true, data: processedMember, message: 'Member created successfully' }; } catch (error: any) { console.error('[api/members.post] ❌ Error creating member:', error); handleNocoDbError(error, 'createMember', 'Member'); } }); function validateMemberData(data: any): string[] { const errors: string[] = []; // Required fields if (!data.first_name || typeof data.first_name !== 'string' || data.first_name.trim().length < 2) { errors.push('First Name is required and must be at least 2 characters'); } if (!data.last_name || typeof data.last_name !== 'string' || data.last_name.trim().length < 2) { errors.push('Last Name is required and must be at least 2 characters'); } if (!data.email || typeof data.email !== 'string' || !isValidEmail(data.email)) { errors.push('Valid email address is required'); } // Optional field validation if (data.phone && typeof data.phone === 'string' && data.phone.trim()) { const phoneRegex = /^[\+]?[1-9][\d]{0,15}$/; const cleanPhone = data.phone.replace(/\D/g, ''); if (!phoneRegex.test(cleanPhone)) { errors.push('Phone number format is invalid'); } } if (data.membership_status && !['Active', 'Inactive', 'Pending', 'Expired'].includes(data.membership_status)) { errors.push('Invalid membership status'); } return errors; } async function sanitizeMemberData(data: any): Promise> { const sanitized: any = {}; // Generate unique member ID console.log('[api/members.post] Generating member ID for new member...'); sanitized.member_id = await generateMemberID(); console.log('[api/members.post] Generated member ID:', sanitized.member_id); // Required fields sanitized.first_name = data.first_name.trim(); sanitized.last_name = data.last_name.trim(); sanitized.email = data.email.trim().toLowerCase(); // Optional fields if (data.phone) sanitized.phone = data.phone.trim(); if (data.nationality) sanitized.nationality = data.nationality.trim(); if (data.address) sanitized.address = data.address.trim(); if (data.date_of_birth) sanitized.date_of_birth = data.date_of_birth; if (data.member_since) sanitized.member_since = data.member_since; if (data.membership_date_paid) sanitized.membership_date_paid = data.membership_date_paid; if (data.payment_due_date) sanitized.payment_due_date = data.payment_due_date; // Boolean fields sanitized.current_year_dues_paid = Boolean(data.current_year_dues_paid) ? 'true' : 'false'; // Enum fields sanitized.membership_status = data.membership_status || 'Pending'; return sanitized; } function normalizeFieldNames(data: any): any { // Field mapping for display names to snake_case const fieldMap: Record = { 'First Name': 'first_name', 'Last Name': 'last_name', 'Email': 'email', 'Phone': 'phone', 'Date of Birth': 'date_of_birth', 'Nationality': 'nationality', 'Address': 'address', 'Membership Status': 'membership_status', 'Member Since': 'member_since', 'Current Year Dues Paid': 'current_year_dues_paid', 'Membership Date Paid': 'membership_date_paid', 'Payment Due Date': 'payment_due_date' }; const normalized: any = {}; // Map display names to snake_case for (const [key, value] of Object.entries(data)) { const normalizedKey = fieldMap[key] || key; normalized[normalizedKey] = value; } console.log('[api/members.post] Field mapping applied:', { original: Object.keys(data), normalized: Object.keys(normalized) }); return normalized; } function isValidEmail(email: string): boolean { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); } function formatPhoneNumber(phone: string): string { if (!phone) return ''; const cleaned = phone.replace(/\D/g, ''); if (cleaned.length === 10) { return `(${cleaned.substring(0, 3)}) ${cleaned.substring(3, 6)}-${cleaned.substring(6)}`; } else if (cleaned.length === 11 && cleaned.startsWith('1')) { return `+1 (${cleaned.substring(1, 4)}) ${cleaned.substring(4, 7)}-${cleaned.substring(7)}`; } return phone; }