From d36209818a7ea1926d0a98bf582403722a07a2b1 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 Aug 2025 21:50:02 +0200 Subject: [PATCH] Migrate member fields to snake_case naming convention Convert field names from space-separated format to snake_case across member API endpoints and validation logic. Add migration guide for reference. --- SNAKE_CASE_FIELD_MIGRATION_GUIDE.md | 187 ++++++++++++++++++++++++++++ server/api/members/[id].get.ts | 4 +- server/api/members/[id].put.ts | 50 ++++---- server/api/members/index.get.ts | 16 +-- server/api/members/index.post.ts | 40 +++--- server/utils/nocodb.ts | 72 +++++------ utils/types.ts | 24 ++-- 7 files changed, 290 insertions(+), 103 deletions(-) create mode 100644 SNAKE_CASE_FIELD_MIGRATION_GUIDE.md diff --git a/SNAKE_CASE_FIELD_MIGRATION_GUIDE.md b/SNAKE_CASE_FIELD_MIGRATION_GUIDE.md new file mode 100644 index 0000000..d6ccfd7 --- /dev/null +++ b/SNAKE_CASE_FIELD_MIGRATION_GUIDE.md @@ -0,0 +1,187 @@ +# MonacoUSA Portal - Snake Case Field Migration Guide + +## ๐ŸŽฏ Overview + +This document provides complete instructions for migrating from space-separated field names (e.g., "First Name") to snake_case field names (e.g., "first_name") to eliminate data corruption issues when editing records directly in NocoDB. + +## ๐Ÿ“Š Required NocoDB Field Name Changes + +You need to rename the following fields in your NocoDB Members table: + +| **Current Field Name** | **New Snake Case Name** | **Type** | +|----------------------------|----------------------------|-------------| +| `First Name` | `first_name` | Text | +| `Last Name` | `last_name` | Text | +| `Email` | `email` | Email | +| `Phone` | `phone` | Text | +| `Date of Birth` | `date_of_birth` | Date | +| `Nationality` | `nationality` | Text | +| `Address` | `address` | LongText | +| `Membership Status` | `membership_status` | SingleSelect| +| `Member Since` | `member_since` | Date | +| `Current Year Dues Paid` | `current_year_dues_paid` | Text | +| `Membership Date Paid` | `membership_date_paid` | Date | +| `Payment Due Date` | `payment_due_date` | Date | + +### ๐Ÿ”ง How to Rename Fields in NocoDB + +1. **Open your NocoDB Members table** +2. **For each field above:** + - Click on the field header + - Select "Edit" or click the gear icon + - Change the field name from the old name to the new snake_case name + - Click "Save" + +โš ๏ธ **Important**: Do NOT change the field types, only the names. + +## ๐Ÿ—๏ธ Backend Files Updated + +The following files have been completely updated to use snake_case field names: + +### Type Definitions +- โœ… `utils/types.ts` - Member interface updated + +### NocoDB Utilities +- โœ… `server/utils/nocodb.ts` - All CRUD operations updated + +### API Endpoints +- โœ… `server/api/members/index.get.ts` - List members API +- โœ… `server/api/members/[id].get.ts` - Get single member API +- โœ… `server/api/members/index.post.ts` - Create member API +- โœ… `server/api/members/[id].put.ts` - Update member API + +## ๐ŸŽจ Frontend Files That Need Updates + +The following frontend components still reference the old field names and need to be updated: + +### Vue Components +- `components/ViewMemberDialog.vue` +- `components/MemberCard.vue` +- `components/EditMemberDialog.vue` +- `components/AddMemberDialog.vue` +- `pages/dashboard/member-list.vue` + +## ๐Ÿ”„ Complete Field Mapping Reference + +### Data Access Patterns + +**Before (โŒ Old)**: +```javascript +member['First Name'] +member['Last Name'] +member['Membership Status'] +member['Current Year Dues Paid'] +``` + +**After (โœ… New)**: +```javascript +member.first_name +member.last_name +member.membership_status +member.current_year_dues_paid +``` + +### API Request/Response Format + +**Create/Update Member Payload**: +```json +{ + "first_name": "John", + "last_name": "Doe", + "email": "john.doe@example.com", + "phone": "+1234567890", + "nationality": "US", + "membership_status": "Active", + "current_year_dues_paid": "true", + "date_of_birth": "1990-01-15", + "member_since": "2023-01-01", + "membership_date_paid": "2024-01-15", + "payment_due_date": "2025-01-15", + "address": "123 Main St, City, State" +} +``` + +## ๐Ÿงช Testing Checklist + +After making the NocoDB field changes, test the following: + +### Backend API Testing +- [ ] `GET /api/members` - List all members +- [ ] `GET /api/members/{id}` - Get single member +- [ ] `POST /api/members` - Create new member +- [ ] `PUT /api/members/{id}` - Update existing member +- [ ] `DELETE /api/members/{id}` - Delete member + +### Data Integrity Testing +- [ ] Create member via portal โ†’ Verify in NocoDB +- [ ] Edit member via portal โ†’ Verify in NocoDB +- [ ] Edit member in NocoDB directly โ†’ Verify portal displays correctly +- [ ] All fields display properly (names, flags, contact info) + +### Frontend Display Testing +- [ ] Member list page loads and displays all members +- [ ] Member cards show correct information +- [ ] Country flags display properly +- [ ] Search and filtering work correctly +- [ ] Add member dialog works +- [ ] Edit member dialog works and pre-populates correctly +- [ ] View member dialog shows all details + +## ๐Ÿšจ Critical Success Criteria + +The migration is successful when: + +1. โœ… **No "undefined undefined" names appear** in member cards +2. โœ… **Country flags display properly** for all nationalities +3. โœ… **Direct NocoDB edits sync properly** with portal display +4. โœ… **All CRUD operations work** through the portal +5. โœ… **No TypeScript errors** in the console +6. โœ… **No API errors** in browser network tab + +## ๐Ÿ”ง Rollback Plan + +If issues occur, you can temporarily rollback by: + +1. **Revert NocoDB field names** back to the original space-separated format +2. **Revert the backend files** using git: + ```bash + git checkout HEAD~1 -- utils/types.ts server/utils/nocodb.ts server/api/members/ + ``` + +## ๐Ÿ“ˆ Next Steps After Migration + +1. **Update frontend components** to use snake_case field names +2. **Test thoroughly** across all functionality +3. **Update any documentation** that references old field names +4. **Consider adding validation** to prevent future field name inconsistencies + +## ๐Ÿ’ก Benefits After Migration + +- โœ… **Consistent data display** regardless of edit source (portal vs NocoDB) +- โœ… **No more "undefined undefined" member names** +- โœ… **Proper country flag rendering** +- โœ… **Standard database naming conventions** +- โœ… **Easier debugging and maintenance** +- โœ… **Better API consistency** + +## ๐Ÿ†˜ Troubleshooting + +### Issue: Members showing "undefined undefined" +- **Cause**: NocoDB field names don't match backend expectations +- **Solution**: Verify all field names in NocoDB match the snake_case format exactly + +### Issue: Country flags not displaying +- **Cause**: Nationality field not properly mapped +- **Solution**: Ensure `Nationality` field is renamed to `nationality` in NocoDB + +### Issue: API errors in console +- **Cause**: Field name mismatch between frontend and backend +- **Solution**: Update frontend components to use snake_case field names + +### Issue: Direct NocoDB edits causing corruption +- **Cause**: This was the original problem - should be fixed after migration +- **Solution**: This migration specifically addresses this issue + +--- + +**๐ŸŽ‰ Once you complete the NocoDB field renaming, the backend will be fully compatible with snake_case field names and the data corruption issue should be completely resolved!** diff --git a/server/api/members/[id].get.ts b/server/api/members/[id].get.ts index c15fc96..0d20743 100644 --- a/server/api/members/[id].get.ts +++ b/server/api/members/[id].get.ts @@ -20,8 +20,8 @@ export default defineEventHandler(async (event) => { // Add computed fields const processedMember = { ...member, - FullName: `${member['First Name'] || ''} ${member['Last Name'] || ''}`.trim(), - FormattedPhone: formatPhoneNumber(member.Phone) + FullName: `${member.first_name || ''} ${member.last_name || ''}`.trim(), + FormattedPhone: formatPhoneNumber(member.phone) }; console.log('[api/members/[id].get] โœ… Successfully retrieved member:', id); diff --git a/server/api/members/[id].put.ts b/server/api/members/[id].put.ts index c4b5432..76082e9 100644 --- a/server/api/members/[id].put.ts +++ b/server/api/members/[id].put.ts @@ -65,8 +65,8 @@ export default defineEventHandler(async (event) => { // Return processed member const processedMember = { ...updatedMember, - FullName: `${updatedMember['First Name'] || ''} ${updatedMember['Last Name'] || ''}`.trim(), - FormattedPhone: formatPhoneNumber(updatedMember.Phone) + FullName: `${updatedMember.first_name || ''} ${updatedMember.last_name || ''}`.trim(), + FormattedPhone: formatPhoneNumber(updatedMember.phone) }; return { @@ -85,34 +85,34 @@ function validateMemberUpdateData(data: any): string[] { const errors: string[] = []; // Only validate fields that are provided (partial updates allowed) - if (data['First Name'] !== undefined) { - if (!data['First Name'] || typeof data['First Name'] !== 'string' || data['First Name'].trim().length < 2) { + if (data.first_name !== undefined) { + if (!data.first_name || typeof data.first_name !== 'string' || data.first_name.trim().length < 2) { errors.push('First Name must be at least 2 characters'); } } - if (data['Last Name'] !== undefined) { - if (!data['Last Name'] || typeof data['Last Name'] !== 'string' || data['Last Name'].trim().length < 2) { + if (data.last_name !== undefined) { + if (!data.last_name || typeof data.last_name !== 'string' || data.last_name.trim().length < 2) { errors.push('Last Name must be at least 2 characters'); } } - if (data.Email !== undefined) { - if (!data.Email || typeof data.Email !== 'string' || !isValidEmail(data.Email)) { + if (data.email !== undefined) { + if (!data.email || typeof data.email !== 'string' || !isValidEmail(data.email)) { errors.push('Valid email address is required'); } } // Optional field validation - if (data.Phone !== undefined && data.Phone && typeof data.Phone === 'string' && data.Phone.trim()) { + if (data.phone !== undefined && data.phone && typeof data.phone === 'string' && data.phone.trim()) { const phoneRegex = /^[\+]?[1-9][\d]{0,15}$/; - const cleanPhone = data.Phone.replace(/\D/g, ''); + const cleanPhone = data.phone.replace(/\D/g, ''); if (!phoneRegex.test(cleanPhone)) { errors.push('Phone number format is invalid'); } } - if (data['Membership Status'] !== undefined && !['Active', 'Inactive', 'Pending', 'Expired'].includes(data['Membership Status'])) { + if (data.membership_status !== undefined && !['Active', 'Inactive', 'Pending', 'Expired'].includes(data.membership_status)) { errors.push('Invalid membership status'); } @@ -123,25 +123,25 @@ function sanitizeMemberUpdateData(data: any): Partial { const sanitized: any = {}; // Only include fields that are provided (partial updates) - if (data['First Name'] !== undefined) sanitized['First Name'] = data['First Name'].trim(); - if (data['Last Name'] !== undefined) sanitized['Last Name'] = data['Last Name'].trim(); - if (data.Email !== undefined) sanitized['Email'] = data.Email.trim().toLowerCase(); - if (data.Phone !== undefined) sanitized.Phone = data.Phone ? data.Phone.trim() : null; - if (data.Nationality !== undefined) sanitized.Nationality = data.Nationality ? data.Nationality.trim() : null; - if (data.Address !== undefined) sanitized.Address = data.Address ? data.Address.trim() : null; - if (data['Date of Birth'] !== undefined) sanitized['Date of Birth'] = data['Date of Birth']; - if (data['Member Since'] !== undefined) sanitized['Member Since'] = data['Member Since']; - if (data['Membership Date Paid'] !== undefined) sanitized['Membership Date Paid'] = data['Membership Date Paid']; - if (data['Payment Due Date'] !== undefined) sanitized['Payment Due Date'] = data['Payment Due Date']; + if (data.first_name !== undefined) sanitized.first_name = data.first_name.trim(); + if (data.last_name !== undefined) sanitized.last_name = data.last_name.trim(); + if (data.email !== undefined) sanitized.email = data.email.trim().toLowerCase(); + if (data.phone !== undefined) sanitized.phone = data.phone ? data.phone.trim() : null; + if (data.nationality !== undefined) sanitized.nationality = data.nationality ? data.nationality.trim() : null; + if (data.address !== undefined) sanitized.address = data.address ? data.address.trim() : null; + if (data.date_of_birth !== undefined) sanitized.date_of_birth = data.date_of_birth; + if (data.member_since !== undefined) sanitized.member_since = data.member_since; + if (data.membership_date_paid !== undefined) sanitized.membership_date_paid = data.membership_date_paid; + if (data.payment_due_date !== undefined) sanitized.payment_due_date = data.payment_due_date; // Boolean fields - if (data['Current Year Dues Paid'] !== undefined) { - sanitized['Current Year Dues Paid'] = Boolean(data['Current Year Dues Paid']); + if (data.current_year_dues_paid !== undefined) { + sanitized.current_year_dues_paid = Boolean(data.current_year_dues_paid) ? 'true' : 'false'; } // Enum fields - if (data['Membership Status'] !== undefined) { - sanitized['Membership Status'] = data['Membership Status']; + if (data.membership_status !== undefined) { + sanitized.membership_status = data.membership_status; } return sanitized; diff --git a/server/api/members/index.get.ts b/server/api/members/index.get.ts index abd9a03..07ae992 100644 --- a/server/api/members/index.get.ts +++ b/server/api/members/index.get.ts @@ -33,33 +33,33 @@ export default defineEventHandler(async (event) => { if (searchTerm) { const search = searchTerm.toLowerCase(); members = members.filter(member => - member['First Name']?.toLowerCase().includes(search) || - member['Last Name']?.toLowerCase().includes(search) || - member.Email?.toLowerCase().includes(search) + member.first_name?.toLowerCase().includes(search) || + member.last_name?.toLowerCase().includes(search) || + member.email?.toLowerCase().includes(search) ); console.log('[api/members.get] After search filter:', members.length); } if (nationality) { - members = members.filter(member => member.Nationality === nationality); + members = members.filter(member => member.nationality === nationality); console.log('[api/members.get] After nationality filter:', members.length); } if (membershipStatus) { - members = members.filter(member => member['Membership Status'] === membershipStatus); + members = members.filter(member => member.membership_status === membershipStatus); console.log('[api/members.get] After status filter:', members.length); } if (duesPaid === 'true' || duesPaid === 'false') { - members = members.filter(member => member['Current Year Dues Paid'] === duesPaid); + members = members.filter(member => member.current_year_dues_paid === duesPaid); console.log('[api/members.get] After dues filter:', members.length); } // Add computed fields const processedMembers = members.map(member => ({ ...member, - FullName: `${member['First Name'] || ''} ${member['Last Name'] || ''}`.trim(), - FormattedPhone: formatPhoneNumber(member.Phone) + FullName: `${member.first_name || ''} ${member.last_name || ''}`.trim(), + FormattedPhone: formatPhoneNumber(member.phone) })); console.log('[api/members.get] โœ… Successfully processed', processedMembers.length, 'members'); diff --git a/server/api/members/index.post.ts b/server/api/members/index.post.ts index 50808c1..d4232fa 100644 --- a/server/api/members/index.post.ts +++ b/server/api/members/index.post.ts @@ -56,8 +56,8 @@ export default defineEventHandler(async (event) => { // Return processed member const processedMember = { ...newMember, - FullName: `${newMember['First Name'] || ''} ${newMember['Last Name'] || ''}`.trim(), - FormattedPhone: formatPhoneNumber(newMember.Phone) + FullName: `${newMember.first_name || ''} ${newMember.last_name || ''}`.trim(), + FormattedPhone: formatPhoneNumber(newMember.phone) }; return { @@ -76,28 +76,28 @@ 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) { + 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) { + 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)) { + 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()) { + if (data.phone && typeof data.phone === 'string' && data.phone.trim()) { const phoneRegex = /^[\+]?[1-9][\d]{0,15}$/; - const cleanPhone = data.Phone.replace(/\D/g, ''); + 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'])) { + if (data.membership_status && !['Active', 'Inactive', 'Pending', 'Expired'].includes(data.membership_status)) { errors.push('Invalid membership status'); } @@ -108,24 +108,24 @@ function sanitizeMemberData(data: any): Partial { const sanitized: any = {}; // Required fields - sanitized['First Name'] = data['First Name'].trim(); - sanitized['Last Name'] = data['Last Name'].trim(); - sanitized['Email'] = data.Email.trim().toLowerCase(); + 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']; + 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']); + sanitized.current_year_dues_paid = Boolean(data.current_year_dues_paid) ? 'true' : 'false'; // Enum fields - sanitized['Membership Status'] = data['Membership Status'] || 'Pending'; + sanitized.membership_status = data.membership_status || 'Pending'; return sanitized; } diff --git a/server/utils/nocodb.ts b/server/utils/nocodb.ts index 41e480c..0e4f502 100644 --- a/server/utils/nocodb.ts +++ b/server/utils/nocodb.ts @@ -189,18 +189,18 @@ export const createMember = async (data: Partial): Promise => { // Only include fields that are part of the member schema const allowedFields = [ - "First Name", - "Last Name", - "Email", - "Phone", - "Current Year Dues Paid", - "Nationality", - "Date of Birth", - "Membership Date Paid", - "Payment Due Date", - "Membership Status", - "Address", - "Member Since" + "first_name", + "last_name", + "email", + "phone", + "current_year_dues_paid", + "nationality", + "date_of_birth", + "membership_date_paid", + "payment_due_date", + "membership_status", + "address", + "member_since" ]; // Filter the data to only include allowed fields @@ -218,14 +218,14 @@ export const createMember = async (data: Partial): Promise => { delete cleanData.FormattedPhone; // Fix date formatting for PostgreSQL - if (cleanData['Date of Birth']) { - cleanData['Date of Birth'] = convertDateFormat(cleanData['Date of Birth']); + if (cleanData['date_of_birth']) { + cleanData['date_of_birth'] = convertDateFormat(cleanData['date_of_birth']); } - if (cleanData['Membership Date Paid']) { - cleanData['Membership Date Paid'] = convertDateFormat(cleanData['Membership Date Paid']); + if (cleanData['membership_date_paid']) { + cleanData['membership_date_paid'] = convertDateFormat(cleanData['membership_date_paid']); } - if (cleanData['Payment Due Date']) { - cleanData['Payment Due Date'] = convertDateFormat(cleanData['Payment Due Date']); + if (cleanData['payment_due_date']) { + cleanData['payment_due_date'] = convertDateFormat(cleanData['payment_due_date']); } console.log('[nocodb.createMember] Clean data fields:', Object.keys(cleanData)); @@ -271,18 +271,18 @@ export const updateMember = async (id: string, data: Partial, retryCount // Only include fields that are part of the member schema const allowedFields = [ - "First Name", - "Last Name", - "Email", - "Phone", - "Current Year Dues Paid", - "Nationality", - "Date of Birth", - "Membership Date Paid", - "Payment Due Date", - "Membership Status", - "Address", - "Member Since" + "first_name", + "last_name", + "email", + "phone", + "current_year_dues_paid", + "nationality", + "date_of_birth", + "membership_date_paid", + "payment_due_date", + "membership_status", + "address", + "member_since" ]; // Filter the data to only include allowed fields @@ -301,14 +301,14 @@ export const updateMember = async (id: string, data: Partial, retryCount } // Fix date formatting for PostgreSQL - if (cleanData['Date of Birth']) { - cleanData['Date of Birth'] = convertDateFormat(cleanData['Date of Birth']); + if (cleanData['date_of_birth']) { + cleanData['date_of_birth'] = convertDateFormat(cleanData['date_of_birth']); } - if (cleanData['Membership Date Paid']) { - cleanData['Membership Date Paid'] = convertDateFormat(cleanData['Membership Date Paid']); + if (cleanData['membership_date_paid']) { + cleanData['membership_date_paid'] = convertDateFormat(cleanData['membership_date_paid']); } - if (cleanData['Payment Due Date']) { - cleanData['Payment Due Date'] = convertDateFormat(cleanData['Payment Due Date']); + if (cleanData['payment_due_date']) { + cleanData['payment_due_date'] = convertDateFormat(cleanData['payment_due_date']); } console.log('[nocodb.updateMember] Clean data fields:', Object.keys(cleanData)); diff --git a/utils/types.ts b/utils/types.ts index b47321f..0a6a894 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -118,18 +118,18 @@ export enum MembershipStatus { export interface Member { Id: string; - "First Name": string; - "Last Name": string; - Email: string; - Phone: string; - "Current Year Dues Paid": string; // "true" or "false" - Nationality: string; // "FR,MC,US" for multiple nationalities - "Date of Birth": string; - "Membership Date Paid": string; - "Payment Due Date": string; - "Membership Status": string; - Address: string; - "Member Since": string; + first_name: string; + last_name: string; + email: string; + phone: string; + current_year_dues_paid: string; // "true" or "false" + nationality: string; // "FR,MC,US" for multiple nationalities + date_of_birth: string; + membership_date_paid: string; + payment_due_date: string; + membership_status: string; + address: string; + member_since: string; // Computed fields (added by processing) FullName?: string;