Add room invites, moderation, and sync acknowledgements #2
@@ -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.
|
||||
|
||||
15
server.js
15
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 },
|
||||
|
||||
@@ -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"
|
||||
}))}
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
<strong>{message.user}</strong>
|
||||
<span>{message.body}</span>
|
||||
</div>
|
||||
{canManageRoom ? (
|
||||
<button className="icon-button compact danger animated-button" type="button" title="Delete message" onClick={() => emit("chat:delete", { messageId: message.id })}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -514,22 +533,65 @@ export function RoomConsole({
|
||||
|
||||
{rail === "Invite" ? (
|
||||
<div className="settings-list" id="room-invite">
|
||||
{savedState ? <p className="form-success">Room member saved.</p> : null}
|
||||
{savedState ? <p className="form-success">Room invite settings saved.</p> : null}
|
||||
{errorState === "user" ? <p className="form-error">No matching user was found, or the user already owns the room.</p> : null}
|
||||
<form className="form compact-form" action={addRoomMember}>
|
||||
<input type="hidden" name="roomId" value={roomId} />
|
||||
<label>
|
||||
Username
|
||||
<input className="input" name="username" placeholder="username" autoComplete="off" required />
|
||||
</label>
|
||||
<label className="inline-check">
|
||||
<input name="canManage" type="checkbox" />
|
||||
Can manage room
|
||||
</label>
|
||||
<button className="button primary animated-button" type="submit">
|
||||
<UserPlus size={16} /> Add member
|
||||
</button>
|
||||
</form>
|
||||
{canManageRoom ? (
|
||||
<>
|
||||
<form className="form compact-form" action={createRoomInvite}>
|
||||
<input type="hidden" name="roomId" value={roomId} />
|
||||
<label>
|
||||
Invite expires after days
|
||||
<input className="input" name="expiresDays" type="number" min="0" max="365" defaultValue="7" />
|
||||
</label>
|
||||
<button className="button primary animated-button" type="submit">
|
||||
<Ticket size={16} /> Create invite code
|
||||
</button>
|
||||
</form>
|
||||
{invites.length > 0 ? (
|
||||
<div className="settings-list">
|
||||
{invites.map((invite) => (
|
||||
<div className="setting-row" key={invite.id}>
|
||||
<Ticket size={16} />
|
||||
<span>
|
||||
<strong>{invite.code}</strong>
|
||||
<small>
|
||||
{invite.status.toLowerCase()} by {invite.creator} - {invite.createdAt}
|
||||
{invite.expiresAt ? ` - expires ${formatDateTime(invite.expiresAt)}` : ""}
|
||||
</small>
|
||||
</span>
|
||||
<StatusBadge tone={invite.status === "ACTIVE" && !isExpired(invite.expiresAt) ? "good" : "warn"}>
|
||||
{isExpired(invite.expiresAt) ? "expired" : invite.status.toLowerCase()}
|
||||
</StatusBadge>
|
||||
{invite.status === "ACTIVE" && !isExpired(invite.expiresAt) ? (
|
||||
<form action={revokeRoomInvite}>
|
||||
<input type="hidden" name="inviteId" value={invite.id} />
|
||||
<button className="icon-button compact danger animated-button" type="submit" title="Revoke invite">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<form className="form compact-form" action={addRoomMember}>
|
||||
<input type="hidden" name="roomId" value={roomId} />
|
||||
<label>
|
||||
Username
|
||||
<input className="input" name="username" placeholder="username" autoComplete="off" required />
|
||||
</label>
|
||||
<label className="inline-check">
|
||||
<input name="canManage" type="checkbox" />
|
||||
Can manage room
|
||||
</label>
|
||||
<button className="button primary animated-button" type="submit">
|
||||
<UserPlus size={16} /> Add member
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<p className="disabled-note">Only room managers can create invite codes or change room members.</p>
|
||||
)}
|
||||
{participants.filter((participant) => participant.role !== "Owner").length > 0 ? (
|
||||
<div className="settings-list">
|
||||
{participants
|
||||
@@ -541,13 +603,15 @@ export function RoomConsole({
|
||||
<strong>{participant.name}</strong>
|
||||
<small>{participant.role}</small>
|
||||
</span>
|
||||
<form action={removeRoomMember}>
|
||||
<input type="hidden" name="roomId" value={roomId} />
|
||||
<input type="hidden" name="userId" value={participant.id} />
|
||||
<button className="icon-button compact danger animated-button" type="submit" title="Remove member">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</form>
|
||||
{canManageRoom ? (
|
||||
<form action={removeRoomMember}>
|
||||
<input type="hidden" name="roomId" value={roomId} />
|
||||
<input type="hidden" name="userId" value={participant.id} />
|
||||
<button className="icon-button compact danger animated-button" type="submit" title="Remove member">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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") || "");
|
||||
|
||||
Reference in New Issue
Block a user