345 lines
10 KiB
TypeScript
345 lines
10 KiB
TypeScript
|
|
// server/utils/nocodb-events.ts
|
||
|
|
import type { Event, EventRSVP, EventsResponse, EventFilters } from '~/utils/types';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Creates a client for interacting with the Events NocoDB table
|
||
|
|
* Provides CRUD operations and specialized queries for events and RSVPs
|
||
|
|
*/
|
||
|
|
export function createNocoDBEventsClient() {
|
||
|
|
const config = useRuntimeConfig();
|
||
|
|
|
||
|
|
const baseUrl = config.nocodb.url;
|
||
|
|
const token = config.nocodb.token;
|
||
|
|
const eventsBaseId = config.nocodb.eventsBaseId;
|
||
|
|
const eventsTableId = config.nocodb.eventsTableId || 'events'; // fallback to table name
|
||
|
|
|
||
|
|
if (!baseUrl || !token || !eventsBaseId) {
|
||
|
|
throw new Error('Events NocoDB configuration is incomplete. Please check environment variables.');
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
const whereConditions: string[] = [];
|
||
|
|
|
||
|
|
if (filters?.start_date && filters?.end_date) {
|
||
|
|
whereConditions.push(`(start_datetime >= '${filters.start_date}' AND start_datetime <= '${filters.end_date}')`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (filters?.event_type) {
|
||
|
|
whereConditions.push(`(event_type = '${filters.event_type}')`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (filters?.visibility) {
|
||
|
|
whereConditions.push(`(visibility = '${filters.visibility}')`);
|
||
|
|
} else if (filters?.user_role) {
|
||
|
|
// Role-based visibility filtering
|
||
|
|
if (filters.user_role === 'user') {
|
||
|
|
whereConditions.push(`(visibility = 'public')`);
|
||
|
|
} else if (filters.user_role === 'board') {
|
||
|
|
whereConditions.push(`(visibility = 'public' OR visibility = 'board-only')`);
|
||
|
|
}
|
||
|
|
// Admin sees all events (no filter)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (filters?.status) {
|
||
|
|
whereConditions.push(`(status = '${filters.status}')`);
|
||
|
|
} else {
|
||
|
|
// Default to active events only
|
||
|
|
whereConditions.push(`(status = 'active')`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (filters?.search) {
|
||
|
|
whereConditions.push(`(title LIKE '%${filters.search}%' OR description LIKE '%${filters.search}%')`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (whereConditions.length > 0) {
|
||
|
|
queryParams.set('where', whereConditions.join(' AND '));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sort by start date
|
||
|
|
queryParams.set('sort', 'start_datetime');
|
||
|
|
|
||
|
|
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}?${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/v1/db/data/v1/${eventsBaseId}/${eventsTableId}/${id}`;
|
||
|
|
|
||
|
|
return await $fetch<Event>(url, {
|
||
|
|
method: 'GET',
|
||
|
|
headers
|
||
|
|
});
|
||
|
|
},
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a new event
|
||
|
|
*/
|
||
|
|
async create(eventData: Partial<Event>) {
|
||
|
|
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}`;
|
||
|
|
|
||
|
|
// 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/v1/db/data/v1/${eventsBaseId}/${eventsTableId}/${id}`;
|
||
|
|
|
||
|
|
const data = {
|
||
|
|
...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/v1/db/data/v1/${eventsBaseId}/${eventsTableId}/${id}`;
|
||
|
|
|
||
|
|
return await $fetch(url, {
|
||
|
|
method: 'DELETE',
|
||
|
|
headers
|
||
|
|
});
|
||
|
|
},
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create an RSVP record for an event
|
||
|
|
*/
|
||
|
|
async createRSVP(rsvpData: Partial<EventRSVP>) {
|
||
|
|
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}`;
|
||
|
|
|
||
|
|
const data = {
|
||
|
|
...rsvpData,
|
||
|
|
created_at: new Date().toISOString(),
|
||
|
|
updated_at: 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 = '${eventId}')`);
|
||
|
|
queryParams.set('sort', 'created_at');
|
||
|
|
|
||
|
|
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}?${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', `(event_id = '${eventId}' AND member_id = '${memberId}')`);
|
||
|
|
queryParams.set('limit', '1');
|
||
|
|
|
||
|
|
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}?${queryParams.toString()}`;
|
||
|
|
|
||
|
|
const response = await $fetch(url, {
|
||
|
|
method: 'GET',
|
||
|
|
headers
|
||
|
|
});
|
||
|
|
|
||
|
|
return response?.list?.[0] || null;
|
||
|
|
},
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update an RSVP record
|
||
|
|
*/
|
||
|
|
async updateRSVP(id: string, rsvpData: Partial<EventRSVP>) {
|
||
|
|
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}/${id}`;
|
||
|
|
|
||
|
|
const data = {
|
||
|
|
...rsvpData,
|
||
|
|
updated_at: 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/v1/db/data/v1/${eventsBaseId}/${eventsTableId}/${eventId}`;
|
||
|
|
|
||
|
|
return await $fetch(url, {
|
||
|
|
method: 'PATCH',
|
||
|
|
headers,
|
||
|
|
body: {
|
||
|
|
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);
|
||
|
|
|
||
|
|
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();
|
||
|
|
rsvpQueryParams.set('where', `(member_id = '${memberId}' AND event_id IN (${eventIds.map(id => `'${id}'`).join(',')}))`);
|
||
|
|
|
||
|
|
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}?${rsvpQueryParams.toString()}`;
|
||
|
|
|
||
|
|
const rsvps = await $fetch(url, {
|
||
|
|
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 = 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
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|