"use server"; import config from "@payload-config"; import { getPayload } from "payload"; import { emitGameEvent } from "@/utils/event-log/emit"; import { EventTypes } from "@/utils/event-log/eventTypes"; import { broadcastToUser } from "@/lib/realtime/bus"; import { notifyUser } from "@/lib/notifications"; import hasRoles from "@/utils/access-control/hasRoles"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import type { VoiceScript, VoiceSubmission } from "@/payload-types"; interface ActionResult { success: boolean; error?: string; data?: T; } /** Server actions body limit is 10mb — enforce a 9 MB cap server-side with a clear error. */ const MAX_AUDIO_BYTES = 9 * 1024 * 1024; async function authenticate() { const payloadConfig = await config; const payload = await getPayload({ config: payloadConfig }); const { headers } = await import("next/headers"); const hdrs = await headers(); const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false, }); if (!isPayloadUser(user)) { throw new Error("Unauthorized"); } return { payload, user }; } async function fetchScript( payload: Awaited>, id: number, ): Promise { try { return await payload.findByID({ collection: "voice-scripts", id, depth: 0, overrideAccess: true, }); } catch { return null; } } async function fetchSubmission( payload: Awaited>, id: number, ): Promise { try { return await payload.findByID({ collection: "voice-submissions", id, depth: 0, overrideAccess: true, }); } catch { return null; } } export async function submitVoiceRecording( scriptId: number, formData: FormData, ): Promise> { try { const { payload, user } = await authenticate(); const script = await fetchScript(payload, scriptId); if (!script) { return { success: false, error: "Voiceover script not found." }; } if (script.status !== "open") { return { success: false, error: "This script is closed to new submissions." }; } const file = formData.get("file"); if (!(file instanceof File)) { return { success: false, error: "No audio file provided." }; } if (!file.type.startsWith("audio/")) { return { success: false, error: "Only audio files can be submitted." }; } if (file.size > MAX_AUDIO_BYTES) { return { success: false, error: "Audio recordings must be 9 MB or smaller." }; } const submission = await payload.create({ collection: "voice-submissions", data: { script: scriptId, user: user.id, status: "pending", }, file: { data: Buffer.from(await file.arrayBuffer()), mimetype: file.type, name: file.name, size: file.size, }, overrideAccess: true, depth: 0, }); await emitGameEvent(payload, { type: EventTypes.VoiceoverSubmit, message: `${user.displayName ?? user.username} submitted a recording for "${script.title}"`, actor: user.id, targetCollection: "voice-submissions", targetId: submission.id, data: { scriptId, scriptTitle: script.title }, }); return { success: true, data: { id: submission.id } }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; if (message === "Unauthorized") { return { success: false, error: "You must be logged in." }; } return { success: false, error: message }; } } export async function withdrawSubmission( submissionId: number, ): Promise> { try { const { payload, user } = await authenticate(); const submission = await fetchSubmission(payload, submissionId); if (!submission) { return { success: false, error: "Submission not found." }; } if (submission.user !== user.id) { return { success: false, error: "You can only withdraw your own submissions." }; } if (submission.status !== "pending") { return { success: false, error: "Only pending submissions can be withdrawn." }; } await payload.delete({ collection: "voice-submissions", id: submissionId, overrideAccess: true, }); return { success: true, data: { ok: true } }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; if (message === "Unauthorized") { return { success: false, error: "You must be logged in." }; } return { success: false, error: message }; } } export async function reviewVoiceSubmission( submissionId: number, decision: "accepted" | "rejected", reviewNote?: string, ): Promise> { try { const { payload, user } = await authenticate(); if (!hasRoles(["admin", "developer"], user)) { return { success: false, error: "Only admins can review voiceover submissions.", }; } const submission = await fetchSubmission(payload, submissionId); if (!submission) { return { success: false, error: "Submission not found." }; } if (submission.status !== "pending") { return { success: false, error: "This submission has already been reviewed." }; } const script = await fetchScript( payload, typeof submission.script === "number" ? submission.script : submission.script.id, ); if (!script) { return { success: false, error: "The script for this submission no longer exists." }; } const submitterId = typeof submission.user === "number" ? submission.user : submission.user.id; // Resolve the profile before mutating anything so a missing profile fails // the review cleanly instead of leaving the submission accepted without XP. let profileId: number | undefined; let progression: { xp?: unknown; voiceover?: { xpEarned?: number }; } = {}; if (decision === "accepted") { const profileRes = await payload.find({ collection: "profiles", where: { user: { equals: submitterId } }, limit: 1, depth: 0, overrideAccess: true, }); const profile = profileRes.docs[0]; if (!profile) { return { success: false, error: "No profile found for the submitter." }; } profileId = profile.id; progression = (profile.progression ?? {}) as { xp?: unknown; voiceover?: { xpEarned?: number }; }; } await payload.update({ collection: "voice-submissions", id: submissionId, data: { status: decision, reviewNote: reviewNote ?? null, }, overrideAccess: true, depth: 0, }); if (decision === "accepted" && profileId !== undefined) { const voiceover = progression.voiceover ?? {}; const xpEarned = (voiceover.xpEarned ?? 0) + script.xpAmount; // Strip the virtual `xp` field before writing (computed, not stored). const { xp: _xp, ...progressionRest } = progression; await payload.update({ collection: "profiles", id: profileId, data: { progression: { ...progressionRest, voiceover: { ...voiceover, xpEarned, }, }, }, overrideAccess: true, depth: 0, }); broadcastToUser("profile-update", submitterId, { reason: "xp", xpGained: script.xpAmount, profileId, }); await notifyUser(payload, { userId: submitterId, type: "voiceover:accepted", title: "Voiceover accepted", message: `Your recording for "${script.title}" was accepted (+${script.xpAmount} XP).`, link: "/voiceover", }); } else { await notifyUser(payload, { userId: submitterId, type: "voiceover:rejected", title: "Voiceover rejected", message: `Your recording for "${script.title}" was not accepted this time.`, link: "/voiceover", }); } await emitGameEvent(payload, { type: decision === "accepted" ? EventTypes.VoiceoverAccepted : EventTypes.VoiceoverRejected, message: `${user.displayName ?? user.username} ${decision} a voiceover submission for "${script.title}"`, actor: user.id, targetCollection: "voice-submissions", targetId: submissionId, data: { scriptId: script.id, scriptTitle: script.title, submitterId, decision, reviewNote: reviewNote ?? null, xpAwarded: decision === "accepted" ? script.xpAmount : 0, }, }); return { success: true, data: { id: submissionId, status: decision } }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; if (message === "Unauthorized") { return { success: false, error: "You must be logged in." }; } return { success: false, error: message }; } }