"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 { 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>, id: number, ): Promise { 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; }): Promise> { 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> { 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> { 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 = { 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 }; } }