1
0
Fork 0

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:
Jason Fraley 2026-09-11 03:25:59 -04:00
parent b477103522
commit ac822f259c
9 changed files with 28106 additions and 5 deletions

View 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],
},
};

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

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

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

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

File diff suppressed because it is too large Load diff

View 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";`)
}

View file

@ -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,

View 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.");
});
});
});