From 44aee8f2f988a6b93566936944d43f0b1cdea568 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 13 Aug 2025 22:23:06 +0200 Subject: [PATCH] Refactor event form to use separate date/time inputs with validation - Split combined datetime pickers into separate date and time fields - Add validation for past dates and time consistency - Implement error message display with dismissible alerts - Add watchers to combine date/time values into ISO strings - Set minimum date constraints to prevent past date selection - Add delete endpoint for events --- components/CreateEventDialog.vue | 184 ++++++++++++++++++++++++++----- composables/useEvents.ts | 43 ++++++++ server/api/events/[id].delete.ts | 135 +++++++++++++++++++++++ server/utils/nocodb-events.ts | 38 ++++++- 4 files changed, 371 insertions(+), 29 deletions(-) create mode 100644 server/api/events/[id].delete.ts diff --git a/components/CreateEventDialog.vue b/components/CreateEventDialog.vue index 5b04ba9..b346a7d 100644 --- a/components/CreateEventDialog.vue +++ b/components/CreateEventDialog.vue @@ -81,23 +81,58 @@ + + + + + + + + @@ -241,6 +276,18 @@ + + + + {{ errorMessage }} + + + (''); +const startTime = ref(''); +const endDate = ref(''); +const endTime = ref(''); + +// Legacy date model refs for backward compatibility const startDateModel = ref(null); const endDateModel = ref(null); @@ -427,30 +480,45 @@ watch(recurrenceFrequency, (newValue) => { } }); +// Watch for separate date and time changes to combine them +watch([startDate, startTime], ([newDate, newTime]) => { + if (newDate && newTime) { + const combinedDateTime = new Date(`${newDate}T${newTime}`); + eventData.start_datetime = combinedDateTime.toISOString(); + console.log('[CreateEventDialog] Combined start datetime:', eventData.start_datetime); + } +}); + +watch([endDate, endTime], ([newDate, newTime]) => { + if (newDate && newTime) { + const combinedDateTime = new Date(`${newDate}T${newTime}`); + eventData.end_datetime = combinedDateTime.toISOString(); + console.log('[CreateEventDialog] Combined end datetime:', eventData.end_datetime); + } +}); + // Watch for prefilled dates watch(() => props.prefilledDate, (newDate) => { if (newDate) { - const startDate = new Date(newDate); - startDateModel.value = startDate; - eventData.start_datetime = startDate.toISOString(); - + const prefillDate = new Date(newDate); + startDate.value = prefillDate.toISOString().split('T')[0]; + startTime.value = prefillDate.toTimeString().substring(0, 5); // Set end date 2 hours later if not provided if (!props.prefilledEndDate) { - const endDate = new Date(startDate); - endDate.setHours(endDate.getHours() + 2); - endDateModel.value = endDate; - eventData.end_datetime = endDate.toISOString(); + const endDateTime = new Date(prefillDate); + endDateTime.setHours(endDateTime.getHours() + 2); + endDate.value = endDateTime.toISOString().split('T')[0]; + endTime.value = endDateTime.toTimeString().substring(0, 5); } } }, { immediate: true }); watch(() => props.prefilledEndDate, (newEndDate) => { if (newEndDate) { - const endDate = new Date(newEndDate); - endDateModel.value = endDate; - eventData.end_datetime = endDate.toISOString(); - + const prefillEndDate = new Date(newEndDate); + endDate.value = prefillEndDate.toISOString().split('T')[0]; + endTime.value = prefillEndDate.toTimeString().substring(0, 5); } }, { immediate: true }); @@ -472,6 +540,22 @@ const onDatePickerClosed = () => { // This handler ensures the date picker behaves correctly on mobile and desktop }; +// Validation functions +const validateEndTime = () => { + if (!startDate.value || !endDate.value || !startTime.value || !endTime.value) { + return false; // Return false (no error) if not all fields are filled + } + + // Only validate if start and end are on the same date + if (startDate.value === endDate.value) { + const start = startTime.value; + const end = endTime.value; + return start >= end; // Return true if there's an error (start >= end) + } + + return false; // No error if different dates +}; + // Methods const resetForm = () => { eventData.title = ''; @@ -512,31 +596,81 @@ const close = () => { resetForm(); }; +// Error handling +const errorMessage = ref(null); + const handleSubmit = async () => { if (!form.value) return; const isValid = await form.value.validate(); if (!isValid.valid) return; + // Clear previous errors + errorMessage.value = null; + + // Validate that we have proper date/time combination + if (!startDate.value || !startTime.value) { + errorMessage.value = 'Start date and time are required'; + return; + } + + if (!endDate.value || !endTime.value) { + errorMessage.value = 'End date and time are required'; + return; + } + loading.value = true; try { - // Ensure datetime strings are properly formatted - const startDate = new Date(eventData.start_datetime); - const endDate = new Date(eventData.end_datetime); + // Combine date and time properly + const startDateTime = new Date(`${startDate.value}T${startTime.value}`); + const endDateTime = new Date(`${endDate.value}T${endTime.value}`); + + // Validate start is not in the past + if (startDateTime < new Date()) { + errorMessage.value = 'Event start time cannot be in the past'; + loading.value = false; + return; + } + + // Validate end is after start + if (endDateTime <= startDateTime) { + errorMessage.value = 'Event end time must be after start time'; + loading.value = false; + return; + } const formattedEventData = { ...eventData, - start_datetime: startDate.toISOString(), - end_datetime: endDate.toISOString() + start_datetime: startDateTime.toISOString(), + end_datetime: endDateTime.toISOString() }; + console.log('[CreateEventDialog] Creating event with data:', formattedEventData); + const newEvent = await createEvent(formattedEventData); emit('event-created', newEvent); close(); } catch (error: any) { console.error('Error creating event:', error); + + // Parse error message for better UX + let userErrorMessage = 'Failed to create event'; + + if (error?.data?.message) { + userErrorMessage = error.data.message; + } else if (error?.message) { + if (error.message.includes('past')) { + userErrorMessage = 'Event date cannot be in the past'; + } else if (error.message.includes('validation')) { + userErrorMessage = 'Please check all required fields'; + } else { + userErrorMessage = error.message; + } + } + + errorMessage.value = userErrorMessage; } finally { loading.value = false; } diff --git a/composables/useEvents.ts b/composables/useEvents.ts index d50c06f..5df6772 100644 --- a/composables/useEvents.ts +++ b/composables/useEvents.ts @@ -352,6 +352,47 @@ export const useEvents = () => { cache.clear(); }; + /** + * Delete an event (board/admin only) + */ + const deleteEvent = async (eventId: string) => { + loading.value = true; + error.value = null; + + try { + const response = await $fetch<{ success: boolean; message: string; deleted: any }>(`/api/events/${eventId}`, { + method: 'DELETE' + }); + + if (response.success) { + // Remove event from local state + const eventIndex = events.value.findIndex(e => + e.event_id === eventId || + e.id === eventId || + (e as any).Id === eventId + ); + + if (eventIndex !== -1) { + events.value.splice(eventIndex, 1); + } + + // Clear cache and refresh + clearCache(); + await fetchEvents({ force: true }); + + return response; + } else { + throw new Error(response.message || 'Failed to delete event'); + } + } catch (err: any) { + error.value = err.message || 'Failed to delete event'; + console.error('Error deleting event:', err); + throw err; + } finally { + loading.value = false; + } + }; + /** * Refresh events data */ @@ -385,7 +426,9 @@ export const useEvents = () => { // Methods fetchEvents, createEvent, + deleteEvent, rsvpToEvent, + cancelRSVP, updateAttendance, getCalendarEvents, getUpcomingEvents, diff --git a/server/api/events/[id].delete.ts b/server/api/events/[id].delete.ts new file mode 100644 index 0000000..8ccdc41 --- /dev/null +++ b/server/api/events/[id].delete.ts @@ -0,0 +1,135 @@ +// server/api/events/[id].delete.ts +import { createNocoDBEventsClient } from '~/server/utils/nocodb-events'; +import { createSessionManager } from '~/server/utils/session'; + +export default defineEventHandler(async (event) => { + console.log('[api/events/[id].delete] ========================='); + console.log('[api/events/[id].delete] DELETE /api/events/[id] - Delete event'); + console.log('[api/events/[id].delete] Request from:', getClientIP(event)); + + try { + const eventId = getRouterParam(event, 'id'); + + if (!eventId) { + throw createError({ + statusCode: 400, + statusMessage: 'Event ID is required' + }); + } + + console.log('[api/events/[id].delete] Deleting event:', eventId); + + // Get user session using the working session manager + const sessionManager = createSessionManager(); + const cookieHeader = getHeader(event, 'cookie'); + const session = sessionManager.getSession(cookieHeader); + + if (!session || !session.user) { + throw createError({ + statusCode: 401, + statusMessage: 'Authentication required' + }); + } + + // Check if user has permission to delete events (board or admin only) + const userTier = session.user.tier; + if (userTier !== 'board' && userTier !== 'admin') { + throw createError({ + statusCode: 403, + statusMessage: 'Only board members and admins can delete events' + }); + } + + console.log('[api/events/[id].delete] ✅ User authorized to delete events:', session.user.email, 'tier:', userTier); + + const eventsClient = createNocoDBEventsClient(); + + // First, get the event to verify it exists and get the event_id for RSVP cleanup + let eventToDelete; + try { + eventToDelete = await eventsClient.findOne(eventId); + console.log('[api/events/[id].delete] Found event to delete:', eventToDelete.title, 'event_id:', eventToDelete.event_id); + } catch (error: any) { + if (error.statusCode === 404) { + throw createError({ + statusCode: 404, + statusMessage: 'Event not found' + }); + } + throw error; + } + + // Get all RSVPs for this event to clean them up + const eventIdentifier = eventToDelete.event_id || eventToDelete.id || (eventToDelete as any).Id; + console.log('[api/events/[id].delete] Getting RSVPs for event identifier:', eventIdentifier); + + const eventRSVPs = await eventsClient.getEventRSVPs(eventIdentifier.toString()); + console.log('[api/events/[id].delete] Found', eventRSVPs.length, 'RSVPs to delete'); + + // Delete all RSVPs first + let deletedRSVPs = 0; + for (const rsvp of eventRSVPs) { + try { + // Delete RSVP using its database Id + const rsvpId = rsvp.Id || rsvp.id; + if (rsvpId) { + console.log('[api/events/[id].delete] Deleting RSVP:', rsvpId); + await $fetch(eventsClient.constructor.prototype.createEventTableUrl('EventRSVPs'), { + method: 'DELETE', + headers: { + 'xc-token': eventsClient.constructor.prototype.getNocoDbConfiguration().token, + }, + body: { + Id: parseInt(rsvpId.toString()) + } + }); + deletedRSVPs++; + } + } catch (rsvpError: any) { + console.log('[api/events/[id].delete] ⚠️ Error deleting RSVP:', rsvp.Id || rsvp.id, rsvpError); + // Continue with other RSVPs even if one fails + } + } + + console.log('[api/events/[id].delete] Deleted', deletedRSVPs, 'RSVPs'); + + // Now delete the event itself using its database Id + const eventDatabaseId = (eventToDelete as any).Id || eventToDelete.id; + if (!eventDatabaseId) { + throw createError({ + statusCode: 500, + statusMessage: 'Could not determine event database ID for deletion' + }); + } + + console.log('[api/events/[id].delete] Deleting event with database ID:', eventDatabaseId); + await eventsClient.delete(eventDatabaseId.toString()); + + console.log('[api/events/[id].delete] ✅ Successfully deleted event and all associated RSVPs'); + + return { + success: true, + message: `Event "${eventToDelete.title}" and ${deletedRSVPs} associated RSVPs deleted successfully`, + deleted: { + event: { + id: eventDatabaseId, + event_id: eventToDelete.event_id, + title: eventToDelete.title + }, + rsvps_deleted: deletedRSVPs + } + }; + + } catch (error: any) { + console.error('[api/events/[id].delete] ❌ Error deleting event:', error); + + if (error.statusCode) { + throw error; + } + + throw createError({ + statusCode: 500, + statusMessage: 'Failed to delete event' + }); + } +}); diff --git a/server/utils/nocodb-events.ts b/server/utils/nocodb-events.ts index ac5b4d2..37c5c61 100644 --- a/server/utils/nocodb-events.ts +++ b/server/utils/nocodb-events.ts @@ -274,22 +274,52 @@ export function createNocoDBEventsClient() { }, /** - * Find a single event by ID + * Find a single event by ID (supports both database Id and business event_id) */ async findOne(id: string) { console.log('[nocodb-events] Fetching event ID:', id); try { - const result = await $fetch(`${createEventTableUrl(EventTable.Events)}/${id}`, { + // First, try to fetch by database Id (numeric) + if (/^\d+$/.test(id)) { + console.log('[nocodb-events] Using database Id lookup for:', id); + const result = await $fetch(`${createEventTableUrl(EventTable.Events)}/${id}`, { + headers: { + "xc-token": getNocoDbConfiguration().token, + }, + }); + + console.log('[nocodb-events] Successfully retrieved event by database Id:', result.id || (result as any).Id); + return normalizeEventFieldsFromNocoDB(result); + } + + // Otherwise, search by business event_id + console.log('[nocodb-events] Using event_id lookup for:', id); + const results = await $fetch<{list: Event[]}>(`${createEventTableUrl(EventTable.Events)}`, { headers: { "xc-token": getNocoDbConfiguration().token, }, + params: { + where: `(event_id,eq,${id})`, + limit: 1 + } }); - console.log('[nocodb-events] Successfully retrieved event:', result.id || (result as any).Id); - return normalizeEventFieldsFromNocoDB(result); + if (results.list && results.list.length > 0) { + console.log('[nocodb-events] Successfully found event by event_id:', results.list[0].id || (results.list[0] as any).Id); + return normalizeEventFieldsFromNocoDB(results.list[0]); + } + + console.log('[nocodb-events] No event found with event_id:', id); + throw createError({ + statusCode: 404, + statusMessage: 'Event not found' + }); } catch (error: any) { console.error('[nocodb-events] Error fetching event:', error); + if (error.statusCode === 404) { + throw error; // Re-throw 404 errors as-is + } handleNocoDbError(error, 'getEventById', 'Event'); throw error; }