From b29d37da294b11f6429e1b2164e68e87f5cf9844 Mon Sep 17 00:00:00 2001 From: ashwin-athappan Date: Thu, 6 Nov 2025 11:43:21 -0600 Subject: [PATCH 01/69] EMS-159: Implement Speaker Admin Messaging Changelog: Backend 1. Database schema: Enhanced Message model with eventId, status enum (SENT/DELIVERED/READ), attachment fields, and indexes 2. Message service: Added admin methods (getAllSpeakerMessages, getMessagesByEvent, getMessagesBySpeaker, etc.) 3. API routes: Added authentication and admin-only endpoints for managing speaker messages 4. WebSocket server: Real-time messaging with Socket.IO, including: * JWT authentication * User-specific and admin rooms * Real-time message delivery * Read receipts * Typing indicators * Admin notifications for new speaker messages Frontend 1. Admin API client: Added messaging methods to admin.api.ts 2. Admin messaging center: Component at /dashboard/admin/messages with: * View all speaker messages * Filter by search, speaker, or event * Message detail view with read receipts * Compose and reply functionality * Unread message count badge * Mobile-responsive design 3. Admin dashboard: Added "Messages" quick action button Pending tasks 1. Database migration: Run npx prisma migrate dev in speaker-service to apply schema changes 2. WebSocket client: Install socket.io-client and integrate real-time updates in frontend components (currently using polling) 3. Speaker interface enhancement: Enhance the existing speaker MessageCenter component with event context Documentation Created docs/MESSAGING_SYSTEM_IMPLEMENTATION.md with: * Implementation details * API endpoints * WebSocket events * Testing checklist * Next steps Features * Two-way communication between speakers and admins * Real-time message delivery (WebSocket server ready) * Read receipts and status tracking * Message threading * Event association * Message history persistence * Admin notification system * Mobile-responsive interface * File attachment support (backend ready, frontend pending) The system is functional. Remaining work: run the database migration, install dependencies, and optionally add WebSocket client integration for true real-time updates (currently using 30-second polling). --- docs/MESSAGING_SYSTEM_IMPLEMENTATION.md | 227 +++++++ .../app/dashboard/admin/messages/page.tsx | 619 ++++++++++++++++++ ems-client/app/dashboard/admin/page.tsx | 16 +- ems-client/lib/api/admin.api.ts | 262 +++++++- .../speaker-service/package-lock.json | 321 ++++++++- ems-services/speaker-service/package.json | 4 +- .../migration.sql | 25 + .../speaker-service/prisma/schema.prisma | 46 +- .../src/middleware/auth.middleware.ts | 4 +- .../src/routes/message.routes.ts | 330 ++++++++-- ems-services/speaker-service/src/server.ts | 14 +- .../src/services/message.service.ts | 180 ++++- .../src/services/websocket.service.ts | 198 ++++++ .../speaker-service/src/types/index.ts | 11 + 14 files changed, 2140 insertions(+), 117 deletions(-) create mode 100644 docs/MESSAGING_SYSTEM_IMPLEMENTATION.md create mode 100644 ems-client/app/dashboard/admin/messages/page.tsx rename ems-services/speaker-service/prisma/migrations/{20251103205831_init => 20251106172550_init}/migration.sql (77%) create mode 100644 ems-services/speaker-service/src/services/websocket.service.ts diff --git a/docs/MESSAGING_SYSTEM_IMPLEMENTATION.md b/docs/MESSAGING_SYSTEM_IMPLEMENTATION.md new file mode 100644 index 0000000..85feaa1 --- /dev/null +++ b/docs/MESSAGING_SYSTEM_IMPLEMENTATION.md @@ -0,0 +1,227 @@ +# Messaging System Implementation Summary + +## Overview +A comprehensive two-way messaging system has been implemented to enable communication between speakers and administrators. The system includes real-time message delivery, read receipts, message threading, and event association. + +## Backend Implementation + +### 1. Database Schema Enhancements (`ems-services/speaker-service/prisma/schema.prisma`) +- **Added `MessageStatus` enum**: `SENT`, `DELIVERED`, `READ` +- **Enhanced `Message` model** with: + - `eventId` (optional): Associate messages with events + - `status`: MessageStatus enum (default: SENT) + - `deliveredAt`: Timestamp when message was delivered + - `attachmentUrl`, `attachmentName`, `attachmentType`: Support for file attachments + - `updatedAt`: Track message updates + - Added indexes for performance: `fromUserId`, `toUserId`, `threadId`, `eventId`, `status` + +### 2. Message Service (`ems-services/speaker-service/src/services/message.service.ts`) +- Enhanced `createMessage()` to support new fields (eventId, attachments, status) +- Added `markMessageAsDelivered()` method +- Updated `markMessageAsRead()` to update status to 'READ' +- **New admin-specific methods**: + - `getAllSpeakerMessages()`: Get all messages from speakers + - `getMessagesByEvent()`: Filter messages by event ID + - `getMessagesBySpeaker()`: Get all messages from a specific speaker + - `getThreadsBySpeaker()`: Get message threads organized by speaker + - `getUnreadSpeakerMessageCount()`: Count unread messages for admins + +### 3. Message Routes (`ems-services/speaker-service/src/routes/message.routes.ts`) +- **Authentication**: All routes now require authentication via `authMiddleware` +- **Authorization**: Users can only access their own messages unless they're admins +- **Enhanced POST /**: Now uses authenticated user ID, supports eventId and attachments +- **Admin-only routes**: + - `GET /admin/all-speaker-messages`: Get all speaker messages + - `GET /admin/event/:eventId`: Get messages by event + - `GET /admin/speaker/:speakerUserId`: Get messages by speaker + - `GET /admin/speaker/:speakerUserId/threads`: Get threads by speaker + - `GET /admin/unread-count`: Get unread message count + +### 4. WebSocket Service (`ems-services/speaker-service/src/services/websocket.service.ts`) +- **Real-time message delivery** using Socket.IO +- **Features**: + - JWT authentication for WebSocket connections + - User-specific rooms (`user:${userId}`) + - Admin room (`admins`) for broadcasting speaker messages + - Events: + - `message:sent`: When a message is created + - `message:received`: Real-time delivery to recipient + - `message:read`: Read receipt notifications + - `message:typing`: Typing indicators + - `message:new_speaker_message`: Notify all admins of new speaker messages + - Automatic status updates (SENT → DELIVERED → READ) + +### 5. Server Integration (`ems-services/speaker-service/src/server.ts`) +- Integrated WebSocket service with HTTP server +- Graceful shutdown handling for WebSocket connections + +### 6. Dependencies (`ems-services/speaker-service/package.json`) +- Added `socket.io` (^4.7.5) +- Added `@types/socket.io` (^3.0.2) + +## Frontend Implementation + +### 1. API Client Methods (`ems-client/lib/api/admin.api.ts`) +- Added `Message` and `MessageThread` interfaces +- **New methods**: + - `getAllSpeakerMessages()`: Fetch all speaker messages + - `getMessagesByEvent()`: Filter by event + - `getMessagesBySpeaker()`: Filter by speaker + - `getThreadsBySpeaker()`: Get threads by speaker + - `getUnreadSpeakerMessageCount()`: Get unread count + - `sendMessage()`: Send a message + - `markMessageAsRead()`: Mark message as read + +### 2. Admin Messaging Center (`ems-client/app/dashboard/admin/messages/page.tsx`) +- **Features**: + - View all speaker messages + - Filter by search query, speaker, or event + - Message detail view with read receipts + - Compose and reply to messages + - Unread message count badge + - Real-time updates (via polling every 30 seconds) + - Mobile-responsive design + - Event association display + +### 3. Admin Dashboard Integration (`ems-client/app/dashboard/admin/page.tsx`) +- Added "Messages" quick action button linking to `/dashboard/admin/messages` + +## Features Implemented + +✅ **Speakers can send messages to admins** +- Speakers can compose and send messages from their dashboard +- Messages are automatically associated with events (optional) +- Support for file attachments (optional) + +✅ **Admins can view all messages** +- Centralized messaging center at `/dashboard/admin/messages` +- Filter messages by speaker, event, or search query +- View message threads organized by speaker + +✅ **Real-time message delivery** +- WebSocket server implemented for real-time updates +- Message status tracking (SENT → DELIVERED → READ) +- Read receipts sent to message senders + +✅ **Message history persistence** +- All messages stored in PostgreSQL database +- Message threading support +- Event association for organizing messages + +✅ **Notification system** +- WebSocket events notify admins of new speaker messages +- Unread message count displayed in admin dashboard +- Real-time notifications via Socket.IO + +✅ **Message thread organization** +- Messages grouped by threadId +- Threads organized by speaker and event +- Conversation history maintained + +✅ **Mobile-responsive interface** +- Responsive grid layout +- Touch-friendly buttons and interactions +- Optimized for mobile devices + +## Pending Tasks + +### 1. Database Migration +**Action Required**: Run Prisma migration to apply schema changes +```bash +cd ems-services/speaker-service +npx prisma migrate dev --name add_message_enhancements +``` + +### 2. WebSocket Client Integration (Frontend) +**Status**: Pending +- Install `socket.io-client` in frontend +- Create WebSocket hook (`useWebSocket.ts`) +- Integrate real-time updates in messaging components +- Replace polling with WebSocket events + +### 3. Speaker Message Interface Enhancement +**Status**: Pending +- Enhance existing `MessageCenter` component with event context +- Add event selection when composing messages +- Display event information in message threads + +### 4. File Upload Support +**Status**: Partially Implemented +- Backend supports attachment fields +- Frontend upload UI needs to be added +- File storage integration required + +### 5. Notification Service Integration +**Status**: Pending +- Create RabbitMQ publisher for message events +- Add consumer in notification-service +- Send email notifications for new messages (optional) + +## Testing Checklist + +- [ ] Test message creation from speaker dashboard +- [ ] Test admin viewing all speaker messages +- [ ] Test message filtering (by speaker, event, search) +- [ ] Test read receipt functionality +- [ ] Test WebSocket real-time delivery +- [ ] Test message threading +- [ ] Test event association +- [ ] Test mobile responsiveness +- [ ] Test authentication and authorization + +## API Endpoints + +### Speaker Endpoints +- `POST /api/messages` - Send message (authenticated) +- `GET /api/messages/inbox/:userId` - Get inbox messages +- `GET /api/messages/sent/:userId` - Get sent messages +- `GET /api/messages/threads/:userId` - Get message threads +- `GET /api/messages/unread/:userId/count` - Get unread count +- `PUT /api/messages/:id/read` - Mark as read + +### Admin Endpoints +- `GET /api/messages/admin/all-speaker-messages` - Get all speaker messages +- `GET /api/messages/admin/event/:eventId` - Get messages by event +- `GET /api/messages/admin/speaker/:speakerUserId` - Get messages by speaker +- `GET /api/messages/admin/speaker/:speakerUserId/threads` - Get threads by speaker +- `GET /api/messages/admin/unread-count` - Get unread count + +## WebSocket Events + +### Client → Server +- `message:sent` - Notify server of new message +- `message:read` - Mark message as read +- `message:typing` - Send typing indicator + +### Server → Client +- `message:received` - New message received +- `message:delivered` - Message delivery confirmation +- `message:read_receipt` - Read receipt notification +- `message:new_speaker_message` - New speaker message (admins only) +- `message:typing` - Typing indicator received + +## Environment Variables + +No new environment variables required. Uses existing: +- `JWT_SECRET` - For WebSocket authentication +- `CLIENT_URL` - For CORS configuration +- `DATABASE_URL` - For Prisma +- `RABBITMQ_URL` - For future notification integration + +## Next Steps + +1. **Run database migration** to apply schema changes +2. **Install dependencies**: `npm install` in `speaker-service` +3. **Test WebSocket connection** from frontend +4. **Add WebSocket client** to frontend components +5. **Enhance speaker interface** with event context +6. **Add file upload UI** for attachments +7. **Integrate notification service** for email alerts + +## Notes + +- WebSocket server is ready but frontend client integration is pending +- Current implementation uses polling (30s interval) for unread count +- File attachment support is partially implemented (backend ready, frontend pending) +- Notification service integration can be added for email alerts + diff --git a/ems-client/app/dashboard/admin/messages/page.tsx b/ems-client/app/dashboard/admin/messages/page.tsx new file mode 100644 index 0000000..a58b998 --- /dev/null +++ b/ems-client/app/dashboard/admin/messages/page.tsx @@ -0,0 +1,619 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useAuth } from '@/lib/auth-context'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { + MessageSquare, + Send, + Mail, + MailOpen, + Clock, + User, + Search, + Filter, + ArrowLeft, + Calendar, + FileText, + CheckCircle2, + Circle +} from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useLogger } from '@/lib/logger/LoggerProvider'; +import { withAdminAuth } from '@/components/hoc/withAuth'; +import { adminApiClient, Message, MessageThread, SpeakerInvitation } from '@/lib/api/admin.api'; +import { eventAPI } from '@/lib/api/event.api'; +import { useThemeColors } from '@/hooks/useThemeColors'; + +const LOGGER_COMPONENT_NAME = 'AdminMessagingCenter'; + +function AdminMessagingCenter() { + const { user } = useAuth(); + const router = useRouter(); + const logger = useLogger(); + const colors = useThemeColors(); + + const [messages, setMessages] = useState([]); + const [filteredMessages, setFilteredMessages] = useState([]); + const [selectedMessage, setSelectedMessage] = useState(null); + const [selectedThread, setSelectedThread] = useState(null); + const [viewMode, setViewMode] = useState<'messages' | 'threads' | 'by-speaker' | 'by-event'>('messages'); + const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + const [unreadCount, setUnreadCount] = useState(0); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedSpeakerId, setSelectedSpeakerId] = useState(null); + const [selectedEventId, setSelectedEventId] = useState(null); + + // Compose message state + const [showCompose, setShowCompose] = useState(false); + const [composeData, setComposeData] = useState({ + toUserId: '', + subject: '', + content: '', + eventId: '' + }); + const [events, setEvents] = useState>([]); + const [invitedSpeakers, setInvitedSpeakers] = useState>([]); + const [loadingEvents, setLoadingEvents] = useState(false); + const [loadingSpeakers, setLoadingSpeakers] = useState(false); + + useEffect(() => { + logger.debug(LOGGER_COMPONENT_NAME, 'Admin messaging center loaded', { userRole: user?.role }); + loadMessages(); + loadUnreadCount(); + + // Poll for unread count every 30 seconds + const interval = setInterval(loadUnreadCount, 30000); + return () => clearInterval(interval); + }, [user, logger]); + + useEffect(() => { + filterMessages(); + }, [searchQuery, messages, selectedSpeakerId, selectedEventId]); + + const loadMessages = async () => { + try { + setLoading(true); + logger.info(LOGGER_COMPONENT_NAME, 'Loading all speaker messages'); + const allMessages = await adminApiClient.getAllSpeakerMessages(100, 0); + setMessages(allMessages); + setFilteredMessages(allMessages); + } catch (error) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load messages', error as Error); + } finally { + setLoading(false); + } + }; + + const loadUnreadCount = async () => { + try { + const count = await adminApiClient.getUnreadSpeakerMessageCount(); + setUnreadCount(count); + } catch (error) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load unread count', error as Error); + } + }; + + const filterMessages = () => { + let filtered = [...messages]; + + if (searchQuery) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter(m => + m.subject.toLowerCase().includes(query) || + m.content.toLowerCase().includes(query) + ); + } + + if (selectedSpeakerId) { + filtered = filtered.filter(m => m.fromUserId === selectedSpeakerId); + } + + if (selectedEventId) { + filtered = filtered.filter(m => m.eventId === selectedEventId); + } + + setFilteredMessages(filtered); + }; + + const handleMessageClick = async (message: Message) => { + setSelectedMessage(message); + + // Mark as read if unread + if (!message.readAt) { + try { + await adminApiClient.markMessageAsRead(message.id); + // Update local state + setMessages(prev => prev.map(m => + m.id === message.id ? { ...m, readAt: new Date().toISOString(), status: 'READ' } : m + )); + setUnreadCount(prev => Math.max(0, prev - 1)); + } catch (error) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to mark message as read', error as Error); + } + } + }; + + const handleSendMessage = async () => { + if (!composeData.toUserId || !composeData.subject || !composeData.content || !composeData.eventId) { + return; + } + + try { + setSending(true); + logger.info(LOGGER_COMPONENT_NAME, 'Sending message', { toUserId: composeData.toUserId }); + + const newMessage = await adminApiClient.sendMessage( + composeData.toUserId, + composeData.subject, + composeData.content, + selectedMessage?.threadId || undefined, + composeData.eventId || undefined + ); + + // Refresh messages + await loadMessages(); + + // Reset compose form + setComposeData({ toUserId: '', subject: '', content: '', eventId: '' }); + setShowCompose(false); + setSelectedMessage(null); + + logger.info(LOGGER_COMPONENT_NAME, 'Message sent successfully', { messageId: newMessage.id }); + } catch (error) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to send message', error as Error); + } finally { + setSending(false); + } + }; + + const handleReply = (message: Message) => { + setComposeData({ + toUserId: message.fromUserId, + subject: `Re: ${message.subject}`, + content: '', + eventId: message.eventId || '' + }); + setShowCompose(true); + setSelectedMessage(message); + // Load speakers if event is set + if (message.eventId) { + loadSpeakersForEvent(message.eventId); + } + }; + + const loadEvents = async () => { + try { + setLoadingEvents(true); + logger.info(LOGGER_COMPONENT_NAME, 'Loading events for message composition'); + const response = await adminApiClient.getAllEvents({ limit: 100 }); + const eventsList = response.data.events.map((event: any) => ({ + id: event.id, + name: event.name + })); + setEvents(eventsList); + } catch (error) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load events', error as Error); + setEvents([]); + } finally { + setLoadingEvents(false); + } + }; + + const loadSpeakersForEvent = async (eventId: string) => { + try { + setLoadingSpeakers(true); + logger.info(LOGGER_COMPONENT_NAME, 'Loading speakers for event', { eventId }); + + // Get invitations for this event + const invitations = await adminApiClient.getEventInvitations(eventId); + + // Get speaker profiles for each invitation + const speakersWithInfo = await Promise.all( + invitations.map(async (invitation: SpeakerInvitation) => { + try { + const speakerProfile = await adminApiClient.getSpeakerProfile(invitation.speakerId); + return { + userId: speakerProfile.userId, + name: speakerProfile.name, + email: speakerProfile.email + }; + } catch (err) { + logger.warn(LOGGER_COMPONENT_NAME, 'Failed to load speaker profile', { + speakerId: invitation.speakerId, + error: err + }); + return null; + } + }) + ); + + // Filter out null values + const validSpeakers = speakersWithInfo.filter((s): s is { userId: string; name: string; email: string } => s !== null); + setInvitedSpeakers(validSpeakers); + + logger.info(LOGGER_COMPONENT_NAME, 'Speakers loaded for event', { + eventId, + count: validSpeakers.length + }); + } catch (error) { + logger.error(LOGGER_COMPONENT_NAME, 'Failed to load speakers for event', error as Error); + setInvitedSpeakers([]); + } finally { + setLoadingSpeakers(false); + } + }; + + const handleEventSelect = (eventId: string) => { + setComposeData({ ...composeData, eventId, toUserId: '' }); // Clear speaker selection when event changes + setInvitedSpeakers([]); // Clear speakers list + if (eventId) { + loadSpeakersForEvent(eventId); + } + }; + + const handleSpeakerSelect = (userId: string) => { + setComposeData({ ...composeData, toUserId: userId }); + }; + + const handleOpenCompose = () => { + setShowCompose(true); + setComposeData({ toUserId: '', subject: '', content: '', eventId: '' }); + setInvitedSpeakers([]); + loadEvents(); + }; + + // Get unique speakers from messages + const uniqueSpeakers = Array.from(new Set(messages.map(m => m.fromUserId))); + + // Get unique events from messages + const uniqueEvents = Array.from(new Set(messages.filter(m => m.eventId).map(m => m.eventId!))); + + if (loading && messages.length === 0) { + return ( +
+
+
+
+

Loading messages...

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+

+ + Messaging Center +

+

+ Manage all speaker communications +

+
+
+
+ {unreadCount > 0 && ( + + {unreadCount} unread + + )} + +
+
+ + {/* Filters */} + + + + + Filters + + + +
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ + +
+
+
+ +
+ {/* Messages List */} +
+ + + Messages ({filteredMessages.length}) + + {unreadCount > 0 && `${unreadCount} unread messages`} + + + +
+ {filteredMessages.length === 0 ? ( +
+ +

No messages found

+
+ ) : ( + filteredMessages.map((message) => ( +
handleMessageClick(message)} + className={`p-4 border-b cursor-pointer transition-colors ${ + selectedMessage?.id === message.id + ? 'bg-blue-50 dark:bg-blue-950' + : !message.readAt + ? 'bg-yellow-50 dark:bg-yellow-950 hover:bg-yellow-100 dark:hover:bg-yellow-900' + : 'hover:bg-gray-50 dark:hover:bg-gray-800' + }`} + > +
+
+ {!message.readAt ? ( + + ) : ( + + )} + + {message.subject} + +
+ + {new Date(message.sentAt).toLocaleDateString()} + +
+

+ {message.content} +

+ {message.eventId && ( +
+ + Event: {message.eventId.substring(0, 8)} +
+ )} +
+ )) + )} +
+
+
+
+ + {/* Message Detail */} +
+ {selectedMessage ? ( + + +
+
+ {selectedMessage.subject} + +
+ + + From: Speaker {selectedMessage.fromUserId.substring(0, 8)} + + + + {new Date(selectedMessage.sentAt).toLocaleString()} + + {selectedMessage.eventId && ( + + + Event: {selectedMessage.eventId.substring(0, 8)} + + )} +
+
+
+
+ + {selectedMessage.status} + + +
+
+
+ +
+

{selectedMessage.content}

+
+ {selectedMessage.attachmentUrl && ( +
+
+ +
+

{selectedMessage.attachmentName}

+ + Download attachment + +
+
+
+ )} +
+
+ ) : ( + + + +

Select a message to view details

+
+
+ )} +
+
+ + {/* Compose Modal */} + {showCompose && ( +
+ + + Compose Message + Send a message to a speaker + + +
+ + {loadingEvents ? ( +
+ Loading events... +
+ ) : ( + + )} +
+ + {composeData.eventId && ( +
+ + {loadingSpeakers ? ( +
+ Loading speakers... +
+ ) : invitedSpeakers.length === 0 ? ( +
+ + No speakers have been invited to this event yet. + +
+ ) : ( + + )} +
+ )} + +
+ + setComposeData({ ...composeData, subject: e.target.value })} + placeholder="Enter subject" + required + /> +
+
+ +