diff --git a/src/app/(frontend)/awards/actions.ts b/src/app/(frontend)/awards/actions.ts index b40a013..03ffb93 100644 --- a/src/app/(frontend)/awards/actions.ts +++ b/src/app/(frontend)/awards/actions.ts @@ -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 { 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 { +}): Promise> { 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 +> { + 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, diff --git a/src/components/frontend/account/GrantAwardDialog.tsx b/src/components/frontend/account/GrantAwardDialog.tsx index 4279923..21cf1a5 100644 --- a/src/components/frontend/account/GrantAwardDialog.tsx +++ b/src/components/frontend/account/GrantAwardDialog.tsx @@ -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(null); + const [step, setStep] = useState<1 | 2 | 3>(1); + const [recipients, setRecipients] = useState([]); const [reason, setReason] = useState(""); const [confirmText, setConfirmText] = useState(""); const [stats, setStats] = useState(null); const [statsError, setStatsError] = useState(null); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [results, setResults] = useState(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({ Grant {awardName} - {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 && ( {stats.remainingGrants === null ? "Unlimited grants available." @@ -146,11 +165,18 @@ export function GrantAwardDialog({ {showRecipientPicker ? (
- + 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} />
) : ( @@ -204,37 +230,53 @@ export function GrantAwardDialog({ - ) : ( + ) : step === 2 ? (
Confirm grant - 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. + {showRecipientPicker && recipients.length > 1 && ( +
    + {recipients.map((r) => ( +
  • + @{r.username} +
  • + ))} +
+ )} + {limited && (

- {(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}.`}

)}
- + setConfirmText(e.target.value)} - placeholder={`@${recipient?.username}`} + placeholder={awardName} autoFocus />
@@ -256,6 +298,54 @@ export function GrantAwardDialog({
+ ) : ( +
+ {results && ( + <> + + + + {results.filter((r) => !r.success).length} of {results.length} grants could + not be completed + + + The following recipients were skipped; the rest were granted successfully. + + +
    + {results.map((r) => ( +
  • + {r.success ? ( + + ) : ( + + )} + + + @{usernameOf(r.recipientUserId)} + + {!r.success && r.error && ( + {r.error} + )} + +
  • + ))} +
+ + )} + + + + +
)} diff --git a/src/components/frontend/account/RecipientPicker.tsx b/src/components/frontend/account/RecipientPicker.tsx index cdb4dbd..cf1f0da 100644 --- a/src/components/frontend/account/RecipientPicker.tsx +++ b/src/components/frontend/account/RecipientPicker.tsx @@ -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([]); 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 ( -
- - {initialsOf(selectedUser.username)} - - - {selectedUser.displayName || selectedUser.username} - - @{selectedUser.username} - -
- ); - } + const atMax = maxSelections != null && selectedUsers.length >= maxSelections; return ( -
- - { - 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 && ( -
- {searching ? ( -
- - Searching... -
- ) : results.length === 0 ? ( -
No users found.
- ) : ( - results.map((result, i) => ( +
+ {selectedUsers.length > 0 && ( +
+ {selectedUsers.map((u) => ( + + + + {initialsOf(u.username)} + + + + {u.displayName || u.username} + + @{u.username} - )) - )} + + ))}
)} + +
+ + { + 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 && ( +
+ {searching ? ( +
+ + Searching... +
+ ) : results.length === 0 ? ( +
No users found.
+ ) : ( + results.map((result, i) => { + const alreadySelected = selectedUsers.some((u) => u.id === result.id); + return ( + + ); + }) + )} +
+ )} +
); }