Add member management system with NocoDB integration
All checks were successful
Build And Push Image / docker (push) Successful in 3m5s
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:
56
server/api/members/[id].delete.ts
Normal file
56
server/api/members/[id].delete.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
49
server/api/members/[id].get.ts
Normal file
49
server/api/members/[id].get.ts
Normal 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;
|
||||
}
|
||||
164
server/api/members/[id].put.ts
Normal file
164
server/api/members/[id].put.ts
Normal 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;
|
||||
}
|
||||
102
server/api/members/index.get.ts
Normal file
102
server/api/members/index.get.ts
Normal 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
|
||||
}
|
||||
147
server/api/members/index.post.ts
Normal file
147
server/api/members/index.post.ts
Normal 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;
|
||||
}
|
||||
384
server/utils/nocodb.ts
Normal file
384
server/utils/nocodb.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import type { Member, MembershipStatus, MemberResponse, NocoDBSettings } from '~/utils/types';
|
||||
|
||||
// Data normalization functions
|
||||
export const normalizePersonName = (name: string): string => {
|
||||
if (!name) return 'Unknown';
|
||||
|
||||
// Trim whitespace and normalize case
|
||||
return name.trim()
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
// Pagination interface
|
||||
export interface PageInfo {
|
||||
pageSize: number;
|
||||
totalRows: number;
|
||||
isFirstPage: boolean;
|
||||
isLastPage: boolean;
|
||||
page: number;
|
||||
}
|
||||
|
||||
// Response interfaces
|
||||
export interface EntityResponse<T> {
|
||||
list: T[];
|
||||
PageInfo: PageInfo;
|
||||
}
|
||||
|
||||
// Table ID enumeration - Replace with your actual table IDs
|
||||
export enum Table {
|
||||
Members = "members-table-id", // Will be configured via admin panel
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert date from DD-MM-YYYY format to YYYY-MM-DD format for PostgreSQL
|
||||
*/
|
||||
const convertDateFormat = (dateString: string): string => {
|
||||
if (!dateString) return dateString;
|
||||
|
||||
// If it's already in ISO format or contains 'T', return as is
|
||||
if (dateString.includes('T') || dateString.match(/^\d{4}-\d{2}-\d{2}/)) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
// Handle DD-MM-YYYY format
|
||||
const ddmmyyyyMatch = dateString.match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/);
|
||||
if (ddmmyyyyMatch) {
|
||||
const [, day, month, year] = ddmmyyyyMatch;
|
||||
const convertedDate = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
||||
console.log(`[convertDateFormat] Converted ${dateString} to ${convertedDate}`);
|
||||
return convertedDate;
|
||||
}
|
||||
|
||||
// Handle DD/MM/YYYY format
|
||||
const ddmmyyyySlashMatch = dateString.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
if (ddmmyyyySlashMatch) {
|
||||
const [, day, month, year] = ddmmyyyySlashMatch;
|
||||
const convertedDate = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
||||
console.log(`[convertDateFormat] Converted ${dateString} to ${convertedDate}`);
|
||||
return convertedDate;
|
||||
}
|
||||
|
||||
console.warn(`[convertDateFormat] Could not parse date format: ${dateString}`);
|
||||
return dateString;
|
||||
};
|
||||
|
||||
// String data handling functions
|
||||
export const parseStringBoolean = (value: string): boolean => {
|
||||
return value === 'true';
|
||||
};
|
||||
|
||||
export const formatBooleanAsString = (value: boolean): string => {
|
||||
return value ? 'true' : 'false';
|
||||
};
|
||||
|
||||
export const parseNationalities = (nationalityString: string): string[] => {
|
||||
return nationalityString ? nationalityString.split(',').map(n => n.trim()).filter(n => n.length > 0) : [];
|
||||
};
|
||||
|
||||
export const formatNationalitiesAsString = (nationalities: string[]): string => {
|
||||
return nationalities.filter(n => n && n.trim()).join(',');
|
||||
};
|
||||
|
||||
export const getNocoDbConfiguration = () => {
|
||||
const config = useRuntimeConfig().nocodb;
|
||||
// Use the new database URL
|
||||
const updatedConfig = {
|
||||
...config,
|
||||
url: 'https://database.monacousa.org'
|
||||
};
|
||||
console.log('[nocodb] Configuration URL:', updatedConfig.url);
|
||||
return updatedConfig;
|
||||
};
|
||||
|
||||
export const createTableUrl = (table: Table) => {
|
||||
const url = `${getNocoDbConfiguration().url}/api/v2/tables/${table}/records`;
|
||||
console.log('[nocodb] Table URL:', url);
|
||||
return url;
|
||||
};
|
||||
|
||||
// CRUD operations for Members table
|
||||
export const getMembers = async (): Promise<EntityResponse<Member>> => {
|
||||
console.log('[nocodb.getMembers] Fetching all members...');
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await $fetch<EntityResponse<Member>>(createTableUrl(Table.Members), {
|
||||
headers: {
|
||||
"xc-token": getNocoDbConfiguration().token,
|
||||
},
|
||||
params: {
|
||||
limit: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[nocodb.getMembers] Successfully fetched members, count:', result.list?.length || 0);
|
||||
console.log('[nocodb.getMembers] Request duration:', Date.now() - startTime, 'ms');
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('[nocodb.getMembers] Error fetching members:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getMemberById = async (id: string): Promise<Member> => {
|
||||
console.log('[nocodb.getMemberById] Fetching member ID:', id);
|
||||
|
||||
const result = await $fetch<Member>(`${createTableUrl(Table.Members)}/${id}`, {
|
||||
headers: {
|
||||
"xc-token": getNocoDbConfiguration().token,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[nocodb.getMemberById] Successfully retrieved member:', result.Id);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const createMember = async (data: Partial<Member>): Promise<Member> => {
|
||||
console.log('[nocodb.createMember] Creating member with fields:', Object.keys(data));
|
||||
|
||||
// Create a clean data object that matches the member schema
|
||||
const cleanData: Record<string, any> = {};
|
||||
|
||||
// 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"
|
||||
];
|
||||
|
||||
// Filter the data to only include allowed fields
|
||||
for (const field of allowedFields) {
|
||||
if (field in data) {
|
||||
cleanData[field] = (data as any)[field];
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any computed or relation fields that shouldn't be sent
|
||||
delete cleanData.Id;
|
||||
delete cleanData.CreatedAt;
|
||||
delete cleanData.UpdatedAt;
|
||||
delete cleanData.FullName;
|
||||
delete cleanData.FormattedPhone;
|
||||
|
||||
// Fix date formatting for PostgreSQL
|
||||
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['Payment Due Date']) {
|
||||
cleanData['Payment Due Date'] = convertDateFormat(cleanData['Payment Due Date']);
|
||||
}
|
||||
|
||||
console.log('[nocodb.createMember] Clean data fields:', Object.keys(cleanData));
|
||||
const url = createTableUrl(Table.Members);
|
||||
|
||||
try {
|
||||
const result = await $fetch<Member>(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"xc-token": getNocoDbConfiguration().token,
|
||||
},
|
||||
body: cleanData,
|
||||
});
|
||||
console.log('[nocodb.createMember] Created member with ID:', result.Id);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[nocodb.createMember] Create failed:', error);
|
||||
console.error('[nocodb.createMember] Error details:', error instanceof Error ? error.message : 'Unknown error');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateMember = async (id: string, data: Partial<Member>, retryCount = 0): Promise<Member> => {
|
||||
console.log('[nocodb.updateMember] Updating member:', id, 'Retry:', retryCount);
|
||||
console.log('[nocodb.updateMember] Data fields:', Object.keys(data));
|
||||
|
||||
// First, try to verify the record exists
|
||||
if (retryCount === 0) {
|
||||
try {
|
||||
console.log('[nocodb.updateMember] Verifying record exists...');
|
||||
const existingRecord = await getMemberById(id);
|
||||
console.log('[nocodb.updateMember] Record exists with ID:', existingRecord.Id);
|
||||
} catch (verifyError: any) {
|
||||
console.error('[nocodb.updateMember] Failed to verify record:', verifyError);
|
||||
if (verifyError.statusCode === 404 || verifyError.status === 404) {
|
||||
console.error('[nocodb.updateMember] Record verification failed - record not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a clean data object
|
||||
const cleanData: Record<string, any> = {};
|
||||
|
||||
// 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"
|
||||
];
|
||||
|
||||
// Filter the data to only include allowed fields
|
||||
for (const field of allowedFields) {
|
||||
if (field in data) {
|
||||
const value = (data as any)[field];
|
||||
|
||||
// Handle clearing fields - NocoDB requires null for clearing, not undefined
|
||||
if (value === undefined) {
|
||||
cleanData[field] = null;
|
||||
console.log(`[nocodb.updateMember] Converting undefined to null for field: ${field}`);
|
||||
} else {
|
||||
cleanData[field] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix date formatting for PostgreSQL
|
||||
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['Payment Due Date']) {
|
||||
cleanData['Payment Due Date'] = convertDateFormat(cleanData['Payment Due Date']);
|
||||
}
|
||||
|
||||
console.log('[nocodb.updateMember] Clean data fields:', Object.keys(cleanData));
|
||||
|
||||
// PATCH requires ID in the body (not in URL)
|
||||
cleanData.Id = parseInt(id);
|
||||
|
||||
const url = createTableUrl(Table.Members);
|
||||
|
||||
try {
|
||||
console.log('[nocodb.updateMember] Sending PATCH request');
|
||||
|
||||
const result = await $fetch<Member>(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"xc-token": getNocoDbConfiguration().token,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: cleanData
|
||||
});
|
||||
console.log('[nocodb.updateMember] Update successful for ID:', id);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('[nocodb.updateMember] Update failed:', error);
|
||||
console.error('[nocodb.updateMember] Error details:', error instanceof Error ? error.message : 'Unknown error');
|
||||
|
||||
// If it's a 404 error and we haven't retried too many times, wait and retry
|
||||
if ((error.statusCode === 404 || error.status === 404) && retryCount < 3) {
|
||||
console.error('[nocodb.updateMember] 404 Error - Record not found. This might be a sync delay.');
|
||||
console.error(`Retrying in ${(retryCount + 1) * 1000}ms... (Attempt ${retryCount + 1}/3)`);
|
||||
|
||||
// Wait with exponential backoff
|
||||
await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000));
|
||||
|
||||
// Retry the update
|
||||
return updateMember(id, data, retryCount + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteMember = async (id: string) => {
|
||||
const startTime = Date.now();
|
||||
console.log('[nocodb.deleteMember] =========================');
|
||||
console.log('[nocodb.deleteMember] DELETE operation started at:', new Date().toISOString());
|
||||
console.log('[nocodb.deleteMember] Target ID:', id);
|
||||
|
||||
const url = createTableUrl(Table.Members);
|
||||
console.log('[nocodb.deleteMember] URL:', url);
|
||||
|
||||
const requestBody = {
|
||||
"Id": parseInt(id)
|
||||
};
|
||||
|
||||
console.log('[nocodb.deleteMember] Request configuration:');
|
||||
console.log(' Method: DELETE');
|
||||
console.log(' URL:', url);
|
||||
console.log(' Body:', JSON.stringify(requestBody, null, 2));
|
||||
|
||||
try {
|
||||
const result = await $fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"xc-token": getNocoDbConfiguration().token,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: requestBody
|
||||
});
|
||||
|
||||
console.log('[nocodb.deleteMember] DELETE successful');
|
||||
console.log('[nocodb.deleteMember] Duration:', Date.now() - startTime, 'ms');
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('[nocodb.deleteMember] DELETE FAILED');
|
||||
console.error('[nocodb.deleteMember] Error type:', error.constructor.name);
|
||||
console.error('[nocodb.deleteMember] Error message:', error.message);
|
||||
console.error('[nocodb.deleteMember] Duration:', Date.now() - startTime, 'ms');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Centralized error handling
|
||||
export const handleNocoDbError = (error: any, operation: string, entityType: string = 'Member') => {
|
||||
console.error(`[nocodb.${operation}] =========================`);
|
||||
console.error(`[nocodb.${operation}] ERROR in ${operation} for ${entityType}`);
|
||||
console.error(`[nocodb.${operation}] Error type:`, error.constructor?.name || 'Unknown');
|
||||
console.error(`[nocodb.${operation}] Error status:`, error.statusCode || error.status || 'Unknown');
|
||||
console.error(`[nocodb.${operation}] Error message:`, error.message || 'Unknown error');
|
||||
console.error(`[nocodb.${operation}] Error data:`, error.data);
|
||||
console.error(`[nocodb.${operation}] =========================`);
|
||||
|
||||
// Provide more specific error messages
|
||||
if (error.statusCode === 401 || error.status === 401) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: `Authentication failed when accessing ${entityType}. Please check your access permissions.`
|
||||
});
|
||||
} else if (error.statusCode === 403 || error.status === 403) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: `Access denied to ${entityType}. This feature requires appropriate privileges.`
|
||||
});
|
||||
} else if (error.statusCode === 404 || error.status === 404) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: `${entityType} not found. Please verify the record exists.`
|
||||
});
|
||||
} else if (error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT') {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: `${entityType} database is temporarily unavailable. Please try again in a moment.`
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
Reference in New Issue
Block a user