Migrate member fields to snake_case naming convention
Build And Push Image / docker (push) Successful in 3m11s Details

Convert field names from space-separated format to snake_case across
member API endpoints and validation logic. Add migration guide for
reference.
This commit is contained in:
Matt 2025-08-07 21:50:02 +02:00
parent f096a22824
commit d36209818a
7 changed files with 290 additions and 103 deletions

View File

@ -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!**

View File

@ -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);

View File

@ -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<Member> {
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;

View File

@ -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');

View File

@ -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<Member> {
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;
}

View File

@ -189,18 +189,18 @@ export const createMember = async (data: Partial<Member>): Promise<Member> => {
// 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<Member>): Promise<Member> => {
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<Member>, 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<Member>, 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));

View File

@ -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;