Skip to content
228 changes: 228 additions & 0 deletions app/api/chat/messages/[id]/route.ts
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 }
)
}

Comment thread
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 })
}
const { content } = await req.json()
Comment thread
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 }
)
}
Comment thread
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 }
)
}
Comment thread
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 }
)
}
Comment thread
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 }
)
}
}
160 changes: 160 additions & 0 deletions app/api/chat/messages/route.ts
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 })
}
Comment thread
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 }
)
}

Comment thread
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,
})
Comment thread
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 }
)
}
}

Loading
Loading