1
0
Fork 0
polaris-task-force/src/app/(frontend)/voiceover/actions.ts
Z8MB1E 44b78ba7d9 feat(voiceover): add submission pages, recorder UI, and server actions
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>
2026-09-06 17:23:03 -04:00

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 };
}
}