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>
254 lines
No EOL
8.1 KiB
TypeScript
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 };
|
|
}
|
|
} |