feat(awards): add ribbon submissions collection, design validation and migration
Also scopes the MCP plugin to explicit per-collection eligibility (mcpCollections). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
b477103522
commit
ac822f259c
9 changed files with 28106 additions and 5 deletions
397
src/collections/users/RibbonSubmissions.ts
Normal file
397
src/collections/users/RibbonSubmissions.ts
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
import {
|
||||||
|
CollectionConfig,
|
||||||
|
type CollectionAfterChangeHook,
|
||||||
|
type CollectionBeforeChangeHook,
|
||||||
|
} from "payload";
|
||||||
|
import type { User, Award, RibbonSubmission } from "@/payload-types";
|
||||||
|
import hasRoles from "@/utils/access-control/hasRoles";
|
||||||
|
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
|
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||||
|
import { notifyUser } from "@/lib/notifications";
|
||||||
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
import { validateRibbonDesign } from "@/lib/awards/ribbonValidation";
|
||||||
|
|
||||||
|
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||||
|
|
||||||
|
const submitterIdOf = (user: RibbonSubmission["user"]): number =>
|
||||||
|
typeof user === "object" && user !== null ? user.id : user;
|
||||||
|
|
||||||
|
const reviewerName = (reqUser: unknown): string => {
|
||||||
|
const user = reqUser as Pick<User, "displayName" | "username"> | null | undefined;
|
||||||
|
return user?.displayName ?? user?.username ?? "A reviewer";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard review transition and apply review side effects for ribbon submissions.
|
||||||
|
*/
|
||||||
|
const guardReviewTransition: CollectionBeforeChangeHook = async ({
|
||||||
|
operation,
|
||||||
|
data,
|
||||||
|
originalDoc,
|
||||||
|
req,
|
||||||
|
}) => {
|
||||||
|
if (operation !== "update") return data;
|
||||||
|
const nextStatus = data?.status;
|
||||||
|
if (!nextStatus || nextStatus === originalDoc?.status) return data;
|
||||||
|
if (nextStatus !== "approved" && nextStatus !== "rejected") return data;
|
||||||
|
|
||||||
|
// Validate design when transitioning to approved or rejected
|
||||||
|
const designToValidate = data.design ?? originalDoc.design;
|
||||||
|
const validation = validateRibbonDesign(designToValidate);
|
||||||
|
if (!validation.ok) {
|
||||||
|
throw new Error(validation.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextStatus === "approved") {
|
||||||
|
// Validate XP and grantor types
|
||||||
|
if (data.awardXp === undefined || data.awardXp < 1) {
|
||||||
|
throw new Error("XP must be at least 1.");
|
||||||
|
}
|
||||||
|
if (!data.grantorTypes || !Array.isArray(data.grantorTypes) || data.grantorTypes.length === 0) {
|
||||||
|
throw new Error("Choose at least one grantor type.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for name collision
|
||||||
|
const existingAwards = await req.payload.count({
|
||||||
|
collection: "awards",
|
||||||
|
where: { name: { equals: data.name ?? originalDoc.name } },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
if (existingAwards.totalDocs > 0) {
|
||||||
|
throw new Error(`An award named ${data.name ?? originalDoc.name} already exists. Rename the ribbon and approve again.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyReviewSideEffects: CollectionAfterChangeHook = async ({
|
||||||
|
operation,
|
||||||
|
doc,
|
||||||
|
previousDoc,
|
||||||
|
req,
|
||||||
|
}) => {
|
||||||
|
if (operation !== "update") return doc;
|
||||||
|
if (previousDoc?.status !== "pending") return doc;
|
||||||
|
if (doc.status !== "approved" && doc.status !== "rejected") return doc;
|
||||||
|
|
||||||
|
const submitterId = submitterIdOf(doc.user);
|
||||||
|
|
||||||
|
if (doc.status === "approved") {
|
||||||
|
// Create the award with a simple description
|
||||||
|
const descriptionRichText: Award["description"] = {
|
||||||
|
root: {
|
||||||
|
type: "root",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: doc.description,
|
||||||
|
format: 0,
|
||||||
|
style: "",
|
||||||
|
mode: "normal",
|
||||||
|
detail: 0,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: "ltr",
|
||||||
|
format: "",
|
||||||
|
indent: 0,
|
||||||
|
textFormat: 0,
|
||||||
|
textStyle: "",
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: "ltr",
|
||||||
|
format: "",
|
||||||
|
indent: 0,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let createdAward: Award | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// req joins every nested write to the outer update's transaction;
|
||||||
|
// without it the same-doc update below deadlocks on this hook's row lock.
|
||||||
|
const createdAwardResult = await req.payload.create({
|
||||||
|
collection: "awards",
|
||||||
|
req,
|
||||||
|
data: {
|
||||||
|
name: doc.name,
|
||||||
|
description: descriptionRichText,
|
||||||
|
type: "ribbon",
|
||||||
|
ribbonDesign: doc.design,
|
||||||
|
experiencePoints: doc.awardXp,
|
||||||
|
grantConfig: {
|
||||||
|
allowedGrantorAssignmentTypes: doc.grantorTypes,
|
||||||
|
recipientScope: doc.recipientScope ?? "same_command",
|
||||||
|
maxGrantsPerGrantor: doc.maxGrantsPerGrantor ?? null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
createdAward = createdAwardResult;
|
||||||
|
|
||||||
|
// Update the submission with award info and reviewer details
|
||||||
|
await req.payload.update({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
id: doc.id,
|
||||||
|
req,
|
||||||
|
data: {
|
||||||
|
award: createdAward.id,
|
||||||
|
reviewedBy: req.user?.id,
|
||||||
|
reviewedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify user of approval
|
||||||
|
await notifyUser(req.payload, {
|
||||||
|
userId: submitterId,
|
||||||
|
type: "award:ribbon-approved",
|
||||||
|
title: "Ribbon approved",
|
||||||
|
message: `Your ribbon design "${doc.name}" has been approved and is now awardable.`,
|
||||||
|
link: "/awards",
|
||||||
|
});
|
||||||
|
|
||||||
|
await emitGameEvent(req.payload, {
|
||||||
|
type: EventTypes.AwardRibbonApproved,
|
||||||
|
message: `${reviewerName(req.user)} approved the ribbon design "${doc.name}"`,
|
||||||
|
actor: typeof req.user === "object" && req.user !== null ? req.user.id : undefined,
|
||||||
|
targetCollection: "ribbon-submissions",
|
||||||
|
targetId: doc.id,
|
||||||
|
data: {
|
||||||
|
submissionId: doc.id,
|
||||||
|
submitterId,
|
||||||
|
decision: "approved",
|
||||||
|
reviewNote: doc.reviewNote ?? null,
|
||||||
|
awardId: createdAward.id,
|
||||||
|
awardName: doc.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
req.payload.logger.error(
|
||||||
|
`Failed to create award for ribbon submission ${doc.id}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
// Roll back the partial approval so the reviewer can retry. Nested
|
||||||
|
// writes use req for the same-transaction reason noted above.
|
||||||
|
if (createdAward) {
|
||||||
|
try {
|
||||||
|
await req.payload.delete({
|
||||||
|
collection: "awards",
|
||||||
|
id: createdAward.id,
|
||||||
|
req,
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
} catch (cleanupError) {
|
||||||
|
req.payload.logger.error(
|
||||||
|
`Failed to clean up award ${createdAward.id} after failed approval: ${
|
||||||
|
cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await req.payload.update({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
id: doc.id,
|
||||||
|
req,
|
||||||
|
data: {
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle rejection
|
||||||
|
await notifyUser(req.payload, {
|
||||||
|
userId: submitterId,
|
||||||
|
type: "award:ribbon-rejected",
|
||||||
|
title: "Ribbon not approved",
|
||||||
|
message: `Your ribbon design "${doc.name}" was not approved${doc.reviewNote ? `. ${doc.reviewNote}` : "."}`,
|
||||||
|
link: "/awards",
|
||||||
|
});
|
||||||
|
|
||||||
|
await emitGameEvent(req.payload, {
|
||||||
|
type: EventTypes.AwardRibbonRejected,
|
||||||
|
message: `${reviewerName(req.user)} rejected the ribbon design "${doc.name}"`,
|
||||||
|
actor: typeof req.user === "object" && req.user !== null ? req.user.id : undefined,
|
||||||
|
targetCollection: "ribbon-submissions",
|
||||||
|
targetId: doc.id,
|
||||||
|
data: {
|
||||||
|
submissionId: doc.id,
|
||||||
|
submitterId,
|
||||||
|
decision: "rejected",
|
||||||
|
reviewNote: doc.reviewNote ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RibbonSubmissions: CollectionConfig = {
|
||||||
|
slug: "ribbon-submissions",
|
||||||
|
admin: {
|
||||||
|
group: "Awards",
|
||||||
|
useAsTitle: "name",
|
||||||
|
defaultColumns: ["name", "user", "status", "createdAt"],
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
read: async ({ req }) => {
|
||||||
|
const user = req.user as User | null;
|
||||||
|
if (!user) return false;
|
||||||
|
// Elevation bypasses the owner scope so reviewers see every submission;
|
||||||
|
// regular users only ever see their own.
|
||||||
|
if (await isSuperuser(req.payload, user)) return true;
|
||||||
|
if (hasRoles(["admin", "developer"], user)) return true;
|
||||||
|
return { user: { equals: user.id } };
|
||||||
|
},
|
||||||
|
create: ({ req, data }) => {
|
||||||
|
const user = req.user as User | null;
|
||||||
|
if (!user) return false;
|
||||||
|
if (hasRoles(["admin", "developer"], user)) return true;
|
||||||
|
// Submitters may only create their own submissions. Server actions that
|
||||||
|
// write on behalf of users run with overrideAccess and set `user` themselves.
|
||||||
|
const submittedUser =
|
||||||
|
typeof data?.user === "object" && data?.user !== null ? data.user.id : data?.user;
|
||||||
|
return submittedUser === user.id;
|
||||||
|
},
|
||||||
|
// Status changes are review actions — submitters must not edit their own docs.
|
||||||
|
update: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
||||||
|
delete: ({ req }) => hasRoles(["developer"], req.user),
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
maxLength: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
type: "textarea",
|
||||||
|
required: true,
|
||||||
|
maxLength: 300,
|
||||||
|
admin: {
|
||||||
|
description: "What this ribbon is awarded for.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "design",
|
||||||
|
type: "json",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "users",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description: "Submitter",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
defaultValue: "pending",
|
||||||
|
options: [
|
||||||
|
{ label: "Pending", value: "pending" },
|
||||||
|
{ label: "Approved", value: "approved" },
|
||||||
|
{ label: "Rejected", value: "rejected" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reviewNote",
|
||||||
|
type: "textarea",
|
||||||
|
admin: {
|
||||||
|
description: "Reviewer feedback shown to the submitter.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reviewedBy",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "users",
|
||||||
|
admin: {
|
||||||
|
condition: (data) => data?.status === "approved" || data?.status === "rejected",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reviewedAt",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
condition: (data) => data?.status === "approved" || data?.status === "rejected",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "award",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "awards",
|
||||||
|
admin: {
|
||||||
|
description: "Award created after approval",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "awardXp",
|
||||||
|
type: "number",
|
||||||
|
min: 1,
|
||||||
|
admin: {
|
||||||
|
description: "XP awarded for this ribbon",
|
||||||
|
condition: (data) => data?.status === "approved",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "grantorTypes",
|
||||||
|
type: "select",
|
||||||
|
hasMany: true,
|
||||||
|
options: [
|
||||||
|
{ label: "Division Leader", value: "division" },
|
||||||
|
{ label: "Squad Leader", value: "squad" },
|
||||||
|
{ label: "Team Leader", value: "team" },
|
||||||
|
{ label: "Special Assignment Leader", value: "special" },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
description: "Who can grant this award",
|
||||||
|
condition: (data) => data?.status === "approved",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "recipientScope",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "Own Command (grantor's assignment tree)", value: "same_command" },
|
||||||
|
{ label: "Whitelist (specific assignments)", value: "whitelist" },
|
||||||
|
{ label: "Anyone (unit-wide)", value: "anyone" },
|
||||||
|
],
|
||||||
|
defaultValue: "same_command",
|
||||||
|
admin: {
|
||||||
|
description: "Who can receive this award",
|
||||||
|
condition: (data) => data?.status === "approved",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maxGrantsPerGrantor",
|
||||||
|
type: "number",
|
||||||
|
admin: {
|
||||||
|
description: "Maximum grants per leader (empty = unlimited)",
|
||||||
|
condition: (data) => data?.status === "approved",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timestamps: true,
|
||||||
|
hooks: {
|
||||||
|
beforeChange: [guardReviewTransition],
|
||||||
|
afterChange: [applyReviewSideEffects],
|
||||||
|
},
|
||||||
|
};
|
||||||
51
src/components/awards/ribbonDesign/constants.ts
Normal file
51
src/components/awards/ribbonDesign/constants.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import type { DeviceType, RibbonDesign } from "./types";
|
||||||
|
|
||||||
|
// ─── Constants ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const DEFAULT_RIBBON: RibbonDesign = {
|
||||||
|
stripes: [
|
||||||
|
{ color: "#b91c1c", width: 30 },
|
||||||
|
{ color: "#f5f5f4", width: 5 },
|
||||||
|
{ color: "#1e3a5f", width: 30 },
|
||||||
|
{ color: "#f5f5f4", width: 5 },
|
||||||
|
{ color: "#b91c1c", width: 30 },
|
||||||
|
],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MILITARY_COLORS: { name: string; hex: string }[] = [
|
||||||
|
{ name: "Scarlet", hex: "#b91c1c" },
|
||||||
|
{ name: "Crimson", hex: "#991b1b" },
|
||||||
|
{ name: "Red", hex: "#dc2626" },
|
||||||
|
{ name: "Dark Blue", hex: "#1e3a5f" },
|
||||||
|
{ name: "Navy", hex: "#1e293b" },
|
||||||
|
{ name: "Air Force Blue", hex: "#5b8fa8" },
|
||||||
|
{ name: "Sky Blue", hex: "#38bdf8" },
|
||||||
|
{ name: "Gold", hex: "#ca8a04" },
|
||||||
|
{ name: "Yellow", hex: "#eab308" },
|
||||||
|
{ name: "Forest Green", hex: "#166534" },
|
||||||
|
{ name: "Green", hex: "#16a34a" },
|
||||||
|
{ name: "Olive", hex: "#4d7c0f" },
|
||||||
|
{ name: "Maroon", hex: "#581c87" },
|
||||||
|
{ name: "Purple", hex: "#7e22ce" },
|
||||||
|
{ name: "Orange", hex: "#ea580c" },
|
||||||
|
{ name: "Brown", hex: "#78350f" },
|
||||||
|
{ name: "Tan / Khaki", hex: "#d4a056" },
|
||||||
|
{ name: "White", hex: "#f5f5f4" },
|
||||||
|
{ name: "Silver / Gray", hex: "#9ca3af" },
|
||||||
|
{ name: "Black", hex: "#18181b" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RIBBON_DEVICE_INFO: Record<DeviceType, { label: string; symbol: string }> = {
|
||||||
|
star: { label: "Star", symbol: "\u2605" },
|
||||||
|
oakLeaf: { label: "Oak Leaf", symbol: "\u{1F333}" },
|
||||||
|
arrowUp: { label: "Arrow", symbol: "\u2191" },
|
||||||
|
campaignClasp: { label: "Clasp", symbol: "\u2015" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RIBBON_CONSTANTS = {
|
||||||
|
RIBBON_ASPECT: 3.667,
|
||||||
|
RIBBON_WIDTH: 366,
|
||||||
|
PREVIEW_HEIGHT: 80,
|
||||||
|
};
|
||||||
220
src/components/awards/ribbonDesign/parts.tsx
Normal file
220
src/components/awards/ribbonDesign/parts.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RIBBON_CONSTANTS, RIBBON_DEVICE_INFO } from "./constants";
|
||||||
|
import type { Device, DeviceType, RibbonDesign, Stripe } from "./types";
|
||||||
|
|
||||||
|
const { RIBBON_ASPECT, RIBBON_WIDTH, PREVIEW_HEIGHT } = RIBBON_CONSTANTS;
|
||||||
|
|
||||||
|
// ─── Preview ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function RibbonPreview({ design }: { design: RibbonDesign }) {
|
||||||
|
const { stripes, devices, mirrored } = design;
|
||||||
|
if (stripes.length === 0) return null;
|
||||||
|
|
||||||
|
const displayStripes = mirrored ? [...stripes, ...stripes.slice(0, -1).reverse()] : stripes;
|
||||||
|
|
||||||
|
const totalWeight = displayStripes.reduce((sum, s) => sum + s.width, 0);
|
||||||
|
const viewWidth = RIBBON_WIDTH;
|
||||||
|
const viewHeight = PREVIEW_HEIGHT;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${viewWidth} ${viewHeight}`}
|
||||||
|
className="w-full h-auto rounded border border-zinc-700 bg-zinc-950"
|
||||||
|
style={{ maxHeight: 140 }}
|
||||||
|
>
|
||||||
|
<rect x={0} y={0} width={viewWidth} height={viewHeight} rx={2} fill="#18181b" />
|
||||||
|
{(() => {
|
||||||
|
let x = 0;
|
||||||
|
return displayStripes.map((stripe, i) => {
|
||||||
|
const w = (stripe.width / totalWeight) * viewWidth;
|
||||||
|
const el = <rect key={i} x={x} y={0} width={w} height={viewHeight} fill={stripe.color} />;
|
||||||
|
x += w;
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{devices.map((device) => {
|
||||||
|
const info = RIBBON_DEVICE_INFO[device.type];
|
||||||
|
const cx = (device.position / 100) * viewWidth;
|
||||||
|
const cy = (device.positionY / 100) * viewHeight;
|
||||||
|
const fontSize = Math.min(viewHeight * 0.45, 24);
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
key={device.id}
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fill="#facc15"
|
||||||
|
fontSize={fontSize}
|
||||||
|
fontFamily="serif"
|
||||||
|
style={{ pointerEvents: "none" }}
|
||||||
|
>
|
||||||
|
{info.symbol}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stripe Editor ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function StripeEditor({
|
||||||
|
stripes,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
stripes: Stripe[];
|
||||||
|
onChange: (s: Stripe[]) => void;
|
||||||
|
}) {
|
||||||
|
const totalWeight = stripes.reduce((sum, s) => sum + s.width, 0);
|
||||||
|
const addStripe = () => onChange([...stripes, { color: "#f5f5f4", width: 10 }]);
|
||||||
|
const removeStripe = (idx: number) => onChange(stripes.filter((_, i) => i !== idx));
|
||||||
|
const updateStripe = (idx: number, patch: Partial<Stripe>) =>
|
||||||
|
onChange(stripes.map((s, i) => (i === idx ? { ...s, ...patch } : s)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">Stripes</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addStripe}
|
||||||
|
className="text-[11px] px-2 py-0.5 rounded bg-emerald-950 border border-emerald-700 text-emerald-200 hover:bg-emerald-900 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
+ Add Stripe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{stripes.map((stripe, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2 bg-zinc-900 rounded px-2 py-1.5">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={stripe.color}
|
||||||
|
onChange={(e) => updateStripe(idx, { color: e.target.value })}
|
||||||
|
className="size-7 rounded cursor-pointer bg-transparent border-0 p-0"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={stripe.color}
|
||||||
|
onChange={(e) => updateStripe(idx, { color: e.target.value })}
|
||||||
|
className="text-[11px] font-mono bg-zinc-800 border border-zinc-700 rounded px-2 py-1 w-20 text-zinc-300"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1.5 flex-1">
|
||||||
|
<label className="text-[10px] text-zinc-500 shrink-0">%</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={stripe.width}
|
||||||
|
onChange={(e) => updateStripe(idx, { width: Number(e.target.value) })}
|
||||||
|
className="flex-1 h-1 accent-zinc-400"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-zinc-500 font-mono w-8 text-right">
|
||||||
|
{totalWeight > 0 ? Math.round((stripe.width / totalWeight) * 100) : 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="size-5 rounded shrink-0 border border-zinc-700"
|
||||||
|
style={{ backgroundColor: stripe.color }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeStripe(idx)}
|
||||||
|
className="text-zinc-500 hover:text-red-400 text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
\u00D7
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Device Editor ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function DeviceEditor({
|
||||||
|
devices,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
devices: Device[];
|
||||||
|
onChange: (d: Device[]) => void;
|
||||||
|
}) {
|
||||||
|
const addDevice = (type: DeviceType) =>
|
||||||
|
onChange([...devices, { id: crypto.randomUUID(), type, position: 50, positionY: 50 }]);
|
||||||
|
const removeDevice = (id: string) => onChange(devices.filter((d) => d.id !== id));
|
||||||
|
const updateDevice = (id: string, patch: Partial<Device>) =>
|
||||||
|
onChange(devices.map((d) => (d.id === id ? { ...d, ...patch } : d)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">Devices</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(Object.keys(RIBBON_DEVICE_INFO) as DeviceType[]).map((type) => {
|
||||||
|
const info = RIBBON_DEVICE_INFO[type];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => addDevice(type)}
|
||||||
|
className="text-[11px] px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-zinc-300 hover:bg-zinc-700 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{info.symbol} {info.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{devices.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{devices.map((device) => {
|
||||||
|
const info = RIBBON_DEVICE_INFO[device.type];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={device.id}
|
||||||
|
className="flex items-center gap-2 bg-zinc-900 rounded px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="text-sm">{info.symbol}</span>
|
||||||
|
<span className="text-[11px] text-zinc-400">{info.label}</span>
|
||||||
|
<div className="flex items-center gap-1.5 flex-1">
|
||||||
|
<label className="text-[10px] text-zinc-500 shrink-0">X</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={device.position}
|
||||||
|
onChange={(e) => updateDevice(device.id, { position: Number(e.target.value) })}
|
||||||
|
className="flex-1 h-1 accent-zinc-400"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-zinc-500 font-mono w-5 text-right">
|
||||||
|
{device.position}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-1">
|
||||||
|
<label className="text-[10px] text-zinc-500 shrink-0">Y</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={device.positionY}
|
||||||
|
onChange={(e) => updateDevice(device.id, { positionY: Number(e.target.value) })}
|
||||||
|
className="flex-1 h-1 accent-zinc-400"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-zinc-500 font-mono w-5 text-right">
|
||||||
|
{device.positionY}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeDevice(device.id)}
|
||||||
|
className="text-zinc-500 hover:text-red-400 text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
\u00D7
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/awards/ribbonDesign/types.ts
Normal file
21
src/components/awards/ribbonDesign/types.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type DeviceType = "star" | "oakLeaf" | "arrowUp" | "campaignClasp";
|
||||||
|
|
||||||
|
export type Device = {
|
||||||
|
id: string;
|
||||||
|
type: DeviceType;
|
||||||
|
position: number;
|
||||||
|
positionY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Stripe = {
|
||||||
|
color: string;
|
||||||
|
width: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RibbonDesign = {
|
||||||
|
stripes: Stripe[];
|
||||||
|
devices: Device[];
|
||||||
|
mirrored: boolean;
|
||||||
|
};
|
||||||
77
src/lib/awards/ribbonValidation.ts
Normal file
77
src/lib/awards/ribbonValidation.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
export type RibbonValidationResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
|
export function validateRibbonDesign(design: unknown): RibbonValidationResult {
|
||||||
|
if (!design || typeof design !== "object") {
|
||||||
|
return { ok: false, error: "Invalid design object" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const designObj = design as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Check stripes
|
||||||
|
const stripes = designObj.stripes;
|
||||||
|
if (!Array.isArray(stripes)) {
|
||||||
|
return { ok: false, error: "Stripes must be an array" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stripes.length < 1 || stripes.length > 10) {
|
||||||
|
return { ok: false, error: "Must have between 1 and 10 stripes" };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < stripes.length; i++) {
|
||||||
|
const stripe = stripes[i];
|
||||||
|
if (!stripe || typeof stripe !== "object") {
|
||||||
|
return { ok: false, error: `Stripe ${i + 1} is invalid` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripeObj = stripe as Record<string, unknown>;
|
||||||
|
if (typeof stripeObj.width !== "number" || stripeObj.width <= 0 || !Number.isFinite(stripeObj.width)) {
|
||||||
|
return { ok: false, error: `Stripe ${i + 1} width must be a positive finite number` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof stripeObj.color !== "string" || !/^#[0-9a-fA-F]{6}$/.test(stripeObj.color)) {
|
||||||
|
return { ok: false, error: `Stripe ${i + 1} color must be a valid hex color (#RRGGBB)` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check devices
|
||||||
|
const devices = designObj.devices;
|
||||||
|
if (!Array.isArray(devices)) {
|
||||||
|
return { ok: false, error: "Devices must be an array" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (devices.length > 6) {
|
||||||
|
return { ok: false, error: "Must have at most 6 devices" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validDeviceTypes = ["star", "oakLeaf", "arrowUp", "campaignClasp"];
|
||||||
|
for (let i = 0; i < devices.length; i++) {
|
||||||
|
const device = devices[i];
|
||||||
|
if (!device || typeof device !== "object") {
|
||||||
|
return { ok: false, error: `Device ${i + 1} is invalid` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceObj = device as Record<string, unknown>;
|
||||||
|
if (typeof deviceObj.id !== "string") {
|
||||||
|
return { ok: false, error: `Device ${i + 1} must have a string id` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validDeviceTypes.includes(deviceObj.type as string)) {
|
||||||
|
return { ok: false, error: `Device ${i + 1} type must be one of: ${validDeviceTypes.join(", ")}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof deviceObj.position !== "number" || !Number.isFinite(deviceObj.position) || deviceObj.position < 0 || deviceObj.position > 100) {
|
||||||
|
return { ok: false, error: `Device ${i + 1} position must be a finite number between 0 and 100` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof deviceObj.positionY !== "number" || !Number.isFinite(deviceObj.positionY) || deviceObj.positionY < 0 || deviceObj.positionY > 100) {
|
||||||
|
return { ok: false, error: `Device ${i + 1} positionY must be a finite number between 0 and 100` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check mirrored
|
||||||
|
if (typeof designObj.mirrored !== "boolean") {
|
||||||
|
return { ok: false, error: "Mirrored must be a boolean" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
26657
src/migrations/20260911_052147_add_ribbon_submissions.json
Normal file
26657
src/migrations/20260911_052147_add_ribbon_submissions.json
Normal file
File diff suppressed because it is too large
Load diff
75
src/migrations/20260911_052147_add_ribbon_submissions.ts
Normal file
75
src/migrations/20260911_052147_add_ribbon_submissions.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TYPE "public"."enum_ribbon_submissions_grantor_types" AS ENUM('division', 'squad', 'team', 'special');
|
||||||
|
CREATE TYPE "public"."enum_ribbon_submissions_status" AS ENUM('pending', 'approved', 'rejected');
|
||||||
|
CREATE TYPE "public"."enum_ribbon_submissions_recipient_scope" AS ENUM('same_command', 'whitelist', 'anyone');
|
||||||
|
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'ribbon-submissions';
|
||||||
|
CREATE TABLE "ribbon_submissions_grantor_types" (
|
||||||
|
"order" integer NOT NULL,
|
||||||
|
"parent_id" integer NOT NULL,
|
||||||
|
"value" "enum_ribbon_submissions_grantor_types",
|
||||||
|
"id" serial PRIMARY KEY NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "ribbon_submissions" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar NOT NULL,
|
||||||
|
"description" varchar NOT NULL,
|
||||||
|
"design" jsonb NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"status" "enum_ribbon_submissions_status" DEFAULT 'pending' NOT NULL,
|
||||||
|
"review_note" varchar,
|
||||||
|
"reviewed_by_id" integer,
|
||||||
|
"reviewed_at" timestamp(3) with time zone,
|
||||||
|
"award_id" integer,
|
||||||
|
"award_xp" numeric,
|
||||||
|
"recipient_scope" "enum_ribbon_submissions_recipient_scope" DEFAULT 'same_command',
|
||||||
|
"max_grants_per_grantor" numeric,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "ribbon_submissions_find" boolean DEFAULT false;
|
||||||
|
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "ribbon_submissions_create" boolean DEFAULT false;
|
||||||
|
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "ribbon_submissions_update" boolean DEFAULT false;
|
||||||
|
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "ribbon_submissions_delete" boolean DEFAULT false;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "ribbon_submissions_id" integer;
|
||||||
|
ALTER TABLE "ribbon_submissions_grantor_types" ADD CONSTRAINT "ribbon_submissions_grantor_types_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."ribbon_submissions"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "ribbon_submissions" ADD CONSTRAINT "ribbon_submissions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "ribbon_submissions" ADD CONSTRAINT "ribbon_submissions_reviewed_by_id_users_id_fk" FOREIGN KEY ("reviewed_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "ribbon_submissions" ADD CONSTRAINT "ribbon_submissions_award_id_awards_id_fk" FOREIGN KEY ("award_id") REFERENCES "public"."awards"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
CREATE INDEX "ribbon_submissions_grantor_types_order_idx" ON "ribbon_submissions_grantor_types" USING btree ("order");
|
||||||
|
CREATE INDEX "ribbon_submissions_grantor_types_parent_idx" ON "ribbon_submissions_grantor_types" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "ribbon_submissions_user_idx" ON "ribbon_submissions" USING btree ("user_id");
|
||||||
|
CREATE INDEX "ribbon_submissions_reviewed_by_idx" ON "ribbon_submissions" USING btree ("reviewed_by_id");
|
||||||
|
CREATE INDEX "ribbon_submissions_award_idx" ON "ribbon_submissions" USING btree ("award_id");
|
||||||
|
CREATE INDEX "ribbon_submissions_updated_at_idx" ON "ribbon_submissions" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "ribbon_submissions_created_at_idx" ON "ribbon_submissions" USING btree ("created_at");
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_ribbon_submissions_fk" FOREIGN KEY ("ribbon_submissions_id") REFERENCES "public"."ribbon_submissions"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_ribbon_submissions_id_idx" ON "payload_locked_documents_rels" USING btree ("ribbon_submissions_id");`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "ribbon_submissions_grantor_types" DISABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "ribbon_submissions" DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP TABLE "ribbon_submissions_grantor_types" CASCADE;
|
||||||
|
DROP TABLE "ribbon_submissions" CASCADE;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_ribbon_submissions_fk";
|
||||||
|
|
||||||
|
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE text;
|
||||||
|
DROP TYPE "public"."enum_game_event_logs_target_collection";
|
||||||
|
CREATE TYPE "public"."enum_game_event_logs_target_collection" AS ENUM('game-structures', 'game-vehicles', 'game-npcs', 'npc-staffing', 'structures', 'resources', 'assets', 'vehicles', 'factions', 'missions', 'campaigns', 'users', 'technologies', 'maps', 'shipments', 'bank-accounts', 'bank-transactions', 'ledger-entries', 'locker-storages', 'loadouts', 'market-listings', 'market-negotiations', 'market-state', 'evaluations', 'tickets', 'wiki-pages', 'wiki-revisions', 'wiki-templates', 'game-servers', 'assignment-transfers', 'voice-scripts', 'voice-submissions', 'story-beats', 'story-beat-states', 'custom-minefields', 'custom-minefield-scores', 'custom-radio-tests', 'custom-radio-test-scores');
|
||||||
|
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE "public"."enum_game_event_logs_target_collection" USING "target_collection"::"public"."enum_game_event_logs_target_collection";
|
||||||
|
DROP INDEX "payload_locked_documents_rels_ribbon_submissions_id_idx";
|
||||||
|
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "ribbon_submissions_find";
|
||||||
|
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "ribbon_submissions_create";
|
||||||
|
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "ribbon_submissions_update";
|
||||||
|
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "ribbon_submissions_delete";
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "ribbon_submissions_id";
|
||||||
|
DROP TYPE "public"."enum_ribbon_submissions_grantor_types";
|
||||||
|
DROP TYPE "public"."enum_ribbon_submissions_status";
|
||||||
|
DROP TYPE "public"."enum_ribbon_submissions_recipient_scope";`)
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
// storage-adapter-import-placeholder
|
import { migrations } from "./migrations";
|
||||||
import { postgresAdapter } from "@payloadcms/db-postgres";
|
import { postgresAdapter } from "@payloadcms/db-postgres";
|
||||||
import { HeadingFeature, lexicalEditor } from "@payloadcms/richtext-lexical";
|
import { HeadingFeature, lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||||
|
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { buildConfig } from "payload";
|
import { buildConfig } from "payload";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
import { Users } from "@/collections/users/Users";
|
import { Users } from "@/collections/users/Users";
|
||||||
import { Roles } from "@/collections/users/Roles";
|
import { Roles } from "@/collections/users/Roles";
|
||||||
|
|
@ -64,14 +66,12 @@ import { Releases } from "@/collections/projects/Releases";
|
||||||
import { Labels } from "@/collections/projects/Labels";
|
import { Labels } from "@/collections/projects/Labels";
|
||||||
import { StoryBeats } from "@/collections/story/StoryBeats";
|
import { StoryBeats } from "@/collections/story/StoryBeats";
|
||||||
import { StoryBeatStates } from "@/collections/story/StoryBeatStates";
|
import { StoryBeatStates } from "@/collections/story/StoryBeatStates";
|
||||||
import { migrations } from "./migrations";
|
|
||||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
|
||||||
import nodemailer from "nodemailer";
|
|
||||||
import { Shims } from "@/collections/Shims";
|
import { Shims } from "@/collections/Shims";
|
||||||
import { CustomMinefields } from "@/collections/minigames/CustomMinefields";
|
import { CustomMinefields } from "@/collections/minigames/CustomMinefields";
|
||||||
import { CustomMinefieldScores } from "@/collections/minigames/CustomMinefieldScores";
|
import { CustomMinefieldScores } from "@/collections/minigames/CustomMinefieldScores";
|
||||||
import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests";
|
import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests";
|
||||||
import { CustomRadioTestScores } from "@/collections/minigames/CustomRadioTestScores";
|
import { CustomRadioTestScores } from "@/collections/minigames/CustomRadioTestScores";
|
||||||
|
import { RibbonSubmissions } from "@/collections/users/RibbonSubmissions";
|
||||||
import { requireAdminPageAccess } from "@/utils/access-control/hasPermission";
|
import { requireAdminPageAccess } from "@/utils/access-control/hasPermission";
|
||||||
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
|
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
|
||||||
import { mcpPlugin } from "@payloadcms/plugin-mcp";
|
import { mcpPlugin } from "@payloadcms/plugin-mcp";
|
||||||
|
|
@ -178,6 +178,7 @@ const collections = [
|
||||||
Experience,
|
Experience,
|
||||||
UserNotifications,
|
UserNotifications,
|
||||||
Evaluations,
|
Evaluations,
|
||||||
|
RibbonSubmissions,
|
||||||
|
|
||||||
// Intel
|
// Intel
|
||||||
Missions,
|
Missions,
|
||||||
|
|
@ -259,6 +260,13 @@ const scopedCollections = collections.map((collection) => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// MCP: eligible per collection, not granted: `enabled: true` makes all CRUD
|
||||||
|
// capabilities available, but each MCP API key still picks its own capability
|
||||||
|
// checkboxes and each collection's access control still applies.
|
||||||
|
const mcpCollections = Object.fromEntries(
|
||||||
|
scopedCollections.map((collection) => [collection.slug, { enabled: true } as const]),
|
||||||
|
);
|
||||||
|
|
||||||
export default buildConfig({
|
export default buildConfig({
|
||||||
// serverURL: "http://localhost:3000/",
|
// serverURL: "http://localhost:3000/",
|
||||||
admin: {
|
admin: {
|
||||||
|
|
@ -358,7 +366,7 @@ export default buildConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
// payloadCloudPlugin(),
|
// payloadCloudPlugin(),
|
||||||
// storage-adapter-placeholder
|
// storage-adapter-placeholder
|
||||||
mcpPlugin({}),
|
mcpPlugin({ collections: mcpCollections }),
|
||||||
payloadAiPlugin({
|
payloadAiPlugin({
|
||||||
collections: {
|
collections: {
|
||||||
[GameHardResources.slug]: true,
|
[GameHardResources.slug]: true,
|
||||||
|
|
|
||||||
595
tests/int/ribbon-submissions.int.spec.ts
Normal file
595
tests/int/ribbon-submissions.int.spec.ts
Normal file
|
|
@ -0,0 +1,595 @@
|
||||||
|
import { getPayload, Payload } from "payload";
|
||||||
|
import config from "@/payload.config";
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest";
|
||||||
|
import type { User, RibbonSubmission } from "@/payload-types";
|
||||||
|
import { submitRibbonDesign, withdrawRibbonSubmission, reviewRibbonSubmission } from "@/app/(frontend)/awards/design/actions";
|
||||||
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
|
||||||
|
// Mock next/headers to avoid "headers was called outside a request scope" error
|
||||||
|
vi.mock("next/headers", () => ({
|
||||||
|
headers: async () => new Headers(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let payload: Payload;
|
||||||
|
let authSpy: MockInstance;
|
||||||
|
|
||||||
|
const RUN = `ribbon-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
describe("Ribbon Design Submissions", () => {
|
||||||
|
let user: User;
|
||||||
|
let userId: number;
|
||||||
|
let adminUser: User;
|
||||||
|
let adminUserId: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
payload = await getPayload({ config: payloadConfig });
|
||||||
|
|
||||||
|
// Create a regular user
|
||||||
|
user = (await payload.create({
|
||||||
|
collection: "users",
|
||||||
|
data: {
|
||||||
|
username: `${RUN}-user`,
|
||||||
|
discordUsername: `${RUN}-user`,
|
||||||
|
displayName: "RIBBON TEST",
|
||||||
|
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||||
|
password: "Test123",
|
||||||
|
roles: ["user"],
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as User;
|
||||||
|
userId = user.id;
|
||||||
|
|
||||||
|
// Create an admin user
|
||||||
|
adminUser = (await payload.create({
|
||||||
|
collection: "users",
|
||||||
|
data: {
|
||||||
|
username: `${RUN}-admin`,
|
||||||
|
discordUsername: `${RUN}-admin`,
|
||||||
|
displayName: "RIBBON ADMIN",
|
||||||
|
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||||
|
password: "Test123",
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as User;
|
||||||
|
adminUserId = adminUser.id;
|
||||||
|
|
||||||
|
// Set up auth spy for testing authentication
|
||||||
|
authSpy = vi.spyOn(payload, "auth");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Clean up users
|
||||||
|
try {
|
||||||
|
await payload.delete({ collection: "users", id: userId, overrideAccess: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore if already deleted
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await payload.delete({ collection: "users", id: adminUserId, overrideAccess: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore if already deleted
|
||||||
|
}
|
||||||
|
// Restore auth spy
|
||||||
|
authSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("submitRibbonDesign", () => {
|
||||||
|
it("creates a pending submission and emits the submitted event", async () => {
|
||||||
|
// Mock authentication for regular user
|
||||||
|
authSpy.mockResolvedValue({ user });
|
||||||
|
|
||||||
|
const design = {
|
||||||
|
stripes: [
|
||||||
|
{ color: "#b91c1c", width: 30 },
|
||||||
|
{ color: "#f5f5f4", width: 5 },
|
||||||
|
{ color: "#1e3a5f", width: 30 },
|
||||||
|
],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await submitRibbonDesign({
|
||||||
|
name: "Test Ribbon",
|
||||||
|
description: "A test ribbon design",
|
||||||
|
design,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.id).toBeDefined();
|
||||||
|
|
||||||
|
const submissionId = result.data!.id;
|
||||||
|
|
||||||
|
// Verify the submission was created with correct data
|
||||||
|
const submission = await payload.findByID({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
id: submissionId,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
}) as RibbonSubmission;
|
||||||
|
|
||||||
|
expect(submission.user).toBe(userId);
|
||||||
|
expect(submission.status).toBe("pending");
|
||||||
|
expect(submission.name).toBe("Test Ribbon");
|
||||||
|
expect(submission.description).toBe("A test ribbon design");
|
||||||
|
expect(submission.design).toEqual(design);
|
||||||
|
|
||||||
|
// Verify event was emitted (scoped to this submission; the shared dev
|
||||||
|
// database accumulates events across runs)
|
||||||
|
const events = await payload.find({
|
||||||
|
collection: "game-event-logs",
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ type: { equals: EventTypes.AwardRibbonSubmitted } },
|
||||||
|
{ targetId: { equals: submissionId } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events.docs).toHaveLength(1);
|
||||||
|
expect(events.docs[0].message).toContain("Test Ribbon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid design", async () => {
|
||||||
|
// Mock authentication for regular user
|
||||||
|
authSpy.mockResolvedValue({ user });
|
||||||
|
|
||||||
|
const result = await submitRibbonDesign({
|
||||||
|
name: "Invalid Test",
|
||||||
|
description: "A test with invalid design",
|
||||||
|
design: {
|
||||||
|
stripes: [],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe("Must have between 1 and 10 stripes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces the pending cap", async () => {
|
||||||
|
// Mock authentication for regular user
|
||||||
|
authSpy.mockResolvedValue({ user });
|
||||||
|
|
||||||
|
// Create 3 pending submissions
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await payload.create({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
data: {
|
||||||
|
name: `Pending ${i}`,
|
||||||
|
description: `Pending submission ${i}`,
|
||||||
|
design: {
|
||||||
|
stripes: [{ color: "#b91c1c", width: 30 }],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
user: userId,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await submitRibbonDesign({
|
||||||
|
name: "Too Many",
|
||||||
|
description: "This should fail due to cap",
|
||||||
|
design: {
|
||||||
|
stripes: [{ color: "#b91c1c", width: 30 }],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe("You already have 3 pending ribbon submissions. Wait for them to be reviewed first.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withdrawRibbonSubmission", () => {
|
||||||
|
it("allows withdrawing own pending submissions", async () => {
|
||||||
|
// Mock authentication for regular user
|
||||||
|
authSpy.mockResolvedValue({ user });
|
||||||
|
|
||||||
|
// Create a pending submission
|
||||||
|
const submission = await payload.create({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
data: {
|
||||||
|
name: "Withdraw Test",
|
||||||
|
description: "A test for withdrawal",
|
||||||
|
design: {
|
||||||
|
stripes: [{ color: "#b91c1c", width: 30 }],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
user: userId,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await withdrawRibbonSubmission(submission.id);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify submission was deleted
|
||||||
|
const fetchedSubmission = await payload.findByID({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
id: submission.id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
expect(fetchedSubmission).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects withdrawing submissions that don't belong to the user", async () => {
|
||||||
|
// Create a pending submission for the user
|
||||||
|
const submission = await payload.create({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
data: {
|
||||||
|
name: "Withdraw Test 2",
|
||||||
|
description: "A test for withdrawal",
|
||||||
|
design: {
|
||||||
|
stripes: [{ color: "#b91c1c", width: 30 }],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
user: userId,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock authentication for admin user to test access control
|
||||||
|
authSpy.mockResolvedValue({ user: adminUser });
|
||||||
|
|
||||||
|
const result = await withdrawRibbonSubmission(submission.id);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe("You can only withdraw your own submissions.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects withdrawing non-pending submissions", async () => {
|
||||||
|
// Create an approved submission
|
||||||
|
const submission = await payload.create({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
data: {
|
||||||
|
name: "Approved Test",
|
||||||
|
description: "An approved submission",
|
||||||
|
design: {
|
||||||
|
stripes: [{ color: "#b91c1c", width: 30 }],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
user: userId,
|
||||||
|
status: "approved",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock authentication for regular user
|
||||||
|
authSpy.mockResolvedValue({ user });
|
||||||
|
|
||||||
|
const result = await withdrawRibbonSubmission(submission.id);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe("Only pending submissions can be withdrawn.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reviewRibbonSubmission", () => {
|
||||||
|
it("allows admins to approve submissions", async () => {
|
||||||
|
// Mock authentication for admin user
|
||||||
|
authSpy.mockResolvedValue({ user: adminUser });
|
||||||
|
|
||||||
|
// Create a pending submission (unique name: approvals persist an award
|
||||||
|
// with this name, and the shared dev database keeps prior runs' awards)
|
||||||
|
const submission = await payload.create({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
data: {
|
||||||
|
name: `Approve Test-${RUN}`,
|
||||||
|
description: "A test for approval",
|
||||||
|
design: {
|
||||||
|
stripes: [{ color: "#b91c1c", width: 30 }],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
user: userId,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await reviewRibbonSubmission(submission.id, "approved", {
|
||||||
|
awardXp: 25,
|
||||||
|
grantorTypes: ["division"],
|
||||||
|
recipientScope: "same_command",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.status).toBe("approved");
|
||||||
|
|
||||||
|
// Verify submission was updated
|
||||||
|
const updatedSubmission = await payload.findByID({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
id: submission.id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
}) as RibbonSubmission;
|
||||||
|
|
||||||
|
expect(updatedSubmission.status).toBe("approved");
|
||||||
|
expect(updatedSubmission.awardXp).toBe(25);
|
||||||
|
expect(updatedSubmission.grantorTypes).toEqual(["division"]);
|
||||||
|
expect(updatedSubmission.recipientScope).toBe("same_command");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-reviewers from updating submissions", async () => {
|
||||||
|
// Create a pending submission
|
||||||
|
const submission = await payload.create({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
data: {
|
||||||
|
name: "Access Test",
|
||||||
|
description: "A test for access control",
|
||||||
|
design: {
|
||||||
|
stripes: [{ color: "#b91c1c", width: 30 }],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
user: userId,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock authentication for regular user (not admin)
|
||||||
|
authSpy.mockResolvedValue({ user });
|
||||||
|
|
||||||
|
// Try to approve as a regular user (should fail)
|
||||||
|
const result = await reviewRibbonSubmission(submission.id, "approved", {
|
||||||
|
awardXp: 25,
|
||||||
|
grantorTypes: ["division"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe("Only admins and developers can review ribbon submissions.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects approval without XP", async () => {
|
||||||
|
authSpy.mockResolvedValue({ user: adminUser });
|
||||||
|
|
||||||
|
// Create a pending submission
|
||||||
|
const submission = await payload.create({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
data: {
|
||||||
|
name: "No XP Test",
|
||||||
|
description: "A test for XP validation",
|
||||||
|
design: {
|
||||||
|
stripes: [{ color: "#b91c1c", width: 30 }],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
user: userId,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await reviewRibbonSubmission(submission.id, "approved", {
|
||||||
|
grantorTypes: ["division"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe("XP must be at least 1.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects approval with empty grantor types", async () => {
|
||||||
|
authSpy.mockResolvedValue({ user: adminUser });
|
||||||
|
|
||||||
|
// Create a pending submission
|
||||||
|
const submission = await payload.create({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
data: {
|
||||||
|
name: "Invalid Grantor Test",
|
||||||
|
description: "A test for grantor validation",
|
||||||
|
design: {
|
||||||
|
stripes: [{ color: "#b91c1c", width: 30 }],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
user: userId,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await reviewRibbonSubmission(submission.id, "approved", {
|
||||||
|
awardXp: 25,
|
||||||
|
grantorTypes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe("Choose at least one grantor type.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects approval with invalid recipient scope", async () => {
|
||||||
|
authSpy.mockResolvedValue({ user: adminUser });
|
||||||
|
|
||||||
|
// Create a pending submission
|
||||||
|
const submission = await payload.create({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
data: {
|
||||||
|
name: "Invalid Scope Test",
|
||||||
|
description: "A test for recipient scope validation",
|
||||||
|
design: {
|
||||||
|
stripes: [{ color: "#b91c1c", width: 30 }],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
user: userId,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server action args arrive untyped over the wire, so a caller can send
|
||||||
|
// a scope outside the union; forge one to exercise the runtime guard.
|
||||||
|
const forgedOpts = {
|
||||||
|
awardXp: 25,
|
||||||
|
grantorTypes: ["division"],
|
||||||
|
recipientScope: "whitelist",
|
||||||
|
};
|
||||||
|
const result = await reviewRibbonSubmission(
|
||||||
|
submission.id,
|
||||||
|
"approved",
|
||||||
|
forgedOpts as Parameters<typeof reviewRibbonSubmission>[2],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe("Whitelist scoping is not available yet.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects approval with duplicate award names", async () => {
|
||||||
|
authSpy.mockResolvedValue({ user: adminUser });
|
||||||
|
|
||||||
|
const dupName = `Duplicate Test-${RUN}`;
|
||||||
|
|
||||||
|
// Create an existing award with the same name (using proper structure)
|
||||||
|
await payload.create({
|
||||||
|
collection: "awards",
|
||||||
|
data: {
|
||||||
|
name: dupName,
|
||||||
|
description: {
|
||||||
|
root: {
|
||||||
|
type: "root",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Test description",
|
||||||
|
format: 0,
|
||||||
|
style: "",
|
||||||
|
mode: "normal",
|
||||||
|
detail: 0,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: "ltr",
|
||||||
|
format: "",
|
||||||
|
indent: 0,
|
||||||
|
textFormat: 0,
|
||||||
|
textStyle: "",
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: "ltr",
|
||||||
|
format: "",
|
||||||
|
indent: 0,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "ribbon",
|
||||||
|
ribbonDesign: {
|
||||||
|
stripes: [{ color: "#b91c1c", width: 30 }],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
experiencePoints: 25,
|
||||||
|
grantConfig: {
|
||||||
|
allowedGrantorAssignmentTypes: ["division"],
|
||||||
|
recipientScope: "same_command",
|
||||||
|
maxGrantsPerGrantor: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const submission = await payload.create({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
data: {
|
||||||
|
name: dupName,
|
||||||
|
description: "A test for duplicate name",
|
||||||
|
design: {
|
||||||
|
stripes: [{ color: "#b91c1c", width: 30 }],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
user: userId,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock authentication for admin user
|
||||||
|
authSpy.mockResolvedValue({ user: adminUser });
|
||||||
|
|
||||||
|
const result = await reviewRibbonSubmission(submission.id, "approved", {
|
||||||
|
awardXp: 25,
|
||||||
|
grantorTypes: ["division"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain("already exists");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects rejection without review note", async () => {
|
||||||
|
// Mock authentication for admin user
|
||||||
|
authSpy.mockResolvedValue({ user: adminUser });
|
||||||
|
|
||||||
|
// Create a pending submission
|
||||||
|
const submission = await payload.create({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
data: {
|
||||||
|
name: "Reject Test",
|
||||||
|
description: "A test for rejection",
|
||||||
|
design: {
|
||||||
|
stripes: [{ color: "#b91c1c", width: 30 }],
|
||||||
|
devices: [],
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
user: userId,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await reviewRibbonSubmission(submission.id, "rejected", {
|
||||||
|
reviewNote: "Too similar to an existing ribbon.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.status).toBe("rejected");
|
||||||
|
|
||||||
|
// Verify submission was updated
|
||||||
|
const updatedSubmission = await payload.findByID({
|
||||||
|
collection: "ribbon-submissions",
|
||||||
|
id: submission.id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
}) as RibbonSubmission;
|
||||||
|
|
||||||
|
expect(updatedSubmission.status).toBe("rejected");
|
||||||
|
expect(updatedSubmission.reviewNote).toBe("Too similar to an existing ribbon.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue