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) => (
-
- ))}
+
+
+
+
+
+
+
+ {room.members
+ .filter((member) => member.user.id !== me.id)
+ .map((member) => (
+
+ ))}
+
+
>
);
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
+}