From e610b6c9dbcf7afdd3878527f438709dc34b54c0 Mon Sep 17 00:00:00 2001 From: Chneemann Date: Mon, 31 Aug 2026 09:24:37 +0200 Subject: [PATCH] feat(chat): add PATCH /api/messages/[messageId] endpoint and message inline editing UI --- app/api/messages/[messageId]/route.ts | 82 ++++++++++++++- app/api/messages/route.ts | 2 + components/chat/ChatItem.tsx | 145 ++++++++++++++++++++++---- 3 files changed, 204 insertions(+), 25 deletions(-) diff --git a/app/api/messages/[messageId]/route.ts b/app/api/messages/[messageId]/route.ts index 7fae7eb..61ba94f 100644 --- a/app/api/messages/[messageId]/route.ts +++ b/app/api/messages/[messageId]/route.ts @@ -1,6 +1,6 @@ /** * @file app/api/messages/[messageId]/route.ts - * @description API route handler for deleting a specific message. + * @description API route handlers for deleting and updating individual chat messages. */ import { auth } from "@/auth"; @@ -48,7 +48,7 @@ export async function DELETE( return NextResponse.json({ error: "Message not found" }, { status: 404 }); } - // Check permissions (Is the user the creator of the message?) + // Check Permissions if (existingMessage.userId !== session.user.id) { return NextResponse.json( { error: "You do not have permission to delete this message" }, @@ -56,7 +56,7 @@ export async function DELETE( ); } - // 3. Nachricht löschen + // Delete message await db.delete(messages).where(eq(messages.id, messageId)); return NextResponse.json({ success: true }); @@ -68,3 +68,79 @@ export async function DELETE( ); } } + +/** + * Handles the PATCH request to update the content of a specific message by its ID. + * Verifies user authentication, request body content, and ensures the user owns the message before updating. + * + * @async + * @function PATCH + * @param {Request} req - The incoming HTTP request object containing the updated message content. + * @param {Object} context - The route context. + * @param {Promise<{ messageId: string }>} context.params - A promise resolving to the route parameters containing the message ID. + * @returns {Promise} JSON response containing the updated message object or an error message with appropriate HTTP status codes. + */ +export async function PATCH( + req: Request, + { params }: { params: Promise<{ messageId: string }> }, +) { + try { + const { messageId } = await params; + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { content } = await req.json(); + + if (!content?.trim()) { + return NextResponse.json( + { error: "The content cannot be empty" }, + { status: 400 }, + ); + } + + // Check Message and Owner + const [existingMessage] = await db + .select({ + id: messages.id, + memberId: messages.memberId, + userId: members.userId, + }) + .from(messages) + .innerJoin(members, eq(messages.memberId, members.id)) + .where(eq(messages.id, messageId)) + .limit(1); + + if (!existingMessage) { + return NextResponse.json({ error: "Message not found" }, { status: 404 }); + } + + // Check Permissions + if (existingMessage.userId !== session.user.id) { + return NextResponse.json( + { error: "You do not have permission to edit this message" }, + { status: 403 }, + ); + } + + // Update message + const [updatedMessage] = await db + .update(messages) + .set({ + content: content.trim(), + updatedAt: new Date(), + }) + .where(eq(messages.id, messageId)) + .returning(); + + return NextResponse.json(updatedMessage); + } catch (error) { + console.error("API Message PATCH error:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts index 9605755..2e74c70 100644 --- a/app/api/messages/route.ts +++ b/app/api/messages/route.ts @@ -58,6 +58,8 @@ export async function POST(req: Request) { content: content.trim(), channelId, memberId: member.id, + createdAt: new Date(), + updatedAt: new Date(), }) .returning(); diff --git a/components/chat/ChatItem.tsx b/components/chat/ChatItem.tsx index 0f14274..9e6e0e4 100644 --- a/components/chat/ChatItem.tsx +++ b/components/chat/ChatItem.tsx @@ -1,6 +1,6 @@ /** * @file components/chat/ChatItem.tsx - * @description Single message row component with support for user avatars, metadata, and deletion handling. + * @description Single message row component supporting inline editing, deletion, and user association details. */ "use client"; @@ -9,7 +9,7 @@ import type { Message, Member, User } from "@/db/schema"; import { UserAvatar } from "../ui/UserAvatar"; import { useState } from "react"; import { useRouter } from "next/navigation"; -import { Trash2 } from "lucide-react"; +import { Check, Pencil, Trash2, X } from "lucide-react"; /** * Composite message type extending base database Message with populated member and user relation. @@ -18,6 +18,7 @@ import { Trash2 } from "lucide-react"; * @property {string} id - The unique identifier of the message. * @property {string} content - The text content of the message. * @property {string} createdAt - The timestamp when the message was created. + * @property {string} [updatedAt] - The timestamp when the message was last updated. * @property {Member & { user: User }} member - The associated member and user relational data. */ export type MessageWithMember = Message & { @@ -39,7 +40,8 @@ interface ChatItemProps { } /** - * Renders an individual chat message row displaying user avatar, sender name, timestamp, and text content. + * Renders an individual chat message row displaying user avatar, sender name, timestamp, + * edited indicator, and inline editing or deletion capabilities. * * @async * @param {ChatItemProps} props - The component props. @@ -50,10 +52,17 @@ interface ChatItemProps { export function ChatItem({ message, currentUserId }: ChatItemProps) { const router = useRouter(); const [isDeleting, setIsDeleting] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [content, setContent] = useState(message.content); + const [isLoading, setIsLoading] = useState(false); const user = message.member?.user; const fullName = user ? user.username.trim() : "Deleted Member"; const isOwner = user?.id === currentUserId; + const isUpdated = + message.updatedAt && + new Date(message.updatedAt).getTime() > + new Date(message.createdAt).getTime(); const formattedTime = new Date(message.createdAt).toLocaleTimeString( "de-DE", @@ -64,11 +73,11 @@ export function ChatItem({ message, currentUserId }: ChatItemProps) { ); /** - * Handles the asynchronous deletion of the chat message. + * Handles the asynchronous deletion of the chat message via API. * * @async * @function handleDelete - * @returns {Promise} Resolves when the deletion process completes or fails. + * @returns {Promise} Resolves when the deletion completes or fails. */ const handleDelete = async () => { if (isDeleting) return; @@ -80,7 +89,7 @@ export function ChatItem({ message, currentUserId }: ChatItemProps) { }); if (!response.ok) { - throw new Error("Error while deleting"); + throw new Error("Failed to delete message"); } router.refresh(); @@ -91,12 +100,58 @@ export function ChatItem({ message, currentUserId }: ChatItemProps) { } }; + /** + * Handles the asynchronous update of the chat message content via API. + * + * @async + * @function handleEdit + * @returns {Promise} Resolves when the message update completes or fails. + */ + const handleEdit = async () => { + if (!content.trim() || isLoading) return; + + try { + setIsLoading(true); + const response = await fetch(`/api/messages/${message.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); + + if (!response.ok) { + throw new Error("Failed to update message"); + } + + setIsEditing(false); + router.refresh(); + } catch (error) { + console.error("Error editing the message:", error); + } finally { + setIsLoading(false); + } + }; + + /** + * Handles keyboard events during inline editing (Enter to save, Escape to cancel). + * + * @function handleKeyDown + * @param {React.KeyboardEvent} e - The keyboard event object. + * @returns {void} + */ + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleEdit(); + } else if (e.key === "Escape") { + setIsEditing(false); + setContent(message.content); + } + }; + return (
- {/* Avatar Component */} - {/* Message Content & Top Bar */}
@@ -104,25 +159,71 @@ export function ChatItem({ message, currentUserId }: ChatItemProps) { {fullName} {formattedTime} + {isUpdated && ( + (edited) + )}
- {/* Delete Button */} - {isOwner && ( - + {/* Action Buttons */} + {isOwner && !isEditing && ( +
+ + +
)}
-

- {message.content} -

+ {/* Inline Edit Input vs. Regular Content */} + {isEditing ? ( +
+ setContent(e.target.value)} + onKeyDown={handleKeyDown} + disabled={isLoading} + className="w-full bg-background border border-surface rounded px-2 py-1 text-sm text-foreground outline-none focus:ring-1 focus:ring-accent" + autoFocus + /> + + +
+ ) : ( +

+ {message.content} +

+ )}
);