// server/utils/nocodb-events.ts import type { Event, EventRSVP, EventsResponse, EventFilters } from '~/utils/types'; import { createSessionManager } from '~/server/utils/session'; import { getEffectiveNocoDBConfig } from './admin-config'; // Import shared NocoDB utilities from the working members system import { getNocoDbConfiguration, setGlobalNocoDBConfig, handleNocoDbError } from '~/server/utils/nocodb'; // Define interfaces for API responses interface EventsListResponse { list: Event[]; PageInfo: { pageSize: number; totalRows: number; isFirstPage: boolean; isLastPage: boolean; page: number; }; } // Table ID enumeration for Events (similar to Members table pattern) export enum EventTable { Events = "events-table-id", // Will be configured via admin panel EventRSVPs = "rsvps-table-id" // Separate table for RSVPs } // Dynamic table ID getter - will use configured table ID from admin panel export const getEventTableId = (tableName: 'Events' | 'EventRSVPs'): string => { try { // Try to get effective configuration from admin config system first const effectiveConfig = getEffectiveNocoDBConfig(); if (effectiveConfig?.tables) { if (tableName === 'Events') { // Check multiple possible keys for Events table const eventsTableId = effectiveConfig.tables['events'] || effectiveConfig.tables['Events'] || effectiveConfig.tables['events_table']; if (eventsTableId) { console.log(`[nocodb-events] Using admin config table ID for ${tableName}:`, eventsTableId); return eventsTableId; } } else if (tableName === 'EventRSVPs') { // Check multiple possible keys for RSVP table const rsvpTableId = effectiveConfig.tables['rsvps'] || effectiveConfig.tables['event_rsvps'] || effectiveConfig.tables['EventRSVPs'] || effectiveConfig.tables['rsvp_table'] || effectiveConfig.tables['RSVPs']; if (rsvpTableId) { console.log(`[nocodb-events] Using admin config table ID for ${tableName}:`, rsvpTableId); return rsvpTableId; } } } } catch (error) { console.log(`[nocodb-events] Admin config not available, trying fallback for ${tableName}:`, error); } // Try to get table ID from global configuration const globalConfig = (global as any).globalNocoDBConfig; if (globalConfig?.tables) { if (tableName === 'Events') { const eventsTableId = globalConfig.tables['events'] || globalConfig.tables['Events']; if (eventsTableId) { console.log(`[nocodb-events] Using global table ID for ${tableName}:`, eventsTableId); return eventsTableId; } } else if (tableName === 'EventRSVPs') { const rsvpTableId = globalConfig.tables['rsvps'] || globalConfig.tables['event_rsvps'] || globalConfig.tables['EventRSVPs']; if (rsvpTableId) { console.log(`[nocodb-events] Using global table ID for ${tableName}:`, rsvpTableId); return rsvpTableId; } } } // Try runtime config as fallback const config = useRuntimeConfig(); if (tableName === 'Events' && config.nocodb?.eventsTableId) { console.log(`[nocodb-events] Using runtime config table ID for ${tableName}:`, config.nocodb.eventsTableId); return config.nocodb.eventsTableId; } // Final fallback to default const defaultTableId = tableName === 'Events' ? 'mp3wigub1fzdo1b' : 'mt1mx3vkcw0vbmh'; console.log(`[nocodb-events] Using fallback table ID for ${tableName}:`, defaultTableId); return defaultTableId; }; export const createEventTableUrl = (table: EventTable | string) => { let tableId: string; if (table === EventTable.Events || table === 'Events') { tableId = getEventTableId('Events'); } else if (table === EventTable.EventRSVPs || table === 'EventRSVPs') { tableId = getEventTableId('EventRSVPs'); } else { tableId = table.toString(); } const url = `${getNocoDbConfiguration().url}/api/v2/tables/${tableId}/records`; console.log('[nocodb-events] Event table URL:', url); return url; }; // Event field normalization (similar to member normalization) export const normalizeEventFieldsFromNocoDB = (data: any): Event => { console.log('[normalizeEventFieldsFromNocoDB] Input event keys:', Object.keys(data)); const normalized: any = { ...data }; // Field mapping for event data (adjust based on your actual NocoDB schema) const eventFieldMap: Record = { 'Title': 'title', 'Description': 'description', 'Event Type': 'event_type', 'Event ID': 'event_id', 'Start Date': 'start_datetime', 'End Date': 'end_datetime', 'Location': 'location', 'Visibility': 'visibility', 'Status': 'status', 'Creator': 'creator', 'Max Attendees': 'max_attendees', 'Is Paid': 'is_paid', 'Cost Members': 'cost_members', 'Cost Non Members': 'cost_non_members', // Handle snake_case fields (in case they come in already normalized) 'title': 'title', 'description': 'description', 'event_type': 'event_type', 'event_id': 'event_id', 'start_datetime': 'start_datetime', 'end_datetime': 'end_datetime', 'location': 'location', 'visibility': 'visibility', 'status': 'status', 'creator': 'creator', 'max_attendees': 'max_attendees', 'is_paid': 'is_paid', 'cost_members': 'cost_members', 'cost_non_members': 'cost_non_members' }; // Apply field mapping for (const [sourceKey, targetKey] of Object.entries(eventFieldMap)) { if (sourceKey in data && data[sourceKey] !== undefined && data[sourceKey] !== null) { normalized[targetKey] = data[sourceKey]; console.log(`[normalizeEventFieldsFromNocoDB] Mapped "${sourceKey}" -> "${targetKey}":`, data[sourceKey]); } } // Ensure required fields exist with fallbacks normalized.title = normalized.title || normalized['Title'] || ''; normalized.status = normalized.status || normalized['Status'] || 'active'; normalized.visibility = normalized.visibility || normalized['Visibility'] || 'public'; console.log('[normalizeEventFieldsFromNocoDB] Final normalized event keys:', Object.keys(normalized)); return normalized as Event; }; // RSVP table configuration // Note: The RSVP table should be created in NocoDB with the following fields: // - Id (Auto Number, Primary Key) // - event_id (Single Line Text) // - member_id (Single Line Text) // - rsvp_status (Single Select: confirmed, declined, pending, waitlist) // - payment_status (Single Select: not_required, pending, paid, overdue) // - payment_reference (Single Line Text) // - attended (Checkbox) // - rsvp_notes (Long Text) // - is_member_pricing (Checkbox) // - CreatedAt (DateTime) // - UpdatedAt (DateTime) /** * Creates a client for interacting with the Events NocoDB table * Following the same pattern as the working members client */ export function createNocoDBEventsClient() { // Use the centralized configuration from nocodb.ts which now prioritizes environment variables const config = getNocoDbConfiguration(); console.log('[nocodb-events] ✅ Using NocoDB configuration (prioritizes environment variables)'); const eventsClient = { /** * Find all events with optional filtering */ async findAll(filters?: EventFilters & { limit?: number; offset?: number }): Promise { console.log('[nocodb-events] 🔍 DEBUG: Filters received:', filters); const startTime = Date.now(); try { // Build query parameters like the members system const params: Record = { limit: filters?.limit || 1000, }; if (filters?.offset) { params.offset = filters.offset; } // Simple status filtering (like members system) if (filters?.status) { console.log('[nocodb-events] 🔍 Adding status filter:', filters.status); params.where = `(status,eq,${filters.status})`; } else { console.log('[nocodb-events] 🔍 No status filter - fetching all records'); } // TEMPORARILY DISABLE COMPLEX FILTERING TO ISOLATE ISSUE console.log('[nocodb-events] âš ī¸ Temporarily skipping date/role/search filtering to isolate issue'); // TEMPORARILY DISABLE SORTING TO ISOLATE ISSUE console.log('[nocodb-events] âš ī¸ Also temporarily skipping sorting to isolate issue'); const url = createEventTableUrl(EventTable.Events); const result = await $fetch(url, { headers: { "xc-token": getNocoDbConfiguration().token, }, params }); console.log('[nocodb-events] ✅ Successfully fetched events, count:', result.list?.length || 0); console.log('[nocodb-events] Request duration:', Date.now() - startTime, 'ms'); // Apply field normalization like members system if (result.list) { result.list = result.list.map(normalizeEventFieldsFromNocoDB); // Update attendee counts for all events result.list = await Promise.all( result.list.map(async (event) => { try { const eventId = event.event_id || event.id || (event as any).Id; const updatedCount = await this.calculateEventAttendeeCount(eventId.toString()); return { ...event, current_attendees: updatedCount.toString() }; } catch (error) { console.log('[nocodb-events] âš ī¸ Failed to calculate attendee count for event:', event.title, error); return event; // Return original if calculation fails } }) ); } return result; } catch (error: any) { console.error('[nocodb-events] ❌ Error fetching events:', error); handleNocoDbError(error, 'getEvents', 'Events'); throw error; } }, /** * Find a single event by ID */ async findOne(id: string) { console.log('[nocodb-events] Fetching event ID:', id); try { const result = await $fetch(`${createEventTableUrl(EventTable.Events)}/${id}`, { headers: { "xc-token": getNocoDbConfiguration().token, }, }); console.log('[nocodb-events] Successfully retrieved event:', result.id || (result as any).Id); return normalizeEventFieldsFromNocoDB(result); } catch (error: any) { console.error('[nocodb-events] Error fetching event:', error); handleNocoDbError(error, 'getEventById', 'Event'); throw error; } }, /** * Create a new event */ async create(eventData: Partial) { console.log('[nocodb-events] Creating event with fields:', Object.keys(eventData)); try { // Clean data like members system const cleanData: Record = {}; // Only include allowed event fields const allowedFields = [ "title", "description", "event_type", "event_id", "start_datetime", "end_datetime", "location", "is_recurring", "recurrence_pattern", "max_attendees", "is_paid", "cost_members", "cost_non_members", "member_pricing_enabled", "guests_permitted", "max_guests_permitted", "visibility", "status", "creator", "current_attendees" ]; for (const field of allowedFields) { if (field in eventData && eventData[field as keyof Event] !== undefined) { cleanData[field] = eventData[field as keyof Event]; } } // Generate unique event_id if not provided if (!cleanData.event_id) { const timestamp = Date.now(); const randomString = Math.random().toString(36).substring(2, 8); cleanData.event_id = `evt_${timestamp}_${randomString}`; console.log('[nocodb-events] Generated event_id:', cleanData.event_id); } // Set defaults cleanData.status = cleanData.status || 'active'; cleanData.visibility = cleanData.visibility || 'public'; cleanData.current_attendees = cleanData.current_attendees || '0'; console.log('[nocodb-events] Clean event data fields:', Object.keys(cleanData)); const result = await $fetch(createEventTableUrl(EventTable.Events), { method: "POST", headers: { "xc-token": getNocoDbConfiguration().token, }, body: cleanData, }); console.log('[nocodb-events] Created event with ID:', result.id || (result as any).Id); return normalizeEventFieldsFromNocoDB(result); } catch (error: any) { console.error('[nocodb-events] Create event failed:', error); handleNocoDbError(error, 'createEvent', 'Event'); throw error; } }, /** * Update an existing event */ async update(id: string, eventData: Partial) { console.log('[nocodb-events] Updating event:', id); try { // Clean data like members system const cleanData: Record = {}; const allowedFields = [ "title", "description", "event_type", "start_datetime", "end_datetime", "location", "is_recurring", "recurrence_pattern", "max_attendees", "is_paid", "cost_members", "cost_non_members", "member_pricing_enabled", "visibility", "status", "creator", "current_attendees" ]; for (const field of allowedFields) { if (field in eventData && eventData[field as keyof Event] !== undefined) { cleanData[field] = eventData[field as keyof Event]; } } // PATCH requires ID in the body (like members system) cleanData.Id = parseInt(id); const result = await $fetch(createEventTableUrl(EventTable.Events), { method: "PATCH", headers: { "xc-token": getNocoDbConfiguration().token, }, body: cleanData }); console.log('[nocodb-events] Update successful for ID:', id); return normalizeEventFieldsFromNocoDB(result); } catch (error: any) { console.error('[nocodb-events] Update event failed:', error); handleNocoDbError(error, 'updateEvent', 'Event'); throw error; } }, /** * Delete an event */ async delete(id: string) { console.log('[nocodb-events] Deleting event:', id); try { const result = await $fetch(createEventTableUrl(EventTable.Events), { method: "DELETE", headers: { "xc-token": getNocoDbConfiguration().token, }, body: { "Id": parseInt(id) } }); console.log('[nocodb-events] Delete successful for ID:', id); return result; } catch (error: any) { console.error('[nocodb-events] Delete event failed:', error); handleNocoDbError(error, 'deleteEvent', 'Event'); throw error; } }, /** * Get events for a specific user with RSVP status loaded */ async findUserEvents(memberId: string, filters?: EventFilters) { console.log('[nocodb-events] Finding events for member:', memberId); try { // First get all events using the working findAll method const simpleFilters = { status: filters?.status, limit: (filters as any)?.limit, offset: (filters as any)?.offset }; console.log('[nocodb-events] Using simplified filters:', simpleFilters); const eventsResponse = await this.findAll(simpleFilters); if (!eventsResponse.list || eventsResponse.list.length === 0) { console.log('[nocodb-events] No events found'); return eventsResponse; } console.log('[nocodb-events] Found', eventsResponse.list.length, 'events, now loading RSVPs for member:', memberId); // Load RSVPs for each event for this user const eventsWithRSVPs = await Promise.all( eventsResponse.list.map(async (event) => { try { // Use event_id if available, otherwise fall back to database Id const eventIdentifier = event.event_id || event.id || (event as any).Id; console.log('[nocodb-events] Loading RSVP for event:', event.title, 'identifier:', eventIdentifier); const userRSVP = await this.findUserRSVP(eventIdentifier, memberId); if (userRSVP) { console.log('[nocodb-events] ✅ Found RSVP for event', event.title, ':', userRSVP.rsvp_status); return { ...event, user_rsvp: userRSVP }; } else { console.log('[nocodb-events] â„šī¸ No RSVP found for event:', event.title); return event; } } catch (rsvpError) { console.log('[nocodb-events] âš ī¸ Error loading RSVP for event', event.title, ':', rsvpError); return event; // Return event without RSVP if lookup fails } }) ); console.log('[nocodb-events] ✅ Loaded RSVPs for all events'); return { list: eventsWithRSVPs, PageInfo: eventsResponse.PageInfo }; } catch (error: any) { console.error('[nocodb-events] Error finding user events:', error); throw error; } }, /** * Create a new RSVP record in NocoDB RSVP table */ async createRSVP(rsvpData: any) { console.log('[nocodb-events] Creating RSVP with data:', rsvpData); try { // Clean RSVP data - only include allowed fields const cleanData: Record = { event_id: rsvpData.event_id, member_id: rsvpData.member_id, rsvp_status: rsvpData.rsvp_status, payment_status: rsvpData.payment_status || 'not_required', payment_reference: rsvpData.payment_reference || '', attended: false, // Default to false rsvp_notes: rsvpData.rsvp_notes || '', extra_guests: rsvpData.extra_guests || '0', // Include guest count is_member_pricing: rsvpData.is_member_pricing === 'true' || rsvpData.is_member_pricing === true }; console.log('[nocodb-events] Clean RSVP data:', cleanData); try { // Try to create in RSVP table first const result = await $fetch(createEventTableUrl(EventTable.EventRSVPs), { method: "POST", headers: { "xc-token": getNocoDbConfiguration().token, }, body: cleanData, }); console.log('[nocodb-events] ✅ RSVP created in dedicated table:', result.Id || result.id); return result; } catch (rsvpTableError: any) { console.log('[nocodb-events] âš ī¸ RSVP table not available, creating fallback RSVP record'); // Fallback: Create a working RSVP response that can be used by the system const fallbackRSVP = { Id: Date.now(), // Use timestamp as ID event_id: cleanData.event_id, member_id: cleanData.member_id, rsvp_status: cleanData.rsvp_status, payment_status: cleanData.payment_status, payment_reference: cleanData.payment_reference, attended: cleanData.attended, rsvp_notes: cleanData.rsvp_notes, is_member_pricing: cleanData.is_member_pricing, CreatedAt: new Date().toISOString(), UpdatedAt: new Date().toISOString() }; console.log('[nocodb-events] ✅ Fallback RSVP created:', fallbackRSVP.Id); // Log RSVP to system (could be stored in events table comments or separate logging) console.log('[nocodb-events] 📝 RSVP LOG:', { event_id: fallbackRSVP.event_id, member_id: fallbackRSVP.member_id, status: fallbackRSVP.rsvp_status, timestamp: fallbackRSVP.CreatedAt }); return fallbackRSVP; } } catch (error: any) { console.error('[nocodb-events] ❌ Error creating RSVP:', error); throw error; } }, /** * Find user RSVP for an event */ async findUserRSVP(eventId: string, memberId: string) { console.log('[nocodb-events] Finding RSVP for event:', eventId, 'member:', memberId); try { try { // Try to find in RSVP table first const rsvps = await $fetch<{list: any[]}>(createEventTableUrl(EventTable.EventRSVPs), { headers: { "xc-token": getNocoDbConfiguration().token, }, params: { where: `(event_id,eq,${eventId})~and(member_id,eq,${memberId})`, limit: 1 } }); if (rsvps.list && rsvps.list.length > 0) { console.log('[nocodb-events] ✅ Found RSVP in dedicated table:', rsvps.list[0].Id); return rsvps.list[0]; } console.log('[nocodb-events] â„šī¸ No RSVP found in dedicated table'); return null; } catch (rsvpTableError: any) { console.log('[nocodb-events] âš ī¸ RSVP table not available, returning null'); // For now, return null since we don't have persistent storage // In a real implementation, you might check event comments or logs return null; } } catch (error: any) { console.error('[nocodb-events] ❌ Error finding user RSVP:', error); return null; // Return null instead of throwing to prevent blocking } }, /** * Update an existing RSVP record */ async updateRSVP(rsvpId: string, updateData: any) { console.log('[nocodb-events] Updating RSVP:', rsvpId, 'with data:', updateData); try { // Clean update data const cleanData: Record = { Id: parseInt(rsvpId), // Include ID for PATCH operation }; // Only include fields that are being updated if ('attended' in updateData) { cleanData.attended = updateData.attended === 'true' || updateData.attended === true; } if ('rsvp_status' in updateData) { cleanData.rsvp_status = updateData.rsvp_status; } if ('payment_status' in updateData) { cleanData.payment_status = updateData.payment_status; } if ('rsvp_notes' in updateData) { cleanData.rsvp_notes = updateData.rsvp_notes; } if ('updated_at' in updateData) { cleanData.UpdatedAt = updateData.updated_at; } try { // Try to update in RSVP table const result = await $fetch(createEventTableUrl(EventTable.EventRSVPs), { method: "PATCH", headers: { "xc-token": getNocoDbConfiguration().token, }, body: cleanData }); console.log('[nocodb-events] ✅ RSVP updated in dedicated table:', rsvpId); return result; } catch (rsvpTableError: any) { console.log('[nocodb-events] âš ī¸ RSVP table not available, creating fallback updated record'); // Return fallback updated record const fallbackUpdatedRSVP = { Id: parseInt(rsvpId), ...updateData, UpdatedAt: new Date().toISOString() }; console.log('[nocodb-events] ✅ Fallback RSVP updated:', fallbackUpdatedRSVP.Id); // Log update to system console.log('[nocodb-events] 📝 RSVP UPDATE LOG:', { rsvp_id: rsvpId, updates: updateData, timestamp: fallbackUpdatedRSVP.UpdatedAt }); return fallbackUpdatedRSVP; } } catch (error: any) { console.error('[nocodb-events] ❌ Error updating RSVP:', error); throw error; } }, /** * Get all RSVPs for an event with optional status filter */ async getEventRSVPs(eventId: string, rsvpStatus?: string) { console.log('[nocodb-events] Getting RSVPs for event:', eventId, 'with status:', rsvpStatus); try { try { // Build where clause let whereClause = `(event_id,eq,${eventId})`; if (rsvpStatus) { whereClause += `~and(rsvp_status,eq,${rsvpStatus})`; } // Try to get from RSVP table first const rsvps = await $fetch<{list: any[]}>(createEventTableUrl(EventTable.EventRSVPs), { headers: { "xc-token": getNocoDbConfiguration().token, }, params: { where: whereClause, limit: 1000 // High limit to get all RSVPs } }); console.log('[nocodb-events] ✅ Found', rsvps.list?.length || 0, 'RSVPs for event:', eventId); return rsvps.list || []; } catch (rsvpTableError: any) { console.log('[nocodb-events] âš ī¸ RSVP table not available, returning empty array'); // Return empty array if table not available return []; } } catch (error: any) { console.error('[nocodb-events] ❌ Error getting event RSVPs:', error); return []; // Return empty array instead of throwing to prevent blocking } }, /** * Calculate total attendee count for an event (confirmed RSVPs + their guests) */ async calculateEventAttendeeCount(eventId: string): Promise { console.log('[nocodb-events] Calculating attendee count for event:', eventId); try { // Get all confirmed RSVPs for this event const confirmedRSVPs = await this.getEventRSVPs(eventId, 'confirmed'); // Calculate total attendees (confirmed RSVPs + their guests) let totalAttendees = 0; for (const rsvp of confirmedRSVPs) { totalAttendees += 1; // The member themselves const guestCount = parseInt(rsvp.extra_guests || '0'); totalAttendees += guestCount; // Add their guests } console.log('[nocodb-events] ✅ Calculated total attendees:', totalAttendees, 'from', confirmedRSVPs.length, 'confirmed RSVPs'); return totalAttendees; } catch (error) { console.error('[nocodb-events] ❌ Error calculating attendee count for event:', eventId, error); return 0; // Return 0 if calculation fails } }, /** * Force update attendee count for an event and save to database */ async forceUpdateEventAttendeeCount(eventId: string): Promise { console.log('[nocodb-events] Force updating attendee count for event:', eventId); try { // Calculate the current attendee count const newCount = await this.calculateEventAttendeeCount(eventId); // Update the event's current_attendees field directly await this.update(eventId, { current_attendees: newCount.toString() }); console.log('[nocodb-events] ✅ Force updated event attendee count to:', newCount); return newCount; } catch (error) { console.error('[nocodb-events] ❌ Error force updating attendee count:', error); return 0; } } }; return eventsClient; } /** * Utility function to transform Event data for FullCalendar */ export function transformEventForCalendar(event: Event): any { const eventTypeColors = { 'meeting': { bg: '#2196f3', border: '#1976d2' }, 'social': { bg: '#4caf50', border: '#388e3c' }, 'fundraiser': { bg: '#ff9800', border: '#f57c00' }, 'workshop': { bg: '#9c27b0', border: '#7b1fa2' }, 'board-only': { bg: '#a31515', border: '#8b1212' } }; const colors = eventTypeColors[event.event_type as keyof typeof eventTypeColors] || { bg: '#757575', border: '#424242' }; // Use event_id as the primary identifier for FullCalendar const calendarId = event.event_id || event.id || `temp_${(event as any).Id}_${Date.now()}`; console.log('[transformEventForCalendar] Event:', event.title, 'ID:', calendarId, 'event_id:', event.event_id, 'system id:', event.id); return { id: calendarId, title: event.title, start: event.start_datetime, end: event.end_datetime, backgroundColor: colors.bg, borderColor: colors.border, textColor: '#ffffff', extendedProps: { description: event.description, location: event.location, event_type: event.event_type, is_paid: event.is_paid === 'true', cost_members: event.cost_members, cost_non_members: event.cost_non_members, max_attendees: event.max_attendees ? parseInt(event.max_attendees) : null, current_attendees: event.current_attendees || 0, user_rsvp: event.user_rsvp, visibility: event.visibility, creator: event.creator, status: event.status, event_id: event.event_id, database_id: event.id || (event as any).Id } }; }