1
0
Fork 0

feat(awards): support bulk recipient selection in award grant flow

Refactors grantAward to accept an array of recipientUserIds, adds
duplicate-award and eligibility checks per recipient, and returns a
per-recipient result summary. GrantAwardDialog gains a third step
showing grant results when some recipients fail. RecipientPicker is
rewritten to support multi-select with chip-based selected state and
a maxSelections cap.
This commit is contained in:
Jason Fraley 2026-09-02 16:14:07 -04:00
parent d952a33ee7
commit 2e1fbf8006
3 changed files with 403 additions and 196 deletions

View file

@ -12,7 +12,8 @@ import {
getGrantorAssignments,
isRecipientEligible,
} from "@/lib/awards/grant";
import type { Profile } from "@/payload-types";
import { cleanupDuplicateAwards, type CleanupResult } from "@/lib/awards/index";
import type { Assignment, Profile } from "@/payload-types";
interface ActionResult<T = undefined> {
success: boolean;
@ -37,14 +38,24 @@ async function authenticate() {
return { payload, user };
}
export async function grantAward(input: {
export interface GrantResult {
recipientUserId: number;
success: boolean;
error?: string;
}
export async function grantAward(input: {
recipientUserIds: number[];
awardId: number;
reason?: string;
}): Promise<ActionResult> {
}): Promise<ActionResult<{ results: GrantResult[]; granted: number; failed: number }>> {
try {
const { payload, user } = await authenticate();
if (input.recipientUserIds.length === 0) {
return { success: false, error: "No recipients selected." };
}
let award;
try {
award = await payload.findByID({
@ -60,11 +71,17 @@ export async function grantAward(input: {
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 isMedal = award.type === "medal";
if (isMedal && !superuser) {
return { success: false, error: "Only superusers can grant medals." };
}
let grantorAssignmentId: number | undefined;
let scope: "same_command" | "whitelist" | "anyone" | null | undefined;
let whitelist: (number | Assignment)[] | null | undefined;
let remaining: number | null = null;
if (!superuser && !isMedal) {
const allowedTypes = award.grantConfig?.allowedGrantorAssignmentTypes ?? [];
if (allowedTypes.length === 0) {
return { success: false, error: "This award cannot be granted by leaders." };
@ -78,7 +95,9 @@ export async function grantAward(input: {
if (grantorAssignments.length === 0) {
return { success: false, error: "You are not authorized to grant this award." };
}
const grantorAssignmentId = grantorAssignments[0].id;
grantorAssignmentId = grantorAssignments[0].id;
scope = award.grantConfig?.recipientScope ?? "same_command";
whitelist = award.grantConfig?.eligibleRecipientAssignments;
const maxGrants = award.grantConfig?.maxGrantsPerGrantor ?? null;
if (maxGrants) {
@ -87,29 +106,14 @@ export async function grantAward(input: {
return { success: false, error: "You do not have a profile." };
}
const issued = await countGrantsByGrantor(payload, award.id, grantorProfile.id);
if (issued >= maxGrants) {
remaining = maxGrants - issued;
if (remaining <= 0) {
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) {
@ -119,37 +123,115 @@ export async function grantAward(input: {
return { success: false, error: "You do not have a profile." };
}
const progression = recipientProfile.progression;
const currentAwards = progression?.awards ?? [];
const results: GrantResult[] = [];
let granted = 0;
let processed = 0;
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,
},
],
for (const recipientUserId of input.recipientUserIds) {
if (remaining !== null && processed >= remaining) break;
if (grantorAssignmentId !== undefined) {
const eligible = await isRecipientEligible(
payload,
recipientUserId,
scope,
grantorAssignmentId,
whitelist,
);
if (!eligible) {
results.push({
recipientUserId,
success: false,
error: "Recipient is not eligible for this award.",
});
processed += 1;
continue;
}
}
const recipientProfile = await findUserProfile(payload, recipientUserId);
if (!recipientProfile) {
results.push({
recipientUserId,
success: false,
error: "Recipient does not have a profile.",
});
processed += 1;
continue;
}
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.",
});
processed += 1;
continue;
}
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,
});
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 },
});
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 };
results.push({ recipientUserId, success: true });
granted += 1;
processed += 1;
}
return {
success: true,
data: { results, granted, failed: results.length - granted },
};
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
}
}
export async function cleanupDuplicateAwardsAction(): Promise<
ActionResult<CleanupResult>
> {
try {
const { payload, user } = await authenticate();
const superuser = await isSuperuser(payload, user);
if (!superuser) {
return { success: false, error: "Only superusers can run award cleanup." };
}
const result = await cleanupDuplicateAwards(payload);
return { success: true, data: result };
} catch (e) {
return {
success: false,

View file

@ -14,10 +14,10 @@ import {
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 { grantAward, type GrantResult } 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 { CheckIcon, Loader2Icon, TriangleAlertIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
interface GrantAwardDialogProps {
@ -33,9 +33,10 @@ interface GrantAwardDialogProps {
}
/**
* 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.
* Two-step grant flow: pick recipient(s) + reason, then confirm by typing the
* award name. Grant limits are pre-checked via getGrantStats so selection is
* capped and users see warnings before submitting. Individual ineligible
* recipients are skipped and reported after the fact.
*/
export function GrantAwardDialog({
awardId,
@ -48,14 +49,19 @@ export function GrantAwardDialog({
onOpenChange,
onGranted,
}: GrantAwardDialogProps) {
const [step, setStep] = useState<1 | 2>(1);
const [recipient, setRecipient] = useState<RecipientSelection | null>(null);
const [step, setStep] = useState<1 | 2 | 3>(1);
const [recipients, setRecipients] = useState<RecipientSelection[]>([]);
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);
const [results, setResults] = useState<GrantResult[] | null>(null);
// Map a recipient id back to its username for result rendering.
const usernameOf = (id: number) =>
recipients.find((r) => r.id === id)?.username ?? String(id);
// Reset the flow each time the dialog opens; pre-fill a fixed recipient.
useEffect(() => {
@ -67,10 +73,11 @@ export function GrantAwardDialog({
setStatsError(null);
setError(null);
setSubmitting(false);
setRecipient(
setResults(null);
setRecipients(
!showRecipientPicker && recipientUserId != null && recipientUsername
? { id: recipientUserId, username: recipientUsername }
: null,
? [{ id: recipientUserId, username: recipientUsername }]
: [],
);
}, [open, showRecipientPicker, recipientUserId, recipientUsername]);
@ -88,26 +95,36 @@ export function GrantAwardDialog({
};
}, [open, awardId]);
const expectedConfirm = recipient ? `@${recipient.username}` : null;
const confirmMatches = expectedConfirm !== null && confirmText.trim() === expectedConfirm;
const expectedConfirm = awardName;
const confirmMatches = confirmText.trim() === expectedConfirm;
const limited = stats?.remainingGrants != null;
const maxSelections = limited ? stats?.remainingGrants ?? null : null;
const lowRemaining = limited && (stats?.remainingGrants ?? 0) <= 2;
async function handleGrant() {
if (!recipient || !confirmMatches || submitting) return;
if (recipients.length === 0 || !confirmMatches || submitting) return;
setSubmitting(true);
setError(null);
const res = await grantAward({
recipientUserId: recipient.id,
recipientUserIds: recipients.map((r) => r.id),
awardId,
reason: reason.trim() ? reason.trim() : undefined,
});
if (res.success) {
toast.success("Award granted", {
description: `${awardName} granted to @${recipient.username}.`,
});
onGranted?.();
onOpenChange(false);
if (res.success && res.data) {
const { failed } = res.data;
if (failed === 0) {
toast.success("Award granted", {
description: `${awardName} granted to ${recipients.length} recipient${
recipients.length === 1 ? "" : "s"
}.`,
});
onGranted?.();
onOpenChange(false);
} else {
setResults(res.data.results);
setStep(3);
setSubmitting(false);
}
} else {
setError(res.error ?? "Failed to grant award.");
setSubmitting(false);
@ -125,10 +142,12 @@ export function GrantAwardDialog({
<DialogHeader>
<DialogTitle>Grant {awardName}</DialogTitle>
<DialogDescription>
{showRecipientPicker
? "Select a recipient, then confirm the grant."
: `Granting to @${recipientUsername}.`}
{stats && stats.canGrant && (
{step === 3
? "Grant summary."
: showRecipientPicker
? "Select recipients, then confirm the grant."
: `Granting to @${recipientUsername}.`}
{step !== 3 && stats && stats.canGrant && (
<span className="block mt-1 text-xs opacity-50">
{stats.remainingGrants === null
? "Unlimited grants available."
@ -146,11 +165,18 @@ export function GrantAwardDialog({
{showRecipientPicker ? (
<div className="flex flex-col gap-2">
<Label>Recipient</Label>
<Label>Recipients</Label>
<RecipientPicker
selectedUser={recipient}
onSelect={setRecipient}
onClear={() => setRecipient(null)}
selectedUsers={recipients}
onSelect={(u) =>
setRecipients((prev) =>
prev.some((r) => r.id === u.id) ? prev : [...prev, u],
)
}
onRemove={(id) =>
setRecipients((prev) => prev.filter((r) => r.id !== id))
}
maxSelections={maxSelections ?? undefined}
/>
</div>
) : (
@ -204,37 +230,53 @@ export function GrantAwardDialog({
</Button>
<Button
onClick={() => setStep(2)}
disabled={!recipient || (stats !== null && !stats.canGrant)}
disabled={recipients.length === 0 || (stats !== null && !stats.canGrant)}
>
Continue
</Button>
</DialogFooter>
</div>
) : (
) : step === 2 ? (
<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.
You are about to grant "{awardName}" to{" "}
{recipients.length === 1
? `@${recipients[0].username}`
: `${recipients.length} recipients`}
. This action cannot be undone.
</AlertDescription>
</Alert>
{showRecipientPicker && recipients.length > 1 && (
<ul className="flex flex-wrap gap-1.5 text-sm">
{recipients.map((r) => (
<li
key={r.id}
className="rounded-full border bg-muted/40 px-2 py-0.5 text-xs"
>
@{r.username}
</li>
))}
</ul>
)}
{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}.`}
{maxSelections != null && maxSelections <= recipients.length
? "This will use ALL of your remaining grants for this award."
: `Grants remaining after this: ${(stats?.remainingGrants ?? 0) - recipients.length}.`}
</p>
)}
<div className="flex flex-col gap-2">
<Label>Type @{recipient?.username} to confirm</Label>
<Label>Type the award name to confirm</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={`@${recipient?.username}`}
placeholder={awardName}
autoFocus
/>
</div>
@ -256,6 +298,54 @@ export function GrantAwardDialog({
</Button>
</DialogFooter>
</div>
) : (
<div className="flex flex-col gap-4">
{results && (
<>
<Alert variant="destructive">
<TriangleAlertIcon data-icon="inline-start" />
<AlertTitle>
{results.filter((r) => !r.success).length} of {results.length} grants could
not be completed
</AlertTitle>
<AlertDescription>
The following recipients were skipped; the rest were granted successfully.
</AlertDescription>
</Alert>
<ul className="flex flex-col gap-1.5 text-sm">
{results.map((r) => (
<li key={r.recipientUserId} className="flex items-start gap-2">
{r.success ? (
<CheckIcon className="mt-0.5 size-4 shrink-0 text-emerald-500" />
) : (
<XIcon className="mt-0.5 size-4 shrink-0 text-red-500" />
)}
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">
@{usernameOf(r.recipientUserId)}
</span>
{!r.success && r.error && (
<span className="text-xs text-muted-foreground">{r.error}</span>
)}
</span>
</li>
))}
</ul>
</>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
onGranted?.();
onOpenChange(false);
}}
>
Done
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>

View file

@ -15,9 +15,11 @@ export interface RecipientSelection {
}
interface RecipientPickerProps {
selectedUsers: RecipientSelection[];
onSelect: (user: RecipientSelection) => void;
selectedUser: RecipientSelection | null;
onClear: () => void;
onRemove: (id: number) => void;
maxSelections?: number;
disabled?: boolean;
}
interface SearchResult extends RecipientSelection {
@ -35,7 +37,13 @@ function initialsOf(username: string): string {
* `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) {
export function RecipientPicker({
selectedUsers,
onSelect,
onRemove,
maxSelections,
disabled = false,
}: RecipientPickerProps) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
@ -136,119 +144,146 @@ export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPi
profileId: result.profileId,
});
setQuery("");
setOpen(false);
setActiveIndex(0);
}
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>
);
}
const atMax = maxSelections != null && selectedUsers.length >= maxSelections;
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) => (
<div ref={wrapperRef} className="flex flex-col gap-2">
{selectedUsers.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedUsers.map((u) => (
<span
key={u.id}
className="inline-flex items-center gap-1.5 rounded-full border bg-muted/40 py-1 pl-1 pr-1.5 text-sm"
>
<Avatar className="size-5">
<AvatarFallback className="text-[10px]">
{initialsOf(u.username)}
</AvatarFallback>
</Avatar>
<span className="max-w-[10rem] truncate font-medium">
{u.displayName || u.username}
</span>
<span className="truncate text-xs text-muted-foreground">@{u.username}</span>
<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",
)}
aria-label={`Remove ${u.username}`}
disabled={disabled}
onClick={() => onRemove(u.id)}
className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
>
<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>
)}
<XIcon className="size-3.5" />
</button>
))
)}
</span>
))}
</div>
)}
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
disabled={disabled || atMax}
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={
atMax
? "Maximum recipients selected"
: "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) => {
const alreadySelected = selectedUsers.some((u) => u.id === result.id);
return (
<button
key={result.id}
type="button"
role="option"
aria-selected={i === activeIndex}
disabled={alreadySelected || atMax}
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",
(alreadySelected || atMax) && "cursor-not-allowed opacity-50",
)}
>
<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>
)}
{alreadySelected && (
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
Selected
</span>
)}
</button>
);
})
)}
</div>
)}
</div>
</div>
);
}