Add event management system with calendar and CRUD operations
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:
2025-08-12 04:25:35 +02:00
parent a555584b2c
commit f096897129
21 changed files with 3855 additions and 20 deletions

View 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;
}
}

View 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'
};
}

View 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
}

View 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;
}
}

View 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;
}
}