"use server"; import { randomBytes } from "node:crypto"; import { Prisma, RoomVisibility } from "@prisma/client"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { prisma } from "./prisma"; import { requireCurrentUser, userIsAdmin } from "./session"; import { getAppSettings } from "./settings"; function normalizeSlug(value: string) { return value .trim() .toLowerCase() .replace(/[^a-z0-9_-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 48); } export async function createRoom(formData: FormData) { const user = await requireCurrentUser(); const settings = await getAppSettings(); const name = String(formData.get("name") || "").trim(); const visibility = String(formData.get("visibility") || settings.defaultRoomVisibility); const baseSlug = normalizeSlug(name); if (!name || !baseSlug) { redirect("/rooms?error=invalid-room"); } let slug = baseSlug; let suffix = 2; while (await prisma.room.findUnique({ where: { slug }, select: { id: true } })) { slug = `${baseSlug}-${suffix}`; suffix += 1; } const allowedVisibility: RoomVisibility[] = ["PUBLIC", "FRIENDS", "EXPLICIT", "ROLE_RESTRICTED"]; const roomVisibility = allowedVisibility.includes(visibility as RoomVisibility) ? (visibility as RoomVisibility) : "FRIENDS"; const room = await prisma.room.create({ data: { name, slug, ownerId: user.id, visibility: roomVisibility }, select: { slug: true } }); revalidatePath("/rooms"); revalidatePath("/dashboard"); redirect(`/rooms/${encodeURIComponent(room.slug)}`); } export async function deleteRoom(formData: FormData) { const user = await requireCurrentUser(); const roomId = String(formData.get("roomId") || ""); if (!roomId) return; const room = await prisma.room.findUnique({ where: { id: roomId }, include: { members: { where: { userId: user.id }, select: { canManage: true } } } }); if (!room || room.isPersonal || !canManageRoom(user, room)) return; await prisma.$transaction([ prisma.auditEvent.create({ data: { actorId: user.id, roomId: room.id, action: "room.delete", metadata: { slug: room.slug, name: room.name } } }), prisma.room.delete({ where: { id: room.id } }) ]); revalidatePath("/rooms"); revalidatePath("/dashboard"); redirect("/rooms?deleted=1"); } export async function resetPersonalRoom(formData: FormData) { const user = await requireCurrentUser(); const roomId = String(formData.get("roomId") || ""); if (!roomId) return; const room = await prisma.room.findUnique({ where: { id: roomId }, include: { members: { where: { userId: user.id }, select: { canManage: true } } } }); if (!room || !room.isPersonal || !canManageRoom(user, room)) return; await prisma.$transaction([ prisma.mediaSource.deleteMany({ where: { roomId: room.id } }), prisma.roomMessage.deleteMany({ where: { roomId: room.id } }), prisma.room.update({ where: { id: room.id }, data: { currentState: Prisma.JsonNull } }), prisma.auditEvent.create({ data: { actorId: user.id, roomId: room.id, action: "room.reset", metadata: { slug: room.slug } } }) ]); revalidateRoom(room.slug); redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Settings&saved=1`); } export async function updateRoomSettings(formData: FormData) { const user = await requireCurrentUser(); const roomId = String(formData.get("roomId") || ""); const name = String(formData.get("name") || "").trim().slice(0, 80); const visibility = String(formData.get("visibility") || "FRIENDS"); const room = await prisma.room.findUnique({ where: { id: roomId }, include: { members: { where: { userId: user.id }, select: { canManage: true } } } }); if (!room || !canManageRoom(user, room) || !name) return; const allowedVisibility: RoomVisibility[] = ["PUBLIC", "FRIENDS", "EXPLICIT", "ROLE_RESTRICTED"]; const roomVisibility = allowedVisibility.includes(visibility as RoomVisibility) ? (visibility as RoomVisibility) : room.visibility; await prisma.room.update({ where: { id: room.id }, data: { name, visibility: roomVisibility } }); revalidateRoom(room.slug); redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Settings&saved=1`); } export async function addRoomMember(formData: FormData) { const user = await requireCurrentUser(); const roomId = String(formData.get("roomId") || ""); const username = String(formData.get("username") || "").trim().toLowerCase(); const canManage = formData.get("canManage") === "on"; const room = await prisma.room.findUnique({ where: { id: roomId }, include: { members: { where: { userId: user.id }, select: { canManage: true } } } }); if (!room || !canManageRoom(user, room) || !username) return; const invited = await prisma.user.findUnique({ where: { username }, select: { id: true } }); if (!invited || invited.id === room.ownerId) { redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Invite&error=user`); } await prisma.roomMember.upsert({ where: { roomId_userId: { roomId: room.id, userId: invited.id } }, update: { canManage }, create: { roomId: room.id, userId: invited.id, canManage } }); revalidateRoom(room.slug); 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") || ""); const userId = String(formData.get("userId") || ""); if (!roomId || !userId) return; const room = await prisma.room.findUnique({ where: { id: roomId }, include: { members: { where: { userId: user.id }, select: { canManage: true } } } }); if (!room || !canManageRoom(user, room) || userId === room.ownerId) return; await prisma.roomMember.deleteMany({ where: { roomId: room.id, userId } }); await prisma.auditEvent.create({ data: { actorId: user.id, roomId: room.id, action: "room.member.remove", metadata: { userId } } }); revalidateRoom(room.slug); redirect(`/rooms/${encodeURIComponent(room.slug)}?rail=Invite&saved=1`); } function canManageRoom( user: Awaited>, room: { ownerId: string | null; members: Array<{ canManage: boolean }> } ) { return room.ownerId === user.id || userIsAdmin(user) || room.members.some((member) => member.canManage); } function revalidateRoom(slug: string) { revalidatePath(`/rooms/${encodeURIComponent(slug)}`); revalidatePath("/rooms"); revalidatePath("/dashboard"); }