-
Notifications
You must be signed in to change notification settings - Fork 59
Implemented the group Chat feature #228
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
c7e44a4
Implemented the group Chat feature
Rohank3 5f6f38e
Fixed the floating chat icon appearing on chat page and some other mi…
Rohank3 4725bc0
Added Docstring-Coverage
Rohank3 6813bd3
Added Docstring-Coverage and minor bugs fixed
Rohank3 65f7180
Fixed the polling leftover bug
Rohank3 9d73eb7
bug fixes
Rohank3 76af0b9
Final Bug Fixes and Docstring coverage to 80%+
Rohank3 36486f8
Added Aria-label to components/chat/ChatWidget.tsx
Rohank3 daed739
Added Aria-label to components/chat/ChatWidget.tsx
Rohank3 8497bf4
Added Aria-label to components/chat/ChatWidget.tsx
Rohank3 4561ddc
Merge branch 'main' into group-chat
Rohank3 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| 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 } | ||
| ) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /* ── Deleted messages are tombstones and cannot be resurrected ── */ | ||
| if (message.isDeleted) { | ||
| return NextResponse.json( | ||
| { error: 'Cannot edit a deleted message' }, | ||
| { status: 400 } | ||
| ) | ||
| } | ||
|
MrImmortal09 marked this conversation as resolved.
|
||
|
|
||
| /* ── 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 } | ||
| ) | ||
| } | ||
|
MrImmortal09 marked this conversation as resolved.
|
||
|
|
||
| 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 } | ||
| ) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /* ── 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 } | ||
| ) | ||
| } | ||
|
|
||
|
MrImmortal09 marked this conversation as resolved.
|
||
| 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, | ||
| }) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /* | ||
| * 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 } | ||
| ) | ||
| } | ||
| } | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.