fix(voiceover): award xp and notify on admin-panel review approvals via hooks
This commit is contained in:
parent
1b552b3a6d
commit
dcaa8c9bbe
2 changed files with 185 additions and 91 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<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 = {
|
||||
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],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue