Skip to content

Commit 6754da5

Browse files
authored
feat: delete chat room (#326)
1 parent 5f3a3fa commit 6754da5

File tree

8 files changed

+220
-17
lines changed

8 files changed

+220
-17
lines changed

server/routes/chat.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,65 @@ const router = new Hono()
128128
}
129129
},
130130
)
131+
/**
132+
* Delete a chat room and all its associated data
133+
*
134+
* This endpoint allows a room member to delete the entire chat room.
135+
* It will:
136+
* 1. Verify the requesting user is a member of the room
137+
* 2. Delete the room (which cascades to messages and memberships due to foreign key constraints)
138+
* 3. Broadcast a DeleteRoom event to all room members
139+
*
140+
* @route DELETE /rooms/:room
141+
* @param room - The ID of the room to delete
142+
* @returns {Object} Success status
143+
* @throws {HTTPException} 404 - If room not found or user is not a member
144+
* @throws {HTTPException} 500 - If room deletion fails
145+
*/
146+
.delete(
147+
"/rooms/:room",
148+
zValidator("param", z.object({ room: z.string() })),
149+
zValidator("header", z.object({ Authorization: z.string() })),
150+
async (c) => {
151+
const userId = await getUserID(c);
152+
const { room: roomId } = c.req.valid("param");
153+
154+
// Verify user is a member of the room to prevent unauthorized deletion
155+
const membership = await prisma.belongs.findUnique({
156+
where: { userId_roomId: { userId, roomId } },
157+
select: { roomId: true },
158+
});
159+
160+
if (!membership) {
161+
throw new HTTPException(404, { message: "Room not found or access denied" });
162+
}
163+
164+
// Get all room members before deletion so we can notify them
165+
const roomMembers = await prisma.belongs.findMany({
166+
where: { roomId },
167+
select: { userId: true },
168+
});
169+
const memberIds = roomMembers.map((member) => member.userId);
170+
171+
try {
172+
// Delete the room - this will cascade to messages and memberships
173+
await prisma.room.delete({
174+
where: { id: roomId },
175+
});
176+
177+
// Notify all room members that the room was deleted
178+
broadcast(memberIds, {
179+
event: "DeleteRoom",
180+
data: devalue({ roomId }),
181+
});
182+
183+
return c.json({ ok: true }, 200);
184+
} catch (err) {
185+
console.error("Failed to delete room:", err);
186+
throw new HTTPException(500, { message: "Failed to delete room" });
187+
}
188+
},
189+
)
131190
// ## room preview
132191
.get("/rooms/preview", zValidator("header", z.object({ Authorization: z.string() })), async (c) => {
133192
const requester = await getUserID(c);
@@ -540,6 +599,12 @@ type BroadcastEvents =
540599
id: string;
541600
}>;
542601
}
602+
| {
603+
event: "DeleteRoom";
604+
data: Devalue<{
605+
roomId: string;
606+
}>;
607+
}
543608
| {
544609
event: "Ping";
545610
data: "";

web/src/actions/room.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"use server";
2+
3+
import { deleteRoom as deleteRoomApi } from "@/data/room.server";
4+
import { revalidatePath } from "next/cache";
5+
6+
export async function deleteRoom(roomId: string) {
7+
try {
8+
await deleteRoomApi(roomId);
9+
revalidatePath("/chat");
10+
return { success: true };
11+
} catch (error) {
12+
console.error("Failed to delete room:", error);
13+
return { error: "Failed to delete room" };
14+
}
15+
}

web/src/app/[locale]/(auth)/chat/[id]/page.tsx

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import Avatar from "@/components/Avatar";
22
import Loading from "@/components/Loading.tsx";
3+
import DeleteRoomButton from "@/components/chat/DeleteRoomButton";
4+
import { HEADER_HEIGHT_TW } from "@/consts.ts";
35
import { getRoomData } from "@/data/room.server.ts";
46
import { getMyData } from "@/data/user.server.ts";
57
import { Link } from "@/i18n/navigation";
@@ -45,21 +47,26 @@ async function Load({ roomId }: { roomId: string }) {
4547
function ChatHeader({ room, me }: { room: ContentfulRoom; me: MYDATA }) {
4648
return (
4749
<>
48-
<div className="invisible h-[56px]" />
49-
<div className="fixed top-[56px] z-10 flex w-full items-center bg-stone-200 py-2">
50-
<Link href={"/chat"} className="mx-2">
51-
<AiOutlineLeft size={25} />
52-
</Link>
53-
<div className="mr-[33px] w-full text-center text-xl">
54-
{room.members
55-
.filter((member) => member.user.id !== me.id)
56-
.map((member) => (
57-
<div key={member.user.id} className=" ml-2 flex items-center gap-2">
58-
<Avatar alt={member.user.name || "User"} src={member.user.imageUrl} size={40} />
59-
<div>{member.user.name}</div>
60-
</div>
61-
))}
50+
<div className={`invisible h-${HEADER_HEIGHT_TW}`} />
51+
<div
52+
className={`fixed top-${HEADER_HEIGHT_TW} z-10 flex w-full items-center justify-between bg-stone-200 px-4 py-2`}
53+
>
54+
<div className="flex items-center">
55+
<Link href={"/chat"} className="mr-2">
56+
<AiOutlineLeft size={25} />
57+
</Link>
58+
<div className="flex items-center text-xl">
59+
{room.members
60+
.filter((member) => member.user.id !== me.id)
61+
.map((member) => (
62+
<div key={member.user.id} className="flex items-center gap-2">
63+
<Avatar alt={member.user.name || "User"} src={member.user.imageUrl} size={40} />
64+
<div>{member.user.name}</div>
65+
</div>
66+
))}
67+
</div>
6268
</div>
69+
<DeleteRoomButton roomId={room.id} />
6370
</div>
6471
</>
6572
);

web/src/app/[locale]/(auth)/users/client.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import Avatar from "@/components/Avatar";
55
import { useAuthContext } from "@/features/auth/providers/AuthProvider";
66
import type { FlatUser, MYDATA } from "common/zod/schema";
77
import { useTranslations } from "next-intl";
8-
import router from "next/router";
8+
import { useRouter } from "next/navigation";
99
import { useState } from "react";
1010

1111
export function ClientPage({ me, initUser }: { me: MYDATA; initUser: FlatUser }) {
12+
const router = useRouter();
1213
const [user, setUser] = useState(initUser);
1314
const { idToken: Authorization } = useAuthContext();
1415
const t = useTranslations();

web/src/components/Header.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"use client";
22

3-
import { useUserContext } from "@/features/user/userProvider.tsx";
3+
import { HEADER_HEIGHT_TW } from "@/consts.ts";
44
import { useNormalizedPathname } from "@/hooks/useNormalizedPath.ts";
55
import { Link } from "@/i18n/navigation.ts";
66
import type { MYDATA } from "common/zod/schema";
77
import { useTranslations } from "next-intl";
88
import { AppIcon } from "./AppIcon.tsx";
99
import Avatar from "./Avatar.tsx";
1010

11+
const __HEADER_HEIGHT_CLASSES = `h-${HEADER_HEIGHT_TW} top-${HEADER_HEIGHT_TW}`; // make tailwind compiler happy
12+
1113
export default function Header({ user }: { user: MYDATA | null }) {
1214
const t = useTranslations();
1315
const pathname = useNormalizedPathname();
@@ -25,7 +27,7 @@ export default function Header({ user }: { user: MYDATA | null }) {
2527

2628
return (
2729
<>
28-
<header className="fixed top-0 z-10 h-16 w-full bg-tBlue">
30+
<header className={`fixed top-0 z-10 h-${HEADER_HEIGHT_TW} w-full bg-tBlue`}>
2931
<div className="flex h-16 items-center">
3032
<Link href={user ? "/community" : "/"} passHref className="px-4">
3133
<AppIcon width={36} height={36} />
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"use client";
2+
3+
import { deleteRoom } from "@/actions/room";
4+
import { useRouter } from "next/navigation";
5+
import { useState } from "react";
6+
7+
export default function DeleteRoomButton({ roomId }: { roomId: string }) {
8+
const router = useRouter();
9+
const [isDeleting, setIsDeleting] = useState(false);
10+
const [showConfirm, setShowConfirm] = useState(false);
11+
12+
const handleDelete = async () => {
13+
if (isDeleting) return;
14+
15+
setIsDeleting(true);
16+
try {
17+
const result = await deleteRoom(roomId);
18+
if (result.error) {
19+
throw new Error(result.error);
20+
}
21+
// The server will broadcast a DeleteRoom event that will be handled by the chat client
22+
router.push("/chat");
23+
} catch (error) {
24+
console.error("Failed to delete room:", error);
25+
alert(error instanceof Error ? error.message : "Failed to delete room. Please try again.");
26+
} finally {
27+
setIsDeleting(false);
28+
setShowConfirm(false);
29+
}
30+
};
31+
32+
return (
33+
<div className="relative">
34+
<button
35+
type="button"
36+
onClick={(e) => {
37+
e.preventDefault();
38+
setShowConfirm(true);
39+
}}
40+
className="rounded-full p-2 text-red-600 hover:bg-red-100"
41+
title="Delete Room"
42+
disabled={isDeleting}
43+
aria-label="Delete room"
44+
>
45+
<svg
46+
xmlns="http://www.w3.org/2000/svg"
47+
className="h-5 w-5"
48+
fill="none"
49+
viewBox="0 0 24 24"
50+
stroke="currentColor"
51+
aria-hidden="true"
52+
>
53+
<path
54+
strokeLinecap="round"
55+
strokeLinejoin="round"
56+
strokeWidth={2}
57+
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
58+
/>
59+
</svg>
60+
</button>
61+
62+
{showConfirm && (
63+
<div className="absolute right-0 z-50 w-64 rounded-md bg-white p-4 shadow-lg ring-1 ring-black/5">
64+
<p className="mb-4 text-gray-700 text-sm">
65+
Are you sure you want to delete this chat room? This action cannot be undone.
66+
</p>
67+
<div className="flex justify-end space-x-2">
68+
<button
69+
type="button"
70+
onClick={(e) => {
71+
e.preventDefault();
72+
setShowConfirm(false);
73+
}}
74+
className="rounded px-3 py-1 text-gray-600 text-sm hover:bg-gray-100"
75+
disabled={isDeleting}
76+
>
77+
Cancel
78+
</button>
79+
<button
80+
type="button"
81+
onClick={(e) => {
82+
e.preventDefault();
83+
handleDelete();
84+
}}
85+
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700 disabled:opacity-50"
86+
disabled={isDeleting}
87+
>
88+
{isDeleting ? "Deleting..." : "Delete"}
89+
</button>
90+
</div>
91+
</div>
92+
)}
93+
</div>
94+
);
95+
}

web/src/consts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const PATHNAME_LANG_PREFIX_PATTERN = /^\/(ja|en)/;
55
export const STEP_1_DATA_SESSION_STORAGE_KEY = "ut_bridge_step_1_data";
66
// registration formのimagePreviewUrlをsession storageに保存するkey
77
export const IMAGE_PREVIEW_URL_SESSION_STORAGE_KEY = "ut_bridge_image_preview_url";
8+
export const HEADER_HEIGHT_TW = "16";
89

910
export const cookieNames = {
1011
idToken: "ut-bridge::id-token",

web/src/data/room.server.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export async function getRoomData(roomId: string): Promise<ContentfulRoom> {
2121
room: roomId,
2222
},
2323
});
24+
if (!res.ok) throw new Error("Failed to fetch room data");
2425
const json = await res.json();
2526
return {
2627
...json,
@@ -30,3 +31,19 @@ export async function getRoomData(roomId: string): Promise<ContentfulRoom> {
3031
})),
3132
};
3233
}
34+
35+
export async function deleteRoom(roomId: string): Promise<void> {
36+
const idToken = await getIdToken();
37+
38+
const response = await client.chat.rooms[":room"].$delete({
39+
header: { Authorization: idToken },
40+
param: { room: roomId },
41+
});
42+
43+
if (!response.ok) {
44+
const error = await response.json();
45+
throw new Error("Failed to delete room");
46+
}
47+
48+
// Redirect should be handled in the component where this function is called
49+
}

0 commit comments

Comments
 (0)