diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 551024a..174cbdc 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -17,4 +17,6 @@ Initial implementation created from `codex-agent-repository-kit` guidance. - Gitea Actions can run `npm install`, tests, build, and Docker image publishing. - Local Docker is still unavailable in this Codex environment, but Gitea Actions can build the image. - Docker publish was fixed by updating `REGISTRY_TOKEN`; Gitea issue #1 is closed. -- UI redesign follow-ups that need real backend work: persisted invite records, editable room settings, queue reorder/remove server actions, chat persistence/moderation, user ban/remove-friend actions, a real audit/event table, and socket acknowledgements for sync health instead of client-local connection assumptions. +- Room-scoped persisted invite codes were added to the room Invite rail. Room owners, admins, and room managers can create expiring invite codes and revoke active room invites. +- Room chat moderation was added through Socket.IO. Room owners, admins, and room managers can delete room chat messages; deletions are audited and rebroadcast to the room. +- Remaining UI redesign follow-ups that need real backend work: user ban/remove-friend admin actions and socket acknowledgements for sync health instead of client-local connection assumptions. diff --git a/server.js b/server.js index 136c3ba..907d41f 100644 --- a/server.js +++ b/server.js @@ -160,6 +160,16 @@ app.prepare().then(() => { await broadcastRoom(io, room.slug, room.id); })); + socket.on("chat:delete", (payload) => safeRoomAction(socket, async ({ room, user }) => { + if (!canManageRoom(room, user.id, user)) return reject(socket, "Only room managers can moderate chat."); + const messageId = String(payload?.messageId || ""); + if (!messageId) return; + const result = await prisma.roomMessage.deleteMany({ where: { id: messageId, roomId: room.id } }); + if (result.count === 0) return; + await audit("room.chat.delete", user.id, room.id, { messageId }); + await broadcastRoom(io, room.slug, room.id); + })); + socket.on("disconnect", () => { const roomSlug = socket.data.roomSlug; const user = socket.data.user; @@ -276,6 +286,11 @@ async function getRoomContext(slug, userId) { return { allowed, room }; } +function canManageRoom(room, userId, user) { + const isAdmin = Boolean(user?.roles?.some((userRole) => userRole.role.name === "admin")); + return room.ownerId === userId || isAdmin || room.members.some((member) => member.userId === userId && member.canManage); +} + async function buildRoomSnapshot(roomId) { const room = await prisma.room.findUnique({ where: { id: roomId }, diff --git a/src/app/rooms/[slug]/page.tsx b/src/app/rooms/[slug]/page.tsx index 429dde9..7a6ef59 100644 --- a/src/app/rooms/[slug]/page.tsx +++ b/src/app/rooms/[slug]/page.tsx @@ -31,6 +31,11 @@ export default async function RoomPage({ include: { owner: true, members: { include: { user: true } }, + invites: { + include: { creator: true }, + orderBy: { createdAt: "desc" }, + take: 12 + }, mediaSources: { include: { submitter: true }, orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }], take: 40 } } }); @@ -41,6 +46,7 @@ export default async function RoomPage({ const isOwner = room.ownerId === user.id; const explicitMember = room.members.some((member) => member.userId === user.id); + const canManageRoom = isOwner || isAdmin || room.members.some((member) => member.userId === user.id && member.canManage); const isFriend = room.ownerId ? Boolean( await prisma.friendship.findFirst({ @@ -97,6 +103,7 @@ export default async function RoomPage({ roomVisibility={room.visibility} isPersonal={room.isPersonal} ownerName={room.owner?.displayName || room.owner?.username || "Unassigned"} + canManageRoom={canManageRoom} initialRail={rail} savedState={saved} errorState={error} @@ -132,8 +139,16 @@ export default async function RoomPage({ avatarUrl: member.user.avatarUrl, role: member.canManage ? "Manager" : "Member", status: member.userId === user.id ? "Online" : "Allowed" - })) + })) ]} + invites={room.invites.map((invite) => ({ + id: invite.id, + code: invite.code, + status: invite.status, + expiresAt: invite.expiresAt?.toISOString() || null, + createdAt: formatDate(invite.createdAt), + creator: invite.creator?.displayName || invite.creator?.username || "Unknown" + }))} /> ); diff --git a/src/components/room-console.tsx b/src/components/room-console.tsx index bbbe7f2..01645e8 100644 --- a/src/components/room-console.tsx +++ b/src/components/room-console.tsx @@ -15,10 +15,11 @@ import { ShieldCheck, SkipForward, Trash2, - UserPlus + UserPlus, + Ticket } from "lucide-react"; import { io, type Socket } from "socket.io-client"; -import { addRoomMember, deleteRoom, removeRoomMember, resetPersonalRoom, updateRoomSettings } from "@/lib/room-actions"; +import { addRoomMember, createRoomInvite, deleteRoom, removeRoomMember, resetPersonalRoom, revokeRoomInvite, updateRoomSettings } from "@/lib/room-actions"; import { Avatar } from "./avatar"; import { StatusBadge } from "./status-badge"; import { EmptyState, Panel, StatusDot } from "./ui"; @@ -116,6 +117,15 @@ type Participant = { status: string; }; +type RoomInvite = { + id: string; + code: string; + status: string; + expiresAt: string | null; + createdAt: string; + creator: string; +}; + export function RoomConsole({ roomId, roomSlug, @@ -123,12 +133,14 @@ export function RoomConsole({ roomVisibility, isPersonal, ownerName, + canManageRoom, initialRail = "Activity", savedState, errorState, currentUser, queue = [], - participants = [] + participants = [], + invites = [] }: { roomId: string; roomSlug: string; @@ -136,12 +148,14 @@ export function RoomConsole({ roomVisibility: string; isPersonal: boolean; ownerName: string; + canManageRoom: boolean; initialRail?: string; savedState?: string; errorState?: string; currentUser: string; queue?: QueueItem[]; participants?: Participant[]; + invites?: RoomInvite[]; }) { const [connected, setConnected] = useState(false); const [source, setSource] = useState(""); @@ -501,6 +515,11 @@ export function RoomConsole({ {message.user} {message.body} + {canManageRoom ? ( + + ) : null} )) )} @@ -514,22 +533,65 @@ export function RoomConsole({ {rail === "Invite" ? (
Room member saved.
: null} + {savedState ?Room invite settings saved.
: null} {errorState === "user" ?No matching user was found, or the user already owns the room.
: null} - + {canManageRoom ? ( + <> + + {invites.length > 0 ? ( +Only room managers can create invite codes or change room members.
+ )} {participants.filter((participant) => participant.role !== "Owner").length > 0 ? (