diff --git a/src/components/dashboard/EventsSection/EventCheckIn.tsx b/src/components/dashboard/EventsSection/EventCheckIn.tsx index 6b3eea4..cd17ed9 100644 --- a/src/components/dashboard/EventsSection/EventCheckIn.tsx +++ b/src/components/dashboard/EventsSection/EventCheckIn.tsx @@ -7,7 +7,7 @@ import { DataSyncService } from "../../../scripts/database/DataSyncService"; import { Collections } from "../../../schemas/pocketbase/schema"; import { Icon } from "@iconify/react"; import toast from "react-hot-toast"; -import type { Event, AttendeeEntry } from "../../../schemas/pocketbase"; +import type { Event, EventAttendee } from "../../../schemas/pocketbase"; // Extended Event interface with additional properties needed for this component interface ExtendedEvent extends Event { @@ -51,91 +51,152 @@ const EventCheckIn = () => { // Log the check-in attempt await logger.send( - "attempt", + "info", "event_check_in", - `User ${currentUser.id} attempted to check in with code: ${eventCode}` + `Attempting to check in with code: ${eventCode}` ); - // SECURITY FIX: Instead of syncing and querying IndexedDB with the event code, - // directly query PocketBase for the event with the given code - // This prevents the event code from being stored in IndexedDB - const pb = auth.getPocketBase(); - const records = await pb.collection(Collections.EVENTS).getList(1, 1, { - filter: `event_code = "${eventCode}"`, - }); - - // Convert the first result to our Event type - let event: Event | null = null; - if (records.items.length > 0) { - event = Get.convertUTCToLocal(records.items[0] as unknown as Event); - } - - if (!event) { + // Validate event code + if (!eventCode || eventCode.trim() === "") { await logger.send( "error", "event_check_in", - `Check-in failed: Invalid event code "${eventCode}"` + "Check-in failed: Empty event code" ); - throw new Error("Invalid event code"); + toast.error("Please enter an event code"); + return; } - // Check if user is already checked in - const attendees = event.attendees || []; - if (attendees.some((entry) => entry.user_id === currentUser.id)) { + // Get event by code + const events = await get.getList( + Collections.EVENTS, + 1, + 1, + `event_code="${eventCode}"` + ); + + if (events.totalItems === 0) { await logger.send( "error", "event_check_in", - `Check-in failed: User ${currentUser.id} already checked in to event ${event.event_name} (${event.id})` + `Check-in failed: Invalid event code: ${eventCode}` ); - throw new Error("You have already checked in to this event"); + toast.error("Invalid event code. Please try again."); + return; + } + + const event = events.items[0]; + + // Check if event is published + if (!event.published) { + await logger.send( + "error", + "event_check_in", + `Check-in failed: Event not published: ${event.event_name}` + ); + toast.error("This event is not currently available for check-in"); + return; } // Check if the event is active (has started and hasn't ended yet) const currentTime = new Date(); - const eventStartDate = new Date(event.start_date); // Now properly converted to local time by Get - const eventEndDate = new Date(event.end_date); // Now properly converted to local time by Get + const eventStartDate = new Date(event.start_date); + const eventEndDate = new Date(event.end_date); - if (eventStartDate > currentTime) { + if (currentTime < eventStartDate) { await logger.send( "error", "event_check_in", - `Check-in failed: Event ${event.event_name} (${event.id}) has not started yet` + `Check-in failed: Event has not started yet: ${event.event_name}` ); - throw new Error("This event has not started yet"); + toast.error(`This event hasn't started yet. It begins on ${eventStartDate.toLocaleDateString()} at ${eventStartDate.toLocaleTimeString()}`); + return; } - if (eventEndDate < currentTime) { + if (currentTime > eventEndDate) { await logger.send( "error", "event_check_in", - `Check-in failed: Event ${event.event_name} (${event.id}) has already ended` + `Check-in failed: Event has already ended: ${event.event_name}` ); - throw new Error("This event has already ended"); + toast.error("This event has already ended"); + return; } - // Log successful validation before proceeding + // Check if user is already checked in + const attendees = await get.getList( + Collections.EVENT_ATTENDEES, + 1, + 1, + `user="${currentUser.id}" && event="${event.id}"` + ); + + if (attendees.totalItems > 0) { + await logger.send( + "error", + "event_check_in", + `Check-in failed: Already checked in to event: ${event.event_name}` + ); + toast.error("You have already checked in to this event"); + return; + } + + // Set current event for check-in + setCurrentCheckInEvent(event); + + // Log successful event lookup await logger.send( "info", "event_check_in", - `Check-in validation successful for user ${currentUser.id} to event ${event.event_name} (${event.id})` + `Found event for check-in: ${event.event_name}` ); + // Store event code in local storage for offline check-in + await dataSync.storeEventCode(eventCode); + + // Show event details toast only for non-food events + // For food events, we'll show the toast after food selection + if (!event.has_food) { + toast.success( +
+ Event found! +

{event.event_name}

+

+ {event.points_to_reward > 0 ? `${event.points_to_reward} points` : "No points"} +

+
, + { duration: 5000 } + ); + } + // If event has food, show food selection modal if (event.has_food) { - setCurrentCheckInEvent(event); + // Show food-specific toast + toast.success( +
+ Event with food found! +

{event.event_name}

+

Please select your food preference

+
, + { duration: 5000 } + ); + const modal = document.getElementById("foodSelectionModal") as HTMLDialogElement; - modal.showModal(); + if (modal) modal.showModal(); } else { - // If no food, complete check-in directly - await completeCheckIn(event, null); + // If no food, show confirmation modal + const modal = document.getElementById("confirmCheckInModal") as HTMLDialogElement; + if (modal) modal.showModal(); } } catch (error: any) { - toast.error(error?.message || "Failed to check in to event"); + console.error("Error checking in:", error); + toast.error(error.message || "An error occurred during check-in"); } } async function completeCheckIn(event: Event, foodSelection: string | null): Promise { try { + setIsLoading(true); const auth = Authentication.getInstance(); const update = Update.getInstance(); const logger = SendLog.getInstance(); @@ -146,119 +207,108 @@ const EventCheckIn = () => { throw new Error("You must be logged in to check in to events"); } - // Check if user is already checked in - const userId = auth.getUserId(); - - if (!userId) { - toast.error("You must be logged in to check in to an event"); - return; - } - - // Initialize attendees array if it doesn't exist - const attendees = event.attendees || []; + const userId = currentUser.id; + const eventId = event.id; // Check if user is already checked in - const isAlreadyCheckedIn = attendees.some( - (attendee) => attendee.user_id === userId + const get = Get.getInstance(); + const existingAttendees = await get.getList( + Collections.EVENT_ATTENDEES, + 1, + 1, + `user="${userId}" && event="${eventId}"` ); + const isAlreadyCheckedIn = existingAttendees.totalItems > 0; + if (isAlreadyCheckedIn) { - toast("You are already checked in to this event", { - icon: '⚠️', - style: { - borderRadius: '10px', - background: '#FFC107', - color: '#000', - }, - }); - return; - } - - // Create attendee entry with check-in details - const attendeeEntry: AttendeeEntry = { - user_id: currentUser.id, - time_checked_in: new Date().toISOString(), // Will be properly converted to UTC by Update - food: foodSelection || "none", - }; - - // Get existing attendees or initialize empty array - const existingAttendees = event.attendees || []; - - // Check if user is already checked in - if (existingAttendees.some((entry) => entry.user_id === currentUser.id)) { throw new Error("You have already checked in to this event"); } - // Add new attendee entry to the array - const updatedAttendees = [...existingAttendees, attendeeEntry]; + // Create new attendee record + const attendeeData = { + user: userId, + event: eventId, + food_ate: foodSelection || "", + time_checked_in: new Date().toISOString(), + points_earned: event.points_to_reward || 0 + }; - // Update attendees array with the new entry - await update.updateField("events", event.id, "attendees", updatedAttendees); + // Create the attendee record using PocketBase's create method + // This will properly use the collection rules defined in PocketBase + try { + // Use the update.create method which calls PocketBase's collection.create method + await update.create(Collections.EVENT_ATTENDEES, attendeeData); - // SECURITY FIX: Instead of syncing the entire events collection which would store event codes in IndexedDB, - // only sync the user's collection to update their points - if (event.points_to_reward > 0) { - await dataSync.syncCollection(Collections.USERS); + console.log("Successfully created attendance record"); + } catch (createError: any) { + console.error("Error creating attendance record:", createError); + + // Check if this is a duplicate record error + if (createError.status === 400 && createError.data?.data?.user?.code === "validation_not_unique") { + throw new Error("You have already checked in to this event"); + } + + throw createError; } - // If food selection was made, log it - if (foodSelection) { - await logger.send( - "update", - "event check-in", - `Food selection for ${event.event_name}: ${foodSelection}` - ); - } - - // Award points to user if available - if (event.points_to_reward > 0) { - const userPoints = currentUser.points || 0; - await update.updateField( - "users", - currentUser.id, - "points", - userPoints + event.points_to_reward - ); - - // Log the points award - await logger.send( - "update", - "event check-in", - `Awarded ${event.points_to_reward} points for checking in to ${event.event_name}` - ); - } - - // Show success message with points if awarded - toast.success( - `Successfully checked in to ${event.event_name}${event.points_to_reward > 0 - ? ` (+${event.points_to_reward} points!)` - : "" - }` - ); - - // Log the check-in + // Log successful check-in await logger.send( - "check_in", - "events", - `Checked in to event ${event.event_name}` + "info", + "event_check_in", + `Successfully checked in to event: ${event.event_name}` ); - // Close the food selection modal if it's open - const modal = document.getElementById("foodSelectionModal") as HTMLDialogElement; - if (modal) { - modal.close(); - setFoodInput(""); - } + // Clear event code from local storage + await dataSync.clearEventCode(); + + // Show success message with event name and points + const pointsMessage = event.points_to_reward > 0 + ? ` (+${event.points_to_reward} points!)` + : ""; + toast.success(`Successfully checked in to ${event.event_name}${pointsMessage}`); + setCurrentCheckInEvent(null); + setFoodInput(""); } catch (error: any) { - toast.error(error?.message || "Failed to check in to event"); + console.error("Error completing check-in:", error); + toast.error(error.message || "An error occurred during check-in"); + } finally { + setIsLoading(false); } } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (currentCheckInEvent) { - await completeCheckIn(currentCheckInEvent, foodInput.trim()); - setCurrentCheckInEvent(null); + if (!currentCheckInEvent) return; + + try { + const auth = Authentication.getInstance(); + const logger = SendLog.getInstance(); + const get = Get.getInstance(); + + const currentUser = auth.getCurrentUser(); + if (!currentUser) { + throw new Error("You must be logged in to check in to events"); + } + + // Get existing attendees or initialize empty array + const existingAttendees = await get.getList( + Collections.EVENT_ATTENDEES, + 1, + 1, + `user="${currentUser.id}" && event="${currentCheckInEvent.id}"` + ); + + // Check if user is already checked in + if (existingAttendees.totalItems > 0) { + throw new Error("You have already checked in to this event"); + } + + // Complete check-in with food selection + await completeCheckIn(currentCheckInEvent, foodInput); + } catch (error: any) { + console.error("Error submitting check-in:", error); + toast.error(error.message || "An error occurred during check-in"); } }; @@ -326,7 +376,13 @@ const EventCheckIn = () => { {/* Food Selection Modal */}
-

Food Selection

+

{currentCheckInEvent?.event_name}

+
+ {currentCheckInEvent?.event_description} +
+
+ {currentCheckInEvent?.points_to_reward} points +

This event has food! Please let us know what you'd like to eat:

@@ -345,7 +401,17 @@ const EventCheckIn = () => { modal.close(); setCurrentCheckInEvent(null); }}>Cancel - +
@@ -353,6 +419,58 @@ const EventCheckIn = () => {
+ + {/* Confirmation Modal (for events without food) */} + +
+

{currentCheckInEvent?.event_name}

+
+ {currentCheckInEvent?.event_description} +
+
+ {currentCheckInEvent?.points_to_reward} points +
+

Are you sure you want to check in to this event?

+
+ + +
+
+
+ +
+
); }; diff --git a/src/components/dashboard/EventsSection/EventLoad.tsx b/src/components/dashboard/EventsSection/EventLoad.tsx index 8082998..6e25136 100644 --- a/src/components/dashboard/EventsSection/EventLoad.tsx +++ b/src/components/dashboard/EventsSection/EventLoad.tsx @@ -5,7 +5,7 @@ import { Authentication } from "../../../scripts/pocketbase/Authentication"; import { DataSyncService } from "../../../scripts/database/DataSyncService"; import { DexieService } from "../../../scripts/database/DexieService"; import { Collections } from "../../../schemas/pocketbase/schema"; -import type { Event, AttendeeEntry } from "../../../schemas/pocketbase"; +import type { Event, AttendeeEntry, EventAttendee } from "../../../schemas/pocketbase"; // Extended Event interface with additional properties needed for this component interface ExtendedEvent extends Event { @@ -132,76 +132,111 @@ const EventLoad = () => { ); const renderEventCard = (event: Event) => { - const startDate = new Date(event.start_date); - const endDate = new Date(event.end_date); - const now = new Date(); - const isPastEvent = endDate < now; + try { + // Get authentication instance + const auth = Authentication.getInstance(); + const currentUser = auth.getCurrentUser(); - // Get current user to check attendance - const auth = Authentication.getInstance(); - const currentUser = auth.getCurrentUser(); - const hasAttended = currentUser && event.attendees?.some(entry => entry.user_id === currentUser.id); + // Check if user has attended this event by querying the event_attendees collection + let hasAttended = false; + if (currentUser) { + // We'll check attendance status when displaying the card + // This will be done asynchronously after rendering + setTimeout(async () => { + try { + const get = Get.getInstance(); + const attendees = await get.getList( + "event_attendees", + 1, + 1, + `user="${currentUser.id}" && event="${event.id}"` + ); - // Store event data in window object with unique ID - const eventDataId = `event_${event.id}`; - window[eventDataId] = event; + const hasAttendedEvent = attendees.totalItems > 0; - return ( -
-
-
-
-
-

{event.event_name}

-
-
{event.points_to_reward} pts
-
- {startDate.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - })} - {" • "} - {startDate.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - })} + // Update the card UI based on attendance status + const cardElement = document.getElementById(`event-card-${event.id}`); + if (cardElement && hasAttendedEvent) { + const attendedBadge = cardElement.querySelector('.attended-badge'); + if (attendedBadge) { + (attendedBadge as HTMLElement).style.display = 'flex'; + } + } + } catch (error) { + console.error("Error checking attendance status:", error); + } + }, 0); + } + + // Store event data in window object with unique ID + const eventDataId = `event_${event.id}`; + window[eventDataId] = event; + + const startDate = new Date(event.start_date); + const endDate = new Date(event.end_date); + const now = new Date(); + const isPastEvent = endDate < now; + + return ( +
+
+
+
+
+

{event.event_name}

+
+
{event.points_to_reward} pts
+
+ {startDate.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + })} + {" • "} + {startDate.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + })} +
-
-
- {event.event_description || "No description available"} -
+
+ {event.event_description || "No description available"} +
-
- {event.files && event.files.length > 0 && ( - - )} - {isPastEvent && ( -
- - {hasAttended ? 'Attended' : 'Not Attended'} +
+ {event.files && event.files.length > 0 && ( + + )} + {isPastEvent && ( +
+ + {hasAttended ? 'Attended' : 'Not Attended'} +
+ )} +
+ {event.location}
- )} -
- {event.location}
-
- ); + ); + } catch (error) { + console.error("Error rendering event card:", error); + return null; + } }; const loadEvents = async () => { diff --git a/src/components/dashboard/Officer_EventManagement.astro b/src/components/dashboard/Officer_EventManagement.astro index 7d70e50..14839a7 100644 --- a/src/components/dashboard/Officer_EventManagement.astro +++ b/src/components/dashboard/Officer_EventManagement.astro @@ -6,37 +6,45 @@ import EventEditor from "./Officer_EventManagement/EventEditor"; import FilePreview from "./universal/FilePreview"; import Attendees from "./Officer_EventManagement/Attendees"; import type { Event, AttendeeEntry } from "../../schemas/pocketbase"; +import { DataSyncService } from "../../scripts/database/DataSyncService"; +import toast from "react-hot-toast"; // Get instances const get = Get.getInstance(); const auth = Authentication.getInstance(); interface ListResponse { - page: number; - perPage: number; - totalItems: number; - totalPages: number; - items: T[]; + page: number; + perPage: number; + totalItems: number; + totalPages: number; + items: T[]; } // Initialize variables let eventResponse: ListResponse = { - page: 1, - perPage: 5, - totalItems: 0, - totalPages: 0, - items: [], + page: 1, + perPage: 5, + totalItems: 0, + totalPages: 0, + items: [], }; let upcomingEvents: Event[] = []; // Fetch events try { - if (auth.isAuthenticated()) { - eventResponse = await get.getList("events", 1, 5, "", "-start_date"); - upcomingEvents = eventResponse.items; - } + if (auth.isAuthenticated()) { + eventResponse = await get.getList( + "events", + 1, + 5, + "", + "-start_date" + ); + upcomingEvents = eventResponse.items; + } } catch (error) { - console.error("Failed to fetch events:", error); + console.error("Failed to fetch events:", error); } const totalEvents = eventResponse.totalItems; @@ -45,999 +53,1066 @@ const currentPage = eventResponse.page; ---
-
-
-

Event Management

-

- Manage and create IEEE UCSD events -

-
-
- - -
-
-
- Total Events +
+

Event Management

+

+ Manage and create IEEE UCSD events +

+
+
+ + +
+
+
+
+ Total Events +
+
+ - +
+
+
+ Current Academic Term +
+
+
- - -
-
-
- Current Academic Term -
-
-
-
-
-
-
- Unique Attendees +
+
+ Unique Attendees +
+
+ - +
+
+
+ Current Academic Term +
+
+
- - +
+
+ Recurring Attendees +
+
+ - +
+
+
+ Current Quarter (-) +
+
+
-
-
- Current Academic Term -
-
-
+ +
-
-
- Recurring Attendees -
-
- - -
-
-
- Current Quarter (-) -
-
-
-
-
+
+

+
+
+ +
+ Events List +
+
+ + +
+

- -
-
-

-
-
- -
- Events List -
-
- - -
-

+
-
+ +
+ +
+
+ +
+ + + + +
+
- -
- -
-
- -
- - - - + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
-
- -
- - -
{ - // Reset cache timestamp to force refresh - window.lastCacheUpdate = 0; - // Refresh events list - window.fetchEvents?.(); - }} + client:load + onEventSaved={() => { + // Reset cache timestamp to force refresh + window.lastCacheUpdate = 0; + // Refresh events list + window.fetchEvents?.(); + }} /> - - - diff --git a/src/components/dashboard/Officer_EventManagement/Attendees.tsx b/src/components/dashboard/Officer_EventManagement/Attendees.tsx index a589b3f..d16bb9f 100644 --- a/src/components/dashboard/Officer_EventManagement/Attendees.tsx +++ b/src/components/dashboard/Officer_EventManagement/Attendees.tsx @@ -5,13 +5,22 @@ import { SendLog } from '../../../scripts/pocketbase/SendLog'; import { DataSyncService } from '../../../scripts/database/DataSyncService'; import { Collections } from '../../../schemas/pocketbase/schema'; import { Icon } from "@iconify/react"; -import type { Event, AttendeeEntry, User as SchemaUser } from "../../../schemas/pocketbase"; +import type { Event, User as SchemaUser, EventAttendee } from "../../../schemas/pocketbase"; +import toast from "react-hot-toast"; // Extended User interface with additional properties needed for this component interface User extends SchemaUser { member_type: string; } +// Define AttendeeEntry interface locally +interface AttendeeEntry { + user_id: string; + time_checked_in: string; + food: string; + points_earned?: number; +} + // Cache for storing user data const userCache = new Map([]); + const [refreshKey, setRefreshKey] = useState(0); // Add a refresh key to force re-fetching const get = Get.getInstance(); const auth = Authentication.getInstance(); @@ -141,6 +150,8 @@ export default function Attendees() { // Optimized user data fetching with cache const fetchUserData = useCallback(async (userIds: string[]) => { + if (!userIds.length) return new Map(); + const now = Date.now(); const uncachedIds: string[] = []; const cachedUsers = new Map(); @@ -160,48 +171,86 @@ export default function Attendees() { return cachedUsers; } - // Fetch uncached users try { - const dataSync = DataSyncService.getInstance(); + // Create a filter to get all uncached users in one request + const userFilter = uncachedIds.map(id => `id="${id}"`).join(" || "); - // Sync users collection for the uncached IDs - if (uncachedIds.length > 0) { - const idFilter = uncachedIds.map(id => `id = "${id}"`).join(' || '); - await dataSync.syncCollection(Collections.USERS, idFilter); - } - - // Get users from IndexedDB - const users = await Promise.all( - uncachedIds.map(async id => { - try { - return await dataSync.getItem(Collections.USERS, id); - } catch (error) { - console.error(`Failed to fetch user ${id}:`, error); - return null; - } - }) + // Fetch all uncached users in one request + const usersResponse = await get.getAll( + Collections.USERS, + userFilter ); - // Update cache and merge with cached users - users.forEach(user => { - if (user) { - userCache.set(user.id, { data: user, timestamp: now }); - cachedUsers.set(user.id, user); + // Process the fetched users + usersResponse.forEach(user => { + // Add member_type if it doesn't exist + const userWithMemberType = { + ...user, + member_type: user.member_type || "N/A" + }; + cachedUsers.set(user.id, userWithMemberType); + + // Update cache + userCache.set(user.id, { + data: userWithMemberType, + timestamp: now + }); + }); + + // Create placeholders for any users that weren't found + const fetchedIds = new Set(usersResponse.map(user => user.id)); + uncachedIds.forEach(id => { + if (!fetchedIds.has(id) && !cachedUsers.has(id)) { + // Create a placeholder user + const placeholderUser: User = { + id, + name: `User ${id}`, + email: "N/A", + emailVisibility: false, + verified: false, + created: "", + updated: "", + member_type: "N/A" + }; + cachedUsers.set(id, placeholderUser); } }); } catch (error) { - console.error('Failed to fetch uncached users:', error); + console.error('Failed to fetch users:', error); + + // Create placeholders for all uncached users that failed to fetch + uncachedIds.forEach(id => { + if (!cachedUsers.has(id)) { + const placeholderUser: User = { + id, + name: `User ${id}`, + email: "N/A", + emailVisibility: false, + verified: false, + created: "", + updated: "", + member_type: "N/A" + }; + cachedUsers.set(id, placeholderUser); + } + }); } return cachedUsers; }, []); + // Function to refresh attendees data + const refreshAttendees = useCallback(() => { + setRefreshKey(prev => prev + 1); + }, []); + // Listen for the custom event useEffect(() => { const handleUpdateAttendees = async (e: CustomEvent<{ eventId: string; eventName: string }>) => { setEventId(e.detail.eventId); setEventName(e.detail.eventName); setCurrentPage(1); // Reset pagination on new event + setSearchTerm(''); // Clear search on new event // Log the attendees view action try { @@ -217,17 +266,22 @@ export default function Attendees() { }; window.addEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener); + + // Expose refresh function to window + (window as any).refreshAttendees = refreshAttendees; + return () => { window.removeEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener); + delete (window as any).refreshAttendees; }; - }, []); + }, [refreshAttendees]); // Update search terms when search input changes useEffect(() => { updateProcessedSearchTerms(searchTerm); }, [searchTerm, updateProcessedSearchTerms]); - // Fetch event data when eventId changes + // Fetch event data when eventId changes or refreshKey changes useEffect(() => { let isMounted = true; const fetchEventData = async () => { @@ -243,44 +297,118 @@ export default function Attendees() { setLoading(true); setError(null); + if (!eventId) { + setAttendeesList([]); + setUsers(new Map()); + return; + } + + // Clear cache to ensure fresh data const dataSync = DataSyncService.getInstance(); + await dataSync.clearCache(); + await dataSync.syncCollection(Collections.EVENTS, `id="${eventId}"`); - // Sync the event data - await dataSync.syncCollection(Collections.EVENTS, `id = "${eventId}"`); - - // Get the event from IndexedDB - const event = await dataSync.getItem(Collections.EVENTS, eventId); - - if (!isMounted) return; + const event = await get.getOne(Collections.EVENTS, eventId); if (!event) { - setError('Event not found'); + setError("Event not found"); setAttendeesList([]); setUsers(new Map()); return; } - if (!event.attendees?.length) { - setAttendeesList([]); - setUsers(new Map()); + // Fetch attendees from event_attendees collection with a higher limit + const attendeesList = await get.getList( + Collections.EVENT_ATTENDEES, + 1, + 2000, // Increased limit to handle more attendees + `event="${eventId}"` + ); + + if (!attendeesList.items.length) { + if (isMounted) { + setAttendeesList([]); + setUsers(new Map()); + } return; } - setAttendeesList(event.attendees); - - // Fetch user details with cache - const userIds = [...new Set(event.attendees.map(a => a.user_id))]; - const userMap = await fetchUserData(userIds); + // Transform EventAttendee records to match the expected format + const transformedAttendees = attendeesList.items.map(attendee => ({ + user_id: attendee.user, // This is the user ID (relation) + time_checked_in: attendee.time_checked_in, + food: attendee.food_ate, + points_earned: attendee.points_earned + })); if (isMounted) { - setUsers(userMap); + setAttendeesList(transformedAttendees); } + + // Fetch all users at once to improve performance + const userIds = transformedAttendees.map(a => a.user_id); + + // Create a filter to get all users in one request + const userFilter = userIds.map(id => `id="${id}"`).join(" || "); + + try { + // Fetch all users directly from PocketBase in one request + const usersResponse = await get.getAll( + Collections.USERS, + userFilter + ); + + // Create a map of users + const userMap = new Map(); + usersResponse.forEach(user => { + // Add member_type if it doesn't exist + const userWithMemberType = { + ...user, + member_type: user.member_type || "N/A" + }; + userMap.set(user.id, userWithMemberType); + + // Update cache + userCache.set(user.id, { + data: userWithMemberType, + timestamp: Date.now() + }); + }); + + // For any missing users, create placeholders + userIds.forEach(id => { + if (!userMap.has(id)) { + const placeholderUser: User = { + id, + name: `User ${id}`, + email: "N/A", + emailVisibility: false, + verified: false, + created: "", + updated: "", + member_type: "N/A" + }; + userMap.set(id, placeholderUser); + } + }); + + if (isMounted) { + setUsers(userMap); + } + } catch (error) { + console.error("Failed to fetch users:", error); + + // Fallback to individual user fetching + const userMap = await fetchUserData(userIds); + if (isMounted) { + setUsers(userMap); + } + } + + toast.success(`Loaded ${attendeesList.items.length} attendees for ${event.event_name}`); } catch (error) { - if (isMounted) { - console.error('Failed to fetch event data:', error); - setError('Failed to load event data'); - setAttendeesList([]); - } + console.error("Error fetching event data:", error); + setError("Failed to load event data. Please try refreshing."); } finally { if (isMounted) { setLoading(false); @@ -290,15 +418,18 @@ export default function Attendees() { fetchEventData(); return () => { isMounted = false; }; - }, [eventId, auth, fetchUserData]); + }, [eventId, auth, fetchUserData, refreshKey]); // Reset state when modal is closed useEffect(() => { const handleModalClose = () => { setEventId(''); + setEventName(''); setAttendeesList([]); setUsers(new Map()); setError(null); + setSearchTerm(''); + setCurrentPage(1); }; const modal = document.getElementById('attendeesModal'); @@ -332,15 +463,22 @@ export default function Attendees() { 'Graduation Year', 'Major', 'Check-in Time', - 'Food Choice' + 'Food Choice', + 'Points Earned' ].map(escapeCSV); // Create CSV rows const rows = attendeesList.map(attendee => { const user = users.get(attendee.user_id); - const checkInTime = new Date(attendee.time_checked_in).toLocaleString(); + let checkInTime = ''; + try { + checkInTime = new Date(attendee.time_checked_in).toLocaleString(); + } catch (e) { + checkInTime = attendee.time_checked_in || 'N/A'; + } + return [ - user?.name || 'Unknown User', + user?.name || `User ${attendee.user_id}`, user?.email || 'N/A', user?.pid || 'N/A', user?.member_id || 'N/A', @@ -348,7 +486,8 @@ export default function Attendees() { user?.graduation_year || 'N/A', user?.major || 'N/A', checkInTime, - attendee.food || 'N/A' + attendee.food || 'N/A', + attendee.points_earned || 'N/A' ].map(escapeCSV); }); @@ -375,6 +514,8 @@ export default function Attendees() { link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); // Clean up the URL object + + toast.success(`Downloaded ${rows.length} attendee records`); }; if (loading) { @@ -390,6 +531,13 @@ export default function Attendees() {
{error} +
); } @@ -403,6 +551,13 @@ export default function Attendees() {

No attendees yet

+
); } @@ -426,13 +581,22 @@ export default function Attendees() { />
- +
+ + +
{/* Stats Row */} @@ -462,16 +626,22 @@ export default function Attendees() { Major Check-in Time Food Choice + Points {paginatedAttendees.map((attendee, index) => { const user = users.get(attendee.user_id); - const checkInTime = new Date(attendee.time_checked_in).toLocaleString(); + let checkInTime = ''; + try { + checkInTime = new Date(attendee.time_checked_in).toLocaleString(); + } catch (e) { + checkInTime = attendee.time_checked_in || 'N/A'; + } return ( - + @@ -480,6 +650,7 @@ export default function Attendees() { + ); })} diff --git a/src/components/dashboard/Officer_EventManagement/EventEditor.tsx b/src/components/dashboard/Officer_EventManagement/EventEditor.tsx index 3e5eb58..93fe614 100644 --- a/src/components/dashboard/Officer_EventManagement/EventEditor.tsx +++ b/src/components/dashboard/Officer_EventManagement/EventEditor.tsx @@ -9,6 +9,7 @@ import FilePreview from "../universal/FilePreview"; import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase"; import { DataSyncService } from '../../../scripts/database/DataSyncService'; import { Collections } from '../../../schemas/pocketbase/schema'; +import toast from "react-hot-toast"; // Note: Date conversion is now handled automatically by the Get and Update classes. // When fetching events, UTC dates are converted to local time by the Get class. @@ -510,18 +511,19 @@ const ErrorDisplay = memo(({ error, onRetry }: { error: string; onRetry: () => v export default function EventEditor({ onEventSaved }: EventEditorProps) { // State for form data and UI const [event, setEvent] = useState({ - id: '', - event_name: '', - event_description: '', - event_code: '', - location: '', + id: "", + created: "", + updated: "", + event_name: "", + event_description: "", + event_code: "", + location: "", files: [], points_to_reward: 0, - start_date: Get.formatLocalDate(new Date(), false), - end_date: Get.formatLocalDate(new Date(), false), + start_date: "", + end_date: "", published: false, - has_food: false, - attendees: [] + has_food: false }); const [previewUrl, setPreviewUrl] = useState(""); @@ -557,7 +559,16 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) { const initializeEventData = useCallback(async (eventId: string) => { try { if (eventId) { - const eventData = await services.get.getOne("events", eventId); + // Clear cache to ensure fresh data + const dataSync = DataSyncService.getInstance(); + await dataSync.clearCache(); + + // Fetch fresh event data + const eventData = await services.get.getOne(Collections.EVENTS, eventId); + + if (!eventData) { + throw new Error("Event not found"); + } // Ensure dates are properly formatted for datetime-local input if (eventData.start_date) { @@ -573,20 +584,22 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) { } setEvent(eventData); + console.log("Event data loaded successfully:", eventData); } else { setEvent({ id: '', + created: '', + updated: '', event_name: '', event_description: '', event_code: '', location: '', files: [], points_to_reward: 0, - start_date: Get.formatLocalDate(new Date(), false), - end_date: Get.formatLocalDate(new Date(), false), + start_date: '', + end_date: '', published: false, - has_food: false, - attendees: [] + has_food: false }); } setSelectedFiles(new Map()); @@ -595,7 +608,7 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) { setHasUnsavedChanges(false); } catch (error) { console.error("Failed to initialize event data:", error); - alert("Failed to load event data. Please try again."); + toast.error("Failed to load event data. Please try again."); } }, [services.get]); @@ -614,7 +627,7 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) { modal.showModal(); } catch (error) { console.error("Failed to open edit modal:", error); - alert("Failed to open edit modal. Please try again."); + toast.error("Failed to open edit modal. Please try again."); } }; @@ -637,23 +650,25 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) { } setEvent({ - id: '', - event_name: '', - event_description: '', - event_code: '', - location: '', + id: "", + created: "", + updated: "", + event_name: "", + event_description: "", + event_code: "", + location: "", files: [], points_to_reward: 0, - start_date: Get.formatLocalDate(new Date(), false), - end_date: Get.formatLocalDate(new Date(), false), + start_date: "", + end_date: "", published: false, - has_food: false, - attendees: [] + has_food: false }); setSelectedFiles(new Map()); setFilesToDelete(new Set()); setShowPreview(false); - setHasUnsavedChanges(false); + setPreviewUrl(""); + setPreviewFilename(""); const modal = document.getElementById("editEventModal") as HTMLDialogElement; if (modal) modal.close(); @@ -663,176 +678,160 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) { e.preventDefault(); if (isSubmitting) return; - const form = e.target as HTMLFormElement; - const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement; - const cancelButton = form.querySelector('button[type="button"]') as HTMLButtonElement; - - setIsSubmitting(true); - if (submitButton) submitButton.disabled = true; - if (cancelButton) cancelButton.disabled = true; - try { + setIsSubmitting(true); window.showLoading?.(); - const pb = services.auth.getPocketBase(); - console.log('Form submission started'); - console.log('Event data:', event); + const submitButton = document.getElementById("submitEventButton") as HTMLButtonElement; + const cancelButton = document.getElementById("cancelEventButton") as HTMLButtonElement; - const formData = new FormData(form); - const eventData = { - event_name: formData.get("editEventName"), - event_code: formData.get("editEventCode"), - event_description: formData.get("editEventDescription"), - location: formData.get("editEventLocation"), - points_to_reward: Number(formData.get("editEventPoints")), + if (submitButton) { + submitButton.disabled = true; + submitButton.classList.add("btn-disabled"); + } + if (cancelButton) cancelButton.disabled = true; + + // Get form data + const formData = new FormData(e.currentTarget); + + // Create updated event object + const updatedEvent: Event = { + id: event.id, + created: event.created, + updated: event.updated, + event_name: formData.get("editEventName") as string, + event_description: formData.get("editEventDescription") as string, + event_code: formData.get("editEventCode") as string, + location: formData.get("editEventLocation") as string, + files: event.files || [], + points_to_reward: parseInt(formData.get("editEventPoints") as string) || 0, start_date: formData.get("editEventStartDate") as string, end_date: formData.get("editEventEndDate") as string, published: formData.get("editEventPublished") === "on", - has_food: formData.get("editEventHasFood") === "on", - attendees: event.attendees || [] + has_food: formData.get("editEventHasFood") === "on" }; + // Log the update attempt + await services.sendLog.send( + "update", + "event", + `Updating event: ${updatedEvent.event_name} (${updatedEvent.id})` + ); + + // Process file changes + const uploadQueue = new UploadQueue(); + const fileChanges: FileChanges = { + added: selectedFiles, + deleted: filesToDelete, + unchanged: event.files?.filter(file => !filesToDelete.has(file)) || [] + }; + + // Handle file deletions + if (fileChanges.deleted.size > 0) { + for (const fileId of fileChanges.deleted) { + await services.fileManager.deleteFile("events", event.id, fileId); + } + } + + // Handle file uploads + if (fileChanges.added.size > 0) { + for (const [filename, file] of fileChanges.added.entries()) { + await uploadQueue.add(async () => { + const uploadedFile = await services.fileManager.uploadFile( + "events", + event.id, + filename, + file + ); + if (uploadedFile) { + fileChanges.unchanged.push(uploadedFile); + } + }); + } + } + + // Update the event with the new file list + updatedEvent.files = fileChanges.unchanged; + + // Save the event + let savedEvent; if (event.id) { // Update existing event - console.log('Updating event:', event.id); - await services.update.updateFields("events", event.id, eventData); + savedEvent = await services.update.updateFields( + Collections.EVENTS, + event.id, + updatedEvent + ); - // Handle file deletions first - if (filesToDelete.size > 0) { - console.log('Deleting files:', Array.from(filesToDelete)); - // Get current files - const currentRecord = await pb.collection("events").getOne(event.id); - let remainingFiles = [...currentRecord.files]; + // Clear cache to ensure fresh data + const dataSync = DataSyncService.getInstance(); + await dataSync.clearCache(); - // Remove files marked for deletion - for (const filename of filesToDelete) { - const fileIndex = remainingFiles.indexOf(filename); - if (fileIndex > -1) { - remainingFiles.splice(fileIndex, 1); - } - } + // Log success + await services.sendLog.send( + "success", + "event_update", + `Successfully updated event: ${savedEvent.event_name}` + ); - // Update record with remaining files - await pb.collection("events").update(event.id, { - files: remainingFiles - }); - - // Sync the events collection to update IndexedDB - const dataSync = DataSyncService.getInstance(); - await dataSync.syncCollection(Collections.EVENTS); - } - - // Handle file additions - if (selectedFiles.size > 0) { - try { - // Convert Map to array of Files - const filesToUpload = Array.from(selectedFiles.values()); - console.log('Uploading files:', filesToUpload.map(f => f.name)); - - // Use appendFiles to preserve existing files - await services.fileManager.appendFiles("events", event.id, "files", filesToUpload); - - // Sync the events collection to update IndexedDB - const dataSync = DataSyncService.getInstance(); - await dataSync.syncCollection(Collections.EVENTS); - } catch (error: any) { - if (error.status === 413) { - throw new Error("Files are too large. Please try uploading smaller files or fewer files at once."); - } - throw error; - } - } + // Show success toast + toast.success(`Event "${savedEvent.event_name}" updated successfully!`); } else { // Create new event - console.log('Creating new event'); - const newEvent = await pb.collection("events").create(eventData); - console.log('New event created:', newEvent); + savedEvent = await services.update.create( + Collections.EVENTS, + updatedEvent + ); - // Sync the events collection to update IndexedDB - const dataSync = DataSyncService.getInstance(); - await dataSync.syncCollection(Collections.EVENTS); + // Log success + await services.sendLog.send( + "success", + "event_create", + `Successfully created event: ${savedEvent.event_name}` + ); - // Upload files if any - if (selectedFiles.size > 0) { - try { - const filesToUpload = Array.from(selectedFiles.values()); - console.log('Uploading files:', filesToUpload.map(f => f.name)); - - // Use uploadFiles for new event - await services.fileManager.uploadFiles("events", newEvent.id, "files", filesToUpload); - } catch (error: any) { - if (error.status === 413) { - throw new Error("Files are too large. Please try uploading smaller files or fewer files at once."); - } - throw error; - } - } + // Show success toast + toast.success(`Event "${savedEvent.event_name}" created successfully!`); } - // Show success message - if (submitButton) { - submitButton.classList.remove("btn-disabled"); - submitButton.classList.add("btn-success"); - const successIcon = document.createElement('span'); - successIcon.innerHTML = ''; - submitButton.textContent = ''; - submitButton.appendChild(successIcon); - } - - // Reset all state - setHasUnsavedChanges(false); - setSelectedFiles(new Map()); - setFilesToDelete(new Set()); + // Reset form state setEvent({ - id: '', - event_name: '', - event_description: '', - event_code: '', - location: '', + id: "", + created: "", + updated: "", + event_name: "", + event_description: "", + event_code: "", + location: "", files: [], points_to_reward: 0, - start_date: Get.formatLocalDate(new Date(), false), - end_date: Get.formatLocalDate(new Date(), false), + start_date: "", + end_date: "", published: false, - has_food: false, - attendees: [] + has_food: false }); + setSelectedFiles(new Map()); + setFilesToDelete(new Set()); + setHasUnsavedChanges(false); - // Reset cache timestamp to force refresh - if (window.lastCacheUpdate) { - window.lastCacheUpdate = 0; - } - - // Trigger the callback - onEventSaved?.(); - - // Close modal directly instead of using handleModalClose + // Close modal const modal = document.getElementById("editEventModal") as HTMLDialogElement; if (modal) modal.close(); - // Force refresh of events list - if (typeof window.fetchEvents === 'function') { + // Refresh events list + if (window.fetchEvents) { window.fetchEvents(); } - } catch (error: any) { - console.error("Failed to save event:", error); - if (submitButton) { - submitButton.classList.remove("btn-disabled"); - submitButton.classList.add("btn-error"); - const errorIcon = document.createElement('span'); - errorIcon.innerHTML = ''; - submitButton.textContent = ''; - submitButton.appendChild(errorIcon); + + // Trigger callback + if (onEventSaved) { + onEventSaved(); } - alert(error.message || "Failed to save event. Please try again."); + } catch (error) { + console.error("Failed to save event:", error); + toast.error(`Failed to ${event.id ? "update" : "create"} event: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsSubmitting(false); - if (submitButton) { - submitButton.disabled = false; - submitButton.classList.remove("btn-disabled", "btn-success", "btn-error"); - submitButton.textContent = 'Save Changes'; - } - if (cancelButton) cancelButton.disabled = false; window.hideLoading?.(); } }, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting]); diff --git a/src/components/dashboard/ProfileSection/Stats.tsx b/src/components/dashboard/ProfileSection/Stats.tsx index bc99569..b043995 100644 --- a/src/components/dashboard/ProfileSection/Stats.tsx +++ b/src/components/dashboard/ProfileSection/Stats.tsx @@ -3,6 +3,8 @@ import { Authentication } from "../../../scripts/pocketbase/Authentication"; import { DataSyncService } from "../../../scripts/database/DataSyncService"; import { Collections } from "../../../schemas/pocketbase/schema"; import type { Event, Log, User } from "../../../schemas/pocketbase"; +import { Get } from "../../../scripts/pocketbase/Get"; +import type { EventAttendee } from "../../../schemas/pocketbase"; // Extended User interface with points property interface ExtendedUser extends User { @@ -18,109 +20,68 @@ export function Stats() { const [memberSince, setMemberSince] = useState(null); const [upcomingEvents, setUpcomingEvents] = useState(0); const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [user, setUser] = useState(null); + const [pointsEarned, setPointsEarned] = useState(0); + const [attendancePercentage, setAttendancePercentage] = useState(0); useEffect(() => { const fetchStats = async () => { try { setIsLoading(true); + setError(null); + const auth = Authentication.getInstance(); - const dataSync = DataSyncService.getInstance(); - const userId = auth.getCurrentUser()?.id; + const get = Get.getInstance(); + const currentUser = auth.getCurrentUser(); - if (!userId) return; - - // Get current date - const now = new Date(); - - // Get current quarter dates for points calculation - const month = now.getMonth(); // 0-11 - let quarterStart = new Date(); - - // Fall: Sept-Dec - if (month >= 8 && month <= 11) { - quarterStart = new Date(now.getFullYear(), 8, 1); // Sept 1 - } - // Winter: Jan-Mar - else if (month >= 0 && month <= 2) { - quarterStart = new Date(now.getFullYear(), 0, 1); // Jan 1 - } - // Spring: Apr-Jun - else if (month >= 3 && month <= 5) { - quarterStart = new Date(now.getFullYear(), 3, 1); // Apr 1 - } - // Summer: Jul-Aug - else { - quarterStart = new Date(now.getFullYear(), 6, 1); // Jul 1 + if (!currentUser) { + setError("User not logged in"); + return; } - // Sync user data to ensure we have the latest - await dataSync.syncCollection(Collections.USERS, `id = "${userId}"`); + const userId = currentUser.id; - // Get user from IndexedDB - const user = await dataSync.getItem(Collections.USERS, userId); - const totalPoints = user?.points || 0; - - // Set membership status and date - setMembershipStatus(user?.member_type || "Member"); - if (user?.created) { - const createdDate = new Date(user.created); - setMemberSince(createdDate.toLocaleDateString(undefined, { - year: 'numeric', - month: 'long' - })); + // Get user data + const userData = await get.getOne("users", userId); + if (!userData) { + setError("Failed to load user data"); + return; } - // Sync logs for the current quarter to calculate points change - await dataSync.syncCollection( - Collections.LOGS, - `user_id = "${userId}" && created >= "${quarterStart.toISOString()}" && type = "update" && part = "event check-in"`, - "-created" + // Set user data + setUser(userData); + + // Get events attended by the user + const attendedEvents = await get.getList( + "event_attendees", + 1, + 1000, + `user="${userId}"` ); - // Get logs from IndexedDB - const logs = await dataSync.getData( - Collections.LOGS, - false, // Don't force sync again - `user_id = "${userId}" && created >= "${quarterStart.toISOString()}" && type = "update" && part = "event check-in"`, - "-created" - ); + setEventsAttended(attendedEvents.totalItems); - // Calculate quarterly points - const quarterlyPoints = logs.reduce((total, log) => { - const pointsMatch = log.message?.match(/Awarded (\d+) points/); - if (pointsMatch) { - return total + parseInt(pointsMatch[1]); - } - return total; - }, 0); - - // Set points change message - setPointsChange(quarterlyPoints > 0 ? `+${quarterlyPoints} this quarter` : "No activity"); - - // Sync events collection - await dataSync.syncCollection(Collections.EVENTS); - - // Get events from IndexedDB - const events = await dataSync.getData(Collections.EVENTS); - - // Count attended events - const attendedEvents = events.filter(event => - event.attendees?.some(attendee => attendee.user_id === userId) - ); - setEventsAttended(attendedEvents.length); - - // Count upcoming events (events that haven't ended yet) - const upcoming = events.filter(event => { - if (!event.end_date) return false; - const endDate = new Date(event.end_date); - return endDate > now && event.published; + // Calculate total points earned + let totalPoints = 0; + attendedEvents.items.forEach(attendee => { + totalPoints += attendee.points_earned || 0; }); - setUpcomingEvents(upcoming.length); - // Set loyalty points - setLoyaltyPoints(totalPoints); + setPointsEarned(totalPoints); + + // Get all events to calculate percentage + const allEvents = await get.getList("events", 1, 1000); + if (allEvents.totalItems > 0) { + const percentage = (attendedEvents.totalItems / allEvents.totalItems) * 100; + setAttendancePercentage(Math.round(percentage)); + } else { + setAttendancePercentage(0); + } + } catch (error) { console.error("Error fetching stats:", error); + setError("Failed to load stats"); } finally { setIsLoading(false); } diff --git a/src/components/dashboard/SettingsSection.astro b/src/components/dashboard/SettingsSection.astro index 4bf2322..8723219 100644 --- a/src/components/dashboard/SettingsSection.astro +++ b/src/components/dashboard/SettingsSection.astro @@ -4,7 +4,6 @@ import UserProfileSettings from "./SettingsSection/UserProfileSettings"; import AccountSecuritySettings from "./SettingsSection/AccountSecuritySettings"; import NotificationSettings from "./SettingsSection/NotificationSettings"; import DisplaySettings from "./SettingsSection/DisplaySettings"; -import { Toaster } from "react-hot-toast"; ---
@@ -13,8 +12,6 @@ import { Toaster } from "react-hot-toast";

Manage your account settings and preferences

- -