From e3fbdc4ee8f0af6b26a8a3cbea51dd7fa2863b10 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Tue, 17 Jun 2025 19:03:34 +0900 Subject: [PATCH] feat: delete chat room --- server/routes/chat.ts | 65 +++++++++++++ web/src/actions/room.ts | 15 +++ .../app/[locale]/(auth)/chat/[id]/page.tsx | 35 ++++--- web/src/app/[locale]/(auth)/users/client.tsx | 3 +- web/src/components/Header.tsx | 6 +- web/src/components/chat/DeleteRoomButton.tsx | 95 +++++++++++++++++++ web/src/consts.ts | 1 + web/src/data/room.server.ts | 17 ++++ 8 files changed, 220 insertions(+), 17 deletions(-) create mode 100644 web/src/actions/room.ts create mode 100644 web/src/components/chat/DeleteRoomButton.tsx diff --git a/server/routes/chat.ts b/server/routes/chat.ts index 3319fc1..4793298 100644 --- a/server/routes/chat.ts +++ b/server/routes/chat.ts @@ -128,6 +128,65 @@ const router = new Hono() } }, ) + /** + * Delete a chat room and all its associated data + * + * This endpoint allows a room member to delete the entire chat room. + * It will: + * 1. Verify the requesting user is a member of the room + * 2. Delete the room (which cascades to messages and memberships due to foreign key constraints) + * 3. Broadcast a DeleteRoom event to all room members + * + * @route DELETE /rooms/:room + * @param room - The ID of the room to delete + * @returns {Object} Success status + * @throws {HTTPException} 404 - If room not found or user is not a member + * @throws {HTTPException} 500 - If room deletion fails + */ + .delete( + "/rooms/:room", + zValidator("param", z.object({ room: z.string() })), + zValidator("header", z.object({ Authorization: z.string() })), + async (c) => { + const userId = await getUserID(c); + const { room: roomId } = c.req.valid("param"); + + // Verify user is a member of the room to prevent unauthorized deletion + const membership = await prisma.belongs.findUnique({ + where: { userId_roomId: { userId, roomId } }, + select: { roomId: true }, + }); + + if (!membership) { + throw new HTTPException(404, { message: "Room not found or access denied" }); + } + + // Get all room members before deletion so we can notify them + const roomMembers = await prisma.belongs.findMany({ + where: { roomId }, + select: { userId: true }, + }); + const memberIds = roomMembers.map((member) => member.userId); + + try { + // Delete the room - this will cascade to messages and memberships + await prisma.room.delete({ + where: { id: roomId }, + }); + + // Notify all room members that the room was deleted + broadcast(memberIds, { + event: "DeleteRoom", + data: devalue({ roomId }), + }); + + return c.json({ ok: true }, 200); + } catch (err) { + console.error("Failed to delete room:", err); + throw new HTTPException(500, { message: "Failed to delete room" }); + } + }, + ) // ## room preview .get("/rooms/preview", zValidator("header", z.object({ Authorization: z.string() })), async (c) => { const requester = await getUserID(c); @@ -540,6 +599,12 @@ type BroadcastEvents = id: string; }>; } + | { + event: "DeleteRoom"; + data: Devalue<{ + roomId: string; + }>; + } | { event: "Ping"; data: ""; diff --git a/web/src/actions/room.ts b/web/src/actions/room.ts new file mode 100644 index 0000000..45b7766 --- /dev/null +++ b/web/src/actions/room.ts @@ -0,0 +1,15 @@ +"use server"; + +import { deleteRoom as deleteRoomApi } from "@/data/room.server"; +import { revalidatePath } from "next/cache"; + +export async function deleteRoom(roomId: string) { + try { + await deleteRoomApi(roomId); + revalidatePath("/chat"); + return { success: true }; + } catch (error) { + console.error("Failed to delete room:", error); + return { error: "Failed to delete room" }; + } +} diff --git a/web/src/app/[locale]/(auth)/chat/[id]/page.tsx b/web/src/app/[locale]/(auth)/chat/[id]/page.tsx index 5fbb0f9..05978c0 100644 --- a/web/src/app/[locale]/(auth)/chat/[id]/page.tsx +++ b/web/src/app/[locale]/(auth)/chat/[id]/page.tsx @@ -1,5 +1,7 @@ import Avatar from "@/components/Avatar"; import Loading from "@/components/Loading.tsx"; +import DeleteRoomButton from "@/components/chat/DeleteRoomButton"; +import { HEADER_HEIGHT_TW } from "@/consts.ts"; import { getRoomData } from "@/data/room.server.ts"; import { getMyData } from "@/data/user.server.ts"; import { Link } from "@/i18n/navigation"; @@ -45,21 +47,26 @@ async function Load({ roomId }: { roomId: string }) { function ChatHeader({ room, me }: { room: ContentfulRoom; me: MYDATA }) { return ( <> -
-
- - - -
- {room.members - .filter((member) => member.user.id !== me.id) - .map((member) => ( -
- -
{member.user.name}
-
- ))} +
+
+
+ + + +
+ {room.members + .filter((member) => member.user.id !== me.id) + .map((member) => ( +
+ +
{member.user.name}
+
+ ))} +
+
); diff --git a/web/src/app/[locale]/(auth)/users/client.tsx b/web/src/app/[locale]/(auth)/users/client.tsx index d102501..e72f3bc 100644 --- a/web/src/app/[locale]/(auth)/users/client.tsx +++ b/web/src/app/[locale]/(auth)/users/client.tsx @@ -5,10 +5,11 @@ import Avatar from "@/components/Avatar"; import { useAuthContext } from "@/features/auth/providers/AuthProvider"; import type { FlatUser, MYDATA } from "common/zod/schema"; import { useTranslations } from "next-intl"; -import router from "next/router"; +import { useRouter } from "next/navigation"; import { useState } from "react"; export function ClientPage({ me, initUser }: { me: MYDATA; initUser: FlatUser }) { + const router = useRouter(); const [user, setUser] = useState(initUser); const { idToken: Authorization } = useAuthContext(); const t = useTranslations(); diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index 4f1d14f..9475ec1 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -1,6 +1,6 @@ "use client"; -import { useUserContext } from "@/features/user/userProvider.tsx"; +import { HEADER_HEIGHT_TW } from "@/consts.ts"; import { useNormalizedPathname } from "@/hooks/useNormalizedPath.ts"; import { Link } from "@/i18n/navigation.ts"; import type { MYDATA } from "common/zod/schema"; @@ -8,6 +8,8 @@ import { useTranslations } from "next-intl"; import { AppIcon } from "./AppIcon.tsx"; import Avatar from "./Avatar.tsx"; +const __HEADER_HEIGHT_CLASSES = `h-${HEADER_HEIGHT_TW} top-${HEADER_HEIGHT_TW}`; // make tailwind compiler happy + export default function Header({ user }: { user: MYDATA | null }) { const t = useTranslations(); const pathname = useNormalizedPathname(); @@ -25,7 +27,7 @@ export default function Header({ user }: { user: MYDATA | null }) { return ( <> -
+
diff --git a/web/src/components/chat/DeleteRoomButton.tsx b/web/src/components/chat/DeleteRoomButton.tsx new file mode 100644 index 0000000..3baa646 --- /dev/null +++ b/web/src/components/chat/DeleteRoomButton.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { deleteRoom } from "@/actions/room"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export default function DeleteRoomButton({ roomId }: { roomId: string }) { + const router = useRouter(); + const [isDeleting, setIsDeleting] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + + const handleDelete = async () => { + if (isDeleting) return; + + setIsDeleting(true); + try { + const result = await deleteRoom(roomId); + if (result.error) { + throw new Error(result.error); + } + // The server will broadcast a DeleteRoom event that will be handled by the chat client + router.push("/chat"); + } catch (error) { + console.error("Failed to delete room:", error); + alert(error instanceof Error ? error.message : "Failed to delete room. Please try again."); + } finally { + setIsDeleting(false); + setShowConfirm(false); + } + }; + + return ( +
+ + + {showConfirm && ( +
+

+ Are you sure you want to delete this chat room? This action cannot be undone. +

+
+ + +
+
+ )} +
+ ); +} diff --git a/web/src/consts.ts b/web/src/consts.ts index ddff65e..736e077 100644 --- a/web/src/consts.ts +++ b/web/src/consts.ts @@ -5,6 +5,7 @@ export const PATHNAME_LANG_PREFIX_PATTERN = /^\/(ja|en)/; export const STEP_1_DATA_SESSION_STORAGE_KEY = "ut_bridge_step_1_data"; // registration formのimagePreviewUrlをsession storageに保存するkey export const IMAGE_PREVIEW_URL_SESSION_STORAGE_KEY = "ut_bridge_image_preview_url"; +export const HEADER_HEIGHT_TW = "16"; export const cookieNames = { idToken: "ut-bridge::id-token", diff --git a/web/src/data/room.server.ts b/web/src/data/room.server.ts index b502f17..c4ae294 100644 --- a/web/src/data/room.server.ts +++ b/web/src/data/room.server.ts @@ -21,6 +21,7 @@ export async function getRoomData(roomId: string): Promise { room: roomId, }, }); + if (!res.ok) throw new Error("Failed to fetch room data"); const json = await res.json(); return { ...json, @@ -30,3 +31,19 @@ export async function getRoomData(roomId: string): Promise { })), }; } + +export async function deleteRoom(roomId: string): Promise { + const idToken = await getIdToken(); + + const response = await client.chat.rooms[":room"].$delete({ + header: { Authorization: idToken }, + param: { room: roomId }, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error("Failed to delete room"); + } + + // Redirect should be handled in the component where this function is called +}