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, getGrantorAssignments,
isRecipientEligible, isRecipientEligible,
} from "@/lib/awards/grant"; } 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> { interface ActionResult<T = undefined> {
success: boolean; success: boolean;
@ -37,14 +38,24 @@ async function authenticate() {
return { payload, user }; return { payload, user };
} }
export async function grantAward(input: { export interface GrantResult {
recipientUserId: number; recipientUserId: number;
success: boolean;
error?: string;
}
export async function grantAward(input: {
recipientUserIds: number[];
awardId: number; awardId: number;
reason?: string; reason?: string;
}): Promise<ActionResult> { }): Promise<ActionResult<{ results: GrantResult[]; granted: number; failed: number }>> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (input.recipientUserIds.length === 0) {
return { success: false, error: "No recipients selected." };
}
let award; let award;
try { try {
award = await payload.findByID({ award = await payload.findByID({
@ -60,11 +71,17 @@ export async function grantAward(input: {
let grantorProfile: Profile | null = null; let grantorProfile: Profile | null = null;
const superuser = await isSuperuser(payload, user); const superuser = await isSuperuser(payload, user);
if (award.type === "medal") { const isMedal = award.type === "medal";
if (!superuser) { if (isMedal && !superuser) {
return { success: false, error: "Only superusers can grant medals." }; return { success: false, error: "Only superusers can grant medals." };
} }
} else if (!superuser) {
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 ?? []; const allowedTypes = award.grantConfig?.allowedGrantorAssignmentTypes ?? [];
if (allowedTypes.length === 0) { if (allowedTypes.length === 0) {
return { success: false, error: "This award cannot be granted by leaders." }; return { success: false, error: "This award cannot be granted by leaders." };
@ -78,7 +95,9 @@ export async function grantAward(input: {
if (grantorAssignments.length === 0) { if (grantorAssignments.length === 0) {
return { success: false, error: "You are not authorized to grant this award." }; 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; const maxGrants = award.grantConfig?.maxGrantsPerGrantor ?? null;
if (maxGrants) { if (maxGrants) {
@ -87,29 +106,14 @@ export async function grantAward(input: {
return { success: false, error: "You do not have a profile." }; return { success: false, error: "You do not have a profile." };
} }
const issued = await countGrantsByGrantor(payload, award.id, grantorProfile.id); const issued = await countGrantsByGrantor(payload, award.id, grantorProfile.id);
if (issued >= maxGrants) { remaining = maxGrants - issued;
if (remaining <= 0) {
return { return {
success: false, success: false,
error: "You have reached the maximum number of grants for this award.", 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) { if (!grantorProfile) {
@ -119,9 +123,60 @@ export async function grantAward(input: {
return { success: false, error: "You do not have a profile." }; return { success: false, error: "You do not have a profile." };
} }
const results: GrantResult[] = [];
let granted = 0;
let processed = 0;
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 progression = recipientProfile.progression;
const currentAwards = progression?.awards ?? []; 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({ await payload.update({
collection: "profiles", collection: "profiles",
id: recipientProfile.id, id: recipientProfile.id,
@ -149,7 +204,34 @@ export async function grantAward(input: {
data: { awardId: award.id, grantorProfileId: grantorProfile.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) { } catch (e) {
return { return {
success: false, success: false,

View file

@ -14,10 +14,10 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; 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 { getGrantStats, type GrantStats } from "@/app/(frontend)/awards/grant-context";
import { RecipientPicker, type RecipientSelection } from "./RecipientPicker"; import { RecipientPicker, type RecipientSelection } from "./RecipientPicker";
import { Loader2Icon, TriangleAlertIcon } from "lucide-react"; import { CheckIcon, Loader2Icon, TriangleAlertIcon, XIcon } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
interface GrantAwardDialogProps { interface GrantAwardDialogProps {
@ -33,9 +33,10 @@ interface GrantAwardDialogProps {
} }
/** /**
* Two-step grant flow: pick a recipient + reason, then confirm by typing the * Two-step grant flow: pick recipient(s) + reason, then confirm by typing the
* recipient's @username. Grant limits are pre-checked via getGrantStats so * award name. Grant limits are pre-checked via getGrantStats so selection is
* users see warnings before submitting. * capped and users see warnings before submitting. Individual ineligible
* recipients are skipped and reported after the fact.
*/ */
export function GrantAwardDialog({ export function GrantAwardDialog({
awardId, awardId,
@ -48,14 +49,19 @@ export function GrantAwardDialog({
onOpenChange, onOpenChange,
onGranted, onGranted,
}: GrantAwardDialogProps) { }: GrantAwardDialogProps) {
const [step, setStep] = useState<1 | 2>(1); const [step, setStep] = useState<1 | 2 | 3>(1);
const [recipient, setRecipient] = useState<RecipientSelection | null>(null); const [recipients, setRecipients] = useState<RecipientSelection[]>([]);
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [confirmText, setConfirmText] = useState(""); const [confirmText, setConfirmText] = useState("");
const [stats, setStats] = useState<GrantStats | null>(null); const [stats, setStats] = useState<GrantStats | null>(null);
const [statsError, setStatsError] = useState<string | null>(null); const [statsError, setStatsError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); 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. // Reset the flow each time the dialog opens; pre-fill a fixed recipient.
useEffect(() => { useEffect(() => {
@ -67,10 +73,11 @@ export function GrantAwardDialog({
setStatsError(null); setStatsError(null);
setError(null); setError(null);
setSubmitting(false); setSubmitting(false);
setRecipient( setResults(null);
setRecipients(
!showRecipientPicker && recipientUserId != null && recipientUsername !showRecipientPicker && recipientUserId != null && recipientUsername
? { id: recipientUserId, username: recipientUsername } ? [{ id: recipientUserId, username: recipientUsername }]
: null, : [],
); );
}, [open, showRecipientPicker, recipientUserId, recipientUsername]); }, [open, showRecipientPicker, recipientUserId, recipientUsername]);
@ -88,26 +95,36 @@ export function GrantAwardDialog({
}; };
}, [open, awardId]); }, [open, awardId]);
const expectedConfirm = recipient ? `@${recipient.username}` : null; const expectedConfirm = awardName;
const confirmMatches = expectedConfirm !== null && confirmText.trim() === expectedConfirm; const confirmMatches = confirmText.trim() === expectedConfirm;
const limited = stats?.remainingGrants != null; const limited = stats?.remainingGrants != null;
const maxSelections = limited ? stats?.remainingGrants ?? null : null;
const lowRemaining = limited && (stats?.remainingGrants ?? 0) <= 2; const lowRemaining = limited && (stats?.remainingGrants ?? 0) <= 2;
async function handleGrant() { async function handleGrant() {
if (!recipient || !confirmMatches || submitting) return; if (recipients.length === 0 || !confirmMatches || submitting) return;
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
const res = await grantAward({ const res = await grantAward({
recipientUserId: recipient.id, recipientUserIds: recipients.map((r) => r.id),
awardId, awardId,
reason: reason.trim() ? reason.trim() : undefined, reason: reason.trim() ? reason.trim() : undefined,
}); });
if (res.success) { if (res.success && res.data) {
const { failed } = res.data;
if (failed === 0) {
toast.success("Award granted", { toast.success("Award granted", {
description: `${awardName} granted to @${recipient.username}.`, description: `${awardName} granted to ${recipients.length} recipient${
recipients.length === 1 ? "" : "s"
}.`,
}); });
onGranted?.(); onGranted?.();
onOpenChange(false); onOpenChange(false);
} else {
setResults(res.data.results);
setStep(3);
setSubmitting(false);
}
} else { } else {
setError(res.error ?? "Failed to grant award."); setError(res.error ?? "Failed to grant award.");
setSubmitting(false); setSubmitting(false);
@ -125,10 +142,12 @@ export function GrantAwardDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>Grant {awardName}</DialogTitle> <DialogTitle>Grant {awardName}</DialogTitle>
<DialogDescription> <DialogDescription>
{showRecipientPicker {step === 3
? "Select a recipient, then confirm the grant." ? "Grant summary."
: showRecipientPicker
? "Select recipients, then confirm the grant."
: `Granting to @${recipientUsername}.`} : `Granting to @${recipientUsername}.`}
{stats && stats.canGrant && ( {step !== 3 && stats && stats.canGrant && (
<span className="block mt-1 text-xs opacity-50"> <span className="block mt-1 text-xs opacity-50">
{stats.remainingGrants === null {stats.remainingGrants === null
? "Unlimited grants available." ? "Unlimited grants available."
@ -146,11 +165,18 @@ export function GrantAwardDialog({
{showRecipientPicker ? ( {showRecipientPicker ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Recipient</Label> <Label>Recipients</Label>
<RecipientPicker <RecipientPicker
selectedUser={recipient} selectedUsers={recipients}
onSelect={setRecipient} onSelect={(u) =>
onClear={() => setRecipient(null)} 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> </div>
) : ( ) : (
@ -204,37 +230,53 @@ export function GrantAwardDialog({
</Button> </Button>
<Button <Button
onClick={() => setStep(2)} onClick={() => setStep(2)}
disabled={!recipient || (stats !== null && !stats.canGrant)} disabled={recipients.length === 0 || (stats !== null && !stats.canGrant)}
> >
Continue Continue
</Button> </Button>
</DialogFooter> </DialogFooter>
</div> </div>
) : ( ) : step === 2 ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Alert> <Alert>
<TriangleAlertIcon data-icon="inline-start" /> <TriangleAlertIcon data-icon="inline-start" />
<AlertTitle>Confirm grant</AlertTitle> <AlertTitle>Confirm grant</AlertTitle>
<AlertDescription> <AlertDescription>
You are about to grant "{awardName}" to @{recipient?.username}. This action cannot You are about to grant "{awardName}" to{" "}
be undone. {recipients.length === 1
? `@${recipients[0].username}`
: `${recipients.length} recipients`}
. This action cannot be undone.
</AlertDescription> </AlertDescription>
</Alert> </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 && ( {limited && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{(stats?.remainingGrants ?? 0) <= 1 {maxSelections != null && maxSelections <= recipients.length
? "This will use your LAST grant for this award." ? "This will use ALL of your remaining grants for this award."
: `Grants remaining after this: ${(stats?.remainingGrants ?? 0) - 1}.`} : `Grants remaining after this: ${(stats?.remainingGrants ?? 0) - recipients.length}.`}
</p> </p>
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Type @{recipient?.username} to confirm</Label> <Label>Type the award name to confirm</Label>
<Input <Input
value={confirmText} value={confirmText}
onChange={(e) => setConfirmText(e.target.value)} onChange={(e) => setConfirmText(e.target.value)}
placeholder={`@${recipient?.username}`} placeholder={awardName}
autoFocus autoFocus
/> />
</div> </div>
@ -256,6 +298,54 @@ export function GrantAwardDialog({
</Button> </Button>
</DialogFooter> </DialogFooter>
</div> </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> </DialogContent>
</Dialog> </Dialog>

View file

@ -15,9 +15,11 @@ export interface RecipientSelection {
} }
interface RecipientPickerProps { interface RecipientPickerProps {
selectedUsers: RecipientSelection[];
onSelect: (user: RecipientSelection) => void; onSelect: (user: RecipientSelection) => void;
selectedUser: RecipientSelection | null; onRemove: (id: number) => void;
onClear: () => void; maxSelections?: number;
disabled?: boolean;
} }
interface SearchResult extends RecipientSelection { interface SearchResult extends RecipientSelection {
@ -35,7 +37,13 @@ function initialsOf(username: string): string {
* `profile` relationship, so searching /api/users would not surface rank * `profile` relationship, so searching /api/users would not surface rank
* data) and enriches each hit with the user's primary assignment. * 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 [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]); const [results, setResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
@ -136,36 +144,48 @@ export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPi
profileId: result.profileId, profileId: result.profileId,
}); });
setQuery(""); setQuery("");
setOpen(false); setActiveIndex(0);
} }
if (selectedUser) { const atMax = maxSelections != null && selectedUsers.length >= maxSelections;
return ( return (
<div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2"> <div ref={wrapperRef} className="flex flex-col gap-2">
<Avatar className="size-7"> {selectedUsers.length > 0 && (
<AvatarFallback>{initialsOf(selectedUser.username)}</AvatarFallback> <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> </Avatar>
<span className="text-sm font-medium"> <span className="max-w-[10rem] truncate font-medium">
{selectedUser.displayName || selectedUser.username} {u.displayName || u.username}
</span> </span>
<span className="text-sm text-muted-foreground">@{selectedUser.username}</span> <span className="truncate text-xs text-muted-foreground">@{u.username}</span>
<button <button
type="button" type="button"
aria-label="Clear recipient" aria-label={`Remove ${u.username}`}
onClick={onClear} disabled={disabled}
className="ml-auto rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" onClick={() => onRemove(u.id)}
className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
> >
<XIcon className="size-4" /> <XIcon className="size-3.5" />
</button> </button>
</span>
))}
</div> </div>
); )}
}
return ( <div className="relative">
<div ref={wrapperRef} className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /> <SearchIcon className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
value={query} value={query}
disabled={disabled || atMax}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
setOpen(true); setOpen(true);
@ -189,7 +209,11 @@ export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPi
if (active) select(active); if (active) select(active);
} }
}} }}
placeholder="Search by username or display name..." placeholder={
atMax
? "Maximum recipients selected"
: "Search by username or display name..."
}
className="pl-8" className="pl-8"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
@ -209,18 +233,22 @@ export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPi
) : results.length === 0 ? ( ) : results.length === 0 ? (
<div className="px-3 py-3 text-sm text-muted-foreground">No users found.</div> <div className="px-3 py-3 text-sm text-muted-foreground">No users found.</div>
) : ( ) : (
results.map((result, i) => ( results.map((result, i) => {
const alreadySelected = selectedUsers.some((u) => u.id === result.id);
return (
<button <button
key={result.id} key={result.id}
type="button" type="button"
role="option" role="option"
aria-selected={i === activeIndex} aria-selected={i === activeIndex}
disabled={alreadySelected || atMax}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => select(result)} onClick={() => select(result)}
onMouseEnter={() => setActiveIndex(i)} onMouseEnter={() => setActiveIndex(i)}
className={cn( className={cn(
"flex w-full items-center gap-3 px-3 py-2 text-left", "flex w-full items-center gap-3 px-3 py-2 text-left",
i === activeIndex && "bg-accent", i === activeIndex && "bg-accent",
(alreadySelected || atMax) && "cursor-not-allowed opacity-50",
)} )}
> >
<Avatar className="size-8"> <Avatar className="size-8">
@ -244,11 +272,18 @@ export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPi
{result.assignmentLabel} {result.assignmentLabel}
</span> </span>
)} )}
{alreadySelected && (
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
Selected
</span>
)}
</button> </button>
)) );
})
)} )}
</div> </div>
)} )}
</div> </div>
</div>
); );
} }