diff --git a/app/api/chat/messages/[id]/route.ts b/app/api/chat/messages/[id]/route.ts new file mode 100644 index 0000000..6b71b58 --- /dev/null +++ b/app/api/chat/messages/[id]/route.ts @@ -0,0 +1,228 @@ +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth/next' +import { authOptions } from '@/app/api/auth/[...nextauth]/options' +import dbConnect from '@/lib/dbConnect' +import Message from '@/model/Message' +import { chatEmitter } from '@/lib/eventEmitter' + +import mongoose from 'mongoose' + +/** + * PATCH /api/chat/messages/:id + * + * Authorises and modifies the text content of an existing chat message. + * Only the original author (matched via session user ID) may edit a message, + * and the message must not have been soft-deleted. + * + * **Auth gates:** + * 1. User must be logged in with a valid `session.user.id`. + * 2. User's email must belong to the `@iiitl.ac.in` domain. + * 3. `session.user.id` must match the message's `sender` field (ownership). + * + * **Request body:** + * - `content` (string, required) — The replacement text for the message. + * + * **Side effect:** On success, an `UPDATE_MESSAGE` event is emitted on the + * shared `chatEmitter` to push the edit to all connected SSE clients. + * + * @param req - The incoming Request containing `{ content }` JSON. + * @param params - Next.js dynamic route params (Promise-based in App Router). + * @returns A `NextResponse` with the fully populated updated message (200), + * or an appropriate error response (400/401/403/404/500). + */ +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getServerSession(authOptions) + + /* ── Auth gate: must be logged in with a valid user ID ── */ + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + /* ── Domain gate: only @iiitl.ac.in emails may write (edit/delete) ── */ + if (!session.user.email?.toLowerCase().endsWith('@iiitl.ac.in')) { + return NextResponse.json( + { error: 'Read-only: write access restricted to @iiitl.ac.in' }, + { status: 403 } + ) + } + + const { id } = await params + if (!mongoose.Types.ObjectId.isValid(id)) { + return NextResponse.json({ error: 'Invalid message ID' }, { status: 400 }) + } + const { content } = await req.json() + + if (!content || !content.trim()) { + return NextResponse.json( + { error: 'Message content is required' }, + { status: 400 } + ) + } + + await dbConnect() + + /* ── Fetch the target message from the database ── */ + const message = await Message.findById(id) + if (!message) { + return NextResponse.json({ error: 'Message not found' }, { status: 404 }) + } + + /* ── Ownership gate: only the original author may edit ── */ + if (message.sender.toString() !== session.user.id) { + return NextResponse.json( + { error: 'You can only edit your own messages' }, + { status: 403 } + ) + } + + /* ── Deleted messages are tombstones and cannot be resurrected ── */ + if (message.isDeleted) { + return NextResponse.json( + { error: 'Cannot edit a deleted message' }, + { status: 400 } + ) + } + + /* ── Apply the content update and mark as edited ── */ + message.content = content.trim() + message.isEdited = true + await message.save() + + /* + * Re-populate sender and replyTo so the emitted SSE payload and the + * HTTP response both contain display-ready data. + */ + await message.populate('sender', 'name image email') + if (message.replyTo) { + await message.populate({ + path: 'replyTo', + populate: { path: 'sender', select: 'name' }, + }) + } + + /* Broadcast UPDATE_MESSAGE to all SSE clients via the shared emitter. */ + chatEmitter.emit('chatUpdate', { + type: 'UPDATE_MESSAGE', + message: message, + }) + + return NextResponse.json(message, { status: 200 }) + } catch (error) { + console.error('Error updating message:', error) + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/chat/messages/:id + * + * Performs a soft delete on the specified message. The message content + * is replaced with a tombstone string (`🚫 This message was deleted.`) + * and the `isDeleted` flag is set to `true`. The document is **not** + * removed from the database so that reply chains referencing it + * continue to render correctly (showing "Deleted message" inline). + * + * **Auth gates:** + * 1. User must be logged in with a valid `session.user.id`. + * 2. User's email must belong to the `@iiitl.ac.in` domain. + * 3. User must be the original sender **or** have the `admin` role. + * + * **Side effect:** On success, a `DELETE_MESSAGE` event is emitted on + * the shared `chatEmitter` to push the deletion to all SSE clients. + * + * @param req - The incoming Request (body is not used). + * @param params - Next.js dynamic route params providing the message ID. + * @returns A `NextResponse` with `{ success: true }` (200), or an + * appropriate error response (400/401/403/404/500). + */ +export async function DELETE( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getServerSession(authOptions) + + /* ── Auth gate: must be logged in with a valid user ID ── */ + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + /* ── Domain gate: only @iiitl.ac.in emails may write (edit/delete) ── */ + if (!session.user.email?.toLowerCase().endsWith('@iiitl.ac.in')) { + return NextResponse.json( + { error: 'Read-only: write access restricted to @iiitl.ac.in' }, + { status: 403 } + ) + } + + const { id } = await params + if (!mongoose.Types.ObjectId.isValid(id)) { + return NextResponse.json({ error: 'Invalid message ID' }, { status: 400 }) + } + + await dbConnect() + + /* ── Fetch the target message from the database ── */ + const message = await Message.findById(id) + if (!message) { + return NextResponse.json({ error: 'Message not found' }, { status: 404 }) + } + + /* + * Ownership / admin gate: the user must either be the original sender + * or hold the 'admin' role. This allows moderators to clean up + * inappropriate content while normal users can only delete their own. + */ + if ( + message.sender.toString() !== session.user.id && + !session.user.roles?.includes('admin') + ) { + return NextResponse.json( + { error: 'You can only delete your own messages' }, + { status: 403 } + ) + } + + /* + * Soft delete: replace the content with a tombstone indicator and set + * the isDeleted flag. The document stays in the database so reply + * chains can still reference it (displaying "Deleted message"). + */ + message.content = '🚫 This message was deleted.' + message.isDeleted = true + await message.save() + + /* + * Re-populate sender and replyTo so the emitted SSE payload + * contains display-ready data for connected clients. + */ + await message.populate('sender', 'name image email') + if (message.replyTo) { + await message.populate({ + path: 'replyTo', + populate: { path: 'sender', select: 'name' }, + }) + } + + /* Broadcast DELETE_MESSAGE to all SSE clients via the shared emitter. */ + chatEmitter.emit('chatUpdate', { + type: 'DELETE_MESSAGE', + message: message, + }) + + return NextResponse.json({ success: true }, { status: 200 }) + } catch (error) { + console.error('Error deleting message:', error) + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ) + } +} diff --git a/app/api/chat/messages/route.ts b/app/api/chat/messages/route.ts new file mode 100644 index 0000000..bbd1ee5 --- /dev/null +++ b/app/api/chat/messages/route.ts @@ -0,0 +1,160 @@ +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth/next' +import { authOptions } from '@/app/api/auth/[...nextauth]/options' +import dbConnect from '@/lib/dbConnect' +import Message from '@/model/Message' +import { chatEmitter } from '@/lib/eventEmitter' + +import mongoose from 'mongoose' + +/** + * Force this route to be dynamically rendered on every request. + * Chat message endpoints must never serve stale cached data. + */ +export const dynamic = 'force-dynamic' + +/** + * GET /api/chat/messages + * + * Retrieves the latest 100 chat messages from the database, sorted in + * chronological order (oldest first). Each message's `sender` field is + * populated with the user's `name`, `image`, and `email`. For reply + * chains, the `replyTo` field is also populated recursively to include + * the referenced message and its sender's `name`. + * + * This endpoint is publicly accessible (no auth required) so that + * unauthenticated or non-IIITL users can still view the chat in + * read-only mode. + * + * @returns A `NextResponse` containing a JSON array of `Message` documents + * ordered chronologically (oldest → newest), or a 500 error on failure. + */ +export async function GET() { + try { + await dbConnect() + + /* + * Fetch latest 100 messages in descending timestamp order, then + * reverse the result to present oldest-first (natural chat order). + * Populate sender fields for display and replyTo for thread context. + */ + const messages = await Message.find() + .sort({ timestamp: -1 }) + .limit(100) + .populate('sender', 'name image email') // Populate sender with limited fields + .populate({ + path: 'replyTo', + populate: { path: 'sender', select: 'name' }, // Populate the sender of the replied message + }) + .exec() + + // Return messages in chronological order (oldest first) + return NextResponse.json(messages.reverse(), { status: 200 }) + } catch (error) { + console.error('Error fetching messages:', error) + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ) + } +} + +/** + * POST /api/chat/messages + * + * Validates, authorizes, and persists a brand-new chat message in the + * database. Only authenticated users with an `@iiitl.ac.in` email domain + * are permitted to send messages; all others receive a 403 response. + * + * **Request body:** + * - `content` (string, required) — The text body of the message. + * - `replyTo` (string, optional) — A valid MongoDB ObjectId referencing + * the parent message when this message is a reply. + * + * **Side effect:** On successful creation, a `NEW_MESSAGE` event is + * emitted on the shared `chatEmitter` so that all connected SSE clients + * receive the new message in real time without requiring a page refresh. + * + * @param req - The incoming Request containing the JSON payload. + * @returns A `NextResponse` with the created (and populated) message + * document (status 201), or an appropriate error response. + */ +export async function POST(req: Request) { + try { + const session = await getServerSession(authOptions) + + /* ── Auth gate: require both email (for domain check) and id (for sender reference) ── */ + if (!session?.user?.email || !session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + /* ── Domain gate: only @iiitl.ac.in emails can send messages ── */ + if (!session.user.email.toLowerCase().endsWith('@iiitl.ac.in')) { + return NextResponse.json( + { error: 'Only IIITL students can send messages' }, + { status: 403 } + ) + } + + const { content, replyTo } = await req.json() + + /* ── Input validation: content must be a non-empty trimmed string ── */ + if (!content || !content.trim()) { + return NextResponse.json( + { error: 'Message content is required' }, + { status: 400 } + ) + } + + /* + * Validate replyTo if provided — must be a valid ObjectId to prevent + * injection of malformed references that would cause Mongoose cast errors. + */ + if (replyTo && !mongoose.Types.ObjectId.isValid(replyTo)) { + return NextResponse.json( + { error: 'Invalid replyTo message ID' }, + { status: 400 } + ) + } + + await dbConnect() + + /* ── Create the new message document with the authenticated sender ── */ + const newMessage = await Message.create({ + sender: session.user.id, + email: session.user.email, + content: content.trim(), + replyTo: replyTo || null, + }) + + /* + * Populate sender and replyTo fields before returning so the response + * is immediately usable by the client without a second fetch. + */ + await newMessage.populate('sender', 'name image email') + if (newMessage.replyTo) { + await newMessage.populate({ + path: 'replyTo', + populate: { path: 'sender', select: 'name' }, + }) + } + + /* + * Emit a NEW_MESSAGE event to notify all SSE subscribers in real time. + * The SSE stream handler in /api/chat/stream forwards this to browser clients. + */ + chatEmitter.emit('chatUpdate', { + type: 'NEW_MESSAGE', + message: newMessage, + }) + + return NextResponse.json(newMessage, { status: 201 }) + } catch (error) { + console.error('Error creating message:', error) + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ) + } +} + diff --git a/app/api/chat/stream/route.ts b/app/api/chat/stream/route.ts new file mode 100644 index 0000000..23ca4ff --- /dev/null +++ b/app/api/chat/stream/route.ts @@ -0,0 +1,137 @@ +import { chatEmitter } from '@/lib/eventEmitter' +import { NextRequest, NextResponse } from 'next/server' + +/** + * Payload shape emitted by the chat event system and streamed to clients via SSE. + * + * The `type` field distinguishes between lifecycle events and mutation events: + * - `CONNECTED` – Sent once when the SSE connection is first established. + * - `HEARTBEAT` – Sent every 30 seconds to prevent proxy/load-balancer timeouts. + * - `NEW_MESSAGE` – A brand-new message was created in the chat. + * - `UPDATE_MESSAGE` – An existing message's content was edited by its author. + * - `DELETE_MESSAGE` – A message was soft-deleted (content replaced, isDeleted flag set). + * + * `message` carries the fully populated, serialised Mongoose document when the + * event involves a mutation (NEW_MESSAGE, UPDATE_MESSAGE, DELETE_MESSAGE). + * It is omitted for lifecycle-only events (CONNECTED, HEARTBEAT). + * + * **Type consistency note:** The emitter in `app/api/chat/messages/[id]/route.ts` + * emits `'UPDATE_MESSAGE'` and the consumer in `hooks/useChatMessages.ts` listens + * for `'UPDATE_MESSAGE'`. This union must stay in sync with both sides. + */ +interface ChatEventPayload { + type: + | 'CONNECTED' + | 'HEARTBEAT' + | 'NEW_MESSAGE' + | 'UPDATE_MESSAGE' + | 'DELETE_MESSAGE' + message?: Record +} + +/** + * Force this route to be dynamically rendered on every request. + * SSE endpoints must never be statically cached by Next.js. + */ +export const dynamic = 'force-dynamic' + +/** + * GET /api/chat/stream + * + * Initialises and manages a Server-Sent Events (SSE) stream for real-time + * chat updates. Each connected browser client receives a dedicated stream + * that forwards mutation events (new messages, edits, deletions) as they + * occur via the in-process `chatEmitter`. + * + * **Architecture note:** The current implementation uses a process-local + * `EventEmitter` (`chatEmitter`) as the broadcast layer. This works + * correctly when the application runs as a single Node.js process (e.g., + * Vercel serverless functions sharing the same runtime, or a single + * `next start` instance). For multi-instance deployments behind a load + * balancer, this should be replaced with a shared pub/sub layer such as + * Redis Pub/Sub, Pusher, or Postgres LISTEN/NOTIFY to ensure events + * propagate across all server instances. + * + * @param req - The incoming NextRequest; its `signal` is used to detect + * client disconnection and trigger cleanup. + * @returns A streaming NextResponse encoded as `text/event-stream`. + */ +export async function GET(req: NextRequest) { + /* + * TransformStream provides a ReadableStream/WritableStream pair. + * The writable side is used internally to push SSE frames; the readable + * side is returned to the client as the response body. + */ + const { readable, writable } = new TransformStream() + const writer = writable.getWriter() + const encoder = new TextEncoder() + + /** + * Serialises `data` as a JSON-encoded SSE frame (`data: ...\n\n`) and + * pushes it to the writable side of the transform stream. + * + * Write errors (e.g., the client has already disconnected before the + * abort signal fires) are caught and logged to prevent unhandled + * rejections from crashing the request handler. + * + * @param data - The structured event payload to send to the client. + */ + const sendEvent = async (data: ChatEventPayload) => { + try { + await writer.write(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) + } catch (writeErr) { + console.error('SSE write error:', writeErr) + } + } + + /** + * Listener callback wired to the shared `chatEmitter` on the + * `'chatUpdate'` channel. Forwards every received event (new message, + * edit, or deletion) directly to this SSE client without filtering. + * + * @param data - The chat event payload emitted by a mutation handler. + */ + const onChatUpdate = (data: ChatEventPayload) => { + sendEvent(data) + } + + // Subscribe this client's listener to the shared in-process event bus. + chatEmitter.on('chatUpdate', onChatUpdate) + + // Immediately send a CONNECTED event so the client knows the stream is live. + sendEvent({ type: 'CONNECTED' }) + + /* + * Heartbeat interval (30 s) keeps the connection alive through proxies + * and load balancers that may drop idle connections. The HEARTBEAT event + * type is ignored by the client-side SSE handler in useChatMessages. + */ + const heartbeatInterval = setInterval(() => { + sendEvent({ type: 'HEARTBEAT' }) + }, 30000) + + /* + * Cleanup handler: when the client disconnects (tab closed, navigation, + * network loss), the request's AbortSignal fires. We must: + * 1. Stop the heartbeat timer to prevent memory leaks. + * 2. Remove the listener from chatEmitter to avoid dangling references. + * 3. Close the writer to finalise the stream. + */ + req.signal.addEventListener('abort', () => { + clearInterval(heartbeatInterval) + chatEmitter.off('chatUpdate', onChatUpdate) + try { + writer.close() + } catch (_closeErr) { + /* Writer may already be closed by the runtime; safe to ignore. */ + } + }) + + return new NextResponse(readable, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }) +} diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 969e275..d472efa 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -1,69 +1,192 @@ 'use client' -import Message from '@/components/message' -import Sidebar from '@/components/sidebar' + +import React, { useEffect, useRef } from 'react' +import { MessageCircle, Send, XCircle } from 'lucide-react' import { Button } from '@/components/ui/button' -import sample_message from '@/data/sample_message' -import React, { useState } from 'react' +import ChatMessage from '@/components/chat/ChatMessage' +import { useChatMessages } from '@/hooks/useChatMessages' +import { useSession } from 'next-auth/react' -interface Participant { - name: string - avatar: string -} +/** + * Full-page UI rendering component for the IIITL General Chat. + * + * This page component serves as a dedicated, immersive chat view that + * occupies the main content area of the layout. It uses the shared + * `useChatMessages` hook with `isOpen: true` (always active) to maintain + * a persistent SSE connection and display real-time message updates. + * + * When the user navigates to `/chat`, the `ChatWidget` component in the + * root layout detects the pathname and returns `null`, ensuring only this + * component manages the SSE connection and message state — preventing + * duplicate API calls and unsynchronised message lists. + * + * @returns A full-page chat layout with message list, input area, and + * contextual reply/edit banners. + */ +export default function ChatPage() { + const { data: session } = useSession() + const { + messages, + inputText, + setInputText, + isLoading, + error, + editingMessage, + replyingTo, + currentUserId, + isIIITLUser, + handleSend, + handleDeleteMessage, + startReply, + startEdit, + cancelAction, + inputRef, + } = useChatMessages({ isOpen: true }) -interface MessageType { - text: string - sender: string - senderAvatar: string - timestamp: string -} -export default function Page() { - const [message, setMessage] = useState('') - const [messages, setMessages] = useState( - sample_message.user_message - ) - const [participants] = useState(sample_message.participants) + /** Ref to a sentinel div at the bottom of the message list for auto-scrolling. */ + const messagesEndRef = useRef(null) - const handleSendMessage = () => { - if (message.trim()) { - const newMessage: MessageType = { - text: message, - sender: 'User1', - senderAvatar: - 'https://th.bing.com/th/id/OIP.7SSen49kIgXR90Ii5VLYUAHaJQ?w=136&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7', - timestamp: new Date().toLocaleTimeString(), - } - setMessages([...messages, newMessage]) - setMessage('') + /** + * Auto-scroll effect: smoothly scrolls the message list to the bottom + * whenever new messages arrive. Scroll is suppressed during edit mode + * to preserve the user's viewport position while they modify a message. + */ + useEffect(() => { + if (!editingMessage && messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) + } + }, [messages, editingMessage]) + + /** + * Keyboard handler for the chat input: submits the message on Enter + * (without Shift held) to match standard chat application UX patterns. + * + * @param e - The keyboard event from the input element. + */ + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() } } return ( -
-
- -
-
- {messages.map((msg, index) => ( - - ))} +
+
+ {/* Header */} +
+
+
+

IIITL General Chat

-
- setMessage(e.target.value)} - placeholder="Type a message..." - className="flex-1 p-3 border border-muted-foreground rounded-lg mr-3" - /> - +
+ Real-time Community
+ + {/* Message Area */} +
+ {messages.length === 0 ? ( +
+
+ +
+

No messages yet. Be the first to say hi!

+
+ ) : ( +
+ {messages.map((msg) => ( + + ))} +
+
+ )} +
+ + {/* Auth / Read-only warning */} + {!isIIITLUser && ( +
+ Read-only mode. You need an @iiitl.ac.in email to send messages in + the group chat. +
+ )} + + {/* Input Area */} + {session && isIIITLUser && ( +
+ {/* Status Banner for Replying/Editing */} + {(replyingTo || editingMessage) && ( +
+ {replyingTo && ( + + Replying to{' '} + {typeof replyingTo.sender === 'object' && + replyingTo.sender !== null + ? (replyingTo.sender as { name: string }).name + : 'someone'} + + )} + {editingMessage && ( + + Editing message + + )} + +
+ )} + +
+ setInputText(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1 bg-muted border border-transparent p-4 rounded-full outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50 text-base transition-all" + disabled={isLoading} + /> + +
+ {error && ( +
+ {error} +
+ )} +
+ )}
) diff --git a/app/layout.jsx b/app/layout.jsx index d03c327..3060ef1 100644 --- a/app/layout.jsx +++ b/app/layout.jsx @@ -5,6 +5,7 @@ import Header from '@/components/header' import AuthProvider from '@/context/session-provider' import { Analytics } from '@vercel/analytics/next' import Script from 'next/script' +import ChatWidget from '@/components/chat/ChatWidget' import { ThemeProvider } from '@/components/theme-provider' const inter = Inter({ subsets: ['latin'] }) @@ -27,6 +28,7 @@ export default function RootLayout({ children }) {
{children}