Add member management system with NocoDB integration
All checks were successful
Build And Push Image / docker (push) Successful in 3m5s

- Add member CRUD operations with API endpoints
- Implement member list page with card-based layout
- Add member creation and viewing dialogs
- Support multiple nationalities with country flags
- Include phone number input with international formatting
- Integrate NocoDB as backend database
- Add comprehensive member data types and utilities
This commit is contained in:
2025-08-07 19:20:29 +02:00
parent c84442433f
commit af99ea48e2
21 changed files with 4794 additions and 139 deletions

View File

@@ -0,0 +1,56 @@
import { deleteMember, handleNocoDbError } from '~/server/utils/nocodb';
import { createSessionManager } from '~/server/utils/session';
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id');
console.log('[api/members/[id].delete] =========================');
console.log('[api/members/[id].delete] DELETE /api/members/' + id);
console.log('[api/members/[id].delete] Request from:', getClientIP(event));
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Member ID is required'
});
}
try {
// Validate session and require Admin privileges (delete is more sensitive)
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 !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Administrator privileges required to delete members'
});
}
console.log('[api/members/[id].delete] Authorized user:', session.user.email, 'Tier:', userTier);
// Delete member from NocoDB
const result = await deleteMember(id);
console.log('[api/members/[id].delete] ✅ Member deleted successfully:', id);
return {
success: true,
data: { id },
message: 'Member deleted successfully'
};
} catch (error: any) {
console.error('[api/members/[id].delete] ❌ Error deleting member:', error);
handleNocoDbError(error, 'deleteMember', 'Member');
}
});

View File

@@ -0,0 +1,49 @@
import { getMemberById, handleNocoDbError } from '~/server/utils/nocodb';
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id');
console.log('[api/members/[id].get] =========================');
console.log('[api/members/[id].get] GET /api/members/' + id);
console.log('[api/members/[id].get] Request from:', getClientIP(event));
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Member ID is required'
});
}
try {
const member = await getMemberById(id);
// Add computed fields
const processedMember = {
...member,
FullName: `${member['First Name'] || ''} ${member['Last Name'] || ''}`.trim(),
FormattedPhone: formatPhoneNumber(member.Phone)
};
console.log('[api/members/[id].get] ✅ Successfully retrieved member:', id);
return {
success: true,
data: processedMember
};
} catch (error: any) {
console.error('[api/members/[id].get] ❌ Error fetching member:', error);
handleNocoDbError(error, 'getMemberById', 'Member');
}
});
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;
}

View File

@@ -0,0 +1,164 @@
import { updateMember, handleNocoDbError } from '~/server/utils/nocodb';
import { createSessionManager } from '~/server/utils/session';
import type { Member, MembershipStatus } from '~/utils/types';
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id');
console.log('[api/members/[id].put] =========================');
console.log('[api/members/[id].put] PUT /api/members/' + id);
console.log('[api/members/[id].put] Request from:', getClientIP(event));
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Member ID is required'
});
}
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 update members'
});
}
console.log('[api/members/[id].put] Authorized user:', session.user.email, 'Tier:', userTier);
// Get and validate request body
const body = await readBody(event);
console.log('[api/members/[id].put] Request body fields:', Object.keys(body));
// Validate updated fields
const validationErrors = validateMemberUpdateData(body);
if (validationErrors.length > 0) {
console.error('[api/members/[id].put] Validation errors:', validationErrors);
throw createError({
statusCode: 400,
statusMessage: `Validation failed: ${validationErrors.join(', ')}`
});
}
// Sanitize and prepare data
const memberData = sanitizeMemberUpdateData(body);
console.log('[api/members/[id].put] Sanitized data fields:', Object.keys(memberData));
// Update member in NocoDB
const updatedMember = await updateMember(id, memberData);
console.log('[api/members/[id].put] ✅ Member updated successfully:', id);
// Return processed member
const processedMember = {
...updatedMember,
FullName: `${updatedMember['First Name'] || ''} ${updatedMember['Last Name'] || ''}`.trim(),
FormattedPhone: formatPhoneNumber(updatedMember.Phone)
};
return {
success: true,
data: processedMember,
message: 'Member updated successfully'
};
} catch (error: any) {
console.error('[api/members/[id].put] ❌ Error updating member:', error);
handleNocoDbError(error, 'updateMember', 'Member');
}
});
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) {
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) {
errors.push('Last Name must be at least 2 characters');
}
}
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()) {
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'] !== undefined && !['Active', 'Inactive', 'Pending', 'Expired'].includes(data['Membership Status'])) {
errors.push('Invalid membership status');
}
return errors;
}
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'];
// Boolean fields
if (data['Current Year Dues Paid'] !== undefined) {
sanitized['Current Year Dues Paid'] = Boolean(data['Current Year Dues Paid']);
}
// Enum fields
if (data['Membership Status'] !== undefined) {
sanitized['Membership Status'] = data['Membership Status'];
}
return sanitized;
}
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;
}

View File

@@ -0,0 +1,102 @@
import { getMembers, handleNocoDbError } from '~/server/utils/nocodb';
import type { Member } from '~/utils/types';
export default defineEventHandler(async (event) => {
console.log('[api/members.get] =========================');
console.log('[api/members.get] GET /api/members - List all members');
console.log('[api/members.get] Request from:', getClientIP(event));
try {
// Get query parameters
const query = getQuery(event);
const limit = parseInt(query.limit as string) || 1000;
const searchTerm = query.search as string;
const nationality = query.nationality as string;
const membershipStatus = query.status as string;
const duesPaid = query.duesPaid as string;
console.log('[api/members.get] Query parameters:', {
limit,
searchTerm,
nationality,
membershipStatus,
duesPaid
});
// Fetch members from NocoDB
const result = await getMembers();
let members = result.list || [];
console.log('[api/members.get] Fetched members count:', members.length);
// Apply client-side filtering since NocoDB filtering can be complex
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)
);
console.log('[api/members.get] After search filter:', members.length);
}
if (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);
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);
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)
}));
console.log('[api/members.get] ✅ Successfully processed', processedMembers.length, 'members');
return {
success: true,
data: {
list: processedMembers,
totalCount: processedMembers.length,
filters: {
searchTerm,
nationality,
membershipStatus,
duesPaid: duesPaid ? duesPaid === 'true' : undefined
}
}
};
} catch (error: any) {
console.error('[api/members.get] ❌ Error fetching members:', error);
handleNocoDbError(error, 'getMembers', 'Members');
}
});
// Utility function to format phone numbers
function formatPhoneNumber(phone: string): string {
if (!phone) return '';
// Remove all non-digits
const cleaned = phone.replace(/\D/g, '');
// Format based on length
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; // Return original if we can't format it
}

View File

@@ -0,0 +1,147 @@
import { createMember, handleNocoDbError } from '~/server/utils/nocodb';
import { createSessionManager } from '~/server/utils/session';
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));
// Validate required fields
const validationErrors = validateMemberData(body);
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 = sanitizeMemberData(body);
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;
}
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();
// 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']);
// Enum fields
sanitized['Membership Status'] = data['Membership Status'] || 'Pending';
return sanitized;
}
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;
}