From 699232f5c682d907fe6056d2238975e2b46b104d Mon Sep 17 00:00:00 2001
From: ToxicCrzay270 <185776014+ToxicCrzay270@users.noreply.github.com>
Date: Thu, 11 Jun 2026 14:39:08 +0200
Subject: [PATCH 1/2] Add room invites and chat moderation
---
docs/agent-handoff.md | 4 +-
server.js | 15 ++++
src/app/rooms/[slug]/page.tsx | 17 ++++-
src/components/room-console.tsx | 122 +++++++++++++++++++++++++-------
src/lib/room-actions.ts | 69 ++++++++++++++++++
5 files changed, 200 insertions(+), 27 deletions(-)
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" ? (
- {savedState ?
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 ? (
+
+ {invites.map((invite) => (
+
+
+
+ {invite.code}
+
+ {invite.status.toLowerCase()} by {invite.creator} - {invite.createdAt}
+ {invite.expiresAt ? ` - expires ${formatDateTime(invite.expiresAt)}` : ""}
+
+
+
+ {isExpired(invite.expiresAt) ? "expired" : invite.status.toLowerCase()}
+
+ {invite.status === "ACTIVE" && !isExpired(invite.expiresAt) ? (
+
+ ) : null}
+
+ ))}
+
+ ) : null}
+
+ >
+ ) : (
+
Only room managers can create invite codes or change room members.
+ )}
{participants.filter((participant) => participant.role !== "Owner").length > 0 ? (
{participants
@@ -541,13 +603,15 @@ export function RoomConsole({
{participant.name}
{participant.role}
-
+ {canManageRoom ? (
+
+ ) : null}
))}
@@ -866,3 +930,11 @@ function SettingRow({ icon, label, value }: { icon: React.ReactNode; label: stri
function formatVisibility(value: string) {
return value.toLowerCase().replace("_", " ");
}
+
+function formatDateTime(value: string) {
+ return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", year: "numeric" }).format(new Date(value));
+}
+
+function isExpired(value: string | null) {
+ return Boolean(value && new Date(value).getTime() < Date.now());
+}
diff --git a/src/lib/room-actions.ts b/src/lib/room-actions.ts
index e6b6ab8..bb8ce0f 100644
--- a/src/lib/room-actions.ts
+++ b/src/lib/room-actions.ts
@@ -1,5 +1,6 @@
"use server";
+import { randomBytes } from "node:crypto";
import { Prisma, RoomVisibility } from "@prisma/client";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
@@ -171,6 +172,74 @@ export async function addRoomMember(formData: FormData) {
redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Invite&saved=1`);
}
+export async function createRoomInvite(formData: FormData) {
+ const user = await requireCurrentUser();
+ const roomId = String(formData.get("roomId") || "");
+ const expiresDays = Number(formData.get("expiresDays") || 0);
+ if (!roomId) return;
+
+ const room = await prisma.room.findUnique({
+ where: { id: roomId },
+ include: { members: { where: { userId: user.id }, select: { canManage: true } } }
+ });
+
+ if (!room || !canManageRoom(user, room)) return;
+
+ const expiresAt = Number.isFinite(expiresDays) && expiresDays > 0 ? new Date(Date.now() + expiresDays * 24 * 60 * 60 * 1000) : null;
+ const invite = await prisma.invite.create({
+ data: {
+ code: randomBytes(12).toString("base64url"),
+ creatorId: user.id,
+ roomId: room.id,
+ expiresAt
+ }
+ });
+
+ await prisma.auditEvent.create({
+ data: {
+ actorId: user.id,
+ roomId: room.id,
+ action: "room.invite.create",
+ metadata: { inviteId: invite.id, expiresAt: expiresAt?.toISOString() || null }
+ }
+ });
+
+ revalidateRoom(room.slug);
+ redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Invite&saved=1`);
+}
+
+export async function revokeRoomInvite(formData: FormData) {
+ const user = await requireCurrentUser();
+ const inviteId = String(formData.get("inviteId") || "");
+ if (!inviteId) return;
+
+ const invite = await prisma.invite.findUnique({
+ where: { id: inviteId },
+ include: {
+ room: {
+ include: { members: { where: { userId: user.id }, select: { canManage: true } } }
+ }
+ }
+ });
+
+ if (!invite?.room || !canManageRoom(user, invite.room)) return;
+
+ await prisma.$transaction([
+ prisma.invite.update({ where: { id: invite.id }, data: { status: "REVOKED" } }),
+ prisma.auditEvent.create({
+ data: {
+ actorId: user.id,
+ roomId: invite.room.id,
+ action: "room.invite.revoke",
+ metadata: { inviteId: invite.id }
+ }
+ })
+ ]);
+
+ revalidateRoom(invite.room.slug);
+ redirect(`/rooms/${encodeURIComponent(invite.room.slug)}?rail=Invite&saved=1`);
+}
+
export async function removeRoomMember(formData: FormData) {
const user = await requireCurrentUser();
const roomId = String(formData.get("roomId") || "");
--
2.49.1
From abee76c9b13964ee47a1df57fb8acaf8ef466363 Mon Sep 17 00:00:00 2001
From: ToxicCrzay270 <185776014+ToxicCrzay270@users.noreply.github.com>
Date: Thu, 11 Jun 2026 21:00:57 +0200
Subject: [PATCH 2/2] Add admin moderation and sync acknowledgements
---
docs/agent-handoff.md | 4 ++-
server.js | 54 ++++++++++++++++++-------------
src/app/admin/page.tsx | 23 +++++++++++--
src/components/room-console.tsx | 57 ++++++++++++++++++++++++++++++---
src/lib/admin-actions.ts | 38 ++++++++++++++++++++++
5 files changed, 145 insertions(+), 31 deletions(-)
diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md
index 174cbdc..1f3064b 100644
--- a/docs/agent-handoff.md
+++ b/docs/agent-handoff.md
@@ -19,4 +19,6 @@ Initial implementation created from `codex-agent-repository-kit` guidance.
- Docker publish was fixed by updating `REGISTRY_TOKEN`; Gitea issue #1 is closed.
- 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.
+- Admin user ban and remove-friendship actions were added. Admins can disable an account while clearing its friendships and explicit room memberships, or remove all friendships for a user without disabling the account.
+- Socket acknowledgements were added for room join and realtime room actions. The room header now shows pending, confirmed, or failed sync acknowledgement state from the server.
+- Remaining UI redesign follow-ups that need real backend work: none currently documented in this handoff.
diff --git a/server.js b/server.js
index 907d41f..5039175 100644
--- a/server.js
+++ b/server.js
@@ -25,12 +25,12 @@ app.prepare().then(() => {
});
io.on("connection", (socket) => {
- socket.on("room:join", async ({ roomSlug } = {}) => {
- await safeSocket(socket, async () => {
+ socket.on("room:join", async ({ roomSlug } = {}, acknowledge) => {
+ await safeSocket(socket, acknowledge, async () => {
const session = await getSocketSession(socket);
- if (!session || !roomSlug) return reject(socket, "Sign in to join this room.");
+ if (!session || !roomSlug) return reject(socket, "Sign in to join this room.", acknowledge);
const context = await getRoomContext(roomSlug, session.user.id);
- if (!context.allowed) return reject(socket, "You do not have access to this room.");
+ if (!context.allowed) return reject(socket, "You do not have access to this room.", acknowledge);
socket.data.user = session.user;
socket.data.roomSlug = context.room.slug;
@@ -40,15 +40,16 @@ app.prepare().then(() => {
socket.emit("room:state", await buildRoomSnapshot(context.room.id));
io.to(context.room.slug).emit("presence:list", getPresence(context.room.slug));
+ ok(acknowledge);
});
});
- socket.on("queue:add", (payload) => safeRoomAction(socket, async ({ room, user }) => {
+ socket.on("queue:add", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => {
const sourceUrl = String(payload?.sourceUrl || "").trim();
if (!sourceUrl) return;
const settings = await getAppSettings();
const media = normalizeMediaUrl(sourceUrl);
- if (!settings.allowedProviders.includes(media.provider)) return reject(socket, "This media provider is disabled.");
+ if (!settings.allowedProviders.includes(media.provider)) return reject(socket, "This media provider is disabled.", acknowledge);
const nextPosition = await prisma.mediaSource.count({ where: { roomId: room.id } });
const created = await prisma.mediaSource.create({
@@ -77,7 +78,7 @@ app.prepare().then(() => {
await broadcastRoom(io, room.slug, room.id);
}));
- socket.on("queue:play", (payload) => safeRoomAction(socket, async ({ room, user }) => {
+ socket.on("queue:play", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => {
const mediaSourceId = String(payload?.mediaSourceId || "");
const media = await prisma.mediaSource.findFirst({ where: { id: mediaSourceId, roomId: room.id } });
if (!media) return;
@@ -86,7 +87,7 @@ app.prepare().then(() => {
await broadcastRoom(io, room.slug, room.id);
}));
- socket.on("queue:remove", (payload) => safeRoomAction(socket, async ({ room, user }) => {
+ socket.on("queue:remove", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => {
const mediaSourceId = String(payload?.mediaSourceId || "");
const media = await prisma.mediaSource.findFirst({ where: { id: mediaSourceId, roomId: room.id } });
if (!media) return;
@@ -113,7 +114,7 @@ app.prepare().then(() => {
await broadcastRoom(io, room.slug, room.id);
}));
- socket.on("queue:move", (payload) => safeRoomAction(socket, async ({ room, user }) => {
+ socket.on("queue:move", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => {
const mediaSourceId = String(payload?.mediaSourceId || "");
const direction = payload?.direction === "down" ? 1 : -1;
await moveMedia(room.id, mediaSourceId, direction);
@@ -121,7 +122,7 @@ app.prepare().then(() => {
await broadcastRoom(io, room.slug, room.id);
}));
- socket.on("playback:play", (payload) => safeRoomAction(socket, async ({ room, user }) => {
+ socket.on("playback:play", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => {
await persistPlaybackState(room.id, user, {
mediaSourceId: String(payload?.mediaSourceId || parseState(room.currentState)?.mediaSourceId || ""),
status: "PLAYING",
@@ -131,7 +132,7 @@ app.prepare().then(() => {
await broadcastRoom(io, room.slug, room.id);
}));
- socket.on("playback:pause", (payload) => safeRoomAction(socket, async ({ room, user }) => {
+ socket.on("playback:pause", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => {
await persistPlaybackState(room.id, user, {
mediaSourceId: String(payload?.mediaSourceId || parseState(room.currentState)?.mediaSourceId || ""),
status: "PAUSED",
@@ -141,7 +142,7 @@ app.prepare().then(() => {
await broadcastRoom(io, room.slug, room.id);
}));
- socket.on("playback:seek", (payload) => safeRoomAction(socket, async ({ room, user }) => {
+ socket.on("playback:seek", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => {
const previous = parseState(room.currentState);
await persistPlaybackState(room.id, user, {
mediaSourceId: String(payload?.mediaSourceId || previous?.mediaSourceId || ""),
@@ -152,7 +153,7 @@ app.prepare().then(() => {
await broadcastRoom(io, room.slug, room.id);
}));
- socket.on("chat:message", (payload) => safeRoomAction(socket, async ({ room, user }) => {
+ socket.on("chat:message", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => {
const body = String(payload?.body || "").trim().slice(0, 1000);
if (!body) return;
await prisma.roomMessage.create({ data: { roomId: room.id, userId: user.id, body } });
@@ -160,8 +161,8 @@ 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.");
+ socket.on("chat:delete", (payload, acknowledge) => safeRoomAction(socket, acknowledge, async ({ room, user }) => {
+ if (!canManageRoom(room, user.id, user)) return reject(socket, "Only room managers can moderate chat.", acknowledge);
const messageId = String(payload?.messageId || "");
if (!messageId) return;
const result = await prisma.roomMessage.deleteMany({ where: { id: messageId, roomId: room.id } });
@@ -185,28 +186,35 @@ app.prepare().then(() => {
});
});
-async function safeSocket(socket, action) {
+async function safeSocket(socket, acknowledge, action) {
try {
await action();
} catch (error) {
console.error(error);
- reject(socket, "Realtime action failed.");
+ reject(socket, "Realtime action failed.", acknowledge);
}
}
-async function safeRoomAction(socket, action) {
- await safeSocket(socket, async () => {
+async function safeRoomAction(socket, acknowledge, action) {
+ await safeSocket(socket, acknowledge, async () => {
const user = socket.data.user || (await getSocketSession(socket))?.user;
const roomSlug = socket.data.roomSlug;
- if (!user || !roomSlug) return reject(socket, "Join a room before sending actions.");
+ if (!user || !roomSlug) return reject(socket, "Join a room before sending actions.", acknowledge);
const context = await getRoomContext(roomSlug, user.id);
- if (!context.allowed) return reject(socket, "You do not have access to this room.");
- await action({ room: context.room, user });
+ if (!context.allowed) return reject(socket, "You do not have access to this room.", acknowledge);
+ const result = await action({ room: context.room, user });
+ if (result !== false) ok(acknowledge);
});
}
-function reject(socket, message) {
+function ok(callback) {
+ if (typeof callback === "function") callback({ ok: true, at: Date.now() });
+}
+
+function reject(socket, message, callback) {
socket.emit("room:error", { message });
+ if (typeof callback === "function") callback({ ok: false, message, at: Date.now() });
+ return false;
}
async function broadcastRoom(io, roomSlug, roomId) {
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 1aa0954..1aefcd9 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -12,7 +12,7 @@ import { requireInitialSetup } from "@/lib/setup";
import { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui";
import { getAppSettings, type AppSettings } from "@/lib/settings";
import { updateInstanceSettings, updateSecuritySettings } from "@/lib/settings-actions";
-import { createInstanceInvite, disableUser, enableUser, grantAdminRole, revokeAdminRole, revokeInvite } from "@/lib/admin-actions";
+import { banUser, createInstanceInvite, disableUser, enableUser, grantAdminRole, removeUserFriendships, revokeAdminRole, revokeInvite } from "@/lib/admin-actions";
import { deleteRoom } from "@/lib/room-actions";
export const dynamic = "force-dynamic";
@@ -34,7 +34,10 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
const [users, rooms, roles, pendingRequests, appSettings, invites, auditEvents] = await Promise.all([
prisma.user.findMany({
- include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } },
+ include: {
+ roles: { include: { role: true } },
+ _count: { select: { ownedRooms: true, roomMembers: true, sentFriends: true, gotFriends: true } }
+ },
orderBy: { createdAt: "asc" }
}),
prisma.room.findMany({
@@ -129,7 +132,7 @@ function UsersTable({
disabledAt: Date | null;
createdAt: Date;
roles: Array<{ roleId: string; role: { name: string } }>;
- _count: { ownedRooms: number; roomMembers: number };
+ _count: { ownedRooms: number; roomMembers: number; sentFriends: number; gotFriends: number };
}>;
currentUserId: string;
}) {
@@ -141,6 +144,7 @@ function UsersTable({
User |
Roles |
Rooms |
+ Friends |
Created |
Actions |
@@ -168,6 +172,7 @@ function UsersTable({
{account._count.ownedRooms + account._count.roomMembers} |
+ {account._count.sentFriends + account._count.gotFriends} |
{account.disabledAt ? disabled : formatDate(account.createdAt)} |
@@ -193,6 +198,18 @@ function UsersTable({
)}
+
+
|
diff --git a/src/components/room-console.tsx b/src/components/room-console.tsx
index 01645e8..1c46e0d 100644
--- a/src/components/room-console.tsx
+++ b/src/components/room-console.tsx
@@ -109,6 +109,12 @@ type RoomSnapshot = {
messages: RoomMessage[];
};
+type RoomAck = {
+ ok: boolean;
+ at?: number;
+ message?: string;
+};
+
type Participant = {
id: string;
name: string;
@@ -165,6 +171,8 @@ export function RoomConsole({
const [presence, setPresence] = useState(participants);
const [playback, setPlayback] = useState(queue[0] ? initialPlayback(queue[0]) : null);
const [socketError, setSocketError] = useState("");
+ const [syncHealth, setSyncHealth] = useState<"idle" | "pending" | "confirmed" | "failed">("idle");
+ const [lastAckAt, setLastAckAt] = useState(null);
const [syncBlocked, setSyncBlocked] = useState(false);
const [playerReady, setPlayerReady] = useState(false);
const [providerIssue, setProviderIssue] = useState("");
@@ -182,7 +190,16 @@ export function RoomConsole({
const emit = useCallback((event: string, payload: Record = {}) => {
setSocketError("");
- socketRef.current?.emit(event, payload);
+ setSyncHealth("pending");
+ socketRef.current?.timeout(5000).emit(event, payload, (error: Error | null, response?: RoomAck) => {
+ if (error || !response?.ok) {
+ setSyncHealth("failed");
+ setSocketError(response?.message || "Realtime action was not acknowledged.");
+ return;
+ }
+ setLastAckAt(response.at || Date.now());
+ setSyncHealth("confirmed");
+ });
}, []);
const currentPosition = useCallback(() => {
@@ -238,10 +255,25 @@ export function RoomConsole({
socketRef.current = socket;
socket.on("connect", () => {
setConnected(true);
- socket.emit("room:join", { roomSlug });
+ setSyncHealth("pending");
+ socket.timeout(5000).emit("room:join", { roomSlug }, (error: Error | null, response?: RoomAck) => {
+ if (error || !response?.ok) {
+ setSyncHealth("failed");
+ setSocketError(response?.message || "Realtime join was not acknowledged.");
+ return;
+ }
+ setLastAckAt(response.at || Date.now());
+ setSyncHealth("confirmed");
+ });
+ });
+ socket.on("disconnect", () => {
+ setConnected(false);
+ setSyncHealth("idle");
+ });
+ socket.on("room:error", ({ message }: { message: string }) => {
+ setSocketError(message);
+ setSyncHealth("failed");
});
- socket.on("disconnect", () => setConnected(false));
- socket.on("room:error", ({ message }: { message: string }) => setSocketError(message));
socket.on("presence:list", (rows: Participant[]) => setPresence(rows));
socket.on("room:state", (snapshot: RoomSnapshot | null) => {
if (!snapshot) return;
@@ -317,6 +349,7 @@ export function RoomConsole({
actions={
+
{formatVisibility(roomVisibility)}
}
@@ -931,6 +964,22 @@ function formatVisibility(value: string) {
return value.toLowerCase().replace("_", " ");
}
+function syncStatusTone(value: "idle" | "pending" | "confirmed" | "failed") {
+ if (value === "confirmed") return "good";
+ if (value === "pending") return "info";
+ if (value === "failed") return "danger";
+ return "neutral";
+}
+
+function syncStatusLabel(value: "idle" | "pending" | "confirmed" | "failed", lastAckAt: number | null) {
+ if (value === "confirmed" && lastAckAt) {
+ return `sync ack ${new Intl.DateTimeFormat("en", { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(new Date(lastAckAt))}`;
+ }
+ if (value === "pending") return "sync pending";
+ if (value === "failed") return "sync unconfirmed";
+ return "sync idle";
+}
+
function formatDateTime(value: string) {
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric", year: "numeric" }).format(new Date(value));
}
diff --git a/src/lib/admin-actions.ts b/src/lib/admin-actions.ts
index 339df4c..95fd806 100644
--- a/src/lib/admin-actions.ts
+++ b/src/lib/admin-actions.ts
@@ -31,6 +31,44 @@ export async function enableUser(formData: FormData) {
revalidateAdmin();
}
+export async function banUser(formData: FormData) {
+ const admin = await requireAdmin();
+ const userId = String(formData.get("userId") || "");
+ if (!userId || userId === admin.id) return;
+
+ const [friendships, memberships] = await prisma.$transaction([
+ prisma.friendship.deleteMany({
+ where: {
+ OR: [{ requesterId: userId }, { receiverId: userId }]
+ }
+ }),
+ prisma.roomMember.deleteMany({ where: { userId } }),
+ prisma.user.update({ where: { id: userId }, data: { disabledAt: new Date() } })
+ ]);
+
+ await audit(admin.id, "admin.user.ban", {
+ userId,
+ removedFriendships: friendships.count,
+ removedRoomMemberships: memberships.count
+ });
+ revalidateAdmin();
+}
+
+export async function removeUserFriendships(formData: FormData) {
+ const admin = await requireAdmin();
+ const userId = String(formData.get("userId") || "");
+ if (!userId || userId === admin.id) return;
+
+ const result = await prisma.friendship.deleteMany({
+ where: {
+ OR: [{ requesterId: userId }, { receiverId: userId }]
+ }
+ });
+
+ await audit(admin.id, "admin.user.friendships.remove", { userId, removedFriendships: result.count });
+ revalidateAdmin();
+}
+
export async function grantAdminRole(formData: FormData) {
const admin = await requireAdmin();
const userId = String(formData.get("userId") || "");
--
2.49.1