diff --git a/components/CreateEventDialog.vue b/components/CreateEventDialog.vue new file mode 100644 index 0000000..bd79672 --- /dev/null +++ b/components/CreateEventDialog.vue @@ -0,0 +1,471 @@ + + + + + + mdi-calendar-plus + Create New Event + + + mdi-close + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + mdi-cog + Advanced Options + + + + + + + + + + + + + + + + + + + + + + + + + + + Cancel + + + Create Event + + + + + + + + + diff --git a/components/DuesActionCard.vue b/components/DuesActionCard.vue index 9e2ddf9..311b34b 100644 --- a/components/DuesActionCard.vue +++ b/components/DuesActionCard.vue @@ -59,7 +59,7 @@ Days Overdue - {{ member.overdueDays || 0 }} days + {{ calculateDisplayOverdueDays(member) }} days @@ -234,6 +234,9 @@ interface DuesMember extends Member { overdueReason?: string; daysUntilDue?: number; nextDueDate?: string; + membership_date_paid?: string; + payment_due_date?: string; + current_year_dues_paid?: string; } interface Props { @@ -316,6 +319,47 @@ const daysDifference = computed(() => { }); // Methods +const calculateDisplayOverdueDays = (member: DuesMember): number => { + // First try to use the pre-calculated overdue days from the API + if (member.overdueDays !== undefined && member.overdueDays > 0) { + return member.overdueDays; + } + + // Fallback calculation if not provided + const today = new Date(); + const DAYS_IN_YEAR = 365; + + // Check if payment is over 1 year old + if (member.membership_date_paid) { + try { + const lastPaidDate = new Date(member.membership_date_paid); + const oneYearFromPayment = new Date(lastPaidDate); + oneYearFromPayment.setFullYear(oneYearFromPayment.getFullYear() + 1); + + if (today > oneYearFromPayment) { + const daysSincePayment = Math.floor((today.getTime() - lastPaidDate.getTime()) / (1000 * 60 * 60 * 24)); + return Math.max(0, daysSincePayment - DAYS_IN_YEAR); + } + } catch { + // Fall through to due date check + } + } + + // Check if past due date + if (member.payment_due_date) { + try { + const dueDate = new Date(member.payment_due_date); + if (today > dueDate) { + return Math.floor((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24)); + } + } catch { + // Invalid date + } + } + + return 0; +}; + const formatDate = (dateString: string): string => { if (!dateString) return ''; diff --git a/components/DuesPaymentBanner.vue b/components/DuesPaymentBanner.vue index f1c4a42..40cb6e3 100644 --- a/components/DuesPaymentBanner.vue +++ b/components/DuesPaymentBanner.vue @@ -209,20 +209,19 @@ const isPaymentOverOneYear = computed(() => { }); /** - * Check if dues are actually current - * Uses the same logic as dues-status API and MemberCard + * Check if dues need to be paid (either overdue or in grace period) + * Banner should show when payment is needed */ -const isDuesActuallyCurrent = computed(() => { +const needsPayment = computed(() => { if (!memberData.value) return false; - const paymentTooOld = isPaymentOverOneYear.value; const duesCurrentlyPaid = memberData.value.current_year_dues_paid === 'true'; - const gracePeriod = isInGracePeriod.value; + const paymentTooOld = isPaymentOverOneYear.value; - // Member is NOT overdue if they're in grace period OR (dues paid AND payment not too old) - const isOverdue = paymentTooOld || (!duesCurrentlyPaid && !gracePeriod); - - return !isOverdue; + // Show banner if: + // 1. Dues are not currently paid (regardless of grace period) + // 2. OR payment is over 1 year old (even if marked as paid) + return !duesCurrentlyPaid || paymentTooOld; }); // Computed properties @@ -230,8 +229,8 @@ const shouldShowBanner = computed(() => { if (!user.value || !memberData.value) return false; if (dismissed.value) return false; - // Show banner if dues are NOT current - return !isDuesActuallyCurrent.value; + // Show banner when payment is needed + return needsPayment.value; }); const daysRemaining = computed(() => { @@ -334,7 +333,7 @@ async function loadMemberData() { // Load configuration and check banner visibility async function loadConfig() { try { - const response = await $fetch('/api/admin/registration-config') as any; + const response = await $fetch('/api/registration-config') as any; if (response?.success) { config.value = response.data; } diff --git a/components/EventCalendar.vue b/components/EventCalendar.vue new file mode 100644 index 0000000..3ee5633 --- /dev/null +++ b/components/EventCalendar.vue @@ -0,0 +1,410 @@ + + + + + mdi-calendar + Events Calendar + + + + Create Event + + + + + + + + + + + mdi-calendar-month + Month + + + mdi-format-list-bulleted + Agenda + + + + + + + + + + + + + + No Events + No events found for the current time period. + + + + + + + + diff --git a/components/EventDetailsDialog.vue b/components/EventDetailsDialog.vue new file mode 100644 index 0000000..1d90e28 --- /dev/null +++ b/components/EventDetailsDialog.vue @@ -0,0 +1,502 @@ + + + + + + {{ eventTypeIcon }} + {{ event.title }} + + + mdi-close + + + + + + + {{ eventTypeIcon }} + {{ eventTypeLabel }} + + + + + + + + mdi-calendar-clock + + {{ formatEventDate }} + {{ formatEventTime }} + + + + + + + + mdi-map-marker + {{ event.location }} + + + + + + + mdi-text + + Description + {{ event.description }} + + + + + + + + mdi-account-group + + Capacity: + + {{ event.current_attendees || 0 }} / {{ event.max_attendees }} + + + + + + + + + + + mdi-currency-eur + Payment Required + + + + Members: €{{ memberPrice }} + Non-Members: €{{ nonMemberPrice }} + + + Cost: €{{ memberPrice }} + + + mdi-information + Member pricing is not available for this event + + + + + + + + + + {{ rsvpStatusIcon }} + + {{ rsvpStatusText }} + {{ userRSVP.rsvp_notes }} + + + + Change + + + + + + + + + mdi-bank-transfer + Payment Details + + + + + + Amount: €{{ paymentAmount }} + + + + + IBAN: {{ paymentInfo.iban }} + + + + + Recipient: {{ paymentInfo.recipient }} + + + + + Reference: {{ userRSVP?.payment_reference }} + + + + + Copy Details + + + + + + + + mdi-account-check + RSVP to this Event + + + + + + + + mdi-check + {{ isEventFull ? 'Join Waitlist' : 'Confirm Attendance' }} + + + + mdi-close + Decline + + + + + + + + + Event Full + This event has reached maximum capacity and waitlist is not available. + + + + + Past Event + This event has already occurred. + + + + + + + Close + + + + + + + + + diff --git a/components/UpcomingEventBanner.vue b/components/UpcomingEventBanner.vue new file mode 100644 index 0000000..a6c7d9c --- /dev/null +++ b/components/UpcomingEventBanner.vue @@ -0,0 +1,282 @@ + + + + {{ eventIcon }} + + + + + {{ event.title }} + + + mdi-calendar-clock + {{ formatEventDate }} + + + mdi-map-marker + {{ event.location }} + + + mdi-currency-eur + {{ memberPrice }} + + + mdi-account-group + {{ capacityInfo }} + + + + + + + + + + {{ rsvpStatusIcon }} + {{ rsvpStatusText }} + + + + + View Details + + + + Quick RSVP + + + + + + + + + diff --git a/composables/useEvents.ts b/composables/useEvents.ts new file mode 100644 index 0000000..6cdbb2f --- /dev/null +++ b/composables/useEvents.ts @@ -0,0 +1,315 @@ +// composables/useEvents.ts +import type { Event, EventsResponse, EventFilters, EventCreateRequest, EventRSVPRequest } from '~/utils/types'; + +export const useEvents = () => { + const events = ref([]); + const loading = ref(false); + const error = ref(null); + const upcomingEvent = ref(null); + const cache = reactive>(new Map()); + const CACHE_TIMEOUT = 5 * 60 * 1000; // 5 minutes + + // Get authenticated user info + const { user, userTier } = useAuth(); + + /** + * Fetch events with optional filtering and caching + */ + const fetchEvents = async (filters?: EventFilters & { force?: boolean }) => { + loading.value = true; + error.value = null; + + try { + // Create cache key + const cacheKey = JSON.stringify(filters || {}); + const cached = cache.get(cacheKey); + + // Check cache if not forcing refresh + if (!filters?.force && cached) { + const now = Date.now(); + if (now - cached.timestamp < CACHE_TIMEOUT) { + events.value = cached.data; + loading.value = false; + return cached.data; + } + } + + // Default date range (current month + 2 months ahead) + const defaultFilters: EventFilters = { + start_date: startOfMonth(new Date()).toISOString(), + end_date: endOfMonth(addMonths(new Date(), 2)).toISOString(), + user_role: userTier.value, + ...filters + }; + + const response = await $fetch('/api/events', { + query: { + ...defaultFilters, + calendar_format: 'false' + } + }); + + if (response.success) { + events.value = response.data; + + // Cache the results + cache.set(cacheKey, { + data: response.data, + timestamp: Date.now() + }); + + // Update upcoming event + updateUpcomingEvent(response.data); + + return response.data; + } else { + throw new Error(response.message || 'Failed to fetch events'); + } + } catch (err: any) { + error.value = err.message || 'Failed to load events'; + console.error('Error fetching events:', err); + throw err; + } finally { + loading.value = false; + } + }; + + /** + * Create a new event (board/admin only) + */ + const createEvent = async (eventData: EventCreateRequest) => { + loading.value = true; + error.value = null; + + try { + const response = await $fetch<{ success: boolean; data: Event; message: string }>('/api/events', { + method: 'POST', + body: eventData + }); + + if (response.success) { + // Clear cache and refresh events + cache.clear(); + await fetchEvents({ force: true }); + + return response.data; + } else { + throw new Error(response.message || 'Failed to create event'); + } + } catch (err: any) { + error.value = err.message || 'Failed to create event'; + console.error('Error creating event:', err); + throw err; + } finally { + loading.value = false; + } + }; + + /** + * RSVP to an event + */ + const rsvpToEvent = async (eventId: string, rsvpData: Omit) => { + loading.value = true; + error.value = null; + + try { + const response = await $fetch(`/api/events/${eventId}/rsvp`, { + method: 'POST', + body: { + ...rsvpData, + event_id: eventId, + member_id: user.value?.id || '' + } + }); + + if (response.success) { + // Update local event data + const eventIndex = events.value.findIndex(e => e.id === eventId); + if (eventIndex !== -1) { + events.value[eventIndex].user_rsvp = response.data; + + // Update attendee count if confirmed + if (rsvpData.rsvp_status === 'confirmed') { + const currentCount = events.value[eventIndex].current_attendees || 0; + events.value[eventIndex].current_attendees = currentCount + 1; + } + } + + // Clear cache + cache.clear(); + + return response.data; + } else { + throw new Error(response.message || 'Failed to RSVP'); + } + } catch (err: any) { + error.value = err.message || 'Failed to RSVP to event'; + console.error('Error RSVPing to event:', err); + throw err; + } finally { + loading.value = false; + } + }; + + /** + * Update attendance for an event (board/admin only) + */ + const updateAttendance = async (eventId: string, memberId: string, attended: boolean) => { + loading.value = true; + error.value = null; + + try { + const response = await $fetch(`/api/events/${eventId}/attendees`, { + method: 'PATCH', + body: { + event_id: eventId, + member_id: memberId, + attended + } + }); + + if (response.success) { + // Update local event data + const eventIndex = events.value.findIndex(e => e.id === eventId); + if (eventIndex !== -1 && events.value[eventIndex].attendee_list) { + const attendeeIndex = events.value[eventIndex].attendee_list!.findIndex( + a => a.member_id === memberId + ); + if (attendeeIndex !== -1) { + events.value[eventIndex].attendee_list![attendeeIndex].attended = attended ? 'true' : 'false'; + } + } + + return response.data; + } else { + throw new Error(response.message || 'Failed to update attendance'); + } + } catch (err: any) { + error.value = err.message || 'Failed to update attendance'; + console.error('Error updating attendance:', err); + throw err; + } finally { + loading.value = false; + } + }; + + /** + * Get events for calendar display + */ + const getCalendarEvents = async (start: string, end: string) => { + try { + const response = await $fetch('/api/events', { + query: { + start_date: start, + end_date: end, + user_role: userTier.value, + calendar_format: 'true' + } + }); + + if (response.success) { + return response.data; + } + return []; + } catch (err) { + console.error('Error fetching calendar events:', err); + return []; + } + }; + + /** + * Get upcoming events for banners/widgets + */ + const getUpcomingEvents = (limit = 5): Event[] => { + const now = new Date(); + return events.value + .filter(event => new Date(event.start_datetime) >= now) + .sort((a, b) => new Date(a.start_datetime).getTime() - new Date(b.start_datetime).getTime()) + .slice(0, limit); + }; + + /** + * Find event by ID + */ + const findEventById = (eventId: string): Event | undefined => { + return events.value.find(event => event.id === eventId); + }; + + /** + * Check if user has RSVP'd to an event + */ + const hasUserRSVP = (eventId: string): boolean => { + const event = findEventById(eventId); + return !!event?.user_rsvp; + }; + + /** + * Get user's RSVP status for an event + */ + const getUserRSVPStatus = (eventId: string): string | null => { + const event = findEventById(eventId); + return event?.user_rsvp?.rsvp_status || null; + }; + + /** + * Update the upcoming event reference + */ + const updateUpcomingEvent = (eventList: Event[]) => { + const upcoming = eventList + .filter(event => new Date(event.start_datetime) >= new Date()) + .sort((a, b) => new Date(a.start_datetime).getTime() - new Date(b.start_datetime).getTime()); + + upcomingEvent.value = upcoming.length > 0 ? upcoming[0] : null; + }; + + /** + * Clear cache manually + */ + const clearCache = () => { + cache.clear(); + }; + + /** + * Refresh events data + */ + const refreshEvents = async () => { + clearCache(); + return await fetchEvents({ force: true }); + }; + + // Utility functions for date handling + function startOfMonth(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), 1); + } + + function endOfMonth(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth() + 1, 0); + } + + function addMonths(date: Date, months: number): Date { + const result = new Date(date); + result.setMonth(result.getMonth() + months); + return result; + } + + return { + // Reactive state + events: readonly(events), + loading: readonly(loading), + error: readonly(error), + upcomingEvent: readonly(upcomingEvent), + + // Methods + fetchEvents, + createEvent, + rsvpToEvent, + updateAttendance, + getCalendarEvents, + getUpcomingEvents, + findEventById, + hasUserRSVP, + getUserRSVPStatus, + clearCache, + refreshEvents + }; +}; diff --git a/layouts/dashboard.vue b/layouts/dashboard.vue index 597004c..c522bbe 100644 --- a/layouts/dashboard.vue +++ b/layouts/dashboard.vue @@ -25,6 +25,13 @@ title="Dashboard" value="dashboard" /> + + =15.0.1" } }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/precinct": { "version": "12.2.0", "resolved": "https://registry.npmjs.org/precinct/-/precinct-12.2.0.tgz", diff --git a/package.json b/package.json index 20f3c0f..0d6610d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,11 @@ "typecheck": "nuxt typecheck" }, "dependencies": { + "@fullcalendar/core": "^6.1.19", + "@fullcalendar/daygrid": "^6.1.19", + "@fullcalendar/interaction": "^6.1.19", + "@fullcalendar/list": "^6.1.19", + "@fullcalendar/vue3": "^6.1.19", "@nuxt/ui": "^3.2.0", "@nuxtjs/device": "^3.2.4", "@types/handlebars": "^4.0.40", @@ -17,6 +22,7 @@ "@types/nodemailer": "^6.4.17", "@vite-pwa/nuxt": "^0.10.8", "cookie": "^0.6.0", + "date-fns": "^4.1.0", "flag-icons": "^7.5.0", "formidable": "^3.5.4", "handlebars": "^4.7.8", diff --git a/pages/dashboard/admin.vue b/pages/dashboard/admin.vue index e007af7..b7f4fd9 100644 --- a/pages/dashboard/admin.vue +++ b/pages/dashboard/admin.vue @@ -311,6 +311,20 @@ + + + + + + @@ -408,6 +422,11 @@ const overdueCount = ref(0); const overdueRefreshTrigger = ref(0); const duesRefreshTrigger = ref(0); +// Member dialog state +const showViewDialog = ref(false); +const showEditDialog = ref(false); +const selectedMember = ref(null); + // Create user dialog data const createUserValid = ref(false); const creatingUser = ref(false); @@ -673,9 +692,16 @@ const handleStatusesUpdated = async (updatedCount: number) => { }; const handleViewMember = (member: any) => { - // Navigate to member details or open modal - console.log('View member:', member.FullName || `${member.first_name} ${member.last_name}`); - navigateTo('/dashboard/member-list'); + // Open the view dialog instead of navigating away + selectedMember.value = member; + showViewDialog.value = true; +}; + +const handleEditMember = (member: any) => { + // Close the view dialog and open the edit dialog + showViewDialog.value = false; + selectedMember.value = member; + showEditDialog.value = true; }; const navigateToMembers = () => { @@ -686,6 +712,9 @@ const navigateToMembers = () => { const handleMemberUpdated = (member: any) => { console.log('Member updated:', member.FullName || `${member.first_name} ${member.last_name}`); + // Close edit dialog + showEditDialog.value = false; + // Trigger dues refresh duesRefreshTrigger.value += 1; }; diff --git a/pages/dashboard/board.vue b/pages/dashboard/board.vue index 2d17da6..b1bb9f2 100644 --- a/pages/dashboard/board.vue +++ b/pages/dashboard/board.vue @@ -235,6 +235,20 @@ + + + + + + @@ -261,6 +275,11 @@ onMounted(() => { // Dues management state const duesRefreshTrigger = ref(0); +// Member dialog state +const showViewDialog = ref(false); +const showEditDialog = ref(false); +const selectedMember = ref(null); + // Mock data for board dashboard const stats = ref({ totalMembers: 156, @@ -300,15 +319,24 @@ const recentActivity = ref([ // Dues management handlers const handleViewMember = (member: Member) => { - // Navigate to member details or open modal - console.log('View member:', member.FullName || `${member.first_name} ${member.last_name}`); - // You could implement member detail view here - navigateToMembers(); + // Open the view dialog instead of navigating away + selectedMember.value = member; + showViewDialog.value = true; +}; + +const handleEditMember = (member: Member) => { + // Close the view dialog and open the edit dialog + showViewDialog.value = false; + selectedMember.value = member; + showEditDialog.value = true; }; const handleMemberUpdated = (member: Member) => { console.log('Member updated:', member.FullName || `${member.first_name} ${member.last_name}`); + // Close edit dialog + showEditDialog.value = false; + // Trigger dues refresh to update the lists duesRefreshTrigger.value += 1; diff --git a/pages/dashboard/events.vue b/pages/dashboard/events.vue new file mode 100644 index 0000000..2a7020e --- /dev/null +++ b/pages/dashboard/events.vue @@ -0,0 +1,464 @@ + + + + + + + + + + mdi-calendar + Events Calendar + + + View and manage events for the MonacoUSA community + + + + + Create Event + + + + + + Subscribe + + + + + + mdi-calendar-export + Export Calendar + + + + + mdi-calendar-sync + Subscribe (iOS/Android) + + + + + + + + + + + + + + + + + + + + + Clear Filters + + + + + + + + + + + + + {{ totalEvents }} + Total Events + + + + + + + {{ totalRSVPs }} + Total RSVPs + + + + + + + {{ upcomingEventsCount }} + Upcoming Events + + + + + + + {{ thisMonthEventsCount }} + This Month + + + + + + + + + + + + + {{ errorMessage }} + + + Close + + + + + + + {{ successMessage }} + + + Close + + + + + + + + + diff --git a/server/api/events/[id]/attendees.patch.ts b/server/api/events/[id]/attendees.patch.ts new file mode 100644 index 0000000..42f12f2 --- /dev/null +++ b/server/api/events/[id]/attendees.patch.ts @@ -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; + } +} diff --git a/server/api/events/[id]/rsvp.post.ts b/server/api/events/[id]/rsvp.post.ts new file mode 100644 index 0000000..c02a62d --- /dev/null +++ b/server/api/events/[id]/rsvp.post.ts @@ -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' + }; +} diff --git a/server/api/events/calendar-feed.get.ts b/server/api/events/calendar-feed.get.ts new file mode 100644 index 0000000..f85131e --- /dev/null +++ b/server/api/events/calendar-feed.get.ts @@ -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 +} diff --git a/server/api/events/index.get.ts b/server/api/events/index.get.ts new file mode 100644 index 0000000..6c59fa3 --- /dev/null +++ b/server/api/events/index.get.ts @@ -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; + } +} diff --git a/server/api/events/index.post.ts b/server/api/events/index.post.ts new file mode 100644 index 0000000..8f1acb6 --- /dev/null +++ b/server/api/events/index.post.ts @@ -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; + } +} diff --git a/server/utils/nocodb-events.ts b/server/utils/nocodb-events.ts new file mode 100644 index 0000000..f135575 --- /dev/null +++ b/server/utils/nocodb-events.ts @@ -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(url, { + method: 'GET', + headers + }); + }, + + /** + * Create a new event + */ + async create(eventData: Partial) { + 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(url, { + method: 'POST', + headers, + body: data + }); + }, + + /** + * Update an existing event + */ + async update(id: string, eventData: Partial) { + const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}/${id}`; + + const data = { + ...eventData, + updated_at: new Date().toISOString() + }; + + return await $fetch(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) { + 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(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) { + const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}/${id}`; + + const data = { + ...rsvpData, + updated_at: new Date().toISOString() + }; + + return await $fetch(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 { + 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 + } + }; +} diff --git a/utils/types.ts b/utils/types.ts index a73e161..116bcf2 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -432,3 +432,137 @@ export interface DuesCalculationUtils { calculateOverdueDays: (member: Member) => number; calculateDaysUntilDue: (member: Member) => { daysUntilDue: number; nextDueDate: string } | null; } + +// Event Management System Types +export interface Event { + id: string; + title: string; + description: string; + event_type: 'meeting' | 'social' | 'fundraiser' | 'workshop' | 'board-only'; + start_datetime: string; + end_datetime: string; + location: string; + is_recurring: string; // 'true' or 'false' as string + recurrence_pattern?: string; // JSON string + max_attendees?: string; // null/empty for unlimited + is_paid: string; // 'true' or 'false' as string + cost_members?: string; + cost_non_members?: string; + member_pricing_enabled: string; // 'true' or 'false' as string + visibility: 'public' | 'board-only' | 'admin-only'; + status: 'active' | 'cancelled' | 'completed' | 'draft'; + creator: string; // member_id who created event + created_at: string; + updated_at: string; + + // Computed fields + current_attendees?: number; + user_rsvp?: EventRSVP; + attendee_list?: EventRSVP[]; +} + +export interface EventRSVP { + id: string; + event_id: string; + member_id: string; + rsvp_status: 'confirmed' | 'declined' | 'pending' | 'waitlist'; + payment_status: 'not_required' | 'pending' | 'paid' | 'overdue'; + payment_reference: string; // EVT-{member_id}-{date} + attended: string; // 'true' or 'false' as string + rsvp_notes?: string; + created_at: string; + updated_at: string; + + // Computed fields + member_details?: Member; + is_member_pricing?: string; // 'true' or 'false' +} + +export interface EventsResponse { + success: boolean; + data: Event[]; + total?: number; + message?: string; +} + +export interface EventCreateRequest { + title: string; + description: string; + event_type: string; + start_datetime: string; + end_datetime: string; + location: string; + is_recurring?: string; + recurrence_pattern?: string; + max_attendees?: string; + is_paid: string; + cost_members?: string; + cost_non_members?: string; + member_pricing_enabled: string; + visibility: string; + status?: string; +} + +export interface EventRSVPRequest { + event_id: string; + member_id: string; + rsvp_status: 'confirmed' | 'declined' | 'pending'; + rsvp_notes?: string; +} + +export interface EventAttendanceRequest { + event_id: string; + member_id: string; + attended: boolean; +} + +// Calendar subscription types +export interface CalendarSubscription { + user_id: string; + feed_url: string; + include_rsvp_only?: boolean; + created_at: string; +} + +// Event configuration types +export interface EventsConfig { + eventsBaseId: string; + eventsTableId: string; + defaultEventTypes: string[]; + maxEventsPerPage: number; + cacheTimeout: number; +} + +// FullCalendar integration types +export interface FullCalendarEvent { + id: string; + title: string; + start: string; + end: string; + backgroundColor?: string; + borderColor?: string; + textColor?: string; + extendedProps: { + description: string; + location: string; + event_type: string; + is_paid: boolean; + cost_members?: string; + cost_non_members?: string; + max_attendees?: number; + current_attendees?: number; + user_rsvp?: EventRSVP; + visibility: string; + creator: string; + }; +} + +export interface EventFilters { + start_date?: string; + end_date?: string; + event_type?: string; + visibility?: string; + status?: string; + user_role?: 'user' | 'board' | 'admin'; + search?: string; +}
+ View and manage events for the MonacoUSA community +