Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions server/routes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -540,6 +599,12 @@ type BroadcastEvents =
id: string;
}>;
}
| {
event: "DeleteRoom";
data: Devalue<{
roomId: string;
}>;
}
| {
event: "Ping";
data: "";
Expand Down
15 changes: 15 additions & 0 deletions web/src/actions/room.ts
Original file line number Diff line number Diff line change
@@ -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" };
}
}
35 changes: 21 additions & 14 deletions web/src/app/[locale]/(auth)/chat/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -45,21 +47,26 @@ async function Load({ roomId }: { roomId: string }) {
function ChatHeader({ room, me }: { room: ContentfulRoom; me: MYDATA }) {
return (
<>
<div className="invisible h-[56px]" />
<div className="fixed top-[56px] z-10 flex w-full items-center bg-stone-200 py-2">
<Link href={"/chat"} className="mx-2">
<AiOutlineLeft size={25} />
</Link>
<div className="mr-[33px] w-full text-center text-xl">
{room.members
.filter((member) => member.user.id !== me.id)
.map((member) => (
<div key={member.user.id} className=" ml-2 flex items-center gap-2">
<Avatar alt={member.user.name || "User"} src={member.user.imageUrl} size={40} />
<div>{member.user.name}</div>
</div>
))}
<div className={`invisible h-${HEADER_HEIGHT_TW}`} />
<div
className={`fixed top-${HEADER_HEIGHT_TW} z-10 flex w-full items-center justify-between bg-stone-200 px-4 py-2`}
>
<div className="flex items-center">
<Link href={"/chat"} className="mr-2">
<AiOutlineLeft size={25} />
</Link>
<div className="flex items-center text-xl">
{room.members
.filter((member) => member.user.id !== me.id)
.map((member) => (
<div key={member.user.id} className="flex items-center gap-2">
<Avatar alt={member.user.name || "User"} src={member.user.imageUrl} size={40} />
<div>{member.user.name}</div>
</div>
))}
</div>
</div>
<DeleteRoomButton roomId={room.id} />
</div>
</>
);
Expand Down
3 changes: 2 additions & 1 deletion web/src/app/[locale]/(auth)/users/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 4 additions & 2 deletions web/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"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";
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();
Expand All @@ -25,7 +27,7 @@ export default function Header({ user }: { user: MYDATA | null }) {

return (
<>
<header className="fixed top-0 z-10 h-16 w-full bg-tBlue">
<header className={`fixed top-0 z-10 h-${HEADER_HEIGHT_TW} w-full bg-tBlue`}>
<div className="flex h-16 items-center">
<Link href={user ? "/community" : "/"} passHref className="px-4">
<AppIcon width={36} height={36} />
Expand Down
95 changes: 95 additions & 0 deletions web/src/components/chat/DeleteRoomButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative">
<button
type="button"
onClick={(e) => {
e.preventDefault();
setShowConfirm(true);
}}
className="rounded-full p-2 text-red-600 hover:bg-red-100"
title="Delete Room"
disabled={isDeleting}
aria-label="Delete room"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
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"
/>
</svg>
</button>

{showConfirm && (
<div className="absolute right-0 z-50 w-64 rounded-md bg-white p-4 shadow-lg ring-1 ring-black/5">
<p className="mb-4 text-gray-700 text-sm">
Are you sure you want to delete this chat room? This action cannot be undone.
</p>
<div className="flex justify-end space-x-2">
<button
type="button"
onClick={(e) => {
e.preventDefault();
setShowConfirm(false);
}}
className="rounded px-3 py-1 text-gray-600 text-sm hover:bg-gray-100"
disabled={isDeleting}
>
Cancel
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700 disabled:opacity-50"
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</button>
</div>
</div>
)}
</div>
);
}
1 change: 1 addition & 0 deletions web/src/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions web/src/data/room.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export async function getRoomData(roomId: string): Promise<ContentfulRoom> {
room: roomId,
},
});
if (!res.ok) throw new Error("Failed to fetch room data");
const json = await res.json();
return {
...json,
Expand All @@ -30,3 +31,19 @@ export async function getRoomData(roomId: string): Promise<ContentfulRoom> {
})),
};
}

export async function deleteRoom(roomId: string): Promise<void> {
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
}