diff --git a/src/app/(frontend)/voiceover/actions.ts b/src/app/(frontend)/voiceover/actions.ts index e5675d1..f439209 100644 --- a/src/app/(frontend)/voiceover/actions.ts +++ b/src/app/(frontend)/voiceover/actions.ts @@ -4,8 +4,6 @@ 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 { transcodeToMp3 } from "@/lib/voiceover/transcode"; @@ -245,34 +243,6 @@ export async function reviewVoiceSubmission( 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, @@ -284,65 +254,6 @@ export async function reviewVoiceSubmission( 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"; diff --git a/src/collections/voiceover/VoiceSubmissions.ts b/src/collections/voiceover/VoiceSubmissions.ts index 3234dec..f2baf01 100644 --- a/src/collections/voiceover/VoiceSubmissions.ts +++ b/src/collections/voiceover/VoiceSubmissions.ts @@ -1,7 +1,186 @@ -import { CollectionConfig } from "payload"; -import type { User } from "@/payload-types"; +import { + CollectionConfig, + type CollectionAfterChangeHook, + type CollectionBeforeChangeHook, +} from "payload"; +import type { User, VoiceScript, VoiceSubmission } from "@/payload-types"; import hasRoles from "@/utils/access-control/hasRoles"; import { isSuperuser } from "@/utils/access-control/hasPermission"; +import { broadcastToUser } from "@/lib/realtime/bus"; +import { notifyUser } from "@/lib/notifications"; +import { emitGameEvent } from "@/utils/event-log/emit"; +import { EventTypes } from "@/utils/event-log/eventTypes"; + +type PayloadType = Awaited>; + +async function fetchScript(payload: PayloadType, id: number): Promise { + try { + return await payload.findByID({ + collection: "voice-scripts", + id, + depth: 0, + overrideAccess: true, + }); + } catch { + return null; + } +} + +const scriptIdOf = (script: VoiceSubmission["script"]): number => + typeof script === "object" && script !== null ? script.id : script; + +const submitterIdOf = (user: VoiceSubmission["user"]): number => + typeof user === "object" && user !== null ? user.id : user; + +const reviewerName = (reqUser: unknown): string => { + const user = reqUser as Pick | null | undefined; + return user?.displayName ?? user?.username ?? "A reviewer"; +}; + +/** + * XP and notifications for review decisions live in hooks so both the + * frontend action and direct admin-panel edits take the same path. XP is + * awarded only on a pending-to-accepted transition, so ordinary re-saves + * never double-award. + */ +const guardReviewTransition: CollectionBeforeChangeHook = async ({ + operation, + data, + originalDoc, + req, +}) => { + if (operation !== "update") return data; + const nextStatus = data?.status; + if (!nextStatus || nextStatus === originalDoc?.status) return data; + if (nextStatus !== "accepted" && nextStatus !== "rejected") return data; + + const script = await fetchScript(req.payload, scriptIdOf(originalDoc.script)); + if (!script) { + throw new Error("The script for this submission no longer exists."); + } + + if (nextStatus === "accepted") { + const profileRes = await req.payload.find({ + collection: "profiles", + where: { user: { equals: submitterIdOf(originalDoc.user) } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + if (!profileRes.docs[0]) { + throw new Error("No profile found for the submitter."); + } + } + + return data; +}; + +const applyReviewSideEffects: CollectionAfterChangeHook = async ({ + operation, + doc, + previousDoc, + req, +}) => { + if (operation !== "update") return doc; + if (previousDoc?.status !== "pending") return doc; + if (doc.status !== "accepted" && doc.status !== "rejected") return doc; + + const submitterId = submitterIdOf(doc.user); + const script = await fetchScript(req.payload, scriptIdOf(doc.script)); + + if (doc.status === "accepted") { + const profileRes = await req.payload.find({ + collection: "profiles", + where: { user: { equals: submitterId } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + const profile = profileRes.docs[0]; + if (!profile) { + req.payload.logger.error( + `Voiceover submission ${doc.id} accepted but submitter ${submitterId} has no profile; XP not awarded.`, + ); + return doc; + } + + const progression = (profile.progression ?? {}) as { + xp?: unknown; + voiceover?: { xpEarned?: number }; + }; + const voiceover = progression.voiceover ?? {}; + const xpGained = script?.xpAmount ?? 0; + const { xp: _xp, ...progressionRest } = progression; + + await req.payload.update({ + collection: "profiles", + id: profile.id, + data: { + progression: { + ...progressionRest, + voiceover: { + ...voiceover, + xpEarned: (voiceover.xpEarned ?? 0) + xpGained, + }, + }, + }, + overrideAccess: true, + depth: 0, + }); + broadcastToUser("profile-update", submitterId, { + reason: "xp", + xpGained, + profileId: profile.id, + }); + await notifyUser(req.payload, { + userId: submitterId, + type: "voiceover:accepted", + title: "Voiceover accepted", + message: `Your recording for "${script?.title ?? "a script"}" was accepted (+${xpGained} XP).`, + link: "/voiceover", + }); + await emitGameEvent(req.payload, { + type: EventTypes.VoiceoverAccepted, + message: `${reviewerName(req.user)} accepted a voiceover submission for "${script?.title ?? "a script"}"`, + actor: typeof req.user === "object" && req.user !== null ? req.user.id : undefined, + targetCollection: "voice-submissions", + targetId: doc.id, + data: { + scriptId: scriptIdOf(doc.script), + scriptTitle: script?.title ?? null, + submitterId, + decision: "accepted", + reviewNote: doc.reviewNote ?? null, + xpAwarded: xpGained, + }, + }); + return doc; + } + + await notifyUser(req.payload, { + userId: submitterId, + type: "voiceover:rejected", + title: "Voiceover rejected", + message: `Your recording for "${script?.title ?? "a script"}" was not accepted this time.`, + link: "/voiceover", + }); + await emitGameEvent(req.payload, { + type: EventTypes.VoiceoverRejected, + message: `${reviewerName(req.user)} rejected a voiceover submission for "${script?.title ?? "a script"}"`, + actor: typeof req.user === "object" && req.user !== null ? req.user.id : undefined, + targetCollection: "voice-submissions", + targetId: doc.id, + data: { + scriptId: scriptIdOf(doc.script), + scriptTitle: script?.title ?? null, + submitterId, + decision: "rejected", + reviewNote: doc.reviewNote ?? null, + xpAwarded: 0, + }, + }); + return doc; +}; export const VoiceSubmissions: CollectionConfig = { slug: "voice-submissions", @@ -96,4 +275,8 @@ export const VoiceSubmissions: CollectionConfig = { // scripts, …) stay blocked by Payload's built-in checks. upload: true, timestamps: true, + hooks: { + beforeChange: [guardReviewTransition], + afterChange: [applyReviewSideEffects], + }, };