diff --git a/.gitignore b/.gitignore index 69f2338..8a492c9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ !.yarn/releases !.yarn/versions +# python dependencies +**/.venv +**/*.pyc # testing /coverage 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 && (
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/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} + + + + )) + )}
diff --git a/ems-client/app/dashboard/admin/users/page.tsx b/ems-client/app/dashboard/admin/users/page.tsx index a5017cd..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 = (value: string) => { + setSelectedRole(value); + setPage(1); // Reset to first page + }; - const handleRoleChange = (userId: string, newRole: string) => { - // TODO: Implement role change API call - logger.debug(COMPONENT_NAME, `Change user ${userId} role to ${newRole}`); + const handleStatusChange = (value: string) => { + setSelectedStatus(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 handlePageChange = (newPage: number) => { + setPage(newPage); + window.scrollTo({ top: 0, behavior: 'smooth' }); }; - if (isLoading) { + // 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" > @@ -298,31 +394,37 @@ 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...

+
+
+ ) : ( +
+
@@ -330,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}

@@ -383,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 ( + + ); + })} +
+ + +
+
+ )}
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..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/app/dashboard/speaker/events/page.tsx b/ems-client/app/dashboard/speaker/events/page.tsx index 57fc36c..e1e08cd 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,112 @@ function SpeakerEventManagementPage() {
setSearchTerm(e.target.value)} className="pl-10" />
- + {activeTab === 'my-events' ? ( + + ) : ( + + )}
{/* Events Grid - My Events */} {activeTab === 'my-events' && ( -
- {filteredEvents.map((event) => ( +
+ {loading && !initialLoad && ( +
+
+
+ )} +
+ {/* 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(); + 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 +628,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 +638,7 @@ function SpeakerEventManagementPage() {
@@ -497,51 +653,161 @@ 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 && ( +
+ +
+ )} + - ))} + ); + })} +
+
+ )} + + {/* 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 */} +
+
+
- {filteredEvents.length === 0 && !loading && ( + {/* Actions */} +
+ +
+
+
+ ); + })} +
+
+ )} + + {events.length === 0 && !loading && (
@@ -552,24 +818,53 @@ function SpeakerEventManagementPage() {

No events match your search criteria.

- +

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

)} +
)} {/* Invited Events Grid */} {activeTab === 'invited-events' && ( -
- {invitations.map((invitation) => { +
+ {loading && !initialLoad && ( +
+
+
+ )} +
+ {/* 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; @@ -597,12 +892,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,39 +941,62 @@ 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; + })()} ); - })} + })} +
+
+ )} {invitations.length === 0 && !loading && (
@@ -683,11 +1013,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 +1064,21 @@ function SpeakerEventManagementPage() { - Page {pagination.page} of {pagination.totalPages} + Page {invitationsPagination.page} of {Math.max(1, invitationsPagination.totalPages)} @@ -717,7 +1086,7 @@ function SpeakerEventManagementPage() {
- Showing {events.length} of {pagination.total} events + Showing {invitations.length} of {invitationsPagination.total} invitations
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/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 1f2d98a..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({ @@ -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); @@ -135,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); @@ -162,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); @@ -190,7 +207,7 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => { if (token) { headers['Authorization'] = `Bearer ${token}`; } - + const response = await fetch(`/api/materials/${materialId}`, { headers }); @@ -205,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) { @@ -225,7 +242,7 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => { await loadEvent(); setLoading(false); }; - + loadData(); }, [eventId]); @@ -238,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); @@ -271,7 +288,19 @@ export const LiveEventAuditorium = ({ userRole }: LiveEventAuditoriumProps) => {

Error Loading Event

{error}

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

{attendance.attendancePercentage}%

Attendance Rate

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 } 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; diff --git a/ems-client/lib/api/admin.api.ts b/ems-client/lib/api/admin.api.ts index 1e4a2f0..7f6ec63 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,310 @@ 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; + } + } + + // 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; + } + } + + // 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(); diff --git a/ems-client/lib/api/booking.api.ts b/ems-client/lib/api/booking.api.ts index b23b5b2..f3bf47a 100644 --- a/ems-client/lib/api/booking.api.ts +++ b/ems-client/lib/api/booking.api.ts @@ -1,7 +1,7 @@ import { BaseApiClient } from './base-api.client'; -import { - CreateBookingRequest, - BookingResponse, +import { + CreateBookingRequest, + BookingResponse, BookingListResponse, TicketResponse, TicketListResponse @@ -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}`); } @@ -58,7 +83,7 @@ class BookingApiClient extends BaseApiClient { if (filters?.page) params.append('page', filters.page.toString()); if (filters?.limit) params.append('limit', filters.limit.toString()); if (filters?.status) params.append('status', filters.status); - + const endpoint = `/admin/tickets/events/${eventId}/tickets?${params.toString()}`; return this.request(endpoint); } @@ -72,6 +97,16 @@ class BookingApiClient extends BaseApiClient { method: 'PUT' }); } + + // Speaker methods + async getEventRegistrationCount(eventId: string): Promise<{ + eventId: string; + totalUsers: number; + confirmedBookings: number; + cancelledBookings: number; + }> { + return this.request(`/speaker/${eventId}/num-registered`); + } } const bookingApiClient = new BookingApiClient(); @@ -98,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 = { /** @@ -120,7 +172,7 @@ export const adminTicketAPI = { /** * Get all tickets for an event */ - getEventTickets: (eventId: string, filters?: { page?: number; limit?: number; status?: string }) => + getEventTickets: (eventId: string, filters?: { page?: number; limit?: number; status?: string }) => bookingApiClient.getEventTickets(eventId, filters), /** @@ -133,3 +185,11 @@ export const adminTicketAPI = { */ revokeTicket: (ticketId: string) => bookingApiClient.revokeTicket(ticketId) }; + +export const speakerBookingAPI = { + /** + * Get number of registered users (confirmed bookings) for an event + * Speaker-only endpoint + */ + 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/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 { 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; } diff --git a/ems-services/auth-service/src/routes/routes.ts b/ems-services/auth-service/src/routes/routes.ts index 83e7edc..b5c4dd2 100644 --- a/ems-services/auth-service/src/routes/routes.ts +++ b/ems-services/auth-service/src/routes/routes.ts @@ -352,4 +352,317 @@ 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'}); + } + }); + + /** + * @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'}); + } + }); + + /** + * @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). + * @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 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/booking-service/src/routes/admin.routes.ts b/ems-services/booking-service/src/routes/admin.routes.ts index bc3670e..423e2f0 100644 --- a/ems-services/booking-service/src/routes/admin.routes.ts +++ b/ems-services/booking-service/src/routes/admin.routes.ts @@ -460,4 +460,189 @@ 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 + } + }); + }) +); + +/** + * 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 + }); + }) +); + +/** + * 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; 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..0a82825 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,297 @@ 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, + ticket: { + select: { + id: true, + status: true + } + } + } + }); + + // 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 eventDetails = eventDetailsMap.get(booking.eventId); + if (!eventDetails) return false; + const eventStart = new Date(eventDetails.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 => { + 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 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 eventDetails = eventDetailsMap.get(booking.eventId); + if (!eventDetails) return false; + const eventStart = new Date(eventDetails.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, + ticket: { + select: { + id: true, + status: true + } + } + }, + orderBy: { + createdAt: 'desc' + } + }); + + // Fetch event details from event-service for all bookings + const eventServiceUrl = process.env.EVENT_SERVICE_URL || 'http://event-service:3000'; + + 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 { + booking, + eventDetails + }; + } catch (error) { + logger.warn('Failed to fetch event details', { eventId: booking.eventId }); + return { + 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 }); + 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, + ticket: { + 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.ticket ? '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.ticket ? '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/ems-services/event-service/src/routes/admin.routes.ts b/ems-services/event-service/src/routes/admin.routes.ts index 0f7209b..141274e 100644 --- a/ems-services/event-service/src/routes/admin.routes.ts +++ b/ems-services/event-service/src/routes/admin.routes.ts @@ -307,4 +307,68 @@ 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 + } + }); + }) +); + +/** + * 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; 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; } 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/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 + + + +
+
+

🎫 Your Event Ticket

+
+
+

Hello ${message.attendeeName},

+

Your ticket has been generated! Here's everything you need for the event.

+ +
+

Event Details:

+
+
Event Name: ${message.eventName}
+
Event Date: ${new Date(message.eventDate).toLocaleDateString()}
+
Venue: ${message.venueName}
+
Ticket ID: ${message.ticketId}
+
Booking ID: ${message.bookingId}
+
Ticket Expires: ${expiresAtFormatted}
+
+
+ + ${message.qrCodeData ? ` +
+

Your QR Code:

+
+ QR Code +
+

+ Present this QR code at the event entrance for entry. +

+
+ ` : ` +
+

QR Code:

+

+ QR code will be available when you view your ticket online. +

+
+ `} + +
+ ⚠️ Important: +
    +
  • Please arrive at least 15 minutes before the event starts
  • +
  • Keep this ticket safe - you'll need it to enter the event
  • +
  • The QR code will expire 2 hours after the event ends
  • +
  • Make sure your device has sufficient battery to display the QR code
  • +
+
+ + ${message.ticketDownloadUrl ? ` + + ` : ''} + +

If you have any questions or need assistance, please contact our support team.

+

We look forward to seeing you at the event!

+
+ +
+ + + ` + }; + } + 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 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" 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; 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: { 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/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) +