diff --git a/docker-compose.yaml b/docker-compose.yaml index 81ce444..592ac18 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -61,6 +61,8 @@ services: args: - DATABASE_URL=postgresql://admin:password@event-service-db:5432/event_db container_name: event-service + environment: + - TZ=America/Chicago env_file: ./ems-services/event-service/.env.production networks: @@ -132,6 +134,11 @@ services: container_name: speaker-service env_file: ./ems-services/speaker-service/.env.production + environment: + - RABBITMQ_URL=amqp://rabbitmq:5672 + - UPLOAD_DIR=/app/uploads + volumes: + - speaker-uploads:/app/uploads # Persistent storage for uploaded materials networks: - event-net depends_on: @@ -237,5 +244,6 @@ volumes: booking-service-data: feedback-service-data: speaker-service-data: + speaker-uploads: # Persistent storage for speaker uploaded materials rabbitmq_data: rabbitmq_log: diff --git a/docs/attendance-tracking-implementation-plan.md b/docs/attendance-tracking-implementation-plan.md new file mode 100644 index 0000000..81bc672 --- /dev/null +++ b/docs/attendance-tracking-implementation-plan.md @@ -0,0 +1,92 @@ +# Attendance Tracking Implementation Plan + +## 📋 **Core Requirements:** +- Real-time event joining system +- Live attendance tracking during events +- Speaker material management per event +- Minimal UI for attendees, informative for admin/speakers + +## ⏰ **Event Joining Logic:** +- **Join Button**: Becomes active exactly when event starts (not 15 mins before) +- **Join Action**: Marks user as "attended" and "joined" simultaneously +- **Multiple Joins**: Allowed (user has valid ticket), but attendance count doesn't increase +- **Attendance Count**: Only increments on first join per user + +## 👥 **User Roles & Permissions:** + +### **Attendees:** +- Can join events when they start +- See basic attendance metrics (e.g., "45 people joined") +- Access speaker materials only during event +- Minimal UI design + +### **Speakers:** +- Can join events when they start +- See complete attendance reports +- Manage materials in invitation section +- Cannot change materials once event starts +- Informative UI design + +### **Admins:** +- See complete attendance reports +- Real-time attendance dashboard +- Material management oversight +- Informative UI design + +## 📁 **Material Management:** +- **Location**: Speaker invitation section (where they receive admin invitations) +- **Selection**: Speakers choose materials per event via checkboxes +- **Timing**: Materials locked once event starts +- **Visibility**: Attendees can access materials during event only +- **Lifespan**: Materials accessible throughout event duration + +## 📊 **Attendance Tracking:** +- **Real-time Count**: Live updates as users join +- **Duplicate Prevention**: Multiple joins don't increase count +- **Status Tracking**: Track both "attended" and "joined" status +- **Report Data**: Total attendance vs current attendance for mid-event reports + +## 🎨 **UI Design Requirements:** +- **Attendees**: Minimal, clean interface +- **Speakers/Admins**: Informative, detailed interface +- **Real-time Updates**: Live attendance counters +- **Material Access**: Easy access during events + +## 🔧 **Technical Implementation Plan:** + +### **Phase 1: Database Schema** +- Add `joinedAt` timestamp to `Booking` model +- Add `speakerJoinedAt` timestamp to `SpeakerInvitation` model +- Add `materialsSelected` array to `SpeakerInvitation` model +- Add `eventStartTime` validation for join button + +### **Phase 2: API Endpoints** +- `POST /events/:eventId/join` - Attendee joins event +- `POST /events/:eventId/speaker-join` - Speaker joins event +- `GET /events/:eventId/live-attendance` - Real-time attendance data +- `PUT /speaker-invitations/:id/materials` - Update material selection + +### **Phase 3: Frontend Implementation** +- Event joining interface with time-based access +- Live attendance dashboard +- Speaker material selection in invitation section +- Real-time updates and minimal UI design + +## ⚠️ **Edge Cases to Handle:** +- User joins multiple times (prevent count increase) +- Event starts while user is on page (enable join button) +- Speaker changes materials after event starts (prevent) +- Materials access timing (only during event) +- Real-time updates for all user types + +## 📈 **Future Report Implementation:** +- Total attendance vs current attendance +- Mid-event report generation +- Historical attendance data +- Export functionality + +--- + +**Status**: Ready for implementation +**Created**: $(date) +**Last Updated**: $(date) diff --git a/docs/professor-scope-requirements.md b/docs/professor-scope-requirements.md new file mode 100644 index 0000000..d4d117b --- /dev/null +++ b/docs/professor-scope-requirements.md @@ -0,0 +1,122 @@ +# Professor's Scope Requirements +## Event Management System - Official Project Scope + +**Document Version**: 1.0 +**Created**: January 2024 +**Source**: Professor's Raw Requirements +**Status**: Official Project Scope + +--- + +## **Official Project Scope** + +The Event Management System must implement the following features: + +### **1. Event Creation** +- Admin can create and manage events +- Event lifecycle management +- Event status management (Draft, Published, etc.) + +### **2. Registration System** +- Users can register online for events +- Registration management +- Waitlist handling when capacity is reached + +### **3. Speakers** +- Speakers can search and get invited to events +- Speakers can upload slides (presentation materials) +- Speakers can receive messages from the admin +- Speaker management and assignment + +### **4. Ticketing** +- Generate and manage digital tickets +- QR code generation and validation +- Ticket lifecycle management + +### **5. Schedule Management** +- Manage event timing and sessions +- Session creation within events +- Speaker assignment to sessions + +### **6. Attendee Tracking** +- Monitor attendance and check-ins +- QR code scanning at venue +- Attendance recording and tracking + +### **7. Notifications** +- Send alerts about upcoming events or changes +- Email notifications for various events +- System-generated alerts + +### **8. Feedback Collection** +- Attendees can submit feedback +- Feedback forms and collection +- Post-event feedback system + +### **9. Reporting** +- Generate event attendance reports +- Generate performance reports +- Basic analytics and reporting + +--- + +## **Scope Boundaries** + +### **✅ IN SCOPE** +- Basic event management +- User registration and booking +- Speaker invitation and management +- Digital ticketing with QR codes +- Session management within events +- Basic attendance tracking +- Email notifications +- Basic feedback collection +- Basic reporting + +### **❌ OUT OF SCOPE** +- Multi-track event management +- Speaker rating systems +- Advanced material access control +- Backup speaker management +- Complex scheduling algorithms +- Real-time dashboards +- Advanced communication systems +- Enterprise-level features +- Advanced analytics +- Multi-tenant systems + +--- + +## **Implementation Priority** + +Based on the professor's scope, the implementation should focus on: + +1. **Core Event Management** (Already implemented) +2. **Speaker Management System** (Search, invite, messaging, slides) +3. **Session Management** (Sessions within events) +4. **Basic Attendance Tracking** (Check-in system) +5. **Feedback Collection** (Post-event feedback) +6. **Basic Reporting** (Attendance and performance reports) + +--- + +## **Success Criteria** + +The system will be considered complete when: + +- [ ] Admins can create and manage events +- [ ] Users can register for events online +- [ ] Speakers can be searched and invited to events +- [ ] Speakers can upload slides and receive admin messages +- [ ] Digital tickets are generated and managed +- [ ] Event timing and sessions are managed +- [ ] Attendance and check-ins are monitored +- [ ] Notifications are sent for events and changes +- [ ] Attendees can submit feedback +- [ ] Basic reports are generated + +--- + +**Document Status**: Official Project Scope +**Last Updated**: January 2024 +**Approved By**: Professor diff --git a/ems-client/app/dashboard/admin/events/[id]/live/page.tsx b/ems-client/app/dashboard/admin/events/[id]/live/page.tsx new file mode 100644 index 0000000..2b206b0 --- /dev/null +++ b/ems-client/app/dashboard/admin/events/[id]/live/page.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { LiveEventAuditorium } from "@/components/events/LiveEventAuditorium"; +import { withAdminAuth } from "@/components/hoc/withAuth"; + +const AdminLiveAuditoriumPage = () => { + return ( + + ); +}; + +export default withAdminAuth(AdminLiveAuditoriumPage); diff --git a/ems-client/app/dashboard/admin/events/[id]/page.tsx b/ems-client/app/dashboard/admin/events/[id]/page.tsx index a7a82df..bd032bb 100644 --- a/ems-client/app/dashboard/admin/events/[id]/page.tsx +++ b/ems-client/app/dashboard/admin/events/[id]/page.tsx @@ -1,349 +1,17 @@ 'use client'; -import { useAuth } from "@/lib/auth-context"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { - ArrowLeft, - Calendar, - MapPin, - Clock, - Users, - AlertCircle, - Edit, - CheckCircle, - XCircle, - Ban -} from "lucide-react"; -import { useRouter, useParams } from "next/navigation"; -import { useEffect, useState } from "react"; -import { useLogger } from "@/lib/logger/LoggerProvider"; -import { eventAPI } from "@/lib/api/event.api"; -import { EventResponse, EventStatus } from "@/lib/api/types/event.types"; +import { EventDetailsPage } from "@/components/events/EventDetailsPage"; import { withAdminAuth } from "@/components/hoc/withAuth"; -import { RejectionModal } from "@/components/admin/RejectionModal"; - -const LOGGER_COMPONENT_NAME = 'AdminEventDetailsPage'; - -const statusColors = { - [EventStatus.DRAFT]: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', - [EventStatus.PENDING_APPROVAL]: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', - [EventStatus.PUBLISHED]: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', - [EventStatus.REJECTED]: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', - [EventStatus.CANCELLED]: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', - [EventStatus.COMPLETED]: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200' -}; - -function AdminEventDetailsPage() { - const { user } = useAuth(); - const router = useRouter(); - const params = useParams(); - const logger = useLogger(); - const eventId = params.id as string; - - const [event, setEvent] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [actionLoading, setActionLoading] = useState(false); - const [isRejectionModalOpen, setIsRejectionModalOpen] = useState(false); - - useEffect(() => { - if (eventId) { - loadEvent(); - } - }, [eventId]); - - const loadEvent = async () => { - try { - setIsLoading(true); - logger.debug(LOGGER_COMPONENT_NAME, 'Loading event details', { eventId }); - - const response = await eventAPI.getEventById(eventId); - - if (response.success) { - setEvent(response.data); - logger.debug(LOGGER_COMPONENT_NAME, 'Event loaded successfully'); - } else { - throw new Error('Failed to load event'); - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to load event'; - setError(errorMessage); - logger.error(LOGGER_COMPONENT_NAME, 'Failed to load event', err instanceof Error ? err : new Error(String(err))); - } finally { - setIsLoading(false); - } - }; - - const handleApprove = async () => { - if (!event) return; - - try { - setActionLoading(true); - logger.info(LOGGER_COMPONENT_NAME, 'Approving event', { eventId: event.id }); - - await eventAPI.approveEvent(event.id); - - // Reload event to show updated status - await loadEvent(); - } catch (err) { - logger.error(LOGGER_COMPONENT_NAME, 'Failed to approve event', err as Error); - setError('Failed to approve event'); - } finally { - setActionLoading(false); - } - }; - - const handleRejectConfirm = async (rejectionReason: string) => { - if (!event) return; - - try { - setActionLoading(true); - logger.info(LOGGER_COMPONENT_NAME, 'Rejecting event', { eventId: event.id }); - - await eventAPI.rejectEvent(event.id, { rejectionReason }); - - setIsRejectionModalOpen(false); - await loadEvent(); - } catch (err) { - logger.error(LOGGER_COMPONENT_NAME, 'Failed to reject event', err as Error); - setError('Failed to reject event'); - throw err; - } finally { - setActionLoading(false); - } - }; - - const handleCancel = async () => { - if (!event) return; - - try { - setActionLoading(true); - logger.info(LOGGER_COMPONENT_NAME, 'Cancelling event', { eventId: event.id }); - - await eventAPI.cancelEvent(event.id); - - await loadEvent(); - } catch (err) { - logger.error(LOGGER_COMPONENT_NAME, 'Failed to cancel event', err as Error); - setError('Failed to cancel event'); - } finally { - setActionLoading(false); - } - }; - - if (isLoading) { - return ( -
-
-
- ); - } - - if (error || !event) { - return ( -
- - - -

Error Loading Event

-

{error}

- -
-
-
- ); - } +const AdminEventDetailsPage = () => { return ( -
-
-
-
-
- -

- Event Details -

-
-
-
-
- -
- - -
-
- {event.name} - - {event.status.replace('_', ' ')} - -
- -
- - - {event.status === EventStatus.PENDING_APPROVAL && ( - <> - - - - )} - - {event.status === EventStatus.PUBLISHED && ( - - )} -
-
-
- - - {event.rejectionReason && ( -
-

- - Rejection Reason: -

-

{event.rejectionReason}

-
- )} - -
-

Description

-

{event.description}

-
- -
-
-

Category

-

{event.category}

-
- -
-

Speaker ID

-

{event.speakerId}

-
- -
-

- - Venue -

-

{event.venue.name}

-

{event.venue.address}

-

- Hours: {event.venue.openingTime} - {event.venue.closingTime} -

-
- -
-

- - Capacity -

-

{event.venue.capacity} attendees

-
- -
-

- - Event Dates -

-
-
-

Start Date

-

- {new Date(event.bookingStartDate).toLocaleString()} -

-
-
-

End Date

-

- {new Date(event.bookingEndDate).toLocaleString()} -

-
-
-
- -
-

Created At

-

- {new Date(event.createdAt).toLocaleString()} -

-
- -
-

Last Updated

-

- {new Date(event.updatedAt).toLocaleString()} -

-
-
- - {event.bannerImageUrl && ( -
-

Banner Image

- {event.name} -
- )} -
-
- - {/* Rejection Modal */} - setIsRejectionModalOpen(false)} - onConfirm={handleRejectConfirm} - eventName={event?.name} - isLoading={actionLoading} - /> -
-
+ ); -} - -export default withAdminAuth(AdminEventDetailsPage); +}; +export default withAdminAuth(AdminEventDetailsPage); \ No newline at end of file diff --git a/ems-client/app/dashboard/admin/events/page.tsx b/ems-client/app/dashboard/admin/events/page.tsx index 9828cda..d5a362d 100644 --- a/ems-client/app/dashboard/admin/events/page.tsx +++ b/ems-client/app/dashboard/admin/events/page.tsx @@ -27,6 +27,7 @@ import { import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useLogger } from "@/lib/logger/LoggerProvider"; +import { EventJoinInterface } from '@/components/attendance/EventJoinInterface'; import { eventAPI } from "@/lib/api/event.api"; import { EventResponse, EventStatus, EventFilters } from "@/lib/api/types/event.types"; import { withAdminAuth } from "@/components/hoc/withAuth"; @@ -417,6 +418,14 @@ function EventManagementPage() { {event.name} + {event.status.replace('_', ' ')} @@ -520,6 +529,23 @@ function EventManagementPage() { Delete + + {/* Event Join Interface - Only show for published events */} + {event.status === EventStatus.PUBLISHED && ( +
+ +
+ )} ))} diff --git a/ems-client/app/dashboard/attendee/events/[id]/live/page.tsx b/ems-client/app/dashboard/attendee/events/[id]/live/page.tsx new file mode 100644 index 0000000..9b43f56 --- /dev/null +++ b/ems-client/app/dashboard/attendee/events/[id]/live/page.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { LiveEventAuditorium } from "@/components/events/LiveEventAuditorium"; +import { withUserAuth } from "@/components/hoc/withAuth"; + +const AttendeeLiveAuditoriumPage = () => { + return ( + + ); +}; + +export default withUserAuth(AttendeeLiveAuditoriumPage); diff --git a/ems-client/app/dashboard/attendee/events/[id]/page.tsx b/ems-client/app/dashboard/attendee/events/[id]/page.tsx new file mode 100644 index 0000000..a85eb67 --- /dev/null +++ b/ems-client/app/dashboard/attendee/events/[id]/page.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { EventDetailsPage } from "@/components/events/EventDetailsPage"; +import { withUserAuth } from "@/components/hoc/withAuth"; + +const AttendeeEventDetailsPage = () => { + return ( + + ); +}; + +export default withUserAuth(AttendeeEventDetailsPage); diff --git a/ems-client/app/dashboard/attendee/events/page.tsx b/ems-client/app/dashboard/attendee/events/page.tsx index 5401587..5ab7b89 100644 --- a/ems-client/app/dashboard/attendee/events/page.tsx +++ b/ems-client/app/dashboard/attendee/events/page.tsx @@ -29,6 +29,7 @@ import { const LOGGER_COMPONENT_NAME = 'AttendeeEventsPage'; import { EventResponse } from '@/lib/api/types/event.types'; +import { EventJoinInterface } from '@/components/attendance/EventJoinInterface'; interface Event extends EventResponse {} @@ -230,7 +231,12 @@ export default function AttendeeEventsPage() { }; - const getBookingButtonText = (eventId: string) => { + const getBookingButtonText = (eventId: string, event: Event) => { + // Check if event is expired + if (isEventExpired(event)) { + return 'Event Ended'; + } + // Check if user already has a booking for this event if (userBookings[eventId]) { return 'Already Booked ✓'; @@ -245,7 +251,12 @@ export default function AttendeeEventsPage() { } }; - const getBookingButtonVariant = (eventId: string) => { + const getBookingButtonVariant = (eventId: string, event: Event) => { + // Check if event is expired + if (isEventExpired(event)) { + return 'secondary'; + } + // Check if user already has a booking for this event if (userBookings[eventId]) { return 'secondary'; @@ -270,8 +281,8 @@ export default function AttendeeEventsPage() { return bookingStatus[eventId] === 'success'; }; - const isButtonDisabled = (eventId: string) => { - return isEventBooked(eventId) || bookingStatus[eventId] === 'loading'; + const isButtonDisabled = (eventId: string, event: Event) => { + return isEventExpired(event) || isEventBooked(eventId) || bookingStatus[eventId] === 'loading'; }; const handleFilterChange = (key: keyof EventFilters, value: string) => { @@ -304,6 +315,28 @@ export default function AttendeeEventsPage() { }; }; + // Utility function to check if event is expired/ended + const isEventExpired = (event: Event) => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return eventEndDate < now || event.status === 'COMPLETED' || event.status === 'CANCELLED'; + }; + + // Utility function to check if event is upcoming + const isEventUpcoming = (event: Event) => { + const now = new Date(); + const eventStartDate = new Date(event.bookingStartDate); + return eventStartDate > now && event.status === 'PUBLISHED'; + }; + + // Utility function to check if event is currently running + const isEventRunning = (event: Event) => { + const now = new Date(); + const eventStartDate = new Date(event.bookingStartDate); + const eventEndDate = new Date(event.bookingEndDate); + return eventStartDate <= now && eventEndDate >= now && event.status === 'PUBLISHED'; + }; + if (loading) { return ( @@ -486,8 +519,16 @@ export default function AttendeeEventsPage() { ) : ( -
- {filteredEvents.map((event) => { +
+ {/* Upcoming and Running Events */} + {filteredEvents.filter(event => !isEventExpired(event)).length > 0 && ( +
+

+ + Available Events ({filteredEvents.filter(event => !isEventExpired(event)).length}) +

+
+ {filteredEvents.filter(event => !isEventExpired(event)).map((event) => { const eventTime = formatEventTime(event.bookingStartDate, event.bookingEndDate); const isBooked = userBookings[event.id]; const isBooking = bookingStatus[event.id] === 'loading'; @@ -499,7 +540,17 @@ export default function AttendeeEventsPage() { }`}>
- {event.name} +
+ {event.name} + +
{isBooked && ( @@ -507,9 +558,25 @@ export default function AttendeeEventsPage() { BOOKED )} - - {event.status} - + {isEventExpired(event) ? ( + + EXPIRED + + ) : isEventRunning(event) ? ( + + + LIVE + + ) : isEventUpcoming(event) ? ( + + + UPCOMING + + ) : ( + + {event.status} + + )}
@@ -564,13 +631,18 @@ export default function AttendeeEventsPage() { {/* Booking Button */} + + {/* Event Join Interface - Only show for booked events */} + {isBooked && ( +
+ +
+ )} ); })} +
+
+ )} + + {/* Expired Events */} + {filteredEvents.filter(event => isEventExpired(event)).length > 0 && ( +
+

+ + Past Events ({filteredEvents.filter(event => isEventExpired(event)).length}) +

+
+ {filteredEvents.filter(event => isEventExpired(event)).map((event) => { + const eventTime = formatEventTime(event.bookingStartDate, event.bookingEndDate); + const isBooked = userBookings[event.id]; + + return ( + + +
+
+ {event.name} + +
+
+ {isBooked && ( + + + ATTENDED + + )} + + EXPIRED + +
+
+ + + {eventTime.date} + +
+ + + {/* Description */} +

+ {event.description} +

+ + {/* Event Details */} +
+
+ + Time: + {eventTime.time} +
+ +
+ + Venue: + {event.venue.name} +
+ +
+ + Capacity: + {event.venue.capacity} people +
+ +
+ + Category: + {event.category} +
+
+ + {/* Booking Status */} + {isBooked && ( +
+
+ + You attended this event! +
+
+ )} + + {/* Disabled Button for Expired Events */} + +
+
+ ); + })} +
+
+ )}
)}
diff --git a/ems-client/app/dashboard/attendee/tickets/page.tsx b/ems-client/app/dashboard/attendee/tickets/page.tsx index 9b51445..07675e0 100644 --- a/ems-client/app/dashboard/attendee/tickets/page.tsx +++ b/ems-client/app/dashboard/attendee/tickets/page.tsx @@ -66,6 +66,31 @@ export default function AttendeeTicketsPage() { return new Date(expiresAt) < new Date(); }; + // Utility function to check if ticket's event is expired/ended + const isTicketEventExpired = (ticket: TicketResponse) => { + if (!ticket.event) return false; + const now = new Date(); + const eventEndDate = new Date(ticket.event.bookingEndDate); + return eventEndDate < now; + }; + + // Utility function to check if ticket's event is upcoming + const isTicketEventUpcoming = (ticket: TicketResponse) => { + if (!ticket.event) return false; + const now = new Date(); + const eventStartDate = new Date(ticket.event.bookingStartDate); + return eventStartDate > now; + }; + + // Utility function to check if ticket's event is currently running + const isTicketEventRunning = (ticket: TicketResponse) => { + if (!ticket.event) return false; + const now = new Date(); + const eventStartDate = new Date(ticket.event.bookingStartDate); + const eventEndDate = new Date(ticket.event.bookingEndDate); + return eventStartDate <= now && eventEndDate >= now; + }; + if (loading) { return ( @@ -120,8 +145,16 @@ export default function AttendeeTicketsPage() { ) : ( -
- {tickets.map((ticket) => ( +
+ {/* Active Tickets (Upcoming and Running Events) */} + {tickets.filter(ticket => !isTicketEventExpired(ticket)).length > 0 && ( +
+

+
+ Active Tickets ({tickets.filter(ticket => !isTicketEventExpired(ticket)).length}) +

+
+ {tickets.filter(ticket => !isTicketEventExpired(ticket)).map((ticket) => ( @@ -203,6 +236,88 @@ export default function AttendeeTicketsPage() { ))} +
+
+ )} + + {/* Expired Tickets */} + {tickets.filter(ticket => isTicketEventExpired(ticket)).length > 0 && ( +
+

+
+ Past Event Tickets ({tickets.filter(ticket => isTicketEventExpired(ticket)).length}) +

+
+ {tickets.filter(ticket => isTicketEventExpired(ticket)).map((ticket) => ( + + + + {ticket.event?.name || 'Event Ticket'} + + EXPIRED EVENT + + + + {ticket.event?.category && ( + + {ticket.event.category} + + )} + Ticket ID: {ticket.id.substring(0, 8)}... + + + + {/* QR Code Display - Disabled for expired events */} +
+
+

QR Code no longer valid

+
+
+ +
+ {ticket.event && ( + <> +

+ Event: {ticket.event.name} +

+

+ Venue: {ticket.event.venue.name} +

+

+ {ticket.event.venue.address} +

+

+ Event Date: { + ticket.event.bookingStartDate ? + new Date(ticket.event.bookingStartDate).toLocaleDateString() : + 'Date not available' + } +

+ + )} +

+ Issued: {new Date(ticket.issuedAt).toLocaleString()} +

+

+ Expires: {new Date(ticket.expiresAt).toLocaleString()} +

+ {ticket.scannedAt && ( +

+ Scanned: {new Date(ticket.scannedAt).toLocaleString()} +

+ )} +
+ + {/* Event Ended Notice */} +
+ 📅 This event has ended +
+
+
+ ))} +
+
+ )}
)}
diff --git a/ems-client/app/dashboard/speaker/events/[id]/live/page.tsx b/ems-client/app/dashboard/speaker/events/[id]/live/page.tsx new file mode 100644 index 0000000..59afde7 --- /dev/null +++ b/ems-client/app/dashboard/speaker/events/[id]/live/page.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { LiveEventAuditorium } from "@/components/events/LiveEventAuditorium"; +import { withSpeakerAuth } from "@/components/hoc/withAuth"; + +const SpeakerLiveAuditoriumPage = () => { + return ( + + ); +}; + +export default withSpeakerAuth(SpeakerLiveAuditoriumPage); diff --git a/ems-client/app/dashboard/speaker/events/[id]/page.tsx b/ems-client/app/dashboard/speaker/events/[id]/page.tsx index 718b384..df49887 100644 --- a/ems-client/app/dashboard/speaker/events/[id]/page.tsx +++ b/ems-client/app/dashboard/speaker/events/[id]/page.tsx @@ -1,217 +1,17 @@ 'use client'; -import { useAuth } from "@/lib/auth-context"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { - ArrowLeft, - Calendar, - MapPin, - Clock, - Users, - AlertCircle, - Edit, - Send -} from "lucide-react"; -import { useRouter, useParams } from "next/navigation"; -import { useEffect, useState } from "react"; -import { useLogger } from "@/lib/logger/LoggerProvider"; -import { eventAPI } from "@/lib/api/event.api"; -import { EventResponse, EventStatus } from "@/lib/api/types/event.types"; +import { EventDetailsPage } from "@/components/events/EventDetailsPage"; import { withSpeakerAuth } from "@/components/hoc/withAuth"; -const LOGGER_COMPONENT_NAME = 'SpeakerEventDetailsPage'; - -const statusColors = { - [EventStatus.DRAFT]: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', - [EventStatus.PENDING_APPROVAL]: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', - [EventStatus.PUBLISHED]: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', - [EventStatus.REJECTED]: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', - [EventStatus.CANCELLED]: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', - [EventStatus.COMPLETED]: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200' -}; - -function SpeakerEventDetailsPage() { - const { user } = useAuth(); - const router = useRouter(); - const params = useParams(); - const logger = useLogger(); - const eventId = params.id as string; - - const [event, setEvent] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - if (eventId) { - loadEvent(); - } - }, [eventId]); - - const loadEvent = async () => { - try { - setIsLoading(true); - logger.debug(LOGGER_COMPONENT_NAME, 'Loading event details', { eventId }); - - const response = await eventAPI.getMyEventById(eventId); - - if (response.success) { - setEvent(response.data); - logger.debug(LOGGER_COMPONENT_NAME, 'Event loaded successfully'); - } else { - throw new Error('Failed to load event'); - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to load event'; - setError(errorMessage); - logger.error(LOGGER_COMPONENT_NAME, 'Failed to load event', err instanceof Error ? err : new Error(String(err))); - } finally { - setIsLoading(false); - } - }; - - if (isLoading) { - return ( -
-
-
- ); - } - - if (error || !event) { - return ( -
- - - -

Error Loading Event

-

{error}

- -
-
-
- ); - } - - const canEdit = event.status === EventStatus.DRAFT || event.status === EventStatus.REJECTED; - const canSubmit = event.status === EventStatus.DRAFT || event.status === EventStatus.REJECTED; - +const SpeakerEventDetailsPage = () => { return ( -
-
-
-
-
- -

- Event Details -

-
-
-
-
- -
- - -
-
- {event.name} - - {event.status.replace('_', ' ')} - -
-
- {canEdit && ( - - )} - {canSubmit && ( - - )} -
-
-
- - {event.rejectionReason && ( -
-

Rejection Reason:

-

{event.rejectionReason}

-
- )} - -
-

Description

-

{event.description}

-
- -
-
-

Category

-

{event.category}

-
- -
-

- - Venue -

-

{event.venue.name}

-

{event.venue.address}

-
- -
-

- - Event Dates -

-

- {new Date(event.bookingStartDate).toLocaleString()} -
- {new Date(event.bookingEndDate).toLocaleString()} -

-
- -
-

- - Capacity -

-

{event.venue.capacity} attendees

-
-
-
-
-
-
+ ); -} - -export default withSpeakerAuth(SpeakerEventDetailsPage); +}; +export default withSpeakerAuth(SpeakerEventDetailsPage); \ No newline at end of file diff --git a/ems-client/app/dashboard/speaker/events/page.tsx b/ems-client/app/dashboard/speaker/events/page.tsx index 0f35b23..57fc36c 100644 --- a/ems-client/app/dashboard/speaker/events/page.tsx +++ b/ems-client/app/dashboard/speaker/events/page.tsx @@ -22,15 +22,20 @@ import { Play, Pause, Archive, - AlertCircle + AlertCircle, + Mail, + CheckCircle, + XCircle } from "lucide-react"; import {useRouter} from "next/navigation"; import {useEffect, useState} from "react"; import {useLogger} from "@/lib/logger/LoggerProvider"; +import { EventJoinInterface } from '@/components/attendance/EventJoinInterface'; import {eventAPI} from "@/lib/api/event.api"; import {EventResponse, EventStatus, EventFilters} from "@/lib/api/types/event.types"; import {withSpeakerAuth} from "@/components/hoc/withAuth"; +import {speakerApiClient, SpeakerInvitation} from "@/lib/api/speaker.api"; const statusColors = { [EventStatus.DRAFT]: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', @@ -48,11 +53,13 @@ function SpeakerEventManagementPage() { const router = useRouter(); const logger = useLogger(); const [searchTerm, setSearchTerm] = useState(''); - const [selectedStatus, setSelectedStatus] = useState('ALL'); const [selectedTimeframe, setSelectedTimeframe] = useState('ALL'); + const [activeTab, setActiveTab] = useState<'my-events' | 'invited-events'>('my-events'); // API state management const [events, setEvents] = useState([]); + const [invitations, setInvitations] = useState([]); + const [invitedEvents, setInvitedEvents] = useState>(new Map()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [actionLoading, setActionLoading] = useState(null); @@ -63,10 +70,8 @@ function SpeakerEventManagementPage() { totalPages: 0 }); - // Load events from API + // Load events from API - Show all published events in "All Events" tab const loadEvents = async () => { - if (!user?.id) return; - try { setLoading(true); setError(null); @@ -74,12 +79,13 @@ function SpeakerEventManagementPage() { const filters: EventFilters = { page: pagination.page, limit: pagination.limit, - status: selectedStatus !== 'ALL' ? selectedStatus : undefined + // Note: Published events API only returns PUBLISHED events, so status filter is not needed }; - logger.debug(LOGGER_COMPONENT_NAME, 'Loading events with filters', filters); + logger.debug(LOGGER_COMPONENT_NAME, 'Loading all published events with filters', filters); - const response = await eventAPI.getMyEvents(user.id, filters); + // Use getPublishedEvents to show ALL published events, not just speaker's own events + const response = await eventAPI.getPublishedEvents(filters); if (response.success) { setEvents(response.data.events); @@ -88,7 +94,7 @@ function SpeakerEventManagementPage() { total: response.data.total, totalPages: response.data.totalPages })); - logger.debug(LOGGER_COMPONENT_NAME, 'Events loaded successfully', { count: response.data.events.length }); + logger.debug(LOGGER_COMPONENT_NAME, 'All events loaded successfully', { count: response.data.events.length }); } else { throw new Error('Failed to load events'); } @@ -101,9 +107,44 @@ function SpeakerEventManagementPage() { } }; + // Load invitations and their events + const loadInvitations = async () => { + if (!user?.id) return; + + try { + setLoading(true); + const speakerProfile = await speakerApiClient.getSpeakerProfile(user.id); + const allInvitations = await speakerApiClient.getSpeakerInvitations(speakerProfile.id); + setInvitations(allInvitations); + + // Load event details for each invitation + const eventMap = new Map(); + for (const invitation of allInvitations) { + try { + const eventResponse = await eventAPI.getEventById(invitation.eventId); + eventMap.set(invitation.eventId, eventResponse.data); + } catch (err) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to load event for invitation', { + invitationId: invitation.id, + eventId: invitation.eventId + }); + } + } + setInvitedEvents(eventMap); + } catch (err) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load invitations', err instanceof Error ? err : new Error(String(err))); + } finally { + setLoading(false); + } + }; + useEffect(() => { - loadEvents(); - }, [selectedStatus, pagination.page]); + if (activeTab === 'my-events') { + loadEvents(); + } else { + loadInvitations(); + } + }, [pagination.page, activeTab]); // Filter events based on search and timeframe (status filtering is done server-side) const filteredEvents = events.filter(event => { @@ -161,13 +202,13 @@ function SpeakerEventManagementPage() { return Math.round((registered / capacity) * 100); }; - // Calculate stats from real data + // Calculate stats from real data (all events shown are published) const stats = { total: events.length, - published: events.filter(e => e.status === EventStatus.PUBLISHED).length, - draft: events.filter(e => e.status === EventStatus.DRAFT).length, - pending: events.filter(e => e.status === EventStatus.PENDING_APPROVAL).length, - rejected: events.filter(e => e.status === EventStatus.REJECTED).length + published: events.length, // All events in this tab are published + draft: 0, // Draft events are not shown in "All Events" tab + pending: 0, // Pending events are not shown in "All Events" tab + rejected: 0 // Rejected events are not shown in "All Events" tab }; if (loading) { @@ -261,10 +302,39 @@ function SpeakerEventManagementPage() { Event Management

- Create, manage, and monitor all events across the platform. + Browse all published events, manage your own events, and join invited events.

+ {/* Tabs */} +
+ + +
+ {/* Stats Cards */}
@@ -324,7 +394,7 @@ function SpeakerEventManagementPage() { -
+
- - - -
- {/* Events Grid */} + {/* Events Grid - My Events */} + {activeTab === 'my-events' && (
{filteredEvents.map((event) => ( {event.name} + {event.status.replace('_', ' ')} @@ -438,43 +494,49 @@ function SpeakerEventManagementPage() { onClick={() => router.push(`/dashboard/speaker/events/${event.id}`)} > - View + View Details - - - {(event.status === EventStatus.DRAFT || event.status === EventStatus.REJECTED) && ( - - )} - - {event.status === EventStatus.DRAFT && ( - + {/* Only show edit/delete/submit actions if speaker is the event creator */} + {event.speakerId === user?.id && ( + <> + + + {(event.status === EventStatus.DRAFT || event.status === EventStatus.REJECTED) && ( + + )} + + {event.status === EventStatus.DRAFT && ( + + )} + )}
+ ))} @@ -502,6 +564,127 @@ function SpeakerEventManagementPage() {
)}
+ )} + + {/* Invited Events Grid */} + {activeTab === 'invited-events' && ( +
+ {invitations.map((invitation) => { + const event = invitedEvents.get(invitation.eventId); + if (!event) return null; + + const getStatusIcon = () => { + switch (invitation.status) { + case 'PENDING': + return ; + case 'ACCEPTED': + return ; + case 'DECLINED': + return ; + default: + return ; + } + }; + + return ( + + +
+
+
+ {getStatusIcon()} + + {event.name} + +
+ + {event.status.replace('_', ' ')} + + + {invitation.status} + +
+
+
+ +

+ {event.description} +

+ +
+
+ + {event.venue.name} +
+
+ + + {new Date(event.bookingStartDate).toLocaleDateString()} - {new Date(event.bookingEndDate).toLocaleDateString()} + +
+
+ +
+ + + {invitation.status === 'ACCEPTED' && event.status === EventStatus.PUBLISHED && ( + + )} +
+ + {invitation.status === 'ACCEPTED' && event.status === EventStatus.PUBLISHED && ( +
+ +
+ )} +
+
+ ); + })} + + {invitations.length === 0 && !loading && ( +
+ + + +

+ No Invitations +

+

+ You haven't received any event invitations yet. +

+
+
+
+ )} +
+ )} {/* Pagination */} {pagination.totalPages > 1 && ( diff --git a/ems-client/app/dashboard/speaker/page.tsx b/ems-client/app/dashboard/speaker/page.tsx index a7c2935..7feac42 100644 --- a/ems-client/app/dashboard/speaker/page.tsx +++ b/ems-client/app/dashboard/speaker/page.tsx @@ -24,7 +24,7 @@ import { Download, Presentation } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useEffect } from "react"; import {useLogger} from "@/lib/logger/LoggerProvider"; import {withSpeakerAuth} from "@/components/hoc/withAuth"; @@ -44,6 +44,7 @@ const LOGGER_COMPONENT_NAME = 'SpeakerDashboard'; function SpeakerDashboard() { const { user, logout } = useAuth(); const router = useRouter(); + const searchParams = useSearchParams(); const logger = useLogger(); const [activeSection, setActiveSection] = useState('overview'); @@ -65,6 +66,14 @@ function SpeakerDashboard() { updateSpeakerProfile, } = useSpeakerData(); + // Set active section from URL query params + useEffect(() => { + const section = searchParams.get('section') as DashboardSection | null; + if (section && ['overview', 'profile', 'materials', 'invitations', 'messages'].includes(section)) { + setActiveSection(section); + } + }, [searchParams]); + useEffect(() => { logger.debug(LOGGER_COMPONENT_NAME, 'Speaker dashboard loaded', { userRole: user?.role }); }, [user, logger]); @@ -333,9 +342,18 @@ function SpeakerDashboard() { -
+
+ + + + {/* Status Messages */} + {joinMessage && ( +
+ {joinMessage} +
+ )} + + + ); +} diff --git a/ems-client/components/attendance/EventJoinInterface.tsx b/ems-client/components/attendance/EventJoinInterface.tsx new file mode 100644 index 0000000..941669c --- /dev/null +++ b/ems-client/components/attendance/EventJoinInterface.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { SimpleEventJoin } from './SimpleEventJoin'; +import { SimpleSpeakerJoin } from './SimpleSpeakerJoin'; +import { SimpleAdminJoin } from './SimpleAdminJoin'; + +interface EventJoinInterfaceProps { + eventId: string; + eventTitle: string; + eventStartTime: string; + eventEndTime: string; + eventVenue: string; + eventCategory: string; + eventStatus: string; + eventDescription: string; + userRole: string; + speakerId?: string; +} + +export const EventJoinInterface: React.FC = ({ + eventId, + eventTitle, + eventStartTime, + eventEndTime, + eventVenue, + eventCategory, + eventStatus, + eventDescription, + userRole, + speakerId, +}) => { + // Render appropriate UI based on user role + if (userRole === 'ADMIN') { + return ( + + ); + } + + if (userRole === 'SPEAKER') { + return ( + + ); + } + + // Default to attendee view for USER role + return ( + + ); +}; diff --git a/ems-client/components/attendance/LiveAttendanceDashboard.tsx b/ems-client/components/attendance/LiveAttendanceDashboard.tsx new file mode 100644 index 0000000..cf2cb50 --- /dev/null +++ b/ems-client/components/attendance/LiveAttendanceDashboard.tsx @@ -0,0 +1,293 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { useLogger } from '@/lib/logger/LoggerProvider'; +import { attendanceApiClient } from '@/lib/api/attendance.api'; +import { + Users, + Clock, + CheckCircle, + AlertCircle, + Loader2, + RefreshCw, + User, + Mail, + Calendar +} from 'lucide-react'; + +const LOGGER_COMPONENT_NAME = 'LiveAttendanceDashboard'; + +interface LiveAttendanceDashboardProps { + eventId: string; + eventName: string; + eventStartTime: string; + eventEndTime: string; + userRole: 'admin' | 'speaker' | 'attendee'; +} + +export function LiveAttendanceDashboard({ + eventId, + eventName, + eventStartTime, + eventEndTime, + userRole +}: LiveAttendanceDashboardProps) { + const logger = useLogger(); + const [attendanceData, setAttendanceData] = useState(null); + const [speakerAttendanceData, setSpeakerAttendanceData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [lastUpdated, setLastUpdated] = useState(new Date()); + + const loadAttendanceData = async () => { + try { + setLoading(true); + setError(''); + + // Load attendee attendance data + const attendeeData = await attendanceApiClient.getLiveAttendance(eventId); + setAttendanceData(attendeeData); + + // Load speaker attendance data if user is admin or speaker + if (userRole === 'admin' || userRole === 'speaker') { + try { + const speakerData = await attendanceApiClient.getSpeakerAttendance(eventId); + setSpeakerAttendanceData(speakerData); + } catch (speakerError) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to load speaker attendance data', speakerError as Error); + } + } + + setLastUpdated(new Date()); + logger.info(LOGGER_COMPONENT_NAME, 'Attendance data loaded successfully', { + eventId, + attendeeCount: attendeeData.totalAttended, + speakerCount: speakerAttendanceData?.totalSpeakersJoined || 0 + }); + + } catch (error) { + setError('Failed to load attendance data'); + logger.error(LOGGER_COMPONENT_NAME, 'Error loading attendance data', error as Error, { eventId }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadAttendanceData(); + + // Auto-refresh every 30 seconds + const interval = setInterval(loadAttendanceData, 30000); + + return () => clearInterval(interval); + }, [eventId, userRole]); + + const formatTime = (timeString: string) => { + return new Date(timeString).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true + }); + }; + + const formatEventTime = (startTime: string, endTime: string) => { + const start = new Date(startTime); + const end = new Date(endTime); + + const startTimeStr = start.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: true + }); + const endTimeStr = end.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: true + }); + + return `${startTimeStr} - ${endTimeStr}`; + }; + + const getEventStatus = () => { + const now = new Date(); + const startTime = new Date(eventStartTime); + const endTime = new Date(eventEndTime); + + if (now < startTime) { + return { status: 'upcoming', color: 'bg-yellow-100 text-yellow-800', icon: Clock }; + } else if (now >= startTime && now <= endTime) { + return { status: 'live', color: 'bg-green-100 text-green-800', icon: CheckCircle }; + } else { + return { status: 'ended', color: 'bg-gray-100 text-gray-800', icon: AlertCircle }; + } + }; + + const eventStatus = getEventStatus(); + const StatusIcon = eventStatus.icon; + + if (loading && !attendanceData) { + return ( + + + +

Loading attendance data...

+
+
+ ); + } + + if (error) { + return ( + + + +

{error}

+ +
+
+ ); + } + + return ( +
+ {/* Event Status Header */} + + +
+
+ {eventName} + + + {formatEventTime(eventStartTime, eventEndTime)} + +
+
+ + + {eventStatus.status.toUpperCase()} + + +
+
+
+ Last updated: {lastUpdated.toLocaleTimeString()} +
+
+
+ + {/* Attendee Attendance */} + {attendanceData && ( + + + + + Attendee Attendance + + + {attendanceData.totalAttended} of {attendanceData.totalRegistered} attendees joined + + + + {/* Attendance Progress Bar */} +
+
+ Attendance Rate + {attendanceData.attendancePercentage}% +
+
+
+
+
+ + {/* Attendee List (Admin/Speaker only) */} + {(userRole === 'admin' || userRole === 'speaker') && ( +
+

Attendees ({attendanceData.attendees.length})

+
+ {attendanceData.attendees.map((attendee: any, index: number) => ( +
+
+ +
+
{attendee.userName}
+
+ + {attendee.userEmail} +
+
+
+
+ Joined: {formatTime(attendee.joinedAt)} +
+
+ ))} +
+
+ )} +
+
+ )} + + {/* Speaker Attendance (Admin/Speaker only) */} + {speakerAttendanceData && (userRole === 'admin' || userRole === 'speaker') && ( + + + + + Speaker Attendance + + + {speakerAttendanceData.totalSpeakersJoined} of {speakerAttendanceData.totalSpeakersInvited} speakers joined + + + +
+

Speakers ({speakerAttendanceData.speakers.length})

+
+ {speakerAttendanceData.speakers.map((speaker: any, index: number) => ( +
+
+ +
+
{speaker.speakerName}
+
+ + {speaker.speakerEmail} +
+ {speaker.materialsSelected.length > 0 && ( +
+ {speaker.materialsSelected.length} materials selected +
+ )} +
+
+
+ {speaker.isAttended ? `Joined: ${formatTime(speaker.joinedAt)}` : 'Not joined'} +
+
+ ))} +
+
+
+
+ )} +
+ ); +} diff --git a/ems-client/components/attendance/SimpleAdminJoin.tsx b/ems-client/components/attendance/SimpleAdminJoin.tsx new file mode 100644 index 0000000..60a31a0 --- /dev/null +++ b/ems-client/components/attendance/SimpleAdminJoin.tsx @@ -0,0 +1,309 @@ +import React, { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { useLogger } from '@/lib/logger/LoggerProvider'; +import { attendanceApiClient } from '@/lib/api/attendance.api'; +import { formatLocalTime, formatTimeDifference, getCurrentLocalTime } from '@/lib/utils/timezone'; +import { + Users, + Clock, + CheckCircle, + AlertCircle, + Loader2, + Play, + CalendarDays, + MapPin, + Tag, + BarChart3, + Eye, + RefreshCw +} from 'lucide-react'; + +interface SimpleAdminJoinProps { + eventId: string; + eventTitle: string; + eventStartTime: string; + eventEndTime: string; + eventVenue: string; + eventCategory: string; + eventStatus: string; + eventDescription: string; +} + +const LOGGER_COMPONENT_NAME = 'SimpleAdminJoin'; + +export const SimpleAdminJoin: React.FC = ({ + eventId, + eventTitle, + eventStartTime, + eventEndTime, + eventVenue, + eventCategory, + eventStatus, + eventDescription, +}) => { + const logger = useLogger(); + const router = useRouter(); + const [canJoin, setCanJoin] = useState(false); + const [hasJoined, setHasJoined] = useState(false); + const [isJoining, setIsJoining] = useState(false); + const [timeUntilStart, setTimeUntilStart] = useState(''); + const [joinMessage, setJoinMessage] = useState(''); + // Admin-only component; default to compact rendering to avoid duplication in parent card + const compact = true; + + // Attendance overview state + const [attendanceData, setAttendanceData] = useState({ + totalRegistered: 0, + totalAttended: 0, + attendancePercentage: 0, + attendees: [] as any[] + }); + const [isLoadingAttendance, setIsLoadingAttendance] = useState(false); + + // Check if event has started and update join button state + useEffect(() => { + const checkEventStatus = () => { + const now = getCurrentLocalTime(); + const startTime = new Date(eventStartTime); + const endTime = new Date(eventEndTime); + + if (now >= startTime && now <= endTime) { + setCanJoin(true); + setTimeUntilStart(''); + } else if (now < startTime) { + setCanJoin(false); + setTimeUntilStart(formatTimeDifference(now, startTime)); + } else { + setCanJoin(false); + setTimeUntilStart('Event ended'); + } + }; + + checkEventStatus(); + const interval = setInterval(checkEventStatus, 1000); + + return () => clearInterval(interval); + }, [eventStartTime, eventEndTime]); + + // Fetch attendance data + const fetchAttendanceData = async () => { + setIsLoadingAttendance(true); + try { + const data = await attendanceApiClient.getLiveAttendance(eventId); + setAttendanceData(data); + } catch (error) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to fetch attendance data', error as Error); + } finally { + setIsLoadingAttendance(false); + } + }; + + useEffect(() => { + fetchAttendanceData(); + const interval = setInterval(fetchAttendanceData, 10000); // Update every 10 seconds + + return () => clearInterval(interval); + }, [eventId, logger]); + + const handleJoinEvent = async () => { + setIsJoining(true); + setJoinMessage(''); + + try { + // Join as admin using admin-specific endpoint + const response = await attendanceApiClient.adminJoinEvent(eventId); + + if (response.success) { + setHasJoined(true); + setJoinMessage(response.message); + logger.info(LOGGER_COMPONENT_NAME, response.message, { eventId }); + + // Refresh attendance data (don't let this fail the join) + try { + await fetchAttendanceData(); + } catch (attendanceError) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to refresh attendance data, but join was successful', attendanceError as Error); + // Don't fail the join if attendance refresh fails + } + + // Redirect to live auditorium after 2 seconds + setTimeout(() => { + router.push(`/dashboard/admin/events/${eventId}/live`); + }, 2000); + } else { + setJoinMessage(response.message); + logger.warn(LOGGER_COMPONENT_NAME, `Failed to join event: ${response.message}`, { eventId }); + } + } catch (error) { + setJoinMessage('An error occurred while trying to join the event.'); + logger.error(LOGGER_COMPONENT_NAME, 'Error joining event', error as Error); + } finally { + setIsJoining(false); + } + }; + + const formatEventTime = (startTime: string, endTime: string) => { + const start = new Date(startTime); + const end = new Date(endTime); + return `${formatLocalTime(start)} - ${formatLocalTime(end)}`; + }; + + const formatJoinTime = (joinTime: string) => { + return formatLocalTime(new Date(joinTime)); + }; + + return ( + + + {!compact && ( + <> + + + {eventTitle} - Admin View + + + + {formatEventTime(eventStartTime, eventEndTime)} + + + + {eventVenue} + + + + {eventCategory} + + + )} + {compact && ( + + + Live Event - Admin View + + )} + + + + {/* Event Description - Only show in full view */} + {!compact && ( +
+

{eventDescription}

+
+ )} + + {/* Attendance Overview */} +
+
+
+ + Attendance Overview +
+ +
+ +
+
+
+ {attendanceData.totalRegistered} +
+
Registered
+
+
+
+ {attendanceData.totalAttended} +
+
Attended
+
+
+
+ {attendanceData.attendancePercentage.toFixed(0)}% +
+
Rate
+
+
+ + {/* Recent Attendees - Hide in compact mode or show fewer */} + {attendanceData.attendees.length > 0 && !compact && ( +
+
+ + Recent Attendees +
+
+ {attendanceData.attendees.slice(0, 5).map((attendee, index) => ( +
+
+ + {attendee.userName || 'User'} +
+ + {formatJoinTime(attendee.joinedAt)} + +
+ ))} + {attendanceData.attendees.length > 5 && ( +
+ +{attendanceData.attendees.length - 5} more attendees +
+ )} +
+
+ )} +
+ + {/* Join Button */} + {eventStatus === 'PUBLISHED' && ( +
+ {canJoin ? ( + + ) : ( + + )} + + {/* Join Status Message */} + {joinMessage && ( +
+ {hasJoined ? : } + {joinMessage} +
+ )} +
+ )} + + {/* Event Status Badge */} + {eventStatus !== 'PUBLISHED' && ( + + Event Status: {eventStatus.replace(/_/g, ' ')} + + )} +
+
+ ); +}; diff --git a/ems-client/components/attendance/SimpleEventJoin.tsx b/ems-client/components/attendance/SimpleEventJoin.tsx new file mode 100644 index 0000000..128155f --- /dev/null +++ b/ems-client/components/attendance/SimpleEventJoin.tsx @@ -0,0 +1,261 @@ +import React, { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { useLogger } from '@/lib/logger/LoggerProvider'; +import { attendanceApiClient } from '@/lib/api/attendance.api'; +import { formatLocalTime, formatTimeDifference, getCurrentLocalTime } from '@/lib/utils/timezone'; +import { useRouter } from 'next/navigation'; +import { + Users, + Clock, + CheckCircle, + AlertCircle, + Loader2, + Play, + CalendarDays, + MapPin, + Tag +} from 'lucide-react'; + +interface SimpleEventJoinProps { + eventId: string; + eventTitle: string; + eventStartTime: string; + eventEndTime: string; + eventVenue: string; + eventCategory: string; + eventStatus: string; + eventDescription: string; + userRole: string; +} + +const LOGGER_COMPONENT_NAME = 'SimpleEventJoin'; + +export const SimpleEventJoin: React.FC = ({ + eventId, + eventTitle, + eventStartTime, + eventEndTime, + eventVenue, + eventCategory, + eventStatus, + eventDescription, + userRole, +}) => { + const logger = useLogger(); + const router = useRouter(); + const [canJoin, setCanJoin] = useState(false); + const [hasJoined, setHasJoined] = useState(false); + const [isJoining, setIsJoining] = useState(false); + const [attendanceCount, setAttendanceCount] = useState(0); + const [timeUntilStart, setTimeUntilStart] = useState(''); + const [joinMessage, setJoinMessage] = useState(''); + + // Check if event has started and update join button state + useEffect(() => { + const checkEventStatus = () => { + const now = getCurrentLocalTime(); + const startTime = new Date(eventStartTime); + const endTime = new Date(eventEndTime); + + if (now >= startTime && now <= endTime) { + setCanJoin(true); + setTimeUntilStart(''); + } else if (now < startTime) { + setCanJoin(false); + setTimeUntilStart(formatTimeDifference(now, startTime)); + } else { + setCanJoin(false); + setTimeUntilStart('Event ended'); + } + }; + + checkEventStatus(); + const interval = setInterval(checkEventStatus, 1000); + + return () => clearInterval(interval); + }, [eventStartTime, eventEndTime]); + + // Fetch attendance count + useEffect(() => { + const fetchAttendance = async () => { + try { + const metrics = await attendanceApiClient.getAttendanceMetrics(eventId); + setAttendanceCount(metrics.totalAttended); + } catch (error) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to fetch attendance metrics', error as Error); + } + }; + + fetchAttendance(); + const interval = setInterval(fetchAttendance, 5000); // Update every 5 seconds + + return () => clearInterval(interval); + }, [eventId, logger]); + + const handleJoinEvent = async () => { + setIsJoining(true); + setJoinMessage(''); + + try { + let response; + if (userRole === 'SPEAKER') { + response = await attendanceApiClient.speakerJoinEvent(eventId); + } else if (userRole === 'ADMIN') { + response = await attendanceApiClient.adminJoinEvent(eventId); + } else { + response = await attendanceApiClient.joinEvent(eventId); + } + + if (response.success) { + setHasJoined(true); + setJoinMessage(response.message); + logger.info(LOGGER_COMPONENT_NAME, response.message, { eventId, userRole }); + + // Refresh attendance count (don't let this fail the join) + try { + const metrics = await attendanceApiClient.getAttendanceMetrics(eventId); + setAttendanceCount(metrics.totalAttended); + } catch (metricsError) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to refresh attendance metrics, but join was successful', metricsError as Error); + // Don't fail the join if metrics refresh fails + } + + // Redirect to live auditorium after 2 seconds + setTimeout(() => { + if (userRole === 'ADMIN') { + router.push(`/dashboard/admin/events/${eventId}/live`); + } else if (userRole === 'SPEAKER') { + router.push(`/dashboard/speaker/events/${eventId}/live`); + } else { + router.push(`/dashboard/attendee/events/${eventId}/live`); + } + }, 2000); + } else { + setJoinMessage(response.message); + logger.warn(LOGGER_COMPONENT_NAME, `Failed to join event: ${response.message}`, { eventId, userRole }); + } + } catch (error) { + setJoinMessage('An error occurred while trying to join the event.'); + logger.error(LOGGER_COMPONENT_NAME, 'Error joining event', error as Error); + } finally { + setIsJoining(false); + } + }; + + const formatEventTime = (startTime: string, endTime: string) => { + const start = new Date(startTime); + const end = new Date(endTime); + return `${formatLocalTime(start)} - ${formatLocalTime(end)}`; + }; + + const getJoinButtonText = () => { + if (hasJoined) return 'Joined ✓'; + if (userRole === 'SPEAKER') return 'Join as Speaker'; + if (userRole === 'ADMIN') return 'Join as Admin'; + return 'Join Event'; + }; + + const getJoinButtonColor = () => { + if (hasJoined) return 'bg-green-600 hover:bg-green-700'; + return 'bg-blue-600 hover:bg-blue-700'; + }; + + return ( + + + + + {eventTitle} + + + + {formatEventTime(eventStartTime, eventEndTime)} + + + + {eventVenue} + + + + {eventCategory} + + + + + {/* Attendance Info */} +
+
+ + {attendanceCount} people joined +
+
+ + {timeUntilStart || 'Event active'} +
+
+ + {/* Event Description */} +
+

{eventDescription}

+
+ + {/* Join Button */} + {eventStatus === 'PUBLISHED' && ( +
+ {canJoin ? ( + + ) : ( + + )} + + {/* Join Status Message */} + {joinMessage && ( +
+ {hasJoined ? : } + {joinMessage} +
+ )} + + {/* Enter Auditorium Button */} + {hasJoined && ( + + )} +
+ )} + + {/* Event Status Badge */} + {eventStatus !== 'PUBLISHED' && ( + + Event Status: {eventStatus.replace(/_/g, ' ')} + + )} +
+
+ ); +}; diff --git a/ems-client/components/attendance/SimpleSpeakerJoin.tsx b/ems-client/components/attendance/SimpleSpeakerJoin.tsx new file mode 100644 index 0000000..269fb0e --- /dev/null +++ b/ems-client/components/attendance/SimpleSpeakerJoin.tsx @@ -0,0 +1,485 @@ +import React, { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { useLogger } from '@/lib/logger/LoggerProvider'; +import { attendanceApiClient } from '@/lib/api/attendance.api'; +import { speakerApiClient } from '@/lib/api/speaker.api'; +import { formatLocalTime, formatTimeDifference, getCurrentLocalTime } from '@/lib/utils/timezone'; +import { + Users, + Clock, + CheckCircle, + AlertCircle, + Loader2, + Play, + CalendarDays, + MapPin, + Tag, + FileText, + Upload, + Search +} from 'lucide-react'; + +interface SimpleSpeakerJoinProps { + eventId: string; + eventTitle: string; + eventStartTime: string; + eventEndTime: string; + eventVenue: string; + eventCategory: string; + eventStatus: string; + eventDescription: string; + speakerId: string; +} + +const LOGGER_COMPONENT_NAME = 'SimpleSpeakerJoin'; + +export const SimpleSpeakerJoin: React.FC = ({ + eventId, + eventTitle, + eventStartTime, + eventEndTime, + eventVenue, + eventCategory, + eventStatus, + eventDescription, + speakerId, +}) => { + const logger = useLogger(); + const router = useRouter(); + const [canJoin, setCanJoin] = useState(false); + const [hasJoined, setHasJoined] = useState(false); + const [isJoining, setIsJoining] = useState(false); + const [attendanceCount, setAttendanceCount] = useState(0); + const [timeUntilStart, setTimeUntilStart] = useState(''); + const [joinMessage, setJoinMessage] = useState(''); + const [hasAcceptedInvitation, setHasAcceptedInvitation] = useState(false); + const [isCheckingInvitation, setIsCheckingInvitation] = useState(true); + + // Material selection state + const [availableMaterials, setAvailableMaterials] = useState([]); + const [selectedMaterials, setSelectedMaterials] = useState([]); + const [isLoadingMaterials, setIsLoadingMaterials] = useState(false); + const [materialsMessage, setMaterialsMessage] = useState(''); + const [invitationId, setInvitationId] = useState(null); + const [profileId, setProfileId] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + + // Check if event has started and update join button state + useEffect(() => { + const checkEventStatus = () => { + const now = getCurrentLocalTime(); + const startTime = new Date(eventStartTime); + const endTime = new Date(eventEndTime); + + if (now >= startTime && now <= endTime) { + setCanJoin(true); + setTimeUntilStart(''); + } else if (now < startTime) { + setCanJoin(false); + setTimeUntilStart(formatTimeDifference(now, startTime)); + } else { + setCanJoin(false); + setTimeUntilStart('Event ended'); + } + }; + + checkEventStatus(); + const interval = setInterval(checkEventStatus, 1000); + + return () => clearInterval(interval); + }, [eventStartTime, eventEndTime]); + + // Check if speaker has accepted invitation for this event + useEffect(() => { + const checkAcceptedInvitation = async () => { + if (!speakerId) { + setIsCheckingInvitation(false); + return; + } + + try { + setIsCheckingInvitation(true); + // Get speaker profile from userId + const speakerProfile = await speakerApiClient.getSpeakerProfile(speakerId); + + // Get all invitations for this speaker + const invitations = await speakerApiClient.getSpeakerInvitations(speakerProfile.id); + + // Check if there's an accepted invitation for this event + const acceptedInvitation = invitations.find( + inv => inv.eventId === eventId && inv.status === 'ACCEPTED' + ); + + setHasAcceptedInvitation(!!acceptedInvitation); + setProfileId(speakerProfile.id); + if (acceptedInvitation) { + setInvitationId(acceptedInvitation.id); + } + + // Note: isAttended is not in the SpeakerInvitation type from the API + // We'll check it separately if needed + } catch (error) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to check accepted invitation', error as Error); + setHasAcceptedInvitation(false); + } finally { + setIsCheckingInvitation(false); + } + }; + + checkAcceptedInvitation(); + }, [eventId, speakerId, logger]); + + // Fetch attendance count + useEffect(() => { + const fetchAttendance = async () => { + try { + const metrics = await attendanceApiClient.getAttendanceMetrics(eventId); + setAttendanceCount(metrics.totalAttended); + } catch (error) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to fetch attendance metrics', error as Error); + } + }; + + fetchAttendance(); + const interval = setInterval(fetchAttendance, 5000); + + return () => clearInterval(interval); + }, [eventId, logger]); + + // Load available materials when profileId is available + useEffect(() => { + const loadMaterials = async () => { + if (!profileId) return; + + setIsLoadingMaterials(true); + try { + // Get speaker materials + const speakerMaterials = await speakerApiClient.getSpeakerMaterials(profileId); + setAvailableMaterials(speakerMaterials); + + if (speakerMaterials.length === 0) { + setMaterialsMessage('No materials available. Please upload materials first.'); + } + } catch (error) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load materials', error as Error); + setMaterialsMessage('Failed to load materials. Please try again.'); + } finally { + setIsLoadingMaterials(false); + } + }; + + loadMaterials(); + }, [profileId, logger]); + + const handleMaterialToggle = (materialId: string) => { + setSelectedMaterials(prev => + prev.includes(materialId) + ? prev.filter(id => id !== materialId) + : [...prev, materialId] + ); + }; + + const handleJoinEvent = async () => { + setIsJoining(true); + setJoinMessage(''); + + try { + // First, update materials if any are selected and we have invitationId + if (selectedMaterials.length > 0 && invitationId) { + try { + await attendanceApiClient.updateMaterialsForEvent(invitationId, selectedMaterials); + logger.info(LOGGER_COMPONENT_NAME, 'Materials updated for event', { + eventId, + invitationId, + selectedMaterials + }); + } catch (materialError) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to update materials, continuing with join', materialError as Error); + } + } + + // Join as speaker + const response = await attendanceApiClient.speakerJoinEvent(eventId); + + logger.debug(LOGGER_COMPONENT_NAME, 'Join event response received', { + response, + hasSuccess: 'success' in response, + successValue: response?.success, + eventId + }); + + if (response && response.success === true) { + setHasJoined(true); + setJoinMessage(response.message || 'Successfully joined the event!'); + logger.info(LOGGER_COMPONENT_NAME, 'Speaker joined event successfully', { + eventId, + message: response.message + }); + + // Refresh attendance count (don't let this fail the join) + try { + const metrics = await attendanceApiClient.getAttendanceMetrics(eventId); + setAttendanceCount(metrics.totalAttended); + } catch (metricsError) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to refresh attendance metrics, but join was successful', metricsError as Error); + // Don't fail the join if metrics refresh fails + } + + // Redirect to live auditorium after 2 seconds + setTimeout(() => { + router.push(`/dashboard/speaker/events/${eventId}/live`); + }, 2000); + } else { + const errorMsg = response?.message || 'Failed to join event. Please try again.'; + setJoinMessage(errorMsg); + setHasJoined(false); + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to join event', { + eventId, + response, + message: errorMsg + }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'An error occurred while trying to join the event.'; + setJoinMessage(errorMessage); + setHasJoined(false); + logger.error(LOGGER_COMPONENT_NAME, 'Error joining event', error as Error, { eventId }); + } finally { + setIsJoining(false); + } + }; + + const formatEventTime = (startTime: string, endTime: string) => { + const start = new Date(startTime); + const end = new Date(endTime); + return `${formatLocalTime(start)} - ${formatLocalTime(end)}`; + }; + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + return ( + + + + + {eventTitle} - Speaker View + + + + {formatEventTime(eventStartTime, eventEndTime)} + + + + {eventVenue} + + + + {eventCategory} + + + + + {/* Attendance Info */} +
+
+ + {attendanceCount} people joined +
+
+ + {timeUntilStart || 'Event active'} +
+
+ + {/* Event Description */} +
+

{eventDescription}

+
+ + {/* Material Selection */} + {canJoin && !hasJoined && hasAcceptedInvitation && ( +
+
+
+ + Select Materials for This Event +
+
+ + {isLoadingMaterials ? ( +
+ +

Loading materials...

+
+ ) : availableMaterials.length === 0 ? ( +
+ +

+ No materials available +

+

+ You must upload at least one material before joining an event +

+ +
+ ) : ( + <> + {/* Search Box */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* Materials List */} +
+ {availableMaterials + .filter(material => + material.fileName.toLowerCase().includes(searchTerm.toLowerCase()) + ) + .map((material) => ( +
+ handleMaterialToggle(material.id)} + /> + +
+ ))} +
+ + {searchTerm && availableMaterials.filter(m => m.fileName.toLowerCase().includes(searchTerm.toLowerCase())).length === 0 && ( +
+ No materials found matching "{searchTerm}" +
+ )} + + {selectedMaterials.length > 0 && ( +
+ {selectedMaterials.length} material(s) selected +
+ )} + + )} + + {materialsMessage && !isLoadingMaterials && availableMaterials.length === 0 && ( +
+ + {materialsMessage} +
+ )} +
+ )} + + {/* Join Button - Only show if speaker has accepted invitation */} + {eventStatus === 'PUBLISHED' && ( +
+ {isCheckingInvitation ? ( + + ) : !hasAcceptedInvitation ? ( +
+ +

+ You must be invited and accept the invitation to join this event. +

+
+ ) : canJoin ? ( + + ) : ( + + )} + + {/* Join Status Message */} + {joinMessage && ( +
+ {hasJoined ? : } + {joinMessage} +
+ )} + + {/* Material Selection Reminder */} + {canJoin && !hasJoined && hasAcceptedInvitation && availableMaterials.length > 0 && selectedMaterials.length === 0 && ( +
+ + Please select at least one material to join this event +
+ )} + + {/* No Materials Warning */} + {canJoin && !hasJoined && hasAcceptedInvitation && availableMaterials.length === 0 && !isLoadingMaterials && ( +
+ + You must upload at least one presentation material before joining this event +
+ )} +
+ )} + + {/* Event Status Badge */} + {eventStatus !== 'PUBLISHED' && ( + + Event Status: {eventStatus.replace(/_/g, ' ')} + + )} +
+
+ ); +}; diff --git a/ems-client/components/attendance/SpeakerMaterialSelection.tsx b/ems-client/components/attendance/SpeakerMaterialSelection.tsx new file mode 100644 index 0000000..0629971 --- /dev/null +++ b/ems-client/components/attendance/SpeakerMaterialSelection.tsx @@ -0,0 +1,280 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { useLogger } from '@/lib/logger/LoggerProvider'; +import { attendanceApiClient } from '@/lib/api/attendance.api'; +import { + FileText, + Upload, + CheckCircle, + AlertCircle, + Loader2, + Save, + Clock +} from 'lucide-react'; + +const LOGGER_COMPONENT_NAME = 'SpeakerMaterialSelection'; + +interface SpeakerMaterialSelectionProps { + invitationId: string; + eventId: string; + eventName: string; + eventStartTime: string; + onMaterialsUpdated?: () => void; +} + +export function SpeakerMaterialSelection({ + invitationId, + eventId, + eventName, + eventStartTime, + onMaterialsUpdated +}: SpeakerMaterialSelectionProps) { + const logger = useLogger(); + const [materialsData, setMaterialsData] = useState(null); + const [selectedMaterials, setSelectedMaterials] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + const [canEditMaterials, setCanEditMaterials] = useState(true); + + useEffect(() => { + loadMaterialsData(); + }, [invitationId]); + + // Check if materials can be edited (before event starts) + useEffect(() => { + const now = new Date(); + const eventStart = new Date(eventStartTime); + setCanEditMaterials(now < eventStart); + }, [eventStartTime]); + + const loadMaterialsData = async () => { + try { + setLoading(true); + setError(''); + + const data = await attendanceApiClient.getAvailableMaterials(invitationId); + setMaterialsData(data); + setSelectedMaterials(data.selectedMaterials); + + logger.info(LOGGER_COMPONENT_NAME, 'Materials data loaded successfully', { + invitationId, + availableCount: data.availableMaterials.length, + selectedCount: data.selectedMaterials.length + }); + + } catch (error) { + setError('Failed to load materials'); + logger.error(LOGGER_COMPONENT_NAME, 'Error loading materials data', error as Error, { invitationId }); + } finally { + setLoading(false); + } + }; + + const handleMaterialToggle = (materialId: string) => { + if (!canEditMaterials) return; + + setSelectedMaterials(prev => + prev.includes(materialId) + ? prev.filter(id => id !== materialId) + : [...prev, materialId] + ); + }; + + const handleSaveMaterials = async () => { + try { + setSaving(true); + setError(''); + setSuccessMessage(''); + + const result = await attendanceApiClient.updateMaterialsForEvent(invitationId, selectedMaterials); + + if (result.success) { + setSuccessMessage(result.message); + logger.info(LOGGER_COMPONENT_NAME, 'Materials updated successfully', { + invitationId, + selectedCount: selectedMaterials.length + }); + + if (onMaterialsUpdated) { + onMaterialsUpdated(); + } + } else { + setError(result.message); + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to update materials', { + invitationId, + message: result.message + }); + } + } catch (error) { + setError('Failed to save materials. Please try again.'); + logger.error(LOGGER_COMPONENT_NAME, 'Error saving materials', error as Error, { invitationId }); + } finally { + setSaving(false); + } + }; + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + }; + + if (loading) { + return ( + + + +

Loading materials...

+
+
+ ); + } + + if (error && !materialsData) { + return ( + + + +

{error}

+ +
+
+ ); + } + + return ( + + + + + Material Selection + + + Select materials to include in "{eventName}" + + {!canEditMaterials && ( + + + Materials locked - event started + + )} + + + + {/* Available Materials */} + {materialsData && materialsData.availableMaterials.length > 0 ? ( +
+

+ Available Materials ({materialsData.availableMaterials.length}) +

+
+ {materialsData.availableMaterials.map((material: any) => ( +
+ handleMaterialToggle(material.id)} + disabled={!canEditMaterials} + /> +
+
+ + +
+
+ {formatFileSize(material.fileSize)} • {material.mimeType} • Uploaded {formatDate(material.uploadDate)} +
+
+ {selectedMaterials.includes(material.id) && ( + + )} +
+ ))} +
+
+ ) : ( +
+ +

No materials available

+

Upload materials first to select them for events

+
+ )} + + {/* Selection Summary */} + {materialsData && materialsData.availableMaterials.length > 0 && ( +
+
+ Selected Materials: + + {selectedMaterials.length} of {materialsData.availableMaterials.length} + +
+
+ )} + + {/* Save Button */} + {materialsData && materialsData.availableMaterials.length > 0 && canEditMaterials && ( + + )} + + {/* Status Messages */} + {successMessage && ( +
+ {successMessage} +
+ )} + + {error && ( +
+ {error} +
+ )} +
+
+ ); +} diff --git a/ems-client/components/events/EventDetailsPage.tsx b/ems-client/components/events/EventDetailsPage.tsx new file mode 100644 index 0000000..d941d45 --- /dev/null +++ b/ems-client/components/events/EventDetailsPage.tsx @@ -0,0 +1,602 @@ +'use client'; + +import { useAuth } from "@/lib/auth-context"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + ArrowLeft, + Calendar, + MapPin, + Clock, + Users, + UserCheck, + UserPlus, + FileText, + Crown, + Download, + Eye, + RefreshCw, + AlertCircle, + CheckCircle, + XCircle +} from "lucide-react"; +import { useRouter, useParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useLogger } from "@/lib/logger/LoggerProvider"; +import { eventAPI } from "@/lib/api/event.api"; +import { AttendanceApiClient } from "@/lib/api/attendance.api"; +import { AdminApiClient, SpeakerInvitation } from "@/lib/api/admin.api"; +import { EventResponse, EventStatus } from "@/lib/api/types/event.types"; +import { LiveAttendanceResponse, AttendanceMetricsResponse } from "@/lib/api/attendance.api"; +import { EventJoinInterface } from "@/components/attendance/EventJoinInterface"; + +const LOGGER_COMPONENT_NAME = 'EventDetailsPage'; + +const statusColors = { + [EventStatus.DRAFT]: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + [EventStatus.PENDING_APPROVAL]: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', + [EventStatus.PUBLISHED]: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + [EventStatus.REJECTED]: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', + [EventStatus.CANCELLED]: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200', + [EventStatus.COMPLETED]: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', +}; + +interface EventDetailsPageProps { + userRole: 'ADMIN' | 'SPEAKER' | 'USER'; + showJoinInterface?: boolean; + showAdminControls?: boolean; + showSpeakerControls?: boolean; +} + +export const EventDetailsPage = ({ + userRole, + showJoinInterface = true, + showAdminControls = false, + showSpeakerControls = false +}: EventDetailsPageProps) => { + const { user } = useAuth(); + const router = useRouter(); + const params = useParams(); + const logger = useLogger(); + + const eventId = params.id as string; + + const [event, setEvent] = useState(null); + const [attendance, setAttendance] = useState(null); + const [metrics, setMetrics] = useState(null); + interface SpeakerInvitationWithInfo extends SpeakerInvitation { + speakerName?: string | null; + speakerEmail?: string | null; + isAttended?: boolean; + } + + const [acceptedSpeakers, setAcceptedSpeakers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + // Create API client instances + const attendanceAPI = new AttendanceApiClient(); + const adminAPI = new AdminApiClient(); + + const loadEvent = async () => { + try { + logger.info(LOGGER_COMPONENT_NAME, 'Loading event details', { eventId }); + const eventResponse = await eventAPI.getEventById(eventId); + setEvent(eventResponse.data); + setError(null); + } catch (err) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load event', err as Error); + setError('Failed to load event details'); + } + }; + + const loadAcceptedSpeakers = async () => { + if (!eventId) return; + + try { + logger.info(LOGGER_COMPONENT_NAME, 'Loading accepted speakers for event', { eventId }); + + // Fetch all invitations for this event + const invitations = await adminAPI.getEventInvitations(eventId); + + // Filter to only show speakers who have ACCEPTED invitations + const accepted = invitations.filter(inv => inv.status === 'ACCEPTED'); + + // Fetch speaker profiles for accepted invitations + const speakersWithInfo = await Promise.all( + accepted.map(async (invitation) => { + try { + const speakerProfile = await adminAPI.getSpeakerProfile(invitation.speakerId); + return { + ...invitation, + speakerName: speakerProfile.name, + speakerEmail: speakerProfile.email + }; + } catch (err) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to load speaker profile', { + speakerId: invitation.speakerId, + error: err + }); + return { + ...invitation, + speakerName: null, + speakerEmail: null + }; + } + }) + ); + + setAcceptedSpeakers(speakersWithInfo); + + logger.info(LOGGER_COMPONENT_NAME, 'Accepted speakers loaded', { + eventId, + count: speakersWithInfo.length + }); + } catch (err) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load accepted speakers', err as Error); + // Don't set error - this is not critical, just show empty list + setAcceptedSpeakers([]); + } + }; + + const loadAttendance = async () => { + if (!event) return; + + try { + logger.info(LOGGER_COMPONENT_NAME, 'Loading attendance data', { eventId }); + + // Load live attendance data + const attendanceData = await attendanceAPI.getLiveAttendance(eventId); + setAttendance(attendanceData); + + // Load metrics (for admins and speakers) + if (userRole === 'ADMIN' || userRole === 'SPEAKER') { + const metricsData = await attendanceAPI.getAttendanceMetrics(eventId); + setMetrics(metricsData); + } + } catch (err) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load attendance data', err as Error); + // Don't set error for attendance - it's not critical + } + }; + + const refreshData = async () => { + setRefreshing(true); + await Promise.all([loadEvent(), loadAttendance(), loadAcceptedSpeakers()]); + setRefreshing(false); + }; + + useEffect(() => { + const loadData = async () => { + setLoading(true); + await loadEvent(); + await loadAcceptedSpeakers(); + setLoading(false); + }; + + loadData(); + }, [eventId]); + + useEffect(() => { + if (event) { + loadAttendance(); + } + }, [event]); + + // Auto-refresh attendance data every 30 seconds + useEffect(() => { + if (!event) return; + + const interval = setInterval(() => { + loadAttendance(); + }, 30000); + + return () => clearInterval(interval); + }, [event]); + + if (loading) { + return ( +
+
+ + + +

Loading Event Details

+

Please wait...

+
+
+
+
+ ); + } + + if (error || !event) { + return ( +
+
+ + + +

Error Loading Event

+

{error}

+ +
+
+
+
+ ); + } + + const formatDateTime = (dateString: string) => { + return new Date(dateString).toLocaleString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZone: 'America/Chicago' + }); + }; + + const isEventStarted = new Date(event.bookingStartDate) <= new Date(); + const isEventEnded = new Date(event.bookingEndDate) <= new Date(); + + return ( +
+ {/* Header */} +
+
+
+
+ +
+

+ Event Details +

+

+ {event.name} +

+
+
+ +
+ +
+
+
+
+ +
+ {/* Event Overview Card */} + + +
+
+ {event.name} +
+ + {event.status.replace('_', ' ')} + + {isEventStarted && !isEventEnded && ( + + Live + + )} + {isEventEnded && ( + + Ended + + )} +
+
+
+
+ + +
+

Description

+

{event.description}

+
+ +
+
+

Category

+

{event.category}

+
+ +
+

+ + Venue +

+

{event.venue.name}

+

{event.venue.address}

+
+ +
+

+ + Capacity +

+

{event.venue.capacity} attendees

+
+ +
+

+ + Event Schedule +

+
+
+

Start Time

+

{formatDateTime(event.bookingStartDate)}

+
+
+

End Time

+

{formatDateTime(event.bookingEndDate)}

+
+
+
+
+ + {/* Join Interface */} + {showJoinInterface && ( +
+ +
+ )} +
+
+ + {/* Detailed Information Tabs */} + + + Attendance + Speakers + Materials + Event Info + + + {/* Attendance Tab */} + + + + + + Live Attendance + + + + {attendance ? ( +
+ {/* Attendance Stats */} +
+
+ +

{attendance.totalRegistered}

+

Registered

+
+
+ +

{attendance.totalAttended}

+

Joined

+
+
+
+ {attendance.attendancePercentage}% +
+

Attendance Rate

+
+
+ + {/* Detailed Attendee List (for admins and speakers) */} + {(userRole === 'ADMIN' || userRole === 'SPEAKER') && attendance.attendees.length > 0 && ( +
+

Attendee Details

+
+ {attendance.attendees.map((attendee) => ( +
+
+

{attendee.userName}

+

{attendee.userEmail}

+
+
+ {attendee.isAttended ? ( + + + Joined + + ) : ( + + + Not Joined + + )} + {attendee.joinedAt && ( +

+ {new Date(attendee.joinedAt).toLocaleTimeString()} +

+ )} +
+
+ ))} +
+
+ )} +
+ ) : ( +
+ +

No attendance data available

+
+ )} +
+
+
+ + {/* Speakers Tab */} + + + + + + Event Speakers + + + + {acceptedSpeakers.length > 0 ? ( +
+ {acceptedSpeakers.map((invitation) => ( +
+
+
+ {invitation.speakerName ? invitation.speakerName.charAt(0).toUpperCase() : 'S'} +
+
+

+ {invitation.speakerName || `Speaker ${invitation.speakerId.substring(0, 8)}`} +

+ {invitation.speakerEmail && ( +

{invitation.speakerEmail}

+ )} + {invitation.isAttended && ( + + + Joined Event + + )} +
+
+
+ ))} +
+ ) : ( +
+ +

No speakers have been invited yet

+

+ Speakers must be invited and accept their invitation to appear here +

+
+ )} +
+
+
+ + {/* Materials Tab */} + + + + + + Event Materials + + + + {isEventStarted ? ( +
+ {/* Materials would be displayed here */} +
+ +

Materials will be available once the event starts

+
+
+ ) : ( +
+ +

Materials will be available when the event starts

+
+ )} +
+
+
+ + {/* Event Info Tab */} + + + + + + Event Information + + + +
+
+

Event ID

+

{event.id}

+
+ +
+

Created By

+

Admin User

+
+ +
+

Created At

+

{formatDateTime(event.createdAt)}

+
+ +
+

Last Updated

+

{formatDateTime(event.updatedAt)}

+
+
+ + {/* Additional metrics for admins */} + {userRole === 'ADMIN' && metrics && ( +
+

Detailed Metrics

+
+
+

Total Registered

+

{metrics.totalRegistered}

+
+
+

Attendance Rate

+

{metrics.attendancePercentage}%

+
+
+
+ )} +
+
+
+
+
+
+ ); +}; diff --git a/ems-client/components/events/LiveEventAuditorium.tsx b/ems-client/components/events/LiveEventAuditorium.tsx new file mode 100644 index 0000000..1f2d98a --- /dev/null +++ b/ems-client/components/events/LiveEventAuditorium.tsx @@ -0,0 +1,638 @@ +'use client'; + +import { useAuth } from "@/lib/auth-context"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + ArrowLeft, + Users, + UserCheck, + UserPlus, + FileText, + Download, + Clock, + MapPin, + Calendar, + RefreshCw, + Crown, + CheckCircle, + XCircle, + AlertCircle +} from "lucide-react"; +import { useRouter, useParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useLogger } from "@/lib/logger/LoggerProvider"; +import { eventAPI } from "@/lib/api/event.api"; +import { AttendanceApiClient } from "@/lib/api/attendance.api"; +import { EventResponse, EventStatus } from "@/lib/api/types/event.types"; +import { LiveAttendanceResponse, SpeakerAttendanceResponse } from "@/lib/api/attendance.api"; +import { adminApiClient } from "@/lib/api/admin.api"; +import { speakerApiClient, PresentationMaterial } from "@/lib/api/speaker.api"; + +const LOGGER_COMPONENT_NAME = 'LiveEventAuditorium'; + +const statusColors = { + [EventStatus.DRAFT]: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + [EventStatus.PENDING_APPROVAL]: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', + [EventStatus.PUBLISHED]: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + [EventStatus.REJECTED]: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', + [EventStatus.CANCELLED]: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200', + [EventStatus.COMPLETED]: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', +}; + +interface LiveEventAuditoriumProps { + userRole: 'ADMIN' | 'SPEAKER' | 'USER'; +} + +export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => { + const { user } = useAuth(); + const router = useRouter(); + const params = useParams(); + const logger = useLogger(); + + const eventId = params.id as string; + + const [event, setEvent] = useState(null); + const [attendance, setAttendance] = useState(null); + const [speakerAttendance, setSpeakerAttendance] = useState(null); + const [speakerInfo, setSpeakerInfo] = useState<{ name: string | null; email: string } | null>(null); + const [selectedMaterials, setSelectedMaterials] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + // Create API client instance + const attendanceAPI = new AttendanceApiClient(); + + const loadSpeakerInfo = async (eventId: string, speakerAttendanceData?: SpeakerAttendanceResponse | null) => { + try { + // Try to get speaker info from speaker attendance data first (for all roles) + if (speakerAttendanceData && speakerAttendanceData.speakers.length > 0) { + // Get the first joined speaker, or first speaker if none joined + const speaker = speakerAttendanceData.speakers.find(s => s.isAttended) || speakerAttendanceData.speakers[0]; + setSpeakerInfo({ + name: speaker.speakerName || null, + email: speaker.speakerEmail + }); + logger.info(LOGGER_COMPONENT_NAME, 'Speaker info loaded from speaker attendance', { + speakerId: speaker.speakerId, + speakerName: speaker.speakerName + }); + return; + } + + // Fallback: Try to get speaker info from event (for all roles) + // This is a last resort if speaker attendance data doesn't have the info + if (!speakerInfo && event) { + // Try using event speakerId if available + if (event.speakerId) { + // For non-admin users, we can't fetch speaker profile, so use a default + if (userRole === 'ADMIN') { + try { + const invitations = await adminApiClient.getEventInvitations(eventId); + const acceptedInvitation = invitations.find(inv => inv.status === 'ACCEPTED'); + + if (acceptedInvitation) { + const speakerProfile = await adminApiClient.getSpeakerProfile(acceptedInvitation.speakerId); + setSpeakerInfo({ + name: speakerProfile.name || null, + email: speakerProfile.email + }); + logger.info(LOGGER_COMPONENT_NAME, 'Speaker info loaded from accepted invitation', { + speakerId: acceptedInvitation.speakerId, + speakerName: speakerProfile.name + }); + } + } catch (err) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to load speaker info from invitations', err as Error); + } + } + } + } + } catch (err) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to load speaker info', err as Error); + // Don't fail the whole page if speaker info fails + } + }; + + const loadEvent = async () => { + try { + logger.info(LOGGER_COMPONENT_NAME, 'Loading event details', { eventId }); + const eventResponse = await eventAPI.getEventById(eventId); + setEvent(eventResponse.data); + setError(null); + } catch (err) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load event', err as Error); + setError('Failed to load event details'); + } + }; + + const loadSpeakerAttendanceForAll = async () => { + // Load speaker attendance for all roles (to show speaker join status) + try { + // Try to load speaker attendance - all authenticated users can now access it + const speakerData = await attendanceAPI.getSpeakerAttendance(eventId).catch(() => null); + setSpeakerAttendance(speakerData); + + // Update speaker info from speaker attendance data + if (speakerData) { + await loadSpeakerInfo(eventId, speakerData); + } + + // Load selected materials if speaker has joined and selected materials + if (speakerData && speakerData.speakers.length > 0) { + const joinedSpeaker = speakerData.speakers.find(s => s.isAttended); + if (joinedSpeaker && joinedSpeaker.materialsSelected.length > 0) { + await loadMaterialDetails(joinedSpeaker.materialsSelected); + } else { + // Clear materials if no speaker joined or no materials selected + setSelectedMaterials([]); + } + } else { + setSelectedMaterials([]); + } + } catch (err) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to load speaker attendance data', err as Error); + // Don't fail the whole page - clear materials and continue + setSelectedMaterials([]); + } + }; + + const loadAttendance = async () => { + if (!event) return; + + // Load speaker attendance for all roles + await loadSpeakerAttendanceForAll(); + + // Load detailed attendance data only for ADMIN and SPEAKER roles + if (userRole !== 'ADMIN' && userRole !== 'SPEAKER') return; + + try { + logger.info(LOGGER_COMPONENT_NAME, 'Loading attendance data', { eventId }); + const attendanceData = await attendanceAPI.getLiveAttendance(eventId); + setAttendance(attendanceData); + } catch (err) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load attendance data', err as Error); + } + }; + + const loadMaterialDetails = async (materialIds: string[]) => { + try { + const token = attendanceAPI.getToken(); + const materialPromises = materialIds.map(async (materialId) => { + try { + const headers: HeadersInit = { + 'Content-Type': 'application/json' + }; + // Only add auth header if token exists + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`/api/materials/${materialId}`, { + headers + }); + if (!response.ok) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to load material', { materialId, status: response.status }); + return null; + } + const result = await response.json(); + return result.data || null; + } catch (err) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to load material', err as Error); + return null; + } + }); + + const materials = await Promise.all(materialPromises); + setSelectedMaterials(materials.filter(m => m !== null) as PresentationMaterial[]); + } catch (err) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load material details', err as Error); + } + }; + + const refreshData = async () => { + setRefreshing(true); + await Promise.all([loadEvent(), loadAttendance()]); + setRefreshing(false); + }; + + useEffect(() => { + const loadData = async () => { + setLoading(true); + await loadEvent(); + setLoading(false); + }; + + loadData(); + }, [eventId]); + + useEffect(() => { + if (event) { + loadAttendance(); + } + }, [event, userRole]); + + // Auto-refresh attendance data every 15 seconds for live experience + useEffect(() => { + if (!event) return; + + const interval = setInterval(() => { + loadAttendance(); + }, 15000); + + return () => clearInterval(interval); + }, [event, userRole]); + + if (loading) { + return ( +
+
+ + + +

Entering Auditorium...

+

Please wait...

+
+
+
+
+ ); + } + + if (error || !event) { + return ( +
+
+ + + +

Error Loading Event

+

{error}

+ +
+
+
+
+ ); + } + + const formatDateTime = (dateString: string) => { + return new Date(dateString).toLocaleString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZone: 'America/Chicago' + }); + }; + + const isEventStarted = new Date(event.bookingStartDate) <= new Date(); + const isEventEnded = new Date(event.bookingEndDate) <= new Date(); + + // Separate attendees into joined and not joined + const joinedAttendees = attendance?.attendees.filter(attendee => attendee.isAttended) || []; + const notJoinedAttendees = attendance?.attendees.filter(attendee => !attendee.isAttended) || []; + + // Get speaker info from speaker attendance + const speakerHasJoined = speakerAttendance?.speakers && speakerAttendance.speakers.length > 0 && speakerAttendance.speakers[0].isAttended; + + return ( +
+ {/* Header */} +
+
+
+
+ +
+

+ 🎭 Live Event Auditorium +

+

+ {event.name} +

+
+
+ +
+ + +
+ LIVE +
+
+
+
+
+ +
+
+ {/* Main Content Area */} +
+ {/* Event Status Banner */} + + +
+
+

{event.name}

+
+
+ + {formatDateTime(event.bookingStartDate)} +
+
+ + {event.venue.name} +
+
+
+ + {event.status.replace('_', ' ')} + +
+
+
+ + {/* Event Description */} + + + Event Description + + +

{event.description}

+
+
+ + {/* Materials Section */} + + + + + Event Materials + {selectedMaterials.length > 0 && ( + + {selectedMaterials.length} {selectedMaterials.length === 1 ? 'file' : 'files'} + + )} + + + + {isEventStarted ? ( + selectedMaterials.length > 0 ? ( +
+ {selectedMaterials.map((material) => ( +
+
+ +
+

{material.fileName}

+

+ {material.fileSize ? `${(material.fileSize / 1024).toFixed(2)} KB` : 'Unknown size'} • {material.mimeType || 'Unknown type'} +

+
+
+ +
+ ))} +
+ ) : ( +
+ +

+ {speakerAttendance && speakerAttendance.speakers.some(s => s.isAttended) + ? 'No materials selected by the speaker' + : 'Materials will be available when the speaker joins and selects materials'} +

+
+ ) + ) : ( +
+ +

Materials will be available when the event starts

+
+ )} +
+
+
+ + {/* Right Sidebar - Attendance Panel */} +
+
+ {/* Speaker Info */} + + + +
+ + Speaker +
+ {speakerAttendance && speakerAttendance.speakers.length > 0 && speakerAttendance.speakers.some(s => s.isAttended) && ( + + + Joined + + )} + {speakerAttendance && speakerAttendance.speakers.length > 0 && !speakerAttendance.speakers.some(s => s.isAttended) && ( + + + Not Joined + + )} +
+
+ +
+
+ {speakerInfo?.name ? speakerInfo.name.charAt(0).toUpperCase() : (event.speakerId ? event.speakerId.charAt(0).toUpperCase() : 'S')} +
+
+

{speakerInfo?.name || 'Speaker'}

+

{speakerInfo?.email || 'Main Speaker'}

+
+
+
+
+ + {/* Attendance Stats - Show for ADMIN only */} + {userRole === 'ADMIN' && ( + + + + + Attendance + + + + {attendance ? ( + <> +
+
+ +

{attendance.totalAttended}

+

Joined

+
+
+ +

{attendance.totalRegistered}

+

Registered

+
+
+ +
+

{attendance.attendancePercentage}%

+

Attendance Rate

+
+ + ) : ( +
+ +

Loading attendance...

+
+ )} +
+
+ )} + + {/* People Who Joined - Show for ADMIN and SPEAKER only */} + {(userRole === 'ADMIN' || userRole === 'SPEAKER') && ( + + + + + In Auditorium ({joinedAttendees.length}) + + + + +
+ {joinedAttendees.length > 0 ? ( + joinedAttendees.map((attendee) => ( +
+
+ {attendee.userName.charAt(0).toUpperCase()} +
+
+

{attendee.userName}

+

{attendee.userEmail}

+
+ +
+ )) + ) : ( +
+ +

No one has joined yet

+
+ )} +
+
+
+
+ )} + + {/* Other Registered Attendees - Show for ADMIN and SPEAKER only */} + {(userRole === 'ADMIN' || userRole === 'SPEAKER') && ( + + + + + Other Registered Attendees ({notJoinedAttendees.length}) + + + + +
+ {notJoinedAttendees.length > 0 ? ( + notJoinedAttendees.map((attendee) => ( +
+
+ {attendee.userName.charAt(0).toUpperCase()} +
+
+

{attendee.userName}

+

{attendee.userEmail}

+
+ +
+ )) + ) : ( +
+ +

Everyone has joined!

+
+ )} +
+
+
+
+ )} +
+
+
+
+
+ ); +}; diff --git a/ems-client/components/ui/checkbox.tsx b/ems-client/components/ui/checkbox.tsx new file mode 100644 index 0000000..bccad2f --- /dev/null +++ b/ems-client/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +export interface CheckboxProps + extends React.InputHTMLAttributes { + onCheckedChange?: (checked: boolean) => void +} + +const Checkbox = React.forwardRef( + ({ className, onCheckedChange, onChange, ...props }, ref) => { + const handleChange = (event: React.ChangeEvent) => { + onCheckedChange?.(event.target.checked) + onChange?.(event) + } + + return ( + + ) + } +) +Checkbox.displayName = "Checkbox" + +export { Checkbox } diff --git a/ems-client/components/ui/scroll-area.tsx b/ems-client/components/ui/scroll-area.tsx new file mode 100644 index 0000000..552ec6a --- /dev/null +++ b/ems-client/components/ui/scroll-area.tsx @@ -0,0 +1,23 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" + +interface ScrollAreaProps extends React.HTMLAttributes { + children: React.ReactNode +} + +const ScrollArea = React.forwardRef( + ({ className, children, ...props }, ref) => ( +
+ {children} +
+ ) +) +ScrollArea.displayName = "ScrollArea" + +export { ScrollArea } diff --git a/ems-client/eslint.config.mjs b/ems-client/eslint.config.mjs index db6e9ff..bf205c7 100644 --- a/ems-client/eslint.config.mjs +++ b/ems-client/eslint.config.mjs @@ -19,10 +19,14 @@ const eslintConfig = [ "build/**", "next-env.d.ts", ], - extends: ["next/core-web-vitals", "next/typescript"], rules: { "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": "off" + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-empty-object-type": "off", + "react/no-unescaped-entities": "off", + "react-hooks/exhaustive-deps": "off", + "@next/next/no-img-element": "off", + "@next/next/no-page-custom-font": "off" } }, ]; diff --git a/ems-client/lib/api/attendance.api.ts b/ems-client/lib/api/attendance.api.ts new file mode 100644 index 0000000..a971d60 --- /dev/null +++ b/ems-client/lib/api/attendance.api.ts @@ -0,0 +1,250 @@ +import { BaseApiClient } from './base-api.client'; + +export interface JoinEventRequest { + eventId: string; +} + +export interface JoinEventResponse { + success: boolean; + message: string; + joinedAt?: string; + isFirstJoin: boolean; +} + +export interface LiveAttendanceResponse { + eventId: string; + totalRegistered: number; + totalAttended: number; + attendancePercentage: number; + attendees: Array<{ + userId: string; + userName: string; + userEmail: string; + joinedAt: string; + isAttended: boolean; + }>; +} + +export interface AttendanceMetricsResponse { + eventId: string; + totalAttended: number; + totalRegistered: number; + attendancePercentage: number; +} + +export interface SpeakerJoinEventRequest { + eventId: string; +} + +export interface SpeakerJoinEventResponse { + success: boolean; + message: string; + joinedAt?: string; + isFirstJoin: boolean; +} + +export interface UpdateMaterialsRequest { + materialIds: string[]; +} + +export interface UpdateMaterialsResponse { + success: boolean; + message: string; +} + +export interface SpeakerAttendanceResponse { + eventId: string; + totalSpeakersInvited: number; + totalSpeakersJoined: number; + speakers: Array<{ + speakerId: string; + speakerName: string; + speakerEmail: string; + joinedAt: string; + isAttended: boolean; + materialsSelected: string[]; + }>; +} + +export interface AvailableMaterialsResponse { + invitationId: string; + eventId: string; + speakerId: string; + availableMaterials: Array<{ + id: string; + fileName: string; + fileSize: number; + mimeType: string; + uploadDate: string; + }>; + selectedMaterials: string[]; +} + +export class AttendanceApiClient extends BaseApiClient { + protected readonly LOGGER_COMPONENT_NAME = 'AttendanceApiClient'; + private readonly baseUrl: string; + + constructor() { + const baseUrl = process.env.NEXT_PUBLIC_BOOKING_SERVICE_URL || 'http://localhost/api/booking'; + super(baseUrl); + this.baseUrl = baseUrl; + } + + // ==================== ATTENDEE ATTENDANCE ==================== + + /** + * Join an event as an attendee + */ + async joinEvent(eventId: string): Promise { + const response = await this.request('/attendance/join', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.getToken()}` + }, + body: JSON.stringify({ eventId }) + }); + + return response; + } + + /** + * Get live attendance data for an event (admin/speaker only) + */ + async getLiveAttendance(eventId: string): Promise { + const response = await this.request(`/attendance/live/${eventId}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.getToken()}` + } + }); + + return response; + } + + /** + * Get basic attendance metrics (all users) + */ + async getAttendanceMetrics(eventId: string): Promise { + const response = await this.request(`/attendance/metrics/${eventId}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.getToken()}` + } + }); + + return response; + } + + /** + * Get attendance summary for reporting (admin/speaker only) + */ + async getAttendanceSummary(eventId: string): Promise<{ + eventId: string; + totalRegistered: number; + totalAttended: number; + attendancePercentage: number; + joinTimes: Array<{ time: string; count: number }>; + }> { + const response = await this.request<{ eventId: string; totalRegistered: number; totalAttended: number; attendancePercentage: number; joinTimes: { time: string; count: number; }[]; }>(`/attendance/summary/${eventId}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.getToken()}` + } + }); + + return response; + } + + // ==================== SPEAKER ATTENDANCE ==================== + + /** + * Join an event as an admin (no booking required) + */ + async adminJoinEvent(eventId: string): Promise { + const response = await this.request('/attendance/admin/join', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.getToken()}` + }, + body: JSON.stringify({ eventId }) + }); + + return response; + } + + /** + * Join an event as a speaker + */ + async speakerJoinEvent(eventId: string): Promise { + const speakerServiceUrl = process.env.NEXT_PUBLIC_SPEAKER_SERVICE_URL || 'http://localhost/api/speaker-attendance'; + + // Use absolute URL to avoid baseURL concatenation + const response = await this.request(`${speakerServiceUrl}/join`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.getToken()}` + }, + body: JSON.stringify({ eventId }) + }); + + return response; + } + + /** + * Update materials selected for an event + */ + async updateMaterialsForEvent(invitationId: string, materialIds: string[]): Promise { + const speakerServiceUrl = process.env.NEXT_PUBLIC_SPEAKER_SERVICE_URL || 'http://localhost/api/speaker-attendance'; + + // Use absolute URL to avoid baseURL concatenation + const response = await this.request(`${speakerServiceUrl}/materials/${invitationId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.getToken()}` + }, + body: JSON.stringify({ materialIds }) + }); + + return response; + } + + /** + * Get speaker attendance data for an event (admin/speaker only) + */ + async getSpeakerAttendance(eventId: string): Promise { + const speakerServiceUrl = process.env.NEXT_PUBLIC_SPEAKER_SERVICE_URL || 'http://localhost/api/speaker-attendance'; + + // Use absolute URL to avoid baseURL concatenation + const response = await this.request(`${speakerServiceUrl}/${eventId}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.getToken()}` + } + }); + + return response; + } + + /** + * Get available materials for selection + */ + async getAvailableMaterials(invitationId: string): Promise { + const speakerServiceUrl = process.env.NEXT_PUBLIC_SPEAKER_SERVICE_URL || 'http://localhost/api/speaker-attendance'; + + // Use absolute URL to avoid baseURL concatenation + const response = await this.request(`${speakerServiceUrl}/materials/${invitationId}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.getToken()}` + } + }); + + return response; + } +} + +export const attendanceApiClient = new AttendanceApiClient(); diff --git a/ems-client/lib/api/base-api.client.ts b/ems-client/lib/api/base-api.client.ts index 4c0bb00..aa5a5d8 100644 --- a/ems-client/lib/api/base-api.client.ts +++ b/ems-client/lib/api/base-api.client.ts @@ -13,7 +13,11 @@ export abstract class BaseApiClient { endpoint: string, options: RequestInit = {} ): Promise { - const url = `${this.baseURL}${endpoint}`; + // If endpoint is already an absolute URL, use it directly + // Otherwise, prepend the base URL + const url = endpoint.startsWith('http://') || endpoint.startsWith('https://') + ? endpoint + : `${this.baseURL}${endpoint}`; const method = options.method || 'GET'; const defaultHeaders: Record = { @@ -77,27 +81,93 @@ export abstract class BaseApiClient { // Token management (for authenticated requests) public getToken(): string | null { if (typeof window === 'undefined') return null; - const token = localStorage.getItem('auth_token'); - logger.debug(this.LOGGER_COMPONENT_NAME, 'Token retrieved from storage', { hasToken: !!token }); + + // Try localStorage first + let token = localStorage.getItem('auth_token'); + + // If not found in localStorage, try sessionStorage (fallback) + if (!token) { + token = sessionStorage.getItem('auth_token'); + } + + logger.debug(this.LOGGER_COMPONENT_NAME, 'Token retrieved from storage', { + hasToken: !!token, + source: token ? (localStorage.getItem('auth_token') ? 'localStorage' : 'sessionStorage') : 'none' + }); return token; } // Public token management methods (can be overridden by subclasses) public setToken(token: string): void { if (typeof window === 'undefined') return; - localStorage.setItem('auth_token', token); - logger.info(this.LOGGER_COMPONENT_NAME, 'Token stored in localStorage'); + + try { + localStorage.setItem('auth_token', token); + logger.info(this.LOGGER_COMPONENT_NAME, 'Token stored in localStorage'); + } catch (error) { + // Handle localStorage quota exceeded + if (error instanceof DOMException && error.name === 'QuotaExceededError') { + logger.warn(this.LOGGER_COMPONENT_NAME, 'localStorage quota exceeded, attempting cleanup'); + + // Try to clear old data and retry + this.cleanupLocalStorage(); + + try { + localStorage.setItem('auth_token', token); + logger.info(this.LOGGER_COMPONENT_NAME, 'Token stored after cleanup'); + } catch (retryError) { + logger.error(this.LOGGER_COMPONENT_NAME, 'Failed to store token even after cleanup', retryError as Error); + // Fallback: use sessionStorage + sessionStorage.setItem('auth_token', token); + logger.info(this.LOGGER_COMPONENT_NAME, 'Token stored in sessionStorage as fallback'); + } + } else { + logger.error(this.LOGGER_COMPONENT_NAME, 'Failed to store token', error as Error); + throw error; + } + } } public removeToken(): void { if (typeof window === 'undefined') return; localStorage.removeItem('auth_token'); - logger.info(this.LOGGER_COMPONENT_NAME, 'Token removed from localStorage'); + sessionStorage.removeItem('auth_token'); // Also remove from sessionStorage fallback + logger.info(this.LOGGER_COMPONENT_NAME, 'Token removed from storage'); } public isAuthenticated(): boolean { - const hasToken = !!this.getToken(); + const hasToken = !!(this.getToken()); logger.debug(this.LOGGER_COMPONENT_NAME, 'Authentication check', { isAuthenticated: hasToken }); return hasToken; } + + // Helper method to cleanup localStorage + private cleanupLocalStorage(): void { + try { + // Remove old/expired tokens and other unnecessary data + const keysToRemove: string[] = []; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && ( + key.startsWith('auth_token_') || // Old token versions + key.startsWith('temp_') || // Temporary data + key.startsWith('cache_') || // Cache data + key.includes('_backup') // Backup data + )) { + keysToRemove.push(key); + } + } + + // Remove identified keys + keysToRemove.forEach(key => { + localStorage.removeItem(key); + logger.debug(this.LOGGER_COMPONENT_NAME, `Removed old data: ${key}`); + }); + + logger.info(this.LOGGER_COMPONENT_NAME, `Cleaned up ${keysToRemove.length} old items from localStorage`); + } catch (error) { + logger.error(this.LOGGER_COMPONENT_NAME, 'Failed to cleanup localStorage', error as Error); + } + } } diff --git a/ems-client/lib/api/event.api.ts b/ems-client/lib/api/event.api.ts index 0a867cd..8b23c99 100644 --- a/ems-client/lib/api/event.api.ts +++ b/ems-client/lib/api/event.api.ts @@ -141,7 +141,7 @@ class EventApiClient extends BaseApiClient { } async getEventById(eventId: string): Promise<{ success: boolean; data: EventResponse }> { - return this.request<{ success: boolean; data: EventResponse }>(`/admin/admin/events/${eventId}`); + return this.request<{ success: boolean; data: EventResponse }>(`/events/${eventId}`); } async approveEvent(eventId: string): Promise<{ success: boolean; data: EventResponse }> { diff --git a/ems-client/lib/logger.ts b/ems-client/lib/logger.ts index 72e80c5..bbd5610 100644 --- a/ems-client/lib/logger.ts +++ b/ems-client/lib/logger.ts @@ -53,20 +53,82 @@ class Logger { this.logBuffer.shift(); } - // Client-side (browser) - store in localStorage + // Client-side (browser) - store in localStorage with size limits if (typeof window !== 'undefined') { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const logKey = `ems-logs-${timestamp.split('T')[0]}`; + const maxLogSize = 2 * 1024 * 1024; // 2MB max per day + const maxLogEntries = 500; // Max log entries per day // Get existing logs for today - const existingLogs = localStorage.getItem(logKey) || ''; + let existingLogs = localStorage.getItem(logKey) || ''; + + // Clean up old logs if needed (keep last 7 days max) + this.cleanupOldLogs(7); + + // Check if we need to truncate existing logs + if (existingLogs.length > maxLogSize) { + // Keep only the most recent entries + const logLines = existingLogs.split('\n').filter(line => line.trim()); + const recentLogs = logLines.slice(-maxLogEntries).join('\n'); + existingLogs = recentLogs; + } + const updatedLogs = existingLogs + logEntry + '\n'; - // Store in localStorage (limited to ~5-10MB per domain) - localStorage.setItem(logKey, updatedLogs); + // Truncate if exceeds max size + if (updatedLogs.length > maxLogSize) { + const logLines = updatedLogs.split('\n').filter(line => line.trim()); + const recentLogs = logLines.slice(-maxLogEntries).join('\n'); + localStorage.setItem(logKey, recentLogs); + } else { + localStorage.setItem(logKey, updatedLogs); + } } } catch (error) { - console.error('Failed to write log to storage:', error); + // If quota exceeded, try to clean up and retry once + if (error instanceof DOMException && error.name === 'QuotaExceededError') { + try { + this.cleanupOldLogs(1); // Keep only today's logs + // Retry with truncated logs + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const logKey = `ems-logs-${timestamp.split('T')[0]}`; + const existingLogs = localStorage.getItem(logKey) || ''; + const logLines = existingLogs.split('\n').filter(line => line.trim()); + const recentLogs = logLines.slice(-200).join('\n') + '\n' + logEntry + '\n'; + localStorage.setItem(logKey, recentLogs); + } catch (retryError) { + // If still fails, just log to console and skip localStorage + console.warn('localStorage quota exceeded, skipping log storage'); + } + } else { + console.error('Failed to write log to storage:', error); + } + } + } + + private cleanupOldLogs(daysToKeep: number = 7): void { + if (typeof window === 'undefined') return; + + try { + const today = new Date(); + const keys = Object.keys(localStorage); + + keys.forEach(key => { + if (key.startsWith('ems-logs-')) { + const dateMatch = key.match(/ems-logs-(\d{4}-\d{2}-\d{2})/); + if (dateMatch) { + const logDate = new Date(dateMatch[1]); + const daysDiff = Math.floor((today.getTime() - logDate.getTime()) / (1000 * 60 * 60 * 24)); + + if (daysDiff > daysToKeep) { + localStorage.removeItem(key); + } + } + } + }); + } catch (error) { + console.warn('Failed to cleanup old logs:', error); } } @@ -158,6 +220,11 @@ class Logger { }); } } + + // Method to cleanup old logs (public API) + public cleanup(): void { + this.cleanupOldLogs(7); + } } // Create and export a singleton instance diff --git a/ems-client/lib/utils/timezone.ts b/ems-client/lib/utils/timezone.ts new file mode 100644 index 0000000..81ccccb --- /dev/null +++ b/ems-client/lib/utils/timezone.ts @@ -0,0 +1,61 @@ +/** + * Simple timezone handling for CDT display + * Backend uses UTC, frontend displays in CDT + */ + +export const USER_TIMEZONE = 'America/Chicago'; // CDT for Texas + +/** + * Format time in CDT for display + */ +export function formatLocalTime(date: Date | string): string { + const dateObj = typeof date === 'string' ? new Date(date) : date; + return dateObj.toLocaleTimeString('en-US', { + timeZone: USER_TIMEZONE, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); +} + +/** + * Get current time (JavaScript handles timezone automatically) + */ +export function getCurrentLocalTime(): Date { + return new Date(); +} + +/** + * Format time difference as human readable string + */ +export function formatTimeDifference(date1: Date | string, date2: Date | string): string { + const d1 = typeof date1 === 'string' ? new Date(date1) : date1; + const d2 = typeof date2 === 'string' ? new Date(date2) : date2; + const minutes = Math.floor((d2.getTime() - d1.getTime()) / (1000 * 60)); + + if (minutes < 0) { + const absMinutes = Math.abs(minutes); + if (absMinutes < 60) { + return `${absMinutes} minutes ago`; + } else if (absMinutes < 1440) { // 24 hours + const hours = Math.floor(absMinutes / 60); + return `${hours} hour${hours > 1 ? 's' : ''} ago`; + } else { + const days = Math.floor(absMinutes / 1440); + return `${days} day${days > 1 ? 's' : ''} ago`; + } + } else if (minutes === 0) { + return 'now'; + } else { + if (minutes < 60) { + return `in ${minutes} minutes`; + } else if (minutes < 1440) { // 24 hours + const hours = Math.floor(minutes / 60); + return `in ${hours} hour${hours > 1 ? 's' : ''}`; + } else { + const days = Math.floor(minutes / 1440); + return `in ${days} day${days > 1 ? 's' : ''}`; + } + } +} diff --git a/ems-gateway/nginx.conf b/ems-gateway/nginx.conf index 41189a7..2f9d339 100644 --- a/ems-gateway/nginx.conf +++ b/ems-gateway/nginx.conf @@ -63,6 +63,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; } # event-service @@ -73,6 +74,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; } # # booking-service @@ -83,6 +85,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; } # # # ticketing-service @@ -103,6 +106,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; } location /api/invitations/ { @@ -112,6 +116,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; } location /api/messages/ { @@ -121,6 +126,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; } location /api/materials/ { @@ -130,6 +136,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; } # speaker-service - legacy /api/speaker/ route @@ -140,6 +147,18 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; + } + + # speaker-attendance routes + location /api/speaker-attendance/ { + rewrite ^/api/speaker-attendance/?(.*)$ /api/speaker-attendance/$1 break; + proxy_pass http://speaker-service; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; } # # feedback-service @@ -150,6 +169,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; } # notification-service @@ -160,6 +180,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; } # # reporting-analytics-service diff --git a/ems-services/booking-service/prisma/migrations/20251028190000_add_attendance_tracking_fields/migration.sql b/ems-services/booking-service/prisma/migrations/20251028190000_add_attendance_tracking_fields/migration.sql new file mode 100644 index 0000000..33b2666 --- /dev/null +++ b/ems-services/booking-service/prisma/migrations/20251028190000_add_attendance_tracking_fields/migration.sql @@ -0,0 +1,7 @@ +-- Add attendance tracking fields to bookings table +ALTER TABLE "bookings" ADD COLUMN "joinedAt" TIMESTAMP(3); +ALTER TABLE "bookings" ADD COLUMN "isAttended" BOOLEAN NOT NULL DEFAULT false; + +-- Add index for attendance queries +CREATE INDEX "bookings_joinedAt_idx" ON "bookings"("joinedAt"); +CREATE INDEX "bookings_isAttended_idx" ON "bookings"("isAttended"); diff --git a/ems-services/booking-service/prisma/schema.prisma b/ems-services/booking-service/prisma/schema.prisma index 51bf524..ea369c9 100644 --- a/ems-services/booking-service/prisma/schema.prisma +++ b/ems-services/booking-service/prisma/schema.prisma @@ -55,6 +55,10 @@ model Booking { // Ticket relationship ticket Ticket? + // Attendance tracking fields + joinedAt DateTime? // When user joined the event (null if not joined) + isAttended Boolean @default(false) // Whether user has attended the event + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/ems-services/booking-service/src/routes/attendance.routes.ts b/ems-services/booking-service/src/routes/attendance.routes.ts new file mode 100644 index 0000000..63b9341 --- /dev/null +++ b/ems-services/booking-service/src/routes/attendance.routes.ts @@ -0,0 +1,153 @@ +import { Router, Response } from 'express'; +import { attendanceService } from '../services/attendance.service'; +import { asyncHandler } from '../middleware/error.middleware'; +import { authenticateToken } from '../middleware/auth.middleware'; +import { AuthRequest } from '../types'; + +const router = Router(); + +// ==================== ATTENDANCE ROUTES ==================== + +/** + * Join an event + * POST /attendance/join + */ +router.post('/attendance/join', authenticateToken, asyncHandler(async (req: AuthRequest, res: Response) => { + const { eventId } = req.body; + const userId = req.user?.userId; + + if (!userId) { + return res.status(401).json({ error: 'User not authenticated' }); + } + + if (!eventId) { + return res.status(400).json({ error: 'Event ID is required' }); + } + + try { + const result = await attendanceService.joinEvent({ + userId, + eventId + }); + + if (result.success) { + res.status(200).json(result); + } else { + res.status(400).json(result); + } + } catch (error) { + console.error('Error joining event:', error); + res.status(500).json({ error: 'Internal server error' }); + } +})); + +/** + * Join an event as admin (no booking required) + * POST /attendance/admin/join + */ +router.post('/attendance/admin/join', authenticateToken, asyncHandler(async (req: AuthRequest, res: Response) => { + const { eventId } = req.body; + const userId = req.user?.userId; + const userRole = req.user?.role; + + if (!userId) { + return res.status(401).json({ error: 'User not authenticated' }); + } + + if (userRole !== 'ADMIN') { + return res.status(403).json({ error: 'Only admins can use this endpoint' }); + } + + if (!eventId) { + return res.status(400).json({ error: 'Event ID is required' }); + } + + try { + const result = await attendanceService.adminJoinEvent({ + userId, + eventId + }); + + if (result.success) { + res.status(200).json(result); + } else { + res.status(400).json(result); + } + } catch (error) { + console.error('Error admin joining event:', error); + res.status(500).json({ + success: false, + message: 'Internal server error', + isFirstJoin: false + }); + } +})); + +/** + * Get live attendance data for an event + * GET /attendance/live/:eventId + */ +router.get('/attendance/live/:eventId', authenticateToken, asyncHandler(async (req: AuthRequest, res: Response) => { + const { eventId } = req.params; + const userRole = req.user?.role; + + // Only admins and speakers can view live attendance + if (!['ADMIN', 'SPEAKER'].includes(userRole || '')) { + return res.status(403).json({ error: 'Access denied. Admin or speaker role required.' }); + } + + try { + const attendanceData = await attendanceService.getLiveAttendance(eventId); + res.status(200).json(attendanceData); + } catch (error) { + console.error('Error fetching live attendance:', error); + res.status(500).json({ error: 'Internal server error' }); + } +})); + +/** + * Get attendance summary for reporting + * GET /attendance/summary/:eventId + */ +router.get('/attendance/summary/:eventId', authenticateToken, asyncHandler(async (req: AuthRequest, res: Response) => { + const { eventId } = req.params; + const userRole = req.user?.role; + + // Only admins and speakers can view attendance summary + if (!['ADMIN', 'SPEAKER'].includes(userRole || '')) { + return res.status(403).json({ error: 'Access denied. Admin or speaker role required.' }); + } + + try { + const summary = await attendanceService.getAttendanceSummary(eventId); + res.status(200).json(summary); + } catch (error) { + console.error('Error fetching attendance summary:', error); + res.status(500).json({ error: 'Internal server error' }); + } +})); + +/** + * Get basic attendance metrics for attendees + * GET /attendance/metrics/:eventId + */ +router.get('/attendance/metrics/:eventId', authenticateToken, asyncHandler(async (req: AuthRequest, res: Response) => { + const { eventId } = req.params; + + try { + const attendanceData = await attendanceService.getLiveAttendance(eventId); + + // Return only basic metrics for attendees + res.status(200).json({ + eventId: attendanceData.eventId, + totalAttended: attendanceData.totalAttended, + totalRegistered: attendanceData.totalRegistered, + attendancePercentage: attendanceData.attendancePercentage + }); + } catch (error) { + console.error('Error fetching attendance metrics:', error); + res.status(500).json({ error: 'Internal server error' }); + } +})); + +export default router; diff --git a/ems-services/booking-service/src/routes/index.ts b/ems-services/booking-service/src/routes/index.ts index 230ebe1..6ff8494 100644 --- a/ems-services/booking-service/src/routes/index.ts +++ b/ems-services/booking-service/src/routes/index.ts @@ -3,9 +3,13 @@ import bookingRoutes from './booking.routes'; import { ticketRoutes } from './ticket.routes'; import speakerRoutes from './speaker.routes'; import adminRoutes from './admin.routes'; +import attendanceRoutes from './attendance.routes'; const router = Router(); +// Attendance routes (All authenticated users) - MUST be first to avoid conflicts with booking routes +router.use('/', attendanceRoutes); + // Booking routes (USER/ADMIN role required) router.use('/', bookingRoutes); diff --git a/ems-services/booking-service/src/services/attendance.service.ts b/ems-services/booking-service/src/services/attendance.service.ts new file mode 100644 index 0000000..7b40dec --- /dev/null +++ b/ems-services/booking-service/src/services/attendance.service.ts @@ -0,0 +1,405 @@ +import { prisma } from '../database'; +import { logger } from '../utils/logger'; +import axios from 'axios'; +import { EventDetails } from '../types/ticket.types'; +import { getUserInfo } from '../utils/auth-helpers'; + +export interface JoinEventRequest { + userId: string; + eventId: string; +} + +export interface JoinEventResponse { + success: boolean; + message: string; + joinedAt?: string; + isFirstJoin: boolean; +} + +export interface LiveAttendanceResponse { + eventId: string; + totalRegistered: number; + totalAttended: number; + attendancePercentage: number; + attendees: Array<{ + userId: string; + userName: string; + userEmail: string; + joinedAt: string; + isAttended: boolean; + }>; + speaker?: { + speakerId: string; + speakerName: string; + speakerEmail: string; + joinedAt: string; + isAttended: boolean; + }; +} + +export class AttendanceService { + private readonly eventServiceUrl: string; + + constructor() { + this.eventServiceUrl = process.env.GATEWAY_URL ? + `${process.env.GATEWAY_URL}/api/event` : 'http://ems-gateway/api/event'; + } + + /** + * Get event details from event service + */ + private async getEventDetails(eventId: string): Promise { + try { + const response = await axios.get(`${this.eventServiceUrl}/events/${eventId}`, { + timeout: 5000, + headers: { + 'Content-Type': 'application/json' + } + }); + + if (response.status === 200 && response.data.success) { + return response.data.data; + } + return null; + } catch (error) { + logger.warn('Failed to get event details from event service', { eventId, error: (error as Error).message }); + return null; + } + } + + /** + * Join an event - marks user as attended + */ + async joinEvent(data: JoinEventRequest): Promise { + try { + logger.info('Processing event join request', { + userId: data.userId, + eventId: data.eventId + }); + + // Find the booking for this user and event + const booking = await prisma.booking.findFirst({ + where: { + userId: data.userId, + eventId: data.eventId, + status: 'CONFIRMED' + }, + include: { + event: true + } + }); + + if (!booking) { + return { + success: false, + message: 'No valid booking found for this event', + isFirstJoin: false + }; + } + + // Check if event has started by fetching event details from event service + const now = new Date(); + let eventStartTime: Date; + + try { + const eventDetails = await this.getEventDetails(data.eventId); + if (!eventDetails || !eventDetails.bookingStartDate) { + return { + success: false, + message: 'Event details not available', + isFirstJoin: false + }; + } + eventStartTime = new Date(eventDetails.bookingStartDate); + + if (now < eventStartTime) { + return { + success: false, + message: 'Event has not started yet', + isFirstJoin: false + }; + } + } catch (error) { + logger.error('Error fetching event details', error as Error, { eventId: data.eventId }); + return { + success: false, + message: 'Unable to verify event timing', + isFirstJoin: false + }; + } + + // Check if this is the first time joining + const isFirstJoin = !booking.isAttended; + + // Update booking with join information + const updatedBooking = await prisma.booking.update({ + where: { id: booking.id }, + data: { + joinedAt: now, + isAttended: true + } + }); + + logger.info('User successfully joined event', { + bookingId: booking.id, + userId: data.userId, + eventId: data.eventId, + isFirstJoin + }); + + return { + success: true, + message: isFirstJoin ? 'Successfully joined the event!' : 'Rejoined the event', + joinedAt: updatedBooking.joinedAt?.toISOString(), + isFirstJoin + }; + + } catch (error) { + logger.error('Error joining event', error as Error, { + userId: data.userId, + eventId: data.eventId + }); + throw error; + } + } + + /** + * Join an event as admin (no booking required) + */ + async adminJoinEvent(data: JoinEventRequest): Promise { + try { + logger.info('Processing admin event join request', { + userId: data.userId, + eventId: data.eventId + }); + + // Check if event has started by fetching event details from event service + const now = new Date(); + let eventStartTime: Date; + + try { + const eventDetails = await this.getEventDetails(data.eventId); + if (!eventDetails || !eventDetails.bookingStartDate) { + return { + success: false, + message: 'Event details not available', + isFirstJoin: false + }; + } + eventStartTime = new Date(eventDetails.bookingStartDate); + + if (now < eventStartTime) { + return { + success: false, + message: 'Event has not started yet', + isFirstJoin: false + }; + } + } catch (error) { + logger.error('Failed to fetch event details', error as Error); + return { + success: false, + message: 'Unable to verify event timing', + isFirstJoin: false + }; + } + + // For admins, we don't need a booking - just track their attendance + // Check if admin has already joined this event + const existingAttendance = await prisma.booking.findFirst({ + where: { + userId: data.userId, + eventId: data.eventId, + isAttended: true + } + }); + + const isFirstJoin = !existingAttendance; + + if (isFirstJoin) { + // Create a special admin attendance record + await prisma.booking.create({ + data: { + userId: data.userId, + eventId: data.eventId, + status: 'CONFIRMED', // Admin status + joinedAt: now, + isAttended: true + } + }); + + logger.info('Admin successfully joined event', { + userId: data.userId, + eventId: data.eventId, + isFirstJoin: true + }); + + return { + success: true, + message: 'Successfully joined event as admin!', + joinedAt: now.toISOString(), + isFirstJoin: true + }; + } else { + // Admin has already joined + logger.info('Admin already joined event', { + userId: data.userId, + eventId: data.eventId, + isFirstJoin: false + }); + + return { + success: true, + message: 'Already joined this event as admin', + joinedAt: existingAttendance.joinedAt?.toISOString(), + isFirstJoin: false + }; + } + } catch (error) { + logger.error('Error in admin join event', error as Error); + return { + success: false, + message: 'Failed to join event', + isFirstJoin: false + }; + } + } + + /** + * Get live attendance data for an event + */ + async getLiveAttendance(eventId: string): Promise { + try { + logger.info('Fetching live attendance data', { eventId }); + + // Get all bookings for this event + const bookings = await prisma.booking.findMany({ + where: { + eventId: eventId, + status: 'CONFIRMED' + }, + include: { + event: true + } + }); + + // Get user info for all bookings to filter out admins + const bookingsWithUserInfo = await Promise.all( + bookings.map(async (booking) => { + const userInfo = await getUserInfo(booking.userId); + return { + booking, + userInfo + }; + }) + ); + + // Filter out admin users from attendance counts + const nonAdminBookings = bookingsWithUserInfo.filter( + ({ userInfo }) => userInfo && userInfo.role !== 'ADMIN' + ); + + const totalRegistered = nonAdminBookings.length; + const totalAttended = nonAdminBookings.filter(({ booking }) => booking.isAttended).length; + const attendancePercentage = totalRegistered > 0 ? Math.round((totalAttended / totalRegistered) * 100) : 0; + + // Get all attendee details (both attended and not attended) - exclude admins + const attendees = nonAdminBookings.map(({ booking, userInfo }) => ({ + userId: booking.userId, + userName: userInfo?.name || 'User', + userEmail: userInfo?.email || 'user@example.com', + joinedAt: booking.joinedAt?.toISOString() || '', + isAttended: booking.isAttended + })); + + logger.info('Live attendance data retrieved', { + eventId, + totalRegistered, + totalAttended, + attendancePercentage + }); + + return { + eventId, + totalRegistered, + totalAttended, + attendancePercentage, + attendees + }; + + } catch (error) { + logger.error('Error fetching live attendance', error as Error, { eventId }); + throw error; + } + } + + /** + * Get attendance summary for reporting + */ + async getAttendanceSummary(eventId: string): Promise<{ + eventId: string; + totalRegistered: number; + totalAttended: number; + attendancePercentage: number; + joinTimes: Array<{ time: string; count: number }>; + }> { + try { + const bookings = await prisma.booking.findMany({ + where: { + eventId: eventId, + status: 'CONFIRMED' + } + }); + + // Get user info for all bookings to filter out admins + const bookingsWithUserInfo = await Promise.all( + bookings.map(async (booking) => { + const userInfo = await getUserInfo(booking.userId); + return { + booking, + userInfo + }; + }) + ); + + // Filter out admin users from attendance counts + const nonAdminBookings = bookingsWithUserInfo.filter( + ({ userInfo }) => userInfo && userInfo.role !== 'ADMIN' + ); + + const totalRegistered = nonAdminBookings.length; + const totalAttended = nonAdminBookings.filter(({ booking }) => booking.isAttended).length; + const attendancePercentage = totalRegistered > 0 ? Math.round((totalAttended / totalRegistered) * 100) : 0; + + // Group join times by hour for reporting (excluding admins) + const joinTimes = nonAdminBookings + .map(({ booking }) => booking) + .filter(booking => booking.joinedAt) + .reduce((acc, booking) => { + const hour = new Date(booking.joinedAt!).getHours(); + const key = `${hour}:00`; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {} as Record); + + const joinTimesArray = Object.entries(joinTimes).map(([time, count]) => ({ + time, + count + })); + + return { + eventId, + totalRegistered, + totalAttended, + attendancePercentage, + joinTimes: joinTimesArray + }; + + } catch (error) { + logger.error('Error fetching attendance summary', error as Error, { eventId }); + throw error; + } + } +} + +export const attendanceService = new AttendanceService(); diff --git a/ems-services/booking-service/src/services/booking.service.ts b/ems-services/booking-service/src/services/booking.service.ts index 1e95b74..739c7e5 100644 --- a/ems-services/booking-service/src/services/booking.service.ts +++ b/ems-services/booking-service/src/services/booking.service.ts @@ -1,5 +1,6 @@ import { prisma } from '../database'; import { logger } from '../utils/logger'; +import { getUserInfo } from '../utils/auth-helpers'; import { CreateBookingRequest, BookingResponse, @@ -360,14 +361,33 @@ class BookingService { throw new Error('Event not found'); } - // Count confirmed bookings (active users) - const confirmedBookings = await prisma.booking.count({ + // Get all confirmed bookings + const confirmedBookingsList = await prisma.booking.findMany({ where: { eventId: eventId, status: BookingStatus.CONFIRMED } }); + // Get user info for all bookings to filter out admins + const bookingsWithUserInfo = await Promise.all( + confirmedBookingsList.map(async (booking) => { + const userInfo = await getUserInfo(booking.userId); + return { + booking, + userInfo + }; + }) + ); + + // Filter out admin users from attendance counts + const nonAdminBookings = bookingsWithUserInfo.filter( + ({ userInfo }) => userInfo && userInfo.role !== 'ADMIN' + ); + + // Count confirmed bookings (active users, excluding admins) + const confirmedBookings = nonAdminBookings.length; + // Count cancelled bookings const cancelledBookings = await prisma.booking.count({ where: { diff --git a/ems-services/booking-service/src/utils/auth-helpers.ts b/ems-services/booking-service/src/utils/auth-helpers.ts new file mode 100644 index 0000000..94b3c50 --- /dev/null +++ b/ems-services/booking-service/src/utils/auth-helpers.ts @@ -0,0 +1,33 @@ +import axios from 'axios'; +import { logger } from './logger'; + +const authServiceUrl = process.env.GATEWAY_URL ? + `${process.env.GATEWAY_URL}/api/auth` : 'http://ems-gateway/api/auth'; + +/** + * Get user information from auth service + */ +export async function getUserInfo(userId: string): Promise<{ name: string | null; email: string; role: string } | null> { + try { + const response = await axios.get(`${authServiceUrl}/internal/users/${userId}`, { + timeout: 5000, + headers: { + 'Content-Type': 'application/json', + 'x-internal-service': 'booking-service' + } + }); + + if (response.status === 200 && response.data.valid && response.data.user) { + return { + name: response.data.user.name || null, + email: response.data.user.email, + role: response.data.user.role + }; + } + return null; + } catch (error) { + logger.warn('Failed to fetch user info from auth service', { userId, error: (error as Error).message }); + return null; + } +} + diff --git a/ems-services/speaker-service/Dockerfile b/ems-services/speaker-service/Dockerfile index 4a3ebc1..2450c97 100644 --- a/ems-services/speaker-service/Dockerfile +++ b/ems-services/speaker-service/Dockerfile @@ -1,32 +1,26 @@ # Stage 1: Build -FROM alpine:latest AS builder -RUN apk update && apk add nodejs npm && rm -rf /var/cache/apk/* +FROM node:20-bullseye-slim AS builder +RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY prisma ./prisma -ARG DATABASE_URL -ENV DATABASE_URL=$DATABASE_URL RUN npx prisma generate COPY . . RUN npm run build # Stage 2: Runner -FROM alpine:latest AS runner -RUN apk update && apk add nodejs npm openssl && rm -rf /var/cache/apk/* +FROM node:20-bullseye-slim AS runner +RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* WORKDIR /app ENV NODE_ENV=production ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -# Database URL will be provided at runtime via docker-compose -ARG DATABASE_URL -ENV DATABASE_URL=$DATABASE_URL - # Install only prod deps in final image COPY package.json package-lock.json ./ RUN npm ci --omit=dev --omit=optional --ignore-scripts && npm cache clean --force diff --git a/ems-services/speaker-service/prisma/migrations/20250101000000_initial_schema/migration.sql b/ems-services/speaker-service/prisma/migrations/20250101000000_initial_schema/migration.sql new file mode 100644 index 0000000..1b3611c --- /dev/null +++ b/ems-services/speaker-service/prisma/migrations/20250101000000_initial_schema/migration.sql @@ -0,0 +1,73 @@ +-- CreateEnum +CREATE TYPE "InvitationStatus" AS ENUM ('PENDING', 'ACCEPTED', 'DECLINED', 'EXPIRED'); + +-- CreateTable +CREATE TABLE "speaker_profiles" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "bio" TEXT, + "expertise" TEXT[], + "isAvailable" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "speaker_profiles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "speaker_invitations" ( + "id" TEXT NOT NULL, + "speakerId" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "message" TEXT, + "status" "InvitationStatus" NOT NULL DEFAULT 'PENDING', + "sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "respondedAt" TIMESTAMP(3), + "joinedAt" TIMESTAMP(3), + "materialsSelected" TEXT[] DEFAULT ARRAY[]::TEXT[], + "isAttended" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "speaker_invitations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "messages" ( + "id" TEXT NOT NULL, + "fromUserId" TEXT NOT NULL, + "toUserId" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "content" TEXT NOT NULL, + "threadId" TEXT, + "sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "readAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "messages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "presentation_materials" ( + "id" TEXT NOT NULL, + "speakerId" TEXT NOT NULL, + "eventId" TEXT, + "fileName" TEXT NOT NULL, + "filePath" TEXT NOT NULL, + "fileSize" INTEGER NOT NULL, + "mimeType" TEXT NOT NULL, + "uploadDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "presentation_materials_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "speaker_profiles_userId_key" ON "speaker_profiles"("userId"); + +-- AddForeignKey +ALTER TABLE "speaker_invitations" ADD CONSTRAINT "speaker_invitations_speakerId_fkey" FOREIGN KEY ("speakerId") REFERENCES "speaker_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "presentation_materials" ADD CONSTRAINT "presentation_materials_speakerId_fkey" FOREIGN KEY ("speakerId") REFERENCES "speaker_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/ems-services/speaker-service/prisma/migrations/migration_lock.toml b/ems-services/speaker-service/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..99e4f20 --- /dev/null +++ b/ems-services/speaker-service/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" diff --git a/ems-services/speaker-service/prisma/schema.prisma b/ems-services/speaker-service/prisma/schema.prisma index 9e49db2..156d183 100644 --- a/ems-services/speaker-service/prisma/schema.prisma +++ b/ems-services/speaker-service/prisma/schema.prisma @@ -4,7 +4,8 @@ generator client { provider = "prisma-client-js" output = "../generated/prisma" - binaryTargets = ["native", "linux-arm64-openssl-3.0.x"] + // Debian (glibc) compatible engines + binaryTargets = ["native", "debian-openssl-3.0.x"] } datasource db { @@ -46,8 +47,14 @@ model SpeakerInvitation { status InvitationStatus @default(PENDING) sentAt DateTime @default(now()) respondedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + + // Speaker joining and material selection + joinedAt DateTime? // When speaker joined the event (null if not joined) + materialsSelected String[] @default([]) // Array of material IDs selected for this event + isAttended Boolean @default(false) // Whether speaker has attended the event + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt speaker SpeakerProfile @relation(fields: [speakerId], references: [id]) diff --git a/ems-services/speaker-service/src/middleware/auth.middleware.ts b/ems-services/speaker-service/src/middleware/auth.middleware.ts index 1538523..269279b 100644 --- a/ems-services/speaker-service/src/middleware/auth.middleware.ts +++ b/ems-services/speaker-service/src/middleware/auth.middleware.ts @@ -34,7 +34,7 @@ export const authMiddleware = (req: AuthRequest, res: Response, next: NextFuncti const decoded = jwt.verify(token, JWT_SECRET) as any; req.user = { - id: decoded.id, + id: decoded.userId, // Auth-service uses 'userId' in token payload email: decoded.email, role: decoded.role }; diff --git a/ems-services/speaker-service/src/routes/speaker-attendance.routes.ts b/ems-services/speaker-service/src/routes/speaker-attendance.routes.ts new file mode 100644 index 0000000..965b4fa --- /dev/null +++ b/ems-services/speaker-service/src/routes/speaker-attendance.routes.ts @@ -0,0 +1,154 @@ +import { Router, Response, Request } from 'express'; +import { speakerAttendanceService } from '../services/speaker-attendance.service'; +import { SpeakerService } from '../services/speaker.service'; +import { asyncHandler } from '../middleware/error.middleware'; +import { authMiddleware } from '../middleware/auth.middleware'; + +const speakerService = new SpeakerService(); + +interface AuthRequest extends Request { + user?: { + id: string; + email: string; + role: string; + }; +} + +const router = Router(); + +// ==================== SPEAKER ATTENDANCE ROUTES ==================== + +/** + * Speaker joins an event + * POST /join (mounted at /api/speaker-attendance, so full path is /api/speaker-attendance/join) + */ +router.post('/join', authMiddleware, asyncHandler(async (req: AuthRequest, res: Response) => { + const { eventId } = req.body; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ error: 'Speaker not authenticated' }); + } + + if (!eventId) { + return res.status(400).json({ error: 'Event ID is required' }); + } + + try { + // Get speaker profile ID from userId + // The speakerInvitation table uses speaker profile ID, not userId + const speakerProfile = await speakerService.getSpeakerByUserId(userId); + if (!speakerProfile) { + return res.status(404).json({ error: 'Speaker profile not found' }); + } + + const result = await speakerAttendanceService.speakerJoinEvent({ + speakerId: speakerProfile.id, // Use speaker profile ID, not userId + eventId + }); + + if (result.success) { + return res.status(200).json(result); + } else { + return res.status(400).json(result); + } + } catch (error) { + console.error('Error speaker joining event:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +})); + +/** + * Update materials selected for an event + * PUT /materials/:invitationId (mounted at /api/speaker-attendance, so full path is /api/speaker-attendance/materials/:invitationId) + */ +router.put('/materials/:invitationId', authMiddleware, asyncHandler(async (req: AuthRequest, res: Response) => { + const { invitationId } = req.params; + const { materialIds } = req.body; + const speakerId = req.user?.id; + + if (!speakerId) { + return res.status(401).json({ error: 'Speaker not authenticated' }); + } + + if (!invitationId) { + return res.status(400).json({ error: 'Invitation ID is required' }); + } + + if (!Array.isArray(materialIds)) { + return res.status(400).json({ error: 'Material IDs must be an array' }); + } + + try { + const result = await speakerAttendanceService.updateMaterialsForEvent({ + invitationId, + materialIds + }); + + if (result.success) { + return res.status(200).json(result); + } else { + return res.status(400).json(result); + } + } catch (error) { + console.error('Error updating materials:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +})); + +/** + * Get available materials for selection + * GET /materials/:invitationId (mounted at /api/speaker-attendance, so full path is /api/speaker-attendance/materials/:invitationId) + * MUST be defined before /:eventId to avoid route conflicts + */ +router.get('/materials/:invitationId', authMiddleware, asyncHandler(async (req: AuthRequest, res: Response) => { + const { invitationId } = req.params; + const speakerId = req.user?.id; + + if (!speakerId) { + return res.status(401).json({ error: 'Speaker not authenticated' }); + } + + if (!invitationId) { + return res.status(400).json({ error: 'Invitation ID is required' }); + } + + try { + const materialsData = await speakerAttendanceService.getAvailableMaterials(invitationId); + + // Verify that the invitation belongs to the authenticated speaker + if (materialsData.speakerId !== speakerId) { + return res.status(403).json({ error: 'Access denied. Invitation does not belong to this speaker.' }); + } + + return res.status(200).json(materialsData); + } catch (error) { + console.error('Error fetching available materials:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +})); + +/** + * Get speaker attendance data for an event + * GET /:eventId (mounted at /api/speaker-attendance, so full path is /api/speaker-attendance/:eventId) + * MUST be defined after /materials/:invitationId to avoid route conflicts + */ +router.get('/:eventId', authMiddleware, asyncHandler(async (req: AuthRequest, res: Response) => { + const { eventId } = req.params; + + if (!eventId) { + return res.status(400).json({ error: 'Event ID is required' }); + } + + // Allow all authenticated users (ADMIN, SPEAKER, USER) to view basic speaker attendance info + // This allows attendees to see if speakers have joined and their selected materials + try { + const attendanceData = await speakerAttendanceService.getSpeakerAttendance(eventId); + return res.status(200).json(attendanceData); + } catch (error) { + console.error('Error fetching speaker attendance:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +})); + +export default router; diff --git a/ems-services/speaker-service/src/server.ts b/ems-services/speaker-service/src/server.ts index 5e0a56c..4341d53 100644 --- a/ems-services/speaker-service/src/server.ts +++ b/ems-services/speaker-service/src/server.ts @@ -6,6 +6,7 @@ import speakerRoutes from './routes/speaker.routes'; import invitationRoutes from './routes/invitation.routes'; import messageRoutes from './routes/message.routes'; import materialRoutes from './routes/material.routes'; +import speakerAttendanceRoutes from './routes/speaker-attendance.routes'; import { errorMiddleware, notFoundHandler } from './middleware/error.middleware'; import { RabbitMQService } from './services/rabbitmq.service'; import { SpeakerService } from './services/speaker.service'; @@ -34,6 +35,7 @@ app.use('/api/speakers', speakerRoutes); app.use('/api/invitations', invitationRoutes); app.use('/api/messages', messageRoutes); app.use('/api/materials', materialRoutes); +app.use('/api/speaker-attendance', speakerAttendanceRoutes); // Error handling middleware app.use(notFoundHandler); diff --git a/ems-services/speaker-service/src/services/material.service.ts b/ems-services/speaker-service/src/services/material.service.ts index df2c118..3278bc8 100644 --- a/ems-services/speaker-service/src/services/material.service.ts +++ b/ems-services/speaker-service/src/services/material.service.ts @@ -11,7 +11,9 @@ export class MaterialService { private readonly uploadDir: string; constructor() { - this.uploadDir = process.env['UPLOAD_DIR'] || './uploads'; + // Use absolute path to avoid path resolution issues + const defaultDir = path.resolve(process.cwd(), 'uploads'); + this.uploadDir = process.env['UPLOAD_DIR'] || defaultDir; this.ensureUploadDir(); } @@ -26,6 +28,18 @@ export class MaterialService { } } + /** + * Check if file exists + */ + private async fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + } + /** * Upload presentation material */ @@ -71,7 +85,8 @@ export class MaterialService { const fileExtension = path.extname(data.fileName); const baseName = path.basename(data.fileName, fileExtension); const uniqueFileName = `${baseName}_${Date.now()}${fileExtension}`; - const filePath = path.join(this.uploadDir, uniqueFileName); + // Use absolute path to avoid path resolution issues + const filePath = path.resolve(this.uploadDir, uniqueFileName); // Save file to disk await fs.writeFile(filePath, file.buffer); @@ -200,14 +215,30 @@ export class MaterialService { throw new Error('Material not found'); } + // Resolve file path - handle both absolute and relative paths + let resolvedPath = material.filePath; + + // If path is relative, resolve it relative to current working directory + if (!path.isAbsolute(material.filePath)) { + // Try relative to uploads directory first + resolvedPath = path.resolve(this.uploadDir, path.basename(material.filePath)); + + // If not found, try the original relative path + if (!await this.fileExists(resolvedPath)) { + resolvedPath = path.resolve(process.cwd(), material.filePath); + } + } + // Check if file exists on disk try { - await fs.access(material.filePath); + await fs.access(resolvedPath); } catch { - throw new Error('Material file not found on disk'); + const errorMsg = `Material file not found on disk. Material ID: ${id}, Original Path: ${material.filePath}, Resolved Path: ${resolvedPath}, Upload Dir: ${this.uploadDir}, CWD: ${process.cwd()}`; + logger.error('Material file not found', new Error(errorMsg)); + throw new Error(`Material file not found on disk. Path: ${resolvedPath}`); } - const fileBuffer = await fs.readFile(material.filePath); + const fileBuffer = await fs.readFile(resolvedPath); logger.info('Material downloaded successfully', { materialId: id }); return { material, fileBuffer }; diff --git a/ems-services/speaker-service/src/services/speaker-attendance.service.ts b/ems-services/speaker-service/src/services/speaker-attendance.service.ts new file mode 100644 index 0000000..f47ecf8 --- /dev/null +++ b/ems-services/speaker-service/src/services/speaker-attendance.service.ts @@ -0,0 +1,278 @@ +import { prisma } from '../database'; +import { logger } from '../utils/logger'; + +export interface SpeakerJoinEventRequest { + speakerId: string; + eventId: string; +} + +export interface SpeakerJoinEventResponse { + success: boolean; + message: string; + joinedAt?: string; + isFirstJoin: boolean; +} + +export interface UpdateMaterialsRequest { + invitationId: string; + materialIds: string[]; +} + +export interface SpeakerAttendanceResponse { + eventId: string; + totalSpeakersInvited: number; + totalSpeakersJoined: number; + speakers: Array<{ + speakerId: string; + speakerName: string; + speakerEmail: string; + joinedAt: string; + isAttended: boolean; + materialsSelected: string[]; + }>; +} + +export class SpeakerAttendanceService { + /** + * Speaker joins an event + */ + async speakerJoinEvent(data: SpeakerJoinEventRequest): Promise { + try { + logger.info('Processing speaker event join request', { + speakerId: data.speakerId, + eventId: data.eventId + }); + + // Find the invitation for this speaker and event + const invitation = await prisma.speakerInvitation.findFirst({ + where: { + speakerId: data.speakerId, + eventId: data.eventId, + status: 'ACCEPTED' + } + }); + + if (!invitation) { + return { + success: false, + message: 'No accepted invitation found for this event', + isFirstJoin: false + }; + } + + // Check if this is the first time joining + const isFirstJoin = !invitation.isAttended; + + // Update invitation with join information + const updatedInvitation = await prisma.speakerInvitation.update({ + where: { id: invitation.id }, + data: { + joinedAt: new Date(), + isAttended: true + } + }); + + logger.info('Speaker successfully joined event', { + invitationId: invitation.id, + speakerId: data.speakerId, + eventId: data.eventId, + isFirstJoin + }); + + return { + success: true, + message: isFirstJoin ? 'Successfully joined the event!' : 'Rejoined the event', + joinedAt: updatedInvitation.joinedAt?.toISOString(), + isFirstJoin + }; + + } catch (error) { + logger.error('Error speaker joining event', error as Error, { + speakerId: data.speakerId, + eventId: data.eventId + }); + throw error; + } + } + + /** + * Update materials selected for an event + */ + async updateMaterialsForEvent(data: UpdateMaterialsRequest): Promise<{ success: boolean; message: string }> { + try { + logger.info('Updating materials for event', { + invitationId: data.invitationId, + materialCount: data.materialIds.length + }); + + // Find the invitation + const invitation = await prisma.speakerInvitation.findUnique({ + where: { id: data.invitationId }, + include: { + speaker: true + } + }); + + if (!invitation) { + return { + success: false, + message: 'Invitation not found' + }; + } + + // Check if event has started (materials can't be changed after event starts) + // We'll need to get event details from event service + // For now, we'll allow updates until speaker joins + if (invitation.joinedAt) { + return { + success: false, + message: 'Cannot change materials after joining the event' + }; + } + + // Validate that all material IDs belong to this speaker + const speakerMaterials = await prisma.presentationMaterial.findMany({ + where: { + speakerId: invitation.speakerId, + id: { in: data.materialIds } + } + }); + + if (speakerMaterials.length !== data.materialIds.length) { + return { + success: false, + message: 'Some materials do not belong to this speaker' + }; + } + + // Update the invitation with selected materials + await prisma.speakerInvitation.update({ + where: { id: data.invitationId }, + data: { + materialsSelected: data.materialIds + } + }); + + logger.info('Materials updated successfully', { + invitationId: data.invitationId, + materialCount: data.materialIds.length + }); + + return { + success: true, + message: 'Materials updated successfully' + }; + + } catch (error) { + logger.error('Error updating materials', error as Error, { + invitationId: data.invitationId + }); + throw error; + } + } + + /** + * Get speaker attendance data for an event + */ + async getSpeakerAttendance(eventId: string): Promise { + try { + logger.info('Fetching speaker attendance data', { eventId }); + + // Get all invitations for this event + const invitations = await prisma.speakerInvitation.findMany({ + where: { + eventId: eventId, + status: 'ACCEPTED' + }, + include: { + speaker: true + } + }); + + const totalSpeakersInvited = invitations.length; + const totalSpeakersJoined = invitations.filter(invitation => invitation.isAttended).length; + + const speakers = invitations.map(invitation => ({ + speakerId: invitation.speakerId, + speakerName: invitation.speaker.name, + speakerEmail: invitation.speaker.email, + joinedAt: invitation.joinedAt?.toISOString() || '', + isAttended: invitation.isAttended, + materialsSelected: invitation.materialsSelected + })); + + logger.info('Speaker attendance data retrieved', { + eventId, + totalSpeakersInvited, + totalSpeakersJoined + }); + + return { + eventId, + totalSpeakersInvited, + totalSpeakersJoined, + speakers + }; + + } catch (error) { + logger.error('Error fetching speaker attendance', error as Error, { eventId }); + throw error; + } + } + + /** + * Get materials available for selection for a specific invitation + */ + async getAvailableMaterials(invitationId: string): Promise<{ + invitationId: string; + eventId: string; + speakerId: string; + availableMaterials: Array<{ + id: string; + fileName: string; + fileSize: number; + mimeType: string; + uploadDate: string; + }>; + selectedMaterials: string[]; + }> { + try { + const invitation = await prisma.speakerInvitation.findUnique({ + where: { id: invitationId }, + include: { + speaker: { + include: { + materials: true + } + } + } + }); + + if (!invitation) { + throw new Error('Invitation not found'); + } + + const availableMaterials = invitation.speaker.materials.map(material => ({ + id: material.id, + fileName: material.fileName, + fileSize: material.fileSize, + mimeType: material.mimeType, + uploadDate: material.uploadDate.toISOString() + })); + + return { + invitationId: invitation.id, + eventId: invitation.eventId, + speakerId: invitation.speakerId, + availableMaterials, + selectedMaterials: invitation.materialsSelected + }; + + } catch (error) { + logger.error('Error fetching available materials', error as Error, { invitationId }); + throw error; + } + } +} + +export const speakerAttendanceService = new SpeakerAttendanceService();