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

369 lines
12 KiB
TypeScript

// server/utils/nocodb-events.ts
import type { Event, EventRSVP, EventsResponse, EventFilters } from '~/utils/types';
import { getEffectiveNocoDBConfig } from './admin-config';
/**
* Creates a client for interacting with the Events NocoDB table
* Provides CRUD operations and specialized queries for events and RSVPs
*/
export function createNocoDBEventsClient() {
// Get effective configuration from admin config system
const effectiveConfig = getEffectiveNocoDBConfig();
const baseUrl = effectiveConfig.url;
const token = effectiveConfig.token;
const eventsBaseId = effectiveConfig.baseId;
const eventsTableId = effectiveConfig.tables.events || 'events';
const rsvpTableId = effectiveConfig.tables.rsvps || 'event_rsvps';
if (!baseUrl || !token || !eventsBaseId) {
throw new Error('Events NocoDB configuration is incomplete. Please check environment variables.');
}
// Validate API token before using it
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!');
console.error('[nocodb-events] This will cause ByteString conversion errors in HTTP headers.');
console.error('[nocodb-events] Please update the API token in the admin configuration.');
throw createError({
statusCode: 500,
statusMessage: 'Events system: NocoDB API token contains invalid characters. Please reconfigure the database connection in the admin panel with a valid API token.'
});
}
// 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!');
console.error('[nocodb-events] Found characters like bullets (•), quotes, etc. that break HTTP headers.');
console.error('[nocodb-events] Please copy the raw API token from NocoDB without any formatting.');
throw createError({
statusCode: 500,
statusMessage: 'Events system: NocoDB API token contains formatting characters (bullets, quotes, etc.). Please reconfigure with the raw token from NocoDB.'
});
}
}
const headers = {
'xc-token': token,
'Content-Type': 'application/json'
};
const eventsClient = {
/**
* Find all events with optional filtering
*/
async findAll(filters?: EventFilters & { limit?: number; offset?: number }) {
const queryParams = new URLSearchParams();
if (filters?.limit) queryParams.set('limit', filters.limit.toString());
if (filters?.offset) queryParams.set('offset', filters.offset.toString());
// TEMPORARILY: Try different query approach to isolate the issue
// Remove complex filtering until we can identify what works
console.log('[nocodb-events] 🔍 DEBUG: Filters received:', JSON.stringify(filters, null, 2));
// Try only status filter first (simplest case)
if (filters?.status) {
console.log('[nocodb-events] 🔍 Adding status filter:', filters.status);
queryParams.set('where', `(status,eq,${filters.status})`);
} else {
// Try no status filter at all to see if that works
console.log('[nocodb-events] 🔍 No status filter - fetching all records');
}
// Skip date filtering completely for now
console.log('[nocodb-events] ⚠️ Temporarily skipping date/role/search filtering to isolate issue');
// ALSO temporarily skip sorting to see if that's the issue
console.log('[nocodb-events] ⚠️ Also temporarily skipping sorting to isolate issue');
const url = `${baseUrl}/api/v2/tables/${eventsTableId}/records?${queryParams.toString()}`;
const response = await $fetch(url, {
method: 'GET',
headers
});
return response;
},
/**
* Find a single event by ID
*/
async findOne(id: string) {
const url = `${baseUrl}/api/v2/tables/${eventsTableId}/records/${id}`;
return await $fetch<Event>(url, {
method: 'GET',
headers
});
},
/**
* Create a new event
*/
async create(eventData: Partial<Event>) {
const url = `${baseUrl}/api/v2/tables/${eventsTableId}/records`;
// Set default values
const data = {
...eventData,
status: eventData.status || 'active',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
return await $fetch<Event>(url, {
method: 'POST',
headers,
body: data
});
},
/**
* Update an existing event
*/
async update(id: string, eventData: Partial<Event>) {
const url = `${baseUrl}/api/v2/tables/${eventsTableId}/records`;
const data = {
Id: parseInt(id),
...eventData,
updated_at: new Date().toISOString()
};
return await $fetch<Event>(url, {
method: 'PATCH',
headers,
body: data
});
},
/**
* Delete an event
*/
async delete(id: string) {
const url = `${baseUrl}/api/v2/tables/${eventsTableId}/records`;
return await $fetch(url, {
method: 'DELETE',
headers,
body: { Id: parseInt(id) }
});
},
/**
* Create an RSVP record for an event
*/
async createRSVP(rsvpData: Partial<EventRSVP>) {
const url = `${baseUrl}/api/v2/tables/${rsvpTableId}/records`;
const data = {
...rsvpData,
created_time: new Date().toISOString(),
updated_time: new Date().toISOString()
};
return await $fetch<EventRSVP>(url, {
method: 'POST',
headers,
body: data
});
},
/**
* Find RSVPs for a specific event
*/
async findEventRSVPs(eventId: string) {
const queryParams = new URLSearchParams();
queryParams.set('where', `(event_id,eq,${eventId})`);
queryParams.set('sort', 'created_time');
const url = `${baseUrl}/api/v2/tables/${rsvpTableId}/records?${queryParams.toString()}`;
return await $fetch(url, {
method: 'GET',
headers
});
},
/**
* Find a user's RSVP for a specific event
*/
async findUserRSVP(eventId: string, memberId: string) {
const queryParams = new URLSearchParams();
queryParams.set('where', `~and((event_id,eq,${eventId}),(member_id,eq,${memberId}))`);
queryParams.set('limit', '1');
const url = `${baseUrl}/api/v2/tables/${rsvpTableId}/records?${queryParams.toString()}`;
const response = await $fetch<{ list?: EventRSVP[]; PageInfo?: any }>(url, {
method: 'GET',
headers
});
return response?.list?.[0] || null;
},
/**
* Update an RSVP record
*/
async updateRSVP(id: string, rsvpData: Partial<EventRSVP>) {
const url = `${baseUrl}/api/v2/tables/${rsvpTableId}/records`;
const data = {
Id: parseInt(id),
...rsvpData,
updated_time: new Date().toISOString()
};
return await $fetch<EventRSVP>(url, {
method: 'PATCH',
headers,
body: data
});
},
/**
* Update event attendance count (for optimization)
*/
async updateAttendeeCount(eventId: string, count: number) {
const url = `${baseUrl}/api/v2/tables/${eventsTableId}/records`;
return await $fetch(url, {
method: 'PATCH',
headers,
body: {
Id: parseInt(eventId),
current_attendees: count.toString(),
updated_at: new Date().toISOString()
}
});
},
/**
* Get events for a specific user with their RSVP status
*/
async findUserEvents(memberId: string, filters?: EventFilters) {
// First get all visible events
const events = await this.findAll(filters) as { list?: Event[]; PageInfo?: any };
if (!events.list || events.list.length === 0) {
return { list: [], PageInfo: events.PageInfo };
}
// Get user's RSVPs for these events
// Fix: Use 'Id' (capital I) as that's the actual field name from NocoDB
const eventIds = events.list.map((e: any) => e.Id);
// Skip RSVP lookup if no valid event IDs
if (!eventIds.length || eventIds.some(id => !id)) {
console.log('[nocodb-events] ⚠️ No valid event IDs found, skipping RSVP lookup');
return { list: events.list, PageInfo: events.PageInfo };
}
const rsvpQueryParams = new URLSearchParams();
const eventIdConditions = eventIds.map(id => `(event_id,eq,${id})`).join(',');
rsvpQueryParams.set('where', `~and((member_id,eq,${memberId}),~or(${eventIdConditions}))`);
const rsvpUrl = `${baseUrl}/api/v2/tables/${rsvpTableId}/records?${rsvpQueryParams.toString()}`;
const rsvps = await $fetch<{ list?: EventRSVP[]; PageInfo?: any }>(rsvpUrl, {
method: 'GET',
headers
});
// Map RSVPs to events
const rsvpMap = new Map();
if (rsvps.list) {
rsvps.list.forEach((rsvp: EventRSVP) => {
rsvpMap.set(rsvp.event_id, rsvp);
});
}
// Add RSVP information to events
// Fix: Use 'Id' (capital I) as that's the actual field name from NocoDB
const eventsWithRSVP = events.list.map((event: any) => ({
...event,
user_rsvp: rsvpMap.get(event.Id) || null
}));
return {
list: eventsWithRSVP,
PageInfo: events.PageInfo
};
},
/**
* Generate payment reference for RSVP
*/
generatePaymentReference(memberId: string, date?: Date): string {
const referenceDate = date || new Date();
const dateString = referenceDate.toISOString().split('T')[0]; // YYYY-MM-DD
return `EVT-${memberId}-${dateString}`;
},
/**
* Check if event has reached capacity
*/
async isEventFull(eventId: string): Promise<boolean> {
const event = await this.findOne(eventId);
if (!event.max_attendees) return false; // Unlimited capacity
const maxAttendees = parseInt(event.max_attendees);
const currentAttendees = parseInt(String(event.current_attendees || 0));
return currentAttendees >= maxAttendees;
}
};
return eventsClient;
}
/**
* Utility function to transform Event data for FullCalendar
* Updated to use correct field names from NocoDB (Id instead of id)
*/
export function transformEventForCalendar(event: any): 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' };
return {
id: event.Id, // Fix: Use 'Id' (capital I) from NocoDB
title: event.title || 'Untitled Event',
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
}
};
}