Add event management system with calendar and CRUD operations
Some checks failed
Build And Push Image / docker (push) Failing after 2m37s
Some checks failed
Build And Push Image / docker (push) Failing after 2m37s
- Add EventCalendar component with FullCalendar integration - Create event CRUD dialogs and upcoming event banner - Implement server-side events API and database utilities - Add events dashboard page and navigation - Improve dues calculation with better overdue day logic - Install FullCalendar and date-fns dependencies
This commit is contained in:
95
server/api/events/[id]/attendees.patch.ts
Normal file
95
server/api/events/[id]/attendees.patch.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// server/api/events/[id]/attendees.patch.ts
|
||||
import { createNocoDBEventsClient } from '~/server/utils/nocodb-events';
|
||||
import type { EventAttendanceRequest } from '~/utils/types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const eventId = getRouterParam(event, 'id');
|
||||
const body = await readBody(event) as EventAttendanceRequest;
|
||||
|
||||
if (!eventId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Event ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Get user session
|
||||
const session = await getUserSession(event);
|
||||
if (!session || !session.user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user has permission to mark attendance (board or admin only)
|
||||
if (session.user.tier !== 'board' && session.user.tier !== 'admin') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Only board members and administrators can mark attendance'
|
||||
});
|
||||
}
|
||||
|
||||
const eventsClient = createNocoDBEventsClient();
|
||||
|
||||
// Verify event exists
|
||||
const eventDetails = await eventsClient.findOne(eventId);
|
||||
if (!eventDetails) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Event not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Find the user's RSVP record
|
||||
const userRSVP = await eventsClient.findUserRSVP(eventId, body.member_id);
|
||||
if (!userRSVP) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'RSVP record not found for this member'
|
||||
});
|
||||
}
|
||||
|
||||
// Update attendance status
|
||||
const updatedRSVP = await eventsClient.updateRSVP(userRSVP.id, {
|
||||
attended: body.attended ? 'true' : 'false',
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: updatedRSVP,
|
||||
message: `Attendance ${body.attended ? 'marked' : 'unmarked'} successfully`
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating attendance:', error);
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to update attendance'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function
|
||||
async function getUserSession(event: any) {
|
||||
try {
|
||||
const sessionCookie = getCookie(event, 'session') || getHeader(event, 'authorization');
|
||||
if (!sessionCookie) return null;
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: 'user-id',
|
||||
tier: 'board' // Replace with actual session logic
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
199
server/api/events/[id]/rsvp.post.ts
Normal file
199
server/api/events/[id]/rsvp.post.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// server/api/events/[id]/rsvp.post.ts
|
||||
import { createNocoDBEventsClient } from '~/server/utils/nocodb-events';
|
||||
import { createNocoDBClient } from '~/server/utils/nocodb';
|
||||
import type { EventRSVPRequest } from '~/utils/types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const eventId = getRouterParam(event, 'id');
|
||||
const body = await readBody(event) as EventRSVPRequest;
|
||||
|
||||
if (!eventId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Event ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Get user session
|
||||
const session = await getUserSession(event);
|
||||
if (!session || !session.user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
const eventsClient = createNocoDBEventsClient();
|
||||
const membersClient = createNocoDBClient();
|
||||
|
||||
// Get the event details
|
||||
const eventDetails = await eventsClient.findOne(eventId);
|
||||
if (!eventDetails) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Event not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if event is active
|
||||
if (eventDetails.status !== 'active') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Cannot RSVP to inactive events'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if event is in the past
|
||||
const eventDate = new Date(eventDetails.start_datetime);
|
||||
const now = new Date();
|
||||
if (eventDate < now) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Cannot RSVP to past events'
|
||||
});
|
||||
}
|
||||
|
||||
// Get member details for pricing logic
|
||||
const member = await membersClient.findByKeycloakId(session.user.id);
|
||||
if (!member) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Member record not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user already has an RSVP
|
||||
const existingRSVP = await eventsClient.findUserRSVP(eventId, member.member_id || member.Id);
|
||||
|
||||
if (existingRSVP && body.rsvp_status === 'confirmed') {
|
||||
// Update existing RSVP instead of creating new one
|
||||
const updatedRSVP = await eventsClient.updateRSVP(existingRSVP.id, {
|
||||
rsvp_status: body.rsvp_status,
|
||||
rsvp_notes: body.rsvp_notes || '',
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: updatedRSVP,
|
||||
message: 'RSVP updated successfully'
|
||||
};
|
||||
}
|
||||
|
||||
// Check event capacity if confirming
|
||||
if (body.rsvp_status === 'confirmed') {
|
||||
const isFull = await eventsClient.isEventFull(eventId);
|
||||
if (isFull) {
|
||||
// Add to waitlist instead
|
||||
body.rsvp_status = 'waitlist';
|
||||
}
|
||||
}
|
||||
|
||||
// Determine pricing and payment status
|
||||
let paymentStatus = 'not_required';
|
||||
let isMemberPricing = 'false';
|
||||
|
||||
if (eventDetails.is_paid === 'true' && body.rsvp_status === 'confirmed') {
|
||||
paymentStatus = 'pending';
|
||||
|
||||
// Check if member qualifies for member pricing
|
||||
const isDuesCurrent = member.current_year_dues_paid === 'true';
|
||||
const memberPricingEnabled = eventDetails.member_pricing_enabled === 'true';
|
||||
|
||||
if (isDuesCurrent && memberPricingEnabled) {
|
||||
isMemberPricing = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
// Generate payment reference
|
||||
const paymentReference = eventsClient.generatePaymentReference(
|
||||
member.member_id || member.Id
|
||||
);
|
||||
|
||||
// Create RSVP record
|
||||
const rsvpData = {
|
||||
event_id: eventId,
|
||||
member_id: member.member_id || member.Id,
|
||||
rsvp_status: body.rsvp_status,
|
||||
payment_status: paymentStatus,
|
||||
payment_reference: paymentReference,
|
||||
attended: 'false',
|
||||
rsvp_notes: body.rsvp_notes || '',
|
||||
is_member_pricing: isMemberPricing
|
||||
};
|
||||
|
||||
const newRSVP = await eventsClient.createRSVP(rsvpData);
|
||||
|
||||
// Update event attendee count if confirmed
|
||||
if (body.rsvp_status === 'confirmed') {
|
||||
const currentCount = parseInt(eventDetails.current_attendees) || 0;
|
||||
await eventsClient.updateAttendeeCount(eventId, currentCount + 1);
|
||||
}
|
||||
|
||||
// Include payment information in response for paid events
|
||||
let responseData: any = newRSVP;
|
||||
|
||||
if (eventDetails.is_paid === 'true' && paymentStatus === 'pending') {
|
||||
const registrationConfig = await getRegistrationConfig();
|
||||
|
||||
responseData = {
|
||||
...newRSVP,
|
||||
payment_info: {
|
||||
cost: isMemberPricing === 'true' ?
|
||||
eventDetails.cost_members :
|
||||
eventDetails.cost_non_members,
|
||||
iban: registrationConfig.iban,
|
||||
recipient: registrationConfig.accountHolder,
|
||||
reference: paymentReference,
|
||||
member_pricing: isMemberPricing === 'true'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: responseData,
|
||||
message: body.rsvp_status === 'waitlist' ?
|
||||
'Added to waitlist - event is full' :
|
||||
'RSVP submitted successfully'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing RSVP:', error);
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to process RSVP'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
async function getUserSession(event: any) {
|
||||
try {
|
||||
const sessionCookie = getCookie(event, 'session') || getHeader(event, 'authorization');
|
||||
if (!sessionCookie) return null;
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: 'user-id',
|
||||
tier: 'user'
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getRegistrationConfig() {
|
||||
// This should fetch from your admin config system
|
||||
return {
|
||||
iban: 'FR76 1234 5678 9012 3456 7890 123',
|
||||
accountHolder: 'MonacoUSA Association'
|
||||
};
|
||||
}
|
||||
209
server/api/events/calendar-feed.get.ts
Normal file
209
server/api/events/calendar-feed.get.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
// server/api/events/calendar-feed.get.ts
|
||||
import { createNocoDBEventsClient } from '~/server/utils/nocodb-events';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const query = getQuery(event);
|
||||
const { user_id, user_role, format } = query;
|
||||
|
||||
if (!user_id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'User ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
const eventsClient = createNocoDBEventsClient();
|
||||
|
||||
// Get events for the user (next 6 months)
|
||||
const now = new Date();
|
||||
const sixMonthsLater = new Date();
|
||||
sixMonthsLater.setMonth(now.getMonth() + 6);
|
||||
|
||||
const filters = {
|
||||
start_date: now.toISOString(),
|
||||
end_date: sixMonthsLater.toISOString(),
|
||||
user_role: (user_role as string) || 'user',
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
const response = await eventsClient.findUserEvents(user_id as string, filters);
|
||||
const events = response.list || [];
|
||||
|
||||
// Generate iCal content
|
||||
const icalContent = generateICalContent(events, {
|
||||
calendarName: 'MonacoUSA Events',
|
||||
timezone: 'Europe/Paris',
|
||||
includeRSVPStatus: true
|
||||
});
|
||||
|
||||
// Set appropriate headers for calendar subscription
|
||||
setHeader(event, 'Content-Type', 'text/calendar; charset=utf-8');
|
||||
setHeader(event, 'Content-Disposition', 'attachment; filename="monacousa-events.ics"');
|
||||
setHeader(event, 'Cache-Control', 'no-cache, must-revalidate');
|
||||
|
||||
return icalContent;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating calendar feed:', error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to generate calendar feed'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate iCal content from events array
|
||||
*/
|
||||
function generateICalContent(events: any[], options: {
|
||||
calendarName: string;
|
||||
timezone: string;
|
||||
includeRSVPStatus: boolean;
|
||||
}): string {
|
||||
const now = new Date();
|
||||
const lines: string[] = [];
|
||||
|
||||
// iCal header
|
||||
lines.push('BEGIN:VCALENDAR');
|
||||
lines.push('VERSION:2.0');
|
||||
lines.push('PRODID:-//MonacoUSA//Portal//EN');
|
||||
lines.push(`X-WR-CALNAME:${options.calendarName}`);
|
||||
lines.push(`X-WR-TIMEZONE:${options.timezone}`);
|
||||
lines.push('X-WR-CALDESC:Events from MonacoUSA Portal');
|
||||
lines.push('CALSCALE:GREGORIAN');
|
||||
lines.push('METHOD:PUBLISH');
|
||||
|
||||
// Add timezone definition
|
||||
lines.push('BEGIN:VTIMEZONE');
|
||||
lines.push(`TZID:${options.timezone}`);
|
||||
lines.push('BEGIN:STANDARD');
|
||||
lines.push('DTSTART:19701025T030000');
|
||||
lines.push('RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU');
|
||||
lines.push('TZNAME:CET');
|
||||
lines.push('TZOFFSETFROM:+0200');
|
||||
lines.push('TZOFFSETTO:+0100');
|
||||
lines.push('END:STANDARD');
|
||||
lines.push('BEGIN:DAYLIGHT');
|
||||
lines.push('DTSTART:19700329T020000');
|
||||
lines.push('RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU');
|
||||
lines.push('TZNAME:CEST');
|
||||
lines.push('TZOFFSETFROM:+0100');
|
||||
lines.push('TZOFFSETTO:+0200');
|
||||
lines.push('END:DAYLIGHT');
|
||||
lines.push('END:VTIMEZONE');
|
||||
|
||||
// Process each event
|
||||
events.forEach(eventItem => {
|
||||
if (!eventItem.start_datetime || !eventItem.end_datetime) return;
|
||||
|
||||
const startDate = new Date(eventItem.start_datetime);
|
||||
const endDate = new Date(eventItem.end_datetime);
|
||||
const createdDate = eventItem.created_at ? new Date(eventItem.created_at) : now;
|
||||
const modifiedDate = eventItem.updated_at ? new Date(eventItem.updated_at) : now;
|
||||
|
||||
lines.push('BEGIN:VEVENT');
|
||||
lines.push(`UID:${eventItem.id}@monacousa.org`);
|
||||
lines.push(`DTSTART;TZID=${options.timezone}:${formatICalDateTime(startDate)}`);
|
||||
lines.push(`DTEND;TZID=${options.timezone}:${formatICalDateTime(endDate)}`);
|
||||
lines.push(`DTSTAMP:${formatICalDateTime(now)}`);
|
||||
lines.push(`CREATED:${formatICalDateTime(createdDate)}`);
|
||||
lines.push(`LAST-MODIFIED:${formatICalDateTime(modifiedDate)}`);
|
||||
lines.push(`SUMMARY:${escapeICalText(eventItem.title)}`);
|
||||
|
||||
// Add description with event details
|
||||
let description = eventItem.description || '';
|
||||
if (eventItem.is_paid === 'true') {
|
||||
description += `\\n\\nEvent Fee: €${eventItem.cost_members || eventItem.cost_non_members || 'TBD'}`;
|
||||
if (eventItem.cost_members && eventItem.cost_non_members) {
|
||||
description += ` (Members: €${eventItem.cost_members}, Non-members: €${eventItem.cost_non_members})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add RSVP status if available
|
||||
if (options.includeRSVPStatus && eventItem.user_rsvp) {
|
||||
const rsvpStatus = eventItem.user_rsvp.rsvp_status;
|
||||
description += `\\n\\nRSVP Status: ${rsvpStatus.toUpperCase()}`;
|
||||
|
||||
if (rsvpStatus === 'confirmed' && eventItem.user_rsvp.payment_reference) {
|
||||
description += `\\nPayment Reference: ${eventItem.user_rsvp.payment_reference}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (description) {
|
||||
lines.push(`DESCRIPTION:${escapeICalText(description)}`);
|
||||
}
|
||||
|
||||
// Add location
|
||||
if (eventItem.location) {
|
||||
lines.push(`LOCATION:${escapeICalText(eventItem.location)}`);
|
||||
}
|
||||
|
||||
// Add categories based on event type
|
||||
const eventTypeLabels = {
|
||||
'meeting': 'Meeting',
|
||||
'social': 'Social Event',
|
||||
'fundraiser': 'Fundraiser',
|
||||
'workshop': 'Workshop',
|
||||
'board-only': 'Board Meeting'
|
||||
};
|
||||
const category = eventTypeLabels[eventItem.event_type as keyof typeof eventTypeLabels] || 'Event';
|
||||
lines.push(`CATEGORIES:${category}`);
|
||||
|
||||
// Add status
|
||||
let eventStatus = 'CONFIRMED';
|
||||
if (eventItem.status === 'cancelled') {
|
||||
eventStatus = 'CANCELLED';
|
||||
} else if (eventItem.status === 'draft') {
|
||||
eventStatus = 'TENTATIVE';
|
||||
}
|
||||
lines.push(`STATUS:${eventStatus}`);
|
||||
|
||||
// Add transparency for paid events
|
||||
if (eventItem.is_paid === 'true') {
|
||||
lines.push('TRANSP:OPAQUE');
|
||||
} else {
|
||||
lines.push('TRANSP:TRANSPARENT');
|
||||
}
|
||||
|
||||
// Add URL (link back to portal)
|
||||
lines.push(`URL:https://portal.monacousa.org/dashboard/events`);
|
||||
|
||||
lines.push('END:VEVENT');
|
||||
});
|
||||
|
||||
// iCal footer
|
||||
lines.push('END:VCALENDAR');
|
||||
|
||||
// Join with CRLF as per RFC 5545
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for iCal (YYYYMMDDTHHMMSS format)
|
||||
*/
|
||||
function formatICalDateTime(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special characters for iCal text fields
|
||||
*/
|
||||
function escapeICalText(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
return text
|
||||
.replace(/\\/g, '\\\\') // Escape backslashes
|
||||
.replace(/;/g, '\\;') // Escape semicolons
|
||||
.replace(/,/g, '\\,') // Escape commas
|
||||
.replace(/\n/g, '\\n') // Escape newlines
|
||||
.replace(/\r/g, '') // Remove carriage returns
|
||||
.replace(/"/g, '\\"'); // Escape quotes
|
||||
}
|
||||
90
server/api/events/index.get.ts
Normal file
90
server/api/events/index.get.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// server/api/events/index.get.ts
|
||||
import { createNocoDBEventsClient, transformEventForCalendar } from '~/server/utils/nocodb-events';
|
||||
import type { EventFilters } from '~/utils/types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const query = getQuery(event) as EventFilters & {
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
calendar_format?: string
|
||||
};
|
||||
|
||||
// Get user session for role-based filtering
|
||||
const session = await getUserSession(event);
|
||||
if (!session || !session.user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
const eventsClient = createNocoDBEventsClient();
|
||||
|
||||
// Build filters with user role
|
||||
const filters: EventFilters & { limit?: number; offset?: number } = {
|
||||
...query,
|
||||
user_role: session.user.tier,
|
||||
limit: query.limit ? parseInt(query.limit) : 50,
|
||||
offset: query.offset ? parseInt(query.offset) : 0
|
||||
};
|
||||
|
||||
// If no date range provided, default to current month + 2 months ahead
|
||||
if (!filters.start_date || !filters.end_date) {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 3, 0); // 3 months ahead
|
||||
|
||||
filters.start_date = startOfMonth.toISOString();
|
||||
filters.end_date = endDate.toISOString();
|
||||
}
|
||||
|
||||
// Get events from database
|
||||
const response = await eventsClient.findUserEvents(session.user.id, filters);
|
||||
|
||||
// Transform for FullCalendar if requested
|
||||
if (query.calendar_format === 'true') {
|
||||
const calendarEvents = response.list.map(transformEventForCalendar);
|
||||
return {
|
||||
success: true,
|
||||
data: calendarEvents,
|
||||
total: response.PageInfo?.totalRows || response.list.length
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.list,
|
||||
total: response.PageInfo?.totalRows || response.list.length,
|
||||
pagination: response.PageInfo
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to fetch events'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to get user session (you may need to adjust this based on your auth implementation)
|
||||
async function getUserSession(event: any) {
|
||||
// This should be replaced with your actual session retrieval logic
|
||||
// For now, assuming you have a session utility similar to your auth system
|
||||
try {
|
||||
const sessionCookie = getCookie(event, 'session') || getHeader(event, 'authorization');
|
||||
if (!sessionCookie) return null;
|
||||
|
||||
// Decode session - adjust based on your session implementation
|
||||
// This is a placeholder that should be replaced with your actual session logic
|
||||
return {
|
||||
user: {
|
||||
id: 'user-id', // This should come from your session
|
||||
tier: 'user' // This should come from your session
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
133
server/api/events/index.post.ts
Normal file
133
server/api/events/index.post.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// server/api/events/index.post.ts
|
||||
import { createNocoDBEventsClient } from '~/server/utils/nocodb-events';
|
||||
import type { EventCreateRequest } from '~/utils/types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const body = await readBody(event) as EventCreateRequest;
|
||||
|
||||
// Get user session for authentication and authorization
|
||||
const session = await getUserSession(event);
|
||||
if (!session || !session.user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user has permission to create events (board or admin only)
|
||||
if (session.user.tier !== 'board' && session.user.tier !== 'admin') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Only board members and administrators can create events'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!body.title || !body.start_datetime || !body.end_datetime) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Title, start date, and end date are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate date range
|
||||
const startDate = new Date(body.start_datetime);
|
||||
const endDate = new Date(body.end_datetime);
|
||||
|
||||
if (startDate >= endDate) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'End date must be after start date'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate event type
|
||||
const validEventTypes = ['meeting', 'social', 'fundraiser', 'workshop', 'board-only'];
|
||||
if (!validEventTypes.includes(body.event_type)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid event type'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate visibility
|
||||
const validVisibilities = ['public', 'board-only', 'admin-only'];
|
||||
if (!validVisibilities.includes(body.visibility)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid visibility setting'
|
||||
});
|
||||
}
|
||||
|
||||
// Admin-only visibility can only be set by admins
|
||||
if (body.visibility === 'admin-only' && session.user.tier !== 'admin') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Only administrators can create admin-only events'
|
||||
});
|
||||
}
|
||||
|
||||
const eventsClient = createNocoDBEventsClient();
|
||||
|
||||
// Prepare event data
|
||||
const eventData = {
|
||||
title: body.title.trim(),
|
||||
description: body.description?.trim() || '',
|
||||
event_type: body.event_type,
|
||||
start_datetime: body.start_datetime,
|
||||
end_datetime: body.end_datetime,
|
||||
location: body.location?.trim() || '',
|
||||
is_recurring: body.is_recurring || 'false',
|
||||
recurrence_pattern: body.recurrence_pattern || '',
|
||||
max_attendees: body.max_attendees || '',
|
||||
is_paid: body.is_paid || 'false',
|
||||
cost_members: body.cost_members || '',
|
||||
cost_non_members: body.cost_non_members || '',
|
||||
member_pricing_enabled: body.member_pricing_enabled || 'true',
|
||||
visibility: body.visibility,
|
||||
status: body.status || 'active',
|
||||
creator: session.user.id,
|
||||
current_attendees: '0'
|
||||
};
|
||||
|
||||
// Create the event
|
||||
const newEvent = await eventsClient.create(eventData);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: newEvent,
|
||||
message: 'Event created successfully'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating event:', error);
|
||||
|
||||
// Re-throw createError instances
|
||||
if (error.statusCode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to create event'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to get user session (same as in index.get.ts)
|
||||
async function getUserSession(event: any) {
|
||||
try {
|
||||
const sessionCookie = getCookie(event, 'session') || getHeader(event, 'authorization');
|
||||
if (!sessionCookie) return null;
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: 'user-id', // Replace with actual session logic
|
||||
tier: 'board' // Replace with actual session logic
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
344
server/utils/nocodb-events.ts
Normal file
344
server/utils/nocodb-events.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
// 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
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user