Add room invites and chat moderation
All checks were successful
Template Compliance / compliance (push) Successful in 7s

This commit is contained in:
ToxicCrzay270
2026-06-11 14:39:08 +02:00
parent 1b68be8802
commit 699232f5c6
5 changed files with 200 additions and 27 deletions

View File

@@ -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.

View File

@@ -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 },

View File

@@ -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>
);

View File

@@ -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());
}

View File

@@ -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") || "");