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 { 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";
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue