From af99ea48e20070aa8f220a8cb29a15de32fab5d0 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 Aug 2025 19:20:29 +0200 Subject: [PATCH] Add member management system with NocoDB integration - 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 --- components/AddMemberDialog.vue | 456 ++++++++ components/CountryFlag.vue | 92 ++ components/MemberCard.vue | 317 ++++++ components/MultipleNationalityInput.vue | 299 ++++++ components/PhoneInputWrapper.vue | 226 ++++ components/ViewMemberDialog.vue | 324 ++++++ nocodb-implementation-guide.md | 1269 +++++++++++++++++++++++ nuxt.config.ts | 3 + package-lock.json | 25 + package.json | 2 + pages/dashboard/admin.vue | 100 +- pages/dashboard/member-list.vue | 466 +++++++++ pages/login.vue | 44 +- server/api/members/[id].delete.ts | 56 + server/api/members/[id].get.ts | 49 + server/api/members/[id].put.ts | 164 +++ server/api/members/index.get.ts | 102 ++ server/api/members/index.post.ts | 147 +++ server/utils/nocodb.ts | 384 +++++++ utils/countries.ts | 352 +++++++ utils/types.ts | 56 + 21 files changed, 4794 insertions(+), 139 deletions(-) create mode 100644 components/AddMemberDialog.vue create mode 100644 components/CountryFlag.vue create mode 100644 components/MemberCard.vue create mode 100644 components/MultipleNationalityInput.vue create mode 100644 components/PhoneInputWrapper.vue create mode 100644 components/ViewMemberDialog.vue create mode 100644 nocodb-implementation-guide.md create mode 100644 pages/dashboard/member-list.vue create mode 100644 server/api/members/[id].delete.ts create mode 100644 server/api/members/[id].get.ts create mode 100644 server/api/members/[id].put.ts create mode 100644 server/api/members/index.get.ts create mode 100644 server/api/members/index.post.ts create mode 100644 server/utils/nocodb.ts create mode 100644 utils/countries.ts diff --git a/components/AddMemberDialog.vue b/components/AddMemberDialog.vue new file mode 100644 index 0000000..3374d26 --- /dev/null +++ b/components/AddMemberDialog.vue @@ -0,0 +1,456 @@ + + + + + diff --git a/components/CountryFlag.vue b/components/CountryFlag.vue new file mode 100644 index 0000000..b27beee --- /dev/null +++ b/components/CountryFlag.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/components/MemberCard.vue b/components/MemberCard.vue new file mode 100644 index 0000000..59d7109 --- /dev/null +++ b/components/MemberCard.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/components/MultipleNationalityInput.vue b/components/MultipleNationalityInput.vue new file mode 100644 index 0000000..bd3d937 --- /dev/null +++ b/components/MultipleNationalityInput.vue @@ -0,0 +1,299 @@ + + + + + diff --git a/components/PhoneInputWrapper.vue b/components/PhoneInputWrapper.vue new file mode 100644 index 0000000..a085f88 --- /dev/null +++ b/components/PhoneInputWrapper.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/components/ViewMemberDialog.vue b/components/ViewMemberDialog.vue new file mode 100644 index 0000000..a8edbc4 --- /dev/null +++ b/components/ViewMemberDialog.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/nocodb-implementation-guide.md b/nocodb-implementation-guide.md new file mode 100644 index 0000000..c78fe8d --- /dev/null +++ b/nocodb-implementation-guide.md @@ -0,0 +1,1269 @@ +# Comprehensive NocoDB Integration Implementation Guide + +This guide provides a complete implementation reference for integrating NocoDB with a Nuxt.js application, based on production patterns and best practices. + +## Table of Contents + +1. [Configuration & Setup](#configuration--setup) +2. [Core Utility Layer](#core-utility-layer) +3. [CRUD Operations Patterns](#crud-operations-patterns) +4. [Advanced Features](#advanced-features) +5. [API Endpoint Patterns](#api-endpoint-patterns) +6. [TypeScript Integration](#typescript-integration) +7. [Best Practices](#best-practices) +8. [Real-World Examples](#real-world-examples) + +## Configuration & Setup + +### Environment Variables + +Create the following environment variables in your `.env` file: + +```env +NUXT_NOCODB_URL=https://your-nocodb-instance.com +NUXT_NOCODB_TOKEN=your-api-token-here +``` + +### Nuxt Configuration + +Configure NocoDB in your `nuxt.config.ts`: + +```typescript +export default defineNuxtConfig({ + runtimeConfig: { + nocodb: { + url: process.env.NUXT_NOCODB_URL || "", + token: process.env.NUXT_NOCODB_TOKEN || "", + }, + // Other runtime config... + }, +}) +``` + +## Core Utility Layer + +### Base Configuration Functions + +Create `server/utils/nocodb.ts` with the following foundation: + +```typescript +import type { YourEntityType, YourEntityResponse, ExpenseFilters } 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 { + list: T[]; + PageInfo: PageInfo; +} + +// Table ID enumeration - Replace with your actual table IDs +export enum Table { + YourEntity = "your-table-id-here", + AnotherEntity = "another-table-id-here", + // Add all your table IDs +} + +/** + * 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; +}; + +export const getNocoDbConfiguration = () => { + const config = useRuntimeConfig().nocodb; + console.log('[nocodb] Configuration URL:', config.url); + return config; +}; + +export const createTableUrl = (table: Table) => { + const url = `${getNocoDbConfiguration().url}/api/v2/tables/${table}/records`; + console.log('[nocodb] Table URL:', url); + return url; +}; +``` + +## CRUD Operations Patterns + +### READ Operations + +#### Basic List Fetching + +```typescript +export const getEntities = async () => + $fetch>(createTableUrl(Table.YourEntity), { + headers: { + "xc-token": getNocoDbConfiguration().token, + }, + params: { + limit: 1000, + }, + }); +``` + +#### Single Record Retrieval + +```typescript +export const getEntityById = async (id: string) => { + console.log('[nocodb.getEntityById] Fetching entity ID:', id); + + const result = await $fetch(`${createTableUrl(Table.YourEntity)}/${id}`, { + headers: { + "xc-token": getNocoDbConfiguration().token, + }, + }); + + console.log('[nocodb.getEntityById] Successfully retrieved entity:', result.Id); + return result; +}; +``` + +#### Advanced Filtering with Dynamic Parameters + +```typescript +const buildEntityParams = (filters?: YourFiltersType) => { + const params: any = { limit: 1000 }; + + // Build filter conditions + if (filters?.startDate && filters?.endDate) { + const startDate = filters.startDate.includes('T') ? filters.startDate.split('T')[0] : filters.startDate; + const endDate = filters.endDate.includes('T') ? filters.endDate.split('T')[0] : filters.endDate; + params.where = `(DateField,gte,${startDate})~and(DateField,lte,${endDate})`; + } else if (filters?.startDate) { + const startDate = filters.startDate.includes('T') ? filters.startDate.split('T')[0] : filters.startDate; + params.where = `(DateField,gte,${startDate})`; + } else if (filters?.endDate) { + const endDate = filters.endDate.includes('T') ? filters.endDate.split('T')[0] : filters.endDate; + params.where = `(DateField,lte,${endDate})`; + } + + // Add additional filters + if (filters?.category) { + const categoryFilter = `(Category,eq,${encodeURIComponent(filters.category)})`; + params.where = params.where ? `${params.where}~and${categoryFilter}` : categoryFilter; + } + + // Sort by date descending (newest first) + params.sort = '-DateField'; + + return params; +}; + +export const getEntitiesFiltered = async (filters?: YourFiltersType) => { + console.log('[nocodb.getEntitiesFiltered] Fetching with filters...', filters); + + const startTime = Date.now(); + + try { + const result = await $fetch>(createTableUrl(Table.YourEntity), { + headers: { "xc-token": getNocoDbConfiguration().token }, + params: buildEntityParams(filters) + }); + + console.log('[nocodb.getEntitiesFiltered] Successfully fetched entities, count:', result.list?.length || 0); + console.log('[nocodb.getEntitiesFiltered] Request duration:', Date.now() - startTime, 'ms'); + + return result; + } catch (error: any) { + console.error('[nocodb.getEntitiesFiltered] Error fetching entities:', error); + throw error; + } +}; +``` + +### CREATE Operations + +```typescript +export const createEntity = async (data: Partial) => { + console.log('[nocodb.createEntity] Creating entity with fields:', Object.keys(data)); + + // Create a clean data object that matches the entity schema + const cleanData: Record = {}; + + // Only include fields that are part of the entity schema + const allowedFields = [ + "Field1", + "Field2", + "Field3", + // Add your actual field names here + ]; + + // 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; + + // Fix date formatting for PostgreSQL + if (cleanData['DateField']) { + cleanData['DateField'] = convertDateFormat(cleanData['DateField']); + } + + console.log('[nocodb.createEntity] Clean data fields:', Object.keys(cleanData)); + const url = createTableUrl(Table.YourEntity); + + try { + const result = await $fetch(url, { + method: "POST", + headers: { + "xc-token": getNocoDbConfiguration().token, + }, + body: cleanData, + }); + console.log('[nocodb.createEntity] Created entity with ID:', result.Id); + return result; + } catch (error) { + console.error('[nocodb.createEntity] Create failed:', error); + console.error('[nocodb.createEntity] Error details:', error instanceof Error ? error.message : 'Unknown error'); + throw error; + } +}; +``` + +### UPDATE Operations with Retry Logic + +```typescript +export const updateEntity = async (id: string, data: Partial, retryCount = 0): Promise => { + console.log('[nocodb.updateEntity] Updating entity:', id, 'Retry:', retryCount); + console.log('[nocodb.updateEntity] Data fields:', Object.keys(data)); + + // First, try to verify the record exists + if (retryCount === 0) { + try { + console.log('[nocodb.updateEntity] Verifying record exists...'); + const existingRecord = await getEntityById(id); + console.log('[nocodb.updateEntity] Record exists with ID:', existingRecord.Id); + } catch (verifyError: any) { + console.error('[nocodb.updateEntity] Failed to verify record:', verifyError); + if (verifyError.statusCode === 404 || verifyError.status === 404) { + console.error('[nocodb.updateEntity] Record verification failed - record not found'); + } + } + } + + // Create a clean data object + const cleanData: Record = {}; + + // Only include fields that are part of the entity schema + const allowedFields = [ + "Field1", + "Field2", + "Field3", + // Add your actual field names here + ]; + + // 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.updateEntity] Converting undefined to null for field: ${field}`); + } else { + cleanData[field] = value; + } + } + } + + // Fix date formatting for PostgreSQL + if (cleanData['DateField']) { + cleanData['DateField'] = convertDateFormat(cleanData['DateField']); + } + + console.log('[nocodb.updateEntity] Clean data fields:', Object.keys(cleanData)); + + // PATCH requires ID in the body (not in URL) + cleanData.Id = parseInt(id); + + const url = createTableUrl(Table.YourEntity); + + try { + console.log('[nocodb.updateEntity] Sending PATCH request'); + + const result = await $fetch(url, { + method: "PATCH", + headers: { + "xc-token": getNocoDbConfiguration().token, + "Content-Type": "application/json" + }, + body: cleanData + }); + console.log('[nocodb.updateEntity] Update successful for ID:', id); + return result; + } catch (error: any) { + console.error('[nocodb.updateEntity] Update failed:', error); + console.error('[nocodb.updateEntity] 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.updateEntity] 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 updateEntity(id, data, retryCount + 1); + } + + throw error; + } +}; +``` + +### DELETE Operations + +```typescript +export const deleteEntity = async (id: string) => { + const startTime = Date.now(); + console.log('[nocodb.deleteEntity] ========================='); + console.log('[nocodb.deleteEntity] DELETE operation started at:', new Date().toISOString()); + console.log('[nocodb.deleteEntity] Target ID:', id); + + const url = createTableUrl(Table.YourEntity); + console.log('[nocodb.deleteEntity] URL:', url); + + const requestBody = { + "Id": parseInt(id) + }; + + console.log('[nocodb.deleteEntity] 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.deleteEntity] DELETE successful'); + console.log('[nocodb.deleteEntity] Duration:', Date.now() - startTime, 'ms'); + + return result; + } catch (error: any) { + console.error('[nocodb.deleteEntity] DELETE FAILED'); + console.error('[nocodb.deleteEntity] Error type:', error.constructor.name); + console.error('[nocodb.deleteEntity] Error message:', error.message); + console.error('[nocodb.deleteEntity] Duration:', Date.now() - startTime, 'ms'); + throw error; + } +}; +``` + +## Advanced Features + +### Relationship Management + +#### Fetching Linked Records + +```typescript +export const getEntityWithRelations = async (id: string) => { + console.log('[nocodb.getEntityWithRelations] Fetching entity with relations:', id); + + try { + // First fetch the basic entity data + const result = await $fetch(`${createTableUrl(Table.YourEntity)}/${id}`, { + headers: { + "xc-token": getNocoDbConfiguration().token, + }, + params: { + fields: '*' + } + }); + + console.log('[nocodb.getEntityWithRelations] Successfully fetched entity:', result.Id); + + // Now fetch and populate the related records + if (result['RelatedField']) { + // Handle case where related field is a count (number) + if (typeof result['RelatedField'] === 'number' && result['RelatedField'] > 0) { + const relatedCount = result['RelatedField'] as number; + console.log(`[nocodb.getEntityWithRelations] Entity has ${relatedCount} related records`); + + // Fetch the linked records using the links API + try { + const config = getNocoDbConfiguration(); + const tableId = "your-table-id"; + const fieldId = "your-relation-field-id"; + + const linkUrl = `${config.url}/api/v2/tables/${tableId}/links/${fieldId}/records/${result.Id}`; + console.log(`[nocodb.getEntityWithRelations] Fetching linked records from: ${linkUrl}`); + + const linkedResponse = await $fetch(linkUrl, { + headers: { + "xc-token": config.token, + }, + params: { + limit: 100 + } + }); + + if (linkedResponse && linkedResponse.list && Array.isArray(linkedResponse.list)) { + // The links API returns limited data, so we need to fetch full records + const fullRelatedDetails = await Promise.all( + linkedResponse.list.map(async (linkedRecord: any) => { + try { + const recordId = linkedRecord.Id || linkedRecord.id; + if (recordId) { + const fullDetails = await getRelatedEntityById(recordId.toString()); + return fullDetails; + } + return linkedRecord; + } catch (error) { + console.error(`[nocodb.getEntityWithRelations] Failed to fetch full details for record ${linkedRecord.Id}:`, error); + return linkedRecord; + } + }) + ); + + result['RelatedField'] = fullRelatedDetails; + console.log(`[nocodb.getEntityWithRelations] Successfully fetched full details for ${fullRelatedDetails.length} related records`); + } + } catch (linkError) { + console.error(`[nocodb.getEntityWithRelations] Failed to fetch linked records:`, linkError); + } + } + } + + return result; + } catch (error: any) { + console.error('[nocodb.getEntityWithRelations] Error fetching entity with relations:', error); + throw error; + } +}; +``` + +#### Linking/Unlinking Records + +```typescript +export const linkRecords = async (parentTableId: string, parentId: string, childTableId: string, childId: string, fieldId: string) => { + console.log('[nocodb.linkRecords] Linking records:', { parentId, childId, fieldId }); + + const config = getNocoDbConfiguration(); + const linkUrl = `${config.url}/api/v2/tables/${parentTableId}/links/${fieldId}/records/${parentId}`; + + try { + const result = await $fetch(linkUrl, { + method: "POST", + headers: { + "xc-token": config.token, + "Content-Type": "application/json" + }, + body: { + Id: parseInt(childId) + } + }); + + console.log('[nocodb.linkRecords] Successfully linked records'); + return result; + } catch (error: any) { + console.error('[nocodb.linkRecords] Failed to link records:', error); + throw error; + } +}; + +export const unlinkRecords = async (parentTableId: string, parentId: string, childTableId: string, childId: string, fieldId: string) => { + console.log('[nocodb.unlinkRecords] Unlinking records:', { parentId, childId, fieldId }); + + const config = getNocoDbConfiguration(); + const unlinkUrl = `${config.url}/api/v2/tables/${parentTableId}/links/${fieldId}/records/${parentId}`; + + try { + const result = await $fetch(unlinkUrl, { + method: "DELETE", + headers: { + "xc-token": config.token, + "Content-Type": "application/json" + }, + body: { + Id: parseInt(childId) + } + }); + + console.log('[nocodb.unlinkRecords] Successfully unlinked records'); + return result; + } catch (error: any) { + console.error('[nocodb.unlinkRecords] Failed to unlink records:', error); + throw error; + } +}; +``` + +### Sorting and Advanced Querying + +```typescript +export const getEntitiesWithSorting = async () => { + try { + const result = await $fetch>(createTableUrl(Table.YourEntity), { + headers: { + "xc-token": getNocoDbConfiguration().token, + }, + params: { + limit: 1000, + fields: '*' + }, + }); + + // Apply custom sorting logic if needed + if (result.list && Array.isArray(result.list)) { + result.list.sort((a, b) => { + // Example: Sort by a field with letter and number parts + const fieldA = a['SortField'] || ''; + const fieldB = b['SortField'] || ''; + + // Extract letter and number parts + const matchA = fieldA.match(/^([A-Za-z]+)(\d+)$/); + const matchB = fieldB.match(/^([A-Za-z]+)(\d+)$/); + + if (matchA && matchB) { + const [, letterA, numberA] = matchA; + const [, letterB, numberB] = matchB; + + // First sort by letter + const letterCompare = letterA.localeCompare(letterB); + if (letterCompare !== 0) { + return letterCompare; + } + + // Then sort by number within the same letter group + return parseInt(numberA) - parseInt(numberB); + } + + // Fallback to string comparison + return fieldA.localeCompare(fieldB); + }); + } + + return result; + } catch (error: any) { + console.error('[nocodb.getEntitiesWithSorting] Error:', error); + throw error; + } +}; +``` + +## API Endpoint Patterns + +### Standard Endpoint Structure + +```typescript +// server/api/get-entities.ts +import { getEntities } from "../utils/nocodb"; +import { requireAuth } from "../utils/auth"; + +export default defineEventHandler(async (event) => { + console.log('[get-entities] Request received'); + + // Check authentication (adapt to your auth system) + await requireAuth(event); + + try { + console.log('[get-entities] Fetching entities...'); + const entities = await getEntities(); + console.log('[get-entities] Successfully fetched entities, count:', entities.list?.length || 0); + return entities; + } catch (error) { + console.error('[get-entities] Error occurred:', error); + console.error('[get-entities] Error details:', error instanceof Error ? error.message : 'Unknown error'); + throw error; + } +}); +``` + +### POST Endpoint with Validation + +```typescript +// server/api/create-entity.ts +import { createEntity } from "../utils/nocodb"; +import { requireAuth } from "../utils/auth"; + +export default defineEventHandler(async (event) => { + console.log('[create-entity] Request received'); + + await requireAuth(event); + + try { + const body = await readBody(event); + console.log('[create-entity] Request body fields:', body ? Object.keys(body) : 'none'); + + if (!body || Object.keys(body).length === 0) { + console.error('[create-entity] No data provided'); + throw createError({ statusCode: 400, statusMessage: "No data provided" }); + } + + // Validate required fields + const requiredFields = ['Field1', 'Field2']; // Add your required fields + const missingFields = requiredFields.filter(field => !body[field]); + + if (missingFields.length > 0) { + throw createError({ + statusCode: 400, + statusMessage: `Missing required fields: ${missingFields.join(', ')}` + }); + } + + console.log('[create-entity] Creating new entity with fields:', Object.keys(body)); + const createdEntity = await createEntity(body); + console.log('[create-entity] Successfully created entity with ID:', createdEntity.Id); + + return createdEntity; + } catch (error) { + console.error('[create-entity] Error occurred:', error); + console.error('[create-entity] Error stack:', error instanceof Error ? error.stack : 'No stack trace'); + + if (error instanceof Error) { + throw createError({ statusCode: 500, statusMessage: error.message }); + } else { + throw createError({ + statusCode: 500, + statusMessage: "An unexpected error occurred", + }); + } + } +}); +``` + +## TypeScript Integration + +### Entity Type Definitions + +Create comprehensive types in `utils/types.ts`: + +```typescript +// Enums for type safety +export enum EntityStatus { + Active = 'Active', + Inactive = 'Inactive', + Pending = 'Pending' +} + +export enum EntityCategory { + TypeA = 'Type A', + TypeB = 'Type B', + TypeC = 'Type C' +} + +// Main entity interface +export interface YourEntityType { + Id: number; + Name: string; + Description?: string; + Status: EntityStatus; + Category: EntityCategory; + DateCreated: string; + DateModified?: string; + // Add your specific fields + + // Computed fields (added by API processing) + DisplayName?: string; + FormattedDate?: string; + + // Related entities + RelatedEntities?: RelatedEntityType[]; + + // File attachments + Attachments?: FileAttachment[]; +} + +export interface RelatedEntityType { + Id: number; + Name: string; + Type: string; +} + +export interface FileAttachment { + id: string; + url: string; + signedUrl: string; + title: string; + mimetype: string; + size: number; + width?: number; + height?: number; + thumbnails?: { + tiny: { signedUrl: string }; + small: { signedUrl: string }; + card_cover: { signedUrl: string }; + }; +} + +// Filter interfaces +export interface EntityFilters { + startDate?: string; + endDate?: string; + status?: EntityStatus; + category?: EntityCategory; + searchTerm?: string; +} + +// Response interfaces +export interface EntityResponse { + list: YourEntityType[]; + PageInfo: PageInfo; +} + +export interface PageInfo { + pageSize: number; + totalRows: number; + isFirstPage: boolean; + isLastPage: boolean; + page: number; +} +``` + +### Generic Helper Functions + +```typescript +// Generic function for any entity type +export const getGenericEntity = async (table: Table, id: string): Promise => { + console.log(`[nocodb.getGenericEntity] Fetching ${table} ID: ${id}`); + + const result = await $fetch(`${createTableUrl(table)}/${id}`, { + headers: { + "xc-token": getNocoDbConfiguration().token, + }, + }); + + console.log(`[nocodb.getGenericEntity] Successfully retrieved ${table}:`, (result as any).Id); + return result; +}; + +// Generic list function +export const getGenericEntityList = async (table: Table, params?: any): Promise> => { + console.log(`[nocodb.getGenericEntityList] Fetching ${table} list with params:`, params); + + const result = await $fetch>(createTableUrl(table), { + headers: { + "xc-token": getNocoDbConfiguration().token, + }, + params: { + limit: 1000, + ...params + } + }); + + console.log(`[nocodb.getGenericEntityList] Successfully fetched ${table} list, count:`, result.list?.length || 0); + return result; +}; +``` + +## Best Practices + +### 1. Comprehensive Logging + +```typescript +// Always include comprehensive logging for debugging +const logOperation = (operation: string, entityType: string, data?: any) => { + console.log(`[nocodb.${operation}] =========================`); + console.log(`[nocodb.${operation}] Operation: ${operation.toUpperCase()}`); + console.log(`[nocodb.${operation}] Entity: ${entityType}`); + console.log(`[nocodb.${operation}] Timestamp: ${new Date().toISOString()}`); + if (data) { + console.log(`[nocodb.${operation}] Data:`, JSON.stringify(data, null, 2)); + } + console.log(`[nocodb.${operation}] =========================`); +}; +``` + +### 2. Error Handling Strategy + +```typescript +// Centralized error handling +export const handleNocoDbError = (error: any, operation: string, entityType: string) => { + 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; +}; +``` + +### 3. Performance Optimization + +```typescript +// Cache frequently accessed data +const entityCache = new Map(); +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + +export const getCachedEntity = async (id: string): Promise => { + const cacheKey = `entity-${id}`; + const cached = entityCache.get(cacheKey); + + if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { + console.log('[nocodb.getCachedEntity] Cache hit for ID:', id); + return cached.data; + } + + try { + const entity = await getEntityById(id); + entityCache.set(cacheKey, { data: entity, timestamp: Date.now() }); + console.log('[nocodb.getCachedEntity] Cache updated for ID:', id); + return entity; + } catch (error) { + console.error('[nocodb.getCachedEntity] Failed to fetch and cache entity:', error); + return null; + } +}; + +// Clear cache when entity is updated +export const clearEntityCache = (id: string) => { + const cacheKey = `entity-${id}`; + entityCache.delete(cacheKey); + console.log('[nocodb.clearEntityCache] Cache cleared for ID:', id); +}; +``` + +### 4. Data Validation + +```typescript +// Input validation functions +export const validateEntityData = (data: Partial): string[] => { + const errors: string[] = []; + + // Required field validation + if (!data.Name || data.Name.trim().length === 0) { + errors.push('Name is required'); + } + + // Format validation + if (data.Email && !isValidEmail(data.Email)) { + errors.push('Invalid email format'); + } + + // Range validation + if (data.Age && (data.Age < 0 || data.Age > 150)) { + errors.push('Age must be between 0 and 150'); + } + + return errors; +}; + +const isValidEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +}; +``` + +### 5. Batch Operations + +```typescript +// Batch create multiple entities +export const createEntitiesBatch = async (entities: Partial[]): Promise => { + console.log('[nocodb.createEntitiesBatch] Creating batch of', entities.length, 'entities'); + + const results: YourEntityType[] = []; + const errors: string[] = []; + + for (let i = 0; i < entities.length; i++) { + try { + const result = await createEntity(entities[i]); + results.push(result); + console.log(`[nocodb.createEntitiesBatch] Created entity ${i + 1}/${entities.length}:`, result.Id); + } catch (error) { + const errorMsg = `Failed to create entity ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`; + errors.push(errorMsg); + console.error('[nocodb.createEntitiesBatch]', errorMsg); + } + } + + if (errors.length > 0) { + console.warn('[nocodb.createEntitiesBatch] Batch completed with errors:', errors); + } + + console.log('[nocodb.createEntitiesBatch] Batch completed. Created:', results.length, 'Errors:', errors.length); + return results; +}; +``` + +## Real-World Examples + +### Example 1: Interest Management System + +Based on the actual implementation patterns from your codebase: + +```typescript +// Interest entity with comprehensive field management +export interface Interest { + Id: number; + "Full Name": string; + "Yacht Name": string; + Length: string; + "Email Address": string; + "Sales Process Level": InterestSalesProcessLevel; + "Phone Number": string; + "EOI Status": EOIStatus; + "Berth Info Sent Status": BerthInfoSentStatus; + "Contract Status": ContractStatus; + "Date Added": string; + "Created At": string; + // Signature links + "Signature Link Client"?: string; + "Signature Link CC"?: string; + "Signature Link Developer"?: string; + // Document ID + "documensoID"?: string; +} + +export const updateInterestWithValidation = async (id: string, data: Partial): Promise => { + console.log('[nocodb.updateInterestWithValidation] Updating interest:', id); + + // Validate the data before processing + const validationErrors = validateInterestData(data); + if (validationErrors.length > 0) { + throw createError({ + statusCode: 400, + statusMessage: `Validation failed: ${validationErrors.join(', ')}` + }); + } + + // Clean and filter data + const cleanData: Record = {}; + + const allowedFields = [ + "Full Name", "Yacht Name", "Length", "Address", "Email Address", + "Sales Process Level", "Phone Number", "Extra Comments", + "Berth Size Desired", "Date Added", "Width", "Depth", + "Source", "Contact Method Preferred", "Lead Category", + "EOI Status", "Berth Info Sent Status", "Contract Sent Status", + "Deposit 10% Status", "Contract Status", + "Signature Link Client", "Signature Link CC", "Signature Link Developer", + "documensoID" + ]; + + for (const field of allowedFields) { + if (field in data) { + const value = (data as any)[field]; + cleanData[field] = value === undefined ? null : value; + } + } + + // Handle date formatting + if (cleanData['Date Added']) { + cleanData['Date Added'] = convertDateFormat(cleanData['Date Added']); + } + + // Include ID for PATCH request + cleanData.Id = parseInt(id); + + try { + const result = await $fetch(createTableUrl(Table.Interest), { + method: "PATCH", + headers: { + "xc-token": getNocoDbConfiguration().token, + "Content-Type": "application/json" + }, + body: cleanData + }); + + console.log('[nocodb.updateInterestWithValidation] Successfully updated interest:', result.Id); + return result; + } catch (error: any) { + console.error('[nocodb.updateInterestWithValidation] Update failed:', error); + throw handleNocoDbError(error, 'updateInterestWithValidation', 'Interest'); + } +}; + +const validateInterestData = (data: Partial): string[] => { + const errors: string[] = []; + + if (data["Full Name"] && data["Full Name"].trim().length === 0) { + errors.push("Full Name cannot be empty"); + } + + if (data["Email Address"] && !isValidEmail(data["Email Address"])) { + errors.push("Invalid email address format"); + } + + if (data["Phone Number"] && data["Phone Number"].length < 10) { + errors.push("Phone number must be at least 10 digits"); + } + + return errors; +}; +``` + +### Example 2: Berth Management with Relationships + +```typescript +// Berth entity with interested parties relationship +export const getBerthWithInterestedParties = async (id: string): Promise => { + console.log('[nocodb.getBerthWithInterestedParties] Fetching berth:', id); + + try { + // Fetch basic berth data + const berth = await $fetch(`${createTableUrl(Table.Berth)}/${id}`, { + headers: { + "xc-token": getNocoDbConfiguration().token, + } + }); + + // Handle interested parties relationship + if (berth['Interested Parties']) { + if (typeof berth['Interested Parties'] === 'number' && berth['Interested Parties'] > 0) { + const partyCount = berth['Interested Parties'] as number; + console.log(`[nocodb.getBerthWithInterestedParties] Fetching ${partyCount} interested parties`); + + try { + const config = getNocoDbConfiguration(); + const berthsTableId = "mczgos9hr3oa9qc"; + const interestedPartiesFieldId = "c7q2z2rb27c1cb5"; + + const linkUrl = `${config.url}/api/v2/tables/${berthsTableId}/links/${interestedPartiesFieldId}/records/${berth.Id}`; + + const linkedResponse = await $fetch(linkUrl, { + headers: { "xc-token": config.token }, + params: { limit: 100 } + }); + + if (linkedResponse?.list?.length > 0) { + const fullPartyDetails = await Promise.all( + linkedResponse.list.map(async (party: any) => { + try { + const partyId = party.Id || party.id; + return partyId ? await getInterestById(partyId.toString()) : party; + } catch (error) { + console.error(`[nocodb.getBerthWithInterestedParties] Failed to fetch party ${party.Id}:`, error); + return party; + } + }) + ); + + berth['Interested Parties'] = fullPartyDetails; + console.log(`[nocodb.getBerthWithInterestedParties] Successfully populated ${fullPartyDetails.length} interested parties`); + } + } catch (linkError) { + console.error(`[nocodb.getBerthWithInterestedParties] Failed to fetch linked parties:`, linkError); + // Provide fallback data + berth['Interested Parties'] = Array.from({ length: partyCount }, (_, i) => ({ + Id: i + 1, + 'Full Name': `Party ${i + 1}`, + 'Sales Process Level': null, + 'EOI Status': null, + 'Contract Status': null + })) as any; + } + } + } + + return berth; + } catch (error: any) { + console.error('[nocodb.getBerthWithInterestedParties] Error fetching berth:', error); + throw handleNocoDbError(error, 'getBerthWithInterestedParties', 'Berth'); + } +}; +``` + +### Example 3: Expense Tracking with Currency Conversion + +```typescript +// Advanced expense management with filtering and post-processing +export const getExpensesWithProcessing = async (filters?: ExpenseFilters) => { + console.log('[nocodb.getExpensesWithProcessing] Fetching expenses with filters:', filters); + const startTime = Date.now(); + + try { + const result = await $fetch(createTableUrl(Table.Expense), { + headers: { "xc-token": getNocoDbConfiguration().token }, + params: buildExpenseParams(filters) + }); + + console.log('[nocodb.getExpensesWithProcessing] Fetched', result.list?.length || 0, 'expenses'); + + // Post-process expenses with currency conversion + if (result.list?.length > 0) { + // Add computed price numbers + result.list = result.list.map(expense => ({ + ...expense, + PriceNumber: parseFloat(expense.Price?.replace(/[€$,]/g, '') || '0') || 0 + })); + + // Apply currency conversion (integrate with your currency service) + const { processExpenseWithCurrency } = await import('@/server/utils/currency'); + const processedExpenses = await Promise.all( + result.list.map(expense => processExpenseWithCurrency(expense)) + ); + + // Calculate summary statistics + const summary = { + totalCount: processedExpenses.length, + totalUSD: processedExpenses.reduce((sum, e) => sum + (e.PriceUSD || e.PriceNumber || 0), 0), + totalEUR: processedExpenses.reduce((sum, e) => sum + (e.PriceEUR || e.PriceNumber || 0), 0), + uniquePayers: [...new Set(processedExpenses.map(e => e.Payer))].length, + currencies: [...new Set(processedExpenses.map(e => e.Currency || e.currency))].filter(Boolean) + }; + + console.log('[nocodb.getExpensesWithProcessing] Processing completed in', Date.now() - startTime, 'ms'); + + return { + list: processedExpenses, + PageInfo: result.PageInfo, + summary + }; + } + + return result; + } catch (error: any) { + console.error('[nocodb.getExpensesWithProcessing] Error:', error); + throw handleNocoDbError(error, 'getExpensesWithProcessing', 'Expense'); + } +}; + +const buildExpenseParams = (filters?: ExpenseFilters) => { + const params: any = { limit: 1000, sort: '-Time' }; + + if (filters?.startDate && filters?.endDate) { + const start = filters.startDate.includes('T') ? filters.startDate.split('T')[0] : filters.startDate; + const end = filters.endDate.includes('T') ? filters.endDate.split('T')[0] : filters.endDate; + params.where = `(Time,gte,${start})~and(Time,lte,${end})`; + } + + if (filters?.payer) { + const payerFilter = `(Payer,eq,${encodeURIComponent(filters.payer)})`; + params.where = params.where ? `${params.where}~and${payerFilter}` : payerFilter; + } + + if (filters?.category) { + const categoryFilter = `(Category,eq,${encodeURIComponent(filters.category)})`; + params.where = params.where ? `${params.where}~and${categoryFilter}` : categoryFilter; + } + + return params; +}; +``` + +### Example 4: File Attachment Handling + +```typescript +// Handle file attachments in NocoDB +export const updateEntityWithAttachment = async (id: string, attachmentData: any) => { + console.log('[nocodb.updateEntityWithAttachment] Adding attachment to entity:', id); + + try { + // Get existing entity + const entity = await getEntityById(id); + const existingAttachments = entity['Attachments'] || []; + + // Add new attachment to array + const updatedAttachments = [...existingAttachments, attachmentData]; + + // Update entity with new attachments + return await updateEntity(id, { + 'Attachments': updatedAttachments + }); + } catch (error) { + console.error('[nocodb.updateEntityWithAttachment] Failed to add attachment:', error); + throw error; + } +}; +``` + +## Conclusion + +This implementation guide provides a comprehensive foundation for integrating NocoDB with Nuxt.js applications. The patterns shown here are based on production code and include: + +- **Robust error handling** with retry mechanisms +- **Type safety** with comprehensive TypeScript interfaces +- **Performance optimization** through caching and efficient queries +- **Security** through field whitelisting and data validation +- **Maintainability** through consistent logging and error reporting +- **Scalability** through generic functions and batch operations + +Use these patterns as a foundation and adapt them to your specific use cases. The modular approach allows you to implement only the features you need while maintaining consistency across your application. + +### Key Takeaways + +1. **Always validate and sanitize data** before sending to NocoDB +2. **Use comprehensive logging** for debugging and monitoring +3. **Implement retry logic** for network operations +4. **Handle relationships carefully** using NocoDB's links API +5. **Cache frequently accessed data** to improve performance +6. **Use TypeScript interfaces** for type safety and better developer experience +7. **Follow consistent naming conventions** for maintainability + +This guide should serve as a complete reference for implementing robust NocoDB integrations in your applications. diff --git a/nuxt.config.ts b/nuxt.config.ts index ab6714a..6494f25 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -68,6 +68,9 @@ export default defineNuxtConfig({ } ] ], + css: [ + 'flag-icons/css/flag-icons.min.css' + ], app: { head: { titleTemplate: "%s • MonacoUSA Portal", diff --git a/package-lock.json b/package-lock.json index d44de86..ad8dbaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "dependencies": { "@nuxt/ui": "^3.2.0", "@vite-pwa/nuxt": "^0.10.8", + "base-vue-phone-input": "^0.1.13", "cookie": "^0.6.0", + "flag-icons": "^7.5.0", "formidable": "^3.5.4", "mime-types": "^3.0.1", "minio": "^8.0.5", @@ -7307,6 +7309,17 @@ "license": "Apache-2.0", "optional": true }, + "node_modules/base-vue-phone-input": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/base-vue-phone-input/-/base-vue-phone-input-0.1.13.tgz", + "integrity": "sha512-e3CH2cI/ddnAAv4+cBBiCmU/VVAcfhWjzBGlJMpte1d2xE/ASmENO72zwulHikLkywQ45lYBWkBQwSxMPYfUXA==", + "dependencies": { + "libphonenumber-js": "^1.11.7" + }, + "peerDependencies": { + "vue": "^3.4.37" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -9705,6 +9718,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flag-icons": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz", + "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==", + "license": "MIT" + }, "node_modules/fn.name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", @@ -11944,6 +11963,12 @@ "node": ">=6" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.10", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.10.tgz", + "integrity": "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", diff --git a/package.json b/package.json index 58e7a75..b8b1e25 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "dependencies": { "@nuxt/ui": "^3.2.0", "@vite-pwa/nuxt": "^0.10.8", + "base-vue-phone-input": "^0.1.13", "cookie": "^0.6.0", + "flag-icons": "^7.5.0", "formidable": "^3.5.4", "mime-types": "^3.0.1", "minio": "^8.0.5", diff --git a/pages/dashboard/admin.vue b/pages/dashboard/admin.vue index 74d30c6..12bb5cd 100644 --- a/pages/dashboard/admin.vue +++ b/pages/dashboard/admin.vue @@ -4,8 +4,8 @@

- mdi-shield-crown - Administration + mdi-account + Welcome Back, {{ firstName }}

Manage users and portal settings for the MonacoUSA Portal. @@ -99,100 +99,7 @@ - - - - - - mdi-lightning-bolt - Quick Actions - - - - - - mdi-account-plus - Add User - - - - - - mdi-file-chart - User Report - - - - - - mdi-shield-account - Manage Roles - - - - - - mdi-wrench - Maintenance - - - - - - - - - - - - - mdi-history - Recent Admin Activity - - - - - - - {{ activity.title }} - {{ activity.description }} - - - - - - - - @@ -265,8 +172,7 @@ const loadStats = async () => { // Action methods (placeholders for now) const manageUsers = () => { - console.log('Navigate to user management'); - // TODO: Implement user management navigation + window.open('https://auth.monacousa.org', '_blank'); }; const viewAuditLogs = () => { diff --git a/pages/dashboard/member-list.vue b/pages/dashboard/member-list.vue new file mode 100644 index 0000000..df997f7 --- /dev/null +++ b/pages/dashboard/member-list.vue @@ -0,0 +1,466 @@ + + + + + diff --git a/pages/login.vue b/pages/login.vue index c675b30..9fff041 100644 --- a/pages/login.vue +++ b/pages/login.vue @@ -3,7 +3,6 @@ -

@@ -89,22 +88,15 @@ :loading="loading" :disabled="!isFormValid" class="mb-4" - style="background-color: #a31515 !important;" + style="background-color: #a31515 !important; color: white !important;" > mdi-login Sign In - -
-

- Need help? Contact your administrator -

-
- @@ -246,38 +238,6 @@ onMounted(() => { box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4) !important; } -.pulse-animation { - animation: pulse 3s infinite; -} - -@keyframes pulse { - 0% { - transform: scale(1); - opacity: 1; - } - 50% { - transform: scale(1.05); - opacity: 0.9; - } - 100% { - transform: scale(1); - opacity: 1; - } -} - -.login-form-enter-active { - transition: all 0.6s ease; -} - -.login-form-enter-from { - opacity: 0; - transform: translateY(30px) scale(0.95); -} - -.login-form-enter-to { - opacity: 1; - transform: translateY(0) scale(1); -} /* Custom scrollbar for mobile */ ::-webkit-scrollbar { diff --git a/server/api/members/[id].delete.ts b/server/api/members/[id].delete.ts new file mode 100644 index 0000000..d312bee --- /dev/null +++ b/server/api/members/[id].delete.ts @@ -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'); + } +}); diff --git a/server/api/members/[id].get.ts b/server/api/members/[id].get.ts new file mode 100644 index 0000000..c15fc96 --- /dev/null +++ b/server/api/members/[id].get.ts @@ -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; +} diff --git a/server/api/members/[id].put.ts b/server/api/members/[id].put.ts new file mode 100644 index 0000000..c4b5432 --- /dev/null +++ b/server/api/members/[id].put.ts @@ -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 { + 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; +} diff --git a/server/api/members/index.get.ts b/server/api/members/index.get.ts new file mode 100644 index 0000000..abd9a03 --- /dev/null +++ b/server/api/members/index.get.ts @@ -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 +} diff --git a/server/api/members/index.post.ts b/server/api/members/index.post.ts new file mode 100644 index 0000000..50808c1 --- /dev/null +++ b/server/api/members/index.post.ts @@ -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 { + 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; +} diff --git a/server/utils/nocodb.ts b/server/utils/nocodb.ts new file mode 100644 index 0000000..cd1eb39 --- /dev/null +++ b/server/utils/nocodb.ts @@ -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 { + 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> => { + console.log('[nocodb.getMembers] Fetching all members...'); + const startTime = Date.now(); + + try { + const result = await $fetch>(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 => { + console.log('[nocodb.getMemberById] Fetching member ID:', id); + + const result = await $fetch(`${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): Promise => { + console.log('[nocodb.createMember] Creating member with fields:', Object.keys(data)); + + // Create a clean data object that matches the member schema + const cleanData: Record = {}; + + // 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(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, retryCount = 0): Promise => { + 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 = {}; + + // 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(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; +}; diff --git a/utils/countries.ts b/utils/countries.ts new file mode 100644 index 0000000..01b7359 --- /dev/null +++ b/utils/countries.ts @@ -0,0 +1,352 @@ +// Complete list of all countries with ISO 3166-1 alpha-2 codes +// This works with the flag-icons library for comprehensive coverage +export const COUNTRIES = [ + { code: 'AD', name: 'Andorra' }, + { code: 'AE', name: 'United Arab Emirates' }, + { code: 'AF', name: 'Afghanistan' }, + { code: 'AG', name: 'Antigua and Barbuda' }, + { code: 'AI', name: 'Anguilla' }, + { code: 'AL', name: 'Albania' }, + { code: 'AM', name: 'Armenia' }, + { code: 'AO', name: 'Angola' }, + { code: 'AQ', name: 'Antarctica' }, + { code: 'AR', name: 'Argentina' }, + { code: 'AS', name: 'American Samoa' }, + { code: 'AT', name: 'Austria' }, + { code: 'AU', name: 'Australia' }, + { code: 'AW', name: 'Aruba' }, + { code: 'AX', name: 'Åland Islands' }, + { code: 'AZ', name: 'Azerbaijan' }, + { code: 'BA', name: 'Bosnia and Herzegovina' }, + { code: 'BB', name: 'Barbados' }, + { code: 'BD', name: 'Bangladesh' }, + { code: 'BE', name: 'Belgium' }, + { code: 'BF', name: 'Burkina Faso' }, + { code: 'BG', name: 'Bulgaria' }, + { code: 'BH', name: 'Bahrain' }, + { code: 'BI', name: 'Burundi' }, + { code: 'BJ', name: 'Benin' }, + { code: 'BL', name: 'Saint Barthélemy' }, + { code: 'BM', name: 'Bermuda' }, + { code: 'BN', name: 'Brunei' }, + { code: 'BO', name: 'Bolivia' }, + { code: 'BQ', name: 'Bonaire, Sint Eustatius and Saba' }, + { code: 'BR', name: 'Brazil' }, + { code: 'BS', name: 'Bahamas' }, + { code: 'BT', name: 'Bhutan' }, + { code: 'BV', name: 'Bouvet Island' }, + { code: 'BW', name: 'Botswana' }, + { code: 'BY', name: 'Belarus' }, + { code: 'BZ', name: 'Belize' }, + { code: 'CA', name: 'Canada' }, + { code: 'CC', name: 'Cocos (Keeling) Islands' }, + { code: 'CD', name: 'Congo - Kinshasa' }, + { code: 'CF', name: 'Central African Republic' }, + { code: 'CG', name: 'Congo - Brazzaville' }, + { code: 'CH', name: 'Switzerland' }, + { code: 'CI', name: 'Côte d\'Ivoire' }, + { code: 'CK', name: 'Cook Islands' }, + { code: 'CL', name: 'Chile' }, + { code: 'CM', name: 'Cameroon' }, + { code: 'CN', name: 'China' }, + { code: 'CO', name: 'Colombia' }, + { code: 'CR', name: 'Costa Rica' }, + { code: 'CU', name: 'Cuba' }, + { code: 'CV', name: 'Cape Verde' }, + { code: 'CW', name: 'Curaçao' }, + { code: 'CX', name: 'Christmas Island' }, + { code: 'CY', name: 'Cyprus' }, + { code: 'CZ', name: 'Czech Republic' }, + { code: 'DE', name: 'Germany' }, + { code: 'DJ', name: 'Djibouti' }, + { code: 'DK', name: 'Denmark' }, + { code: 'DM', name: 'Dominica' }, + { code: 'DO', name: 'Dominican Republic' }, + { code: 'DZ', name: 'Algeria' }, + { code: 'EC', name: 'Ecuador' }, + { code: 'EE', name: 'Estonia' }, + { code: 'EG', name: 'Egypt' }, + { code: 'EH', name: 'Western Sahara' }, + { code: 'ER', name: 'Eritrea' }, + { code: 'ES', name: 'Spain' }, + { code: 'ET', name: 'Ethiopia' }, + { code: 'FI', name: 'Finland' }, + { code: 'FJ', name: 'Fiji' }, + { code: 'FK', name: 'Falkland Islands' }, + { code: 'FM', name: 'Micronesia' }, + { code: 'FO', name: 'Faroe Islands' }, + { code: 'FR', name: 'France' }, + { code: 'GA', name: 'Gabon' }, + { code: 'GB', name: 'United Kingdom' }, + { code: 'GD', name: 'Grenada' }, + { code: 'GE', name: 'Georgia' }, + { code: 'GF', name: 'French Guiana' }, + { code: 'GG', name: 'Guernsey' }, + { code: 'GH', name: 'Ghana' }, + { code: 'GI', name: 'Gibraltar' }, + { code: 'GL', name: 'Greenland' }, + { code: 'GM', name: 'Gambia' }, + { code: 'GN', name: 'Guinea' }, + { code: 'GP', name: 'Guadeloupe' }, + { code: 'GQ', name: 'Equatorial Guinea' }, + { code: 'GR', name: 'Greece' }, + { code: 'GS', name: 'South Georgia and the South Sandwich Islands' }, + { code: 'GT', name: 'Guatemala' }, + { code: 'GU', name: 'Guam' }, + { code: 'GW', name: 'Guinea-Bissau' }, + { code: 'GY', name: 'Guyana' }, + { code: 'HK', name: 'Hong Kong SAR China' }, + { code: 'HM', name: 'Heard & McDonald Islands' }, + { code: 'HN', name: 'Honduras' }, + { code: 'HR', name: 'Croatia' }, + { code: 'HT', name: 'Haiti' }, + { code: 'HU', name: 'Hungary' }, + { code: 'ID', name: 'Indonesia' }, + { code: 'IE', name: 'Ireland' }, + { code: 'IL', name: 'Israel' }, + { code: 'IM', name: 'Isle of Man' }, + { code: 'IN', name: 'India' }, + { code: 'IO', name: 'British Indian Ocean Territory' }, + { code: 'IQ', name: 'Iraq' }, + { code: 'IR', name: 'Iran' }, + { code: 'IS', name: 'Iceland' }, + { code: 'IT', name: 'Italy' }, + { code: 'JE', name: 'Jersey' }, + { code: 'JM', name: 'Jamaica' }, + { code: 'JO', name: 'Jordan' }, + { code: 'JP', name: 'Japan' }, + { code: 'KE', name: 'Kenya' }, + { code: 'KG', name: 'Kyrgyzstan' }, + { code: 'KH', name: 'Cambodia' }, + { code: 'KI', name: 'Kiribati' }, + { code: 'KM', name: 'Comoros' }, + { code: 'KN', name: 'Saint Kitts and Nevis' }, + { code: 'KP', name: 'North Korea' }, + { code: 'KR', name: 'South Korea' }, + { code: 'KW', name: 'Kuwait' }, + { code: 'KY', name: 'Cayman Islands' }, + { code: 'KZ', name: 'Kazakhstan' }, + { code: 'LA', name: 'Laos' }, + { code: 'LB', name: 'Lebanon' }, + { code: 'LC', name: 'Saint Lucia' }, + { code: 'LI', name: 'Liechtenstein' }, + { code: 'LK', name: 'Sri Lanka' }, + { code: 'LR', name: 'Liberia' }, + { code: 'LS', name: 'Lesotho' }, + { code: 'LT', name: 'Lithuania' }, + { code: 'LU', name: 'Luxembourg' }, + { code: 'LV', name: 'Latvia' }, + { code: 'LY', name: 'Libya' }, + { code: 'MA', name: 'Morocco' }, + { code: 'MC', name: 'Monaco' }, + { code: 'MD', name: 'Moldova' }, + { code: 'ME', name: 'Montenegro' }, + { code: 'MF', name: 'Saint Martin' }, + { code: 'MG', name: 'Madagascar' }, + { code: 'MH', name: 'Marshall Islands' }, + { code: 'MK', name: 'North Macedonia' }, + { code: 'ML', name: 'Mali' }, + { code: 'MM', name: 'Myanmar (Burma)' }, + { code: 'MN', name: 'Mongolia' }, + { code: 'MO', name: 'Macao SAR China' }, + { code: 'MP', name: 'Northern Mariana Islands' }, + { code: 'MQ', name: 'Martinique' }, + { code: 'MR', name: 'Mauritania' }, + { code: 'MS', name: 'Montserrat' }, + { code: 'MT', name: 'Malta' }, + { code: 'MU', name: 'Mauritius' }, + { code: 'MV', name: 'Maldives' }, + { code: 'MW', name: 'Malawi' }, + { code: 'MX', name: 'Mexico' }, + { code: 'MY', name: 'Malaysia' }, + { code: 'MZ', name: 'Mozambique' }, + { code: 'NA', name: 'Namibia' }, + { code: 'NC', name: 'New Caledonia' }, + { code: 'NE', name: 'Niger' }, + { code: 'NF', name: 'Norfolk Island' }, + { code: 'NG', name: 'Nigeria' }, + { code: 'NI', name: 'Nicaragua' }, + { code: 'NL', name: 'Netherlands' }, + { code: 'NO', name: 'Norway' }, + { code: 'NP', name: 'Nepal' }, + { code: 'NR', name: 'Nauru' }, + { code: 'NU', name: 'Niue' }, + { code: 'NZ', name: 'New Zealand' }, + { code: 'OM', name: 'Oman' }, + { code: 'PA', name: 'Panama' }, + { code: 'PE', name: 'Peru' }, + { code: 'PF', name: 'French Polynesia' }, + { code: 'PG', name: 'Papua New Guinea' }, + { code: 'PH', name: 'Philippines' }, + { code: 'PK', name: 'Pakistan' }, + { code: 'PL', name: 'Poland' }, + { code: 'PM', name: 'Saint Pierre and Miquelon' }, + { code: 'PN', name: 'Pitcairn Islands' }, + { code: 'PR', name: 'Puerto Rico' }, + { code: 'PS', name: 'Palestinian Territories' }, + { code: 'PT', name: 'Portugal' }, + { code: 'PW', name: 'Palau' }, + { code: 'PY', name: 'Paraguay' }, + { code: 'QA', name: 'Qatar' }, + { code: 'RE', name: 'Réunion' }, + { code: 'RO', name: 'Romania' }, + { code: 'RS', name: 'Serbia' }, + { code: 'RU', name: 'Russia' }, + { code: 'RW', name: 'Rwanda' }, + { code: 'SA', name: 'Saudi Arabia' }, + { code: 'SB', name: 'Solomon Islands' }, + { code: 'SC', name: 'Seychelles' }, + { code: 'SD', name: 'Sudan' }, + { code: 'SE', name: 'Sweden' }, + { code: 'SG', name: 'Singapore' }, + { code: 'SH', name: 'Saint Helena' }, + { code: 'SI', name: 'Slovenia' }, + { code: 'SJ', name: 'Svalbard and Jan Mayen' }, + { code: 'SK', name: 'Slovakia' }, + { code: 'SL', name: 'Sierra Leone' }, + { code: 'SM', name: 'San Marino' }, + { code: 'SN', name: 'Senegal' }, + { code: 'SO', name: 'Somalia' }, + { code: 'SR', name: 'Suriname' }, + { code: 'SS', name: 'South Sudan' }, + { code: 'ST', name: 'São Tomé and Príncipe' }, + { code: 'SV', name: 'El Salvador' }, + { code: 'SX', name: 'Sint Maarten' }, + { code: 'SY', name: 'Syria' }, + { code: 'SZ', name: 'Eswatini' }, + { code: 'TC', name: 'Turks and Caicos Islands' }, + { code: 'TD', name: 'Chad' }, + { code: 'TF', name: 'French Southern Territories' }, + { code: 'TG', name: 'Togo' }, + { code: 'TH', name: 'Thailand' }, + { code: 'TJ', name: 'Tajikistan' }, + { code: 'TK', name: 'Tokelau' }, + { code: 'TL', name: 'Timor-Leste' }, + { code: 'TM', name: 'Turkmenistan' }, + { code: 'TN', name: 'Tunisia' }, + { code: 'TO', name: 'Tonga' }, + { code: 'TR', name: 'Turkey' }, + { code: 'TT', name: 'Trinidad and Tobago' }, + { code: 'TV', name: 'Tuvalu' }, + { code: 'TW', name: 'Taiwan' }, + { code: 'TZ', name: 'Tanzania' }, + { code: 'UA', name: 'Ukraine' }, + { code: 'UG', name: 'Uganda' }, + { code: 'UM', name: 'U.S. Outlying Islands' }, + { code: 'US', name: 'United States' }, + { code: 'UY', name: 'Uruguay' }, + { code: 'UZ', name: 'Uzbekistan' }, + { code: 'VA', name: 'Vatican City' }, + { code: 'VC', name: 'Saint Vincent and the Grenadines' }, + { code: 'VE', name: 'Venezuela' }, + { code: 'VG', name: 'British Virgin Islands' }, + { code: 'VI', name: 'U.S. Virgin Islands' }, + { code: 'VN', name: 'Vietnam' }, + { code: 'VU', name: 'Vanuatu' }, + { code: 'WF', name: 'Wallis and Futuna' }, + { code: 'WS', name: 'Samoa' }, + { code: 'XK', name: 'Kosovo' }, + { code: 'YE', name: 'Yemen' }, + { code: 'YT', name: 'Mayotte' }, + { code: 'ZA', name: 'South Africa' }, + { code: 'ZM', name: 'Zambia' }, + { code: 'ZW', name: 'Zimbabwe' } +]; + +// Create lookup maps for fast access +const countryNamesByCode = new Map( + COUNTRIES.map(country => [country.code, country.name]) +); + +const countriesByName = new Map( + COUNTRIES.map(country => [country.name.toLowerCase(), country]) +); + +/** + * Get country name for a country code + */ +export const getCountryName = (countryCode: string): string => { + if (!countryCode) return 'Unknown'; + + const upperCode = countryCode.toUpperCase(); + return countryNamesByCode.get(upperCode) || countryCode; +}; + +/** + * Get both flag and name formatted (for display) + */ +export const getCountryDisplay = (countryCode: string): string => { + if (!countryCode) return 'Unknown'; + + const name = getCountryName(countryCode); + return `${countryCode.toUpperCase()} - ${name}`; +}; + +/** + * Get all countries sorted by name for dropdowns + */ +export const getAllCountries = (): Array<{ code: string; name: string; display: string }> => { + return COUNTRIES + .map(country => ({ + code: country.code, + name: country.name, + display: `${country.name}` + })) + .sort((a, b) => a.name.localeCompare(b.name)); +}; + +/** + * Search countries by name or code (for autocomplete) + */ +export const searchCountries = (query: string): Array<{ code: string; name: string; display: string }> => { + if (!query) return getAllCountries(); + + const lowerQuery = query.toLowerCase(); + return getAllCountries().filter(country => + country.name.toLowerCase().includes(lowerQuery) || + country.code.toLowerCase().includes(lowerQuery) + ); +}; + +/** + * Parse country from various input formats + */ +export const parseCountryInput = (input: string): string | null => { + if (!input) return null; + + const trimmed = input.trim(); + + // If it's already a country code and exists + if (trimmed.length === 2 && countryNamesByCode.has(trimmed.toUpperCase())) { + return trimmed.toUpperCase(); + } + + // Search by name + const found = countriesByName.get(trimmed.toLowerCase()); + return found ? found.code : null; +}; + +/** + * Validate if a country code exists + */ +export const isValidCountryCode = (code: string): boolean => { + if (!code) return false; + return countryNamesByCode.has(code.toUpperCase()); +}; + +/** + * Get countries by region/continent (basic grouping) + */ +export const getCountriesByRegion = (region: 'europe' | 'asia' | 'africa' | 'americas' | 'oceania'): Array<{ code: string; name: string }> => { + // Basic regional groupings - this could be expanded with more detailed data + const regions = { + europe: ['AD', 'AL', 'AM', 'AT', 'AZ', 'BA', 'BE', 'BG', 'BY', 'CH', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GB', 'GE', 'GR', 'HR', 'HU', 'IE', 'IS', 'IT', 'LI', 'LT', 'LU', 'LV', 'MC', 'MD', 'ME', 'MK', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'RS', 'RU', 'SE', 'SI', 'SK', 'SM', 'TR', 'UA', 'VA'], + asia: ['AF', 'BD', 'BH', 'BN', 'BT', 'CN', 'ID', 'IL', 'IN', 'IQ', 'IR', 'JO', 'JP', 'KG', 'KH', 'KP', 'KR', 'KW', 'KZ', 'LA', 'LB', 'LK', 'MM', 'MN', 'MV', 'MY', 'NP', 'OM', 'PH', 'PK', 'QA', 'SA', 'SG', 'SY', 'TH', 'TJ', 'TL', 'TM', 'TW', 'UZ', 'VN', 'YE'], + africa: ['AO', 'BF', 'BI', 'BJ', 'BW', 'CD', 'CF', 'CG', 'CI', 'CM', 'CV', 'DJ', 'DZ', 'EG', 'EH', 'ER', 'ET', 'GA', 'GH', 'GM', 'GN', 'GQ', 'GW', 'KE', 'KM', 'LR', 'LS', 'LY', 'MA', 'MG', 'ML', 'MR', 'MU', 'MW', 'MZ', 'NA', 'NE', 'NG', 'RW', 'SC', 'SD', 'SL', 'SN', 'SO', 'SS', 'ST', 'SZ', 'TD', 'TG', 'TN', 'TZ', 'UG', 'ZA', 'ZM', 'ZW'], + americas: ['AG', 'AR', 'BB', 'BZ', 'BO', 'BR', 'BS', 'CA', 'CL', 'CO', 'CR', 'CU', 'DM', 'DO', 'EC', 'GD', 'GT', 'GY', 'HN', 'HT', 'JM', 'KN', 'LC', 'MX', 'NI', 'PA', 'PE', 'PY', 'SR', 'SV', 'TT', 'US', 'UY', 'VC', 'VE'], + oceania: ['AU', 'FJ', 'KI', 'MH', 'FM', 'NR', 'NZ', 'PW', 'PG', 'SB', 'TO', 'TV', 'VU', 'WS'] + }; + + const regionCodes = regions[region] || []; + return COUNTRIES.filter(country => regionCodes.includes(country.code)); +}; diff --git a/utils/types.ts b/utils/types.ts index b17fedc..ebd7c8b 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -107,3 +107,59 @@ export interface MinIOConfig { secretKey: string; bucketName: string; } + +// Member Management Types +export enum MembershipStatus { + Active = 'Active', + Inactive = 'Inactive', + Pending = 'Pending', + Expired = 'Expired' +} + +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; + + // Computed fields (added by processing) + FullName?: string; + FormattedPhone?: string; + NationalityArray?: string[]; // Parsed from comma-separated string +} + +// Admin-only NocoDB Configuration +export interface NocoDBSettings { + tableId: string; + apiKey: string; + baseId: string; + url: string; +} + +export interface MemberResponse { + list: Member[]; + PageInfo: { + pageSize: number; + totalRows: number; + isFirstPage: boolean; + isLastPage: boolean; + page: number; + }; +} + +export interface MemberFilters { + searchTerm?: string; + nationality?: string; + membershipStatus?: MembershipStatus; + duesPaid?: boolean; + memberSince?: string; +}