1
0
Fork 0

fix(voiceover): award xp and notify on admin-panel review approvals via hooks

This commit is contained in:
Jason Fraley 2026-09-09 18:54:58 -04:00
parent 1b552b3a6d
commit dcaa8c9bbe
2 changed files with 185 additions and 91 deletions

View file

@ -4,8 +4,6 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; 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 hasRoles from "@/utils/access-control/hasRoles";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { transcodeToMp3 } from "@/lib/voiceover/transcode"; 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." }; 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({ await payload.update({
collection: "voice-submissions", collection: "voice-submissions",
id: submissionId, id: submissionId,
@ -284,65 +254,6 @@ export async function reviewVoiceSubmission(
depth: 0, 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 } }; return { success: true, data: { id: submissionId, status: decision } };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";

View file

@ -1,7 +1,186 @@
import { CollectionConfig } from "payload"; import {
import type { User } from "@/payload-types"; CollectionConfig,
type CollectionAfterChangeHook,
type CollectionBeforeChangeHook,
} from "payload";
import type { User, VoiceScript, VoiceSubmission } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import hasRoles from "@/utils/access-control/hasRoles";
import { isSuperuser } from "@/utils/access-control/hasPermission"; 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<ReturnType<typeof import("payload").getPayload>>;
async function fetchScript(payload: PayloadType, id: number): Promise<VoiceScript | null> {
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<User, "displayName" | "username"> | 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 = { export const VoiceSubmissions: CollectionConfig = {
slug: "voice-submissions", slug: "voice-submissions",
@ -96,4 +275,8 @@ export const VoiceSubmissions: CollectionConfig = {
// scripts, …) stay blocked by Payload's built-in checks. // scripts, …) stay blocked by Payload's built-in checks.
upload: true, upload: true,
timestamps: true, timestamps: true,
hooks: {
beforeChange: [guardReviewTransition],
afterChange: [applyReviewSideEffects],
},
}; };