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:
parent
71e9f3e9e0
commit
19113e39c9
18 changed files with 28217 additions and 98 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
|
|
|
|||
26713
src/migrations/20260911_201559_add_award_advancement_series.json
Normal file
26713
src/migrations/20260911_201559_add_award_advancement_series.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
737
tests/int/award-advancement.int.spec.ts
Normal file
737
tests/int/award-advancement.int.spec.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue