Add /voiceover script list and take detail pages, teleprompter reader, voice capture/recorder components, submission server actions with a 9 MB audio cap, and a Contribute > Voiceover entry in the user menu. Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
303 lines
8.9 KiB
TypeScript
303 lines
8.9 KiB
TypeScript
"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<T = undefined> {
|
|
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<ReturnType<typeof getPayload>>,
|
|
id: number,
|
|
): Promise<VoiceScript | null> {
|
|
try {
|
|
return await payload.findByID({
|
|
collection: "voice-scripts",
|
|
id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function fetchSubmission(
|
|
payload: Awaited<ReturnType<typeof getPayload>>,
|
|
id: number,
|
|
): Promise<VoiceSubmission | null> {
|
|
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<ActionResult<{ id: number }>> {
|
|
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<ActionResult<{ ok: true }>> {
|
|
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<ActionResult<{ id: number; status: string }>> {
|
|
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 };
|
|
}
|
|
}
|