feat(chat): add DELETE /api/messages/[messageId] endpoint and message deletion UI

This commit is contained in:
Chneemann 2026-08-30 19:55:26 +02:00
parent ac8392569b
commit 4f49caf1f1
No known key found for this signature in database
4 changed files with 174 additions and 13 deletions

View file

@ -3,6 +3,7 @@
* @description Dynamic page component for displaying a specific channel within a server, including its messages and chat input.
*/
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { AppHeader } from "@/components/layout/AppHeader";
import { ChatInput } from "@/components/chat/ChatInput";
@ -14,6 +15,7 @@ import { getServerById } from "@/lib/services/server.service";
/**
* Renders the channel view by fetching server, channel, and message details in parallel based on route parameters.
*
* @async
* @param {Object} props - The component props.
* @param {Promise<{ serverId: string; channelId: string }>} props.params - A promise resolving to the route parameters containing serverId and channelId.
* @returns {Promise<JSX.Element>} The rendered channel page interface.
@ -23,6 +25,7 @@ export default async function ChannelPage({
}: {
params: Promise<{ serverId: string; channelId: string }>;
}) {
const session = await auth();
const { serverId, channelId } = await params;
// Parallel loading of server, channel, and messages
@ -44,7 +47,11 @@ export default async function ChannelPage({
/>
{/* Messages Feed */}
<ChatMessages channelName={channel.name} messages={channelMessages} />
<ChatMessages
channelName={channel.name}
messages={channelMessages}
currentUserId={session?.user?.id}
/>
{/* Input Field */}
<ChatInput

View file

@ -0,0 +1,70 @@
/**
* @file app/api/messages/[messageId]/route.ts
* @description API route handler for deleting a specific message.
*/
import { auth } from "@/auth";
import { db } from "@/db";
import { members, messages } from "@/db/schema";
import { eq } from "drizzle-orm";
import { NextResponse } from "next/server";
/**
* Handles the DELETE request to remove a specific message by its ID.
* Verifies user authentication and ensures the user owns the message before deletion.
*
* @async
* @function DELETE
* @param {Request} req - The incoming HTTP request object.
* @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<NextResponse>} JSON response indicating success or failure with appropriate HTTP status codes.
*/
export async function DELETE(
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 });
}
// Retrieve a message and its associated member
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 (Is the user the creator of the message?)
if (existingMessage.userId !== session.user.id) {
return NextResponse.json(
{ error: "You do not have permission to delete this message" },
{ status: 403 },
);
}
// 3. Nachricht löschen
await db.delete(messages).where(eq(messages.id, messageId));
return NextResponse.json({ success: true });
} catch (error) {
console.error("API Message DELETE error:", error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
}

View file

@ -1,15 +1,24 @@
/**
* @file components/chat/ChatItem.tsx
* @description Single message row component.
* @description Single message row component with support for user avatars, metadata, and deletion handling.
*/
"use client";
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";
/**
* Composite message type extending base database Message with populated member and user relation.
*
* @interface MessageWithMember
* @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 {Member & { user: User }} member - The associated member and user relational data.
*/
export type MessageWithMember = Message & {
member: Member & {
@ -17,16 +26,34 @@ export type MessageWithMember = Message & {
};
};
/**
* Props for the ChatItem component.
*
* @interface ChatItemProps
* @property {MessageWithMember} message - The message object containing member and user relational data.
* @property {string} [currentUserId] - The unique identifier of the currently logged-in user.
*/
interface ChatItemProps {
message: MessageWithMember;
currentUserId?: string;
}
/**
* Renders an individual chat message row displaying user avatar, sender name, timestamp, and text content.
*
* @param {Object} props - The component props.
* @async
* @param {ChatItemProps} props - The component props.
* @param {MessageWithMember} props.message - The message object containing member and user relational data.
* @param {string} [props.currentUserId] - The unique identifier of the currently logged-in user.
* @returns {JSX.Element} The rendered single chat message item.
*/
export function ChatItem({ message }: { message: MessageWithMember }) {
export function ChatItem({ message, currentUserId }: ChatItemProps) {
const router = useRouter();
const [isDeleting, setIsDeleting] = useState(false);
const user = message.member?.user;
const fullName = user ? user.username.trim() : "Deleted Member";
const isOwner = user?.id === currentUserId;
const formattedTime = new Date(message.createdAt).toLocaleTimeString(
"de-DE",
@ -36,20 +63,64 @@ export function ChatItem({ message }: { message: MessageWithMember }) {
},
);
/**
* Handles the asynchronous deletion of the chat message.
*
* @async
* @function handleDelete
* @returns {Promise<void>} Resolves when the deletion process completes or fails.
*/
const handleDelete = async () => {
if (isDeleting) return;
try {
setIsDeleting(true);
const response = await fetch(`/api/messages/${message.id}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Error while deleting");
}
router.refresh();
} catch (error) {
console.error("Error deleting the message:", error);
} finally {
setIsDeleting(false);
}
};
return (
<div className="flex items-start gap-3 group p-2 rounded-xl hover:bg-surface transition-colors">
{/* Avatar Component */}
<UserAvatar user={user} size="md" />
{/* Message Header & Content */}
{/* Message Content & Top Bar */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-semibold text-white text-sm hover:underline cursor-pointer">
{fullName}
</span>
<span className="text-xs text-muted">{formattedTime}</span>
<div className="flex items-center justify-between gap-2">
<div className="flex items-baseline gap-2 min-w-0">
<span className="font-semibold text-white text-sm hover:underline cursor-pointer truncate">
{fullName}
</span>
<span className="text-xs text-muted shrink-0">{formattedTime}</span>
</div>
{/* Delete Button */}
{isOwner && (
<button
type="button"
onClick={handleDelete}
disabled={isDeleting}
className="opacity-0 group-hover:opacity-100 p-1 text-muted hover:text-red-400 focus:outline-none transition-all cursor-pointer shrink-0 disabled:opacity-50"
aria-label="Delete message"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<p className="text-foreground text-sm leading-relaxed wrap-break-words">
<p className="text-foreground text-sm leading-relaxed wrap-break-words mt-0.5">
{message.content}
</p>
</div>

View file

@ -13,19 +13,28 @@ import { ChatItem, type MessageWithMember } from "./ChatItem";
* @interface ChatMessagesProps
* @property {string} channelName - The name of the active chat channel to display in the header greeting.
* @property {MessageWithMember[]} messages - Array of message objects, each containing message details and associated member information.
* @property {string} [currentUserId] - The unique identifier of the currently logged-in user.
*/
interface ChatMessagesProps {
channelName: string;
messages: MessageWithMember[];
currentUserId?: string;
}
/**
* Renders the scrollable message list along with a welcoming channel header.
*
* @param {ChatMessagesProps} props - The component props.
* @param {string} props.channelName - The name of the active chat channel to display in the header greeting.
* @param {MessageWithMember[]} props.messages - Array of message objects, each containing message details and associated member information.
* @param {string} [props.currentUserId] - The unique identifier of the currently logged-in user.
* @returns {JSX.Element} The rendered chat messages container.
*/
export function ChatMessages({ channelName, messages }: ChatMessagesProps) {
export function ChatMessages({
channelName,
messages,
currentUserId,
}: ChatMessagesProps) {
return (
<div className="flex-1 overflow-y-auto flex flex-col justify-end">
<div className="mb-4 border-b border-surface/50">
@ -39,7 +48,11 @@ export function ChatMessages({ channelName, messages }: ChatMessagesProps) {
<div className="space-y-1 mb-4">
{messages.map((message) => (
<ChatItem key={message.id} message={message} />
<ChatItem
key={message.id}
message={message}
currentUserId={currentUserId}
/>
))}
</div>
</div>