From 60faf2e9b418cc6263f2720825f38b27ebe06112 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 11:51:32 -0600 Subject: [PATCH 01/28] EMS-74: Replace hardcoded dashboard stats with real database data and clean up UI - Added admin stats endpoints in auth, event, and booking services - Implemented getDashboardStats method to aggregate statistics from all services - Updated admin dashboard to fetch and display real data from database - Removed flagged users functionality from UI and backend - Removed pending approvals button from dashboard - Fixed route paths for admin stats endpoints --- ems-client/app/dashboard/admin/page.tsx | 240 ++++++------------ ems-client/app/dashboard/admin/users/page.tsx | 8 - ems-client/lib/api/admin.api.ts | 63 +++++ .../auth-service/src/routes/routes.ts | 38 +++ .../src/routes/admin.routes.ts | 20 ++ .../event-service/src/routes/admin.routes.ts | 27 ++ 6 files changed, 224 insertions(+), 172 deletions(-) diff --git a/ems-client/app/dashboard/admin/page.tsx b/ems-client/app/dashboard/admin/page.tsx index aedbfc6..43a8394 100644 --- a/ems-client/app/dashboard/admin/page.tsx +++ b/ems-client/app/dashboard/admin/page.tsx @@ -9,78 +9,17 @@ import { LogOut, Users, Calendar, - Settings, UserCheck, - AlertTriangle, - TrendingUp, BarChart3, Plus, Eye, - Edit, - Trash2, Ticket } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import {useLogger} from "@/lib/logger/LoggerProvider"; import {withAdminAuth} from "@/components/hoc/withAuth"; - -// Mock data for development -const mockStats = { - totalUsers: 156, - totalEvents: 8, - activeEvents: 3, - flaggedUsers: 2, - totalRegistrations: 342, - upcomingEvents: 3 -}; - -const mockRecentEvents = [ - { - id: '1', - title: 'Tech Conference 2024', - status: 'published', - registrations: 45, - capacity: 100, - startDate: '2024-02-15', - endDate: '2024-02-17' - }, - { - id: '2', - title: 'Design Workshop', - status: 'draft', - registrations: 12, - capacity: 50, - startDate: '2024-02-20', - endDate: '2024-02-21' - }, - { - id: '3', - title: 'AI Summit', - status: 'published', - registrations: 89, - capacity: 150, - startDate: '2024-03-01', - endDate: '2024-03-03' - } -]; - -const mockFlaggedUsers = [ - { - id: '1', - name: 'John Doe', - email: 'john@example.com', - reason: 'Spam registrations', - flaggedAt: '2024-01-15' - }, - { - id: '2', - name: 'Jane Smith', - email: 'jane@example.com', - reason: 'Inappropriate behavior', - flaggedAt: '2024-01-20' - } -]; +import { adminApiClient, DashboardStats } from "@/lib/api/admin.api"; const LOGGER_COMPONENT_NAME = 'AdminDashboard'; @@ -88,11 +27,31 @@ function AdminDashboard() { const { user, logout } = useAuth(); const router = useRouter(); const logger = useLogger(); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { logger.debug(LOGGER_COMPONENT_NAME, 'Admin dashboard loaded', { userRole: user?.role }); + loadDashboardStats(); }, [user, logger]); + const loadDashboardStats = async () => { + try { + setLoading(true); + setError(null); + logger.info(LOGGER_COMPONENT_NAME, 'Loading dashboard statistics'); + const dashboardStats = await adminApiClient.getDashboardStats(); + setStats(dashboardStats); + logger.info(LOGGER_COMPONENT_NAME, 'Dashboard statistics loaded successfully', dashboardStats); + } catch (err) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load dashboard statistics', err as Error); + setError('Failed to load dashboard statistics. Please try again later.'); + } finally { + setLoading(false); + } + }; + // Loading and auth checks are handled by the HOC return ( @@ -163,10 +122,20 @@ function AdminDashboard() { -
{mockStats.totalUsers}
-

- {mockStats.flaggedUsers} flagged -

+ {loading ? ( +
...
+ ) : error ? ( +
Error
+ ) : ( + <> +
+ {stats?.totalUsers ?? 0} +
+

+ Total registered +

+ + )}
@@ -178,10 +147,20 @@ function AdminDashboard() { -
{mockStats.totalEvents}
-

- {mockStats.activeEvents} active -

+ {loading ? ( +
...
+ ) : error ? ( +
Error
+ ) : ( + <> +
+ {stats?.totalEvents ?? 0} +
+

+ {stats?.activeEvents ?? 0} active +

+ + )}
@@ -193,27 +172,23 @@ function AdminDashboard() { -
{mockStats.totalRegistrations}
-

- Across all events -

+ {loading ? ( +
...
+ ) : error ? ( +
Error
+ ) : ( + <> +
+ {stats?.totalRegistrations ?? 0} +
+

+ Across all events +

+ + )}
- - - - Flagged Users - - - - -
{mockStats.flaggedUsers}
-

- Need review -

-
-
{/* Quick Actions */} @@ -246,15 +221,6 @@ function AdminDashboard() { Create Event - - - - - - - - ))} +
+

+ View all events to see the latest updates +

+
- {/* Flagged Users Alert */} - {mockFlaggedUsers.length > 0 && ( - - - - - Flagged Users Requiring Review - - - -
- {mockFlaggedUsers.map((user) => ( -
-
-

{user.name}

-

{user.email}

-

Reason: {user.reason}

-
-
- - -
-
- ))} -
-
-
- )} ); diff --git a/ems-client/app/dashboard/admin/users/page.tsx b/ems-client/app/dashboard/admin/users/page.tsx index a5017cd..33e82b2 100644 --- a/ems-client/app/dashboard/admin/users/page.tsx +++ b/ems-client/app/dashboard/admin/users/page.tsx @@ -298,14 +298,6 @@ export default function UserManagementPage() { - diff --git a/ems-client/lib/api/admin.api.ts b/ems-client/lib/api/admin.api.ts index 1e4a2f0..2f815d5 100644 --- a/ems-client/lib/api/admin.api.ts +++ b/ems-client/lib/api/admin.api.ts @@ -25,6 +25,13 @@ export interface SpeakerInvitation { updatedAt: string; } +export interface DashboardStats { + totalUsers: number; + totalEvents: number; + activeEvents: number; + totalRegistrations: number; +} + export class AdminApiClient extends BaseApiClient { protected readonly LOGGER_COMPONENT_NAME: string = LOGGER_COMPONENT_NAME; @@ -196,6 +203,62 @@ export class AdminApiClient extends BaseApiClient { } } + // Dashboard Statistics + async getDashboardStats(): Promise { + try { + logger.debug(LOGGER_COMPONENT_NAME, 'Fetching dashboard statistics'); + + const token = this.getToken(); + + // Fetch stats from all services in parallel + const [userStats, eventStats, bookingStats] = await Promise.all([ + fetch('/api/auth/admin/stats', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }).then(res => { + if (!res.ok) throw new Error(`Auth service error: ${res.status}`); + return res.json(); + }), + fetch('/api/event/admin/stats', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }).then(res => { + if (!res.ok) throw new Error(`Event service error: ${res.status}`); + return res.json(); + }), + fetch('/api/booking/admin/stats', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }).then(res => { + if (!res.ok) throw new Error(`Booking service error: ${res.status}`); + return res.json(); + }) + ]); + + const stats: DashboardStats = { + totalUsers: userStats.data?.totalUsers || 0, + totalEvents: eventStats.data?.totalEvents || 0, + activeEvents: eventStats.data?.activeEvents || 0, + totalRegistrations: bookingStats.data?.totalRegistrations || 0 + }; + + logger.info(LOGGER_COMPONENT_NAME, 'Dashboard statistics retrieved', stats); + return stats; + } catch (error) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to fetch dashboard statistics', error as Error); + throw error; + } + } + // Override getToken to return string instead of string | null public getToken(): string { const token = super.getToken(); diff --git a/ems-services/auth-service/src/routes/routes.ts b/ems-services/auth-service/src/routes/routes.ts index 83e7edc..5163e34 100644 --- a/ems-services/auth-service/src/routes/routes.ts +++ b/ems-services/auth-service/src/routes/routes.ts @@ -352,4 +352,42 @@ export function registerRoutes(app: Express, authService: AuthService) { return res.status(400).json({error: error.message}); } }); + + /** + * @route GET /api/auth/admin/stats + * @desc Get user statistics for admin dashboard. + * @access Protected - Admin only + */ + app.get('/admin/stats', authMiddleware, async (req: Request, res: Response) => { + try { + const userId = contextService.getCurrentUserId(); + let user = contextService.getCurrentUser(); + + // If user not in context, fetch it + if (!user) { + user = await authService.getProfile(userId); + } + + // Check if user is admin + if (!user || user.role !== 'ADMIN') { + return res.status(403).json({error: 'Access denied: Admin only'}); + } + + logger.info("/admin/stats - Fetching user statistics", { adminId: userId }); + + const { prisma } = await import('../database'); + + const totalUsers = await prisma.user.count(); + + res.json({ + success: true, + data: { + totalUsers + } + }); + } catch (error: any) { + logger.error("/admin/stats - Failed to fetch user statistics", error); + res.status(500).json({error: 'Failed to fetch user statistics'}); + } + }); } \ No newline at end of file diff --git a/ems-services/booking-service/src/routes/admin.routes.ts b/ems-services/booking-service/src/routes/admin.routes.ts index bc3670e..24fae7a 100644 --- a/ems-services/booking-service/src/routes/admin.routes.ts +++ b/ems-services/booking-service/src/routes/admin.routes.ts @@ -460,4 +460,24 @@ router.put('/:ticketId/revoke', async (req: AuthRequest, res: Response) => { } }); +/** + * GET /admin/stats - Get booking statistics for admin dashboard + */ +router.get('/stats', + asyncHandler(async (req: AuthRequest, res: Response) => { + logger.info('Fetching booking statistics (admin)', { adminId: req.user?.userId }); + + const totalRegistrations = await prisma.booking.count({ + where: { status: 'CONFIRMED' } + }); + + res.json({ + success: true, + data: { + totalRegistrations + } + }); + }) +); + export default router; diff --git a/ems-services/event-service/src/routes/admin.routes.ts b/ems-services/event-service/src/routes/admin.routes.ts index 0f7209b..6cb96ab 100644 --- a/ems-services/event-service/src/routes/admin.routes.ts +++ b/ems-services/event-service/src/routes/admin.routes.ts @@ -307,4 +307,31 @@ router.get('/admin/venues/:id', }) ); +/** + * GET /admin/stats - Get event statistics for admin dashboard + */ +router.get('/stats', + asyncHandler(async (req: Request, res: Response) => { + logger.info('Fetching event statistics (admin)'); + + const { prisma } = await import('../database'); + const { EventStatus } = await import('../../generated/prisma'); + + const [totalEvents, activeEvents] = await Promise.all([ + prisma.event.count(), + prisma.event.count({ + where: { status: EventStatus.PUBLISHED } + }) + ]); + + res.json({ + success: true, + data: { + totalEvents, + activeEvents + } + }); + }) +); + export default router; From 40d8d50091ed4ca4f286039f186874e49338c0d0 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 14:52:11 -0600 Subject: [PATCH 02/28] EMS-74: Add backend pagination, search, and filters for user management - Updated /api/auth/admin/users endpoint to support search by name/email - Added role and status filters (ADMIN, USER, SPEAKER, ACTIVE, INACTIVE) - Implemented pagination with page and limit parameters (default: 10, max: 100) - Returns pagination metadata (total, totalPages, hasNextPage, hasPreviousPage) - Case-insensitive search using Prisma contains with mode insensitive --- .../auth-service/src/routes/routes.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/ems-services/auth-service/src/routes/routes.ts b/ems-services/auth-service/src/routes/routes.ts index 5163e34..08a1d28 100644 --- a/ems-services/auth-service/src/routes/routes.ts +++ b/ems-services/auth-service/src/routes/routes.ts @@ -390,4 +390,121 @@ export function registerRoutes(app: Express, authService: AuthService) { res.status(500).json({error: 'Failed to fetch user statistics'}); } }); + + /** + * @route GET /api/auth/admin/users + * @desc Get all users list for admin dashboard with search, filters, and pagination. + * @access Protected - Admin only + * @query search: string (optional) - Search by name or email + * @query role: string (optional) - Filter by role (ADMIN, USER, SPEAKER) + * @query status: string (optional) - Filter by status (ACTIVE, INACTIVE) + * @query page: number (optional) - Page number (default: 1) + * @query limit: number (optional) - Items per page (default: 10, max: 100) + */ + app.get('/admin/users', authMiddleware, async (req: Request, res: Response) => { + try { + const userId = contextService.getCurrentUserId(); + let user = contextService.getCurrentUser(); + + // If user not in context, fetch it + if (!user) { + user = await authService.getProfile(userId); + } + + // Check if user is admin + if (!user || user.role !== 'ADMIN') { + return res.status(403).json({error: 'Access denied: Admin only'}); + } + + // Extract query parameters + const search = req.query.search as string | undefined; + const role = req.query.role as string | undefined; + const status = req.query.status as string | undefined; + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 10)); + const skip = (page - 1) * limit; + + logger.info("/admin/users - Fetching users", { + adminId: userId, + search, + role, + status, + page, + limit + }); + + const { prisma } = await import('../database'); + + // Build where clause + const where: any = {}; + + // Search filter (name or email) + if (search) { + where.OR = [ + { name: { contains: search, mode: 'insensitive' } }, + { email: { contains: search, mode: 'insensitive' } } + ]; + } + + // Role filter + if (role && role !== 'ALL') { + where.role = role; + } + + // Status filter + if (status && status !== 'ALL') { + where.isActive = status === 'ACTIVE'; + } + + // Get total count for pagination + const total = await prisma.user.count({ where }); + + // Get paginated users + const users = await prisma.user.findMany({ + where, + select: { + id: true, + name: true, + email: true, + role: true, + isActive: true, + emailVerified: true, + createdAt: true, + updatedAt: true + }, + orderBy: { + createdAt: 'desc' + }, + skip, + take: limit + }); + + const totalPages = Math.ceil(total / limit); + + res.json({ + success: true, + data: users.map(u => ({ + id: u.id, + name: u.name, + email: u.email, + role: u.role, + isActive: u.isActive, + emailVerified: u.emailVerified ? u.emailVerified.toISOString() : null, + createdAt: u.createdAt.toISOString(), + updatedAt: u.updatedAt.toISOString() + })), + pagination: { + page, + limit, + total, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1 + } + }); + } catch (error: any) { + logger.error("/admin/users - Failed to fetch users", error); + res.status(500).json({error: 'Failed to fetch users'}); + } + }); } \ No newline at end of file From c24dc9592f6b6fc29dee3162c053da42bfeb9b6d Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 14:52:14 -0600 Subject: [PATCH 03/28] EMS-74: Add attendance statistics endpoint excluding admins and speakers - Added /api/booking/admin/attendance-stats endpoint - Calculates average attendance percentage across all events - Only counts attendees (USER role), excludes ADMIN and SPEAKER - Returns totalRegistrations, totalAttended, and attendancePercentage - Filters bookings to only include attendees for accurate metrics --- .../src/routes/admin.routes.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/ems-services/booking-service/src/routes/admin.routes.ts b/ems-services/booking-service/src/routes/admin.routes.ts index 24fae7a..0c72d61 100644 --- a/ems-services/booking-service/src/routes/admin.routes.ts +++ b/ems-services/booking-service/src/routes/admin.routes.ts @@ -480,4 +480,89 @@ router.get('/stats', }) ); +/** + * GET /admin/attendance-stats - Get overall attendance statistics across all events + * Only counts attendees (USER role), excludes admins and speakers + */ +router.get('/attendance-stats', + asyncHandler(async (req: AuthRequest, res: Response) => { + logger.info('Fetching attendance statistics (admin)', { adminId: req.user?.userId }); + + // Get all confirmed bookings + const bookings = await prisma.booking.findMany({ + where: { status: 'CONFIRMED' }, + select: { + id: true, + userId: true, + isAttended: true + } + }); + + // Get user info for all bookings to filter out admins and speakers + const { getUserInfo } = await import('../utils/auth-helpers'); + const bookingsWithUserInfo = await Promise.all( + bookings.map(async (booking) => { + const userInfo = await getUserInfo(booking.userId); + return { + booking, + userInfo + }; + }) + ); + + // Filter to only include attendees (USER role), exclude ADMIN and SPEAKER + const attendeeBookings = bookingsWithUserInfo.filter( + ({ userInfo }) => userInfo && userInfo.role === 'USER' + ); + + const totalRegistrations = attendeeBookings.length; + const totalAttended = attendeeBookings.filter(({ booking }) => booking.isAttended === true).length; + const attendancePercentage = totalRegistrations > 0 + ? Number(((totalAttended / totalRegistrations) * 100).toFixed(2)) // Round to 2 decimal places + : 0; + + logger.info('Attendance statistics calculated', { + totalBookings: bookings.length, + attendeeRegistrations: totalRegistrations, + attendeeAttended: totalAttended, + attendancePercentage + }); + + res.json({ + success: true, + data: { + totalRegistrations, + totalAttended, + attendancePercentage + } + }); + }) +); + +/** + * GET /admin/users/event-counts - Get event registration counts per user + */ +router.get('/users/event-counts', + asyncHandler(async (req: AuthRequest, res: Response) => { + logger.info('Fetching user event counts (admin)', { adminId: req.user?.userId }); + + // Get all confirmed bookings grouped by userId + const bookings = await prisma.booking.findMany({ + where: { status: 'CONFIRMED' }, + select: { userId: true } + }); + + // Count events per user + const eventCounts: Record = {}; + bookings.forEach(booking => { + eventCounts[booking.userId] = (eventCounts[booking.userId] || 0) + 1; + }); + + res.json({ + success: true, + data: eventCounts + }); + }) +); + export default router; From 5767f3af064b6a0cb27e065da451305be5896992 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 14:52:17 -0600 Subject: [PATCH 04/28] EMS-74: Update admin API client with pagination and attendance stats - Updated getAllUsers() to accept filters (search, role, status, page, limit) - Added pagination response type with metadata - Added getAttendanceStats() method to fetch attendance statistics - Returns both data and pagination information for user queries --- ems-client/lib/api/admin.api.ts | 138 ++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/ems-client/lib/api/admin.api.ts b/ems-client/lib/api/admin.api.ts index 2f815d5..2e1babe 100644 --- a/ems-client/lib/api/admin.api.ts +++ b/ems-client/lib/api/admin.api.ts @@ -259,6 +259,144 @@ export class AdminApiClient extends BaseApiClient { } } + // User Management + async getAllUsers(filters?: { + search?: string; + role?: string; + status?: string; + page?: number; + limit?: number; + }): Promise<{ + data: Array<{ + id: string; + name: string | null; + email: string; + role: string; + isActive: boolean; + emailVerified: string | null; + createdAt: string; + updatedAt: string; + }>; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + }> { + try { + logger.debug(LOGGER_COMPONENT_NAME, 'Fetching users', filters); + + // Build query parameters + const params = new URLSearchParams(); + if (filters?.search) params.append('search', filters.search); + if (filters?.role && filters.role !== 'ALL') params.append('role', filters.role); + if (filters?.status && filters.status !== 'ALL') params.append('status', filters.status); + if (filters?.page) params.append('page', filters.page.toString()); + if (filters?.limit) params.append('limit', filters.limit.toString()); + + const queryString = params.toString(); + const url = `/api/auth/admin/users${queryString ? `?${queryString}` : ''}`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.getToken()}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + logger.info(LOGGER_COMPONENT_NAME, 'Users retrieved', { + count: result.data.length, + pagination: result.pagination + }); + + return { + data: result.data || [], + pagination: result.pagination || { + page: 1, + limit: 10, + total: 0, + totalPages: 0, + hasNextPage: false, + hasPreviousPage: false + } + }; + } catch (error) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to fetch users', error as Error); + throw error; + } + } + + async getUserEventCounts(): Promise> { + try { + logger.debug(LOGGER_COMPONENT_NAME, 'Fetching user event counts'); + + const response = await fetch('/api/booking/admin/users/event-counts', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.getToken()}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + logger.info(LOGGER_COMPONENT_NAME, 'User event counts retrieved'); + return result.data || {}; + } catch (error) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to fetch user event counts', error as Error); + throw error; + } + } + + async getAttendanceStats(): Promise<{ + totalRegistrations: number; + totalAttended: number; + attendancePercentage: number; + }> { + try { + logger.debug(LOGGER_COMPONENT_NAME, 'Fetching attendance statistics'); + + const response = await fetch('/api/booking/admin/attendance-stats', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.getToken()}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + logger.info(LOGGER_COMPONENT_NAME, 'Attendance statistics retrieved', result.data); + + return result.data || { + totalRegistrations: 0, + totalAttended: 0, + attendancePercentage: 0 + }; + } catch (error) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to fetch attendance statistics', error as Error); + throw error; + } + } + // Override getToken to return string instead of string | null public getToken(): string { const token = super.getToken(); From d091c52bfde0263bcb49ffa8fe04b7f5c917a249 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 14:52:21 -0600 Subject: [PATCH 05/28] EMS-74: Add ref forwarding support to Input component - Updated Input component to use React.forwardRef - Allows parent components to access input element reference - Enables focus management for search inputs --- ems-client/components/ui/input.tsx | 34 +++++++++++++++++------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/ems-client/components/ui/input.tsx b/ems-client/components/ui/input.tsx index 8916905..10af539 100644 --- a/ems-client/components/ui/input.tsx +++ b/ems-client/components/ui/input.tsx @@ -2,20 +2,24 @@ import * as React from "react" import { cn } from "@/lib/utils" -function Input({ className, type, ...props }: React.ComponentProps<"input">) { - return ( - - ) -} +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" export { Input } From dc9a015929a29c19ce1c9b97e3f447cd236f7641 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 14:52:24 -0600 Subject: [PATCH 06/28] EMS-74: Implement backend pagination and search for user management - Moved search, filters, and pagination to backend API - Added debounced search (500ms) with focus management - Implemented pagination UI with page numbers and prev/next buttons - Stats cards now show total counts (not filtered) - Replaced Inactive Users card with Average Attendance Percentage - Only shows loading overlay on table, not full page re-render - Maintains search input focus during typing and data updates - Optimized with useCallback to prevent unnecessary re-renders --- ems-client/app/dashboard/admin/users/page.tsx | 433 ++++++++++++------ 1 file changed, 282 insertions(+), 151 deletions(-) diff --git a/ems-client/app/dashboard/admin/users/page.tsx b/ems-client/app/dashboard/admin/users/page.tsx index 33e82b2..12e36b7 100644 --- a/ems-client/app/dashboard/admin/users/page.tsx +++ b/ems-client/app/dashboard/admin/users/page.tsx @@ -10,88 +10,63 @@ import { LogOut, Users, Search, - Filter, - MoreHorizontal, Shield, - ShieldCheck, - UserX, UserCheck, - Mail, - Calendar, - AlertTriangle, - ArrowLeft + TrendingUp, + ArrowLeft, + ChevronLeft, + ChevronRight } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import {logger} from "@/lib/logger"; +import { adminApiClient } from "@/lib/api/admin.api"; const COMPONENT_NAME = 'UserManagementPage'; -// Mock data for development -const mockUsers = [ - { - id: '1', - name: 'John Doe', - email: 'john@example.com', - role: 'USER', - isActive: true, - emailVerified: '2024-01-15', - createdAt: '2024-01-10', - lastLogin: '2024-01-20', - eventsRegistered: 3 - }, - { - id: '2', - name: 'Jane Smith', - email: 'jane@example.com', - role: 'SPEAKER', - isActive: true, - emailVerified: '2024-01-12', - createdAt: '2024-01-08', - lastLogin: '2024-01-19', - eventsRegistered: 1 - }, - { - id: '3', - name: 'Bob Johnson', - email: 'bob@example.com', - role: 'USER', - isActive: false, - emailVerified: null, - createdAt: '2024-01-05', - lastLogin: '2024-01-15', - eventsRegistered: 0 - }, - { - id: '4', - name: 'Alice Wilson', - email: 'alice@example.com', - role: 'ADMIN', - isActive: true, - emailVerified: '2024-01-01', - createdAt: '2024-01-01', - lastLogin: '2024-01-20', - eventsRegistered: 5 - }, - { - id: '5', - name: 'Charlie Brown', - email: 'charlie@example.com', - role: 'USER', - isActive: true, - emailVerified: '2024-01-18', - createdAt: '2024-01-15', - lastLogin: '2024-01-21', - eventsRegistered: 2 - } -]; +interface User { + id: string; + name: string | null; + email: string; + role: string; + isActive: boolean; + emailVerified: string | null; + createdAt: string; + updatedAt: string; + eventsRegistered?: number; +} export default function UserManagementPage() { const { user, isAuthenticated, isLoading, logout } = useAuth(); const router = useRouter(); const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); const [selectedRole, setSelectedRole] = useState('ALL'); const [selectedStatus, setSelectedStatus] = useState('ALL'); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [initialLoad, setInitialLoad] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [limit] = useState(10); + const [pagination, setPagination] = useState({ + page: 1, + limit: 10, + total: 0, + totalPages: 0, + hasNextPage: false, + hasPreviousPage: false + }); + const [totalStats, setTotalStats] = useState({ + total: 0, + active: 0, + admins: 0, + attendancePercentage: 0 + }); + + // Ref to maintain focus on search input + const searchInputRef = useRef(null); + const isSearchingRef = useRef(false); useEffect(() => { if (!isLoading && !isAuthenticated) { @@ -101,29 +76,140 @@ export default function UserManagementPage() { } }, [isAuthenticated, isLoading, user, router]); - // Filter users based on search and filters - const filteredUsers = mockUsers.filter(user => { - const matchesSearch = user.name.toLowerCase().includes(searchTerm.toLowerCase()) || - user.email.toLowerCase().includes(searchTerm.toLowerCase()); - const matchesRole = selectedRole === 'ALL' || user.role === selectedRole; - const matchesStatus = selectedStatus === 'ALL' || - (selectedStatus === 'ACTIVE' && user.isActive) || - (selectedStatus === 'INACTIVE' && !user.isActive); - - return matchesSearch && matchesRole && matchesStatus; - }); + // Debounce search term + useEffect(() => { + if (searchTerm) { + isSearchingRef.current = true; + } + const timer = setTimeout(() => { + setDebouncedSearch(searchTerm); + setPage(1); // Reset to first page on search + }, 500); + + return () => clearTimeout(timer); + }, [searchTerm]); + + // Load users when filters change + const loadUsers = useCallback(async () => { + try { + setLoading(true); + setError(null); + logger.info(COMPONENT_NAME, 'Loading users', { + search: debouncedSearch, + role: selectedRole, + status: selectedStatus, + page, + limit + }); + + const [usersResponse, eventCounts] = await Promise.all([ + adminApiClient.getAllUsers({ + search: debouncedSearch || undefined, + role: selectedRole, + status: selectedStatus, + page, + limit + }), + adminApiClient.getUserEventCounts() + ]); + + // Merge event counts with users + const usersWithEvents = usersResponse.data.map((u) => ({ + ...u, + eventsRegistered: eventCounts[u.id] || 0 + })); + + setUsers(usersWithEvents); + setPagination(usersResponse.pagination); + + logger.info(COMPONENT_NAME, 'Users loaded successfully', { + count: usersWithEvents.length, + pagination: usersResponse.pagination + }); + + // Mark initial load as complete + if (initialLoad) { + setInitialLoad(false); + } + + // Restore focus to search input after loading if user was searching + if (isSearchingRef.current && searchInputRef.current) { + // Use requestAnimationFrame to ensure DOM is updated + requestAnimationFrame(() => { + if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }); + } + } catch (err) { + logger.error(COMPONENT_NAME, 'Failed to load users', err as Error); + setError('Failed to load users. Please try again later.'); + if (initialLoad) { + setInitialLoad(false); + } + } finally { + setLoading(false); + isSearchingRef.current = false; + } + }, [debouncedSearch, selectedRole, selectedStatus, page, limit, initialLoad]); + + useEffect(() => { + if (isAuthenticated && user?.role === 'ADMIN') { + loadUsers(); + } + }, [isAuthenticated, user, loadUsers]); + + // Load initial stats (always load total stats, not filtered) + useEffect(() => { + if (isAuthenticated && user?.role === 'ADMIN') { + loadInitialStats(); + } + }, [isAuthenticated, user]); + + const loadInitialStats = async () => { + try { + // Get stats from dashboard stats endpoint and attendance stats + const [dashboardStats, attendanceStats] = await Promise.all([ + adminApiClient.getDashboardStats(), + adminApiClient.getAttendanceStats() + ]); + + // Get all users to calculate active and admin counts + const allUsersResponse = await adminApiClient.getAllUsers({ limit: 1000 }); + const allUsers = allUsersResponse.data; + + setTotalStats({ + total: dashboardStats.totalUsers, + active: allUsers.filter(u => u.isActive).length, + admins: allUsers.filter(u => u.role === 'ADMIN').length, + attendancePercentage: attendanceStats.attendancePercentage + }); + } catch (err) { + logger.error(COMPONENT_NAME, 'Failed to load initial stats', err as Error); + } + }; + + const handleSearchChange = (value: string) => { + setSearchTerm(value); + }; - const handleRoleChange = (userId: string, newRole: string) => { - // TODO: Implement role change API call - logger.debug(COMPONENT_NAME, `Change user ${userId} role to ${newRole}`); + const handleRoleChange = (value: string) => { + setSelectedRole(value); + setPage(1); // Reset to first page }; - const handleStatusToggle = (userId: string, currentStatus: boolean) => { - // TODO: Implement status toggle API call - logger.debug(COMPONENT_NAME, `Toggle user ${userId} status from ${currentStatus} to ${!currentStatus}`); + const handleStatusChange = (value: string) => { + setSelectedStatus(value); + setPage(1); // Reset to first page }; - if (isLoading) { + const handlePageChange = (newPage: number) => { + setPage(newPage); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + // Only show full page loading on initial load + if (isLoading || (initialLoad && loading)) { return (
@@ -209,7 +295,10 @@ export default function UserManagementPage() {

Total Users

-

{mockUsers.length}

+

+ {totalStats.total || 0} +

+

All users

@@ -222,8 +311,9 @@ export default function UserManagementPage() {

Active Users

- {mockUsers.filter(u => u.isActive).length} + {totalStats.active || 0}

+

All users

@@ -236,8 +326,9 @@ export default function UserManagementPage() {

Admins

- {mockUsers.filter(u => u.role === 'ADMIN').length} + {totalStats.admins || 0}

+

All users

@@ -246,12 +337,13 @@ export default function UserManagementPage() {
- +
-

Inactive Users

+

Avg Attendance

- {mockUsers.filter(u => !u.isActive).length} + {totalStats.attendancePercentage.toFixed(1)}%

+

Across all events

@@ -270,16 +362,20 @@ export default function UserManagementPage() {
setSearchTerm(e.target.value)} + onChange={(e) => handleSearchChange(e.target.value)} className="pl-10" + onFocus={() => { + isSearchingRef.current = true; + }} />
setSelectedStatus(e.target.value)} + onChange={(e) => handleStatusChange(e.target.value)} className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-md bg-white dark:bg-slate-800 text-slate-900 dark:text-white" > @@ -303,18 +399,32 @@ export default function UserManagementPage() {
{/* Users Table */} - + - Users ({filteredUsers.length}) + Users ({pagination.total}) - Manage user accounts and permissions + Showing {users.length} of {pagination.total} users + {pagination.totalPages > 1 && ` (Page ${pagination.page} of ${pagination.totalPages})`} - -
- + + {error && ( +
+

{error}

+
+ )} + {loading && users.length === 0 ? ( +
+
+
+

Loading users...

+
+
+ ) : ( +
+
@@ -322,26 +432,24 @@ export default function UserManagementPage() { - - - {filteredUsers.map((user) => ( + {users.map((user) => ( - - ))}
UserStatus Email Verified EventsLast LoginActions
- {user.name.split(' ').map(n => n[0]).join('')} + {user.name ? user.name.split(' ').map((n: string) => n[0]).join('') : user.email[0].toUpperCase()}
-

{user.name}

+

{user.name || 'No name'}

{user.email}

@@ -375,61 +483,84 @@ export default function UserManagementPage() {
- {user.eventsRegistered} - - - {new Date(user.lastLogin).toLocaleDateString()} - - -
- {user.role !== 'ADMIN' && ( - - )} - - - - -
+ {user.eventsRegistered ?? 0}
- {filteredUsers.length === 0 && ( + {users.length === 0 && !loading && (

No users found matching your criteria

)} -
+ + )} + {loading && users.length > 0 && ( +
+
+
+ )} + + {/* Pagination Controls */} + {pagination.totalPages > 1 && ( +
+
+ Showing {((pagination.page - 1) * pagination.limit) + 1} to {Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total} users +
+
+ + +
+ {Array.from({ length: Math.min(5, pagination.totalPages) }, (_, i) => { + let pageNum; + if (pagination.totalPages <= 5) { + pageNum = i + 1; + } else if (pagination.page <= 3) { + pageNum = i + 1; + } else if (pagination.page >= pagination.totalPages - 2) { + pageNum = pagination.totalPages - 4 + i; + } else { + pageNum = pagination.page - 2 + i; + } + + return ( + + ); + })} +
+ + +
+
+ )}
From b2187c66a6d8d74f6e96d11661b77292a65f1b10 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 14:59:05 -0600 Subject: [PATCH 07/28] EMS-74: Add event status distribution endpoint for reports - Added /api/event/admin/reports/event-status endpoint - Returns breakdown of events by status (Published, Draft, Pending, etc.) - Calculates percentages for each status - Filters out statuses with zero events --- .../event-service/src/routes/admin.routes.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/ems-services/event-service/src/routes/admin.routes.ts b/ems-services/event-service/src/routes/admin.routes.ts index 6cb96ab..141274e 100644 --- a/ems-services/event-service/src/routes/admin.routes.ts +++ b/ems-services/event-service/src/routes/admin.routes.ts @@ -334,4 +334,41 @@ router.get('/stats', }) ); +/** + * GET /admin/reports/event-status - Get event status distribution + */ +router.get('/reports/event-status', + asyncHandler(async (req: Request, res: Response) => { + logger.info('Fetching event status distribution (admin)'); + + const { prisma } = await import('../database'); + const { EventStatus } = await import('../../generated/prisma'); + + const totalEvents = await prisma.event.count(); + + const statusCounts = await Promise.all([ + prisma.event.count({ where: { status: EventStatus.PUBLISHED } }), + prisma.event.count({ where: { status: EventStatus.DRAFT } }), + prisma.event.count({ where: { status: EventStatus.PENDING_APPROVAL } }), + prisma.event.count({ where: { status: EventStatus.REJECTED } }), + prisma.event.count({ where: { status: EventStatus.CANCELLED } }), + prisma.event.count({ where: { status: EventStatus.COMPLETED } }) + ]); + + const eventStats = [ + { status: 'Published', count: statusCounts[0], percentage: totalEvents > 0 ? (statusCounts[0] / totalEvents) * 100 : 0 }, + { status: 'Draft', count: statusCounts[1], percentage: totalEvents > 0 ? (statusCounts[1] / totalEvents) * 100 : 0 }, + { status: 'Pending Approval', count: statusCounts[2], percentage: totalEvents > 0 ? (statusCounts[2] / totalEvents) * 100 : 0 }, + { status: 'Rejected', count: statusCounts[3], percentage: totalEvents > 0 ? (statusCounts[3] / totalEvents) * 100 : 0 }, + { status: 'Cancelled', count: statusCounts[4], percentage: totalEvents > 0 ? (statusCounts[4] / totalEvents) * 100 : 0 }, + { status: 'Completed', count: statusCounts[5], percentage: totalEvents > 0 ? (statusCounts[5] / totalEvents) * 100 : 0 } + ].filter(stat => stat.count > 0); // Only return statuses that have events + + res.json({ + success: true, + data: eventStats + }); + }) +); + export default router; From 979bc5a348c55fd3bcf703e6ec1bfd9d36e3bf67 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 14:59:07 -0600 Subject: [PATCH 08/28] EMS-74: Add top performing events endpoint for reports - Added /api/booking/admin/reports/top-events endpoint - Returns top 10 events by registration count - Only counts attendees (USER role), excludes admins and speakers - Includes registrations, attendance count, and attendance percentage per event --- .../src/routes/admin.routes.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/ems-services/booking-service/src/routes/admin.routes.ts b/ems-services/booking-service/src/routes/admin.routes.ts index 0c72d61..423e2f0 100644 --- a/ems-services/booking-service/src/routes/admin.routes.ts +++ b/ems-services/booking-service/src/routes/admin.routes.ts @@ -565,4 +565,84 @@ router.get('/users/event-counts', }) ); +/** + * GET /admin/reports/top-events - Get top performing events with registrations and attendance + * Only counts attendees (USER role), excludes admins and speakers + */ +router.get('/reports/top-events', + asyncHandler(async (req: AuthRequest, res: Response) => { + logger.info('Fetching top performing events (admin)', { adminId: req.user?.userId }); + + const { getUserInfo } = await import('../utils/auth-helpers'); + + // Get all confirmed bookings with event info + const bookings = await prisma.booking.findMany({ + where: { status: 'CONFIRMED' }, + select: { + eventId: true, + userId: true, + isAttended: true + } + }); + + // Get user info for all bookings to filter out admins and speakers + const bookingsWithUserInfo = await Promise.all( + bookings.map(async (booking) => { + const userInfo = await getUserInfo(booking.userId); + return { + booking, + userInfo + }; + }) + ); + + // Filter to only include attendees (USER role) + const attendeeBookings = bookingsWithUserInfo.filter( + ({ userInfo }) => userInfo && userInfo.role === 'USER' + ); + + // Group by eventId and calculate stats + const eventStats: Record = {}; + + attendeeBookings.forEach(({ booking }) => { + if (!eventStats[booking.eventId]) { + eventStats[booking.eventId] = { + eventId: booking.eventId, + registrations: 0, + attended: 0, + attendancePercentage: 0 + }; + } + eventStats[booking.eventId].registrations++; + if (booking.isAttended) { + eventStats[booking.eventId].attended++; + } + }); + + // Calculate attendance percentage for each event + Object.values(eventStats).forEach(stat => { + stat.attendancePercentage = stat.registrations > 0 + ? Math.round((stat.attended / stat.registrations) * 100) + : 0; + }); + + // Get event names from event service (we'll need to call it or store event names) + // For now, we'll return event IDs and let frontend fetch names if needed + // Sort by registrations descending and take top 10 + const topEvents = Object.values(eventStats) + .sort((a, b) => b.registrations - a.registrations) + .slice(0, 10); + + res.json({ + success: true, + data: topEvents + }); + }) +); + export default router; From 25404ee99a79bf7b73f521782c5b0d3e2068b33c Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 14:59:10 -0600 Subject: [PATCH 09/28] EMS-74: Add user growth trend endpoint for reports - Added /api/auth/admin/reports/user-growth endpoint - Returns monthly user registration data - Calculates cumulative totals and new users per month - Sorted chronologically for trend analysis --- .../auth-service/src/routes/routes.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/ems-services/auth-service/src/routes/routes.ts b/ems-services/auth-service/src/routes/routes.ts index 08a1d28..195089f 100644 --- a/ems-services/auth-service/src/routes/routes.ts +++ b/ems-services/auth-service/src/routes/routes.ts @@ -507,4 +507,73 @@ export function registerRoutes(app: Express, authService: AuthService) { res.status(500).json({error: 'Failed to fetch users'}); } }); + + /** + * @route GET /api/auth/admin/reports/user-growth + * @desc Get user growth trend (monthly user registrations). + * @access Protected - Admin only + */ + app.get('/admin/reports/user-growth', authMiddleware, async (req: Request, res: Response) => { + try { + const userId = contextService.getCurrentUserId(); + let user = contextService.getCurrentUser(); + + if (!user) { + user = await authService.getProfile(userId); + } + + if (!user || user.role !== 'ADMIN') { + return res.status(403).json({error: 'Access denied: Admin only'}); + } + + logger.info("/admin/reports/user-growth - Fetching user growth data", { adminId: userId }); + + const { prisma } = await import('../database'); + + // Get all users ordered by creation date + const users = await prisma.user.findMany({ + select: { + createdAt: true + }, + orderBy: { + createdAt: 'asc' + } + }); + + // Group users by month + const monthlyGrowth: Record = {}; + users.forEach(user => { + const date = new Date(user.createdAt); + const monthKey = `${date.toLocaleString('default', { month: 'short' })} ${date.getFullYear()}`; + monthlyGrowth[monthKey] = (monthlyGrowth[monthKey] || 0) + 1; + }); + + // Convert to array format and calculate cumulative totals + const growthData = Object.entries(monthlyGrowth) + .map(([month, count]) => { + // Calculate cumulative users up to this month + const monthIndex = Object.keys(monthlyGrowth).indexOf(month); + const previousMonths = Object.values(monthlyGrowth).slice(0, monthIndex); + const cumulativeUsers = previousMonths.reduce((sum, val) => sum + val, 0) + count; + + return { + month, + users: cumulativeUsers, + newUsers: count + }; + }) + .sort((a, b) => { + // Sort by date (simple string comparison should work for format "MMM YYYY") + return a.month.localeCompare(b.month); + }); + + res.json({ + success: true, + data: growthData + }); + } catch (error: any) { + logger.error("/admin/reports/user-growth - Failed to fetch user growth data", error); + res.status(500).json({error: 'Failed to fetch user growth data'}); + } + }); } \ No newline at end of file From d1e848efeca801b6c7ee5d677cf2f1b11bff0a0e Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 14:59:12 -0600 Subject: [PATCH 10/28] EMS-74: Add getReportsData method to admin API client - Added getReportsData() method to aggregate all reports data - Fetches dashboard stats, attendance stats, top events, event status, and user growth - Fetches event names for top events from event service - Returns comprehensive reports data structure --- ems-client/lib/api/admin.api.ts | 110 ++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/ems-client/lib/api/admin.api.ts b/ems-client/lib/api/admin.api.ts index 2e1babe..7f6ec63 100644 --- a/ems-client/lib/api/admin.api.ts +++ b/ems-client/lib/api/admin.api.ts @@ -397,6 +397,116 @@ export class AdminApiClient extends BaseApiClient { } } + // Reports + async getReportsData(): Promise<{ + totalEvents: number; + totalUsers: number; + totalRegistrations: number; + averageAttendance: number; + topEvents: Array<{ + eventId: string; + name?: string; + registrations: number; + attendance: number; + }>; + eventStats: Array<{ + status: string; + count: number; + percentage: number; + }>; + userGrowth: Array<{ + month: string; + users: number; + newUsers: number; + }>; + }> { + try { + logger.debug(LOGGER_COMPONENT_NAME, 'Fetching reports data'); + + const [dashboardStats, attendanceStats, topEventsResponse, eventStatusResponse, userGrowthResponse] = await Promise.all([ + this.getDashboardStats(), + this.getAttendanceStats(), + fetch('/api/booking/admin/reports/top-events', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.getToken()}`, + 'Content-Type': 'application/json', + }, + }).then(res => { + if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); + return res.json(); + }), + fetch('/api/event/admin/reports/event-status', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.getToken()}`, + 'Content-Type': 'application/json', + }, + }).then(res => { + if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); + return res.json(); + }), + fetch('/api/auth/admin/reports/user-growth', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.getToken()}`, + 'Content-Type': 'application/json', + }, + }).then(res => { + if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); + return res.json(); + }) + ]); + + // Fetch event names for top events + const topEventsWithNames = await Promise.all( + (topEventsResponse.data || []).map(async (event: { eventId: string; registrations: number; attended: number; attendancePercentage: number }) => { + try { + const eventResponse = await fetch(`/api/event/events/${event.eventId}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.getToken()}`, + 'Content-Type': 'application/json', + }, + }); + if (eventResponse.ok) { + const eventData = await eventResponse.json(); + return { + eventId: event.eventId, + name: eventData.data?.name || `Event ${event.eventId.substring(0, 8)}`, + registrations: event.registrations, + attendance: event.attendancePercentage + }; + } + } catch (err) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to fetch event name', { eventId: event.eventId }); + } + return { + eventId: event.eventId, + name: `Event ${event.eventId.substring(0, 8)}`, + registrations: event.registrations, + attendance: event.attendancePercentage + }; + }) + ); + + logger.info(LOGGER_COMPONENT_NAME, 'Reports data retrieved successfully'); + + return { + totalEvents: dashboardStats.totalEvents, + totalUsers: dashboardStats.totalUsers, + totalRegistrations: dashboardStats.totalRegistrations, + averageAttendance: attendanceStats.attendancePercentage, + topEvents: topEventsWithNames, + eventStats: eventStatusResponse.data || [], + userGrowth: userGrowthResponse.data || [] + }; + } catch (error) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to fetch reports data', error as Error); + throw error; + } + } + // Override getToken to return string instead of string | null public getToken(): string { const token = super.getToken(); From 83bfa25613dfc339b1835430e9d949266c028c68 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 14:59:16 -0600 Subject: [PATCH 11/28] EMS-74: Replace hardcoded data in reports page with real database queries - Removed all mock data from reports page - Implemented real-time data fetching from backend APIs - Added loading states and error handling - Added empty states for when data is not available - Updated all metrics cards to display real data (Total Events, Users, Registrations, Attendance) - Top Performing Events now shows real events with actual registrations and attendance - Event Status Distribution displays real breakdown from database - User Growth Trend shows monthly user registration data with cumulative totals - All data now fetched from database instead of hardcoded values --- .../app/dashboard/admin/reports/page.tsx | 205 +++++++++++------- 1 file changed, 130 insertions(+), 75 deletions(-) diff --git a/ems-client/app/dashboard/admin/reports/page.tsx b/ems-client/app/dashboard/admin/reports/page.tsx index f0ca372..236fbaa 100644 --- a/ems-client/app/dashboard/admin/reports/page.tsx +++ b/ems-client/app/dashboard/admin/reports/page.tsx @@ -6,54 +6,74 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Badge } from "@/components/ui/badge"; import { ArrowLeft, BarChart3, TrendingUp, Users, Calendar, Download, Filter } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import {logger} from "@/lib/logger"; +import { adminApiClient } from "@/lib/api/admin.api"; const COMPONENT_NAME = 'ReportsPage'; -// Mock data for reports -const mockReportData = { - totalEvents: 8, - totalUsers: 156, - totalRegistrations: 342, - averageAttendance: 78, - topEvents: [ - { name: 'Tech Conference 2024', registrations: 156, attendance: 89 }, - { name: 'AI Summit', registrations: 142, attendance: 95 }, - { name: 'Design Workshop', registrations: 44, attendance: 67 } - ], - userGrowth: [ - { month: 'Oct 2023', users: 45 }, - { month: 'Nov 2023', users: 67 }, - { month: 'Dec 2023', users: 89 }, - { month: 'Jan 2024', users: 112 }, - { month: 'Feb 2024', users: 156 } - ], - eventStats: [ - { status: 'Published', count: 5, percentage: 62.5 }, - { status: 'Draft', count: 2, percentage: 25 }, - { status: 'Archived', count: 1, percentage: 12.5 } - ] -}; +interface ReportData { + totalEvents: number; + totalUsers: number; + totalRegistrations: number; + averageAttendance: number; + topEvents: Array<{ + eventId: string; + name?: string; + registrations: number; + attendance: number; + }>; + eventStats: Array<{ + status: string; + count: number; + percentage: number; + }>; + userGrowth: Array<{ + month: string; + users: number; + newUsers: number; + }>; +} export default function ReportsPage() { const { user, isAuthenticated, isLoading } = useAuth(); const router = useRouter(); + const [reportData, setReportData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { if (!isLoading && !isAuthenticated) { router.push('/login'); } else if (!isLoading && user?.role !== 'ADMIN') { router.push('/dashboard'); + } else if (isAuthenticated && user?.role === 'ADMIN') { + loadReportsData(); } }, [isAuthenticated, isLoading, user, router]); + const loadReportsData = async () => { + try { + setLoading(true); + setError(null); + logger.info(COMPONENT_NAME, 'Loading reports data'); + const data = await adminApiClient.getReportsData(); + setReportData(data); + logger.info(COMPONENT_NAME, 'Reports data loaded successfully'); + } catch (err) { + logger.error(COMPONENT_NAME, 'Failed to load reports data', err as Error); + setError('Failed to load reports data. Please try again later.'); + } finally { + setLoading(false); + } + }; + const handleExportReport = (type: string) => { // TODO: Implement report export functionality logger.debug(COMPONENT_NAME, `Exporting ${type} report`); }; - if (isLoading) { + if (isLoading || loading) { return (
@@ -68,6 +88,21 @@ export default function ReportsPage() { return null; } + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + if (!reportData) { + return null; + } + return (
{/* Header */} @@ -112,7 +147,7 @@ export default function ReportsPage() {

Total Events

-

{mockReportData.totalEvents}

+

{reportData.totalEvents}

@@ -124,7 +159,7 @@ export default function ReportsPage() {

Total Users

-

{mockReportData.totalUsers}

+

{reportData.totalUsers}

@@ -136,7 +171,7 @@ export default function ReportsPage() {

Total Registrations

-

{mockReportData.totalRegistrations}

+

{reportData.totalRegistrations}

@@ -148,7 +183,7 @@ export default function ReportsPage() {

Avg. Attendance

-

{mockReportData.averageAttendance}%

+

{reportData.averageAttendance.toFixed(1)}%

@@ -210,22 +245,28 @@ export default function ReportsPage() { -
- {mockReportData.topEvents.map((event, index) => ( -
-
-

{event.name}

-
- {event.registrations} registrations - {event.attendance}% attendance + {reportData.topEvents.length === 0 ? ( +
+

No events with registrations yet

+
+ ) : ( +
+ {reportData.topEvents.map((event, index) => ( +
+
+

{event.name || `Event ${event.eventId.substring(0, 8)}`}

+
+ {event.registrations} registrations + {event.attendance}% attendance +
+ + #{index + 1} +
- - #{index + 1} - -
- ))} -
+ ))} +
+ )} @@ -240,25 +281,31 @@ export default function ReportsPage() { -
- {mockReportData.eventStats.map((stat, index) => ( -
-
- {stat.status} - {stat.count} events -
-
-
-
-
- {stat.percentage}% + {reportData.eventStats.length === 0 ? ( +
+

No events found

+
+ ) : ( +
+ {reportData.eventStats.map((stat, index) => ( +
+
+ {stat.status} + {stat.count} events +
+
+
+
+
+ {stat.percentage.toFixed(1)}% +
-
- ))} -
+ ))} +
+ )}
@@ -289,25 +336,33 @@ export default function ReportsPage() { Month + Total Users New Users - Growth - {mockReportData.userGrowth.map((data, index) => ( - - {data.month} - {data.users} - - - +{data.users - (index > 0 ? mockReportData.userGrowth[index - 1].users : 0)} - + {reportData.userGrowth.length === 0 ? ( + + + No user growth data available - ))} + ) : ( + reportData.userGrowth.map((data, index) => ( + + {data.month} + {data.users} + + + +{data.newUsers} + + + + )) + )}
From 60e964b56bd5f8187f4c51187af4eaeab012acbf Mon Sep 17 00:00:00 2001 From: ashwin-athappan Date: Mon, 3 Nov 2025 15:06:41 -0600 Subject: [PATCH 12/28] EMS-69: Booking Service & Speaker Service Fix Related Fixes: 1. Recreated Migrations for the Booking Service 2. Recreated Migrations for Speaker Service 3. Created Migrations for Feedback Service --- .../20251003234701_init/migration.sql | 31 ------------- .../migration.sql | 7 --- .../migration.sql | 34 ++++++++++++++ .../20251103205856_init/migration.sql | 45 +++++++++++++++++++ .../prisma/migrations/migration_lock.toml | 3 ++ ems-services/speaker-service/.env.example | 8 ++++ ems-services/speaker-service/Dockerfile | 11 +++-- .../migration.sql | 0 .../prisma/migrations/migration_lock.toml | 2 +- 9 files changed, 98 insertions(+), 43 deletions(-) delete mode 100644 ems-services/booking-service/prisma/migrations/20251003234701_init/migration.sql delete mode 100644 ems-services/booking-service/prisma/migrations/20251028190000_add_attendance_tracking_fields/migration.sql rename ems-services/booking-service/prisma/migrations/{20251017001246_add_ticket_tables => 20251103205746_init}/migration.sql (69%) create mode 100644 ems-services/feedback-service/prisma/migrations/20251103205856_init/migration.sql create mode 100644 ems-services/feedback-service/prisma/migrations/migration_lock.toml create mode 100644 ems-services/speaker-service/.env.example rename ems-services/speaker-service/prisma/migrations/{20250101000000_initial_schema => 20251103205831_init}/migration.sql (100%) diff --git a/ems-services/booking-service/prisma/migrations/20251003234701_init/migration.sql b/ems-services/booking-service/prisma/migrations/20251003234701_init/migration.sql deleted file mode 100644 index 950ae02..0000000 --- a/ems-services/booking-service/prisma/migrations/20251003234701_init/migration.sql +++ /dev/null @@ -1,31 +0,0 @@ --- CreateEnum -CREATE TYPE "BookingStatus" AS ENUM ('CONFIRMED', 'CANCELLED'); - --- CreateTable -CREATE TABLE "events" ( - "id" TEXT NOT NULL, - "capacity" INTEGER NOT NULL, - "isActive" BOOLEAN NOT NULL DEFAULT true, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "events_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "bookings" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "eventId" TEXT NOT NULL, - "status" "BookingStatus" NOT NULL DEFAULT 'CONFIRMED', - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "bookings_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "bookings_userId_eventId_key" ON "bookings"("userId", "eventId"); - --- AddForeignKey -ALTER TABLE "bookings" ADD CONSTRAINT "bookings_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "events"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 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 deleted file mode 100644 index 33b2666..0000000 --- a/ems-services/booking-service/prisma/migrations/20251028190000_add_attendance_tracking_fields/migration.sql +++ /dev/null @@ -1,7 +0,0 @@ --- 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/migrations/20251017001246_add_ticket_tables/migration.sql b/ems-services/booking-service/prisma/migrations/20251103205746_init/migration.sql similarity index 69% rename from ems-services/booking-service/prisma/migrations/20251017001246_add_ticket_tables/migration.sql rename to ems-services/booking-service/prisma/migrations/20251103205746_init/migration.sql index d06a4b3..1a9f7e0 100644 --- a/ems-services/booking-service/prisma/migrations/20251017001246_add_ticket_tables/migration.sql +++ b/ems-services/booking-service/prisma/migrations/20251103205746_init/migration.sql @@ -1,9 +1,37 @@ +-- CreateEnum +CREATE TYPE "BookingStatus" AS ENUM ('CONFIRMED', 'CANCELLED'); + -- CreateEnum CREATE TYPE "TicketStatus" AS ENUM ('ISSUED', 'SCANNED', 'REVOKED', 'EXPIRED'); -- CreateEnum CREATE TYPE "ScanMethod" AS ENUM ('QR_CODE', 'MANUAL'); +-- CreateTable +CREATE TABLE "events" ( + "id" TEXT NOT NULL, + "capacity" INTEGER NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "bookings" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "status" "BookingStatus" NOT NULL DEFAULT 'CONFIRMED', + "joinedAt" TIMESTAMP(3), + "isAttended" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "bookings_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "tickets" ( "id" TEXT NOT NULL, @@ -44,6 +72,9 @@ CREATE TABLE "attendance_records" ( CONSTRAINT "attendance_records_pkey" PRIMARY KEY ("id") ); +-- CreateIndex +CREATE UNIQUE INDEX "bookings_userId_eventId_key" ON "bookings"("userId", "eventId"); + -- CreateIndex CREATE UNIQUE INDEX "tickets_bookingId_key" ON "tickets"("bookingId"); @@ -62,6 +93,9 @@ CREATE INDEX "attendance_records_ticketId_idx" ON "attendance_records"("ticketId -- CreateIndex CREATE INDEX "attendance_records_scanTime_idx" ON "attendance_records"("scanTime"); +-- AddForeignKey +ALTER TABLE "bookings" ADD CONSTRAINT "bookings_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "events"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "tickets" ADD CONSTRAINT "tickets_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "bookings"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/ems-services/feedback-service/prisma/migrations/20251103205856_init/migration.sql b/ems-services/feedback-service/prisma/migrations/20251103205856_init/migration.sql new file mode 100644 index 0000000..85dd0eb --- /dev/null +++ b/ems-services/feedback-service/prisma/migrations/20251103205856_init/migration.sql @@ -0,0 +1,45 @@ +-- CreateTable +CREATE TABLE "feedback_forms" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "isPublished" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "feedback_forms_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "feedback_responses" ( + "id" TEXT NOT NULL, + "formId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "rating" INTEGER NOT NULL, + "comment" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "feedback_responses_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "feedback_forms_eventId_key" ON "feedback_forms"("eventId"); + +-- CreateIndex +CREATE UNIQUE INDEX "feedback_responses_bookingId_key" ON "feedback_responses"("bookingId"); + +-- CreateIndex +CREATE INDEX "feedback_responses_formId_idx" ON "feedback_responses"("formId"); + +-- CreateIndex +CREATE INDEX "feedback_responses_userId_idx" ON "feedback_responses"("userId"); + +-- CreateIndex +CREATE INDEX "feedback_responses_eventId_idx" ON "feedback_responses"("eventId"); + +-- AddForeignKey +ALTER TABLE "feedback_responses" ADD CONSTRAINT "feedback_responses_formId_fkey" FOREIGN KEY ("formId") REFERENCES "feedback_forms"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/ems-services/feedback-service/prisma/migrations/migration_lock.toml b/ems-services/feedback-service/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/ems-services/feedback-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 (e.g., Git) +provider = "postgresql" diff --git a/ems-services/speaker-service/.env.example b/ems-services/speaker-service/.env.example new file mode 100644 index 0000000..002d7c1 --- /dev/null +++ b/ems-services/speaker-service/.env.example @@ -0,0 +1,8 @@ +PORT=3000 +APP_NAME=EVENTO +EVENT_APP_URL=http://localhost/api/event/ +CLIENT_URL=http://localhost + +DATABASE_URL= + +RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672 \ No newline at end of file diff --git a/ems-services/speaker-service/Dockerfile b/ems-services/speaker-service/Dockerfile index 2450c97..a8cb9e4 100644 --- a/ems-services/speaker-service/Dockerfile +++ b/ems-services/speaker-service/Dockerfile @@ -1,20 +1,22 @@ # Stage 1: Build -FROM node:20-bullseye-slim AS builder -RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* +FROM alpine:latest AS builder +RUN apk update && apk add nodejs npm && rm -rf /var/cache/apk/* 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 node:20-bullseye-slim AS runner -RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* +FROM alpine:latest AS runner +RUN apk update && apk add nodejs npm openssl && rm -rf /var/cache/apk/* WORKDIR /app ENV NODE_ENV=production @@ -30,6 +32,7 @@ COPY --from=builder /app/dist ./dist COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/generated ./generated + EXPOSE 3000 CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server.js"] diff --git a/ems-services/speaker-service/prisma/migrations/20250101000000_initial_schema/migration.sql b/ems-services/speaker-service/prisma/migrations/20251103205831_init/migration.sql similarity index 100% rename from ems-services/speaker-service/prisma/migrations/20250101000000_initial_schema/migration.sql rename to ems-services/speaker-service/prisma/migrations/20251103205831_init/migration.sql diff --git a/ems-services/speaker-service/prisma/migrations/migration_lock.toml b/ems-services/speaker-service/prisma/migrations/migration_lock.toml index 99e4f20..044d57c 100644 --- a/ems-services/speaker-service/prisma/migrations/migration_lock.toml +++ b/ems-services/speaker-service/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) +# It should be added in your version-control system (e.g., Git) provider = "postgresql" From 1ba1ce9071b06955a4eebda742d908bac9ab0bf9 Mon Sep 17 00:00:00 2001 From: ashwin-athappan Date: Mon, 3 Nov 2025 15:29:32 -0600 Subject: [PATCH 13/28] EMS-69: Speaker Join for Events 1. Aded Speaker Join on client-side --- .../app/dashboard/speaker/events/page.tsx | 32 +++++-- .../attendance/SimpleSpeakerJoin.tsx | 84 +++++++++---------- 2 files changed, 67 insertions(+), 49 deletions(-) diff --git a/ems-client/app/dashboard/speaker/events/page.tsx b/ems-client/app/dashboard/speaker/events/page.tsx index 57fc36c..7609c82 100644 --- a/ems-client/app/dashboard/speaker/events/page.tsx +++ b/ems-client/app/dashboard/speaker/events/page.tsx @@ -124,9 +124,9 @@ function SpeakerEventManagementPage() { 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 + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to load event for invitation', { + invitationId: invitation.id, + eventId: invitation.eventId }); } } @@ -488,8 +488,8 @@ function SpeakerEventManagementPage() { {/* Actions */}
-
+ {/* Event Join Interface - Show for all published events */} + {event.status === EventStatus.PUBLISHED && ( +
+ +
+ )} + ))} @@ -625,8 +643,8 @@ function SpeakerEventManagementPage() {
-
- + {isLoadingMaterials ? (
@@ -352,11 +352,11 @@ export const SimpleSpeakerJoin: React.FC = ({ className="pl-10" />
- + {/* Materials List */}
{availableMaterials - .filter(material => + .filter(material => material.fileName.toLowerCase().includes(searchTerm.toLowerCase()) ) .map((material) => ( @@ -366,7 +366,7 @@ export const SimpleSpeakerJoin: React.FC = ({ checked={selectedMaterials.includes(material.id)} onCheckedChange={() => 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 @@ -392,7 +392,7 @@ export const SimpleSpeakerJoin: React.FC = ({ )} )} - + {materialsMessage && !isLoadingMaterials && availableMaterials.length === 0 && (
@@ -418,20 +418,20 @@ export const SimpleSpeakerJoin: React.FC = ({

) : canJoin ? ( - )} - + {/* Join Status Message */} {joinMessage && (
= ({ {joinMessage}
)} - + {/* Material Selection Reminder */} {canJoin && !hasJoined && hasAcceptedInvitation && availableMaterials.length > 0 && selectedMaterials.length === 0 && (
@@ -462,7 +462,7 @@ export const SimpleSpeakerJoin: React.FC = ({ Please select at least one material to join this event
)} - + {/* No Materials Warning */} {canJoin && !hasJoined && hasAcceptedInvitation && availableMaterials.length === 0 && !isLoadingMaterials && (
From dc092a5a0dc02532aa32692b23d3aac4191f3c62 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 16:28:41 -0600 Subject: [PATCH 14/28] EMS-74: Add backend validation for event expiry in speaker attendance service --- .../services/speaker-attendance.service.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/ems-services/speaker-service/src/services/speaker-attendance.service.ts b/ems-services/speaker-service/src/services/speaker-attendance.service.ts index f47ecf8..73248dc 100644 --- a/ems-services/speaker-service/src/services/speaker-attendance.service.ts +++ b/ems-services/speaker-service/src/services/speaker-attendance.service.ts @@ -1,5 +1,6 @@ import { prisma } from '../database'; import { logger } from '../utils/logger'; +import axios from 'axios'; export interface SpeakerJoinEventRequest { speakerId: string; @@ -33,6 +34,37 @@ export interface SpeakerAttendanceResponse { } export class SpeakerAttendanceService { + 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 to check expiry + */ + private async getEventDetails(eventId: string): Promise<{ bookingEndDate: string } | null> { + 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 { + bookingEndDate: response.data.data.bookingEndDate + }; + } + return null; + } catch (error) { + logger.error('Failed to fetch event details from event service', error as Error, { eventId }); + return null; + } + } + /** * Speaker joins an event */ @@ -43,6 +75,27 @@ export class SpeakerAttendanceService { eventId: data.eventId }); + // Check if event has expired by fetching event details from event service + const eventDetails = await this.getEventDetails(data.eventId); + if (eventDetails) { + const eventEndDate = new Date(eventDetails.bookingEndDate); + const now = new Date(); + + if (now > eventEndDate) { + logger.warn('Speaker attempted to join expired event', { + speakerId: data.speakerId, + eventId: data.eventId, + eventEndDate: eventDetails.bookingEndDate, + currentTime: now.toISOString() + }); + return { + success: false, + message: 'Cannot join event: Event has already ended', + isFirstJoin: false + }; + } + } + // Find the invitation for this speaker and event const invitation = await prisma.speakerInvitation.findFirst({ where: { From 6c61bc12276b828fdb3796aaca7005b24d362be0 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 16:28:42 -0600 Subject: [PATCH 15/28] EMS-74: Add server-side search, filtering, and pagination for speaker invitations --- .../src/routes/invitation.routes.ts | 39 +++++++---- .../src/services/invitation.service.ts | 67 +++++++++++++++++-- 2 files changed, 87 insertions(+), 19 deletions(-) diff --git a/ems-services/speaker-service/src/routes/invitation.routes.ts b/ems-services/speaker-service/src/routes/invitation.routes.ts index b53de12..c68fc2a 100644 --- a/ems-services/speaker-service/src/routes/invitation.routes.ts +++ b/ems-services/speaker-service/src/routes/invitation.routes.ts @@ -88,11 +88,16 @@ router.get('/:id', async (req: Request, res: Response) => { } }); -// Get invitations for a speaker +// Get invitations for a speaker with search, filters, and pagination router.get('/speaker/:speakerId', async (req: Request, res: Response) => { try { const { speakerId } = req.params; - const { status } = req.query; + const { + status, + search, + page = 1, + limit = 20 + } = req.query; if (!speakerId) { return res.status(400).json({ @@ -102,24 +107,32 @@ router.get('/speaker/:speakerId', async (req: Request, res: Response) => { }); } - let invitations; - if (status === 'pending') { - invitations = await invitationService.getPendingInvitations(speakerId); - } else if (status === 'accepted') { - invitations = await invitationService.getAcceptedInvitations(speakerId); - } else { - invitations = await invitationService.getSpeakerInvitations(speakerId); - } + const result = await invitationService.getSpeakerInvitations(speakerId, { + search: search as string, + status: status as string, + page: Number(page), + limit: Number(limit) + }); logger.info('Speaker invitations retrieved', { speakerId, - count: invitations.length, - status: status || 'all' + count: result.invitations.length, + total: result.total, + page: result.page, + totalPages: result.totalPages }); return res.json({ success: true, - data: invitations, + data: result.invitations, + pagination: { + page: result.page, + limit: result.limit, + total: result.total, + totalPages: result.totalPages, + hasNextPage: result.page < result.totalPages, + hasPreviousPage: result.page > 1 + }, timestamp: new Date().toISOString() }); } catch (error) { diff --git a/ems-services/speaker-service/src/services/invitation.service.ts b/ems-services/speaker-service/src/services/invitation.service.ts index fb5cfc5..a895bd1 100644 --- a/ems-services/speaker-service/src/services/invitation.service.ts +++ b/ems-services/speaker-service/src/services/invitation.service.ts @@ -83,20 +83,75 @@ export class InvitationService { } /** - * Get invitations for a speaker + * Get invitations for a speaker with search, filters, and pagination */ - async getSpeakerInvitations(speakerId: string): Promise { + async getSpeakerInvitations( + speakerId: string, + filters?: { + search?: string; + status?: string; + page?: number; + limit?: number; + } + ): Promise<{ + invitations: SpeakerInvitation[]; + total: number; + page: number; + limit: number; + totalPages: number; + }> { try { - logger.debug('Retrieving speaker invitations', { speakerId }); + logger.debug('Retrieving speaker invitations', { speakerId, filters }); - const invitations = await prisma.speakerInvitation.findMany({ - where: { speakerId }, + const { search, status, page = 1, limit = 20 } = filters || {}; + const skip = (page - 1) * limit; + + // Build where clause + const where: any = { speakerId }; + + // Status filter + if (status && status !== 'ALL') { + where.status = status.toUpperCase(); + } + + // Fetch all invitations matching the status filter + // Note: Event search is not available in backend since Event is in a different service/database + // Search filtering by event name/description will be handled client-side + let allInvitations = await prisma.speakerInvitation.findMany({ + where, orderBy: { createdAt: 'desc' } }); - return invitations; + // If search is provided, we need to filter by event details + // Since Event is in a different service, we'll need to fetch event details + // For now, we'll apply search on eventId if it matches (simplified) + // Client-side can do more sophisticated search on event details + if (search) { + const searchLower = search.toLowerCase(); + // Simple search on eventId - client will handle full event name search + allInvitations = allInvitations.filter(inv => + inv.eventId.toLowerCase().includes(searchLower) + ); + } + + const total = allInvitations.length; + const totalPages = Math.ceil(total / limit); + + // Apply pagination + const paginatedInvitations = allInvitations.slice(skip, skip + limit); + + // Return invitations without event data (event details are fetched separately by frontend) + const invitations = paginatedInvitations; + + return { + invitations, + total, + page, + limit, + totalPages + }; } catch (error) { logger.error('Error retrieving speaker invitations', error as Error); throw error; From 15efca25af82a464e60252bbff90a8f7ef4b3f7b Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 16:28:44 -0600 Subject: [PATCH 16/28] EMS-74: Add server-side search and timeframe filtering for events --- .../event-service/src/routes/public.routes.ts | 8 +++-- .../src/services/event.service.ts | 34 +++++++++++++++++++ .../event-service/src/types/event.types.ts | 2 ++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/ems-services/event-service/src/routes/public.routes.ts b/ems-services/event-service/src/routes/public.routes.ts index 72ed25c..9fe5d46 100644 --- a/ems-services/event-service/src/routes/public.routes.ts +++ b/ems-services/event-service/src/routes/public.routes.ts @@ -21,6 +21,8 @@ router.get('/events', venueId, startDate, endDate, + search, + timeframe, page = 1, limit = 10 } = req.query; @@ -29,8 +31,10 @@ router.get('/events', status: EventStatus.PUBLISHED, category: category as string, venueId: venueId ? Number(venueId) : undefined, - startDate: startDate as string, - endDate: endDate as string, + bookingStartDate: startDate as string, + bookingEndDate: endDate as string, + search: search as string, + timeframe: timeframe as string, page: Number(page), limit: Number(limit) }; diff --git a/ems-services/event-service/src/services/event.service.ts b/ems-services/event-service/src/services/event.service.ts index b21d4e2..69aa970 100644 --- a/ems-services/event-service/src/services/event.service.ts +++ b/ems-services/event-service/src/services/event.service.ts @@ -609,6 +609,8 @@ class EventService { speakerId, bookingStartDate, bookingEndDate, + search, + timeframe, page = 1, limit = 10 } = filters; @@ -647,6 +649,38 @@ class EventService { } } + // Search filter - search by name, description, or venue name + if (search) { + where.OR = [ + { name: { contains: search, mode: 'insensitive' } }, + { description: { contains: search, mode: 'insensitive' } }, + { venue: { name: { contains: search, mode: 'insensitive' } } } + ]; + } + + // Timeframe filter + const now = new Date(); + if (timeframe && timeframe !== 'ALL') { + if (timeframe === 'UPCOMING') { + where.bookingStartDate = { + ...where.bookingStartDate, + gt: now + }; + } else if (timeframe === 'ONGOING') { + where.bookingStartDate = { + ...where.bookingStartDate, + lte: now + }; + where.bookingEndDate = { + gte: now + }; + } else if (timeframe === 'PAST') { + where.bookingEndDate = { + lt: now + }; + } + } + logger.info('getEvents() - Built query filters', {where, page, limit}); const [events, total] = await Promise.all([ diff --git a/ems-services/event-service/src/types/event.types.ts b/ems-services/event-service/src/types/event.types.ts index b7058d9..86b1dfc 100644 --- a/ems-services/event-service/src/types/event.types.ts +++ b/ems-services/event-service/src/types/event.types.ts @@ -61,6 +61,8 @@ export interface EventFilters { speakerId?: string; bookingStartDate?: string; bookingEndDate?: string; + search?: string; // Search by name, description, or venue name + timeframe?: string; // UPCOMING, ONGOING, PAST, ALL page?: number; limit?: number; } From c21fe627aaf12b9dcb2c262ce6f996a94efaae09 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 16:28:45 -0600 Subject: [PATCH 17/28] EMS-74: Update frontend API clients for search, filtering, and pagination --- ems-client/lib/api/booking.api.ts | 26 ++++++++++++++++ ems-client/lib/api/event.api.ts | 2 ++ ems-client/lib/api/speaker.api.ts | 40 +++++++++++++++++++++++-- ems-client/lib/api/types/event.types.ts | 2 ++ 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/ems-client/lib/api/booking.api.ts b/ems-client/lib/api/booking.api.ts index b23b5b2..80d7b92 100644 --- a/ems-client/lib/api/booking.api.ts +++ b/ems-client/lib/api/booking.api.ts @@ -72,6 +72,25 @@ class BookingApiClient extends BaseApiClient { method: 'PUT' }); } + + // Speaker methods + async getEventRegistrationCount(eventId: string): Promise<{ + eventId: string; + totalUsers: number; + confirmedBookings: number; + cancelledBookings: number; + }> { + const response = await this.request<{ + success: boolean; + data: { + eventId: string; + totalUsers: number; + confirmedBookings: number; + cancelledBookings: number; + }; + }>(`/${eventId}/num-registered`); + return response.data; + } } const bookingApiClient = new BookingApiClient(); @@ -133,3 +152,10 @@ export const adminTicketAPI = { */ revokeTicket: (ticketId: string) => bookingApiClient.revokeTicket(ticketId) }; + +export const speakerBookingAPI = { + /** + * Get registration count for an event (speaker only) + */ + getEventRegistrationCount: (eventId: string) => bookingApiClient.getEventRegistrationCount(eventId) +}; diff --git a/ems-client/lib/api/event.api.ts b/ems-client/lib/api/event.api.ts index 8b23c99..15c6683 100644 --- a/ems-client/lib/api/event.api.ts +++ b/ems-client/lib/api/event.api.ts @@ -40,6 +40,8 @@ class EventApiClient extends BaseApiClient { if (filters.venueId) queryParams.append('venueId', filters.venueId.toString()); if (filters.startDate) queryParams.append('startDate', filters.startDate); if (filters.endDate) queryParams.append('endDate', filters.endDate); + if (filters.search) queryParams.append('search', filters.search); + if (filters.timeframe) queryParams.append('timeframe', filters.timeframe); if (filters.page) queryParams.append('page', filters.page.toString()); if (filters.limit) queryParams.append('limit', filters.limit.toString()); } diff --git a/ems-client/lib/api/speaker.api.ts b/ems-client/lib/api/speaker.api.ts index 4c02dd5..ac33dfc 100644 --- a/ems-client/lib/api/speaker.api.ts +++ b/ems-client/lib/api/speaker.api.ts @@ -172,8 +172,32 @@ class SpeakerApiClient extends BaseApiClient { } // Invitation Management - use direct API calls to /api/invitations - async getSpeakerInvitations(speakerId: string, status?: string): Promise { - const url = status ? `/api/invitations/speaker/${speakerId}?status=${status}` : `/api/invitations/speaker/${speakerId}`; + async getSpeakerInvitations( + speakerId: string, + filters?: { + search?: string; + status?: string; + page?: number; + limit?: number; + } + ): Promise<{ + invitations: SpeakerInvitation[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + }> { + const queryParams = new URLSearchParams(); + if (filters?.search) queryParams.append('search', filters.search); + if (filters?.status) queryParams.append('status', filters.status); + if (filters?.page) queryParams.append('page', filters.page.toString()); + if (filters?.limit) queryParams.append('limit', filters.limit.toString()); + + const url = `/api/invitations/speaker/${speakerId}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; const response = await fetch(url, { method: 'GET', headers: { @@ -187,7 +211,17 @@ class SpeakerApiClient extends BaseApiClient { } const result = await response.json(); - return result.data || []; + return { + invitations: result.data || [], + pagination: result.pagination || { + page: 1, + limit: 20, + total: 0, + totalPages: 0, + hasNextPage: false, + hasPreviousPage: false + } + }; } async getPendingInvitations(speakerId: string): Promise { diff --git a/ems-client/lib/api/types/event.types.ts b/ems-client/lib/api/types/event.types.ts index f8f7787..19a3e2d 100644 --- a/ems-client/lib/api/types/event.types.ts +++ b/ems-client/lib/api/types/event.types.ts @@ -68,6 +68,8 @@ export interface EventFilters { speakerId?: string; startDate?: string; endDate?: string; + search?: string; // Search by name, description, or venue name + timeframe?: string; // UPCOMING, ONGOING, PAST, ALL page?: number; limit?: number; } From d02f9d2c46384c724116f881149398f7a23f4170 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 16:28:46 -0600 Subject: [PATCH 18/28] EMS-74: Add event expiry validation in LiveEventAuditorium component --- .../components/events/LiveEventAuditorium.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/ems-client/components/events/LiveEventAuditorium.tsx b/ems-client/components/events/LiveEventAuditorium.tsx index 1f2d98a..5cddeef 100644 --- a/ems-client/components/events/LiveEventAuditorium.tsx +++ b/ems-client/components/events/LiveEventAuditorium.tsx @@ -121,7 +121,24 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => { try { logger.info(LOGGER_COMPONENT_NAME, 'Loading event details', { eventId }); const eventResponse = await eventAPI.getEventById(eventId); - setEvent(eventResponse.data); + const event = eventResponse.data; + + // Check if event has expired + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + + if (now > eventEndDate) { + logger.warn(LOGGER_COMPONENT_NAME, 'Event has expired', { + eventId, + eventEndDate: event.bookingEndDate, + currentTime: now.toISOString() + }); + setError('This event has already ended. Live access is no longer available.'); + setEvent(event); // Still set event to show details + return; + } + + setEvent(event); setError(null); } catch (err) { logger.error(LOGGER_COMPONENT_NAME, 'Failed to load event', err as Error); From b1d0e770b135c6a437d2756efeb6d64deb5dbf4f Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 16:28:47 -0600 Subject: [PATCH 19/28] EMS-74: Implement event expiry checks, consistent UI, and remove expired event messages from speaker events page --- .../app/dashboard/speaker/events/page.tsx | 649 ++++++++++++------ 1 file changed, 427 insertions(+), 222 deletions(-) diff --git a/ems-client/app/dashboard/speaker/events/page.tsx b/ems-client/app/dashboard/speaker/events/page.tsx index 57fc36c..1381b21 100644 --- a/ems-client/app/dashboard/speaker/events/page.tsx +++ b/ems-client/app/dashboard/speaker/events/page.tsx @@ -8,27 +8,20 @@ import {Input} from "@/components/ui/input"; import { LogOut, Calendar, - Plus, Search, - Filter, Eye, - Edit, - Trash2, Users, Clock, MapPin, ArrowLeft, - MoreHorizontal, Play, - Pause, - Archive, AlertCircle, Mail, CheckCircle, XCircle } from "lucide-react"; import {useRouter} from "next/navigation"; -import {useEffect, useState} from "react"; +import {useEffect, useState, useRef, useCallback} from "react"; import {useLogger} from "@/lib/logger/LoggerProvider"; import { EventJoinInterface } from '@/components/attendance/EventJoinInterface'; @@ -36,6 +29,7 @@ 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"; +import {speakerBookingAPI} from "@/lib/api/booking.api"; const statusColors = { [EventStatus.DRAFT]: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', @@ -53,73 +47,169 @@ function SpeakerEventManagementPage() { const router = useRouter(); const logger = useLogger(); const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); const [selectedTimeframe, setSelectedTimeframe] = useState('ALL'); + const [selectedInvitationStatus, setSelectedInvitationStatus] = 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 [eventRegistrationCounts, setEventRegistrationCounts] = useState>(new Map()); + // Map eventId to invitation status for "All Events" tab + const [eventInvitationMap, setEventInvitationMap] = useState>(new Map()); + const [loading, setLoading] = useState(false); + const [initialLoad, setInitialLoad] = useState(true); const [error, setError] = useState(null); - const [actionLoading, setActionLoading] = useState(null); - const [pagination, setPagination] = useState({ + const [eventsPagination, setEventsPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 }); + const [invitationsPagination, setInvitationsPagination] = useState({ + page: 1, + limit: 20, + total: 0, + totalPages: 0, + hasNextPage: false, + hasPreviousPage: false + }); + + // Ref to maintain focus on search input + const searchInputRef = useRef(null); + const isSearchingRef = useRef(false); + + // Debounce search term + useEffect(() => { + if (searchTerm) { + isSearchingRef.current = true; + } + const timer = setTimeout(() => { + setDebouncedSearch(searchTerm); + if (activeTab === 'my-events') { + setEventsPagination(prev => ({ ...prev, page: 1 })); + } else { + setInvitationsPagination(prev => ({ ...prev, page: 1 })); + } + }, 500); + + return () => clearTimeout(timer); + }, [searchTerm, activeTab]); // Load events from API - Show all published events in "All Events" tab - const loadEvents = async () => { + const loadEvents = useCallback(async () => { try { setLoading(true); setError(null); const filters: EventFilters = { - page: pagination.page, - limit: pagination.limit, - // Note: Published events API only returns PUBLISHED events, so status filter is not needed + search: debouncedSearch || undefined, + timeframe: selectedTimeframe !== 'ALL' ? selectedTimeframe : undefined, + page: eventsPagination.page, + limit: eventsPagination.limit, }; logger.debug(LOGGER_COMPONENT_NAME, 'Loading all published events with filters', 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); - setPagination(prev => ({ + setEventsPagination(prev => ({ ...prev, total: response.data.total, totalPages: response.data.totalPages })); logger.debug(LOGGER_COMPONENT_NAME, 'All events loaded successfully', { count: response.data.events.length }); + + // Fetch registration counts for all events + const countsMap = new Map(); + await Promise.all( + response.data.events.map(async (event) => { + try { + const registrationData = await speakerBookingAPI.getEventRegistrationCount(event.id); + countsMap.set(event.id, registrationData.totalUsers); + } catch (err) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to load registration count for event', { + eventId: event.id, + error: err instanceof Error ? err.message : String(err) + }); + countsMap.set(event.id, 0); // Default to 0 if fetch fails + } + }) + ); + setEventRegistrationCounts(countsMap); + + // Load speaker's invitations to check which events they've accepted + if (user?.id) { + try { + const speakerProfile = await speakerApiClient.getSpeakerProfile(user.id); + const invitationResult = await speakerApiClient.getSpeakerInvitations(speakerProfile.id, { + page: 1, + limit: 1000 // Get all invitations to map them + }); + + const invitationMap = new Map(); + invitationResult.invitations.forEach(invitation => { + invitationMap.set(invitation.eventId, invitation); + }); + setEventInvitationMap(invitationMap); + } catch (err) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to load invitations for event mapping', err instanceof Error ? err : new Error(String(err))); + } + } } else { throw new Error('Failed to load events'); } + + // Mark initial load as complete + if (initialLoad) { + setInitialLoad(false); + } + + // Restore focus to search input after loading if user was searching + if (isSearchingRef.current && searchInputRef.current) { + requestAnimationFrame(() => { + if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }); + } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to load events'; setError(errorMessage); logger.error(LOGGER_COMPONENT_NAME, 'Failed to load events', err instanceof Error ? err : new Error(String(err))); + if (initialLoad) { + setInitialLoad(false); + } } finally { setLoading(false); } - }; + }, [debouncedSearch, selectedTimeframe, eventsPagination.page, eventsPagination.limit, initialLoad, user?.id]); // Load invitations and their events - const loadInvitations = async () => { + const loadInvitations = useCallback(async () => { if (!user?.id) return; try { setLoading(true); + setError(null); const speakerProfile = await speakerApiClient.getSpeakerProfile(user.id); - const allInvitations = await speakerApiClient.getSpeakerInvitations(speakerProfile.id); - setInvitations(allInvitations); + const result = await speakerApiClient.getSpeakerInvitations(speakerProfile.id, { + search: debouncedSearch || undefined, + status: selectedInvitationStatus !== 'ALL' ? selectedInvitationStatus : undefined, + page: invitationsPagination.page, + limit: invitationsPagination.limit + }); + + setInvitations(result.invitations); + setInvitationsPagination(result.pagination); // Load event details for each invitation const eventMap = new Map(); - for (const invitation of allInvitations) { + for (const invitation of result.invitations) { try { const eventResponse = await eventAPI.getEventById(invitation.eventId); eventMap.set(invitation.eventId, eventResponse.data); @@ -131,12 +221,29 @@ function SpeakerEventManagementPage() { } } setInvitedEvents(eventMap); + + // Mark initial load as complete + if (initialLoad) { + setInitialLoad(false); + } + + // Restore focus to search input after loading if user was searching + if (isSearchingRef.current && searchInputRef.current) { + requestAnimationFrame(() => { + if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }); + } } catch (err) { logger.error(LOGGER_COMPONENT_NAME, 'Failed to load invitations', err instanceof Error ? err : new Error(String(err))); + if (initialLoad) { + setInitialLoad(false); + } } finally { setLoading(false); } - }; + }, [user?.id, debouncedSearch, selectedInvitationStatus, invitationsPagination.page, invitationsPagination.limit, initialLoad]); useEffect(() => { if (activeTab === 'my-events') { @@ -144,56 +251,20 @@ function SpeakerEventManagementPage() { } else { loadInvitations(); } - }, [pagination.page, activeTab]); - - // Filter events based on search and timeframe (status filtering is done server-side) - const filteredEvents = events.filter(event => { - const matchesSearch = event.name.toLowerCase().includes(searchTerm.toLowerCase()) || - event.description.toLowerCase().includes(searchTerm.toLowerCase()) || - event.venue.name.toLowerCase().includes(searchTerm.toLowerCase()); - - const now = new Date(); - const eventStart = new Date(event.bookingStartDate); - const matchesTimeframe = selectedTimeframe === 'ALL' || - (selectedTimeframe === 'UPCOMING' && eventStart > now) || - (selectedTimeframe === 'ONGOING' && eventStart <= now && new Date(event.bookingEndDate) >= now) || - (selectedTimeframe === 'PAST' && new Date(event.bookingEndDate) < now); - - return matchesSearch && matchesTimeframe; - }); + }, [activeTab, loadEvents, loadInvitations]); - const handleEventAction = async (eventId: string, action: string) => { - try { - setActionLoading(eventId); - logger.debug(LOGGER_COMPONENT_NAME, `Event ${eventId} action: ${action}`); - - let response; - switch (action) { - case 'submit': - response = await eventAPI.submitEvent(eventId); - break; - case 'delete': - response = await eventAPI.deleteEvent(eventId); - break; - default: - throw new Error(`Unknown action: ${action}`); - } + // Update when filters change + useEffect(() => { + if (activeTab === 'my-events') { + loadEvents(); + } + }, [debouncedSearch, selectedTimeframe, eventsPagination.page, loadEvents]); - if (response.success) { - logger.info(LOGGER_COMPONENT_NAME, `Event ${eventId} ${action} successful`); - // Reload events to reflect changes - await loadEvents(); - } else { - throw new Error(`Failed to ${action} event`); - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : `Failed to ${action} event`; - setError(errorMessage); - logger.error(LOGGER_COMPONENT_NAME, `Failed to ${action} event ${eventId}`, err instanceof Error ? err : new Error(String(err))); - } finally { - setActionLoading(null); + useEffect(() => { + if (activeTab === 'invited-events') { + loadInvitations(); } - }; + }, [debouncedSearch, selectedInvitationStatus, invitationsPagination.page, loadInvitations]); const getRegistrationPercentage = (registered: number, capacity: number) => { if (capacity <= 0) { @@ -202,16 +273,52 @@ function SpeakerEventManagementPage() { return Math.round((registered / capacity) * 100); }; - // Calculate stats from real data (all events shown are published) - const stats = { - total: events.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 - }; + // Calculate stats from invitations data + const [invitationStats, setInvitationStats] = useState({ + total: 0, + pending: 0, + accepted: 0, + upcoming: 0 + }); + + // Load invitation stats + useEffect(() => { + const loadInvitationStats = async () => { + if (!user?.id) return; + + try { + const speakerProfile = await speakerApiClient.getSpeakerProfile(user.id); + const stats = await speakerApiClient.getInvitationStats(speakerProfile.id); + + // Count upcoming accepted events + let upcomingCount = 0; + const now = new Date(); + for (const invitation of invitations) { + if (invitation.status === 'ACCEPTED') { + const event = invitedEvents.get(invitation.eventId); + if (event && new Date(event.bookingStartDate) > now) { + upcomingCount++; + } + } + } + + setInvitationStats({ + total: stats.total, + pending: stats.pending, + accepted: stats.accepted, + upcoming: upcomingCount + }); + } catch (err) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load invitation stats', err instanceof Error ? err : new Error(String(err))); + } + }; + + if (user?.id) { + loadInvitationStats(); + } + }, [user?.id, invitations, invitedEvents]); - if (loading) { + if (initialLoad && loading) { return (
@@ -260,7 +367,7 @@ function SpeakerEventManagementPage() {
- -
@@ -340,10 +439,10 @@ function SpeakerEventManagementPage() {
- +
-

Total Events

-

{stats.total}

+

Total Invitations

+

{invitationStats.total}

@@ -352,10 +451,10 @@ function SpeakerEventManagementPage() {
- +
-

Published

-

{stats.published}

+

Pending

+

{invitationStats.pending}

@@ -364,10 +463,10 @@ function SpeakerEventManagementPage() {
- +
-

Draft

-

{stats.draft}

+

Accepted

+

{invitationStats.accepted}

@@ -376,10 +475,10 @@ function SpeakerEventManagementPage() {
- +
-

Pending

-

{stats.pending}

+

Upcoming

+

{invitationStats.upcoming}

@@ -398,55 +497,92 @@ function SpeakerEventManagementPage() {
setSearchTerm(e.target.value)} className="pl-10" />
- + {activeTab === 'my-events' ? ( + + ) : ( + + )}
{/* Events Grid - My Events */} {activeTab === 'my-events' && ( -
- {filteredEvents.map((event) => ( +
+ {loading && !initialLoad && ( +
+
+
+ )} +
+ {events.map((event) => { + const invitation = eventInvitationMap.get(event.id); + const isAccepted = invitation?.status === 'ACCEPTED'; + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + const isEventEnded = now > eventEndDate; + + return (
- - {event.name} - - - - {event.status.replace('_', ' ')} - +
+ {isAccepted && } + + {event.name} + +
+
+ + {event.status.replace('_', ' ')} + + {invitation && ( + + {invitation.status} + + )} + {isEventEnded && ( + + ENDED + + )} +
-
@@ -472,8 +608,8 @@ function SpeakerEventManagementPage() {
- {15}/{event.venue.capacity} registered - ({getRegistrationPercentage(15, event.venue.capacity)}%) + {eventRegistrationCounts.get(event.id) ?? 0}/{event.venue.capacity} registered + ({getRegistrationPercentage(eventRegistrationCounts.get(event.id) ?? 0, event.venue.capacity)}%)
@@ -482,7 +618,7 @@ function SpeakerEventManagementPage() {
@@ -497,51 +633,46 @@ function SpeakerEventManagementPage() { View Details - {/* 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 && ( - - )} - + {isAccepted && event.status === EventStatus.PUBLISHED && ( + )}
+ {isAccepted && event.status === EventStatus.PUBLISHED && !isEventEnded && ( +
+ +
+ )} + - ))} + ); + })} - {filteredEvents.length === 0 && !loading && ( + {events.length === 0 && !loading && (
@@ -552,23 +683,26 @@ function SpeakerEventManagementPage() {

No events match your search criteria.

- +

+ Check the "Invited Events" tab to see your invitations. +

)} +
)} {/* Invited Events Grid */} {activeTab === 'invited-events' && ( -
+
+ {loading && !initialLoad && ( +
+
+
+ )} +
{invitations.map((invitation) => { const event = invitedEvents.get(invitation.eventId); if (!event) return null; @@ -597,12 +731,24 @@ function SpeakerEventManagementPage() { {event.name}
- - {event.status.replace('_', ' ')} - - - {invitation.status} - +
+ + {event.status.replace('_', ' ')} + + + {invitation.status} + + {(() => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + const isEventEnded = now > eventEndDate; + return isEventEnded ? ( + + ENDED + + ) : null; + })()} +
@@ -634,35 +780,55 @@ function SpeakerEventManagementPage() { View Details - {invitation.status === 'ACCEPTED' && event.status === EventStatus.PUBLISHED && ( - - )} + {invitation.status === 'ACCEPTED' && event.status === EventStatus.PUBLISHED && (() => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + const isEventEnded = now > eventEndDate; + + return ( + + ); + })()} - {invitation.status === 'ACCEPTED' && event.status === EventStatus.PUBLISHED && ( -
- -
- )} + {invitation.status === 'ACCEPTED' && event.status === EventStatus.PUBLISHED && (() => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + const isEventEnded = now > eventEndDate; + + if (!isEventEnded) { + return ( +
+ +
+ ); + } + + return null; + })()}
); @@ -683,11 +849,50 @@ function SpeakerEventManagementPage() { )} + )} {/* Pagination */} - {pagination.totalPages > 1 && ( + {activeTab === 'my-events' && eventsPagination.total > 0 && ( +
+ + +
+ + + + Page {eventsPagination.page} of {Math.max(1, eventsPagination.totalPages)} + + + +
+ +
+ + Showing {events.length} of {eventsPagination.total} events + +
+
+
+
+ )} + + {activeTab === 'invited-events' && invitationsPagination.total > 0 && (
@@ -695,21 +900,21 @@ function SpeakerEventManagementPage() { - Page {pagination.page} of {pagination.totalPages} + Page {invitationsPagination.page} of {Math.max(1, invitationsPagination.totalPages)} @@ -717,7 +922,7 @@ function SpeakerEventManagementPage() {
- Showing {events.length} of {pagination.total} events + Showing {invitations.length} of {invitationsPagination.total} invitations
From c11285ff0d03d344966c34740a8dfc5ab95e8397 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 16:28:48 -0600 Subject: [PATCH 20/28] EMS-74: Update speaker components and hooks to use new paginated invitation API --- ems-client/components/attendance/SimpleSpeakerJoin.tsx | 3 ++- ems-client/hooks/useSpeakerData.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ems-client/components/attendance/SimpleSpeakerJoin.tsx b/ems-client/components/attendance/SimpleSpeakerJoin.tsx index 269fb0e..7705eff 100644 --- a/ems-client/components/attendance/SimpleSpeakerJoin.tsx +++ b/ems-client/components/attendance/SimpleSpeakerJoin.tsx @@ -108,7 +108,8 @@ export const SimpleSpeakerJoin: React.FC = ({ const speakerProfile = await speakerApiClient.getSpeakerProfile(speakerId); // Get all invitations for this speaker - const invitations = await speakerApiClient.getSpeakerInvitations(speakerProfile.id); + const result = await speakerApiClient.getSpeakerInvitations(speakerProfile.id); + const invitations = result.invitations; // Check if there's an accepted invitation for this event const acceptedInvitation = invitations.find( diff --git a/ems-client/hooks/useSpeakerData.ts b/ems-client/hooks/useSpeakerData.ts index e6e44fe..55f8048 100644 --- a/ems-client/hooks/useSpeakerData.ts +++ b/ems-client/hooks/useSpeakerData.ts @@ -85,8 +85,8 @@ export function useSpeakerData() { const loadAllInvitations = useCallback(async (speakerId: string) => { try { logger.debug(LOGGER_COMPONENT_NAME, 'Loading all invitations', { speakerId }); - const invitations = await speakerApiClient.getSpeakerInvitations(speakerId); - return invitations; + const result = await speakerApiClient.getSpeakerInvitations(speakerId); + return result.invitations; } catch (error) { logger.error(LOGGER_COMPONENT_NAME, 'Failed to load all invitations', error as Error, { speakerId }); throw error; From bfa7f97b732cc91f81edf4ae4edf363f1c728de0 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 16:53:44 -0600 Subject: [PATCH 21/28] EMS-74: Add visual separation between available and past events in admin dashboard --- .../app/dashboard/admin/events/page.tsx | 137 +++++++++++++++++- 1 file changed, 135 insertions(+), 2 deletions(-) diff --git a/ems-client/app/dashboard/admin/events/page.tsx b/ems-client/app/dashboard/admin/events/page.tsx index d5a362d..2c5a294 100644 --- a/ems-client/app/dashboard/admin/events/page.tsx +++ b/ems-client/app/dashboard/admin/events/page.tsx @@ -409,8 +409,28 @@ function EventManagementPage() {
{/* Events Grid */} -
- {filteredEvents.map((event) => ( +
+ {/* Available Events */} + {filteredEvents.filter(event => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return eventEndDate >= now && event.status !== EventStatus.CANCELLED && event.status !== EventStatus.COMPLETED; + }).length > 0 && ( +
+

+ + Available Events ({filteredEvents.filter(event => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return eventEndDate >= now && event.status !== EventStatus.CANCELLED && event.status !== EventStatus.COMPLETED; + }).length}) +

+
+ {filteredEvents.filter(event => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return eventEndDate >= now && event.status !== EventStatus.CANCELLED && event.status !== EventStatus.COMPLETED; + }).map((event) => (
@@ -549,6 +569,119 @@ function EventManagementPage() { ))} +
+
+ )} + + {/* Past Events */} + {filteredEvents.filter(event => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return eventEndDate < now || event.status === EventStatus.CANCELLED || event.status === EventStatus.COMPLETED; + }).length > 0 && ( +
+

+ + Past Events ({filteredEvents.filter(event => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return eventEndDate < now || event.status === EventStatus.CANCELLED || event.status === EventStatus.COMPLETED; + }).length}) +

+
+ {filteredEvents.filter(event => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return eventEndDate < now || event.status === EventStatus.CANCELLED || event.status === EventStatus.COMPLETED; + }).map((event) => ( + + +
+
+ + {event.name} + + + + {event.status.replace('_', ' ')} + +
+ +
+
+ + +

+ {event.description} +

+ + {/* Event Details */} +
+
+ + {event.venue.name} +
+ +
+ + + {new Date(event.bookingStartDate).toLocaleDateString()} - {new Date(event.bookingEndDate).toLocaleDateString()} + +
+ +
+ + + Capacity: {event.venue.capacity} + +
+
+ + {/* Actions */} +
+ + + + + +
+
+
+ ))} +
+
+ )} {filteredEvents.length === 0 && !loading && (
From 01bcae1ba56fd8a88901d1978602c2fc0a1fdcbb Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 16:53:48 -0600 Subject: [PATCH 22/28] EMS-74: Add visual separation between available and past events in speaker dashboard --- .../app/dashboard/speaker/events/page.tsx | 176 +++++++++++++++++- 1 file changed, 170 insertions(+), 6 deletions(-) diff --git a/ems-client/app/dashboard/speaker/events/page.tsx b/ems-client/app/dashboard/speaker/events/page.tsx index 1381b21..e1e08cd 100644 --- a/ems-client/app/dashboard/speaker/events/page.tsx +++ b/ems-client/app/dashboard/speaker/events/page.tsx @@ -546,8 +546,28 @@ function SpeakerEventManagementPage() {
)} -
- {events.map((event) => { +
+ {/* Available Events */} + {events.filter(event => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return now <= eventEndDate; + }).length > 0 && ( +
+

+ + Available Events ({events.filter(event => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return now <= eventEndDate; + }).length}) +

+
+ {events.filter(event => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return now <= eventEndDate; + }).map((event) => { const invitation = eventInvitationMap.get(event.id); const isAccepted = invitation?.status === 'ACCEPTED'; const now = new Date(); @@ -670,7 +690,122 @@ function SpeakerEventManagementPage() { ); - })} + })} +
+
+ )} + + {/* Past Events */} + {events.filter(event => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return now > eventEndDate; + }).length > 0 && ( +
+

+ + Past Events ({events.filter(event => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return now > eventEndDate; + }).length}) +

+
+ {events.filter(event => { + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return now > eventEndDate; + }).map((event) => { + const invitation = eventInvitationMap.get(event.id); + const isAccepted = invitation?.status === 'ACCEPTED'; + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + const isEventEnded = now > eventEndDate; + + return ( + + +
+
+
+ {isAccepted && } + + {event.name} + +
+
+ + {event.status.replace('_', ' ')} + + {invitation && ( + + {invitation.status} + + )} + + ENDED + +
+
+
+
+ + +

+ {event.description} +

+ + {/* Event Details */} +
+
+ + {event.venue.name} +
+ +
+ + + {new Date(event.bookingStartDate).toLocaleDateString()} - {new Date(event.bookingEndDate).toLocaleDateString()} + +
+ +
+ + + {eventRegistrationCounts.get(event.id) ?? 0}/{event.venue.capacity} registered + ({getRegistrationPercentage(eventRegistrationCounts.get(event.id) ?? 0, event.venue.capacity)}%) + +
+
+ + {/* Progress Bar */} +
+
+
+ + {/* Actions */} +
+ +
+
+
+ ); + })} +
+
+ )} {events.length === 0 && !loading && (
@@ -702,8 +837,34 @@ function SpeakerEventManagementPage() {
)} -
- {invitations.map((invitation) => { +
+ {/* Available Invitations */} + {invitations.filter(invitation => { + const event = invitedEvents.get(invitation.eventId); + if (!event) return false; + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return now <= eventEndDate; + }).length > 0 && ( +
+

+ + Available Events ({invitations.filter(invitation => { + const event = invitedEvents.get(invitation.eventId); + if (!event) return false; + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return now <= eventEndDate; + }).length}) +

+
+ {invitations.filter(invitation => { + const event = invitedEvents.get(invitation.eventId); + if (!event) return false; + const now = new Date(); + const eventEndDate = new Date(event.bookingEndDate); + return now <= eventEndDate; + }).map((invitation) => { const event = invitedEvents.get(invitation.eventId); if (!event) return null; @@ -832,7 +993,10 @@ function SpeakerEventManagementPage() { ); - })} + })} +
+
+ )} {invitations.length === 0 && !loading && (
From 782d225ab3302364417f7103c81cb527018bc4f4 Mon Sep 17 00:00:00 2001 From: Buffden Date: Mon, 3 Nov 2025 16:53:52 -0600 Subject: [PATCH 23/28] EMS-74: Simplify attendee event cards and remove repetitive elements, fix hardcoded data --- .../app/dashboard/attendee/events/page.tsx | 334 ++++++--------- ems-client/app/dashboard/attendee/page.tsx | 401 ++++++++++++------ ems-client/lib/api/types/booking.types.ts | 14 + 3 files changed, 413 insertions(+), 336 deletions(-) diff --git a/ems-client/app/dashboard/attendee/events/page.tsx b/ems-client/app/dashboard/attendee/events/page.tsx index 5ab7b89..bc88ccf 100644 --- a/ems-client/app/dashboard/attendee/events/page.tsx +++ b/ems-client/app/dashboard/attendee/events/page.tsx @@ -16,20 +16,18 @@ import { Filter, Calendar, MapPin, - Users, Clock, CheckCircle, AlertCircle, Loader2, - Star, Eye, - Ticket + Ticket, + Play } from 'lucide-react'; const LOGGER_COMPONENT_NAME = 'AttendeeEventsPage'; import { EventResponse } from '@/lib/api/types/event.types'; -import { EventJoinInterface } from '@/components/attendance/EventJoinInterface'; interface Event extends EventResponse {} @@ -535,152 +533,110 @@ export default function AttendeeEventsPage() { const isBookedSuccess = bookingStatus[event.id] === 'success'; return ( - - -
+ + +
- {event.name} - -
-
- {isBooked && ( - - - BOOKED - - )} - {isEventExpired(event) ? ( - - EXPIRED - - ) : isEventRunning(event) ? ( - - - LIVE - - ) : isEventUpcoming(event) ? ( - - - UPCOMING - - ) : ( - - {event.status} - - )} + + {event.name} + +
+ {isBooked && ( + + BOOKED + + )} + {isEventExpired(event) ? ( + + ENDED + + ) : isEventRunning(event) ? ( + + LIVE + + ) : isEventUpcoming(event) ? ( + + UPCOMING + + ) : null} +
- - - {eventTime.date} -
- - {/* Description */} -

+ +

{event.description}

- + {/* Event Details */} -
-
- - Time: - {eventTime.time} -
- -
- - Venue: +
+
+ {event.venue.name}
- -
- - Capacity: - {event.venue.capacity} people -
- -
- - Category: - {event.category} +
+ + {eventTime.time}
- {/* Booking Status */} - {isBooked && ( -
-
- - You have a ticket for this event! -
-
- )} - - {/* Booking Button */} - + {isEventExpired(event) ? ( - <> - + ) : isBooked ? ( - <> - - Already Booked ✓ - + ) : ( - <> - - Book Event - + )} - - - {/* Event Join Interface - Only show for booked events */} - {isBooked && ( -
- -
- )} +
); @@ -702,92 +658,64 @@ export default function AttendeeEventsPage() { const isBooked = userBookings[event.id]; return ( - - -
+ + + +
- {event.name} - -
-
- {isBooked && ( - - - ATTENDED + + {event.name} + +
+ {isBooked && ( + + ATTENDED + + )} + + ENDED - )} - - EXPIRED - +
- - - {eventTime.date} -
- - {/* Description */} -

+ +

{event.description}

{/* Event Details */} -
-
- - Time: - {eventTime.time} -
- -
- - Venue: +
+
+ {event.venue.name}
- -
- - Capacity: - {event.venue.capacity} people -
- -
- - Category: - {event.category} +
+ + {eventTime.time}
- {/* Booking Status */} - {isBooked && ( -
-
- - You attended this event! -
-
- )} - - {/* Disabled Button for Expired Events */} - + {/* Actions */} +
+ + +
); diff --git a/ems-client/app/dashboard/attendee/page.tsx b/ems-client/app/dashboard/attendee/page.tsx index f068e23..2d9be0d 100644 --- a/ems-client/app/dashboard/attendee/page.tsx +++ b/ems-client/app/dashboard/attendee/page.tsx @@ -21,85 +21,189 @@ import { Users } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import {useLogger} from "@/lib/logger/LoggerProvider"; import {withUserAuth} from "@/components/hoc/withAuth"; - -// Mock data for development -const mockStats = { - registeredEvents: 8, - upcomingEvents: 3, - attendedEvents: 5, - ticketsPurchased: 12, - activeTickets: 4, - usedTickets: 8, - pointsEarned: 1250, - pointsThisMonth: 300, - upcomingThisWeek: 2, - nextWeekEvents: 1 -}; - -const mockUpcomingEvents = [ - { - id: '1', - title: 'TechConf 2024', - date: '2024-01-15', - time: '9:00 AM', - location: 'Convention Center', - attendees: 500, - status: 'registered', - ticketType: 'VIP Pass' - }, - { - id: '2', - title: 'React Workshop', - date: '2024-01-18', - time: '2:00 PM', - location: 'Tech Hub', - attendees: 50, - status: 'registered', - ticketType: 'Standard' - }, - { - id: '3', - title: 'Design Thinking Summit', - date: '2024-01-22', - time: '10:00 AM', - location: 'Innovation Lab', - attendees: 200, - status: 'interested', - ticketType: null - } -]; - -const mockRecentRegistrations = [ - { - id: '1', - event: 'DevSummit 2024', - date: '2024-01-10', - status: 'confirmed', - ticketType: 'Early Bird' - }, - { - id: '2', - event: 'UX Conference', - date: '2024-01-08', - status: 'confirmed', - ticketType: 'Standard' - } -]; +import { bookingAPI, ticketAPI } from "@/lib/api/booking.api"; +import { eventAPI } from "@/lib/api/event.api"; const LOGGER_COMPONENT_NAME = 'AttendeeDashboard'; +interface DashboardStats { + registeredEvents: number; + upcomingEvents: number; + attendedEvents: number; + ticketsPurchased: number; + activeTickets: number; + usedTickets: number; + upcomingThisWeek: number; + nextWeekEvents: number; +} + +interface UpcomingEvent { + id: string; + title: string; + date: string; + time: string; + location: string; + status: string; + ticketType: string | null; +} + +interface RecentRegistration { + id: string; + event: string; + date: string; + status: string; + ticketType: string | null; +} + function AttendeeDashboard() { const { user, logout } = useAuth(); const router = useRouter(); const logger = useLogger(); + + const [stats, setStats] = useState({ + registeredEvents: 0, + upcomingEvents: 0, + attendedEvents: 0, + ticketsPurchased: 0, + activeTickets: 0, + usedTickets: 0, + upcomingThisWeek: 0, + nextWeekEvents: 0 + }); + const [upcomingEvents, setUpcomingEvents] = useState([]); + const [recentRegistrations, setRecentRegistrations] = useState([]); + const [loading, setLoading] = useState(true); useEffect(() => { logger.debug(LOGGER_COMPONENT_NAME, 'Attendee dashboard loaded', { userRole: user?.role }); + if (user?.id) { + loadDashboardData(); + } }, [user, logger]); + const loadDashboardData = async () => { + try { + setLoading(true); + logger.info(LOGGER_COMPONENT_NAME, 'Loading attendee dashboard data'); + + // Fetch bookings and tickets in parallel + const [bookingsResponse, ticketsResponse] = await Promise.all([ + bookingAPI.getUserBookings(), + ticketAPI.getUserTickets().catch(() => ({ data: [], success: true })) + ]); + + // Handle both response structures: {success, data: {bookings}} or {bookings} + const bookings = (bookingsResponse as any).data?.bookings || (bookingsResponse as any).bookings || []; + const tickets = ticketsResponse.data || []; + + // Calculate stats + const now = new Date(); + const oneWeekFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); + + const confirmedBookings = bookings.filter((b: any) => b.status === 'CONFIRMED'); + const attendedCount = bookings.filter((b: any) => b.isAttended === true).length; + + const upcomingBookings = confirmedBookings.filter((b: any) => { + if (!b.event?.bookingStartDate) return false; + const eventStart = new Date(b.event.bookingStartDate); + return eventStart > now; + }); + + const thisWeekBookings = upcomingBookings.filter((b: any) => { + if (!b.event?.bookingStartDate) return false; + const eventStart = new Date(b.event.bookingStartDate); + return eventStart <= oneWeekFromNow; + }); + + const nextWeekBookings = upcomingBookings.filter((b: any) => { + if (!b.event?.bookingStartDate) return false; + const eventStart = new Date(b.event.bookingStartDate); + return eventStart > oneWeekFromNow && eventStart <= twoWeeksFromNow; + }); + + const activeTickets = tickets.filter(t => t.status === 'ISSUED' && new Date(t.expiresAt) > now).length; + const usedTickets = tickets.filter(t => t.status === 'SCANNED').length; + + setStats({ + registeredEvents: confirmedBookings.length, + upcomingEvents: upcomingBookings.length, + attendedEvents: attendedCount, + ticketsPurchased: tickets.length, + activeTickets, + usedTickets, + upcomingThisWeek: thisWeekBookings.length, + nextWeekEvents: nextWeekBookings.length + }); + + // Fetch upcoming events with event details + // For events without full details, fetch them separately + const upcomingEventsData: UpcomingEvent[] = []; + for (const booking of upcomingBookings.slice(0, 3)) { + if (booking.event?.name) { + const eventStart = new Date(booking.event.bookingStartDate); + upcomingEventsData.push({ + id: booking.event.id || booking.id, + title: booking.event.name, + date: eventStart.toLocaleDateString(), + time: eventStart.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + location: booking.event.venue?.name || 'TBA', + status: 'registered', + ticketType: booking.ticketType || null + }); + } else { + // If event details not included, fetch them + try { + const eventResponse = await eventAPI.getEventById(booking.eventId); + if (eventResponse.data) { + const eventStart = new Date(eventResponse.data.bookingStartDate); + upcomingEventsData.push({ + id: eventResponse.data.id, + title: eventResponse.data.name, + date: eventStart.toLocaleDateString(), + time: eventStart.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + location: eventResponse.data.venue?.name || 'TBA', + status: 'registered', + ticketType: booking.ticketType || null + }); + } + } catch (err) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to fetch event details', { eventId: booking.eventId }); + } + } + } + setUpcomingEvents(upcomingEventsData); + + // Fetch recent registrations (last 5 bookings, ordered by date) + const recentBookings = bookings + .filter((b: any) => b.status === 'CONFIRMED') + .sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, 5); + + const recentRegistrationsData: RecentRegistration[] = recentBookings.map((booking: any) => ({ + id: booking.id, + event: booking.event?.name || 'Unknown Event', + date: new Date(booking.createdAt).toLocaleDateString(), + status: booking.status.toLowerCase(), + ticketType: booking.ticketType || null + })); + setRecentRegistrations(recentRegistrationsData); + + logger.info(LOGGER_COMPONENT_NAME, 'Dashboard data loaded successfully', { + stats, + upcomingEventsCount: upcomingEventsData.length, + recentRegistrationsCount: recentRegistrationsData.length + }); + } catch (err) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load dashboard data', err instanceof Error ? err : new Error(String(err))); + } finally { + setLoading(false); + } + }; + // Loading and auth checks are handled by the HOC return ( @@ -169,9 +273,11 @@ function AttendeeDashboard() { -
{mockStats.registeredEvents}
+
+ {loading ? '...' : stats.registeredEvents} +

- {mockStats.upcomingEvents} upcoming + {loading ? '...' : `${stats.upcomingEvents} upcoming`}

@@ -184,9 +290,11 @@ function AttendeeDashboard() { -
{mockStats.ticketsPurchased}
+
+ {loading ? '...' : stats.ticketsPurchased} +

- {mockStats.activeTickets} active + {loading ? '...' : `${stats.activeTickets} active`}

@@ -194,14 +302,16 @@ function AttendeeDashboard() { - Points Earned + Attended Events -
{mockStats.pointsEarned}
+
+ {loading ? '...' : stats.attendedEvents} +

- {mockStats.pointsThisMonth} this month + {loading ? '...' : `${stats.usedTickets} tickets used`}

@@ -214,9 +324,11 @@ function AttendeeDashboard() { -
{mockStats.upcomingThisWeek}
+
+ {loading ? '...' : stats.upcomingThisWeek} +

- {mockStats.nextWeekEvents} next week + {loading ? '...' : `${stats.nextWeekEvents} next week`}

@@ -285,41 +397,55 @@ function AttendeeDashboard() { -
- {mockUpcomingEvents.map((event) => ( -
-
-

{event.title}

-
- - - {event.date} at {event.time} - - - - {event.location} - + {loading ? ( +
+ Loading upcoming events... +
+ ) : upcomingEvents.length === 0 ? ( +
+ No upcoming events +
+ ) : ( +
+ {upcomingEvents.map((event) => ( +
+
+

{event.title}

+
+ + + {event.date} at {event.time} + + + + {event.location} + +
+ {event.ticketType && ( + + {event.ticketType} + + )} +
+
+ + {event.status} + +
- {event.ticketType && ( - - {event.ticketType} - - )} -
-
- - {event.status} - -
-
- ))} -
+ ))} +
+ )}
@@ -335,34 +461,43 @@ function AttendeeDashboard() { -
- {mockRecentRegistrations.map((registration) => ( -
-
-

{registration.event}

-
- - {registration.ticketType} - - - Registered on {registration.date} - + {loading ? ( +
+ Loading recent registrations... +
+ ) : recentRegistrations.length === 0 ? ( +
+ No recent registrations +
+ ) : ( +
+ {recentRegistrations.map((registration) => ( +
+
+

{registration.event}

+
+ {registration.ticketType && ( + + {registration.ticketType} + + )} + + Registered on {registration.date} + +
+
+
+ + {registration.status} +
-
- - {registration.status} - - -
-
- ))} -
+ ))} +
+ )} diff --git a/ems-client/lib/api/types/booking.types.ts b/ems-client/lib/api/types/booking.types.ts index ef227a4..e0caeee 100644 --- a/ems-client/lib/api/types/booking.types.ts +++ b/ems-client/lib/api/types/booking.types.ts @@ -11,6 +11,20 @@ export interface BookingResponse { status: 'PENDING' | 'CONFIRMED' | 'CANCELLED'; createdAt: string; updatedAt: string; + isAttended?: boolean; + ticketType?: string | null; + event?: { + id: string; + name: string; + description?: string; + bookingStartDate: string; + bookingEndDate: string; + venue?: { + id: number; + name: string; + address: string; + }; + }; } export interface BookingListResponse { From 7cdd37d24247f698bf8b59638f5750cae7397ce1 Mon Sep 17 00:00:00 2001 From: ashwin-athappan Date: Mon, 3 Nov 2025 18:40:08 -0600 Subject: [PATCH 24/28] EMS-69: Added seeding scripts --- scripts/README-SEEDING.md | 273 ++++++++++ scripts/modules/__init__.py | 2 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 200 bytes .../booking_seeding.cpython-313.pyc | Bin 0 -> 4865 bytes .../__pycache__/event_seeding.cpython-313.pyc | Bin 0 -> 10740 bytes .../speaker_seeding.cpython-313.pyc | Bin 0 -> 17312 bytes .../__pycache__/user_seeding.cpython-313.pyc | Bin 0 -> 11185 bytes .../modules/__pycache__/utils.cpython-313.pyc | Bin 0 -> 2681 bytes scripts/modules/booking_seeding.py | 149 +++++ scripts/modules/event_seeding.py | 302 +++++++++++ scripts/modules/speaker_seeding.py | 507 ++++++++++++++++++ scripts/modules/user_seeding.py | 296 ++++++++++ scripts/modules/utils.py | 41 ++ scripts/requirements.txt | 15 + scripts/seed.py | 245 +++++++++ 15 files changed, 1830 insertions(+) create mode 100644 scripts/README-SEEDING.md create mode 100644 scripts/modules/__init__.py create mode 100644 scripts/modules/__pycache__/__init__.cpython-313.pyc create mode 100644 scripts/modules/__pycache__/booking_seeding.cpython-313.pyc create mode 100644 scripts/modules/__pycache__/event_seeding.cpython-313.pyc create mode 100644 scripts/modules/__pycache__/speaker_seeding.cpython-313.pyc create mode 100644 scripts/modules/__pycache__/user_seeding.cpython-313.pyc create mode 100644 scripts/modules/__pycache__/utils.cpython-313.pyc create mode 100644 scripts/modules/booking_seeding.py create mode 100644 scripts/modules/event_seeding.py create mode 100644 scripts/modules/speaker_seeding.py create mode 100644 scripts/modules/user_seeding.py create mode 100644 scripts/modules/utils.py create mode 100644 scripts/requirements.txt create mode 100644 scripts/seed.py diff --git a/scripts/README-SEEDING.md b/scripts/README-SEEDING.md new file mode 100644 index 0000000..752558e --- /dev/null +++ b/scripts/README-SEEDING.md @@ -0,0 +1,273 @@ +# Event Management System - Complete Seeding Script + +This directory contains a comprehensive seeding script for the Event Management System that creates users, speakers, events, and bookings. + +## Overview + +The seeding process ensures that: +- ✅ Users and speakers are created via HTTP API (triggers all service integrations) +- ✅ Speaker profiles are created automatically via RabbitMQ (when speakers register) +- ✅ Users are activated via admin API (emailVerified and isActive set) +- ✅ Events are created by admin and assigned to speakers +- ✅ Events respect venue operating hours +- ✅ Users register for events (bookings created) +- ✅ Admin users are seeded separately via auth-service prisma seed script (seed.ts) + +## Files + +1. **`seed.py`** - Main seeding script (Python, modular structure) +2. **`modules/`** - Modular components: + - `utils.py` - Shared utilities (colors, config, API URLs) + - `user_seeding.py` - User and speaker registration + - `event_seeding.py` - Event creation by admin + - `booking_seeding.py` - User registrations for events +3. **`requirements.txt`** - Python dependencies + +## Prerequisites + +1. **Admin User**: An admin user must exist before running the script. Seed it via: + ```bash + cd ems-services/auth-service + npx prisma db seed + ``` + Default admin credentials: + - Email: `admin@eventmanagement.com` + - Password: `Admin123!` + +2. **Venues**: Venues should be seeded first. Run: + ```bash + cd ems-services/event-service + npx prisma db seed + ``` + +3. **Services Running**: Ensure all services are running: + - auth-service + - event-service + - booking-service + - speaker-service + - RabbitMQ + +## Installation + +```bash +# Install dependencies +cd scripts +pip install -r requirements.txt + +# Or install manually: +pip install requests faker +``` + +## Usage + +### Basic Usage + +```bash +# From project root +python3 scripts/seed.py + +# Or from scripts directory +cd scripts +python3 seed.py +``` + +### Custom Configuration + +You can override default URLs via environment variables: + +```bash +# Custom API URLs +AUTH_API_URL=http://localhost:3000/api/auth \ +EVENT_API_URL=http://localhost:3000/api/event \ +BOOKING_API_URL=http://localhost:3000/api/booking \ +python3 scripts/seed.py + +# Custom admin credentials +ADMIN_EMAIL=your-admin@example.com \ +ADMIN_PASSWORD=YourPassword123! \ +python3 scripts/seed.py +``` + +### Full Example + +```bash +AUTH_API_URL=http://localhost/api/auth \ +EVENT_API_URL=http://localhost/api/event \ +BOOKING_API_URL=http://localhost/api/booking \ +ADMIN_EMAIL=admin@eventmanagement.com \ +ADMIN_PASSWORD=Admin123! \ +python3 scripts/seed.py +``` + +## What Gets Created + +### Step 1-2: Speakers and Users +- **5 Speakers**: speaker1@test.com through speaker5@test.com + - **Password**: Speaker1123! through Speaker5123! + - **Profile**: Created automatically via RabbitMQ +- **10 Regular Users**: user1@test.com through user10@test.com + - **Password**: User1123! through User10123! + +### Step 3: RabbitMQ Processing +- Waits 5 seconds for RabbitMQ to process speaker profile creation + +### Step 4: User Activation +- All users are activated via admin API +- `emailVerified` set to current date +- `isActive` set to `true` +- Users can login immediately without email verification + +### Step 5: Events (8 events created) +- Created by admin and assigned to random speakers +- Events are **auto-published** (PUBLISHED status) when created by admin +- Events respect venue operating hours (openingTime/closingTime) +- Event durations: + - Same-day events: 2-8 hours + - Multi-day events: 1-3 days +- Event names generated using Faker library (creative names) +- Categories: Technology, Business, Education, Arts & Culture, Health & Wellness, Science, Entertainment, Networking + +### Step 6: Bookings +- Each user randomly registers for 1-4 events +- Only published events are used +- Bookings created via booking-service API + +### Step 7: Speaker Data (Invitations, Materials, Messages) +- Creates speaker invitations for events (2-4 events per speaker) +- Speakers accept ~70% of invitations +- Uploads presentation materials (1-3 per speaker) +- Sends messages to speakers (0-2 per speaker) + +## Script Flow + +``` +1. Admin Login Verification + ↓ +2. Register Speakers (5) + ↓ +3. Register Users (10) + ↓ +4. Wait for RabbitMQ (5 seconds) + ↓ +5. Activate Users (via admin API) + ↓ +6. Create Events (8 events, assigned to speakers) + ↓ +7. Create Bookings (users register for events) + ↓ +8. Seed Speaker Data (invitations, materials, messages) +``` + +## Important Notes + +1. **Admin Credentials**: + - Default: `admin@eventmanagement.com` / `Admin123!` + - Can be overridden via `ADMIN_EMAIL` and `ADMIN_PASSWORD` env vars + - Admin must exist before running script + +2. **Speaker Profiles**: + - Created automatically via RabbitMQ when speakers register + - May take 5-10 seconds after registration + - Script waits for RabbitMQ processing + +3. **Event Creation**: + - Admin creates events with speaker's `userId` to assign them + - Events auto-publish when created by admin + - Venue times are validated (events must fit within venue hours) + +4. **User Activation**: + - All users are activated via protected admin API endpoint + - No need for email verification after seeding + - Users can login immediately + +5. **Password Format**: + - Pattern: `[Role][Number]123!` + - Examples: `Speaker1123!`, `User5123!` + +## Troubleshooting + +### Admin Login Fails +- Verify admin user exists: `cd ems-services/auth-service && npx prisma db seed` +- Check credentials match default or override via env vars +- Ensure auth-service is running + +### 502 Bad Gateway Errors +- Auth-service may not be running +- Check: `docker ps | grep auth-service` +- Check logs: `docker logs auth-service` +- Start service: `docker-compose up -d auth-service` + +### No Venues Available +- Seed venues first: `cd ems-services/event-service && npx prisma db seed` +- Verify venues exist via: `GET /api/event/venues/all` + +### Events Not Created +- Check event-service is running +- Verify venues exist +- Check admin token is valid +- Review event-service logs + +### Bookings Not Created +- Ensure events are PUBLISHED status +- Verify users can login (check activation) +- Check booking-service is running +- Review booking-service logs + +### Speaker Profiles Not Created +- Verify RabbitMQ is running +- Check speaker-service logs for RabbitMQ connection +- Wait longer (RabbitMQ may need more time) +- Manually create via `POST /api/speakers` + +## API Endpoints Used + +- `POST /api/auth/register` - Register users and speakers +- `POST /api/auth/login` - Admin authentication +- `POST /api/auth/admin/activate-users` - Activate users (admin only) +- `GET /api/event/venues/all` - Fetch available venues +- `POST /api/event/speaker/events` - Create events (as admin, auto-publishes) +- `POST /api/booking/bookings` - Create bookings (user registrations) +- `GET /api/speakers/profile/me` - Get speaker profile by user ID +- `POST /api/invitations` - Create speaker invitations (admin only) +- `GET /api/invitations/speaker/:speakerId` - Get speaker invitations +- `PUT /api/invitations/:id/respond` - Respond to invitation (speaker) +- `POST /api/materials/upload` - Upload presentation materials (speaker) +- `POST /api/messages` - Send messages to users + +## Verification + +After running the seeding script, verify the data: + +```bash +# Check users were created +curl http://localhost/api/auth/check-user?email=speaker1@test.com + +# Check events (requires authentication) +curl -H "Authorization: Bearer " \ + http://localhost/api/event/admin/events + +# Check bookings (requires authentication) +curl -H "Authorization: Bearer " \ + http://localhost/api/booking/bookings/my-bookings +``` + +## Next Steps + +After seeding: +1. ✅ Verify users can login +2. ✅ Check speaker profiles were created +3. ✅ Verify events are published and assigned to speakers +4. ✅ Check users have bookings/registrations +5. ✅ Test the system with seeded data + +## Module Structure + +The script is modular for maintainability: + +- **`seed.py`** - Main orchestrator +- **`modules/utils.py`** - Shared utilities (colors, config) +- **`modules/user_seeding.py`** - User/speaker registration logic +- **`modules/event_seeding.py`** - Event creation with venue validation +- **`modules/booking_seeding.py`** - User registration for events + +Each module can be tested independently or modified without affecting others. diff --git a/scripts/modules/__init__.py b/scripts/modules/__init__.py new file mode 100644 index 0000000..5f4aac2 --- /dev/null +++ b/scripts/modules/__init__.py @@ -0,0 +1,2 @@ +# Modules package for seeding script + diff --git a/scripts/modules/__pycache__/__init__.cpython-313.pyc b/scripts/modules/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..576fbbb29ba34855ab3932b1957fb6df26e1cd20 GIT binary patch literal 200 zcmey&%ge<81aa@#GSz_eV-N=h7@>^MEI`IohI9r^M!%H|MNB~6XOPsbbp6oc)S_bj z#Nv$d%shRU{N&Qy)Vz{n{qp>x?BasNPHM4!e0*kJW=VX!UP0w84jYK5T@fqLUXZ(r PL5z>gjEsy$%s>_Z!D=>g literal 0 HcmV?d00001 diff --git a/scripts/modules/__pycache__/booking_seeding.cpython-313.pyc b/scripts/modules/__pycache__/booking_seeding.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4eae12e0d813517bf69a467ec0ff4e902d57042b GIT binary patch literal 4865 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 literal 0 HcmV?d00001 diff --git a/scripts/modules/__pycache__/event_seeding.cpython-313.pyc b/scripts/modules/__pycache__/event_seeding.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..60f0a70531f6057bbeca9218670137df2d65fcc4 GIT binary patch literal 10740 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 ILZ;=v0sOxP`Tzg` literal 0 HcmV?d00001 diff --git a/scripts/modules/__pycache__/speaker_seeding.cpython-313.pyc b/scripts/modules/__pycache__/speaker_seeding.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af01125cb03ce6673328572417f5c93afd6cc319 GIT binary patch literal 17312 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 literal 0 HcmV?d00001 diff --git a/scripts/modules/__pycache__/user_seeding.cpython-313.pyc b/scripts/modules/__pycache__/user_seeding.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..10e442acbebae86baed37ed8efd4ec54d136f2fd GIT binary patch literal 11185 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 Kzd<=_r~d)el@PxG literal 0 HcmV?d00001 diff --git a/scripts/modules/__pycache__/utils.cpython-313.pyc b/scripts/modules/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..698eb6a6c85708bba511a12a81dfaec425930b38 GIT binary patch literal 2681 zcmd5--*3}K96u+HlQ_THw1xg)EHDO(P;1)I0;5a|tyj9XNflENG~gN6O$~{i-8rCE zXo|EAwG&dMN(et}Qjw6L5)u!4+1~dDM5-!9*7m}lc%rDA_PRSe3GHa5?P;9k?|r{_ zpF8_=pYMIW6$-Hcx9hJM^+y_j-$_t!IJVo?RSJLyKmZU3l;9epAccGPnCo*lbjLuJ z%J}L_-t>ooKntFJmooxAd!iV@+dwgUqI`nCf%5K&Vue5h<=Yb#6haM@e@|3ch%``a zJHULJI)TvK7z+r|Z1AK@Xz2tuX&9lkk}+pRgh@U*#%CJX_jbjiq9WZ=AhJUIB|derf$rQD zt=v{L!-||wr^iN9BMtC@UBNeW{T3!;1(L%PqbZS}NRHw!mSzg7_Nl#5LDHn0QovjG z$a=vFJGjZDAY4voh8O%v0`2Wf9FpIp82~sp?Y4CScMm`o;Oqp@B~Z{UxF9XKp+}%0 zBY2=!V4zR%LcidHtl)=%92*Z<%#fbfA$l3b%NM*|(X0dDVmO_-5Gw^ z@{DA7K4mdi`0?@dWs5#PKE+$!iR1{DA!*S=SCT2qli>yaqSYpf$dC+G7A3=g>Wz}2 zAW?)N%wUV0g*C%XAuJ*%p}9M%#trLoi5x59?&$CqDoV1#O+x*qBI6+$Dc${vzJ87; zVs=k7MCcagk)aegC;1TPut8i2rz+|x&cgtHm2Jq;p%1aS2deD8@=!I{UgoQzj`C;K z!2a@ZHQZSqalByL*F5a3ud}=b1tF}tTo)0~00uv9{A_)J%>yvqn47K@696*x(u5K9 z7RJSC3WUKn6xrz?EesNLe{IJY$bq;kRU(_c`2O2ioVGkgsA`78tOXClc5K!tcmQ{Z zEQ`S|Y^{)u6H!T)F#{OGV`Lm1!ln#LW?-MTD=ms3R3WPd@hw&s?w1e!`rK^kMCT(6eM<35V z$v?`kUaznxH|YL%fGqhtz4+$mS3fPsb`oyI6T1^;9m1-HtMCzwBD(J2du{33V%j{t zI%wd~VWg+04L55CRUgV);md>uRgC7=IQHoJFZGnLw*Xd8ax?gWDl!8t{m z!Kxm`I6@N<;p_Q3y)?ZzVIDI(9uGVjd^EV)Q(=2I=)SiJ{y!J?-pWof_wUKICMGUs zMfed$9V1M)-&BWbtC=&eJih(p?xVY_T7~W3pa*QO@u=k$#Ti}3mD;5vi!tVl_&)}T zrWTV^3`M&QY4UcWG$f}1y~L2bgMMO2rL^40fOe&@Z;+i<_QBiVWEXGybxwit4Lara z6WD{-lBP(->GJ(a_szic&HqxHn%+N zSm~&A9e+B#-h8GU{)_Qa`(6WVHlq}E+D!ZbaJR`&)PUJS`~l*hGu^~LNBo5O0r3;W NKV`cA#J*Gh{s#LtAo&0Q literal 0 HcmV?d00001 diff --git a/scripts/modules/booking_seeding.py b/scripts/modules/booking_seeding.py new file mode 100644 index 0000000..fa41ab9 --- /dev/null +++ b/scripts/modules/booking_seeding.py @@ -0,0 +1,149 @@ +""" +Booking/Registration Seeding Module +""" +import random +import time +import requests +from typing import List, Dict, Optional +from .utils import ( + BOOKING_API_URL, print_success, print_error, print_info, print_step +) + + +def login_user(email: str, password: str) -> Optional[str]: + """ + Login as a user to get authentication token + + Args: + email: User email + password: User password + + Returns: + JWT token or None if failed + """ + from .utils import AUTH_API_URL + + url = f"{AUTH_API_URL}/login" + payload = { + "email": email, + "password": password + } + + try: + response = requests.post(url, json=payload, timeout=10) + if response.status_code == 200: + data = response.json() + return data.get('token', '') + else: + return None + except: + return None + + +def create_booking(user_token: str, event_id: str) -> bool: + """ + Create a booking for an event + + Args: + user_token: User authentication token + event_id: Event ID to register for + + Returns: + True if successful, False otherwise + """ + url = f"{BOOKING_API_URL}/bookings" + headers = { + "Authorization": f"Bearer {user_token}", + "Content-Type": "application/json" + } + payload = { + "eventId": event_id + } + + try: + response = requests.post(url, json=payload, headers=headers, timeout=10) + + if response.status_code == 201: + return True + elif response.status_code == 409: + # Already registered or fully booked + return False + else: + return False + except: + return False + + +def seed_bookings(events: List[Dict], users: List[Dict]) -> int: + """ + Seed bookings by having users register for events + + Args: + events: List of event dictionaries (should be PUBLISHED) + users: List of user dictionaries with email + + Returns: + Number of successful bookings created + """ + from .utils import print_header + + print() + print_header("Step 6: Creating User Registrations") + print("-" * 50) + + if not events: + print_error("No events available to register for") + return 0 + + if not users: + print_error("No users available to register") + return 0 + + # Filter to only published events + published_events = [e for e in events if e.get('status') == 'PUBLISHED'] + + if not published_events: + print_error("No published events available to register for") + return 0 + + print_info(f"Registering users for {len(published_events)} published events...") + + successful_bookings = 0 + failed_bookings = 0 + + # For each user, randomly register for 1-4 events + for user in users: + email = user['email'] + password = f"User{email.split('@')[0].replace('user', '')}123!" # Extract number from email + + # Login as user + user_token = login_user(email, password) + if not user_token: + print_error(f"Failed to login as {email}, skipping bookings") + continue + + # Randomly select 1-4 events to register for + num_registrations = random.randint(1, min(4, len(published_events))) + events_to_register = random.sample(published_events, num_registrations) + + for event in events_to_register: + event_id = event.get('id') + event_name = event.get('name', 'Unknown Event') + + if create_booking(user_token, event_id): + successful_bookings += 1 + print_step(f"User {email} registered for: {event_name[:50]}") + time.sleep(0.1) # Small delay between requests + else: + failed_bookings += 1 + # Don't print failed bookings to reduce noise (might be duplicates or full capacity) + + time.sleep(0.2) # Delay between users + + print() + print_success(f"Created {successful_bookings} bookings") + if failed_bookings > 0: + print_info(f"{failed_bookings} bookings failed (may be duplicates or capacity issues)") + + return successful_bookings + diff --git a/scripts/modules/event_seeding.py b/scripts/modules/event_seeding.py new file mode 100644 index 0000000..eb73554 --- /dev/null +++ b/scripts/modules/event_seeding.py @@ -0,0 +1,302 @@ +""" +Event Seeding Module +""" +import random +from datetime import datetime, timedelta +from typing import List, Dict, Optional, Tuple +import requests +from faker import Faker +from .utils import ( + EVENT_API_URL, print_success, print_error, print_info, print_step +) + +fake = Faker() +Faker.seed(42) # For reproducible results + + +def parse_time(time_str: str) -> Tuple[int, int]: + """Parse HH:mm time string to (hour, minute) tuple""" + parts = time_str.split(':') + return int(parts[0]), int(parts[1]) + + +def time_to_minutes(time_str: str) -> int: + """Convert HH:mm time string to total minutes since midnight""" + hour, minute = parse_time(time_str) + return hour * 60 + minute + + +def validate_event_time_against_venue(event_start: datetime, event_end: datetime, + venue_opening: str, venue_closing: str) -> bool: + """ + Validate that event times fall within venue operating hours + + Args: + event_start: Event start datetime + event_end: Event end datetime + venue_opening: Venue opening time (HH:mm) + venue_closing: Venue closing time (HH:mm) + + Returns: + True if valid, False otherwise + """ + event_start_minutes = event_start.hour * 60 + event_start.minute + event_end_minutes = event_end.hour * 60 + event_end.minute + + venue_open_minutes = time_to_minutes(venue_opening) + venue_close_minutes = time_to_minutes(venue_closing) + + # Handle venues that close at 24:00 (midnight) + if venue_close_minutes == 24 * 60: + venue_close_minutes = 23 * 60 + 59 + + return (event_start_minutes >= venue_open_minutes and + event_end_minutes <= venue_close_minutes) + + +def generate_event_dates(days_ahead: int = None, duration_days: int = None) -> Tuple[datetime, datetime]: + """ + Generate event start and end dates + + Args: + days_ahead: Days from now to start event (default: 7-30 days) + duration_days: Event duration in days (default: 0-3 days) + + Returns: + Tuple of (start_date, end_date) + """ + if days_ahead is None: + days_ahead = random.randint(7, 30) + + if duration_days is None: + duration_days = random.randint(0, 3) # 0 = same day, 1-3 = multiple days + + start_date = datetime.now() + timedelta(days=days_ahead) + + # Randomize start time (between 9 AM and 6 PM) + start_hour = random.randint(9, 18) + start_minute = random.choice([0, 30]) + start_date = start_date.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0) + + # Calculate end date + if duration_days == 0: + # Same day event - duration 2-8 hours + duration_hours = random.randint(2, 8) + end_date = start_date + timedelta(hours=duration_hours) + else: + # Multi-day event + end_date = start_date + timedelta(days=duration_days) + # End time between 5 PM and 10 PM + end_hour = random.randint(17, 22) + end_minute = random.choice([0, 30]) + end_date = end_date.replace(hour=end_hour, minute=end_minute, second=0, microsecond=0) + + return start_date, end_date + + +def adjust_event_times_for_venue(event_start: datetime, event_end: datetime, + venue_opening: str, venue_closing: str) -> Tuple[datetime, datetime]: + """ + Adjust event times to fit within venue operating hours + + Args: + event_start: Desired event start datetime + event_end: Desired event end datetime + venue_opening: Venue opening time (HH:mm) + venue_closing: Venue closing time (HH:mm) + + Returns: + Tuple of (adjusted_start, adjusted_end) + """ + venue_open_hour, venue_open_minute = parse_time(venue_opening) + venue_close_hour, venue_close_minute = parse_time(venue_closing) + + # Adjust start time to not be before venue opening + if event_start.hour < venue_open_hour or (event_start.hour == venue_open_hour and event_start.minute < venue_open_minute): + event_start = event_start.replace(hour=venue_open_hour, minute=venue_open_minute) + + # Handle closing at 24:00 (midnight) + if venue_close_hour == 24: + venue_close_hour = 23 + venue_close_minute = 59 + + # Adjust end time to not be after venue closing + if event_end.hour > venue_close_hour or (event_end.hour == venue_close_hour and event_end.minute > venue_close_minute): + event_end = event_end.replace(hour=venue_close_hour, minute=venue_close_minute) + + # Ensure end is after start + if event_end <= event_start: + event_end = event_start + timedelta(hours=2) # Minimum 2 hour event + + return event_start, event_end + + +def get_venues(admin_token: str) -> List[Dict]: + """ + Fetch all available venues + + Args: + admin_token: Admin authentication token + + Returns: + List of venue dictionaries + """ + url = f"{EVENT_API_URL}/venues/all" + headers = { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json" + } + + try: + response = requests.get(url, headers=headers, timeout=10) + if response.status_code == 200: + data = response.json() + return data.get('data', []) + else: + print_error(f"Failed to fetch venues: HTTP {response.status_code}") + return [] + except Exception as e: + print_error(f"Error fetching venues: {str(e)}") + return [] + + +def create_event(admin_token: str, admin_user_id: str, venue: Dict, + event_name: str, description: str, category: str) -> Optional[Dict]: + """ + Create an event as admin (admin is the creator) + + Args: + admin_token: Admin authentication token + admin_user_id: User ID of the admin creating the event + venue: Venue dictionary + event_name: Event name + description: Event description + category: Event category + + Returns: + Created event dictionary or None if failed + """ + # Generate event dates + start_date, end_date = generate_event_dates() + + # Adjust times for venue operating hours + start_date, end_date = adjust_event_times_for_venue( + start_date, end_date, + venue['openingTime'], venue['closingTime'] + ) + + # Create event as admin (admin uses their own userId) + # Admin uses the speaker endpoint but with admin token - this auto-publishes the event + # Note: Gateway rewrites /api/event/speaker/events to /speaker/events on event-service + url = f"{EVENT_API_URL}/speaker/events" + headers = { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json" + } + payload = { + "name": event_name, + "description": description, + "category": category, + "venueId": venue['id'], + "bookingStartDate": start_date.isoformat(), + "bookingEndDate": end_date.isoformat(), + "userId": admin_user_id # Admin creates event with their own userId + } + + try: + response = requests.post(url, json=payload, headers=headers, timeout=10) + + if response.status_code == 201: + data = response.json() + event = data.get('data', {}) + status = event.get('status', 'UNKNOWN') + print_success(f"Created event: {event_name} (status: {status}, created by admin)") + return event + else: + try: + error_data = response.json() + error_msg = error_data.get('error', response.text) + print_error(f"Failed to create event '{event_name}': HTTP {response.status_code} - {error_msg}") + except: + print_error(f"Failed to create event '{event_name}': HTTP {response.status_code}") + return None + + except Exception as e: + print_error(f"Error creating event '{event_name}': {str(e)}") + return None + + +def seed_events(admin_token: str, admin_user_id: str, num_events: int = 8) -> List[Dict]: + """ + Seed events by creating them as admin (admin is the creator) + + Args: + admin_token: Admin authentication token + admin_user_id: Admin user ID + num_events: Number of events to create + + Returns: + List of created event dictionaries + """ + from .utils import print_header + + print() + print_header("Step 5: Creating Events") + print("-" * 50) + + # Fetch available venues + print_info("Fetching available venues...") + venues = get_venues(admin_token) + + if not venues: + print_error("No venues available. Please seed venues first.") + return [] + + print_success(f"Found {len(venues)} venues") + print_info(f"Creating events as admin (user ID: {admin_user_id[:8]}...)") + + # Event categories + categories = [ + "Technology", "Business", "Education", "Arts & Culture", + "Health & Wellness", "Science", "Entertainment", "Networking" + ] + + # Generate creative event names using Faker + created_events = [] + + for i in range(num_events): + # Pick random venue + venue = random.choice(venues) + + # Generate creative event name + event_name = fake.catch_phrase() + " " + fake.bs().title() + # Ensure it's not too long + if len(event_name) > 80: + event_name = event_name[:77] + "..." + + # Generate description + description = fake.text(max_nb_chars=200) + + # Pick category + category = random.choice(categories) + + print_step(f"Creating event {i+1}/{num_events}: {event_name}") + print_info(f" Venue: {venue['name']}") + print_info(f" Category: {category}") + + event = create_event( + admin_token=admin_token, + admin_user_id=admin_user_id, + venue=venue, + event_name=event_name, + description=description, + category=category + ) + + if event: + created_events.append(event) + + print() + print_success(f"Created {len(created_events)} events") + return created_events + diff --git a/scripts/modules/speaker_seeding.py b/scripts/modules/speaker_seeding.py new file mode 100644 index 0000000..03b5a2b --- /dev/null +++ b/scripts/modules/speaker_seeding.py @@ -0,0 +1,507 @@ +""" +Speaker Seeding Module +Creates speaker invitations, materials, and messages for speakers +""" +import random +import time +import requests +import io +from typing import List, Dict, Optional +from .utils import ( + SPEAKER_API_URL, AUTH_API_URL, print_success, print_error, print_info, print_step +) + + +def get_speaker_profile_by_user_id(admin_token: str, user_id: str) -> Optional[Dict]: + """ + Get speaker profile by user ID + + Args: + admin_token: Admin authentication token + user_id: User ID to find speaker profile for + + Returns: + Speaker profile dictionary or None if not found + """ + # Use /api/speakers/profile/me?userId=... endpoint + url = f"{SPEAKER_API_URL}/profile/me" + headers = { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json" + } + params = {"userId": user_id} + + try: + response = requests.get(url, headers=headers, params=params, timeout=10) + if response.status_code == 200: + data = response.json() + return data.get('data') + return None + except Exception as e: + print_error(f"Error fetching speaker profile for user {user_id}: {str(e)}") + return None + + +def get_all_speaker_profiles(admin_token: str, speaker_emails: List[str]) -> List[Dict]: + """ + Get all speaker profiles by logging in as each speaker and fetching their profile + + Args: + admin_token: Admin authentication token (for API access) + speaker_emails: List of speaker email addresses + + Returns: + List of speaker profile dictionaries + """ + profiles = [] + + for email in speaker_emails: + # Extract speaker number from email + speaker_num = email.split('@')[0].replace('speaker', '') + password = f"Speaker{speaker_num}123!" + + # Login as speaker to get token + login_url = f"{AUTH_API_URL}/login" + try: + login_response = requests.post( + login_url, + json={"email": email, "password": password}, + timeout=10 + ) + + if login_response.status_code == 200: + login_data = login_response.json() + speaker_token = login_data.get('token', '') + user_id = login_data.get('user', {}).get('id', '') + + if speaker_token and user_id: + # Get speaker profile using speaker's own token + profile_url = f"{SPEAKER_API_URL}/profile/me" + profile_headers = { + "Authorization": f"Bearer {speaker_token}", + "Content-Type": "application/json" + } + profile_params = {"userId": user_id} + + profile_response = requests.get( + profile_url, + headers=profile_headers, + params=profile_params, + timeout=10 + ) + + if profile_response.status_code == 200: + profile_data = profile_response.json() + profile = profile_data.get('data') + if profile: + profiles.append({ + 'id': profile.get('id'), + 'userId': user_id, + 'email': email, + 'token': speaker_token + }) + print_step(f"Found speaker profile: {email}") + elif profile_response.status_code == 404: + print_info(f"Speaker profile not yet created for {email} (may need to wait for RabbitMQ)") + else: + print_info(f"Could not fetch speaker profile for {email} (HTTP {profile_response.status_code})") + time.sleep(0.2) + except Exception as e: + print_error(f"Error getting speaker profile for {email}: {str(e)}") + continue + + return profiles + + +def create_invitation(admin_token: str, speaker_id: str, event_id: str, message: str = None) -> bool: + """ + Create a speaker invitation + + Args: + admin_token: Admin authentication token + speaker_id: Speaker profile ID + event_id: Event ID + message: Optional invitation message + + Returns: + True if successful, False otherwise + """ + # Invitations API is at /api/invitations (via gateway) + # Extract base URL and use /api/invitations + base_url = SPEAKER_API_URL.replace('/api/speakers', '') + url = f"{base_url}/api/invitations" + headers = { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json" + } + payload = { + "speakerId": speaker_id, + "eventId": event_id, + "message": message or f"You have been invited to speak at this event." + } + + try: + response = requests.post(url, json=payload, headers=headers, timeout=10) + if response.status_code == 201: + return True + elif response.status_code == 400: + # Might be duplicate invitation + try: + error_data = response.json() + if 'already exists' in str(error_data.get('error', '')).lower(): + return False # Duplicate, not an error + except: + pass + return False + else: + return False + except Exception as e: + print_error(f"Error creating invitation: {str(e)}") + return False + + +def respond_to_invitation(speaker_token: str, invitation_id: str, status: str = 'ACCEPTED') -> bool: + """ + Respond to an invitation as a speaker + + Args: + speaker_token: Speaker authentication token + invitation_id: Invitation ID + status: Response status ('ACCEPTED' or 'DECLINED') + + Returns: + True if successful, False otherwise + """ + # Invitations API is at /api/invitations (via gateway) + # Gateway rewrites /api/invitations/:id/respond to /api/invitations/:id/respond on speaker-service + base_url = SPEAKER_API_URL.replace('/api/speakers', '') + url = f"{base_url}/api/invitations/{invitation_id}/respond" + headers = { + "Authorization": f"Bearer {speaker_token}", + "Content-Type": "application/json" + } + payload = { + "status": status + } + + try: + response = requests.put(url, json=payload, headers=headers, timeout=10) + return response.status_code == 200 + except Exception as e: + print_error(f"Error responding to invitation: {str(e)}") + return False + + +def upload_material(speaker_token: str, speaker_id: str, event_id: str = None) -> bool: + """ + Upload a fake presentation material for a speaker + + Args: + speaker_token: Speaker authentication token + speaker_id: Speaker profile ID + event_id: Optional event ID to associate material with + + Returns: + True if successful, False otherwise + """ + # Materials API is at /api/materials (via gateway) + # Gateway rewrites /api/materials/upload to /api/materials/upload on speaker-service + base_url = SPEAKER_API_URL.replace('/api/speakers', '') + url = f"{base_url}/api/materials/upload" + headers = { + "Authorization": f"Bearer {speaker_token}" + } + + # Create a fake PDF file (minimal valid PDF) + fake_pdf_content = b"""%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >> +endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +trailer +<< /Size 4 /Root 1 0 R >> +startxref +174 +%%EOF""" + + # Create form data + files = { + 'file': ('presentation.pdf', fake_pdf_content, 'application/pdf') + } + data = { + 'speakerId': speaker_id + } + if event_id: + data['eventId'] = event_id + + try: + response = requests.post(url, files=files, data=data, headers=headers, timeout=10) + return response.status_code == 201 + except Exception as e: + print_error(f"Error uploading material: {str(e)}") + return False + + +def send_message(admin_token: str, from_user_id: str, to_user_id: str, subject: str, content: str) -> bool: + """ + Send a message to a user + + Args: + admin_token: Admin authentication token + from_user_id: Sender user ID + to_user_id: Recipient user ID + subject: Message subject + content: Message content + + Returns: + True if successful, False otherwise + """ + # Messages API is at /api/messages (via gateway) + # Gateway rewrites /api/messages to /api/messages on speaker-service + base_url = SPEAKER_API_URL.replace('/api/speakers', '') + url = f"{base_url}/api/messages" + headers = { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json" + } + payload = { + "fromUserId": from_user_id, + "toUserId": to_user_id, + "subject": subject, + "content": content + } + + try: + response = requests.post(url, json=payload, headers=headers, timeout=10) + return response.status_code == 201 + except Exception as e: + print_error(f"Error sending message: {str(e)}") + return False + + +def seed_speaker_data( + admin_token: str, + admin_user_id: str, + speaker_emails: List[str], + events: List[Dict] +) -> Dict: + """ + Seed speaker data: invitations, materials, and messages + + Args: + admin_token: Admin authentication token + admin_user_id: Admin user ID (for sending messages) + speaker_emails: List of speaker email addresses + events: List of event dictionaries + + Returns: + Dictionary with seeding statistics + """ + from .utils import print_header + + print() + print_header("Step 7: Seeding Speaker Data (Invitations, Materials, Messages)") + print("-" * 50) + + if not speaker_emails: + print_info("No speakers to seed data for") + return {'invitations': 0, 'accepted': 0, 'materials': 0, 'messages': 0} + + if not events: + print_info("No events available for invitations") + return {'invitations': 0, 'accepted': 0, 'materials': 0, 'messages': 0} + + # Note: We already waited 5 seconds in main seed.py after user registration + # Additional wait here ensures speaker profiles are fully processed + print_info("Waiting 3 seconds for speaker profiles to be fully created...") + time.sleep(3) + + # Get all speaker profiles + print_info("Fetching speaker profiles...") + speaker_profiles = get_all_speaker_profiles(admin_token, speaker_emails) + + if not speaker_profiles: + print_error("No speaker profiles found. They may still be processing via RabbitMQ.") + print_info("You may need to wait a few more seconds and re-run this step.") + return {'invitations': 0, 'accepted': 0, 'materials': 0, 'messages': 0} + + print_success(f"Found {len(speaker_profiles)} speaker profiles") + + stats = { + 'invitations': 0, + 'accepted': 0, + 'materials': 0, + 'messages': 0 + } + + # Filter to published events only + published_events = [e for e in events if e.get('status') == 'PUBLISHED'] + + if not published_events: + print_info("No published events available for invitations") + return stats + + # Step 1: Create invitations + print() + print_info("Creating speaker invitations...") + invitations_created = [] + + for speaker in speaker_profiles: + # Assign each speaker to 2-4 random events + num_events = random.randint(2, min(4, len(published_events))) + assigned_events = random.sample(published_events, num_events) + + for event in assigned_events: + event_id = event.get('id') + event_name = event.get('name', 'Unknown Event') + + if create_invitation(admin_token, speaker['id'], event_id): + invitations_created.append({ + 'invitation_id': None, # Will be fetched + 'speaker': speaker, + 'event_id': event_id, + 'event_name': event_name + }) + stats['invitations'] += 1 + print_step(f"Created invitation: {speaker['email']} → {event_name[:40]}") + time.sleep(0.1) + + print_success(f"Created {stats['invitations']} invitations") + + # Step 2: Fetch invitations and have some speakers accept them + print() + print_info("Having speakers accept invitations...") + + # Wait a moment for invitations to be processed + time.sleep(1) + + for invitation_data in invitations_created[:len(invitations_created)]: # Try all + speaker = invitation_data['speaker'] + event_id = invitation_data['event_id'] + + # Fetch invitations for this speaker to get the invitation ID + try: + base_url = SPEAKER_API_URL.replace('/api/speakers', '') + invites_url = f"{base_url}/api/invitations/speaker/{speaker['id']}" + invites_headers = { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json" + } + invites_response = requests.get(invites_url, headers=invites_headers, timeout=10) + + if invites_response.status_code == 200: + invites_data = invites_response.json() + invitations = invites_data.get('data', []) + + # Find invitation for this event + invitation = next( + (inv for inv in invitations if inv.get('eventId') == event_id and inv.get('status') == 'PENDING'), + None + ) + + if invitation: + invitation_id = invitation.get('id') + # Accept ~70% of invitations + if random.random() < 0.7: + if respond_to_invitation(speaker['token'], invitation_id, 'ACCEPTED'): + stats['accepted'] += 1 + invitation_data['event_id'] = event_id # Keep for material upload + print_step(f"Accepted: {speaker['email']} → {invitation_data['event_name'][:40]}") + time.sleep(0.1) + except Exception as e: + print_error(f"Error processing invitation acceptance: {str(e)}") + continue + + print_success(f"Accepted {stats['accepted']} invitations") + + # Step 3: Upload materials for speakers (especially for accepted events) + print() + print_info("Uploading presentation materials...") + + for speaker in speaker_profiles: + # Upload 1-3 materials per speaker + num_materials = random.randint(1, 3) + + # Get accepted events for this speaker to associate materials + try: + base_url = SPEAKER_API_URL.replace('/api/speakers', '') + invites_url = f"{base_url}/api/invitations/speaker/{speaker['id']}" + invites_headers = { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json" + } + invites_response = requests.get(invites_url, headers=invites_headers, timeout=10) + + accepted_event_ids = [] + if invites_response.status_code == 200: + invites_data = invites_response.json() + invitations = invites_data.get('data', []) + accepted_event_ids = [ + inv.get('eventId') for inv in invitations + if inv.get('status') == 'ACCEPTED' + ] + except: + accepted_event_ids = [] + + for i in range(num_materials): + # Sometimes associate with an accepted event, sometimes general + event_id = random.choice(accepted_event_ids) if accepted_event_ids and random.random() < 0.6 else None + + if upload_material(speaker['token'], speaker['id'], event_id): + stats['materials'] += 1 + print_step(f"Uploaded material {i+1} for {speaker['email']}") + time.sleep(0.2) + + print_success(f"Uploaded {stats['materials']} materials") + + # Step 4: Send messages to speakers + print() + print_info("Sending messages to speakers...") + + message_subjects = [ + "Welcome to EventManager!", + "Important Event Information", + "Reminder: Upcoming Speaking Engagement", + "Event Schedule Update", + "Thank you for your participation" + ] + + message_contents = [ + "We're excited to have you on board as a speaker!", + "Please review the event details and prepare your materials.", + "This is a reminder about your upcoming event.", + "There has been a schedule update for your event.", + "Thank you for your contribution to our events!" + ] + + for speaker in speaker_profiles: + # Send 0-2 messages per speaker + num_messages = random.randint(0, 2) + + for i in range(num_messages): + subject = random.choice(message_subjects) + content = random.choice(message_contents) + + if send_message(admin_token, admin_user_id, speaker['userId'], subject, content): + stats['messages'] += 1 + print_step(f"Sent message to {speaker['email']}: {subject}") + time.sleep(0.1) + + print_success(f"Sent {stats['messages']} messages") + + print() + print_success("Speaker data seeding complete!") + + return stats + diff --git a/scripts/modules/user_seeding.py b/scripts/modules/user_seeding.py new file mode 100644 index 0000000..73ccb1a --- /dev/null +++ b/scripts/modules/user_seeding.py @@ -0,0 +1,296 @@ +""" +User and Speaker Seeding Module +""" +import time +import requests +from typing import Optional, Tuple, List, Dict +from .utils import ( + AUTH_API_URL, ADMIN_EMAIL, ADMIN_PASSWORD, + print_success, print_error, print_info, print_step +) + + +def get_user_id_by_login(email: str, password: str) -> Optional[str]: + """ + Get user ID by logging in with email and password + + Args: + email: User email + password: User password + + Returns: + User ID if login successful, None otherwise + """ + url = f"{AUTH_API_URL}/login" + payload = { + "email": email, + "password": password + } + + try: + response = requests.post(url, json=payload, timeout=10) + if response.status_code == 200: + data = response.json() + user_id = data.get('user', {}).get('id', '') + return user_id + return None + except: + return None + + +def register_user(email: str, password: str, name: str, role: str) -> Tuple[bool, Optional[str], Optional[int]]: + """ + Register a user via HTTP POST to /api/auth/register + If user already exists, attempts to get their user_id via login + + Args: + email: User email + password: User password + name: User name + role: User role (USER or SPEAKER) + + Returns: + Tuple of (success: bool, user_id: Optional[str], http_status: Optional[int]) + """ + print_step(f"Registering {role}: {name} ({email})") + + url = f"{AUTH_API_URL}/register" + payload = { + "email": email, + "password": password, + "name": name, + "role": role + } + + try: + response = requests.post(url, json=payload, timeout=10) + http_status = response.status_code + + if http_status == 201: + data = response.json() + user_id = data.get('user', {}).get('id', '') + print_success(f"Registered {role}: {name}") + return True, user_id, http_status + + elif http_status == 400: + try: + error_data = response.json() + error_msg = error_data.get('error', '') + + if 'already exists' in error_msg.lower() or 'User with this email already exists' in error_msg: + print_info(f"User {email} already exists, fetching user ID...") + # Try to get user_id by logging in + user_id = get_user_id_by_login(email, password) + if user_id: + print_success(f"Found existing {role}: {name} (ID: {user_id[:8]}...)") + return True, user_id, http_status + else: + print_info(f"Could not get user ID for {email} (may need activation)") + # Still return success so we can add email to activation list + return True, None, http_status + else: + print_error(f"Failed to register {email}: {error_msg}") + return False, None, http_status + except: + print_error(f"Failed to register {email}: {response.text}") + return False, None, http_status + + elif http_status == 502: + print_error(f"Bad Gateway (502) - nginx cannot reach auth-service") + print_error(f"Failed to register {email}") + return False, None, http_status + + elif http_status == 503: + print_error(f"Service Unavailable (503) - auth-service may be starting up") + print_info("Wait a few seconds and try again") + return False, None, http_status + + else: + try: + error_data = response.json() + error_msg = error_data.get('error', response.text) + print_error(f"Failed to register {email}: HTTP {http_status} - {error_msg}") + except: + print_error(f"Failed to register {email}: HTTP {http_status}") + return False, None, http_status + + except requests.exceptions.ConnectionError: + print_error(f"Connection error - cannot reach {url}") + return False, None, None + + except requests.exceptions.Timeout: + print_error(f"Request timeout - server took too long to respond") + return False, None, None + + except Exception as e: + print_error(f"Error registering {email}: {str(e)}") + return False, None, None + + +def login_admin() -> Tuple[bool, Optional[str], Optional[str]]: + """ + Login as admin to get authentication token and user ID + + Returns: + Tuple of (success: bool, token: Optional[str], user_id: Optional[str]) + """ + print_info(f"Logging in as admin ({ADMIN_EMAIL})...") + + url = f"{AUTH_API_URL}/login" + payload = { + "email": ADMIN_EMAIL, + "password": ADMIN_PASSWORD + } + + try: + response = requests.post(url, json=payload, timeout=10) + http_status = response.status_code + + if http_status == 200: + data = response.json() + token = data.get('token', '') + user_id = data.get('user', {}).get('id', '') + if token: + print_success("Admin login successful") + return True, token, user_id + else: + print_error("No token received from login") + return False, None, None + elif http_status == 401: + print_error("Admin login failed: Invalid credentials") + print_info(f"Please check ADMIN_EMAIL and ADMIN_PASSWORD environment variables") + print_info(f"Current admin email: {ADMIN_EMAIL}") + print_info(f"Expected password from seed: Admin123!") + return False, None, None + else: + try: + error_data = response.json() + error_msg = error_data.get('error', response.text) + print_error(f"Admin login failed: HTTP {http_status} - {error_msg}") + except: + print_error(f"Admin login failed: HTTP {http_status}") + return False, None, None + + except requests.exceptions.ConnectionError: + print_error(f"Connection error - cannot reach {url}") + return False, None, None + except Exception as e: + print_error(f"Error during admin login: {str(e)}") + return False, None, None + + +def activate_users_via_api(user_emails: List[str], admin_token: str) -> bool: + """ + Activate users via protected API endpoint (requires admin authentication) + + Args: + user_emails: List of email addresses to activate + admin_token: JWT token from admin login + + Returns: + True if successful, False otherwise + """ + if not user_emails: + print_info("No user emails to activate") + return False + + print_info(f"Activating {len(user_emails)} users via API...") + + url = f"{AUTH_API_URL}/admin/activate-users" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {admin_token}" + } + payload = { + "emails": user_emails + } + + try: + response = requests.post(url, json=payload, headers=headers, timeout=10) + http_status = response.status_code + + if http_status == 200: + data = response.json() + activated = data.get('activated', 0) + not_found = data.get('notFound', 0) + + print_success(f"Activated {activated} user(s) via API") + if not_found > 0: + print_info(f"{not_found} user(s) not found (may have been already activated)") + return True + elif http_status == 401: + print_error("Authentication failed - invalid admin token") + return False + elif http_status == 403: + print_error("Authorization failed - admin access required") + return False + else: + try: + error_data = response.json() + error_msg = error_data.get('error', response.text) + print_error(f"Activation failed: HTTP {http_status} - {error_msg}") + except: + print_error(f"Activation failed: HTTP {http_status}") + return False + + except requests.exceptions.ConnectionError: + print_error(f"Connection error - cannot reach {url}") + return False + except Exception as e: + print_error(f"Error activating users: {str(e)}") + return False + + +def seed_users_and_speakers(admin_token: Optional[str] = None) -> Tuple[List[Dict], List[Dict], List[str], bool]: + """ + Seed users and speakers + + Returns: + Tuple of (speakers list, users list, all_emails list, got_502_errors bool) + """ + speakers = [] + users = [] + all_user_emails = [] + got_502_errors = False + + # Register Speakers + print() + from .utils import print_header + print_header("Step 1: Registering Speakers") + print("-" * 50) + + for i in range(1, 6): + email = f"speaker{i}@test.com" + password = f"Speaker{i}123!" + name = f"Speaker {i}" + + success, user_id, status = register_user(email, password, name, "SPEAKER") + if status == 502: + got_502_errors = True + if success: + all_user_emails.append(email) + # Add to speakers list even if user_id is None (user already exists) + speakers.append({"email": email, "user_id": user_id}) + time.sleep(0.2) + + # Register Regular Users + print() + print_header("Step 2: Registering Regular Users") + print("-" * 50) + + for i in range(1, 11): + email = f"user{i}@test.com" + password = f"User{i}123!" + name = f"User {i}" + + success, user_id, status = register_user(email, password, name, "USER") + if status == 502: + got_502_errors = True + if success: + all_user_emails.append(email) + # Add to users list even if user_id is None (user already exists) + users.append({"email": email, "user_id": user_id}) + time.sleep(0.2) + + return speakers, users, all_user_emails, got_502_errors + diff --git a/scripts/modules/utils.py b/scripts/modules/utils.py new file mode 100644 index 0000000..f5f9061 --- /dev/null +++ b/scripts/modules/utils.py @@ -0,0 +1,41 @@ +""" +Utility functions for seeding script +""" +import os +from typing import Optional + +# Configuration +AUTH_API_URL = os.getenv('AUTH_API_URL', 'http://localhost/api/auth') +SPEAKER_API_URL = os.getenv('SPEAKER_API_URL', 'http://localhost/api/speakers') +EVENT_API_URL = os.getenv('EVENT_API_URL', 'http://localhost/api/event') +BOOKING_API_URL = os.getenv('BOOKING_API_URL', 'http://localhost/api/booking') + +# Admin credentials +ADMIN_EMAIL = os.getenv('ADMIN_EMAIL', 'admin@eventmanagement.com') +ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'Admin123!') + +# Colors for terminal output +class Colors: + RED = '\033[0;31m' + GREEN = '\033[0;32m' + YELLOW = '\033[1;33m' + BLUE = '\033[0;34m' + MAGENTA = '\033[0;35m' + CYAN = '\033[0;36m' + RESET = '\033[0m' + +def print_success(message: str): + print(f"{Colors.GREEN}✅ {message}{Colors.RESET}") + +def print_error(message: str): + print(f"{Colors.RED}❌ {message}{Colors.RESET}") + +def print_info(message: str): + print(f"{Colors.YELLOW}ℹ️ {message}{Colors.RESET}") + +def print_header(message: str): + print(f"{Colors.BLUE}{message}{Colors.RESET}") + +def print_step(message: str): + print(f"{Colors.CYAN}→ {message}{Colors.RESET}") + diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000..6b266af --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,15 @@ +# Requirements for seed.py script +# +# Install dependencies: +# pip install -r scripts/requirements.txt +# +# Or install individually: +# pip install requests # Required for HTTP API calls +# pip install faker # Required for generating creative event names + +# Required: HTTP library for API requests +requests>=2.28.0 + +# Required: Library for generating creative event names +faker>=18.0.0 + diff --git a/scripts/seed.py b/scripts/seed.py new file mode 100644 index 0000000..03a0c29 --- /dev/null +++ b/scripts/seed.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +Complete Seeding Script for Event Management System + +This script seeds: +1. Users and Speakers (via auth-service) +2. Activates users (via admin API) +3. Creates events (via event-service, assigned to speakers) +4. Creates bookings/registrations (via booking-service) +5. Seeds speaker data: invitations, materials, and messages + +Usage: + python3 scripts/seed.py + + # With custom API URLs + AUTH_API_URL=http://localhost:3000/api/auth python3 scripts/seed.py +""" +import os +import sys +import time + +try: + import requests +except ImportError: + print("Error: 'requests' library is required but not installed.") + print("Please install it using: pip install requests") + sys.exit(1) + +try: + from faker import Faker +except ImportError: + print("Error: 'faker' library is required but not installed.") + print("Please install it using: pip install faker") + sys.exit(1) + +# Import modules +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from modules import utils +from modules.user_seeding import ( + login_admin, activate_users_via_api, seed_users_and_speakers +) +from modules.event_seeding import seed_events +from modules.booking_seeding import seed_bookings +from modules.speaker_seeding import seed_speaker_data + + +def test_api_connectivity() -> bool: + """Test if the API is reachable before starting""" + utils.print_info(f"Testing API connectivity to {utils.AUTH_API_URL}...") + + # Try health endpoint first + try: + health_url = f"{utils.AUTH_API_URL}/health" + response = requests.get(health_url, timeout=5) + if response.status_code in [200, 404]: + utils.print_success("API is reachable (health check)") + return True + except: + pass + + # Fallback: try register endpoint + try: + test_url = f"{utils.AUTH_API_URL}/register" + response = requests.post( + test_url, + json={"email": "test@test.com", "password": "test123!", "name": "Test", "role": "USER"}, + timeout=5 + ) + if response.status_code in [201, 400, 500]: + utils.print_success("API is reachable (register endpoint accessible)") + return True + elif response.status_code == 502: + utils.print_error("Cannot connect to auth-service (502 Bad Gateway)") + return False + except requests.exceptions.ConnectionError: + utils.print_error(f"Cannot connect to {utils.AUTH_API_URL}") + return False + except: + pass + + utils.print_info("Could not verify API connectivity, but will attempt to proceed...") + return True + + +def main(): + """Main execution function""" + utils.print_header("=" * 60) + utils.print_header(" Event Management System - Complete Seeding Script") + utils.print_header("=" * 60) + print() + + # Test connectivity + if not test_api_connectivity(): + print() + utils.print_error("API connectivity test failed") + print() + utils.print_info("Troubleshooting steps:") + utils.print_info(" 1. Ensure the API server is running") + utils.print_info(" 2. Check the API URLs are correct") + utils.print_info(" 3. If using Docker: docker ps | grep -E 'auth-service|event-service|booking-service'") + print() + response = input("Continue anyway? (y/N): ") + if response.lower() != 'y': + sys.exit(1) + print() + + # Step 0: Verify Admin Login + utils.print_header("Step 0: Verifying Admin Credentials") + print("-" * 60) + utils.print_info("Checking admin credentials before starting...") + + login_success, admin_token, admin_user_id = login_admin() + if not login_success or not admin_token or not admin_user_id: + utils.print_error("Admin login failed - cannot proceed") + print() + utils.print_error("Please ensure:") + utils.print_error(" 1. An admin user exists (seed via: cd ems-services/auth-service && npx prisma db seed)") + utils.print_error(" 2. ADMIN_EMAIL and ADMIN_PASSWORD are set correctly") + print() + utils.print_info("Default admin credentials from seed file:") + utils.print_info(" Email: admin@eventmanagement.com") + utils.print_info(" Password: Admin123!") + print() + sys.exit(1) + + utils.print_success("Admin credentials verified") + print() + + # Step 1-2: Seed Users and Speakers + speakers, users, all_user_emails, got_502_errors = seed_users_and_speakers(admin_token) + + if got_502_errors: + print() + utils.print_error("⚠️ 502 Bad Gateway Errors Detected") + utils.print_error("The nginx gateway is reachable, but it cannot connect to auth-service.") + utils.print_info("Please ensure auth-service is running: docker ps | grep auth-service") + print() + + # Step 3: Wait for RabbitMQ Processing + utils.print_header("Step 3: Waiting for RabbitMQ Processing") + print("-" * 60) + utils.print_info("Waiting 5 seconds for RabbitMQ to process speaker profile creation messages...") + time.sleep(5) + + # Step 4: Activate Users + utils.print_header("Step 4: Activating Users via API") + print("-" * 60) + if all_user_emails and admin_token: + activate_users_via_api(all_user_emails, admin_token) + else: + if not all_user_emails: + utils.print_info("No users created, skipping activation") + elif not admin_token: + utils.print_error("Cannot activate users - admin token not available") + + # Step 5: Create Events + if admin_token and admin_user_id: + events = seed_events(admin_token, admin_user_id, num_events=8) + # Wait a moment for events to be fully processed + time.sleep(2) + else: + utils.print_error("Cannot create events - admin token or user ID not available") + events = [] + + # Step 6: Create Bookings + if events and users: + seed_bookings(events, users) + else: + if not events: + utils.print_info("Skipping bookings - no events created") + if not users: + utils.print_info("Skipping bookings - no users available") + + # Step 7: Seed Speaker Data (Invitations, Materials, Messages) + if admin_token and admin_user_id and speakers and events: + speaker_emails = [s['email'] for s in speakers if s.get('email')] + if speaker_emails: + speaker_stats = seed_speaker_data( + admin_token=admin_token, + admin_user_id=admin_user_id, + speaker_emails=speaker_emails, + events=events + ) + else: + utils.print_info("Skipping speaker data seeding - no speaker emails available") + else: + if not admin_token or not admin_user_id: + utils.print_info("Skipping speaker data seeding - admin credentials not available") + elif not speakers: + utils.print_info("Skipping speaker data seeding - no speakers created") + elif not events: + utils.print_info("Skipping speaker data seeding - no events created") + + # Summary + print() + utils.print_header("=" * 60) + utils.print_success("Seeding Complete!") + utils.print_header("=" * 60) + print() + + utils.print_info("Summary:") + if got_502_errors: + utils.print_error(" ⚠️ Some registrations may have failed due to 502 Bad Gateway errors") + else: + utils.print_success(f" ✓ {len(speakers)} Speakers registered") + utils.print_success(f" ✓ {len(users)} Users registered") + if events: + utils.print_success(f" ✓ {len(events)} Events created and published") + if users and events: + utils.print_info(" ✓ Users registered for events") + # Show speaker data stats if seeding was attempted + if admin_token and admin_user_id and speakers and events: + speaker_emails = [s['email'] for s in speakers if s.get('email')] + if speaker_emails: + utils.print_info(" ✓ Speaker data seeded (invitations, materials, messages)") + + print() + utils.print_info("Important Notes:") + if admin_token: + utils.print_success(" ✓ Users have been activated via API (emailVerified set, isActive=true)") + utils.print_info(" ✓ Users can now login without email verification") + utils.print_info(" ✓ Speaker profiles are created automatically via RabbitMQ") + utils.print_info(" ✓ Events are auto-published when created by admin") + utils.print_info(" ✓ Admin users should be seeded via: cd ems-services/auth-service && npx prisma db seed") + print() + + utils.print_info("Credentials Created:") + utils.print_info(" Speakers: speaker1@test.com to speaker5@test.com (Password: Speaker[N]123!)") + utils.print_info(" Users: user1@test.com to user10@test.com (Password: User[N]123!)") + print() + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print() + utils.print_error("Script interrupted by user") + sys.exit(1) + except Exception as e: + utils.print_error(f"Unexpected error: {str(e)}") + import traceback + traceback.print_exc() + sys.exit(1) + From 2fafb5ea80cab8f910a37eb857cdb1996dbffd57 Mon Sep 17 00:00:00 2001 From: ashwin-athappan Date: Mon, 3 Nov 2025 18:59:24 -0600 Subject: [PATCH 25/28] EMS-69: User Dashboard Mock Data Replaced with actual Backend Data Summary Replaced mock data in the attendee dashboard with backend data. Added: Backend Changes (ems-services/booking-service): 1. Added service methods in booking.service.ts: - getDashboardStats(userId) - Returns statistics (registered events, upcoming events, attended events, tickets, etc.) - getUpcomingEvents(userId, limit) - Returns upcoming events with event details from event-service - getRecentRegistrations(userId, limit) - Returns recent bookings with event details 2. Added routes in booking.routes.ts: - GET /bookings/dashboard/stats - Dashboard statistics - GET /bookings/dashboard/upcoming-events?limit=5 - Upcoming events - GET /bookings/dashboard/recent-registrations?limit=5 - Recent registrations Frontend Changes (ems-client): 1. Updated API client (booking.api.ts): - Added attendeeDashboardAPI with methods for dashboard data - Methods: getDashboardStats(), getUpcomingEvents(limit), getRecentRegistrations(limit) 2. Updated attendee dashboard page (app/dashboard/attendee/page.tsx): - Removed mock data - Added state management for stats, upcoming events, and recent registrations - Added loading and error states - Fetches data from backend on component mount - Displays real data from the booking service - Added empty state messages when no data is available Features: - Dashboard statistics: registered events, upcoming events, attended events, tickets purchased/active/used, upcoming this week/next week - Upcoming events: shows events the user registered for that are in the future, with event details from event-service - Recent registrations: shows the user's most recent bookings - Loading and error states - Navigation: clicking "View" on upcoming events navigates to event details The dashboard now fetches and displays real data from the backend instead of mock data. --- .gitignore | 2 +- ems-client/app/dashboard/attendee/page.tsx | 280 ++++++++++-------- ems-client/lib/api/booking.api.ts | 42 +++ .../auth-service/src/routes/routes.ts | 131 ++++++-- .../src/routes/booking.routes.ts | 56 ++++ .../src/services/booking.service.ts | 246 ++++++++++++++- .../__pycache__/__init__.cpython-313.pyc | Bin 200 -> 0 bytes .../booking_seeding.cpython-313.pyc | Bin 4865 -> 0 bytes .../__pycache__/event_seeding.cpython-313.pyc | Bin 10740 -> 0 bytes .../speaker_seeding.cpython-313.pyc | Bin 17312 -> 0 bytes .../__pycache__/user_seeding.cpython-313.pyc | Bin 11185 -> 0 bytes .../modules/__pycache__/utils.cpython-313.pyc | Bin 2681 -> 0 bytes 12 files changed, 604 insertions(+), 153 deletions(-) delete mode 100644 scripts/modules/__pycache__/__init__.cpython-313.pyc delete mode 100644 scripts/modules/__pycache__/booking_seeding.cpython-313.pyc delete mode 100644 scripts/modules/__pycache__/event_seeding.cpython-313.pyc delete mode 100644 scripts/modules/__pycache__/speaker_seeding.cpython-313.pyc delete mode 100644 scripts/modules/__pycache__/user_seeding.cpython-313.pyc delete mode 100644 scripts/modules/__pycache__/utils.cpython-313.pyc diff --git a/.gitignore b/.gitignore index a192f63..8a492c9 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ # python dependencies **/.venv - +**/*.pyc # testing /coverage diff --git a/ems-client/app/dashboard/attendee/page.tsx b/ems-client/app/dashboard/attendee/page.tsx index f068e23..7114966 100644 --- a/ems-client/app/dashboard/attendee/page.tsx +++ b/ems-client/app/dashboard/attendee/page.tsx @@ -18,76 +18,14 @@ import { TrendingUp, Award, MapPin, - Users + Users, + AlertCircle } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import {useLogger} from "@/lib/logger/LoggerProvider"; import {withUserAuth} from "@/components/hoc/withAuth"; - -// Mock data for development -const mockStats = { - registeredEvents: 8, - upcomingEvents: 3, - attendedEvents: 5, - ticketsPurchased: 12, - activeTickets: 4, - usedTickets: 8, - pointsEarned: 1250, - pointsThisMonth: 300, - upcomingThisWeek: 2, - nextWeekEvents: 1 -}; - -const mockUpcomingEvents = [ - { - id: '1', - title: 'TechConf 2024', - date: '2024-01-15', - time: '9:00 AM', - location: 'Convention Center', - attendees: 500, - status: 'registered', - ticketType: 'VIP Pass' - }, - { - id: '2', - title: 'React Workshop', - date: '2024-01-18', - time: '2:00 PM', - location: 'Tech Hub', - attendees: 50, - status: 'registered', - ticketType: 'Standard' - }, - { - id: '3', - title: 'Design Thinking Summit', - date: '2024-01-22', - time: '10:00 AM', - location: 'Innovation Lab', - attendees: 200, - status: 'interested', - ticketType: null - } -]; - -const mockRecentRegistrations = [ - { - id: '1', - event: 'DevSummit 2024', - date: '2024-01-10', - status: 'confirmed', - ticketType: 'Early Bird' - }, - { - id: '2', - event: 'UX Conference', - date: '2024-01-08', - status: 'confirmed', - ticketType: 'Standard' - } -]; +import { attendeeDashboardAPI } from "@/lib/api/booking.api"; const LOGGER_COMPONENT_NAME = 'AttendeeDashboard'; @@ -96,12 +34,80 @@ function AttendeeDashboard() { const router = useRouter(); const logger = useLogger(); + const [stats, setStats] = useState({ + registeredEvents: 0, + upcomingEvents: 0, + attendedEvents: 0, + ticketsPurchased: 0, + activeTickets: 0, + usedTickets: 0, + upcomingThisWeek: 0, + nextWeekEvents: 0 + }); + const [upcomingEvents, setUpcomingEvents] = useState([]); + const [recentRegistrations, setRecentRegistrations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + useEffect(() => { logger.debug(LOGGER_COMPONENT_NAME, 'Attendee dashboard loaded', { userRole: user?.role }); + loadDashboardData(); }, [user, logger]); + const loadDashboardData = async () => { + try { + setLoading(true); + setError(null); + + const [statsData, upcomingData, recentData] = await Promise.all([ + attendeeDashboardAPI.getDashboardStats(), + attendeeDashboardAPI.getUpcomingEvents(5), + attendeeDashboardAPI.getRecentRegistrations(5) + ]); + + setStats(statsData); + setUpcomingEvents(upcomingData); + setRecentRegistrations(recentData); + } catch (err: any) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load dashboard data', err); + setError(err.message || 'Failed to load dashboard data'); + } finally { + setLoading(false); + } + }; + // Loading and auth checks are handled by the HOC + if (loading) { + return ( +
+
+
+

Loading dashboard...

+
+
+ ); + } + + if (error) { + return ( +
+ + + + + Error Loading Dashboard + + + +

{error}

+ +
+
+
+ ); + } + return (
{/* Header */} @@ -169,9 +175,9 @@ function AttendeeDashboard() { -
{mockStats.registeredEvents}
+
{stats.registeredEvents}

- {mockStats.upcomingEvents} upcoming + {stats.upcomingEvents} upcoming

@@ -184,9 +190,9 @@ function AttendeeDashboard() { -
{mockStats.ticketsPurchased}
+
{stats.ticketsPurchased}

- {mockStats.activeTickets} active + {stats.activeTickets} active

@@ -194,14 +200,14 @@ function AttendeeDashboard() { - Points Earned + Attended Events -
{mockStats.pointsEarned}
+
{stats.attendedEvents}

- {mockStats.pointsThisMonth} this month + {stats.usedTickets} tickets used

@@ -214,9 +220,9 @@ function AttendeeDashboard() { -
{mockStats.upcomingThisWeek}
+
{stats.upcomingThisWeek}

- {mockStats.nextWeekEvents} next week + {stats.nextWeekEvents} next week

@@ -286,39 +292,47 @@ function AttendeeDashboard() {
- {mockUpcomingEvents.map((event) => ( -
-
-

{event.title}

-
- - - {event.date} at {event.time} - - - - {event.location} - + {upcomingEvents.length === 0 ? ( +

No upcoming events

+ ) : ( + upcomingEvents.map((event) => ( +
+
+

{event.title}

+
+ + + {event.date} at {event.time} + + + + {event.location} + +
+ {event.ticketType && ( + + {event.ticketType} + + )} +
+
+ + {event.status} + +
- {event.ticketType && ( - - {event.ticketType} - - )} -
-
- - {event.status} - -
-
- ))} + )) + )}
@@ -336,32 +350,38 @@ function AttendeeDashboard() {
- {mockRecentRegistrations.map((registration) => ( -
-
-

{registration.event}

-
- - {registration.ticketType} - - - Registered on {registration.date} - + {recentRegistrations.length === 0 ? ( +

No recent registrations

+ ) : ( + recentRegistrations.map((registration) => ( +
+
+

{registration.event}

+
+ {registration.ticketType && ( + + {registration.ticketType} + + )} + + Registered on {registration.date} + +
+
+
+ + {registration.status} + +
-
- - {registration.status} - - -
-
- ))} + )) + )}
diff --git a/ems-client/lib/api/booking.api.ts b/ems-client/lib/api/booking.api.ts index 1388d8a..f3bf47a 100644 --- a/ems-client/lib/api/booking.api.ts +++ b/ems-client/lib/api/booking.api.ts @@ -27,6 +27,31 @@ class BookingApiClient extends BaseApiClient { return this.request(endpoint); } + // Dashboard methods + async getDashboardStats(): Promise<{ + registeredEvents: number; + upcomingEvents: number; + attendedEvents: number; + ticketsPurchased: number; + activeTickets: number; + usedTickets: number; + upcomingThisWeek: number; + nextWeekEvents: number; + }> { + const response = await this.request<{ success: boolean; data: any }>('/bookings/dashboard/stats'); + return response.data; + } + + async getUpcomingEvents(limit: number = 5): Promise { + const response = await this.request<{ success: boolean; data: any[] }>(`/bookings/dashboard/upcoming-events?limit=${limit}`); + return response.data; + } + + async getRecentRegistrations(limit: number = 5): Promise { + const response = await this.request<{ success: boolean; data: any[] }>(`/bookings/dashboard/recent-registrations?limit=${limit}`); + return response.data; + } + async getBooking(bookingId: string): Promise { return this.request(`/bookings/${bookingId}`); } @@ -108,6 +133,23 @@ export const bookingAPI = { cancelBooking: (bookingId: string) => bookingApiClient.cancelBooking(bookingId) }; +export const attendeeDashboardAPI = { + /** + * Get dashboard statistics for the authenticated user + */ + getDashboardStats: () => bookingApiClient.getDashboardStats(), + + /** + * Get upcoming events for the authenticated user + */ + getUpcomingEvents: (limit?: number) => bookingApiClient.getUpcomingEvents(limit), + + /** + * Get recent registrations for the authenticated user + */ + getRecentRegistrations: (limit?: number) => bookingApiClient.getRecentRegistrations(limit) +}; + export const ticketAPI = { /** diff --git a/ems-services/auth-service/src/routes/routes.ts b/ems-services/auth-service/src/routes/routes.ts index 195089f..b5c4dd2 100644 --- a/ems-services/auth-service/src/routes/routes.ts +++ b/ems-services/auth-service/src/routes/routes.ts @@ -362,12 +362,12 @@ export function registerRoutes(app: Express, authService: AuthService) { try { const userId = contextService.getCurrentUserId(); let user = contextService.getCurrentUser(); - + // If user not in context, fetch it if (!user) { user = await authService.getProfile(userId); } - + // Check if user is admin if (!user || user.role !== 'ADMIN') { return res.status(403).json({error: 'Access denied: Admin only'}); @@ -376,7 +376,7 @@ export function registerRoutes(app: Express, authService: AuthService) { logger.info("/admin/stats - Fetching user statistics", { adminId: userId }); const { prisma } = await import('../database'); - + const totalUsers = await prisma.user.count(); res.json({ @@ -405,12 +405,12 @@ export function registerRoutes(app: Express, authService: AuthService) { try { const userId = contextService.getCurrentUserId(); let user = contextService.getCurrentUser(); - + // If user not in context, fetch it if (!user) { user = await authService.getProfile(userId); } - + // Check if user is admin if (!user || user.role !== 'ADMIN') { return res.status(403).json({error: 'Access denied: Admin only'}); @@ -424,20 +424,20 @@ export function registerRoutes(app: Express, authService: AuthService) { const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 10)); const skip = (page - 1) * limit; - logger.info("/admin/users - Fetching users", { - adminId: userId, - search, - role, - status, - page, - limit + logger.info("/admin/users - Fetching users", { + adminId: userId, + search, + role, + status, + page, + limit }); const { prisma } = await import('../database'); - + // Build where clause const where: any = {}; - + // Search filter (name or email) if (search) { where.OR = [ @@ -445,12 +445,12 @@ export function registerRoutes(app: Express, authService: AuthService) { { email: { contains: search, mode: 'insensitive' } } ]; } - + // Role filter if (role && role !== 'ALL') { where.role = role; } - + // Status filter if (status && status !== 'ALL') { where.isActive = status === 'ACTIVE'; @@ -458,7 +458,7 @@ export function registerRoutes(app: Express, authService: AuthService) { // Get total count for pagination const total = await prisma.user.count({ where }); - + // Get paginated users const users = await prisma.user.findMany({ where, @@ -508,6 +508,95 @@ export function registerRoutes(app: Express, authService: AuthService) { } }); + /** + * @route POST /api/auth/admin/activate-users + * @desc Activate multiple users by setting isActive=true and emailVerified=now() + * @access Protected - Admin only + * @body { emails: string[] } - Array of user email addresses to activate + */ + app.post('/admin/activate-users', authMiddleware, async (req: Request, res: Response) => { + try { + const userId = contextService.getCurrentUserId(); + let user = contextService.getCurrentUser(); + + // If user not in context, fetch it + if (!user) { + user = await authService.getProfile(userId); + } + + // Check if user is admin + if (!user || user.role !== 'ADMIN') { + return res.status(403).json({error: 'Access denied: Admin only'}); + } + + const { emails } = req.body; + + // Validate request body + if (!emails || !Array.isArray(emails) || emails.length === 0) { + return res.status(400).json({error: 'emails array is required and must not be empty'}); + } + + logger.info("/admin/activate-users - Activating users", { + adminId: userId, + emailCount: emails.length + }); + + const { prisma } = await import('../database'); + + let activated = 0; + let notFound = 0; + const currentDate = new Date(); + + // Process each email + for (const email of emails) { + if (!email || typeof email !== 'string') { + continue; // Skip invalid emails + } + + try { + const updateResult = await prisma.user.updateMany({ + where: { + email: email.trim().toLowerCase() + }, + data: { + isActive: true, + emailVerified: currentDate + } + }); + + if (updateResult.count > 0) { + activated++; + logger.debug("/admin/activate-users - User activated", { email }); + } else { + notFound++; + logger.debug("/admin/activate-users - User not found", { email }); + } + } catch (error: any) { + logger.error("/admin/activate-users - Error activating user", error, { email }); + notFound++; // Count as not found on error + } + } + + logger.info("/admin/activate-users - Activation complete", { + adminId: userId, + activated, + notFound, + total: emails.length + }); + + res.json({ + success: true, + activated, + notFound, + total: emails.length, + message: `Activated ${activated} user(s), ${notFound} user(s) not found` + }); + } catch (error: any) { + logger.error("/admin/activate-users - Failed to activate users", error); + res.status(500).json({error: 'Failed to activate users'}); + } + }); + /** * @route GET /api/auth/admin/reports/user-growth * @desc Get user growth trend (monthly user registrations). @@ -517,11 +606,11 @@ export function registerRoutes(app: Express, authService: AuthService) { try { const userId = contextService.getCurrentUserId(); let user = contextService.getCurrentUser(); - + if (!user) { user = await authService.getProfile(userId); } - + if (!user || user.role !== 'ADMIN') { return res.status(403).json({error: 'Access denied: Admin only'}); } @@ -529,7 +618,7 @@ export function registerRoutes(app: Express, authService: AuthService) { logger.info("/admin/reports/user-growth - Fetching user growth data", { adminId: userId }); const { prisma } = await import('../database'); - + // Get all users ordered by creation date const users = await prisma.user.findMany({ select: { @@ -555,7 +644,7 @@ export function registerRoutes(app: Express, authService: AuthService) { const monthIndex = Object.keys(monthlyGrowth).indexOf(month); const previousMonths = Object.values(monthlyGrowth).slice(0, monthIndex); const cumulativeUsers = previousMonths.reduce((sum, val) => sum + val, 0) + count; - + return { month, users: cumulativeUsers, diff --git a/ems-services/booking-service/src/routes/booking.routes.ts b/ems-services/booking-service/src/routes/booking.routes.ts index 5d60596..85b7b7e 100644 --- a/ems-services/booking-service/src/routes/booking.routes.ts +++ b/ems-services/booking-service/src/routes/booking.routes.ts @@ -184,4 +184,60 @@ router.get('/bookings/event/:eventId/capacity', }) ); +/** + * GET /bookings/dashboard/stats - Get dashboard statistics for the authenticated user + */ +router.get('/bookings/dashboard/stats', + asyncHandler(async (req: AuthRequest, res: Response) => { + const userId = req.user!.userId; + + logger.info('Fetching dashboard stats', { userId }); + + const stats = await bookingService.getDashboardStats(userId); + + res.json({ + success: true, + data: stats + }); + }) +); + +/** + * GET /bookings/dashboard/upcoming-events - Get upcoming events for the authenticated user + */ +router.get('/bookings/dashboard/upcoming-events', + asyncHandler(async (req: AuthRequest, res: Response) => { + const userId = req.user!.userId; + const limit = req.query.limit ? parseInt(req.query.limit as string) : 5; + + logger.info('Fetching upcoming events', { userId, limit }); + + const events = await bookingService.getUpcomingEvents(userId, limit); + + res.json({ + success: true, + data: events + }); + }) +); + +/** + * GET /bookings/dashboard/recent-registrations - Get recent registrations for the authenticated user + */ +router.get('/bookings/dashboard/recent-registrations', + asyncHandler(async (req: AuthRequest, res: Response) => { + const userId = req.user!.userId; + const limit = req.query.limit ? parseInt(req.query.limit as string) : 5; + + logger.info('Fetching recent registrations', { userId, limit }); + + const registrations = await bookingService.getRecentRegistrations(userId, limit); + + res.json({ + success: true, + data: registrations + }); + }) +); + export default router; diff --git a/ems-services/booking-service/src/services/booking.service.ts b/ems-services/booking-service/src/services/booking.service.ts index 739c7e5..855a7b3 100644 --- a/ems-services/booking-service/src/services/booking.service.ts +++ b/ems-services/booking-service/src/services/booking.service.ts @@ -12,6 +12,7 @@ import { import { BookingStatus } from '../../generated/prisma'; import { eventPublisherService } from './event-publisher.service'; import { ticketService } from './ticket.service'; +import axios from 'axios'; class BookingService { /** @@ -75,7 +76,7 @@ class BookingService { eventId: booking.eventId, createdAt: booking.createdAt.toISOString() }; - + await eventPublisherService.publishBookingConfirmed(bookingMessage); // Booking confirmation email will be sent via notification-service pipeline triggered by booking.confirmed event @@ -345,6 +346,249 @@ class BookingService { } } + /** + * Get dashboard statistics for a user + */ + async getDashboardStats(userId: string): Promise<{ + registeredEvents: number; + upcomingEvents: number; + attendedEvents: number; + ticketsPurchased: number; + activeTickets: number; + usedTickets: number; + upcomingThisWeek: number; + nextWeekEvents: number; + }> { + try { + logger.info('Fetching dashboard stats', { userId }); + const now = new Date(); + const oneWeekFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); + + // Get all confirmed bookings for the user + const allBookings = await prisma.booking.findMany({ + where: { + userId: userId, + status: BookingStatus.CONFIRMED + }, + include: { + event: true, + tickets: { + select: { + id: true, + status: true + } + } + } + }); + + // Count registered events (all confirmed bookings) + const registeredEvents = allBookings.length; + + // Count upcoming events (events with bookingStartDate in the future) + const upcomingEvents = allBookings.filter(booking => { + const eventStart = new Date(booking.event.bookingStartDate); + return eventStart > now; + }).length; + + // Count attended events (bookings where isAttended is true) + const attendedEvents = allBookings.filter(booking => booking.isAttended === true).length; + + // Count tickets + let ticketsPurchased = 0; + let activeTickets = 0; + let usedTickets = 0; + + allBookings.forEach(booking => { + ticketsPurchased += booking.tickets.length; + booking.tickets.forEach(ticket => { + if (ticket.status === 'ISSUED' || ticket.status === 'SCANNED') { + activeTickets++; + } + if (ticket.status === 'SCANNED') { + usedTickets++; + } + }); + }); + + // Count upcoming events this week + const upcomingThisWeek = allBookings.filter(booking => { + const eventStart = new Date(booking.event.bookingStartDate); + return eventStart > now && eventStart <= oneWeekFromNow; + }).length; + + // Count events next week + const nextWeekEvents = allBookings.filter(booking => { + const eventStart = new Date(booking.event.bookingStartDate); + return eventStart > oneWeekFromNow && eventStart <= twoWeeksFromNow; + }).length; + + return { + registeredEvents, + upcomingEvents, + attendedEvents, + ticketsPurchased, + activeTickets, + usedTickets, + upcomingThisWeek, + nextWeekEvents + }; + } catch (error) { + logger.error('Failed to fetch dashboard stats', error as Error, { userId }); + throw error; + } + } + + /** + * Get upcoming events for a user (with event details from event-service) + */ + async getUpcomingEvents(userId: string, limit: number = 5): Promise { + try { + logger.info('Fetching upcoming events', { userId, limit }); + const now = new Date(); + + const bookings = await prisma.booking.findMany({ + where: { + userId: userId, + status: BookingStatus.CONFIRMED + }, + include: { + event: true, + tickets: { + select: { + id: true, + status: true + } + } + }, + orderBy: { + event: { + bookingStartDate: 'asc' + } + } + }); + + // Filter to only upcoming events and limit results + const upcomingBookings = bookings + .filter(booking => { + const eventStart = new Date(booking.event.bookingStartDate); + return eventStart > now; + }) + .slice(0, limit); + + // Fetch event details from event-service for each booking + const eventServiceUrl = process.env.EVENT_SERVICE_URL || 'http://event-service:3000'; + + const eventsWithDetails = await Promise.all( + upcomingBookings.map(async (booking) => { + try { + const eventResponse = await axios.get(`${eventServiceUrl}/events/${booking.eventId}`, { + timeout: 5000 + }); + const eventDetails = eventResponse.data.data || eventResponse.data; + + return { + id: booking.eventId, + title: eventDetails.name || 'Unknown Event', + date: new Date(booking.event.bookingStartDate).toISOString().split('T')[0], + time: new Date(booking.event.bookingStartDate).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }), + location: eventDetails.venue?.name || 'TBD', + attendees: eventDetails.venue?.capacity || 0, + status: 'registered', + ticketType: booking.tickets.length > 0 ? 'Standard' : null, + bookingId: booking.id + }; + } catch (error) { + logger.warn('Failed to fetch event details', { eventId: booking.eventId }); + return { + id: booking.eventId, + title: 'Event', + date: new Date(booking.event.bookingStartDate).toISOString().split('T')[0], + time: new Date(booking.event.bookingStartDate).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }), + location: 'TBD', + attendees: 0, + status: 'registered', + ticketType: booking.tickets.length > 0 ? 'Standard' : null, + bookingId: booking.id + }; + } + }) + ); + + return eventsWithDetails; + } catch (error) { + logger.error('Failed to fetch upcoming events', error as Error, { userId }); + throw error; + } + } + + /** + * Get recent registrations for a user + */ + async getRecentRegistrations(userId: string, limit: number = 5): Promise { + try { + logger.info('Fetching recent registrations', { userId, limit }); + + const bookings = await prisma.booking.findMany({ + where: { + userId: userId, + status: BookingStatus.CONFIRMED + }, + include: { + event: true, + tickets: { + select: { + id: true, + status: true + } + } + }, + orderBy: { + createdAt: 'desc' + }, + take: limit + }); + + // Fetch event details from event-service + const eventServiceUrl = process.env.EVENT_SERVICE_URL || 'http://event-service:3000'; + + const registrationsWithDetails = await Promise.all( + bookings.map(async (booking) => { + try { + const eventResponse = await axios.get(`${eventServiceUrl}/events/${booking.eventId}`, { + timeout: 5000 + }); + const eventDetails = eventResponse.data.data || eventResponse.data; + + return { + id: booking.id, + event: eventDetails.name || 'Unknown Event', + date: booking.createdAt.toISOString().split('T')[0], + status: 'confirmed', + ticketType: booking.tickets.length > 0 ? 'Standard' : null, + bookingId: booking.id + }; + } catch (error) { + logger.warn('Failed to fetch event details', { eventId: booking.eventId }); + return { + id: booking.id, + event: 'Event', + date: booking.createdAt.toISOString().split('T')[0], + status: 'confirmed', + ticketType: booking.tickets.length > 0 ? 'Standard' : null, + bookingId: booking.id + }; + } + }) + ); + + return registrationsWithDetails; + } catch (error) { + logger.error('Failed to fetch recent registrations', error as Error, { userId }); + throw error; + } + } + /** * Get number of users (confirmed bookings) for an event */ diff --git a/scripts/modules/__pycache__/__init__.cpython-313.pyc b/scripts/modules/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 576fbbb29ba34855ab3932b1957fb6df26e1cd20..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 200 zcmey&%ge<81aa@#GSz_eV-N=h7@>^MEI`IohI9r^M!%H|MNB~6XOPsbbp6oc)S_bj z#Nv$d%shRU{N&Qy)Vz{n{qp>x?BasNPHM4!e0*kJW=VX!UP0w84jYK5T@fqLUXZ(r PL5z>gjEsy$%s>_Z!D=>g diff --git a/scripts/modules/__pycache__/booking_seeding.cpython-313.pyc b/scripts/modules/__pycache__/booking_seeding.cpython-313.pyc deleted file mode 100644 index 4eae12e0d813517bf69a467ec0ff4e902d57042b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4865 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 diff --git a/scripts/modules/__pycache__/event_seeding.cpython-313.pyc b/scripts/modules/__pycache__/event_seeding.cpython-313.pyc deleted file mode 100644 index 60f0a70531f6057bbeca9218670137df2d65fcc4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10740 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 ILZ;=v0sOxP`Tzg` diff --git a/scripts/modules/__pycache__/speaker_seeding.cpython-313.pyc b/scripts/modules/__pycache__/speaker_seeding.cpython-313.pyc deleted file mode 100644 index af01125cb03ce6673328572417f5c93afd6cc319..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17312 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 diff --git a/scripts/modules/__pycache__/user_seeding.cpython-313.pyc b/scripts/modules/__pycache__/user_seeding.cpython-313.pyc deleted file mode 100644 index 10e442acbebae86baed37ed8efd4ec54d136f2fd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11185 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 Kzd<=_r~d)el@PxG diff --git a/scripts/modules/__pycache__/utils.cpython-313.pyc b/scripts/modules/__pycache__/utils.cpython-313.pyc deleted file mode 100644 index 698eb6a6c85708bba511a12a81dfaec425930b38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2681 zcmd5--*3}K96u+HlQ_THw1xg)EHDO(P;1)I0;5a|tyj9XNflENG~gN6O$~{i-8rCE zXo|EAwG&dMN(et}Qjw6L5)u!4+1~dDM5-!9*7m}lc%rDA_PRSe3GHa5?P;9k?|r{_ zpF8_=pYMIW6$-Hcx9hJM^+y_j-$_t!IJVo?RSJLyKmZU3l;9epAccGPnCo*lbjLuJ z%J}L_-t>ooKntFJmooxAd!iV@+dwgUqI`nCf%5K&Vue5h<=Yb#6haM@e@|3ch%``a zJHULJI)TvK7z+r|Z1AK@Xz2tuX&9lkk}+pRgh@U*#%CJX_jbjiq9WZ=AhJUIB|derf$rQD zt=v{L!-||wr^iN9BMtC@UBNeW{T3!;1(L%PqbZS}NRHw!mSzg7_Nl#5LDHn0QovjG z$a=vFJGjZDAY4voh8O%v0`2Wf9FpIp82~sp?Y4CScMm`o;Oqp@B~Z{UxF9XKp+}%0 zBY2=!V4zR%LcidHtl)=%92*Z<%#fbfA$l3b%NM*|(X0dDVmO_-5Gw^ z@{DA7K4mdi`0?@dWs5#PKE+$!iR1{DA!*S=SCT2qli>yaqSYpf$dC+G7A3=g>Wz}2 zAW?)N%wUV0g*C%XAuJ*%p}9M%#trLoi5x59?&$CqDoV1#O+x*qBI6+$Dc${vzJ87; zVs=k7MCcagk)aegC;1TPut8i2rz+|x&cgtHm2Jq;p%1aS2deD8@=!I{UgoQzj`C;K z!2a@ZHQZSqalByL*F5a3ud}=b1tF}tTo)0~00uv9{A_)J%>yvqn47K@696*x(u5K9 z7RJSC3WUKn6xrz?EesNLe{IJY$bq;kRU(_c`2O2ioVGkgsA`78tOXClc5K!tcmQ{Z zEQ`S|Y^{)u6H!T)F#{OGV`Lm1!ln#LW?-MTD=ms3R3WPd@hw&s?w1e!`rK^kMCT(6eM<35V z$v?`kUaznxH|YL%fGqhtz4+$mS3fPsb`oyI6T1^;9m1-HtMCzwBD(J2du{33V%j{t zI%wd~VWg+04L55CRUgV);md>uRgC7=IQHoJFZGnLw*Xd8ax?gWDl!8t{m z!Kxm`I6@N<;p_Q3y)?ZzVIDI(9uGVjd^EV)Q(=2I=)SiJ{y!J?-pWof_wUKICMGUs zMfed$9V1M)-&BWbtC=&eJih(p?xVY_T7~W3pa*QO@u=k$#Ti}3mD;5vi!tVl_&)}T zrWTV^3`M&QY4UcWG$f}1y~L2bgMMO2rL^40fOe&@Z;+i<_QBiVWEXGybxwit4Lara z6WD{-lBP(->GJ(a_szic&HqxHn%+N zSm~&A9e+B#-h8GU{)_Qa`(6WVHlq}E+D!ZbaJR`&)PUJS`~l*hGu^~LNBo5O0r3;W NKV`cA#J*Gh{s#LtAo&0Q From fb4a728893cab0c566406cca759613e06de6fa08 Mon Sep 17 00:00:00 2001 From: ashwin-athappan Date: Mon, 3 Nov 2025 19:11:12 -0600 Subject: [PATCH 26/28] EMS-69: Fixed TypeScript errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed Issues: 1. Changed tickets to ticket (singular) — The Prisma schema uses ticket (singular, optional), not tickets (plural). 2. Removed references to booking.event.bookingStartDate — The Event model in booking-service doesn't include this field. It only has id, capacity, isActive, createdAt, and updatedAt. 3. Fixed ticket access — Changed from booking.tickets.length and booking.tickets.forEach() to booking.ticket (checking if it exists since it's optional). 4. Fixed orderBy clause — Removed the invalid event: { bookingStartDate: 'asc' } since bookingStartDate doesn't exist in the Event model. Now using createdAt: 'desc' and sorting after fetching event details from event-service. 5. Added event-service fetching — Methods now fetch event details from event-service to get bookingStartDate and other event information not stored in booking-service. The code should now compile successfully. The booking service will: - Use the correct Prisma relationship (ticket instead of tickets) - Fetch event details from event-service when needed - Handle the optional ticket relationship correctly - Sort events properly after fetching their details from event-service --- .../src/services/booking.service.ts | 136 ++++++++++++------ 1 file changed, 92 insertions(+), 44 deletions(-) diff --git a/ems-services/booking-service/src/services/booking.service.ts b/ems-services/booking-service/src/services/booking.service.ts index 855a7b3..0a82825 100644 --- a/ems-services/booking-service/src/services/booking.service.ts +++ b/ems-services/booking-service/src/services/booking.service.ts @@ -373,7 +373,7 @@ class BookingService { }, include: { event: true, - tickets: { + ticket: { select: { id: true, status: true @@ -385,9 +385,34 @@ class BookingService { // Count registered events (all confirmed bookings) const registeredEvents = allBookings.length; + // Fetch event details from event-service to get bookingStartDate + const eventServiceUrl = process.env.EVENT_SERVICE_URL || 'http://event-service:3000'; + const eventDetailsMap = new Map(); + + // Fetch event details for all unique event IDs + const uniqueEventIds = Array.from(new Set(allBookings.map(b => b.eventId))); + await Promise.all( + uniqueEventIds.map(async (eventId) => { + try { + const eventResponse = await axios.get(`${eventServiceUrl}/events/${eventId}`, { + timeout: 5000 + }); + const eventDetails = eventResponse.data.data || eventResponse.data; + eventDetailsMap.set(eventId, { + bookingStartDate: eventDetails.bookingStartDate, + bookingEndDate: eventDetails.bookingEndDate + }); + } catch (error) { + logger.warn('Failed to fetch event details for stats', { eventId }); + } + }) + ); + // Count upcoming events (events with bookingStartDate in the future) const upcomingEvents = allBookings.filter(booking => { - const eventStart = new Date(booking.event.bookingStartDate); + const eventDetails = eventDetailsMap.get(booking.eventId); + if (!eventDetails) return false; + const eventStart = new Date(eventDetails.bookingStartDate); return eventStart > now; }).length; @@ -400,26 +425,31 @@ class BookingService { let usedTickets = 0; allBookings.forEach(booking => { - ticketsPurchased += booking.tickets.length; - booking.tickets.forEach(ticket => { + if (booking.ticket) { + ticketsPurchased++; + const ticket = booking.ticket; if (ticket.status === 'ISSUED' || ticket.status === 'SCANNED') { activeTickets++; } if (ticket.status === 'SCANNED') { usedTickets++; } - }); + } }); // Count upcoming events this week const upcomingThisWeek = allBookings.filter(booking => { - const eventStart = new Date(booking.event.bookingStartDate); + const eventDetails = eventDetailsMap.get(booking.eventId); + if (!eventDetails) return false; + const eventStart = new Date(eventDetails.bookingStartDate); return eventStart > now && eventStart <= oneWeekFromNow; }).length; // Count events next week const nextWeekEvents = allBookings.filter(booking => { - const eventStart = new Date(booking.event.bookingStartDate); + const eventDetails = eventDetailsMap.get(booking.eventId); + if (!eventDetails) return false; + const eventStart = new Date(eventDetails.bookingStartDate); return eventStart > oneWeekFromNow && eventStart <= twoWeeksFromNow; }).length; @@ -454,7 +484,7 @@ class BookingService { }, include: { event: true, - tickets: { + ticket: { select: { id: true, status: true @@ -462,59 +492,77 @@ class BookingService { } }, orderBy: { - event: { - bookingStartDate: 'asc' - } + createdAt: 'desc' } }); - // Filter to only upcoming events and limit results - const upcomingBookings = bookings - .filter(booking => { - const eventStart = new Date(booking.event.bookingStartDate); - return eventStart > now; - }) - .slice(0, limit); - - // Fetch event details from event-service for each booking + // Fetch event details from event-service for all bookings const eventServiceUrl = process.env.EVENT_SERVICE_URL || 'http://event-service:3000'; - const eventsWithDetails = await Promise.all( - upcomingBookings.map(async (booking) => { + const bookingsWithEventDetails = await Promise.all( + bookings.map(async (booking) => { try { const eventResponse = await axios.get(`${eventServiceUrl}/events/${booking.eventId}`, { timeout: 5000 }); const eventDetails = eventResponse.data.data || eventResponse.data; - return { - id: booking.eventId, - title: eventDetails.name || 'Unknown Event', - date: new Date(booking.event.bookingStartDate).toISOString().split('T')[0], - time: new Date(booking.event.bookingStartDate).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }), - location: eventDetails.venue?.name || 'TBD', - attendees: eventDetails.venue?.capacity || 0, - status: 'registered', - ticketType: booking.tickets.length > 0 ? 'Standard' : null, - bookingId: booking.id + booking, + eventDetails }; } catch (error) { logger.warn('Failed to fetch event details', { eventId: booking.eventId }); return { - id: booking.eventId, - title: 'Event', - date: new Date(booking.event.bookingStartDate).toISOString().split('T')[0], - time: new Date(booking.event.bookingStartDate).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }), - location: 'TBD', - attendees: 0, - status: 'registered', - ticketType: booking.tickets.length > 0 ? 'Standard' : null, - bookingId: booking.id + booking, + eventDetails: null }; } }) ); + // Filter to only upcoming events and sort by start date + const upcomingBookings = bookingsWithEventDetails + .filter(({ booking, eventDetails }) => { + if (!eventDetails) return false; + const eventStart = new Date(eventDetails.bookingStartDate); + return eventStart > now; + }) + .sort((a, b) => { + if (!a.eventDetails || !b.eventDetails) return 0; + const dateA = new Date(a.eventDetails.bookingStartDate); + const dateB = new Date(b.eventDetails.bookingStartDate); + return dateA.getTime() - dateB.getTime(); + }) + .slice(0, limit); + + const eventsWithDetails = upcomingBookings.map(({ booking, eventDetails }) => { + if (!eventDetails) { + return { + id: booking.eventId, + title: 'Event', + date: new Date().toISOString().split('T')[0], + time: 'TBD', + location: 'TBD', + attendees: 0, + status: 'registered', + ticketType: booking.ticket ? 'Standard' : null, + bookingId: booking.id + }; + } + + return { + id: booking.eventId, + title: eventDetails.name || 'Unknown Event', + date: new Date(eventDetails.bookingStartDate).toISOString().split('T')[0], + time: new Date(eventDetails.bookingStartDate).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }), + location: eventDetails.venue?.name || 'TBD', + attendees: eventDetails.venue?.capacity || 0, + status: 'registered', + ticketType: booking.ticket ? 'Standard' : null, + bookingId: booking.id + }; + }); + return eventsWithDetails; } catch (error) { logger.error('Failed to fetch upcoming events', error as Error, { userId }); @@ -536,7 +584,7 @@ class BookingService { }, include: { event: true, - tickets: { + ticket: { select: { id: true, status: true @@ -565,7 +613,7 @@ class BookingService { event: eventDetails.name || 'Unknown Event', date: booking.createdAt.toISOString().split('T')[0], status: 'confirmed', - ticketType: booking.tickets.length > 0 ? 'Standard' : null, + ticketType: booking.ticket ? 'Standard' : null, bookingId: booking.id }; } catch (error) { @@ -575,7 +623,7 @@ class BookingService { event: 'Event', date: booking.createdAt.toISOString().split('T')[0], status: 'confirmed', - ticketType: booking.tickets.length > 0 ? 'Standard' : null, + ticketType: booking.ticket ? 'Standard' : null, bookingId: booking.id }; } From 2ee81002ea81c2ed9ad5041b3544a93856c512d0 Mon Sep 17 00:00:00 2001 From: ashwin-athappan Date: Mon, 3 Nov 2025 19:13:02 -0600 Subject: [PATCH 27/28] EMS-69: Add Back Buttons in Dashboards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary Added back buttons to all pages within app/dashboard/admin, app/dashboard/speaker, and app/dashboard/attendee (excluding the home page.tsx files). Changes Made 1 EventDetailsPage Component (components/events/EventDetailsPage.tsx) • Updated back button to navigate to the correct parent page based on user role: • ADMIN → /dashboard/admin/events • SPEAKER → /dashboard/speaker/events • USER → /dashboard/attendee/events 1 LiveEventAuditorium Component (components/events/LiveEventAuditorium.tsx) • Updated back buttons (header and error state) to navigate to the event details page based on user role: • ADMIN → /dashboard/admin/events/{eventId} • SPEAKER → /dashboard/speaker/events/{eventId} • USER → /dashboard/attendee/events/{eventId} Pages Verified with Back Buttons Admin: • ✅ events/[id]/page.tsx (uses EventDetailsPage) • ✅ events/[id]/live/page.tsx (uses LiveEventAuditorium) • ✅ events/create/page.tsx (already had back button) • ✅ events/modify/[id]/page.tsx (already had back button) • ✅ events/pending/page.tsx (already had back button) • ✅ users/flagged/page.tsx (already had back button) Speaker: • ✅ events/[id]/page.tsx (uses EventDetailsPage) • ✅ events/[id]/live/page.tsx (uses LiveEventAuditorium) • ✅ events/create/page.tsx (already had back button) • ✅ events/edit/[id]/page.tsx (already had back button) Attendee: • ✅ events/[id]/page.tsx (uses EventDetailsPage) • ✅ events/[id]/live/page.tsx (uses LiveEventAuditorium) All back buttons now navigate to the correct parent pages based on the user's role and the current page context. --- .../components/events/EventDetailsPage.tsx | 64 +++++++++-------- .../components/events/LiveEventAuditorium.tsx | 68 ++++++++++++------- 2 files changed, 80 insertions(+), 52 deletions(-) diff --git a/ems-client/components/events/EventDetailsPage.tsx b/ems-client/components/events/EventDetailsPage.tsx index d941d45..64691b2 100644 --- a/ems-client/components/events/EventDetailsPage.tsx +++ b/ems-client/components/events/EventDetailsPage.tsx @@ -50,19 +50,19 @@ interface EventDetailsPageProps { showSpeakerControls?: boolean; } -export const EventDetailsPage = ({ - userRole, - showJoinInterface = true, - showAdminControls = false, - showSpeakerControls = false +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); @@ -71,7 +71,7 @@ export const EventDetailsPage = ({ speakerEmail?: string | null; isAttended?: boolean; } - + const [acceptedSpeakers, setAcceptedSpeakers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -95,16 +95,16 @@ export const EventDetailsPage = ({ 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) => { @@ -128,12 +128,12 @@ export const EventDetailsPage = ({ } }) ); - + setAcceptedSpeakers(speakersWithInfo); - - logger.info(LOGGER_COMPONENT_NAME, 'Accepted speakers loaded', { - eventId, - count: speakersWithInfo.length + + 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); @@ -144,14 +144,14 @@ export const EventDetailsPage = ({ 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); @@ -176,7 +176,7 @@ export const EventDetailsPage = ({ await loadAcceptedSpeakers(); setLoading(false); }; - + loadData(); }, [eventId]); @@ -189,7 +189,7 @@ export const EventDetailsPage = ({ // Auto-refresh attendance data every 30 seconds useEffect(() => { if (!event) return; - + const interval = setInterval(() => { loadAttendance(); }, 30000); @@ -256,7 +256,15 @@ export const EventDetailsPage = ({
- +
- +

Description

@@ -559,17 +567,17 @@ export const EventDetailsPage = ({

Event ID

{event.id}

- +

Created By

Admin User

- +

Created At

{formatDateTime(event.createdAt)}

- +

Last Updated

{formatDateTime(event.updatedAt)}

diff --git a/ems-client/components/events/LiveEventAuditorium.tsx b/ems-client/components/events/LiveEventAuditorium.tsx index 5cddeef..db4563f 100644 --- a/ems-client/components/events/LiveEventAuditorium.tsx +++ b/ems-client/components/events/LiveEventAuditorium.tsx @@ -51,9 +51,9 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => { 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); @@ -93,7 +93,7 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => { 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({ @@ -122,14 +122,14 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => { logger.info(LOGGER_COMPONENT_NAME, 'Loading event details', { eventId }); const eventResponse = await eventAPI.getEventById(eventId); const event = eventResponse.data; - + // Check if event has expired const now = new Date(); const eventEndDate = new Date(event.bookingEndDate); - + if (now > eventEndDate) { - logger.warn(LOGGER_COMPONENT_NAME, 'Event has expired', { - eventId, + logger.warn(LOGGER_COMPONENT_NAME, 'Event has expired', { + eventId, eventEndDate: event.bookingEndDate, currentTime: now.toISOString() }); @@ -137,7 +137,7 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => { setEvent(event); // Still set event to show details return; } - + setEvent(event); setError(null); } catch (err) { @@ -152,12 +152,12 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => { // 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); @@ -179,13 +179,13 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => { 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); @@ -207,7 +207,7 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => { if (token) { headers['Authorization'] = `Bearer ${token}`; } - + const response = await fetch(`/api/materials/${materialId}`, { headers }); @@ -222,7 +222,7 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => { return null; } }); - + const materials = await Promise.all(materialPromises); setSelectedMaterials(materials.filter(m => m !== null) as PresentationMaterial[]); } catch (err) { @@ -242,7 +242,7 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => { await loadEvent(); setLoading(false); }; - + loadData(); }, [eventId]); @@ -255,7 +255,7 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => { // Auto-refresh attendance data every 15 seconds for live experience useEffect(() => { if (!event) return; - + const interval = setInterval(() => { loadAttendance(); }, 15000); @@ -288,7 +288,19 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => {

Error Loading Event

{error}

- @@ -317,7 +329,7 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => { // 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; @@ -329,7 +341,15 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => {
- +
- +

{attendance.attendancePercentage}%

Attendance Rate

From 584c34c8dc96ae71d7ed366f17930bbf123f2f5a Mon Sep 17 00:00:00 2001 From: ashwin-athappan Date: Tue, 4 Nov 2025 14:08:33 -0600 Subject: [PATCH 28/28] EMS-69: Email Ticket to user via notification-service --- .../notification-service/package-lock.json | 157 +++++++++++-- .../notification-service/package.json | 4 +- .../src/consumers/ticket-event.consumer.ts | 207 ++++++++++++++++++ .../notification-service/src/server.ts | 6 +- .../src/services/email-template.service.ts | 101 +++++++++ .../notification-service/src/types/types.ts | 22 ++ 6 files changed, 475 insertions(+), 22 deletions(-) create mode 100644 ems-services/notification-service/src/consumers/ticket-event.consumer.ts diff --git a/ems-services/notification-service/package-lock.json b/ems-services/notification-service/package-lock.json index a4502f7..0edfb4e 100644 --- a/ems-services/notification-service/package-lock.json +++ b/ems-services/notification-service/package-lock.json @@ -9,11 +9,13 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@types/qrcode": "^1.5.6", "amqplib": "^0.10.9", "axios": "^1.6.0", "dotenv": "^17.2.2", "express": "^5.1.0", - "nodemailer": "^7.0.6" + "nodemailer": "^7.0.6", + "qrcode": "^1.5.4" }, "devDependencies": { "@jest/globals": "^29.7.0", @@ -2808,7 +2810,6 @@ "version": "24.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.12.0" @@ -2825,6 +2826,15 @@ "@types/node": "*" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -2965,7 +2975,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2975,7 +2984,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3364,7 +3372,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3526,7 +3533,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3539,7 +3545,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -3668,6 +3673,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dedent": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", @@ -3741,6 +3755,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.2.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz", @@ -3797,7 +3817,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -4075,7 +4094,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -4205,7 +4223,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4564,7 +4581,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5666,7 +5682,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -6003,7 +6018,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -6016,7 +6030,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -6032,7 +6045,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6070,7 +6082,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6156,6 +6167,15 @@ "node": ">=8" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/pretty-format": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", @@ -6261,6 +6281,89 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -6346,12 +6449,17 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -6504,6 +6612,12 @@ "node": ">= 18" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -6710,7 +6824,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6725,7 +6838,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7066,7 +7178,6 @@ "version": "7.12.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -7201,6 +7312,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/ems-services/notification-service/package.json b/ems-services/notification-service/package.json index 885a5e9..dea4420 100644 --- a/ems-services/notification-service/package.json +++ b/ems-services/notification-service/package.json @@ -20,11 +20,13 @@ "license": "ISC", "type": "commonjs", "dependencies": { + "@types/qrcode": "^1.5.6", "amqplib": "^0.10.9", "axios": "^1.6.0", "dotenv": "^17.2.2", "express": "^5.1.0", - "nodemailer": "^7.0.6" + "nodemailer": "^7.0.6", + "qrcode": "^1.5.4" }, "devDependencies": { "@jest/globals": "^29.7.0", diff --git a/ems-services/notification-service/src/consumers/ticket-event.consumer.ts b/ems-services/notification-service/src/consumers/ticket-event.consumer.ts new file mode 100644 index 0000000..d9ae0d5 --- /dev/null +++ b/ems-services/notification-service/src/consumers/ticket-event.consumer.ts @@ -0,0 +1,207 @@ +// src/consumers/ticket-event.consumer.ts + +import { connect, ChannelModel, Channel, ConsumeMessage } from 'amqplib'; +import axios from 'axios'; +import * as QRCode from 'qrcode'; +import { MESSAGE_TYPE, TicketGeneratedNotification } from '../types/types'; + +interface TicketGeneratedMessage { + ticketId: string; + userId: string; + eventId: string; + bookingId: string; + qrCodeData: string; + expiresAt: string; + createdAt: string; +} + +export class TicketEventConsumer { + private connection: ChannelModel | null = null; + private channel: Channel | null = null; + private readonly rabbitmqUrl: string; + private readonly exchangeName = 'booking_events'; + private readonly queueName = 'notification.ticket.events'; + private readonly routingKey = 'ticket.generated'; + private readonly notificationQueueName = 'notification.email'; + private readonly authServiceUrl: string; + private readonly eventServiceUrl: string; + + constructor(rabbitmqUrl: string) { + this.rabbitmqUrl = rabbitmqUrl; + this.authServiceUrl = process.env.GATEWAY_URL ? + `${process.env.GATEWAY_URL}/api/auth` : 'http://ems-gateway/api/auth'; + this.eventServiceUrl = process.env.GATEWAY_URL ? + `${process.env.GATEWAY_URL}/api/event` : 'http://ems-gateway/api/event'; + } + + public async start(): Promise { + try { + console.log('🚀 Starting Ticket Event Consumer...'); + this.connection = await connect(this.rabbitmqUrl); + if (this.connection) { + this.channel = await this.connection.createChannel(); + } + + if (this.channel) { + // Assert exchange (should already exist from booking service) + await this.channel.assertExchange(this.exchangeName, 'topic', { durable: true }); + + // Assert our queue + await this.channel.assertQueue(this.queueName, { durable: true }); + + // Bind queue to exchange with routing key + await this.channel.bindQueue(this.queueName, this.exchangeName, this.routingKey); + + // Assert notification queue + await this.channel.assertQueue(this.notificationQueueName, { durable: true }); + + this.channel.prefetch(1); + + console.log(`👂 Worker listening for ticket events on exchange "${this.exchangeName}" with routing key "${this.routingKey}"`); + this.channel.consume(this.queueName, this.handleMessage.bind(this), { noAck: false }); + } + } catch (error) { + console.error('❌ Error starting ticket event consumer:', error); + setTimeout(() => { + this.start().catch(err => console.error('❌ Retry failed:', err)); + }, 5000); + } + } + + private async handleMessage(msg: ConsumeMessage | null) { + if (!msg || !this.channel) return; + + try { + const ticketData = JSON.parse(msg.content.toString()); + console.log('🎫 Processing ticket generated event:', ticketData); + + // Process the ticket generated event directly + await this.processTicketGeneratedEvent(ticketData); + + // Acknowledge the message + this.channel.ack(msg); + } catch (error) { + console.error('❌ Error processing ticket event:', error); + this.channel.nack(msg, false, false); + } + } + + private async processTicketGeneratedEvent(ticketData: TicketGeneratedMessage) { + try { + // Fetch user and event details + const [userInfo, eventInfo] = await Promise.all([ + this.getUserInfo(ticketData.userId), + this.getEventInfo(ticketData.eventId) + ]); + + if (!userInfo || !eventInfo) { + console.error('❌ Failed to fetch user or event info for ticket email'); + return; + } + + // Generate QR code image from text data + let qrCodeImageBase64: string; + try { + // Generate QR code as PNG buffer and convert to base64 + const qrCodeBuffer = await QRCode.toBuffer(ticketData.qrCodeData, { + type: 'png', + width: 200, + margin: 2, + errorCorrectionLevel: 'M' + }); + qrCodeImageBase64 = qrCodeBuffer.toString('base64'); + } catch (qrError) { + console.error('❌ Failed to generate QR code image:', qrError); + // Use empty string if QR code generation fails (email will still be sent) + qrCodeImageBase64 = ''; + } + + // Generate ticket download URL (if you have a frontend route for viewing tickets) + const clientUrl = process.env.CLIENT_URL || 'http://localhost'; + const ticketDownloadUrl = `${clientUrl}/dashboard/attendee/tickets/${ticketData.ticketId}`; + + // Create ticket notification + const notification: TicketGeneratedNotification = { + type: MESSAGE_TYPE.TICKET_GENERATED_NOTIFICATION, + message: { + to: userInfo.email, + subject: '', // Will be generated by email template service + body: '', // Will be generated by email template service + attendeeName: userInfo.name || userInfo.email, + eventName: eventInfo.name, + eventDate: new Date(eventInfo.bookingStartDate).toLocaleDateString(), + venueName: eventInfo.venue?.name || 'Unknown Venue', + ticketId: ticketData.ticketId, + bookingId: ticketData.bookingId, + eventId: ticketData.eventId, + qrCodeData: qrCodeImageBase64, + expiresAt: ticketData.expiresAt, + ticketDownloadUrl: ticketDownloadUrl + } + }; + + // Send notification to email queue + const messageBuffer = Buffer.from(JSON.stringify(notification)); + if (this.channel) { + this.channel.sendToQueue(this.notificationQueueName, messageBuffer, { persistent: true }); + } + + console.log(`✅ Ticket notification queued for ${userInfo.email}`); + } catch (error) { + console.error('❌ Error processing ticket generated event:', error); + } + } + + private async getUserInfo(userId: string): Promise<{ email: string; name?: string } | null> { + try { + const response = await axios.get(`${this.authServiceUrl}/internal/users/${userId}`, { + timeout: 5000, + headers: { + 'Content-Type': 'application/json', + 'x-internal-service': 'notification-service' + } + }); + + if (response.status === 200 && response.data.valid) { + return { + email: response.data.user.email, + name: response.data.user.name + }; + } + return null; + } catch (error) { + console.error('❌ Error fetching user info:', error); + return null; + } + } + + private async getEventInfo(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) { + console.error('❌ Error fetching event info:', error); + return null; + } + } + + public async stop(): Promise { + console.log('🔌 Shutting down ticket event consumer...'); + try { + if (this.channel) await this.channel.close(); + if (this.connection) await this.connection.close(); + } catch (error) { + console.error('Error during ticket event consumer shutdown:', error); + } + } +} + diff --git a/ems-services/notification-service/src/server.ts b/ems-services/notification-service/src/server.ts index fc0de32..c082304 100644 --- a/ems-services/notification-service/src/server.ts +++ b/ems-services/notification-service/src/server.ts @@ -3,6 +3,7 @@ config(); import { NotificationConsumer } from './consumers/notification.consumer'; import { BookingEventConsumer } from './consumers/booking-event.consumer'; +import { TicketEventConsumer } from './consumers/ticket-event.consumer'; async function startServer() { const rabbitmqUrl = process.env.RABBITMQ_URL; @@ -14,15 +15,18 @@ async function startServer() { const notificationConsumer = new NotificationConsumer(rabbitmqUrl); const bookingEventConsumer = new BookingEventConsumer(rabbitmqUrl); - + const ticketEventConsumer = new TicketEventConsumer(rabbitmqUrl); + await notificationConsumer.start(); await bookingEventConsumer.start(); + await ticketEventConsumer.start(); // Handle graceful shutdown const gracefulShutdown = async () => { console.log('Received shutdown signal.'); await notificationConsumer.stop(); await bookingEventConsumer.stop(); + await ticketEventConsumer.stop(); process.exit(0); }; diff --git a/ems-services/notification-service/src/services/email-template.service.ts b/ems-services/notification-service/src/services/email-template.service.ts index 62579b8..f290d0e 100644 --- a/ems-services/notification-service/src/services/email-template.service.ts +++ b/ems-services/notification-service/src/services/email-template.service.ts @@ -6,6 +6,7 @@ import { EventPublishedNotification, BookingConfirmedNotification, BookingCancelledNotification, + TicketGeneratedNotification, EventReminderNotification, WelcomeEmail, MESSAGE_TYPE @@ -40,6 +41,8 @@ class EmailTemplateService { return this.generateBookingConfirmedEmail(notification); case MESSAGE_TYPE.BOOKING_CANCELLED_NOTIFICATION: return this.generateBookingCancelledEmail(notification); + case MESSAGE_TYPE.TICKET_GENERATED_NOTIFICATION: + return this.generateTicketGeneratedEmail(notification); case MESSAGE_TYPE.EVENT_REMINDER_NOTIFICATION: return this.generateEventReminderEmail(notification); case MESSAGE_TYPE.WELCOME_EMAIL: @@ -433,6 +436,104 @@ class EmailTemplateService { }; } + private generateTicketGeneratedEmail(notification: TicketGeneratedNotification) { + const { message } = notification; + const expiresAtDate = new Date(message.expiresAt); + const expiresAtFormatted = expiresAtDate.toLocaleString(); + + return { + subject: `${this.appName} - Your Ticket: ${message.eventName}`, + body: ` + + + + + + Your Event Ticket + + + +
+ + + ` + }; + } + private generateEventReminderEmail(notification: EventReminderNotification) { const { message } = notification; const reminderText = { diff --git a/ems-services/notification-service/src/types/types.ts b/ems-services/notification-service/src/types/types.ts index 17ffb27..1ff423f 100644 --- a/ems-services/notification-service/src/types/types.ts +++ b/ems-services/notification-service/src/types/types.ts @@ -6,6 +6,7 @@ export enum MESSAGE_TYPE { EVENT_PUBLISHED_NOTIFICATION = 'EVENT_PUBLISHED_NOTIFICATION', BOOKING_CONFIRMED_NOTIFICATION = 'BOOKING_CONFIRMED_NOTIFICATION', BOOKING_CANCELLED_NOTIFICATION = 'BOOKING_CANCELLED_NOTIFICATION', + TICKET_GENERATED_NOTIFICATION = 'TICKET_GENERATED_NOTIFICATION', EVENT_REMINDER_NOTIFICATION = 'EVENT_REMINDER_NOTIFICATION', PASSWORD_RESET_EMAIL = 'PASSWORD_RESET_EMAIL', WELCOME_EMAIL = 'WELCOME_EMAIL', @@ -137,6 +138,26 @@ export interface BookingCancelledNotification extends Notification { }; } +// Ticket Generated Notification +export interface TicketGeneratedNotification extends Notification { + type: MESSAGE_TYPE.TICKET_GENERATED_NOTIFICATION; + message: { + to: string; + subject: string; + body: string; + attendeeName: string; + eventName: string; + eventDate: string; + venueName: string; + ticketId: string; + bookingId: string; + eventId: string; + qrCodeData: string; + expiresAt: string; + ticketDownloadUrl?: string; + }; +} + // Event Reminder Notification export interface EventReminderNotification extends Notification { type: MESSAGE_TYPE.EVENT_REMINDER_NOTIFICATION; @@ -176,5 +197,6 @@ export type AnyNotification = | EventPublishedNotification | BookingConfirmedNotification | BookingCancelledNotification + | TicketGeneratedNotification | EventReminderNotification | WelcomeEmail; \ No newline at end of file