monacousa-portal/server/utils/nocodb-events.ts

673 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<string, string> = {
'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() {
// Validate API token before using it (from incoming version)
const config = getNocoDbConfiguration();
const token = config.token;
if (token) {
const cleanToken = token.trim();
// Check for non-ASCII characters that would cause ByteString errors
if (!/^[\x00-\xFF]*$/.test(cleanToken)) {
console.error('[nocodb-events] ❌ CRITICAL ERROR: API token contains invalid Unicode characters!');
throw createError({
statusCode: 500,
statusMessage: 'Events system: NocoDB API token contains invalid characters. Please reconfigure the database connection.'
});
}
// Additional validation for common token issues
if (cleanToken.includes('•') || cleanToken.includes('…') || cleanToken.includes('"') || cleanToken.includes('"')) {
console.error('[nocodb-events] ❌ CRITICAL ERROR: API token contains formatting characters!');
throw createError({
statusCode: 500,
statusMessage: 'Events system: NocoDB API token contains formatting characters. Please reconfigure with the raw token from NocoDB.'
});
}
}
const eventsClient = {
/**
* Find all events with optional filtering
*/
async findAll(filters?: EventFilters & { limit?: number; offset?: number }): Promise<EventsListResponse> {
console.log('[nocodb-events] 🔍 DEBUG: Filters received:', filters);
const startTime = Date.now();
try {
// Build query parameters like the members system
const params: Record<string, any> = {
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<EventsListResponse>(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);
}
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<Event>(`${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<Event>) {
console.log('[nocodb-events] Creating event with fields:', Object.keys(eventData));
try {
// Clean data like members system
const cleanData: Record<string, any> = {};
// 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<Event>(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<Event>) {
console.log('[nocodb-events] Updating event:', id);
try {
// Clean data like members system
const cleanData: Record<string, any> = {};
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<Event>(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 (simplified version)
*/
async findUserEvents(memberId: string, filters?: EventFilters) {
console.log('[nocodb-events] Finding events for member:', memberId);
try {
// For now, just get all visible events using the working findAll method
// Remove complex filtering temporarily to fix the 422 errors
const simpleFilters = {
status: filters?.status,
limit: (filters as any)?.limit,
offset: (filters as any)?.offset
};
console.log('[nocodb-events] Using simplified filters to avoid 422 errors:', simpleFilters);
const events = await this.findAll(simpleFilters);
// TODO: Add RSVP lookup from separate table
// For now, return events without RSVP status
return {
list: events.list || [],
PageInfo: events.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<string, any> = {
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 || '',
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<any>(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<string, any> = {
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<any>(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;
}
}
};
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
}
};
}