385 lines
12 KiB
TypeScript
385 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());
|
|
|
|
// Build where clause for filtering using NocoDB v2 syntax
|
|
const whereConditions: string[] = [];
|
|
|
|
if (filters?.start_date && filters?.end_date) {
|
|
whereConditions.push(`(start_datetime,gte,${filters.start_date})`);
|
|
whereConditions.push(`(start_datetime,lte,${filters.end_date})`);
|
|
}
|
|
|
|
if (filters?.event_type) {
|
|
whereConditions.push(`(event_type,eq,${filters.event_type})`);
|
|
}
|
|
|
|
if (filters?.visibility) {
|
|
whereConditions.push(`(visibility,eq,${filters.visibility})`);
|
|
} else if (filters?.user_role) {
|
|
// Role-based visibility filtering
|
|
if (filters.user_role === 'user') {
|
|
whereConditions.push(`(visibility,eq,public)`);
|
|
} else if (filters.user_role === 'board') {
|
|
// For multiple OR conditions, we'll need to handle this differently
|
|
whereConditions.push(`~or(visibility,eq,public)~or(visibility,eq,board-only)`);
|
|
}
|
|
// Admin sees all events (no filter)
|
|
}
|
|
|
|
if (filters?.status) {
|
|
whereConditions.push(`(status,eq,${filters.status})`);
|
|
} else {
|
|
// Default to active events only
|
|
whereConditions.push(`(status,eq,active)`);
|
|
}
|
|
|
|
if (filters?.search) {
|
|
whereConditions.push(`~or(title,like,%${filters.search}%)~or(description,like,%${filters.search}%)`);
|
|
}
|
|
|
|
if (whereConditions.length > 0) {
|
|
const whereClause = whereConditions.length === 1
|
|
? whereConditions[0]
|
|
: `~and(${whereConditions.join(',')})`;
|
|
queryParams.set('where', whereClause);
|
|
}
|
|
|
|
// Sort by start date
|
|
queryParams.set('sort', 'start_datetime');
|
|
|
|
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
|
|
const eventIds = events.list.map((e: Event) => e.id);
|
|
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
|
|
const eventsWithRSVP = events.list.map((event: Event) => ({
|
|
...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
|
|
*/
|
|
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' };
|
|
|
|
return {
|
|
id: event.id,
|
|
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
|
|
}
|
|
};
|
|
}
|