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.
241 lines
6.9 KiB
TypeScript
241 lines
6.9 KiB
TypeScript
"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 { cleanupDuplicateAwards, type CleanupResult } from "@/lib/awards/index";
|
|
import type { Assignment, 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 interface GrantResult {
|
|
recipientUserId: number;
|
|
success: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
export async function grantAward(input: {
|
|
recipientUserIds: number[];
|
|
awardId: number;
|
|
reason?: string;
|
|
}): 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({
|
|
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);
|
|
|
|
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." };
|
|
}
|
|
|
|
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." };
|
|
}
|
|
grantorAssignmentId = grantorAssignments[0].id;
|
|
scope = award.grantConfig?.recipientScope ?? "same_command";
|
|
whitelist = award.grantConfig?.eligibleRecipientAssignments;
|
|
|
|
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);
|
|
remaining = maxGrants - issued;
|
|
if (remaining <= 0) {
|
|
return {
|
|
success: false,
|
|
error: "You have reached the maximum number of grants for this award.",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!grantorProfile) {
|
|
grantorProfile = await findUserProfile(payload, user.id as number);
|
|
}
|
|
if (!grantorProfile) {
|
|
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 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,
|
|
});
|
|
|
|
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 },
|
|
});
|
|
|
|
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,
|
|
error: e instanceof Error ? e.message : "Unknown error",
|
|
};
|
|
}
|
|
}
|