1
0
Fork 0

feat(awards): add award advancement series

Awards can now upgrade through a series: collection fields and migration, grant flow support, profile and account UI updates, and regression coverage.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-11 17:07:37 -04:00
parent 71e9f3e9e0
commit 19113e39c9
18 changed files with 28217 additions and 98 deletions

View file

@ -9,11 +9,13 @@ import { EventTypes } from "@/utils/event-log/eventTypes";
import {
countGrantsByGrantor,
findUserProfile,
getAwardsByIds,
getGrantorAssignments,
heldTierOfSeries,
isRecipientEligible,
} from "@/lib/awards/grant";
import { cleanupDuplicateAwards, type CleanupResult } from "@/lib/awards/index";
import type { Assignment, Profile } from "@/payload-types";
import type { Assignment, Award, Profile } from "@/payload-types";
interface ActionResult<T = undefined> {
success: boolean;
@ -107,12 +109,6 @@ export async function grantAward(input: {
}
const issued = await countGrantsByGrantor(payload, award.id, grantorProfile.id);
remaining = maxGrants - issued;
if (remaining <= 0) {
return {
success: false,
error: "You have reached the maximum number of grants for this award.",
};
}
}
}
@ -128,8 +124,6 @@ export async function grantAward(input: {
let processed = 0;
for (const recipientUserId of input.recipientUserIds) {
if (remaining !== null && processed >= remaining) break;
if (grantorAssignmentId !== undefined) {
const eligible = await isRecipientEligible(
payload,
@ -163,20 +157,150 @@ export async function grantAward(input: {
const progression = recipientProfile.progression;
const currentAwards = progression?.awards ?? [];
const alreadyHasAward = currentAwards.some((e) => {
const id = typeof e.award === "number" ? e.award : e.award?.id;
return id === award.id;
});
if (alreadyHasAward) {
results.push({
recipientUserId,
success: false,
error: "Recipient already has this award.",
// Handle advancement series logic for ribbons with advancement
if (
award.advancement?.seriesKey &&
award.advancement?.tier !== undefined &&
award.advancement?.tier !== null
) {
// Get all the awards held by this recipient
const heldAwardIds = currentAwards
.map((entry) => {
const awardId = typeof entry.award === "number" ? entry.award : entry.award?.id;
return awardId;
})
.filter((id): id is number => id !== undefined);
// Fetch all the held awards in one batch to check their advancement series
let heldAwards: Award[] = [];
if (heldAwardIds.length > 0) {
heldAwards = await getAwardsByIds(payload, heldAwardIds);
}
// Find the recipient's held entry in this series, if any
let heldSeries: {
entry: (typeof currentAwards)[number];
heldAward: Award;
tier: number;
} | null = null;
for (const entry of currentAwards) {
const awardId = typeof entry.award === "number" ? entry.award : entry.award?.id;
const heldAward = heldAwards.find((a) => a.id === awardId);
if (!heldAward) continue;
const tier = heldTierOfSeries(heldAward, award.advancement.seriesKey);
if (tier !== null) {
heldSeries = { entry, heldAward, tier };
break;
}
}
// If the recipient holds an entry in this series, enforce tier order
// or upgrade it in place (preserving the original award date)
if (heldSeries) {
const targetTier = award.advancement.tier;
if (heldSeries.tier === targetTier) {
results.push({
recipientUserId,
success: false,
error: "Recipient already has this award.",
});
processed += 1;
continue;
}
if (heldSeries.tier > targetTier) {
results.push({
recipientUserId,
success: false,
error: "Recipient already holds a higher tier of this ribbon.",
});
processed += 1;
continue;
}
const upgradedEntry = {
award: award.id,
awardedBy: grantorProfile.id,
awardedAt: heldSeries.entry.awardedAt,
awardReason: input.reason?.trim()
? input.reason.trim()
: (heldSeries.entry.awardReason ?? null),
upgradedFrom: heldSeries.heldAward.id,
upgradedAt: new Date().toISOString(),
};
const updatedAwards = [...currentAwards];
const heldAwardId = heldSeries.heldAward.id;
const entryIndex = updatedAwards.findIndex((e) => {
const id = typeof e.award === "number" ? e.award : e.award?.id;
return id === heldAwardId;
});
if (entryIndex === -1) {
throw new Error("Could not locate the recipient's held ribbon entry for the upgrade.");
}
updatedAwards[entryIndex] = upgradedEntry;
await payload.update({
collection: "profiles",
id: recipientProfile.id,
data: {
progression: {
...progression,
awards: updatedAwards,
},
},
overrideAccess: true,
});
const recipientUserDoc =
typeof recipientProfile.user === "object"
? recipientProfile.user
: await payload.findByID({
collection: "users",
id: recipientProfile.user,
depth: 0,
overrideAccess: true,
});
const recipientName = recipientUserDoc.displayName ?? recipientUserDoc.username;
await emitGameEvent(payload, {
type: EventTypes.AwardUpgrade,
message: `${user.displayName ?? user.username} upgraded ${recipientName}'s ribbon to ${award.name}`,
actor: user.id,
data: {
awardId: award.id,
grantorProfileId: grantorProfile.id,
recipientUserId,
fromAwardId: heldAwardId,
toAwardId: award.id,
seriesKey: award.advancement.seriesKey ?? null,
tier: award.advancement.tier ?? null,
},
});
results.push({ recipientUserId, success: true });
granted += 1;
// Upgrades are exempt from remaining-grant accounting
continue;
}
} else {
// No advancement series - check if recipient already has this award
const alreadyHasAward = currentAwards.some((e) => {
const id = typeof e.award === "number" ? e.award : e.award?.id;
return id === award.id;
});
processed += 1;
continue;
if (alreadyHasAward) {
results.push({
recipientUserId,
success: false,
error: "Recipient already has this award.",
});
processed += 1;
continue;
}
}
// Normal grant (no series advancement)
await payload.update({
collection: "profiles",
id: recipientProfile.id,
@ -221,9 +345,7 @@ export async function grantAward(input: {
}
}
export async function cleanupDuplicateAwardsAction(): Promise<
ActionResult<CleanupResult>
> {
export async function cleanupDuplicateAwardsAction(): Promise<ActionResult<CleanupResult>> {
try {
const { payload, user } = await authenticate();
const superuser = await isSuperuser(payload, user);

View file

@ -15,7 +15,7 @@ interface ActionResult<T = undefined> {
data?: T;
}
const MAX_PENDING_RIBBON_SUBMISSIONS = 3;
const MAX_PENDING_RIBBON_SUBMISSIONS = 15;
async function authenticate() {
const payloadConfig = await config;
@ -90,7 +90,10 @@ export async function submitRibbonDesign(input: {
overrideAccess: true,
});
if (pendingCount.totalDocs >= MAX_PENDING_RIBBON_SUBMISSIONS) {
return { success: false, error: "You already have 3 pending ribbon submissions. Wait for them to be reviewed first." };
return {
success: false,
error: `You already have ${MAX_PENDING_RIBBON_SUBMISSIONS} pending ribbon submissions. Wait for them to be reviewed first.`,
};
}
const submission = await payload.create({

View file

@ -21,6 +21,67 @@ const TYPE_LABEL: Record<"ribbon" | "medal", string> = {
medal: "Medals",
};
type AwardCatalogItem =
| { readonly kind: "award"; readonly award: Award }
| {
readonly kind: "advancement";
readonly seriesKey: string;
readonly familyName: string;
};
function buildRibbonCatalogItems(ribbons: Award[]): AwardCatalogItem[] {
const awardsBySeries = new Map<string, Award[]>();
for (const award of ribbons) {
const seriesKey = award.advancement?.seriesKey;
if (seriesKey == null) continue;
const seriesAwards = awardsBySeries.get(seriesKey);
if (seriesAwards) seriesAwards.push(award);
else awardsBySeries.set(seriesKey, [award]);
}
const groupedSeriesKeys = new Set(
[...awardsBySeries.entries()]
.filter(([, seriesAwards]) => seriesAwards.length > 1)
.map(([seriesKey]) => seriesKey),
);
const emittedSeriesKeys = new Set<string>();
const items: AwardCatalogItem[] = [];
for (const award of ribbons) {
const seriesKey = award.advancement?.seriesKey;
if (seriesKey == null || !groupedSeriesKeys.has(seriesKey)) {
items.push({ kind: "award", award });
continue;
}
if (emittedSeriesKeys.has(seriesKey)) continue;
const seriesAwards = [...(awardsBySeries.get(seriesKey) ?? [])].sort((first, second) => {
const tierDifference =
(first.advancement?.tier ?? Number.POSITIVE_INFINITY) -
(second.advancement?.tier ?? Number.POSITIVE_INFINITY);
if (tierDifference !== 0) return tierDifference;
if (first.name !== second.name) return first.name < second.name ? -1 : 1;
return first.id - second.id;
});
const familyAward =
seriesAwards.find((seriesAward) => seriesAward.advancement?.tier === 1) ?? seriesAwards[0];
if (!familyAward) continue;
items.push({
kind: "advancement",
seriesKey,
familyName: familyAward.name,
});
for (const seriesAward of seriesAwards) {
items.push({ kind: "award", award: seriesAward });
}
emittedSeriesKeys.add(seriesKey);
}
return items;
}
export default async function AwardsPage() {
const headers = await nextHeaders();
const payloadConfig = await config;
@ -54,9 +115,19 @@ export default async function AwardsPage() {
const ribbons = awards.filter((a) => a.type === "ribbon");
const medals = awards.filter((a) => a.type === "medal");
const sections: { title: string; items: Award[] }[] = [];
if (medals.length > 0) sections.push({ title: TYPE_LABEL.medal, items: medals });
if (ribbons.length > 0) sections.push({ title: TYPE_LABEL.ribbon, items: ribbons });
const catalogSections: { title: string; items: AwardCatalogItem[] }[] = [];
if (medals.length > 0) {
catalogSections.push({
title: TYPE_LABEL.medal,
items: medals.map((award) => ({ kind: "award", award })),
});
}
if (ribbons.length > 0) {
catalogSections.push({
title: TYPE_LABEL.ribbon,
items: buildRibbonCatalogItems(ribbons),
});
}
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
@ -96,27 +167,48 @@ export default async function AwardsPage() {
</ItemContent>
</Item>
) : (
sections.map((section) => (
catalogSections.map((section) => (
<section key={section.title} className="flex flex-col gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{section.title}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{section.items.map((award) => {
const awardImage =
typeof award.image === "object" && award.image ? (award.image as Media) : null;
const backgroundImage =
typeof award.backgroundImage === "object" && award.backgroundImage
? (award.backgroundImage as Media)
: null;
return (
<AwardWithGrantButton
key={award.id}
award={award}
awardImage={awardImage}
backgroundImage={backgroundImage}
/>
);
{section.items.map((item) => {
switch (item.kind) {
case "advancement":
return (
<div
key={`advancement-${item.seriesKey}`}
className="col-span-full flex items-center gap-2 border-b border-border pb-1 text-xs font-medium text-muted-foreground"
>
<span className="shrink-0">Advancement: {item.familyName}</span>
<div className="h-px flex-1 bg-border" aria-hidden />
</div>
);
case "award": {
const { award } = item;
const awardImage =
typeof award.image === "object" && award.image
? (award.image as Media)
: null;
const backgroundImage =
typeof award.backgroundImage === "object" && award.backgroundImage
? (award.backgroundImage as Media)
: null;
return (
<AwardWithGrantButton
key={award.id}
award={award}
awardImage={awardImage}
backgroundImage={backgroundImage}
/>
);
}
default: {
const exhaustiveItem: never = item;
return exhaustiveItem;
}
}
})}
</div>
</section>

View file

@ -3,7 +3,7 @@ import { getPayload } from "payload";
import { notFound } from "next/navigation";
import { headers as nextHeaders } from "next/headers";
import Link from "next/link";
import type { Award, Assignment, Media, Qualification, Rank, User } from "@/payload-types";
import type { Award, Assignment, Media, Profile, Qualification, Rank, User } from "@/payload-types";
import { ClientDate } from "@/components/frontend/ClientDate";
import { resolveLevel } from "@/utils/xp/resolveLevel";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
@ -53,6 +53,104 @@ interface ProfilePageProps {
}
type Payload = Awaited<ReturnType<typeof getPayload>>;
type ProfileAwardEntry = NonNullable<NonNullable<Profile["progression"]>["awards"]>[number];
type HeldAwardEntry = {
readonly award: Award;
readonly entry: ProfileAwardEntry;
};
type NextTier = {
readonly id: number;
readonly name: string;
readonly tier: number;
};
function awardIdFromReference(reference: number | Award | null | undefined): number | null {
if (typeof reference === "number") return reference;
return reference?.id ?? null;
}
async function findNextTierByAwardId(
payload: Payload,
heldAwards: readonly Award[],
): Promise<Record<number, NextTier>> {
const heldTierBySeries = new Map<string, number>();
for (const award of heldAwards) {
if (award.type !== "ribbon") continue;
const seriesKey = award.advancement?.seriesKey;
const tier = award.advancement?.tier;
if (seriesKey == null || tier == null) continue;
heldTierBySeries.set(seriesKey, Math.max(heldTierBySeries.get(seriesKey) ?? tier, tier));
}
const nextTierResults = await Promise.all(
[...heldTierBySeries.entries()].map(async ([seriesKey, heldTier]) => {
const nextTierRes = await payload.find({
collection: "awards",
where: {
and: [
{ "advancement.seriesKey": { equals: seriesKey } },
{ "advancement.tier": { greater_than: heldTier } },
{ type: { equals: "ribbon" } },
],
},
sort: "advancement.tier",
limit: 1,
depth: 0,
overrideAccess: true,
});
const nextAward = nextTierRes.docs[0];
const tier = nextAward?.advancement?.tier;
return nextAward && tier != null
? {
seriesKey,
nextTier: { id: nextAward.id, name: nextAward.name, tier },
}
: null;
}),
);
const nextTierBySeries = new Map<string, NextTier>();
for (const result of nextTierResults) {
if (result) nextTierBySeries.set(result.seriesKey, result.nextTier);
}
const nextTierByAwardId: Record<number, NextTier> = {};
for (const award of heldAwards) {
if (award.type !== "ribbon") continue;
const seriesKey = award.advancement?.seriesKey;
const tier = award.advancement?.tier;
if (seriesKey == null || tier == null) continue;
const nextTier = nextTierBySeries.get(seriesKey);
if (nextTier && nextTier.tier > tier) nextTierByAwardId[award.id] = nextTier;
}
return nextTierByAwardId;
}
async function findUpgradedFromNames(
payload: Payload,
entries: readonly ProfileAwardEntry[],
): Promise<Record<number, string>> {
const upgradedFromIds = [
...new Set(
entries
.map((entry) => awardIdFromReference(entry.upgradedFrom))
.filter((id): id is number => id !== null),
),
];
if (upgradedFromIds.length === 0) return {};
const awardsRes = await payload.find({
collection: "awards",
where: { id: { in: upgradedFromIds } },
limit: upgradedFromIds.length,
depth: 0,
overrideAccess: true,
});
const upgradedFromNames: Record<number, string> = {};
for (const award of awardsRes.docs) upgradedFromNames[award.id] = award.name;
return upgradedFromNames;
}
async function findEligibleEvaluationMissions(
payload: Payload,
@ -207,14 +305,19 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const canEditIntelPromise: Promise<boolean> = viewer
? hasPermission(payload, viewer, "profiles:intel_excerpt:update")
: Promise.resolve(false);
const [evaluationSummaryResult, eligibleMissions, isSuperuserViewer, isDirectLeader, canEditIntel] =
await Promise.all([
evaluationSummaryPromise,
eligibleMissionsPromise,
privilegedViewerPromise,
directLeaderPromise,
canEditIntelPromise,
]);
const [
evaluationSummaryResult,
eligibleMissions,
isSuperuserViewer,
isDirectLeader,
canEditIntel,
] = await Promise.all([
evaluationSummaryPromise,
eligibleMissionsPromise,
privilegedViewerPromise,
directLeaderPromise,
canEditIntelPromise,
]);
const canViewSubordinate =
isProfileOwner || isLegacyPrivileged || isSuperuserViewer || isDirectLeader;
const latestMissionRows =
@ -258,12 +361,18 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
: null;
const canSubmitEvaluation = viewer !== null && !isProfileOwner;
const rank = profile.rank as Rank;
const awards = (profile.progression?.awards ?? []).flatMap((entry) => {
if (typeof entry !== "object" || entry === null) return [];
const awardEntries = (profile.progression?.awards ?? []).filter(
(entry): entry is ProfileAwardEntry => typeof entry === "object" && entry !== null,
);
const heldAwardEntries: HeldAwardEntry[] = awardEntries.flatMap((entry) => {
const award = entry.award;
return typeof award === "object" && award !== null ? [award] : [];
return typeof award === "object" && award !== null ? [{ award, entry }] : [];
});
const awards = heldAwardEntries.map(({ award }) => award);
const [nextTierByAwardId, upgradedFromNames] = await Promise.all([
findNextTierByAwardId(payload, awards),
findUpgradedFromNames(payload, awardEntries),
]);
const qualifications = (profile.progression?.qualifications ?? []).filter(
(q): q is Qualification => typeof q === "object" && q !== null,
);
@ -378,12 +487,12 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</ItemMedia>
<ItemContent>
<ItemTitle>Enlisted</ItemTitle>
<ItemDescription>
<ClientDate
date={profile.dossier.enlistmentDate}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
</ItemDescription>
<ItemDescription>
<ClientDate
date={profile.dossier.enlistmentDate}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" size="sm">
@ -489,7 +598,13 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</section>
{grantableAwards.length > 0 && (
<ProfileGrantAwardButton userId={user.id} username={username} awards={grantableAwards} />
<ProfileGrantAwardButton
userId={user.id}
username={username}
awards={grantableAwards}
heldAwards={awards}
nextTierByAwardId={nextTierByAwardId}
/>
)}
{/* ── Combat Record ──────────────────────────────────── */}
@ -588,28 +703,35 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</div>
{awards.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{[...awards]
.sort((a, b) => {
{[...heldAwardEntries]
.sort(({ award: first }, { award: second }) => {
const typeOrder = { medal: 0, ribbon: 1, commendation: 2 } as const;
const categoryOrder =
(typeOrder[a.type as keyof typeof typeOrder] ?? typeOrder.commendation) -
(typeOrder[b.type as keyof typeof typeOrder] ?? typeOrder.commendation);
(typeOrder[first.type as keyof typeof typeOrder] ?? typeOrder.commendation) -
(typeOrder[second.type as keyof typeof typeOrder] ?? typeOrder.commendation);
return categoryOrder || (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0);
return (
categoryOrder || (second.experiencePoints ?? 0) - (first.experiencePoints ?? 0)
);
})
.map((award) => {
.map(({ award, entry }) => {
const awardImage =
award.image && typeof award.image === "object" ? (award.image as Media) : null;
const backgroundImage =
award.backgroundImage && typeof award.backgroundImage === "object"
? (award.backgroundImage as Media)
: null;
const upgradedFromId = awardIdFromReference(entry.upgradedFrom);
return (
<AwardCard
key={award.id}
key={entry.id ?? `${award.id}:${entry.awardedAt}`}
awardImage={awardImage}
backgroundImage={backgroundImage}
award={award}
upgradedFromName={
upgradedFromId === null ? null : (upgradedFromNames[upgradedFromId] ?? null)
}
upgradedAt={entry.upgradedAt}
/>
);
})}

View file

@ -94,6 +94,33 @@ export const Awards: CollectionConfig = {
condition: (data) => data?.type === "medal",
},
},
{
name: "advancement",
type: "group",
label: "Advancement Series",
admin: {
description: "Configure this award as part of an advancement lineup.",
condition: (data) => data?.type === "ribbon",
},
fields: [
{
name: "seriesKey",
type: "text",
admin: {
description:
"Lineup identifier (e.g. years-of-service). Ribbons sharing this key form one advancement family.",
},
},
{
name: "tier",
type: "number",
min: 1,
admin: {
description: "Position in the advancement lineup, starting at 1.",
},
},
],
},
{
name: "grantConfig",
type: "group",
@ -154,4 +181,56 @@ export const Awards: CollectionConfig = {
],
},
],
hooks: {
beforeChange: [
async ({ req, data, operation, originalDoc }) => {
const advancement = data.advancement;
if (
advancement &&
typeof advancement.seriesKey === "string" &&
advancement.seriesKey.trim() !== ""
) {
advancement.seriesKey = advancement.seriesKey.trim().toLowerCase().replace(/\s+/g, "-");
}
const seriesKey = advancement?.seriesKey;
const tier = advancement?.tier;
if (operation === "create" || operation === "update") {
const effectiveType = data.type ?? originalDoc?.type;
if (
(seriesKey !== undefined && tier === undefined) ||
(seriesKey === undefined && tier !== undefined)
) {
throw new Error("Advancement requires both a series key and a tier.");
}
if ((seriesKey !== undefined || tier !== undefined) && effectiveType !== "ribbon") {
throw new Error("Advancement lineups are only supported for ribbons.");
}
if (seriesKey !== undefined && tier !== undefined) {
const result = await req.payload.count({
collection: "awards",
where: {
and: [
{ "advancement.seriesKey": { equals: seriesKey } },
{ "advancement.tier": { equals: tier } },
...(operation === "update" && originalDoc?.id
? [{ id: { not_equals: originalDoc.id } }]
: []),
],
},
overrideAccess: true,
});
if (result.totalDocs > 0) {
throw new Error(`Tier ${tier} of this series already exists.`);
}
}
}
return data;
},
],
},
};

View file

@ -244,6 +244,21 @@ export const Profiles: CollectionConfig = {
description: "An optional reason for the award.",
},
},
{
name: "upgradedFrom",
type: "relationship",
relationTo: "awards",
admin: {
description: "For advanced ribbons: the previous tier this entry replaced.",
},
},
{
name: "upgradedAt",
type: "date",
admin: {
description: "When this entry was upgraded to the current tier.",
},
},
],
},
{

View file

@ -1,9 +1,10 @@
import { Award, Media } from "@/payload-types";
import { ClientDate } from "@/components/frontend/ClientDate";
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
import { cn } from "@/lib/utils";
import type { Award, Media } from "@/payload-types";
const getExperiencePointsClass = (experiencePoints: number) => {
if (experiencePoints >= 5000) return "award-xp-high";
@ -17,6 +18,8 @@ export default function AwardCard(props: {
awardImage: Media | null;
award: Award;
backgroundImage?: Media | null;
upgradedFromName?: string | null;
upgradedAt?: string | null;
}) {
const isCommendation = props.award.type === "commendation" || !props.award.type;
const commendationColor =
@ -27,6 +30,10 @@ export default function AwardCard(props: {
: null;
const bgUrl = props.backgroundImage?.url;
const upgradeTrail =
props.upgradedFromName && props.upgradedAt
? { name: props.upgradedFromName, at: props.upgradedAt }
: null;
return (
<Item
@ -108,6 +115,15 @@ export default function AwardCard(props: {
+{props.award.experiencePoints} XP
</span>
)}
{upgradeTrail && (
<span className="mt-1 text-xs text-muted-foreground">
Upgraded from {upgradeTrail.name} on{" "}
<ClientDate
date={upgradeTrail.at}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
</span>
)}
</ItemContent>
</Item>
);

View file

@ -1,5 +1,6 @@
import { Award, Media } from "@/payload-types";
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Badge } from "@/components/ui/badge";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
@ -68,7 +69,12 @@ export function AwardListingCard({
/>
</>
)}
<ItemHeader className={cn("relative z-10 flex flex-col items-center gap-2", isMedal && "justify-center")}>
<ItemHeader
className={cn(
"relative z-10 flex flex-col items-center gap-2",
isMedal && "justify-center",
)}
>
{hasArt && (
<div
className={cn(
@ -95,6 +101,11 @@ export function AwardListingCard({
<span className="text-[9px] uppercase tracking-wider text-muted-foreground font-mono">
{type}
</span>
{award.advancement?.tier != null && (
<Badge variant="outline" className="w-fit text-xs text-muted-foreground">
Tier {award.advancement.tier}
</Badge>
)}
</ItemHeader>
<ItemContent className="relative z-10 flex flex-col gap-2">

View file

@ -5,12 +5,22 @@ import { useRouter } from "next/navigation";
import type { Award } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { AwardPickerDialog } from "./AwardPickerDialog";
import { AwardIcon, PlusIcon } from "lucide-react";
import { GrantAwardDialog } from "./GrantAwardDialog";
import { cn } from "@/lib/utils";
import { ArrowUpIcon, AwardIcon, PlusIcon } from "lucide-react";
type NextTier = {
readonly id: number;
readonly name: string;
readonly tier: number;
};
interface ProfileGrantAwardButtonProps {
userId: number;
username: string;
awards: Award[];
readonly userId: number;
readonly username: string;
readonly awards: Award[];
readonly heldAwards?: Award[];
readonly nextTierByAwardId?: Record<number, NextTier>;
}
/**
@ -18,9 +28,21 @@ interface ProfileGrantAwardButtonProps {
* Eligibility is enforced server-side (getGrantStats / grantAward inside
* GrantAwardDialog) — this component only provides the entry point.
*/
export function ProfileGrantAwardButton({ userId, username, awards }: ProfileGrantAwardButtonProps) {
export function ProfileGrantAwardButton({
userId,
username,
awards,
heldAwards = [],
nextTierByAwardId = {},
}: ProfileGrantAwardButtonProps) {
const [open, setOpen] = useState(false);
const [openUpgradeKey, setOpenUpgradeKey] = useState<string | null>(null);
const router = useRouter();
const upgradeOptions = heldAwards.flatMap((award) => {
if (award.type !== "ribbon") return [];
const nextTier = nextTierByAwardId[award.id];
return nextTier ? [{ heldAwardId: award.id, nextTier }] : [];
});
return (
<section className="flex flex-col gap-3">
@ -31,10 +53,27 @@ export function ProfileGrantAwardButton({ userId, username, awards }: ProfileGra
Grant Award
</h2>
</div>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<PlusIcon data-icon="inline-start" />
Grant Award
</Button>
<div className="flex flex-wrap items-center justify-end gap-2">
{upgradeOptions.map(({ heldAwardId, nextTier }) => {
const upgradeKey = `${heldAwardId}:${nextTier.id}`;
return (
<Button
key={upgradeKey}
size="sm"
variant="outline"
className={cn("max-w-full", openUpgradeKey === upgradeKey && "border-primary")}
onClick={() => setOpenUpgradeKey(upgradeKey)}
>
<ArrowUpIcon data-icon="inline-start" />
Upgrade to {nextTier.name}
</Button>
);
})}
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<PlusIcon data-icon="inline-start" />
Grant Award
</Button>
</div>
</div>
<AwardPickerDialog
awards={awards}
@ -44,6 +83,26 @@ export function ProfileGrantAwardButton({ userId, username, awards }: ProfileGra
onOpenChange={setOpen}
onGranted={() => router.refresh()}
/>
{upgradeOptions.map(({ heldAwardId, nextTier }) => {
const upgradeKey = `${heldAwardId}:${nextTier.id}`;
return (
<GrantAwardDialog
key={upgradeKey}
awardId={nextTier.id}
awardName={nextTier.name}
recipientUserId={userId}
recipientUsername={username}
open={openUpgradeKey === upgradeKey}
onOpenChange={(nextOpen) => {
if (!nextOpen) setOpenUpgradeKey(null);
}}
onGranted={() => {
setOpenUpgradeKey(null);
router.refresh();
}}
/>
);
})}
</section>
);
}

View file

@ -1,14 +1,62 @@
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Award } from "@/payload-types";
import type { Award } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
function compareAdvancementAwards(candidate: Award, current: Award): number {
const tierDifference =
(candidate.advancement?.tier ?? Number.NEGATIVE_INFINITY) -
(current.advancement?.tier ?? Number.NEGATIVE_INFINITY);
if (tierDifference !== 0) return tierDifference;
const experienceDifference = (candidate.experiencePoints ?? 0) - (current.experiencePoints ?? 0);
if (experienceDifference !== 0) return experienceDifference;
if (candidate.name !== current.name) return candidate.name < current.name ? 1 : -1;
return current.id - candidate.id;
}
export function dedupeAdvancementAwards(awards: Award[]): Award[] {
const seriesCounts = new Map<string, number>();
for (const award of awards) {
const seriesKey = award.advancement?.seriesKey;
if (seriesKey == null) continue;
seriesCounts.set(seriesKey, (seriesCounts.get(seriesKey) ?? 0) + 1);
}
const duplicateSeriesKeys = new Set(
[...seriesCounts.entries()].filter(([, count]) => count > 1).map(([seriesKey]) => seriesKey),
);
if (duplicateSeriesKeys.size === 0) return awards;
const winnerIndexBySeries = new Map<string, number>();
for (let index = 0; index < awards.length; index += 1) {
const award = awards[index];
if (!award) continue;
const seriesKey = award.advancement?.seriesKey;
if (seriesKey == null || !duplicateSeriesKeys.has(seriesKey)) continue;
const currentIndex = winnerIndexBySeries.get(seriesKey);
const currentAward = currentIndex == null ? undefined : awards[currentIndex];
if (currentAward == null || compareAdvancementAwards(award, currentAward) > 0) {
winnerIndexBySeries.set(seriesKey, index);
}
}
return awards.filter((award, index) => {
const seriesKey = award.advancement?.seriesKey;
if (seriesKey == null || !duplicateSeriesKeys.has(seriesKey)) return true;
return winnerIndexBySeries.get(seriesKey) === index;
});
}
export const RibbonRack = ({ awards }: { awards: Award[] }) => {
return (
<div className="flex flex-wrap w-[19.625rem] max-w-full gap-x-1 gap-y-px">
{awards
.filter((award) => award.type === "ribbon" && award.ribbonDesign != null)
{dedupeAdvancementAwards(
awards.filter((award) => award.type === "ribbon" && award.ribbonDesign != null),
)
.sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0))
.map((award, key) => {
const ribbon = award.ribbonDesign as RibbonDesign;

View file

@ -1,5 +1,5 @@
import type { Payload } from "payload";
import type { Assignment, Profile } from "@/payload-types";
import type { Assignment, Award, Profile } from "@/payload-types";
type AssignmentType = Assignment["type"];
type RecipientScope = "same_command" | "whitelist" | "anyone";
@ -26,10 +26,7 @@ export async function getGrantorAssignments(
const res = await payload.find({
collection: "assignments",
where: {
and: [
{ leader: { equals: userId } },
{ type: { in: [...allowedTypes] } },
],
and: [{ leader: { equals: userId } }, { type: { in: [...allowedTypes] } }],
},
limit: 100,
depth: 0,
@ -85,9 +82,42 @@ export async function getAssignmentTreeMembers(
return [...collected];
}
/**
* Get awards by their IDs in a single batched find operation.
*/
export async function getAwardsByIds(payload: Payload, ids: number[]): Promise<Award[]> {
if (ids.length === 0) return [];
const res = await payload.find({
collection: "awards",
where: {
id: {
in: ids,
},
},
limit: 1000,
depth: 0,
overrideAccess: true,
});
return res.docs;
}
/**
* Helper to get the held tier of an award in a series.
* Returns the advancement.tier if the award's advancement.seriesKey matches the given key,
* otherwise returns null.
*/
export function heldTierOfSeries(award: Award, seriesKey: string): number | null {
const normalizedSeriesKey = seriesKey.trim().toLowerCase().replace(/\s+/g, "-");
if (award.advancement?.seriesKey === normalizedSeriesKey) {
return award.advancement.tier ?? null;
}
return null;
}
/**
* How many times `grantorProfileId` has already issued `awardId` across all
* profiles. Scans every profile (depth 0) and counts matching grant rows.
* Excludes entries where `upgradedFrom` is set (upgrade entries are exempt from grant accounting).
*/
export async function countGrantsByGrantor(
payload: Payload,
@ -103,6 +133,9 @@ export async function countGrantsByGrantor(
let count = 0;
for (const profile of res.docs) {
for (const entry of profile.progression?.awards ?? []) {
// Skip entries that are upgrades (exempt from grant accounting)
if (entry.upgradedFrom) continue;
if (refId(entry.award) === awardId && refId(entry.awardedBy) === grantorProfileId) {
count += 1;
}
@ -163,10 +196,7 @@ export async function isRecipientEligible(
}
/** The profile belonging to `userId`, or null if none exists. */
export async function findUserProfile(
payload: Payload,
userId: number,
): Promise<Profile | null> {
export async function findUserProfile(payload: Payload, userId: number): Promise<Profile | null> {
const res = await payload.find({
collection: "profiles",
where: { user: { equals: userId } },

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,34 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TYPE "public"."enum_users_preferences_notifications_muted_types" ADD VALUE 'poker:force-ended';
ALTER TYPE "public"."enum_users_preferences_notifications_muted_types" ADD VALUE 'poker:everyone-left';
ALTER TYPE "public"."enum_users_preferences_discord_muted_types" ADD VALUE 'poker:force-ended';
ALTER TYPE "public"."enum_users_preferences_discord_muted_types" ADD VALUE 'poker:everyone-left';
ALTER TABLE "profiles_progression_awards" ADD COLUMN "upgraded_from_id" integer;
ALTER TABLE "profiles_progression_awards" ADD COLUMN "upgraded_at" timestamp(3) with time zone;
ALTER TABLE "awards" ADD COLUMN "advancement_series_key" varchar;
ALTER TABLE "awards" ADD COLUMN "advancement_tier" numeric;
ALTER TABLE "profiles_progression_awards" ADD CONSTRAINT "profiles_progression_awards_upgraded_from_id_awards_id_fk" FOREIGN KEY ("upgraded_from_id") REFERENCES "public"."awards"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "profiles_progression_awards_upgraded_from_idx" ON "profiles_progression_awards" USING btree ("upgraded_from_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "profiles_progression_awards" DROP CONSTRAINT "profiles_progression_awards_upgraded_from_id_awards_id_fk";
ALTER TABLE "users_preferences_notifications_muted_types" ALTER COLUMN "value" SET DATA TYPE text;
DROP TYPE "public"."enum_users_preferences_notifications_muted_types";
CREATE TYPE "public"."enum_users_preferences_notifications_muted_types" AS ENUM('market:offer', 'market:counter', 'market:accept', 'market:reject', 'market:withdrawn', 'market:closed', 'market:sold', 'market:expired', 'award:granted', 'account:discord-request', 'finance:deposit', 'finance:withdraw', 'finance:transfer', 'shipment:completed', 'ticket:created', 'ticket:assigned', 'ticket:reply', 'ticket:status');
ALTER TABLE "users_preferences_notifications_muted_types" ALTER COLUMN "value" SET DATA TYPE "public"."enum_users_preferences_notifications_muted_types" USING "value"::"public"."enum_users_preferences_notifications_muted_types";
ALTER TABLE "users_preferences_discord_muted_types" ALTER COLUMN "value" SET DATA TYPE text;
DROP TYPE "public"."enum_users_preferences_discord_muted_types";
CREATE TYPE "public"."enum_users_preferences_discord_muted_types" AS ENUM('market:offer', 'market:counter', 'market:accept', 'market:reject', 'market:withdrawn', 'market:closed', 'market:sold', 'market:expired', 'award:granted', 'account:discord-request', 'finance:deposit', 'finance:withdraw', 'finance:transfer', 'shipment:completed', 'ticket:created', 'ticket:assigned', 'ticket:reply', 'ticket:status');
ALTER TABLE "users_preferences_discord_muted_types" ALTER COLUMN "value" SET DATA TYPE "public"."enum_users_preferences_discord_muted_types" USING "value"::"public"."enum_users_preferences_discord_muted_types";
DROP INDEX "profiles_progression_awards_upgraded_from_idx";
ALTER TABLE "profiles_progression_awards" DROP COLUMN "upgraded_from_id";
ALTER TABLE "profiles_progression_awards" DROP COLUMN "upgraded_at";
ALTER TABLE "awards" DROP COLUMN "advancement_series_key";
ALTER TABLE "awards" DROP COLUMN "advancement_tier";`)
}

View file

@ -54,6 +54,7 @@ import * as migration_20260910_221018_make_missions_codename_optional from './20
import * as migration_20260910_222518_add_dashboard_layout_preference from './20260910_222518_add_dashboard_layout_preference';
import * as migration_20260911_044754_add_poker_minigame_stats from './20260911_044754_add_poker_minigame_stats';
import * as migration_20260911_052147_add_ribbon_submissions from './20260911_052147_add_ribbon_submissions';
import * as migration_20260911_201559_add_award_advancement_series from './20260911_201559_add_award_advancement_series';
export const migrations = [
{
@ -334,6 +335,11 @@ export const migrations = [
{
up: migration_20260911_052147_add_ribbon_submissions.up,
down: migration_20260911_052147_add_ribbon_submissions.down,
name: '20260911_052147_add_ribbon_submissions'
name: '20260911_052147_add_ribbon_submissions',
},
{
up: migration_20260911_201559_add_award_advancement_series.up,
down: migration_20260911_201559_add_award_advancement_series.down,
name: '20260911_201559_add_award_advancement_series'
},
];

View file

@ -1360,6 +1360,7 @@ export interface User {
| 'ticket:reply'
| 'ticket:status'
| 'poker:force-ended'
| 'poker:everyone-left'
)[]
| null;
};
@ -1393,6 +1394,7 @@ export interface User {
| 'ticket:reply'
| 'ticket:status'
| 'poker:force-ended'
| 'poker:everyone-left'
)[]
| null;
};
@ -2010,6 +2012,14 @@ export interface Profile {
* An optional reason for the award.
*/
awardReason?: string | null;
/**
* For advanced ribbons: the previous tier this entry replaced.
*/
upgradedFrom?: (number | null) | Award;
/**
* When this entry was upgraded to the current tier.
*/
upgradedAt?: string | null;
id?: string | null;
}[]
| null;
@ -2149,6 +2159,19 @@ export interface Award {
| number
| boolean
| null;
/**
* Configure this award as part of an advancement lineup.
*/
advancement?: {
/**
* Lineup identifier (e.g. years-of-service). Ribbons sharing this key form one advancement family.
*/
seriesKey?: string | null;
/**
* Position in the advancement lineup, starting at 1.
*/
tier?: number | null;
};
/**
* Configure who can grant this award and to whom. Only applies to ribbons and commendations.
*/
@ -5711,6 +5734,8 @@ export interface ProfilesSelect<T extends boolean = true> {
awardedBy?: T;
awardedAt?: T;
awardReason?: T;
upgradedFrom?: T;
upgradedAt?: T;
id?: T;
};
qualifications?: T;
@ -5833,6 +5858,12 @@ export interface AwardsSelect<T extends boolean = true> {
ribbonDesign?: T;
backgroundImage?: T;
medalDesign?: T;
advancement?:
| T
| {
seriesKey?: T;
tier?: T;
};
grantConfig?:
| T
| {

View file

@ -131,6 +131,7 @@ export const EventTypes = {
// Awards
AwardGrant: "award:grant",
AwardUpgrade: "award:upgrade",
AwardRibbonSubmitted: "award:ribbon-submitted",
AwardRibbonApproved: "award:ribbon-approved",
AwardRibbonRejected: "award:ribbon-rejected",

View file

@ -0,0 +1,737 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest";
import type { User, Award } from "@/payload-types";
import { grantAward } from "@/app/(frontend)/awards/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 = `award-${Date.now().toString(36)}`;
describe("Award Advancement Series", () => {
let adminUser: User;
let adminUserId: number;
let userA: User;
let userAId: number;
let userB: User;
let userBId: number;
let userC: User;
let userCId: number;
let tier1Award: Award;
let tier2Award: Award;
let tier3Award: Award;
let superRoleId: number;
async function profileIdFor(userId: number): Promise<number> {
const res = await payload.find({
collection: "profiles",
where: { user: { equals: userId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
if (!res.docs[0]) throw new Error(`No profile found for user ${userId}`);
return res.docs[0].id;
}
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
// Create a superuser role (RBAC resolves via roleDocs, not the legacy enum)
const superRole = await payload.create({
collection: "roles",
data: {
name: `Advancement Superuser ${RUN}`,
slug: `${RUN}-superuser`,
isSuperuser: true,
isSystem: false,
permissions: [],
},
overrideAccess: true,
depth: 0,
});
superRoleId = superRole.id;
// Create an admin user
adminUser = (await payload.create({
collection: "users",
data: {
username: `${RUN}-admin`,
discordUsername: `${RUN}-admin`,
displayName: "AWARD ADMIN",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["admin"],
roleDocs: [superRole.id],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
adminUserId = adminUser.id;
// Create users
userA = (await payload.create({
collection: "users",
data: {
username: `${RUN}-userA`,
discordUsername: `${RUN}-userA`,
displayName: "USER A",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userAId = userA.id;
userB = (await payload.create({
collection: "users",
data: {
username: `${RUN}-userB`,
discordUsername: `${RUN}-userB`,
displayName: "USER B",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userBId = userB.id;
userC = (await payload.create({
collection: "users",
data: {
username: `${RUN}-userC`,
discordUsername: `${RUN}-userC`,
displayName: "USER C",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userCId = userC.id;
// Set up auth spy for testing authentication
authSpy = vi.spyOn(payload, "auth");
// Create awards for testing advancement
tier1Award = (await payload.create({
collection: "awards",
data: {
name: `Tier 1 Award-${RUN}`,
description: {
root: {
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "text",
text: "Tier 1 Award",
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",
advancement: {
seriesKey: `${RUN}-years-of-service`,
tier: 1,
},
ribbonDesign: {
stripes: [
{ color: "#b91c1c", width: 30 },
{ color: "#f5f5f4", width: 5 },
{ color: "#1e3a5f", width: 30 },
],
devices: [],
mirrored: false,
},
experiencePoints: 25,
grantConfig: {
allowedGrantorAssignmentTypes: ["division"],
recipientScope: "same_command",
maxGrantsPerGrantor: null,
},
},
overrideAccess: true,
depth: 0,
})) as unknown as Award;
tier2Award = (await payload.create({
collection: "awards",
data: {
name: `Tier 2 Award-${RUN}`,
description: {
root: {
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "text",
text: "Tier 2 Award",
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",
advancement: {
seriesKey: `${RUN}-years-of-service`,
tier: 2,
},
ribbonDesign: {
stripes: [
{ color: "#1e3a5f", width: 30 },
{ color: "#f5f5f4", width: 5 },
{ color: "#b91c1c", width: 30 },
],
devices: [],
mirrored: false,
},
experiencePoints: 50,
grantConfig: {
allowedGrantorAssignmentTypes: ["division"],
recipientScope: "same_command",
maxGrantsPerGrantor: null,
},
},
overrideAccess: true,
depth: 0,
})) as unknown as Award;
tier3Award = (await payload.create({
collection: "awards",
data: {
name: `Tier 3 Award-${RUN}`,
description: {
root: {
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "text",
text: "Tier 3 Award",
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",
advancement: {
seriesKey: `${RUN}-years-of-service`,
tier: 3,
},
ribbonDesign: {
stripes: [
{ color: "#1e3a5f", width: 30 },
{ color: "#f5f5f4", width: 5 },
{ color: "#b91c1c", width: 30 },
],
devices: [],
mirrored: false,
},
experiencePoints: 100,
grantConfig: {
allowedGrantorAssignmentTypes: ["division"],
recipientScope: "same_command",
maxGrantsPerGrantor: 1,
},
},
overrideAccess: true,
depth: 0,
})) as unknown as Award;
});
afterAll(async () => {
// Clean up users and awards
try {
await payload.delete({ collection: "users", id: userAId, overrideAccess: true });
} catch {
// Ignore if already deleted
}
try {
await payload.delete({ collection: "users", id: userBId, overrideAccess: true });
} catch {
// Ignore if already deleted
}
try {
await payload.delete({ collection: "users", id: userCId, overrideAccess: true });
} catch {
// Ignore if already deleted
}
try {
await payload.delete({ collection: "users", id: adminUserId, overrideAccess: true });
} catch {
// Ignore if already deleted
}
try {
await payload.delete({ collection: "awards", id: tier1Award.id, overrideAccess: true });
} catch {
// Ignore if already deleted
}
try {
await payload.delete({ collection: "awards", id: tier2Award.id, overrideAccess: true });
} catch {
// Ignore if already deleted
}
try {
await payload.delete({ collection: "awards", id: tier3Award.id, overrideAccess: true });
} catch {
// Ignore if already deleted
}
try {
await payload.delete({ collection: "roles", id: superRoleId, overrideAccess: true });
} catch {
// Ignore if already deleted
}
// Restore auth spy
authSpy.mockRestore();
});
it("should grant tier 1 to recipient A", async () => {
// Mock authentication for admin user
authSpy.mockResolvedValue({ user: adminUser });
const result = await grantAward({
recipientUserIds: [userAId],
awardId: tier1Award.id,
reason: "Test grant",
});
expect(result.success).toBe(true);
expect(result.data?.results).toHaveLength(1);
expect(result.data?.results[0].success).toBe(true);
expect(result.data?.granted).toBe(1);
expect(result.data?.failed).toBe(0);
// Verify the profile has the award entry
const profile = await payload.findByID({
collection: "profiles",
id: await profileIdFor(userAId),
depth: 0,
overrideAccess: true,
});
const entries = profile.progression?.awards ?? [];
expect(entries).toHaveLength(1);
expect(entries[0]?.award).toBe(tier1Award.id);
expect(entries[0]?.upgradedFrom).toBeNull();
expect(entries[0]?.upgradedAt).toBeNull();
});
it("should upgrade recipient A from tier 1 to tier 2", async () => {
// Mock authentication for admin user
authSpy.mockResolvedValue({ user: adminUser });
const result = await grantAward({
recipientUserIds: [userAId],
awardId: tier2Award.id,
reason: "Test upgrade",
});
expect(result.success).toBe(true);
expect(result.data?.results).toHaveLength(1);
expect(result.data?.results[0].success).toBe(true);
expect(result.data?.granted).toBe(1);
expect(result.data?.failed).toBe(0);
// Verify the profile has the upgraded award entry (same array position, replaced)
const profile = await payload.findByID({
collection: "profiles",
id: await profileIdFor(userAId),
depth: 0,
overrideAccess: true,
});
const entries = profile.progression?.awards ?? [];
expect(entries).toHaveLength(1);
expect(entries[0]?.award).toBe(tier2Award.id);
expect(entries[0]?.upgradedFrom).toBe(tier1Award.id);
expect(entries[0]?.upgradedAt).toBeDefined();
expect(entries[0]?.awardedAt).toBeDefined(); // Preserved from tier 1
// Verify event was emitted (award names carry the RUN suffix, so the
// message match is idempotent across runs)
const events = await payload.find({
collection: "game-event-logs",
where: {
and: [
{ type: { equals: EventTypes.AwardUpgrade } },
{ message: { like: `Tier 2 Award-${RUN}` } },
],
},
depth: 0,
overrideAccess: true,
});
expect(events.docs).toHaveLength(1);
});
it("should upgrade recipient A from tier 2 to tier 3", async () => {
// Mock authentication for admin user
authSpy.mockResolvedValue({ user: adminUser });
const result = await grantAward({
recipientUserIds: [userAId],
awardId: tier3Award.id,
reason: "Test upgrade 3",
});
expect(result.success).toBe(true);
expect(result.data?.results).toHaveLength(1);
expect(result.data?.results[0].success).toBe(true);
expect(result.data?.granted).toBe(1);
expect(result.data?.failed).toBe(0);
// Verify the profile has the upgraded award entry (same array position, replaced)
const profile = await payload.findByID({
collection: "profiles",
id: await profileIdFor(userAId),
depth: 0,
overrideAccess: true,
});
const entries = profile.progression?.awards ?? [];
expect(entries).toHaveLength(1);
expect(entries[0]?.award).toBe(tier3Award.id);
expect(entries[0]?.upgradedFrom).toBe(tier2Award.id);
expect(entries[0]?.upgradedAt).toBeDefined();
expect(entries[0]?.awardedAt).toBeDefined(); // Preserved from tier 1
});
it("should fail when recipient A tries to get a higher tier of the same ribbon", async () => {
// Mock authentication for admin user
authSpy.mockResolvedValue({ user: adminUser });
const result = await grantAward({
recipientUserIds: [userAId],
awardId: tier1Award.id,
reason: "Test downgrade",
});
expect(result.success).toBe(true);
expect(result.data?.results).toHaveLength(1);
expect(result.data?.results[0].success).toBe(false);
expect(result.data?.results[0].error).toBe(
"Recipient already holds a higher tier of this ribbon.",
);
});
it("should grant tier 2 to fresh recipient B", async () => {
// Mock authentication for admin user
authSpy.mockResolvedValue({ user: adminUser });
const result = await grantAward({
recipientUserIds: [userBId],
awardId: tier2Award.id,
reason: "Fresh grant to B",
});
expect(result.success).toBe(true);
expect(result.data?.results).toHaveLength(1);
expect(result.data?.results[0].success).toBe(true);
expect(result.data?.granted).toBe(1);
expect(result.data?.failed).toBe(0);
// Verify the profile has the award entry (no upgradedFrom)
const profile = await payload.findByID({
collection: "profiles",
id: await profileIdFor(userBId),
depth: 0,
overrideAccess: true,
});
const entries = profile.progression?.awards ?? [];
expect(entries).toHaveLength(1);
expect(entries[0]?.award).toBe(tier2Award.id);
expect(entries[0]?.upgradedFrom).toBeNull();
});
it("should allow upgrade from tier 2 to tier 3 despite remaining 0", async () => {
// First grant tier 3 to user C (consumes the slot)
authSpy.mockResolvedValue({ user: adminUser });
const result1 = await grantAward({
recipientUserIds: [userCId],
awardId: tier3Award.id,
reason: "Grant to C",
});
expect(result1.success).toBe(true);
expect(result1.data?.results).toHaveLength(1);
expect(result1.data?.results[0].success).toBe(true);
// Now try to upgrade user B (tier 2) to tier 3 - should succeed despite remaining 0
authSpy.mockResolvedValue({ user: adminUser });
const result2 = await grantAward({
recipientUserIds: [userBId],
awardId: tier3Award.id,
reason: "Upgrade to B",
});
expect(result2.success).toBe(true);
expect(result2.data?.results).toHaveLength(1);
expect(result2.data?.results[0].success).toBe(true);
// Check that both have the tier-3 award
const profileC = await payload.findByID({
collection: "profiles",
id: await profileIdFor(userCId),
depth: 0,
overrideAccess: true,
});
const profileB = await payload.findByID({
collection: "profiles",
id: await profileIdFor(userBId),
depth: 0,
overrideAccess: true,
});
const entriesC = profileC.progression?.awards ?? [];
const entriesB = profileB.progression?.awards ?? [];
expect(entriesC).toHaveLength(1);
expect(entriesC[0]?.award).toBe(tier3Award.id);
expect(entriesB).toHaveLength(1);
expect(entriesB[0]?.award).toBe(tier3Award.id);
});
it("should fail when trying to grant same tier award to a holder", async () => {
// Mock authentication for admin user
authSpy.mockResolvedValue({ user: adminUser });
// By this point the exemption test has upgraded userB to tier 3, so
// granting tier 3 again must hit the same-tier guard
const result = await grantAward({
recipientUserIds: [userBId],
awardId: tier3Award.id,
reason: "Same tier",
});
expect(result.success).toBe(true);
expect(result.data?.results).toHaveLength(1);
expect(result.data?.results[0].success).toBe(false);
expect(result.data?.results[0].error).toBe("Recipient already has this award.");
});
it("should not affect non-series awards", async () => {
// Create a non-series award
const nonSeriesAward = await payload.create({
collection: "awards",
data: {
name: `Non-Series Award-${RUN}`,
description: {
root: {
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "text",
text: "Non-Series Award",
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: "#1e3a5f", width: 30 },
{ color: "#f5f5f4", width: 5 },
{ color: "#b91c1c", width: 30 },
],
devices: [],
mirrored: false,
},
experiencePoints: 25,
grantConfig: {
allowedGrantorAssignmentTypes: ["division"],
recipientScope: "same_command",
maxGrantsPerGrantor: null,
},
},
overrideAccess: true,
depth: 0,
});
// Grant it to user A
authSpy.mockResolvedValue({ user: adminUser });
const result1 = await grantAward({
recipientUserIds: [userAId],
awardId: nonSeriesAward.id,
reason: "Non-series award",
});
expect(result1.success).toBe(true);
expect(result1.data?.results).toHaveLength(1);
expect(result1.data?.results[0].success).toBe(true);
// Try to grant it again - should fail
const result2 = await grantAward({
recipientUserIds: [userAId],
awardId: nonSeriesAward.id,
reason: "Non-series award again",
});
expect(result2.success).toBe(true);
expect(result2.data?.results).toHaveLength(1);
expect(result2.data?.results[0].success).toBe(false);
expect(result2.data?.results[0].error).toBe("Recipient already has this award.");
// Cleanup
try {
await payload.delete({ collection: "awards", id: nonSeriesAward.id, overrideAccess: true });
} catch {
// Ignore if already deleted
}
});
it("should fail when creating two awards with same seriesKey + tier", async () => {
// Try to create a duplicate award with same seriesKey and tier
await expect(
payload.create({
collection: "awards",
data: {
name: `Duplicate Award-${RUN}`,
description: {
root: {
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "text",
text: "Duplicate Award",
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",
advancement: {
seriesKey: `${RUN}-years-of-service`,
tier: 1,
},
ribbonDesign: {
stripes: [
{ color: "#1e3a5f", width: 30 },
{ color: "#f5f5f4", width: 5 },
{ color: "#b91c1c", width: 30 },
],
devices: [],
mirrored: false,
},
experiencePoints: 25,
grantConfig: {
allowedGrantorAssignmentTypes: ["division"],
recipientScope: "same_command",
maxGrantsPerGrantor: null,
},
},
overrideAccess: true,
depth: 0,
}),
).rejects.toThrow("Tier 1 of this series already exists.");
});
});

View file

@ -158,8 +158,8 @@ describe("Ribbon Design Submissions", () => {
// Mock authentication for regular user
authSpy.mockResolvedValue({ user });
// Create 3 pending submissions
for (let i = 0; i < 3; i++) {
// Fill the pending cap
for (let i = 0; i < 15; i++) {
await payload.create({
collection: "ribbon-submissions",
data: {
@ -189,7 +189,7 @@ describe("Ribbon Design Submissions", () => {
});
expect(result.success).toBe(false);
expect(result.error).toBe("You already have 3 pending ribbon submissions. Wait for them to be reviewed first.");
expect(result.error).toBe("You already have 15 pending ribbon submissions. Wait for them to be reviewed first.");
});
});