1
0
Fork 0
polaris-task-force/src/app/(frontend)/awards/design/actions.ts
Z8MB1E 19113e39c9 feat(awards): add award advancement series
Awards can now upgrade through a series: collection fields and migration, grant flow support, profile and account UI updates, and regression coverage.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 17:07:37 -04:00

254 lines
No EOL
8.1 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 hasRoles from "@/utils/access-control/hasRoles";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { validateRibbonDesign } from "@/lib/awards/ribbonValidation";
import type { RibbonSubmission } from "@/payload-types";
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
const MAX_PENDING_RIBBON_SUBMISSIONS = 15;
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 fetchSubmission(
payload: Awaited<ReturnType<typeof getPayload>>,
id: number,
): Promise<RibbonSubmission | null> {
try {
return await payload.findByID({
collection: "ribbon-submissions",
id,
depth: 0,
overrideAccess: true,
});
} catch {
return null;
}
}
export async function submitRibbonDesign(input: {
name: string;
description: string;
design: Record<string, unknown>;
}): Promise<ActionResult<{ id: number }>> {
try {
const { payload, user } = await authenticate();
const trimmedName = input.name.trim();
if (trimmedName.length === 0) {
return { success: false, error: "A ribbon name is required." };
}
if (trimmedName.length > 60) {
return { success: false, error: "Ribbon names must be 60 characters or fewer." };
}
const trimmedDescription = input.description.trim();
if (trimmedDescription.length === 0) {
return { success: false, error: "A ribbon description is required." };
}
if (trimmedDescription.length > 300) {
return { success: false, error: "Ribbon descriptions must be 300 characters or fewer." };
}
const validation = validateRibbonDesign(input.design);
if (!validation.ok) {
return { success: false, error: validation.error };
}
const pendingCount = await payload.count({
collection: "ribbon-submissions",
where: {
and: [
{ user: { equals: user.id } },
{ status: { equals: "pending" } },
],
},
overrideAccess: true,
});
if (pendingCount.totalDocs >= MAX_PENDING_RIBBON_SUBMISSIONS) {
return {
success: false,
error: `You already have ${MAX_PENDING_RIBBON_SUBMISSIONS} pending ribbon submissions. Wait for them to be reviewed first.`,
};
}
const submission = await payload.create({
collection: "ribbon-submissions",
data: {
name: trimmedName,
description: trimmedDescription,
design: input.design,
user: user.id,
status: "pending",
},
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.AwardRibbonSubmitted,
message: `${user.displayName ?? user.username} submitted the ribbon design "${trimmedName}"`,
actor: user.id,
targetCollection: "ribbon-submissions",
targetId: submission.id,
data: {
submissionId: submission.id,
submitterId: user.id,
name: trimmedName,
},
});
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 withdrawRibbonSubmission(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: "ribbon-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 reviewRibbonSubmission(
submissionId: number,
decision: "approved" | "rejected",
opts?: {
awardXp?: number;
grantorTypes?: ("division" | "squad" | "team" | "special")[];
recipientScope?: "same_command" | "anyone";
maxGrantsPerGrantor?: number | null;
finalName?: string;
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 and developers can review ribbon 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." };
}
if (decision === "approved") {
if (opts?.awardXp === undefined || !Number.isInteger(opts.awardXp) || opts.awardXp < 1) {
return { success: false, error: "XP must be at least 1." };
}
if (!opts?.grantorTypes || !Array.isArray(opts.grantorTypes) || opts.grantorTypes.length === 0) {
return { success: false, error: "Choose at least one grantor type." };
}
const validGrantorTypes = ["division", "squad", "team", "special"];
for (const grantorType of opts.grantorTypes) {
if (!validGrantorTypes.includes(grantorType)) {
return { success: false, error: "Invalid grantor type." };
}
}
if (opts.recipientScope !== undefined && opts.recipientScope !== "same_command" && opts.recipientScope !== "anyone") {
return { success: false, error: "Whitelist scoping is not available yet." };
}
}
const updateData: Record<string, unknown> = {
status: decision,
reviewNote: opts?.reviewNote ?? null,
};
if (decision === "approved") {
updateData.awardXp = opts?.awardXp;
updateData.grantorTypes = opts?.grantorTypes;
updateData.recipientScope = opts?.recipientScope ?? "same_command";
updateData.maxGrantsPerGrantor = opts?.maxGrantsPerGrantor ?? null;
if (opts?.finalName && opts.finalName.trim() !== "" && opts.finalName.trim() !== submission.name) {
updateData.name = opts.finalName.trim();
}
}
await payload.update({
collection: "ribbon-submissions",
id: submissionId,
data: updateData,
overrideAccess: true,
depth: 0,
});
// Check if the afterChange hook reverted the status back to pending due to award creation failure
const updatedSubmission = await payload.findByID({
collection: "ribbon-submissions",
id: submissionId,
depth: 0,
overrideAccess: true,
});
if (decision === "approved" && updatedSubmission.status === "pending") {
return { success: false, error: "Approval failed while creating the award. The submission is back to pending; try again." };
}
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 };
}
}