1
0
Fork 0

feat(awards): add awards listing page, grant permissions, and profile ribbon rack integration

This commit is contained in:
Jason Fraley 2026-08-31 12:27:20 -04:00
parent e3e8b69a54
commit 24c5259056
15 changed files with 1653 additions and 9 deletions

View file

@ -0,0 +1,159 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { isSuperuser } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import {
countGrantsByGrantor,
findUserProfile,
getGrantorAssignments,
isRecipientEligible,
} from "@/lib/awards/grant";
import type { Profile } from "@/payload-types";
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!isPayloadUser(user)) {
throw new Error("Unauthorized");
}
return { payload, user };
}
export async function grantAward(input: {
recipientUserId: number;
awardId: number;
reason?: string;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
let award;
try {
award = await payload.findByID({
collection: "awards",
id: input.awardId,
depth: 0,
overrideAccess: true,
});
} catch {
return { success: false, error: "Award not found." };
}
let grantorProfile: Profile | null = null;
const superuser = await isSuperuser(payload, user);
if (award.type === "medal") {
if (!superuser) {
return { success: false, error: "Only superusers can grant medals." };
}
} else if (!superuser) {
const allowedTypes = award.grantConfig?.allowedGrantorAssignmentTypes ?? [];
if (allowedTypes.length === 0) {
return { success: false, error: "This award cannot be granted by leaders." };
}
const grantorAssignments = await getGrantorAssignments(
payload,
user.id as number,
allowedTypes,
);
if (grantorAssignments.length === 0) {
return { success: false, error: "You are not authorized to grant this award." };
}
const grantorAssignmentId = grantorAssignments[0].id;
const maxGrants = award.grantConfig?.maxGrantsPerGrantor ?? null;
if (maxGrants) {
grantorProfile = await findUserProfile(payload, user.id as number);
if (!grantorProfile) {
return { success: false, error: "You do not have a profile." };
}
const issued = await countGrantsByGrantor(payload, award.id, grantorProfile.id);
if (issued >= maxGrants) {
return {
success: false,
error: "You have reached the maximum number of grants for this award.",
};
}
}
const eligible = await isRecipientEligible(
payload,
input.recipientUserId,
award.grantConfig?.recipientScope ?? "same_command",
grantorAssignmentId,
award.grantConfig?.eligibleRecipientAssignments,
);
if (!eligible) {
return { success: false, error: "Recipient is not eligible for this award." };
}
}
const recipientProfile = await findUserProfile(payload, input.recipientUserId);
if (!recipientProfile) {
return { success: false, error: "Recipient does not have a profile." };
}
if (!grantorProfile) {
grantorProfile = await findUserProfile(payload, user.id as number);
}
if (!grantorProfile) {
return { success: false, error: "You do not have a profile." };
}
const progression = recipientProfile.progression;
const currentAwards = progression?.awards ?? [];
await payload.update({
collection: "profiles",
id: recipientProfile.id,
data: {
progression: {
...progression,
awards: [
...currentAwards,
{
award: award.id,
awardedBy: grantorProfile.id,
awardedAt: new Date().toISOString(),
awardReason: input.reason ?? null,
},
],
},
},
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.AwardGrant,
message: `Granted "${award.name}" to recipient profile #${recipientProfile.id}`,
actor: user.id,
data: { awardId: award.id, grantorProfileId: grantorProfile.id },
});
return { success: true };
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
}
}

View file

@ -0,0 +1,105 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { isSuperuser } from "@/utils/access-control/hasPermission";
import { countGrantsByGrantor, findUserProfile, getGrantorAssignments } from "@/lib/awards/grant";
/**
* Read-only grant context for a single award: whether the current user may
* grant it and how many grants (if limited) they have left. Mirrors the
* authorization logic in `grantAward` so the UI can warn before submitting.
*/
export interface GrantStats {
canGrant: boolean;
isSuperuser: boolean;
currentGrants: number;
maxGrants: number | null;
remainingGrants: number | null;
}
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!isPayloadUser(user)) {
throw new Error("Unauthorized");
}
return { payload, user };
}
export async function getGrantStats(awardId: number): Promise<ActionResult<GrantStats>> {
try {
const { payload, user } = await authenticate();
let award;
try {
award = await payload.findByID({
collection: "awards",
id: awardId,
depth: 0,
overrideAccess: true,
});
} catch {
return { success: false, error: "Award not found." };
}
const superuser = await isSuperuser(payload, user);
let canGrant = false;
if (superuser) {
canGrant = true;
} else if (award.type === "medal") {
canGrant = false;
} else {
const allowedTypes = award.grantConfig?.allowedGrantorAssignmentTypes ?? [];
if (allowedTypes.length > 0) {
const grantorAssignments = await getGrantorAssignments(
payload,
user.id as number,
allowedTypes,
);
canGrant = grantorAssignments.length > 0;
}
}
let currentGrants = 0;
const maxGrants = superuser ? null : (award.grantConfig?.maxGrantsPerGrantor ?? null);
if (canGrant && maxGrants) {
const grantorProfile = await findUserProfile(payload, user.id as number);
if (grantorProfile) {
currentGrants = await countGrantsByGrantor(payload, awardId, grantorProfile.id);
}
}
return {
success: true,
data: {
canGrant,
isSuperuser: superuser,
currentGrants,
maxGrants,
remainingGrants: maxGrants === null ? null : Math.max(maxGrants - currentGrants, 0),
},
};
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
}
}

View file

@ -0,0 +1,86 @@
import config from "@payload-config";
import { getPayload } from "payload";
import type { Award, Media } from "@/payload-types";
import { AwardWithGrantButton } from "@/components/frontend/account/AwardWithGrantButton";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { MedalIcon } from "lucide-react";
export const metadata = {
title: "Awards",
description:
"Browse every medal and ribbon that can be granted to a member of Polaris Task Force.",
};
const TYPE_LABEL: Record<"ribbon" | "medal", string> = {
ribbon: "Ribbons",
medal: "Medals",
};
export default async function AwardsPage() {
const payload = await getPayload({ config });
const res = await payload.find({
collection: "awards",
where: {
type: { in: ["ribbon", "medal"] },
},
sort: "name",
limit: 100,
depth: 1,
overrideAccess: true,
});
const awards: Award[] = res.docs;
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 });
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<MedalIcon className="size-4 text-muted-foreground" />
<h1 className="text-2xl font-bold tracking-tight uppercase">Awards</h1>
</div>
<p className="text-sm text-muted-foreground max-w-2xl">
A reference of every medal and ribbon that can be earned by a member of Polaris Task
Force. Review the requirements for each award and its artwork. Commendations are granted
on the fly and are not catalogued here.
</p>
</div>
{awards.length === 0 ? (
<Item variant="muted">
<ItemHeader>
<ItemTitle>No awards defined</ItemTitle>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No medals or ribbons have been catalogued yet. Check back soon.
</p>
</ItemContent>
</Item>
) : (
sections.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;
return (
<AwardWithGrantButton key={award.id} award={award} awardImage={awardImage} />
);
})}
</div>
</section>
))
)}
</div>
);
}

View file

@ -3,8 +3,9 @@ import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import Link from "next/link"; import Link from "next/link";
import type { Assignment, Media, Qualification, Rank, User } from "@/payload-types"; import type { Award, Assignment, Media, Qualification, Rank, User } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { ClientDate } from "@/components/frontend/ClientDate";
import { resolveLevel } from "@/utils/xp/resolveLevel"; import { resolveLevel } from "@/utils/xp/resolveLevel";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { isSuperuser } from "@/utils/access-control/hasPermission"; import { isSuperuser } from "@/utils/access-control/hasPermission";
@ -39,6 +40,7 @@ import {
ItemTitle, ItemTitle,
} from "@/components/ui/item"; } from "@/components/ui/item";
import AwardCard from "@/components/frontend/account/AwardCard"; import AwardCard from "@/components/frontend/account/AwardCard";
import { ProfileGrantAwardButton } from "@/components/frontend/account/ProfileGrantAwardButton";
import { RibbonRack } from "@/components/frontend/account/RibbonRack"; import { RibbonRack } from "@/components/frontend/account/RibbonRack";
import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection"; import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection";
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels"; import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
@ -282,6 +284,16 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
}); });
const assignments = assignmentRes.docs as Pick<Assignment, "name" | "type" | "callsign">[]; const assignments = assignmentRes.docs as Pick<Assignment, "name" | "type" | "callsign">[];
const grantableAwardsRes = await payload.find({
collection: "awards",
where: { type: { in: ["ribbon", "commendation"] } },
sort: "name",
limit: 100,
depth: 1,
overrideAccess: true,
});
const grantableAwards = grantableAwardsRes.docs as Award[];
const rankInsignia = const rankInsignia =
typeof rank === "object" && rank.insignia && typeof rank.insignia === "object" typeof rank === "object" && rank.insignia && typeof rank.insignia === "object"
? (rank.insignia as Media) ? (rank.insignia as Media)
@ -296,11 +308,6 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "—"; const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "—";
const enlistDate = new Date(profile.dossier.enlistmentDate); const enlistDate = new Date(profile.dossier.enlistmentDate);
const enlistDateStr = enlistDate.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
const now = new Date(); const now = new Date();
const serviceMonths = const serviceMonths =
(now.getFullYear() - enlistDate.getFullYear()) * 12 + (now.getMonth() - enlistDate.getMonth()); (now.getFullYear() - enlistDate.getFullYear()) * 12 + (now.getMonth() - enlistDate.getMonth());
@ -369,7 +376,12 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Enlisted</ItemTitle> <ItemTitle>Enlisted</ItemTitle>
<ItemDescription>{enlistDateStr}</ItemDescription> <ItemDescription>
<ClientDate
date={profile.dossier.enlistmentDate}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
<Item variant="muted" size="sm"> <Item variant="muted" size="sm">
@ -499,6 +511,10 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
)} )}
</section> </section>
{grantableAwards.length > 0 && (
<ProfileGrantAwardButton userId={user.id} username={username} awards={grantableAwards} />
)}
{/* ── Combat Record ──────────────────────────────────── */} {/* ── Combat Record ──────────────────────────────────── */}
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -0,0 +1,132 @@
import { Award, Media } from "@/payload-types";
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 { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { AwardIcon, LockIcon } from "lucide-react";
/**
* Card shown on the awards listing page. Unlike the profile's AwardCard
* (which wraps an individual's grant), this reviews a single definable award
* type — ribbon or medal — so users can inspect the award art and its
* full requirements. Commendations are intentionally out of scope.
*
* Ribbon art is rendered at a fixed, centered width so every ribbon (and its
* container) is an identical, justified size across the grid. Medal art is
* currently hidden; only the ribbon art is shown.
*/
interface AwardListingCardProps {
award: Award;
awardImage?: Media | null;
showGrantButton?: boolean;
onGrantClick?: () => void;
canGrant?: boolean | null;
remainingGrants?: number | null;
}
export function AwardListingCard({
award,
awardImage,
showGrantButton = false,
onGrantClick,
canGrant,
remainingGrants,
}: AwardListingCardProps) {
const type = award.type ?? "commendation";
const isMedal = type === "medal";
const hasRibbonArt =
type === "ribbon" &&
award.ribbonDesign &&
typeof award.ribbonDesign === "object" &&
Array.isArray((award.ribbonDesign as RibbonDesign).stripes);
const hasArt = hasRibbonArt || Boolean(awardImage?.url);
return (
<Item
variant="outline"
className={cn(
"min-w-0 h-full flex flex-col flex-nowrap text-center",
isMedal && "justify-center medal-card",
)}
>
<ItemHeader className={cn("flex flex-col items-center gap-2", isMedal && "justify-center")}>
{hasArt && (
<div
className={cn(
"flex w-full items-center justify-center",
hasRibbonArt ? "h-20" : "h-16",
)}
>
{hasRibbonArt ? (
<div className="w-44 max-w-full">
<RibbonViewer
ribbonDesign={award.ribbonDesign as RibbonDesign}
showRibbonOnly={false}
/>
</div>
) : awardImage?.url ? (
<img src={awardImage.url} alt={award.name} className="size-16 object-contain" />
) : null}
</div>
)}
<ItemTitle className={cn("mt-1", isMedal && "text-lg text-shadow-lg text-shadow-white/15")}>
{award.name}
</ItemTitle>
<span className="text-[9px] uppercase tracking-wider text-muted-foreground font-mono">
{type}
</span>
</ItemHeader>
<ItemContent className="flex flex-col gap-2">
<ItemDescription className="min-w-0 break-words text-center text-sm leading-relaxed">
{award.description
? convertLexicalToPlaintext({ data: award.description })
: "No description available."}
</ItemDescription>
{award.experiencePoints != null && (
<span className="mt-1 font-mono text-sm text-muted-foreground">
+{award.experiencePoints} XP
</span>
)}
</ItemContent>
{showGrantButton && (
<div className="mt-auto w-full">
{canGrant === false ? (
<Button
variant="outline"
size="sm"
disabled
className="w-full opacity-50 cursor-not-allowed"
>
<LockIcon data-icon="inline-start" />
You cannot grant this award
</Button>
) : onGrantClick ? (
<Button
variant="outline"
size="sm"
onClick={onGrantClick}
className="w-full flex flex-col gap-0 h-full py-1.5"
>
<span className="flex flex-row gap-1">
<AwardIcon data-icon="inline-start" />
Award this
</span>
<span className="opacity-50">
{remainingGrants === undefined
? ""
: remainingGrants === null
? ` (\u221E grant${remainingGrants === 1 ? "" : "s"} remaining)`
: ` (${remainingGrants} grant${remainingGrants === 1 ? "" : "s"} remaining)`}{" "}
</span>
</Button>
) : null}
</div>
)}
</Item>
);
}

View file

@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import type { Award } from "@/payload-types";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
import { RibbonViewer } from "./RibbonViewer";
import { GrantAwardDialog } from "./GrantAwardDialog";
import { MedalIcon, RibbonIcon, ScrollTextIcon } from "lucide-react";
interface AwardPickerDialogProps {
awards: Award[];
recipientUserId: number;
recipientUsername: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onGranted?: () => void;
}
/**
* Grid of grantable awards. Selecting a card closes this dialog and opens the
* GrantAwardDialog for that award with the recipient pre-filled.
*/
export function AwardPickerDialog({
awards,
recipientUserId,
recipientUsername,
open,
onOpenChange,
onGranted,
}: AwardPickerDialogProps) {
const [selectedAward, setSelectedAward] = useState<Award | null>(null);
function pick(award: Award) {
setSelectedAward(award);
onOpenChange(false);
}
return (
<>
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) setSelectedAward(null);
onOpenChange(o);
}}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Select an award</DialogTitle>
<DialogDescription>
Choose which award to grant to @{recipientUsername}.
</DialogDescription>
</DialogHeader>
{awards.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No awards are available to grant.
</p>
) : (
<div className="grid max-h-[50vh] grid-cols-1 gap-3 overflow-y-auto sm:grid-cols-2 md:grid-cols-3">
{awards.map((award) => (
<button
key={award.id}
type="button"
onClick={() => pick(award)}
className="flex flex-col items-center gap-2 rounded-md border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-accent"
>
{award.type === "ribbon" && award.ribbonDesign ? (
<div className="flex h-16 w-full items-center justify-center">
<div className="w-32 max-w-full">
<RibbonViewer ribbonDesign={award.ribbonDesign as RibbonDesign} showRibbonOnly />
</div>
</div>
) : (
<span className="flex size-16 items-center justify-center rounded-md bg-muted">
{award.type === "medal" ? (
<MedalIcon className="size-6 text-muted-foreground" />
) : award.type === "commendation" ? (
<ScrollTextIcon className="size-6 text-muted-foreground" />
) : (
<RibbonIcon className="size-6 text-muted-foreground" />
)}
</span>
)}
<span className="line-clamp-2 text-center text-sm font-medium">
{award.name}
</span>
<Badge variant="secondary" className="w-fit capitalize">
{award.type ?? "award"}
</Badge>
</button>
))}
</div>
)}
</DialogContent>
</Dialog>
{selectedAward && (
<GrantAwardDialog
awardId={selectedAward.id}
awardName={selectedAward.name}
recipientUserId={recipientUserId}
recipientUsername={recipientUsername}
open
onOpenChange={(o) => {
if (!o) setSelectedAward(null);
}}
onGranted={onGranted}
/>
)}
</>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type { Award, Media } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { AwardListingCard } from "./AwardListingCard";
import { GrantAwardDialog } from "./GrantAwardDialog";
import { getGrantStats } from "@/app/(frontend)/awards/grant-context";
interface AwardWithGrantButtonProps {
award: Award;
awardImage?: Media | null;
}
export function AwardWithGrantButton({ award, awardImage }: AwardWithGrantButtonProps) {
const [open, setOpen] = useState(false);
const [canGrant, setCanGrant] = useState<boolean | null>(null);
const [remainingGrants, setRemainingGrants] = useState<number | null | undefined>(undefined);
const router = useRouter();
useEffect(() => {
let cancelled = false;
getGrantStats(award.id).then((res) => {
if (cancelled) return;
if (res.success && res.data) {
setCanGrant(res.data.canGrant);
setRemainingGrants(res.data.remainingGrants);
} else {
setCanGrant(null);
}
});
return () => {
cancelled = true;
};
}, [award.id]);
return (
<>
<AwardListingCard
award={award}
awardImage={awardImage}
showGrantButton
onGrantClick={() => setOpen(true)}
canGrant={canGrant}
remainingGrants={remainingGrants}
/>
<GrantAwardDialog
awardId={award.id}
awardName={award.name}
awardDescription={convertLexicalToPlaintext({ data: award.description })}
showRecipientPicker
open={open}
onOpenChange={setOpen}
onGranted={() => router.refresh()}
/>
</>
);
}

View file

@ -0,0 +1,263 @@
"use client";
import { useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { grantAward } from "@/app/(frontend)/awards/actions";
import { getGrantStats, type GrantStats } from "@/app/(frontend)/awards/grant-context";
import { RecipientPicker, type RecipientSelection } from "./RecipientPicker";
import { Loader2Icon, TriangleAlertIcon } from "lucide-react";
import { toast } from "sonner";
interface GrantAwardDialogProps {
awardId: number;
awardName: string;
awardDescription?: string;
recipientUserId?: number;
recipientUsername?: string;
showRecipientPicker?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
onGranted?: () => void;
}
/**
* Two-step grant flow: pick a recipient + reason, then confirm by typing the
* recipient's @username. Grant limits are pre-checked via getGrantStats so
* users see warnings before submitting.
*/
export function GrantAwardDialog({
awardId,
awardName,
awardDescription,
recipientUserId,
recipientUsername,
showRecipientPicker = false,
open,
onOpenChange,
onGranted,
}: GrantAwardDialogProps) {
const [step, setStep] = useState<1 | 2>(1);
const [recipient, setRecipient] = useState<RecipientSelection | null>(null);
const [reason, setReason] = useState("");
const [confirmText, setConfirmText] = useState("");
const [stats, setStats] = useState<GrantStats | null>(null);
const [statsError, setStatsError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset the flow each time the dialog opens; pre-fill a fixed recipient.
useEffect(() => {
if (!open) return;
setStep(1);
setReason("");
setConfirmText("");
setStats(null);
setStatsError(null);
setError(null);
setSubmitting(false);
setRecipient(
!showRecipientPicker && recipientUserId != null && recipientUsername
? { id: recipientUserId, username: recipientUsername }
: null,
);
}, [open, showRecipientPicker, recipientUserId, recipientUsername]);
// Load grant context for the award while the dialog is open.
useEffect(() => {
if (!open) return;
let cancelled = false;
getGrantStats(awardId).then((res) => {
if (cancelled) return;
if (res.success && res.data) setStats(res.data);
else setStatsError(res.error ?? "Failed to load grant stats.");
});
return () => {
cancelled = true;
};
}, [open, awardId]);
const expectedConfirm = recipient ? `@${recipient.username}` : null;
const confirmMatches = expectedConfirm !== null && confirmText.trim() === expectedConfirm;
const limited = stats?.remainingGrants != null;
const lowRemaining = limited && (stats?.remainingGrants ?? 0) <= 2;
async function handleGrant() {
if (!recipient || !confirmMatches || submitting) return;
setSubmitting(true);
setError(null);
const res = await grantAward({
recipientUserId: recipient.id,
awardId,
reason: reason.trim() ? reason.trim() : undefined,
});
if (res.success) {
toast.success("Award granted", {
description: `${awardName} granted to @${recipient.username}.`,
});
onGranted?.();
onOpenChange(false);
} else {
setError(res.error ?? "Failed to grant award.");
setSubmitting(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!submitting) onOpenChange(o);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Grant {awardName}</DialogTitle>
<DialogDescription>
{showRecipientPicker
? "Select a recipient, then confirm the grant."
: `Granting to @${recipientUsername}.`}
{stats && stats.canGrant && (
<span className="block mt-1 text-xs opacity-50">
{stats.remainingGrants === null
? "Unlimited grants available."
: `${stats.remainingGrants} grant${stats.remainingGrants === 1 ? "" : "s"} remaining.`}
</span>
)}
</DialogDescription>
</DialogHeader>
{step === 1 ? (
<div className="flex flex-col gap-4">
{awardDescription && (
<p className="text-sm text-muted-foreground">{awardDescription}</p>
)}
{showRecipientPicker ? (
<div className="flex flex-col gap-2">
<Label>Recipient</Label>
<RecipientPicker
selectedUser={recipient}
onSelect={setRecipient}
onClear={() => setRecipient(null)}
/>
</div>
) : (
<p className="text-sm">
Recipient: <span className="font-medium">@{recipientUsername}</span>
</p>
)}
<div className="flex flex-col gap-2">
<Label>Reason (optional)</Label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
placeholder="Citation or reason for the award..."
/>
</div>
{statsError && (
<Alert variant="destructive">
<TriangleAlertIcon data-icon="inline-start" />
<AlertTitle>Could not verify grant limits</AlertTitle>
<AlertDescription>{statsError}</AlertDescription>
</Alert>
)}
{stats && !stats.canGrant && (
<Alert variant="destructive">
<TriangleAlertIcon data-icon="inline-start" />
<AlertTitle>You are not authorized to grant this award</AlertTitle>
<AlertDescription>
Your current role and assignments do not allow you to grant this award.
</AlertDescription>
</Alert>
)}
{stats && stats.canGrant && lowRemaining && (
<Alert>
<TriangleAlertIcon data-icon="inline-start" />
<AlertTitle>Limited award</AlertTitle>
<AlertDescription>
You have {stats.remainingGrants} grant
{(stats.remainingGrants ?? 0) === 1 ? "" : "s"} remaining for this award.
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => setStep(2)}
disabled={!recipient || (stats !== null && !stats.canGrant)}
>
Continue
</Button>
</DialogFooter>
</div>
) : (
<div className="flex flex-col gap-4">
<Alert>
<TriangleAlertIcon data-icon="inline-start" />
<AlertTitle>Confirm grant</AlertTitle>
<AlertDescription>
You are about to grant "{awardName}" to @{recipient?.username}. This action cannot
be undone.
</AlertDescription>
</Alert>
{limited && (
<p className="text-sm text-muted-foreground">
{(stats?.remainingGrants ?? 0) <= 1
? "This will use your LAST grant for this award."
: `Grants remaining after this: ${(stats?.remainingGrants ?? 0) - 1}.`}
</p>
)}
<div className="flex flex-col gap-2">
<Label>Type @{recipient?.username} to confirm</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={`@${recipient?.username}`}
autoFocus
/>
</div>
{error && (
<Alert variant="destructive">
<TriangleAlertIcon data-icon="inline-start" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setStep(1)} disabled={submitting}>
Back
</Button>
<Button onClick={handleGrant} disabled={!confirmMatches || submitting}>
{submitting && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
Confirm grant
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,214 @@
import type { CSSProperties } from "react";
/**
* Frontend-safe SVG renderer for a medal's art. Mirrors the admin
* `MedalDesigner` preview so the award listing page can show medal art
* without pulling in Payload admin UI dependencies. Self-contained — the
* shape/finish constants intentionally duplicate the admin module so this
* file stays client- and server-safe.
*/
export type MedalShape = "circle" | "star" | "cross" | "shield";
export type MetalFinish = "gold" | "silver" | "bronze" | "copper" | "darkSteel";
export interface MedalDesign {
ribbonStripes: { color: string; width: number }[];
shape: MedalShape;
finish: MetalFinish;
engraving?: string;
}
const FINISH_INFO: Record<MetalFinish, { colors: string[] }> = {
gold: { colors: ["#fbbf24", "#ca8a04", "#92400e"] },
silver: { colors: ["#e5e7eb", "#9ca3af", "#6b7280"] },
bronze: { colors: ["#d97706", "#92400e", "#78350f"] },
copper: { colors: ["#f97316", "#c2410c", "#7c2d12"] },
darkSteel: { colors: ["#6b7280", "#4b5563", "#374151"] },
};
const RIBBON_WIDTH = 366;
function isMedalDesign(value: unknown): value is MedalDesign {
return (
!!value &&
typeof value === "object" &&
Array.isArray((value as MedalDesign).ribbonStripes) &&
((value as MedalDesign).ribbonStripes as unknown[]).length > 0
);
}
function MedalBody({
shape,
finish,
engraving,
cx,
cy,
}: {
shape: MedalShape;
finish: MetalFinish;
engraving: string;
cx: number;
cy: number;
}) {
const finishColors = FINISH_INFO[finish].colors;
const gradId = `medal-grad-${finish}`;
const r = 40;
return (
<g>
<defs>
<radialGradient id={gradId} cx="40%" cy="35%">
<stop offset="0%" stopColor={finishColors[0]} />
<stop offset="60%" stopColor={finishColors[1]} />
<stop offset="100%" stopColor={finishColors[2]} />
</radialGradient>
</defs>
{shape === "circle" && (
<circle
cx={cx}
cy={cy}
r={r}
fill={`url(#${gradId})`}
stroke={finishColors[2]}
strokeWidth={1.5}
/>
)}
{shape === "star" && (
<polygon
points={Array.from({ length: 10 }, (_, i) => {
const angle = Math.PI / 2 + (i * Math.PI) / 5;
const radius = i % 2 === 0 ? r : r * 0.45;
return `${cx + radius * Math.cos(angle)},${cy - radius * Math.sin(angle)}`;
}).join(" ")}
fill={`url(#${gradId})`}
stroke={finishColors[2]}
strokeWidth={1.5}
/>
)}
{shape === "cross" && (
<path
d={`M${cx - 12},${cy - r} L${cx + 12},${cy - r} L${cx + 12},${cy - 12}
L${cx + r},${cy - 12} L${cx + r},${cy + 12} L${cx + 12},${cy + 12}
L${cx + 12},${cy + r} L${cx - 12},${cy + r} L${cx - 12},${cy + 12}
L${cx - r},${cy + 12} L${cx - r},${cy - 12} L${cx - 12},${cy - 12}Z`}
fill={`url(#${gradId})`}
stroke={finishColors[2]}
strokeWidth={1.5}
/>
)}
{shape === "shield" && (
<path
d={`M${cx},${cy - r}
Q${cx + r},${cy - r * 0.6} ${cx + r * 0.9},${cy - r * 0.1}
L${cx},${cy + r}
L${cx - r * 0.9},${cy - r * 0.1}
Q${cx - r},${cy - r * 0.6} ${cx},${cy - r}Z`}
fill={`url(#${gradId})`}
stroke={finishColors[2]}
strokeWidth={1.5}
/>
)}
{engraving && (
<text
x={cx}
y={cy + 2}
textAnchor="middle"
dominantBaseline="central"
fill={finishColors[2]}
fontSize={engraving.length > 10 ? 6 : 8}
fontFamily="serif"
fontWeight="bold"
letterSpacing={0.5}
>
{engraving.toUpperCase()}
</text>
)}
</g>
);
}
export function MedalViewer({
medalDesign,
className,
style,
}: {
medalDesign: MedalDesign | unknown;
className?: string;
style?: CSSProperties;
}) {
const design = isMedalDesign(medalDesign) ? medalDesign : null;
if (!design) return null;
const { ribbonStripes, shape, finish, engraving = "" } = design;
const totalWeight = ribbonStripes.reduce((s, stripe) => s + stripe.width, 0);
const viewWidth = RIBBON_WIDTH;
const ribbonTop = 14;
const ribbonBottom = 72;
const convergeRatio = 0.35;
const medalCx = viewWidth / 2;
const medalCy = 120;
const ribbonViewWidth = RIBBON_WIDTH;
const offset = 0;
return (
<svg
viewBox={`0 0 ${viewWidth} 175`}
className={className ?? "w-full h-auto rounded border border-zinc-700 bg-zinc-950"}
style={{ maxHeight: 220, ...style }}
>
{(() => {
const topStartX = offset;
const bottomCenter = medalCx;
const topWidth = ribbonViewWidth;
const bottomWidth = topWidth * convergeRatio;
let topX = topStartX;
return ribbonStripes.map((stripe, i) => {
const stripeW = (stripe.width / totalWeight) * ribbonViewWidth;
const fracStart = (topX - topStartX) / topWidth;
const fracEnd = (topX + stripeW - topStartX) / topWidth;
const tlx = topX;
const trx = topX + stripeW;
const blx = bottomCenter - bottomWidth / 2 + fracStart * bottomWidth;
const brx = bottomCenter - bottomWidth / 2 + fracEnd * bottomWidth;
topX += stripeW;
return (
<polygon
key={i}
points={`${tlx},${ribbonTop} ${trx},${ribbonTop} ${brx},${ribbonBottom} ${blx},${ribbonBottom}`}
fill={stripe.color}
/>
);
});
})()}
{/* Small connector tab between ribbon and medal */}
<rect
x={medalCx - 4}
y={ribbonBottom}
width={8}
height={8}
rx={2}
fill="#9ca3af"
/>
{/* Medal body */}
<MedalBody
shape={shape}
finish={finish}
engraving={engraving}
cx={medalCx}
cy={medalCy}
/>
</svg>
);
}

View file

@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
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";
interface ProfileGrantAwardButtonProps {
userId: number;
username: string;
awards: Award[];
}
/**
* Profile-page section that lets viewers grant an award to the profile owner.
* Eligibility is enforced server-side (getGrantStats / grantAward inside
* GrantAwardDialog) — this component only provides the entry point.
*/
export function ProfileGrantAwardButton({ userId, username, awards }: ProfileGrantAwardButtonProps) {
const [open, setOpen] = useState(false);
const router = useRouter();
return (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<AwardIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Grant Award
</h2>
</div>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<PlusIcon data-icon="inline-start" />
Grant Award
</Button>
</div>
<AwardPickerDialog
awards={awards}
recipientUserId={userId}
recipientUsername={username}
open={open}
onOpenChange={setOpen}
onGranted={() => router.refresh()}
/>
</section>
);
}

View file

@ -0,0 +1,254 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { Assignment, Media, Profile } from "@/payload-types";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { Loader2Icon, SearchIcon, XIcon } from "lucide-react";
export interface RecipientSelection {
id: number;
username: string;
displayName?: string | null;
profileId?: number;
}
interface RecipientPickerProps {
onSelect: (user: RecipientSelection) => void;
selectedUser: RecipientSelection | null;
onClear: () => void;
}
interface SearchResult extends RecipientSelection {
rankName?: string | null;
insigniaUrl?: string | null;
assignmentLabel?: string | null;
}
function initialsOf(username: string): string {
return username.slice(0, 2).toUpperCase();
}
/**
* Searchable recipient picker. Queries the profiles REST API (users have no
* `profile` relationship, so searching /api/users would not surface rank
* data) and enriches each hit with the user's primary assignment.
*/
export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPickerProps) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const wrapperRef = useRef<HTMLDivElement>(null);
const searchSeq = useRef(0);
// Debounced search with a sequence guard so stale responses are dropped.
useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < 1) {
setResults([]);
setSearching(false);
return;
}
const seq = ++searchSeq.current;
setSearching(true);
const timer = setTimeout(async () => {
try {
const q = encodeURIComponent(trimmed);
const url = `/api/profiles?where[or][0][user.username][like]=${q}&where[or][1][user.displayName][like]=${q}&limit=10&depth=2`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error("Search failed");
const data = (await res.json()) as { docs?: unknown[] };
const mapped: SearchResult[] = [];
for (const doc of data.docs ?? []) {
const profile = doc as Profile;
const userDoc = profile.user;
if (typeof userDoc !== "object" || !userDoc) continue;
const rank = typeof profile.rank === "object" && profile.rank ? profile.rank : null;
mapped.push({
id: userDoc.id,
username: userDoc.username,
displayName: userDoc.displayName,
profileId: profile.id,
rankName: rank?.name ?? null,
insigniaUrl:
rank && typeof rank.insignia === "object" && rank.insignia
? (rank.insignia as Media).url
: null,
});
}
// Enrich with each user's primary assignment (best-effort).
const assignmentResults = await Promise.allSettled(
mapped.map((r) =>
fetch(`/api/assignments?where[members][equals]=${r.id}&limit=1&depth=0`, {
cache: "no-store",
})
.then((res) => (res.ok ? res.json() : null))
.then((json) => {
const doc = (json as { docs?: unknown[] } | null)?.docs?.[0];
if (!doc || typeof doc !== "object") return null;
const assignment = doc as Assignment;
return assignment.callsign ? `[${assignment.callsign}] ${assignment.name}` : assignment.name;
}),
),
);
assignmentResults.forEach((res, i) => {
if (res.status === "fulfilled" && res.value) mapped[i].assignmentLabel = res.value;
});
if (seq !== searchSeq.current) return;
setResults(mapped);
setActiveIndex(0);
} catch {
if (seq === searchSeq.current) {
setResults([]);
}
} finally {
if (seq === searchSeq.current) setSearching(false);
}
}, 300);
return () => clearTimeout(timer);
}, [query]);
// Close the dropdown on outside click.
useEffect(() => {
if (!open) return;
function handlePointerDown(e: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, [open]);
function select(result: SearchResult) {
onSelect({
id: result.id,
username: result.username,
displayName: result.displayName,
profileId: result.profileId,
});
setQuery("");
setOpen(false);
}
if (selectedUser) {
return (
<div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2">
<Avatar className="size-7">
<AvatarFallback>{initialsOf(selectedUser.username)}</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">
{selectedUser.displayName || selectedUser.username}
</span>
<span className="text-sm text-muted-foreground">@{selectedUser.username}</span>
<button
type="button"
aria-label="Clear recipient"
onClick={onClear}
className="ml-auto rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<XIcon className="size-4" />
</button>
</div>
);
}
return (
<div ref={wrapperRef} className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
return;
}
if (!open || results.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const active = results[activeIndex];
if (active) select(active);
}
}}
placeholder="Search by username or display name..."
className="pl-8"
role="combobox"
aria-expanded={open}
aria-controls="recipient-picker-results"
/>
{open && query.trim().length > 0 && (
<div
id="recipient-picker-results"
role="listbox"
className="absolute z-50 mt-1 max-h-80 w-full overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md"
>
{searching ? (
<div className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
Searching...
</div>
) : results.length === 0 ? (
<div className="px-3 py-3 text-sm text-muted-foreground">No users found.</div>
) : (
results.map((result, i) => (
<button
key={result.id}
type="button"
role="option"
aria-selected={i === activeIndex}
onMouseDown={(e) => e.preventDefault()}
onClick={() => select(result)}
onMouseEnter={() => setActiveIndex(i)}
className={cn(
"flex w-full items-center gap-3 px-3 py-2 text-left",
i === activeIndex && "bg-accent",
)}
>
<Avatar className="size-8">
{result.insigniaUrl ? (
<AvatarImage src={result.insigniaUrl} alt="" />
) : (
<AvatarFallback>{initialsOf(result.username)}</AvatarFallback>
)}
</Avatar>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">
{result.displayName || result.username}
</span>
<span className="truncate text-xs text-muted-foreground">
@{result.username}
{result.rankName ? ` · ${result.rankName}` : ""}
</span>
</span>
{result.assignmentLabel && (
<span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{result.assignmentLabel}
</span>
)}
</button>
))
)}
</div>
)}
</div>
);
}

View file

@ -6,7 +6,7 @@ import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintex
export const RibbonRack = ({ awards }: { awards: Award[] }) => { export const RibbonRack = ({ awards }: { awards: Award[] }) => {
return ( return (
<div className="flex flex-row w-52 gap-1"> <div className="flex flex-wrap w-[19.625rem] max-w-full gap-x-1 gap-y-px">
{awards {awards
.filter((award) => award.type === "ribbon" && award.ribbonDesign != null) .filter((award) => award.type === "ribbon" && award.ribbonDesign != null)
.sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0)) .sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0))
@ -14,7 +14,7 @@ export const RibbonRack = ({ awards }: { awards: Award[] }) => {
const ribbon = award.ribbonDesign as RibbonDesign; const ribbon = award.ribbonDesign as RibbonDesign;
return ( return (
<Tooltip key={key}> <Tooltip key={key}>
<TooltipTrigger className="w-full max-h-6.75 hover:scale-110 shadow-black shadow-xs hover:shadow-xl transition-transform"> <TooltipTrigger className="w-[6.375rem] max-w-full h-[1.6875rem] hover:scale-110 shadow-black shadow-xs hover:shadow-xl transition-transform">
<RibbonViewer ribbonDesign={ribbon} showRibbonOnly /> <RibbonViewer ribbonDesign={ribbon} showRibbonOnly />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>

View file

@ -2,6 +2,7 @@
import * as React from "react"; import * as React from "react";
import { import {
Award,
Backpack, Backpack,
Bolt, Bolt,
Crosshair, Crosshair,
@ -134,6 +135,11 @@ const data = {
}, },
], ],
navPersonal: [ navPersonal: [
{
title: "Awards",
url: "/awards",
icon: Award,
},
{ {
title: "Locker", title: "Locker",
url: "/locker", url: "/locker",

178
src/lib/awards/grant.ts Normal file
View file

@ -0,0 +1,178 @@
import type { Payload } from "payload";
import type { Assignment, Profile } from "@/payload-types";
type AssignmentType = Assignment["type"];
type RecipientScope = "same_command" | "whitelist" | "anyone";
const MAX_TREE_DEPTH = 5;
/** Extract the numeric id from a relationship reference (raw id or populated doc). */
function refId(ref: number | { id?: unknown } | null | undefined): number | null {
if (typeof ref === "number") return ref;
if (ref && typeof ref.id === "number") return ref.id;
return null;
}
/**
* Assignments where the given user is the leader and whose type is in
* `allowedTypes`. An empty `allowedTypes` means no leader may grant.
*/
export async function getGrantorAssignments(
payload: Payload,
userId: number,
allowedTypes: readonly AssignmentType[],
): Promise<Assignment[]> {
if (allowedTypes.length === 0) return [];
const res = await payload.find({
collection: "assignments",
where: {
and: [
{ leader: { equals: userId } },
{ type: { in: [...allowedTypes] } },
],
},
limit: 100,
depth: 0,
overrideAccess: true,
});
return res.docs;
}
async function collectMembers(
payload: Payload,
assignmentId: number,
depth: number,
collected: Set<number>,
): Promise<void> {
if (depth > MAX_TREE_DEPTH) return;
let assignment: Assignment | null = null;
try {
assignment = await payload.findByID({
collection: "assignments",
id: assignmentId,
depth: 0,
overrideAccess: true,
});
} catch {
return;
}
if (!assignment) return;
const leaderId = refId(assignment.leader);
if (leaderId !== null) collected.add(leaderId);
for (const member of assignment.members ?? []) {
const id = refId(member);
if (id !== null) collected.add(id);
}
for (const sub of assignment.subAssignments ?? []) {
const id = refId(sub);
if (id !== null) await collectMembers(payload, id, depth + 1, collected);
}
}
/**
* All member user ids under an assignment: its leader, its direct members,
* and (recursively) the members of its sub-assignments. Depth-capped to
* prevent infinite loops on circular sub-assignment graphs.
*/
export async function getAssignmentTreeMembers(
payload: Payload,
assignmentId: number,
depth = 0,
): Promise<number[]> {
const collected = new Set<number>();
await collectMembers(payload, assignmentId, depth, collected);
return [...collected];
}
/**
* How many times `grantorProfileId` has already issued `awardId` across all
* profiles. Scans every profile (depth 0) and counts matching grant rows.
*/
export async function countGrantsByGrantor(
payload: Payload,
awardId: number,
grantorProfileId: number,
): Promise<number> {
const res = await payload.find({
collection: "profiles",
limit: 1000,
depth: 0,
overrideAccess: true,
});
let count = 0;
for (const profile of res.docs) {
for (const entry of profile.progression?.awards ?? []) {
if (refId(entry.award) === awardId && refId(entry.awardedBy) === grantorProfileId) {
count += 1;
}
}
}
return count;
}
async function isDirectMember(
payload: Payload,
assignmentId: number,
userId: number,
): Promise<boolean> {
let assignment: Assignment | null = null;
try {
assignment = await payload.findByID({
collection: "assignments",
id: assignmentId,
depth: 0,
overrideAccess: true,
});
} catch {
return false;
}
if (!assignment) return false;
if (refId(assignment.leader) === userId) return true;
return (assignment.members ?? []).some((m) => refId(m) === userId);
}
/**
* Whether `recipientUserId` may receive an award under the given scope.
* - anyone: always eligible
* - whitelist: a direct member of any assignment in `whitelistIds`
* - same_command: a member of the grantor's assignment tree
*/
export async function isRecipientEligible(
payload: Payload,
recipientUserId: number,
scope: RecipientScope | null | undefined,
grantorAssignmentId?: number,
whitelistIds?: (number | Assignment)[] | null,
): Promise<boolean> {
if (!scope || scope === "anyone") return true;
if (scope === "whitelist") {
const ids = (whitelistIds ?? [])
.map((ref) => refId(ref))
.filter((id): id is number => id !== null);
for (const assignmentId of ids) {
if (await isDirectMember(payload, assignmentId, recipientUserId)) return true;
}
return false;
}
if (!grantorAssignmentId) return false;
const members = await getAssignmentTreeMembers(payload, grantorAssignmentId);
return members.includes(recipientUserId);
}
/** The profile belonging to `userId`, or null if none exists. */
export async function findUserProfile(
payload: Payload,
userId: number,
): Promise<Profile | null> {
const res = await payload.find({
collection: "profiles",
where: { user: { equals: userId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
return res.docs[0] ?? null;
}

View file

@ -92,6 +92,9 @@ export const EventTypes = {
// Evaluations // Evaluations
EvaluationSubmit: "evaluation:submit", EvaluationSubmit: "evaluation:submit",
// Awards
AwardGrant: "award:grant",
// Missions // Missions
MissionAutoComplete: "mission:auto-complete", MissionAutoComplete: "mission:auto-complete",
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle", MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",