feat(promotions): add promotion nominations and leadership transfers
- Promotion nominations: members can nominate a peer for promotion from their profile, and a new /personnel/promotions page lets staff review and action the nominations (with an optional note on each). - Leadership transfers: a member's leadership can be handed over to another member, surfaced on the transfers page and in the roster (which also gains a staff remove-member dialog). - Adds the PromotionNominations and LeadershipTransfers collections (registered in the Payload config), their service libs, the migration, and integration coverage.
This commit is contained in:
parent
d43b2ef658
commit
62573f74e6
28 changed files with 40393 additions and 111 deletions
105
src/app/(frontend)/personnel/promotions/actions.ts
Normal file
105
src/app/(frontend)/personnel/promotions/actions.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import {
|
||||
cancelNomination as cancelNominationCore,
|
||||
recordLeaderDecision,
|
||||
resolveNomination as resolveNominationCore,
|
||||
} from "@/lib/promotions/nominations";
|
||||
|
||||
export interface ActionResult<T = undefined> {
|
||||
readonly success: boolean;
|
||||
readonly error?: string;
|
||||
readonly 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 };
|
||||
}
|
||||
|
||||
function actionFailure<T>(caughtError: unknown): ActionResult<T> {
|
||||
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
||||
return {
|
||||
success: false,
|
||||
error: message === "Unauthorized" ? "You must be logged in." : message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Division leader stage: endorse a nomination that is awaiting endorsement for
|
||||
* a member of one of the leader's divisions. The current-leader check lives in
|
||||
* the service lib (re-fetched at call time).
|
||||
*/
|
||||
export async function endorseNomination(
|
||||
nominationId: number,
|
||||
note?: string,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
await recordLeaderDecision(payload, user, nominationId, {
|
||||
endorse: true,
|
||||
...(note !== undefined ? { note } : {}),
|
||||
});
|
||||
return { success: true };
|
||||
} catch (caughtError) {
|
||||
return actionFailure(caughtError);
|
||||
}
|
||||
}
|
||||
|
||||
/** Division leader stage: reject a pending nomination (terminal). */
|
||||
export async function rejectNomination(nominationId: number, note?: string): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
await recordLeaderDecision(payload, user, nominationId, {
|
||||
endorse: false,
|
||||
...(note !== undefined ? { note } : {}),
|
||||
});
|
||||
return { success: true };
|
||||
} catch (caughtError) {
|
||||
return actionFailure(caughtError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Final superuser stage: approve (bumps the nominee exactly one rung
|
||||
* transactionally) or reject the nomination.
|
||||
*/
|
||||
export async function resolveNomination(
|
||||
nominationId: number,
|
||||
approve: boolean,
|
||||
note?: string,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
await resolveNominationCore(payload, user, nominationId, {
|
||||
approve,
|
||||
...(note !== undefined ? { note } : {}),
|
||||
});
|
||||
return { success: true };
|
||||
} catch (caughtError) {
|
||||
return actionFailure(caughtError);
|
||||
}
|
||||
}
|
||||
|
||||
/** Nominator stage: withdraw one of the caller's own pending nominations. */
|
||||
export async function cancelNomination(nominationId: number): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
await cancelNominationCore(payload, user, nominationId);
|
||||
return { success: true };
|
||||
} catch (caughtError) {
|
||||
return actionFailure(caughtError);
|
||||
}
|
||||
}
|
||||
131
src/app/(frontend)/personnel/promotions/page.tsx
Normal file
131
src/app/(frontend)/personnel/promotions/page.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import config from "@payload-config";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getPayload } from "payload";
|
||||
import { ArrowUpIcon } from "lucide-react";
|
||||
import { PromotionsView } from "@/components/frontend/promotions/PromotionsView";
|
||||
import type { NominationView } from "@/components/frontend/promotions/nominationTypes";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
import type { Assignment, PromotionNomination, User } from "@/payload-types";
|
||||
|
||||
export const metadata = {
|
||||
title: "Promotions: Polaris Task Force",
|
||||
};
|
||||
|
||||
function userRefId(ref: number | User | null | undefined): number | null {
|
||||
if (ref == null) return null;
|
||||
return typeof ref === "number" ? ref : ref.id;
|
||||
}
|
||||
|
||||
function userRefLabel(ref: number | User | null | undefined): string | null {
|
||||
if (ref == null || typeof ref === "number") return null;
|
||||
return ref.displayName || ref.username;
|
||||
}
|
||||
|
||||
function toNominationView(doc: PromotionNomination): NominationView {
|
||||
const nomineeId = userRefId(doc.nominee) ?? 0;
|
||||
const nominatorId = userRefId(doc.nominator) ?? 0;
|
||||
return {
|
||||
id: doc.id,
|
||||
nomineeId,
|
||||
nomineeName: userRefLabel(doc.nominee) ?? `User #${nomineeId}`,
|
||||
nominatorId,
|
||||
nominatorName: userRefLabel(doc.nominator) ?? `User #${nominatorId}`,
|
||||
accolades: doc.accolades,
|
||||
status: doc.status,
|
||||
leaderDecision: doc.leaderDecision ?? "pending",
|
||||
leaderDecisionByName: userRefLabel(doc.leaderDecisionBy),
|
||||
leaderNote: doc.leaderNote ?? null,
|
||||
reviewNote: doc.reviewNote ?? null,
|
||||
createdAt: doc.createdAt,
|
||||
leaderDecidedAt: doc.leaderDecidedAt ?? null,
|
||||
reviewedAt: doc.reviewedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PromotionsPage() {
|
||||
const payload = await getPayload({ config });
|
||||
const { user: authUser } = await payload.auth({
|
||||
headers: await nextHeaders(),
|
||||
canSetHeaders: false,
|
||||
});
|
||||
|
||||
if (!isPayloadUser(authUser)) {
|
||||
redirect("/");
|
||||
}
|
||||
const currentUserId = authUser.id;
|
||||
const superuserViewer = await isSuperuser(payload, authUser);
|
||||
|
||||
// Divisions the viewer leads: their members form the endorsement queue scope.
|
||||
const divisionsLedRes = await payload.find({
|
||||
collection: "assignments",
|
||||
where: {
|
||||
and: [{ type: { equals: "division" } }, { leader: { equals: currentUserId } }],
|
||||
},
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const ledDivisionIds = divisionsLedRes.docs.map((doc: Assignment) => doc.id);
|
||||
const ledMemberIds = new Set<number>();
|
||||
for (const division of divisionsLedRes.docs) {
|
||||
for (const member of division.members ?? []) {
|
||||
const memberId = typeof member === "number" ? member : member.id;
|
||||
if (memberId != null) ledMemberIds.add(memberId);
|
||||
}
|
||||
}
|
||||
|
||||
const nominationsRes = await payload.find({
|
||||
collection: "promotion-nominations",
|
||||
sort: "-createdAt",
|
||||
limit: 100,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const nominations = nominationsRes.docs.map(toNominationView);
|
||||
|
||||
const nomineeInLedDivision = (nomination: NominationView) =>
|
||||
ledMemberIds.has(nomination.nomineeId);
|
||||
|
||||
const leaderQueue = nominations.filter(
|
||||
(nomination) =>
|
||||
nomination.status === "pending" &&
|
||||
nomination.leaderDecision === "pending" &&
|
||||
nomineeInLedDivision(nomination),
|
||||
);
|
||||
const finalQueue = superuserViewer
|
||||
? nominations.filter((nomination) => nomination.status === "awaiting_superuser")
|
||||
: [];
|
||||
const mySubmissions = nominations.filter(
|
||||
(nomination) => nomination.nominatorId === currentUserId,
|
||||
);
|
||||
const resolved = nominations.filter((nomination) =>
|
||||
["approved", "rejected", "cancelled"].includes(nomination.status),
|
||||
);
|
||||
const recentlyResolved = superuserViewer ? resolved : resolved.filter(nomineeInLedDivision);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpIcon className="size-5 text-muted-foreground" aria-hidden="true" />
|
||||
<h1 className="text-lg font-semibold">Promotions</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nominate members for promotion from their profile, endorse nominations for your division,
|
||||
and handle final approvals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PromotionsView
|
||||
isSuperuserViewer={superuserViewer}
|
||||
isLeader={ledDivisionIds.length > 0}
|
||||
leaderQueue={leaderQueue}
|
||||
finalQueue={finalQueue}
|
||||
mySubmissions={mySubmissions}
|
||||
recentlyResolved={recentlyResolved}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,10 @@ import {
|
|||
PERSONAL_EXCERPT_MAX_LENGTH,
|
||||
} from "@/lib/profile/excerpt-limits";
|
||||
import { promoteMember as promoteMemberService } from "@/lib/promotions";
|
||||
import {
|
||||
cancelNomination as cancelNominationCore,
|
||||
submitNomination,
|
||||
} from "@/lib/promotions/nominations";
|
||||
|
||||
interface ActionResult<T = undefined> {
|
||||
success: boolean;
|
||||
|
|
@ -290,3 +294,43 @@ export async function promoteMember(
|
|||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a promotion nomination for the given member (or a self-request when
|
||||
* the nominee is the authenticated user). The division leader endorsement
|
||||
* stage and the open-nomination guards are enforced by the service lib.
|
||||
*/
|
||||
export async function submitPromotionNomination(input: {
|
||||
nomineeUsername: string;
|
||||
accolades: string;
|
||||
}): Promise<ActionResult<{ nominationId: number }>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
const nominee = await findUserByUsername(payload, input.nomineeUsername);
|
||||
if (!nominee) {
|
||||
return { success: false, error: "User not found." };
|
||||
}
|
||||
|
||||
const nomination = await submitNomination(payload, user, {
|
||||
nomineeId: nominee.id,
|
||||
accolades: input.accolades,
|
||||
});
|
||||
return { success: true, data: { nominationId: nomination.id } };
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Unauthorized") throw e;
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Withdraw one of the caller's own pending promotion nominations. */
|
||||
export async function cancelMyNomination(nominationId: number): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
await cancelNominationCore(payload, user, nominationId);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Unauthorized") throw e;
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,8 +42,10 @@ import { RibbonRack } from "@/components/frontend/account/RibbonRack";
|
|||
import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection";
|
||||
import { DossierExcerptEditors } from "@/components/frontend/profile/DossierExcerptEditors";
|
||||
import { PromoteButton } from "@/components/frontend/profile/PromoteButton";
|
||||
import { NominateForPromotionButton } from "@/components/frontend/profile/NominateForPromotionButton";
|
||||
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
|
||||
import { allowedPromotionTargets, resolvePromotionAuthority } from "@/lib/promotions";
|
||||
import { loadRankLadder, rankIndex } from "@/lib/promotions/authority";
|
||||
import type {
|
||||
EvaluationMissionOption,
|
||||
EvaluationSummary,
|
||||
|
|
@ -367,6 +369,27 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||
const allowedTargetRanks = promotionResult
|
||||
? allowedPromotionTargets(promotionResult, rank)
|
||||
: [];
|
||||
// Nomination visibility: any logged-in viewer can nominate (or self-request)
|
||||
// while the member has a next rung to climb and no nomination is open.
|
||||
const nomineeLadder = promotionResult?.ladder ?? (viewer ? await loadRankLadder(payload) : []);
|
||||
const currentRankIndex = rankIndex(nomineeLadder, rank.id);
|
||||
const hasNextRung = currentRankIndex >= 0 && currentRankIndex + 1 < nomineeLadder.length;
|
||||
const openNominationRes = viewer
|
||||
? await payload.find({
|
||||
collection: "promotion-nominations",
|
||||
where: {
|
||||
and: [
|
||||
{ nominee: { equals: user.id } },
|
||||
{ status: { in: ["pending", "awaiting_superuser"] } },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
: null;
|
||||
const canRequestPromotion =
|
||||
viewer !== null && hasNextRung && (openNominationRes?.docs.length ?? 0) === 0;
|
||||
const awardEntries = (profile.progression?.awards ?? []).filter(
|
||||
(entry): entry is ProfileAwardEntry => typeof entry === "object" && entry !== null,
|
||||
);
|
||||
|
|
@ -621,6 +644,14 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{canRequestPromotion && (
|
||||
<NominateForPromotionButton
|
||||
username={username}
|
||||
displayName={user.displayName}
|
||||
isOwnProfile={isProfileOwner}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Combat Record ──────────────────────────────────── */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
94
src/app/(frontend)/roster/actions.ts
Normal file
94
src/app/(frontend)/roster/actions.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import type { LeadershipTransfer } from "@/payload-types";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import {
|
||||
cancelLeadershipTransfer as cancelLeadershipTransferCore,
|
||||
requestLeadershipTransfer as requestLeadershipTransferCore,
|
||||
resolveLeadershipTransfer as resolveLeadershipTransferCore,
|
||||
} from "@/lib/transfers/leadership";
|
||||
|
||||
export interface ActionResult<T = undefined> {
|
||||
readonly success: boolean;
|
||||
readonly error?: string;
|
||||
readonly data?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web actions for leadership-initiated removals. The roster page's client
|
||||
* components call requestDivisionRemoval/cancelDivisionRemoval; the transfers
|
||||
* page's leadership section calls resolveLeadershipTransfer. The lib re-validates
|
||||
* every guard (leader authority, pending-only transitions, superuser-only
|
||||
* resolution), so these actions only authenticate and delegate.
|
||||
*/
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function actionFailure<T>(caughtError: unknown): ActionResult<T> {
|
||||
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
||||
return {
|
||||
success: false,
|
||||
error: message === "Unauthorized" ? "You must be logged in." : message,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestDivisionRemoval(input: {
|
||||
readonly subjectId: number;
|
||||
readonly fromAssignmentId: number;
|
||||
readonly reason?: string;
|
||||
}): Promise<ActionResult<LeadershipTransfer>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const transfer = await requestLeadershipTransferCore(payload, user, {
|
||||
subjectId: input.subjectId,
|
||||
fromAssignmentId: input.fromAssignmentId,
|
||||
...(input.reason !== undefined ? { reason: input.reason } : {}),
|
||||
});
|
||||
return { success: true, data: transfer };
|
||||
} catch (caughtError) {
|
||||
return actionFailure<LeadershipTransfer>(caughtError);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelDivisionRemoval(
|
||||
transferId: number,
|
||||
): Promise<ActionResult<LeadershipTransfer>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const transfer = await cancelLeadershipTransferCore(payload, user, transferId);
|
||||
return { success: true, data: transfer };
|
||||
} catch (caughtError) {
|
||||
return actionFailure<LeadershipTransfer>(caughtError);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveLeadershipTransfer(
|
||||
transferId: number,
|
||||
approve: boolean,
|
||||
note?: string,
|
||||
): Promise<ActionResult<LeadershipTransfer>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const transfer = await resolveLeadershipTransferCore(payload, user, transferId, {
|
||||
approve,
|
||||
...(note !== undefined ? { note } : {}),
|
||||
});
|
||||
return { success: true, data: transfer };
|
||||
} catch (caughtError) {
|
||||
return actionFailure<LeadershipTransfer>(caughtError);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ export default async function RosterPage() {
|
|||
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
|
||||
const currentUser = isPayloadUser(authUser) ? authUser : null;
|
||||
|
||||
const [profileRes, assignmentRes] = await Promise.all([
|
||||
const [profileRes, assignmentRes, rules] = await Promise.all([
|
||||
payload.find({
|
||||
collection: "profiles",
|
||||
limit: 500,
|
||||
|
|
@ -65,6 +65,7 @@ export default async function RosterPage() {
|
|||
subAssignments: true,
|
||||
},
|
||||
}),
|
||||
payload.findGlobal({ slug: "game-rules", depth: 0 }),
|
||||
]);
|
||||
|
||||
const profiles = profileRes.docs;
|
||||
|
|
@ -132,6 +133,16 @@ export default async function RosterPage() {
|
|||
.filter(Boolean) as number[],
|
||||
}));
|
||||
|
||||
// The configured Infantry assignment (leadership-initiated removal
|
||||
// destination), resolved to a display name when it exists.
|
||||
const configuredInfantry = rules?.infantryAssignment ?? null;
|
||||
const infantryId =
|
||||
configuredInfantry != null && typeof configuredInfantry === "object"
|
||||
? configuredInfantry.id
|
||||
: configuredInfantry;
|
||||
const infantryName =
|
||||
infantryId != null ? (assignments.find((a) => a.id === infantryId)?.name ?? null) : null;
|
||||
|
||||
return (
|
||||
<div className="p-5 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-1" data-tour="page-header">
|
||||
|
|
@ -149,6 +160,8 @@ export default async function RosterPage() {
|
|||
members={members}
|
||||
assignments={assignments}
|
||||
leadOrActual={currentUser?.preferences?.display?.leadOrActual ?? "lead"}
|
||||
currentUserId={currentUser?.id}
|
||||
infantryName={infantryName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
168
src/collections/users/LeadershipTransfers.ts
Normal file
168
src/collections/users/LeadershipTransfers.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import {
|
||||
CollectionConfig,
|
||||
type CollectionBeforeChangeHook,
|
||||
} from "payload";
|
||||
import type { User } from "@/payload-types";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
|
||||
export const LEADERSHIP_TRANSFER_STATUSES = [
|
||||
"pending",
|
||||
"approved",
|
||||
"denied",
|
||||
"cancelled",
|
||||
] as const;
|
||||
export type LeadershipTransferStatus = (typeof LEADERSHIP_TRANSFER_STATUSES)[number];
|
||||
|
||||
export const LEADERSHIP_TRANSFER_STATUS_TRANSITIONS: Record<
|
||||
LeadershipTransferStatus,
|
||||
LeadershipTransferStatus[]
|
||||
> = {
|
||||
pending: ["approved", "denied", "cancelled"],
|
||||
approved: [],
|
||||
denied: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
const relationId = (value: unknown): number | null | undefined =>
|
||||
typeof value === "object" && value !== null ? (value as { id: number }).id : (value as number | null | undefined);
|
||||
|
||||
/**
|
||||
* A removal request always belongs to its initiator. REST/admin creates are forced
|
||||
* to the authenticated caller; lib writes that run with overrideAccess and no
|
||||
* req.user set initiator explicitly and skip this guard.
|
||||
*/
|
||||
const enforceInitiatorOnCreate: CollectionBeforeChangeHook = async ({ operation, data, req }) => {
|
||||
if (operation !== "create") return data;
|
||||
if (req.user) {
|
||||
const initiatorId = relationId(data?.initiator);
|
||||
if (initiatorId !== req.user.id) {
|
||||
throw new Error("You can only submit a removal request as yourself.");
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const guardStatusTransition: CollectionBeforeChangeHook = async ({
|
||||
operation,
|
||||
data,
|
||||
originalDoc,
|
||||
}) => {
|
||||
if (operation !== "update") return data;
|
||||
const nextStatus = data?.status as LeadershipTransferStatus | undefined;
|
||||
if (!nextStatus || nextStatus === originalDoc?.status) return data;
|
||||
const current = originalDoc?.status as LeadershipTransferStatus | undefined;
|
||||
const allowed = current ? LEADERSHIP_TRANSFER_STATUS_TRANSITIONS[current] : [];
|
||||
if (!allowed.includes(nextStatus)) {
|
||||
throw new Error(`Illegal leadership transfer status transition: ${current} -> ${nextStatus}.`);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export const LeadershipTransfers: CollectionConfig = {
|
||||
slug: "leadership-transfers",
|
||||
admin: {
|
||||
group: "Users",
|
||||
useAsTitle: "subject",
|
||||
defaultColumns: ["subject", "fromAssignment", "toAssignment", "status", "createdAt"],
|
||||
},
|
||||
access: {
|
||||
read: async ({ req }) => {
|
||||
const user = req.user as User | null;
|
||||
if (!user) return false;
|
||||
if (await isSuperuser(req.payload, user)) return true;
|
||||
if (hasRoles(["admin", "developer"], user)) return true;
|
||||
return { initiator: { equals: user.id } };
|
||||
},
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
||||
delete: ({ req }) => hasRoles(["developer"], req.user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "initiator",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "Division leader requesting the removal.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "subject",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "Member being transferred.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fromAssignment",
|
||||
type: "relationship",
|
||||
relationTo: "assignments",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Division the member is removed from.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "toAssignment",
|
||||
type: "relationship",
|
||||
relationTo: "assignments",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Division the member is transferred to (the Infantry assignment).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reason",
|
||||
type: "textarea",
|
||||
maxLength: 2000,
|
||||
admin: {
|
||||
description: "Optional reason for the removal, visible to command.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "pending",
|
||||
options: [
|
||||
{ label: "Pending approval", value: "pending" },
|
||||
{ label: "Approved", value: "approved" },
|
||||
{ label: "Denied", value: "denied" },
|
||||
{ label: "Cancelled", value: "cancelled" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "reviewNote",
|
||||
type: "textarea",
|
||||
maxLength: 2000,
|
||||
admin: {
|
||||
description: "Reviewer note recorded with the decision.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reviewedBy",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
admin: {
|
||||
condition: (data) => data?.status === "approved" || data?.status === "denied",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolvedAt",
|
||||
type: "date",
|
||||
admin: {
|
||||
condition: (data) => data?.status === "approved" || data?.status === "denied",
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
hooks: {
|
||||
beforeChange: [enforceInitiatorOnCreate, guardStatusTransition],
|
||||
},
|
||||
};
|
||||
200
src/collections/users/PromotionNominations.ts
Normal file
200
src/collections/users/PromotionNominations.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import {
|
||||
CollectionConfig,
|
||||
type CollectionBeforeChangeHook,
|
||||
} from "payload";
|
||||
import type { User } from "@/payload-types";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
|
||||
export const NOMINATION_STATUSES = [
|
||||
"pending",
|
||||
"awaiting_superuser",
|
||||
"approved",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
] as const;
|
||||
export type NominationStatus = (typeof NOMINATION_STATUSES)[number];
|
||||
|
||||
export const NOMINATION_STATUS_TRANSITIONS: Record<NominationStatus, NominationStatus[]> = {
|
||||
pending: ["awaiting_superuser", "rejected", "cancelled"],
|
||||
awaiting_superuser: ["approved", "rejected"],
|
||||
approved: [],
|
||||
rejected: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
export const NOMINATION_LEADER_DECISIONS = [
|
||||
"pending",
|
||||
"endorsed",
|
||||
"rejected",
|
||||
"not_required",
|
||||
] as const;
|
||||
export type NominationLeaderDecision = (typeof NOMINATION_LEADER_DECISIONS)[number];
|
||||
|
||||
const relationId = (value: unknown): number | null | undefined =>
|
||||
typeof value === "object" && value !== null ? (value as { id: number }).id : (value as number | null | undefined);
|
||||
|
||||
/**
|
||||
* A submission always belongs to its submitter. REST/admin creates are forced to
|
||||
* the authenticated caller; lib writes that run with overrideAccess and no req.user
|
||||
* set nominator explicitly and skip this guard.
|
||||
*/
|
||||
const enforceNominatorOnCreate: CollectionBeforeChangeHook = async ({ operation, data, req }) => {
|
||||
if (operation !== "create") return data;
|
||||
if (req.user) {
|
||||
const nominatorId = relationId(data?.nominator);
|
||||
if (nominatorId !== req.user.id) {
|
||||
throw new Error("You can only submit a promotion nomination as yourself.");
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const guardStatusTransition: CollectionBeforeChangeHook = async ({
|
||||
operation,
|
||||
data,
|
||||
originalDoc,
|
||||
}) => {
|
||||
if (operation !== "update") return data;
|
||||
const nextStatus = data?.status as NominationStatus | undefined;
|
||||
if (!nextStatus || nextStatus === originalDoc?.status) return data;
|
||||
const current = originalDoc?.status as NominationStatus | undefined;
|
||||
const allowed = current ? NOMINATION_STATUS_TRANSITIONS[current] : [];
|
||||
if (!allowed.includes(nextStatus)) {
|
||||
throw new Error(`Illegal promotion nomination status transition: ${current} -> ${nextStatus}.`);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export const PromotionNominations: CollectionConfig = {
|
||||
slug: "promotion-nominations",
|
||||
admin: {
|
||||
group: "Users",
|
||||
useAsTitle: "nominee",
|
||||
defaultColumns: ["nominee", "nominator", "status", "leaderDecision", "createdAt"],
|
||||
},
|
||||
access: {
|
||||
read: async ({ req }) => {
|
||||
const user = req.user as User | null;
|
||||
if (!user) return false;
|
||||
if (await isSuperuser(req.payload, user)) return true;
|
||||
if (hasRoles(["admin", "developer"], user)) return true;
|
||||
// Nominations stay private to the nominator until resolved; the review page
|
||||
// scopes leader visibility server-side to divisions the leader actually leads.
|
||||
return { nominator: { equals: user.id } };
|
||||
},
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
||||
delete: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "nominee",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "Member being nominated for promotion.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nominator",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "Member submitting the nomination (self for self-requests).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accolades",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
maxLength: 4000,
|
||||
admin: {
|
||||
description:
|
||||
"Written examples of performance, accolades, and reasons this member deserves promotion.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "pending",
|
||||
options: [
|
||||
{ label: "Pending division leader endorsement", value: "pending" },
|
||||
{ label: "Awaiting final approval", value: "awaiting_superuser" },
|
||||
{ label: "Approved", value: "approved" },
|
||||
{ label: "Rejected", value: "rejected" },
|
||||
{ label: "Cancelled", value: "cancelled" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "leaderDecision",
|
||||
type: "select",
|
||||
defaultValue: "pending",
|
||||
options: [
|
||||
{ label: "Pending", value: "pending" },
|
||||
{ label: "Endorsed", value: "endorsed" },
|
||||
{ label: "Rejected", value: "rejected" },
|
||||
{ label: "Not required", value: "not_required" },
|
||||
],
|
||||
admin: {
|
||||
description: "Decision of the nominee's division leader.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "leaderDecisionBy",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
admin: {
|
||||
condition: (data) => data?.leaderDecision === "endorsed" || data?.leaderDecision === "rejected",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "leaderDecidedAt",
|
||||
type: "date",
|
||||
admin: {
|
||||
condition: (data) => data?.leaderDecision === "endorsed" || data?.leaderDecision === "rejected",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "leaderNote",
|
||||
type: "textarea",
|
||||
maxLength: 2000,
|
||||
admin: {
|
||||
description: "Note from the division leader's endorsement or rejection.",
|
||||
condition: (data) => data?.leaderDecision === "endorsed" || data?.leaderDecision === "rejected",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reviewNote",
|
||||
type: "textarea",
|
||||
maxLength: 2000,
|
||||
admin: {
|
||||
description: "Final reviewer feedback shown to the nominator.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reviewedBy",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
admin: {
|
||||
condition: (data) => data?.status === "approved" || data?.status === "rejected",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reviewedAt",
|
||||
type: "date",
|
||||
admin: {
|
||||
condition: (data) => data?.status === "approved" || data?.status === "rejected",
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
hooks: {
|
||||
beforeChange: [enforceNominatorOnCreate, guardStatusTransition],
|
||||
},
|
||||
};
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
Flag,
|
||||
Kanban,
|
||||
LifeBuoy,
|
||||
Medal,
|
||||
Map,
|
||||
Package,
|
||||
Radar,
|
||||
|
|
@ -153,6 +154,11 @@ export const sidebarData = {
|
|||
url: "/personnel/statistics",
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
title: "Promotions",
|
||||
url: "/personnel/promotions",
|
||||
icon: Medal,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
150
src/components/frontend/profile/NominateForPromotionButton.tsx
Normal file
150
src/components/frontend/profile/NominateForPromotionButton.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { submitPromotionNomination } from "@/app/(frontend)/profile/[username]/actions";
|
||||
import { Loader2Icon, TrophyIcon } from "lucide-react";
|
||||
|
||||
interface NominateForPromotionButtonProps {
|
||||
readonly username: string;
|
||||
readonly displayName: string;
|
||||
readonly isOwnProfile: boolean;
|
||||
}
|
||||
|
||||
const MIN_ACCOLADES_LENGTH = 20;
|
||||
|
||||
/**
|
||||
* Profile-page section that lets any logged-in user nominate the profile owner
|
||||
* for promotion (or self-request on their own profile). The visibility and the
|
||||
* next-rung existence check are computed server-side by the page; this
|
||||
* component only provides the entry point and submits the accolades.
|
||||
*/
|
||||
export function NominateForPromotionButton({
|
||||
username,
|
||||
displayName,
|
||||
isOwnProfile,
|
||||
}: NominateForPromotionButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [accolades, setAccolades] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const label = isOwnProfile ? "Request promotion" : "Nominate for promotion";
|
||||
const trimmed = accolades.trim();
|
||||
const tooShort = trimmed.length > 0 && trimmed.length < MIN_ACCOLADES_LENGTH;
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const res = await submitPromotionNomination({
|
||||
nomineeUsername: username,
|
||||
accolades: accolades,
|
||||
});
|
||||
if (res.success) {
|
||||
toast.success(isOwnProfile ? "Promotion request submitted" : "Nomination submitted", {
|
||||
description: isOwnProfile
|
||||
? "Your promotion request is on its way up the chain."
|
||||
: `Your nomination for ${displayName} is on its way up the chain.`,
|
||||
});
|
||||
setOpen(false);
|
||||
setAccolades("");
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(res.error ?? "Could not submit the nomination.");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrophyIcon className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Promotion nomination
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<TrophyIcon data-icon="inline-start" />
|
||||
{label}
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!submitting) setOpen(o);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isOwnProfile
|
||||
? "Make the case for your own promotion. It will be routed to command."
|
||||
: `Make the case for ${displayName}'s promotion. It will be routed through their division leader.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-2" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={`nomination-accolades-${username}`}>
|
||||
Performance and accolades *
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`nomination-accolades-${username}`}
|
||||
value={accolades}
|
||||
onChange={(event) => setAccolades(event.target.value)}
|
||||
placeholder="Describe their performance, initiative, and conduct with concrete examples."
|
||||
rows={5}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
{tooShort && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{MIN_ACCOLADES_LENGTH - trimmed.length} more character
|
||||
{MIN_ACCOLADES_LENGTH - trimmed.length !== 1 ? "s" : ""} needed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting || trimmed.length < MIN_ACCOLADES_LENGTH}>
|
||||
{submitting && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
|
||||
Submit nomination
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
265
src/components/frontend/promotions/NominationCard.tsx
Normal file
265
src/components/frontend/promotions/NominationCard.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CheckIcon, ScrollTextIcon, XIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
endorseNomination,
|
||||
rejectNomination,
|
||||
resolveNomination,
|
||||
cancelNomination,
|
||||
} from "@/app/(frontend)/personnel/promotions/actions";
|
||||
import { NominationNoteDialog } from "@/components/frontend/promotions/NominationNoteDialog";
|
||||
import {
|
||||
NOMINATION_DECISION_LABELS,
|
||||
NOMINATION_STATUS_LABELS,
|
||||
NOMINATION_STATUS_VARIANTS,
|
||||
formatNominationDate,
|
||||
type NominationView,
|
||||
} from "@/components/frontend/promotions/nominationTypes";
|
||||
|
||||
export type NominationCardActions = "leader" | "final" | "withdraw" | "none";
|
||||
|
||||
interface NominationCardProps {
|
||||
readonly nomination: NominationView;
|
||||
readonly actions: NominationCardActions;
|
||||
}
|
||||
|
||||
const ACCOLADES_PREVIEW_LENGTH = 280;
|
||||
|
||||
export function NominationCard({ nomination, actions }: NominationCardProps) {
|
||||
const [dialog, setDialog] = useState<
|
||||
"endorse" | "leader-reject" | "approve" | "final-reject" | null
|
||||
>(null);
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
const [cancelBusy, setCancelBusy] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const accolades = nomination.accolades;
|
||||
const isTruncated = accolades.length > ACCOLADES_PREVIEW_LENGTH;
|
||||
const accoladesPreview =
|
||||
isTruncated && !expanded
|
||||
? `${accolades.slice(0, ACCOLADES_PREVIEW_LENGTH).trimEnd()}...`
|
||||
: accolades;
|
||||
|
||||
const canWithdraw = actions === "withdraw" && nomination.status === "pending";
|
||||
|
||||
async function handleAction(note: string) {
|
||||
let result: Awaited<ReturnType<typeof endorseNomination>>;
|
||||
if (dialog === "endorse") {
|
||||
result = await endorseNomination(nomination.id, note || undefined);
|
||||
} else if (dialog === "leader-reject") {
|
||||
result = await rejectNomination(nomination.id, note || undefined);
|
||||
} else if (dialog === "approve") {
|
||||
result = await resolveNomination(nomination.id, true, note || undefined);
|
||||
} else if (dialog === "final-reject") {
|
||||
result = await resolveNomination(nomination.id, false, note || undefined);
|
||||
} else {
|
||||
return { success: false, error: "Choose a decision first." };
|
||||
}
|
||||
if (result.success) router.refresh();
|
||||
return result;
|
||||
}
|
||||
|
||||
async function handleWithdraw() {
|
||||
if (!confirmCancel) {
|
||||
setConfirmCancel(true);
|
||||
return;
|
||||
}
|
||||
setCancelBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await cancelNomination(nomination.id);
|
||||
if (result.success) {
|
||||
setConfirmCancel(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(result.error ?? "Could not withdraw this nomination.");
|
||||
}
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error ? caughtError.message : "Could not withdraw this nomination.",
|
||||
);
|
||||
} finally {
|
||||
setCancelBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ScrollTextIcon className="size-4 text-muted-foreground" />
|
||||
{nomination.nomineeName}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Nominated by {nomination.nominatorName} · {formatNominationDate(nomination.createdAt)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={NOMINATION_STATUS_VARIANTS[nomination.status]}>
|
||||
{NOMINATION_STATUS_LABELS[nomination.status]}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Accolades
|
||||
</span>
|
||||
<p className="whitespace-pre-line text-sm text-muted-foreground">{accoladesPreview}</p>
|
||||
{isTruncated && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="w-fit px-0"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<Badge variant="outline">{NOMINATION_DECISION_LABELS[nomination.leaderDecision]}</Badge>
|
||||
{nomination.leaderDecisionByName && (
|
||||
<span className="text-muted-foreground">
|
||||
by {nomination.leaderDecisionByName} ·{" "}
|
||||
{formatNominationDate(nomination.leaderDecidedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{nomination.leaderNote && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Leader note
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">{nomination.leaderNote}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nomination.reviewNote && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Final review note
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">{nomination.reviewNote}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nomination.reviewedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Resolved {formatNominationDate(nomination.reviewedAt)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{(actions === "leader" || actions === "final" || canWithdraw) && (
|
||||
<CardFooter className="flex flex-wrap gap-2">
|
||||
{actions === "leader" && (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setDialog("endorse")}>
|
||||
<CheckIcon data-icon="inline-start" />
|
||||
Endorse
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setDialog("leader-reject")}>
|
||||
<XIcon data-icon="inline-start" />
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{actions === "final" && (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setDialog("approve")}>
|
||||
<CheckIcon data-icon="inline-start" />
|
||||
Approve promotion
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setDialog("final-reject")}>
|
||||
<XIcon data-icon="inline-start" />
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canWithdraw && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => void handleWithdraw()}
|
||||
disabled={cancelBusy}
|
||||
>
|
||||
{cancelBusy
|
||||
? "Withdrawing..."
|
||||
: confirmCancel
|
||||
? "Confirm withdrawal?"
|
||||
: "Withdraw nomination"}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
)}
|
||||
|
||||
{dialog !== null && (
|
||||
<NominationNoteDialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDialog(null);
|
||||
}}
|
||||
title={
|
||||
dialog === "endorse"
|
||||
? `Endorse ${nomination.nomineeName}'s nomination`
|
||||
: dialog === "approve"
|
||||
? `Approve promotion for ${nomination.nomineeName}`
|
||||
: `Reject the nomination for ${nomination.nomineeName}`
|
||||
}
|
||||
description={
|
||||
dialog === "endorse"
|
||||
? "Endorsing sends the nomination to command for final approval."
|
||||
: dialog === "approve"
|
||||
? "Approving promotes the member exactly one rung up the rank ladder."
|
||||
: "This is a terminal decision and will be visible to the nominator only."
|
||||
}
|
||||
fieldId={`nomination-note-${nomination.id}`}
|
||||
fieldLabel="Note"
|
||||
placeholder="Add a note for the nominator (optional)."
|
||||
submitLabel={
|
||||
dialog === "endorse"
|
||||
? "Endorse nomination"
|
||||
: dialog === "approve"
|
||||
? "Approve promotion"
|
||||
: "Reject nomination"
|
||||
}
|
||||
successMessage={
|
||||
dialog === "endorse"
|
||||
? "Nomination endorsed"
|
||||
: dialog === "approve"
|
||||
? "Promotion approved"
|
||||
: "Nomination rejected"
|
||||
}
|
||||
required={false}
|
||||
submitVariant={
|
||||
dialog === "leader-reject" || dialog === "final-reject" ? "destructive" : "default"
|
||||
}
|
||||
onSubmit={handleAction}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
139
src/components/frontend/promotions/NominationNoteDialog.tsx
Normal file
139
src/components/frontend/promotions/NominationNoteDialog.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
export type NominationActionResult = {
|
||||
readonly success: boolean;
|
||||
readonly error?: string;
|
||||
};
|
||||
|
||||
interface NominationNoteDialogProps {
|
||||
readonly open: boolean;
|
||||
readonly onOpenChange: (open: boolean) => void;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly fieldId: string;
|
||||
readonly fieldLabel: string;
|
||||
readonly placeholder: string;
|
||||
readonly submitLabel: string;
|
||||
readonly successMessage: string;
|
||||
readonly required: boolean;
|
||||
readonly submitVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
readonly onSubmit: (note: string) => Promise<NominationActionResult>;
|
||||
}
|
||||
|
||||
export function NominationNoteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
fieldId,
|
||||
fieldLabel,
|
||||
placeholder,
|
||||
submitLabel,
|
||||
successMessage,
|
||||
required,
|
||||
submitVariant = "default",
|
||||
onSubmit,
|
||||
}: NominationNoteDialogProps) {
|
||||
const [note, setNote] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
onOpenChange(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setNote("");
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const trimmedNote = note.trim();
|
||||
if (required && !trimmedNote) {
|
||||
setError("A reason is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await onSubmit(trimmedNote);
|
||||
if (result.success) {
|
||||
toast.success(successMessage);
|
||||
handleOpenChange(false);
|
||||
} else {
|
||||
setError(result.error ?? "Could not complete this action.");
|
||||
}
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error ? caughtError.message : "Could not complete this action.",
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={fieldId}>
|
||||
{fieldLabel}
|
||||
{required ? " *" : " (optional)"}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={fieldId}
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={4}
|
||||
required={required}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={pending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant={submitVariant} disabled={pending}>
|
||||
{pending && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
|
||||
{pending ? "Submitting..." : submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
142
src/components/frontend/promotions/PromotionsView.tsx
Normal file
142
src/components/frontend/promotions/PromotionsView.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ClipboardCheckIcon, FileClockIcon, GavelIcon, SendIcon } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
NominationCard,
|
||||
type NominationCardActions,
|
||||
} from "@/components/frontend/promotions/NominationCard";
|
||||
import type { NominationView } from "@/components/frontend/promotions/nominationTypes";
|
||||
|
||||
interface PromotionsViewProps {
|
||||
readonly isSuperuserViewer: boolean;
|
||||
readonly isLeader: boolean;
|
||||
readonly leaderQueue: readonly NominationView[];
|
||||
readonly finalQueue: readonly NominationView[];
|
||||
readonly mySubmissions: readonly NominationView[];
|
||||
readonly recentlyResolved: readonly NominationView[];
|
||||
}
|
||||
|
||||
function NominationSection({
|
||||
icon: Icon,
|
||||
title,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
readonly icon: LucideIcon;
|
||||
readonly title: string;
|
||||
readonly count: number;
|
||||
readonly children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section aria-labelledby={`${title}-heading`} className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 id={`${title}-heading`} className="text-sm font-semibold uppercase tracking-wider">
|
||||
{title}
|
||||
</h2>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyNominationSection({ message }: { readonly message: string }) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function NominationGrid({
|
||||
nominations,
|
||||
actionFor,
|
||||
}: {
|
||||
readonly nominations: readonly NominationView[];
|
||||
readonly actionFor: (nomination: NominationView) => NominationCardActions;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{nominations.map((nomination) => (
|
||||
<NominationCard
|
||||
key={nomination.id}
|
||||
nomination={nomination}
|
||||
actions={actionFor(nomination)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromotionsView({
|
||||
isSuperuserViewer,
|
||||
isLeader,
|
||||
leaderQueue,
|
||||
finalQueue,
|
||||
mySubmissions,
|
||||
recentlyResolved,
|
||||
}: PromotionsViewProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{isLeader && (
|
||||
<NominationSection
|
||||
icon={ClipboardCheckIcon}
|
||||
title="Awaiting your endorsement"
|
||||
count={leaderQueue.length}
|
||||
>
|
||||
{leaderQueue.length === 0 ? (
|
||||
<EmptyNominationSection message="No pending nominations need your endorsement." />
|
||||
) : (
|
||||
<NominationGrid nominations={leaderQueue} actionFor={() => "leader"} />
|
||||
)}
|
||||
</NominationSection>
|
||||
)}
|
||||
|
||||
{isSuperuserViewer && (
|
||||
<NominationSection
|
||||
icon={GavelIcon}
|
||||
title="Awaiting final approval"
|
||||
count={finalQueue.length}
|
||||
>
|
||||
{finalQueue.length === 0 ? (
|
||||
<EmptyNominationSection message="No nominations are awaiting a final decision." />
|
||||
) : (
|
||||
<NominationGrid nominations={finalQueue} actionFor={() => "final"} />
|
||||
)}
|
||||
</NominationSection>
|
||||
)}
|
||||
|
||||
<NominationSection icon={SendIcon} title="My submissions" count={mySubmissions.length}>
|
||||
{mySubmissions.length === 0 ? (
|
||||
<EmptyNominationSection message="You have not nominated anyone for promotion yet." />
|
||||
) : (
|
||||
<NominationGrid
|
||||
nominations={mySubmissions}
|
||||
actionFor={(nomination) => (nomination.status === "pending" ? "withdraw" : "none")}
|
||||
/>
|
||||
)}
|
||||
</NominationSection>
|
||||
|
||||
{(isSuperuserViewer || isLeader) && (
|
||||
<NominationSection
|
||||
icon={FileClockIcon}
|
||||
title="Recently resolved"
|
||||
count={recentlyResolved.length}
|
||||
>
|
||||
{recentlyResolved.length === 0 ? (
|
||||
<EmptyNominationSection message="No resolved nominations to show." />
|
||||
) : (
|
||||
<NominationGrid nominations={recentlyResolved} actionFor={() => "none"} />
|
||||
)}
|
||||
</NominationSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/frontend/promotions/nominationTypes.ts
Normal file
63
src/components/frontend/promotions/nominationTypes.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type {
|
||||
NominationLeaderDecision,
|
||||
NominationStatus,
|
||||
} from "@/collections/users/PromotionNominations";
|
||||
|
||||
export type NominationStatusValue = NominationStatus;
|
||||
export type NominationLeaderDecisionValue = NominationLeaderDecision;
|
||||
|
||||
/**
|
||||
* Serializable projection of a promotion nomination the review page passes to
|
||||
* the client view. Names are resolved server-side; the client never talks to
|
||||
* the REST API for this data.
|
||||
*/
|
||||
export type NominationView = {
|
||||
readonly id: number;
|
||||
readonly nomineeId: number;
|
||||
readonly nomineeName: string;
|
||||
readonly nominatorId: number;
|
||||
readonly nominatorName: string;
|
||||
readonly accolades: string;
|
||||
readonly status: NominationStatusValue;
|
||||
readonly leaderDecision: NominationLeaderDecisionValue;
|
||||
readonly leaderDecisionByName: string | null;
|
||||
readonly leaderNote: string | null;
|
||||
readonly reviewNote: string | null;
|
||||
readonly createdAt: string;
|
||||
readonly leaderDecidedAt: string | null;
|
||||
readonly reviewedAt: string | null;
|
||||
};
|
||||
|
||||
export type BadgeVariant = "default" | "secondary" | "destructive" | "outline";
|
||||
|
||||
export const NOMINATION_STATUS_LABELS = {
|
||||
pending: "Awaiting endorsement",
|
||||
awaiting_superuser: "Awaiting final approval",
|
||||
approved: "Approved",
|
||||
rejected: "Rejected",
|
||||
cancelled: "Withdrawn",
|
||||
} as const satisfies Record<NominationStatusValue, string>;
|
||||
|
||||
export const NOMINATION_STATUS_VARIANTS = {
|
||||
pending: "secondary",
|
||||
awaiting_superuser: "outline",
|
||||
approved: "default",
|
||||
rejected: "destructive",
|
||||
cancelled: "secondary",
|
||||
} as const satisfies Record<NominationStatusValue, BadgeVariant>;
|
||||
|
||||
export const NOMINATION_DECISION_LABELS = {
|
||||
pending: "Leader decision pending",
|
||||
endorsed: "Leader endorsed",
|
||||
rejected: "Leader rejected",
|
||||
not_required: "Leader endorsement not required",
|
||||
} as const satisfies Record<NominationLeaderDecisionValue, string>;
|
||||
|
||||
export function formatNominationDate(iso: string | null): string {
|
||||
if (!iso) return "Unknown date";
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "Unknown date";
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
|
||||
date,
|
||||
);
|
||||
}
|
||||
147
src/components/frontend/roster/RemoveMemberDialog.tsx
Normal file
147
src/components/frontend/roster/RemoveMemberDialog.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { UserMinusIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { requestDivisionRemoval } from "@/app/(frontend)/roster/actions";
|
||||
|
||||
interface RemoveMemberDialogProps {
|
||||
readonly memberId: number;
|
||||
readonly memberLabel: string;
|
||||
readonly assignmentId: number;
|
||||
readonly assignmentName: string;
|
||||
/** Display name of the configured Infantry assignment, when one exists. */
|
||||
readonly infantryName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leader-only removal dialog launched from a roster MemberCard. Honest copy:
|
||||
* submitting creates a transfer request for command approval; nothing moves
|
||||
* until a superuser approves.
|
||||
*/
|
||||
export function RemoveMemberDialog({
|
||||
memberId,
|
||||
memberLabel,
|
||||
assignmentId,
|
||||
assignmentName,
|
||||
infantryName,
|
||||
}: RemoveMemberDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const destinationLabel = infantryName?.trim() ? infantryName : "the Infantry division";
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setReason("");
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await requestDivisionRemoval({
|
||||
subjectId: memberId,
|
||||
fromAssignmentId: assignmentId,
|
||||
...(reason.trim() ? { reason: reason.trim() } : {}),
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success(`Removal request for ${memberLabel} submitted`);
|
||||
handleOpenChange(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(result.error ?? "Could not submit the removal request.");
|
||||
}
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Could not submit the removal request.",
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
aria-label={`Remove ${memberLabel} from ${assignmentName}`}
|
||||
title="Remove from division"
|
||||
>
|
||||
<UserMinusIcon className="size-3.5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Remove {memberLabel} from {assignmentName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This creates a transfer request for {memberLabel} to {destinationLabel}. It takes
|
||||
effect once command approves.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="flex flex-col gap-4" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="remove-member-reason">Reason (optional)</Label>
|
||||
<Textarea
|
||||
id="remove-member-reason"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="Why should command move this member?"
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={pending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Submitting..." : "Submit removal request"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import Link from "next/link";
|
|||
import { cn, pluralize } from "@/lib/utils";
|
||||
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { RemoveMemberDialog } from "@/components/frontend/roster/RemoveMemberDialog";
|
||||
import {
|
||||
Building2Icon,
|
||||
CalendarDaysIcon,
|
||||
|
|
@ -16,6 +17,23 @@ import {
|
|||
|
||||
const RosterDisplayContext = React.createContext<"lead" | "actual">("lead");
|
||||
|
||||
/**
|
||||
* Viewer-scoped data threaded down for the leader-only removal affordance:
|
||||
* the authenticated user's id (null for the typed guest case; the layout gates
|
||||
* the page) and the configured Infantry assignment name for the removal dialog
|
||||
* copy. Kept in a context (like RosterDisplayContext) so the card components
|
||||
* keep their existing prop signatures.
|
||||
*/
|
||||
type RosterViewerContextValue = {
|
||||
readonly currentUserId: number | null;
|
||||
readonly infantryName: string | null;
|
||||
};
|
||||
|
||||
const RosterViewerContext = React.createContext<RosterViewerContextValue>({
|
||||
currentUserId: null,
|
||||
infantryName: null,
|
||||
});
|
||||
|
||||
const TYPE_ORDER: Record<string, number> = {
|
||||
division: 0,
|
||||
squad: 1,
|
||||
|
|
@ -218,7 +236,7 @@ function UnitBox({
|
|||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground flex items-center gap-1 mb-1">
|
||||
<CrownIcon className="size-3" /> Commanding
|
||||
</span>
|
||||
<MemberCard member={leader} />
|
||||
<MemberCard member={leader} owningAssignment={node} />
|
||||
<hr className="my-2 w-1/3 mx-auto"/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -226,7 +244,7 @@ function UnitBox({
|
|||
{directMembers.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{directMembers.map((m) => (
|
||||
<MemberCard key={m.profileId} member={m} />
|
||||
<MemberCard key={m.profileId} member={m} owningAssignment={node} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -286,7 +304,7 @@ function ByAssignmentView({
|
|||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5">
|
||||
{groupMembers.map((m) => (
|
||||
<MemberCard key={m.profileId} member={m} />
|
||||
<MemberCard key={m.profileId} member={m} owningAssignment={assignment} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -476,8 +494,15 @@ function SeniorityView({ members }: { members: RosterMember[] }) {
|
|||
|
||||
// ─── Member Card ────────────────────────────────────────────────────
|
||||
|
||||
function MemberCard({ member }: { member: RosterMember }) {
|
||||
function MemberCard({
|
||||
member,
|
||||
owningAssignment,
|
||||
}: {
|
||||
member: RosterMember;
|
||||
owningAssignment?: RosterAssignment;
|
||||
}) {
|
||||
const leadOrActual = React.useContext(RosterDisplayContext);
|
||||
const viewer = React.useContext(RosterViewerContext);
|
||||
const leadLabel = leadOrActual === "actual" ? "Actual" : "Lead";
|
||||
const serviceYears = completedServiceYears(member.enlistmentDate);
|
||||
const serviceLabel =
|
||||
|
|
@ -487,16 +512,30 @@ function MemberCard({ member }: { member: RosterMember }) {
|
|||
? "Less than a year"
|
||||
: `${serviceYears} year${serviceYears === 1 ? "" : "s"}`;
|
||||
|
||||
// Leader-only removal affordance: the viewer must be the current leader of
|
||||
// the member's division and cannot remove themself. The server action
|
||||
// re-validates every guard, so this is a display gate only. Only shown in
|
||||
// tabs that know the member's containing assignment (org chart, by
|
||||
// assignment). The assignment chip is suppressed while the action is shown
|
||||
// so the two do not collide at the card's right edge.
|
||||
const canRemove =
|
||||
viewer.currentUserId != null &&
|
||||
owningAssignment != null &&
|
||||
owningAssignment.type === "division" &&
|
||||
owningAssignment.leaderId === viewer.currentUserId &&
|
||||
member.user.id !== viewer.currentUserId;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/profile/${member.user.username}`}
|
||||
className="relative flex items-center gap-2.5 rounded-md border border-border bg-muted/30 px-3 py-2 hover:bg-accent hover:border-accent-foreground/20 transition-colors"
|
||||
>
|
||||
<div className="relative flex items-center gap-2.5 rounded-md border border-border bg-muted/30 px-3 py-2 hover:bg-accent hover:border-accent-foreground/20 transition-colors">
|
||||
{serviceLabel && (
|
||||
<span className="absolute top-1 right-2 text-[9px] leading-none text-muted-foreground/70 font-mono pointer-events-none">
|
||||
{serviceLabel} of service
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
href={`/profile/${member.user.username}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5"
|
||||
>
|
||||
{member.rank.insigniaUrl && (
|
||||
<img
|
||||
src={member.rank.insigniaUrl}
|
||||
|
|
@ -510,19 +549,30 @@ function MemberCard({ member }: { member: RosterMember }) {
|
|||
{member.rank.abbreviation} · @{member.user.username}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{member.isLeader && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-amber-400 bg-amber-950/50 border border-amber-800/50 px-1.5 py-0.5 rounded font-semibold">
|
||||
{leadLabel}
|
||||
</span>
|
||||
)}
|
||||
{member.assignmentName && (
|
||||
{canRemove ? (
|
||||
<RemoveMemberDialog
|
||||
memberId={member.user.id}
|
||||
memberLabel={member.user.displayName}
|
||||
assignmentId={owningAssignment.id}
|
||||
assignmentName={owningAssignment.name}
|
||||
infantryName={viewer.infantryName}
|
||||
/>
|
||||
) : (
|
||||
member.assignmentName && (
|
||||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-mono hidden sm:block">
|
||||
{member.assignmentCallsign ? `[${member.assignmentCallsign}]` : member.assignmentName}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -532,12 +582,22 @@ export function RosterView({
|
|||
members,
|
||||
assignments,
|
||||
leadOrActual = "lead",
|
||||
currentUserId,
|
||||
infantryName,
|
||||
}: {
|
||||
members: RosterMember[];
|
||||
assignments: RosterAssignment[];
|
||||
leadOrActual?: "lead" | "actual";
|
||||
currentUserId?: number;
|
||||
infantryName?: string | null;
|
||||
}) {
|
||||
const viewer = React.useMemo<RosterViewerContextValue>(
|
||||
() => ({ currentUserId: currentUserId ?? null, infantryName: infantryName ?? null }),
|
||||
[currentUserId, infantryName],
|
||||
);
|
||||
|
||||
return (
|
||||
<RosterViewerContext.Provider value={viewer}>
|
||||
<RosterDisplayContext.Provider value={leadOrActual}>
|
||||
<Tabs defaultValue="org">
|
||||
<TabsList variant="line">
|
||||
|
|
@ -580,5 +640,6 @@ export function RosterView({
|
|||
</TabsContent>
|
||||
</Tabs>
|
||||
</RosterDisplayContext.Provider>
|
||||
</RosterViewerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
132
src/components/frontend/transfers/LeadershipTransferCard.tsx
Normal file
132
src/components/frontend/transfers/LeadershipTransferCard.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, UserMinusIcon, XIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { resolveLeadershipTransfer } from "@/app/(frontend)/roster/actions";
|
||||
import { TransferNoteDialog } from "@/components/frontend/transfers/TransferNoteDialog";
|
||||
import {
|
||||
assignmentLabel,
|
||||
formatTransferDate,
|
||||
personLabel,
|
||||
} from "@/components/frontend/transfers/transferTypes";
|
||||
import type { Assignment, LeadershipTransfer } from "@/payload-types";
|
||||
|
||||
type ResolutionMode = "approve" | "deny";
|
||||
|
||||
interface LeadershipTransferCardProps {
|
||||
readonly transfer: LeadershipTransfer;
|
||||
readonly onUpdated: () => Promise<void>;
|
||||
}
|
||||
|
||||
function assignmentRef(value: number | Assignment | null | undefined): string {
|
||||
if (value == null) return "Unknown assignment";
|
||||
if (typeof value === "number") return `Assignment #${value}`;
|
||||
return value.callsign ? `[${value.callsign}] ${value.name}` : value.name;
|
||||
}
|
||||
|
||||
/** Superuser-facing pending removal request card with Approve/Deny + note dialog. */
|
||||
export function LeadershipTransferCard({ transfer, onUpdated }: LeadershipTransferCardProps) {
|
||||
const [resolutionMode, setResolutionMode] = useState<ResolutionMode | null>(null);
|
||||
|
||||
async function handleResolve(note: string) {
|
||||
if (resolutionMode === null) {
|
||||
return { success: false, error: "Choose a resolution first." };
|
||||
}
|
||||
const result = await resolveLeadershipTransfer(
|
||||
transfer.id,
|
||||
resolutionMode === "approve",
|
||||
note || undefined,
|
||||
);
|
||||
if (result.success) void onUpdated();
|
||||
return result;
|
||||
}
|
||||
|
||||
const isApproving = resolutionMode === "approve";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<UserMinusIcon className="size-4 text-muted-foreground" />
|
||||
Removal #{transfer.id}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{personLabel(transfer.initiator)} · requested {formatTransferDate(transfer.createdAt)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary">Pending approval</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Member
|
||||
</span>
|
||||
<span className="truncate text-sm">{personLabel(transfer.subject)}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Moving
|
||||
</span>
|
||||
<span className="truncate text-sm">
|
||||
{assignmentRef(transfer.fromAssignment)} → {assignmentRef(transfer.toAssignment)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{transfer.reason && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Stated reason
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">{transfer.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => setResolutionMode("approve")}>
|
||||
<CheckIcon data-icon="inline-start" />
|
||||
Approve & transfer
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setResolutionMode("deny")}>
|
||||
<XIcon data-icon="inline-start" />
|
||||
Deny request
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
||||
<TransferNoteDialog
|
||||
open={resolutionMode !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setResolutionMode(null);
|
||||
}}
|
||||
title={isApproving ? "Approve removal request" : "Deny removal request"}
|
||||
description={
|
||||
isApproving
|
||||
? "This moves the member into the destination division and notifies everyone involved."
|
||||
: "This denies the removal. Only the requesting leader will be notified."
|
||||
}
|
||||
fieldId={`leadership-transfer-resolution-${transfer.id}`}
|
||||
fieldLabel="Review note"
|
||||
placeholder="Add a note for the record (optional)."
|
||||
submitLabel={isApproving ? "Approve & transfer" : "Deny request"}
|
||||
successMessage={isApproving ? "Removal approved" : "Removal denied"}
|
||||
required={false}
|
||||
submitVariant={isApproving ? "default" : "destructive"}
|
||||
onSubmit={handleResolve}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
287
src/components/frontend/transfers/LeadershipTransfersSection.tsx
Normal file
287
src/components/frontend/transfers/LeadershipTransfersSection.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { UserMinusIcon } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { cancelDivisionRemoval } from "@/app/(frontend)/roster/actions";
|
||||
import { LeadershipTransferCard } from "@/components/frontend/transfers/LeadershipTransferCard";
|
||||
import { formatTransferDate } from "@/components/frontend/transfers/transferTypes";
|
||||
import type { Assignment, LeadershipTransfer, User } from "@/payload-types";
|
||||
|
||||
interface LeadershipTransfersSectionProps {
|
||||
readonly currentUserId: number;
|
||||
readonly canResolve: boolean;
|
||||
}
|
||||
|
||||
const LEADERSHIP_QUERY = new URLSearchParams({
|
||||
depth: "1",
|
||||
limit: "50",
|
||||
sort: "-createdAt",
|
||||
});
|
||||
|
||||
const STATUS_LABELS: Record<LeadershipTransfer["status"], string> = {
|
||||
pending: "Pending approval",
|
||||
approved: "Approved",
|
||||
denied: "Denied",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
|
||||
const STATUS_VARIANTS: Record<
|
||||
LeadershipTransfer["status"],
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
pending: "secondary",
|
||||
approved: "default",
|
||||
denied: "destructive",
|
||||
cancelled: "outline",
|
||||
};
|
||||
|
||||
function initiatorId(value: number | User | null | undefined): number | null {
|
||||
if (value == null) return null;
|
||||
return typeof value === "number" ? value : value.id;
|
||||
}
|
||||
|
||||
function personRefLabel(value: number | User | null | undefined, fallback = "Unknown member"): string {
|
||||
if (value == null) return fallback;
|
||||
if (typeof value === "number") return `User #${value}`;
|
||||
return value.displayName || value.username || fallback;
|
||||
}
|
||||
|
||||
function assignmentRef(value: number | Assignment | null | undefined): string {
|
||||
if (value == null) return "Unknown assignment";
|
||||
if (typeof value === "number") return `Assignment #${value}`;
|
||||
return value.callsign ? `[${value.callsign}] ${value.name}` : value.name;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
/** Shallow-shape-check the REST page so a malformed response surfaces as an error. */
|
||||
function parseDocs(data: unknown): LeadershipTransfer[] | null {
|
||||
if (!isRecord(data) || !Array.isArray(data.docs)) return null;
|
||||
return data.docs.filter((doc) => isRecord(doc) && typeof doc.id === "number") as LeadershipTransfer[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Leadership-initiated removal requests on the transfers page.
|
||||
*
|
||||
* Superusers get the pending queue (Approve/Deny with note) plus a collapsed
|
||||
* history. Regular members only ever see their own requests as status chips;
|
||||
* the whole section stays hidden when they have none (pending removals are
|
||||
* invisible to the unit by design).
|
||||
*/
|
||||
export function LeadershipTransfersSection({
|
||||
currentUserId,
|
||||
canResolve,
|
||||
}: LeadershipTransfersSectionProps) {
|
||||
const [docs, setDocs] = useState<readonly LeadershipTransfer[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancellingId, setCancellingId] = useState<number | null>(null);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/leadership-transfers?${LEADERSHIP_QUERY.toString()}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error("Could not load leadership transfers.");
|
||||
|
||||
const body: unknown = await response.json();
|
||||
const parsed = parseDocs(body);
|
||||
if (!parsed) throw new Error("The leadership transfer response was invalid.");
|
||||
setDocs(parsed);
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Could not load leadership transfers.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const pending = useMemo(() => (docs ?? []).filter((doc) => doc.status === "pending"), [docs]);
|
||||
const mine = useMemo(
|
||||
() => (docs ?? []).filter((doc) => initiatorId(doc.initiator) === currentUserId),
|
||||
[currentUserId, docs],
|
||||
);
|
||||
const resolved = useMemo(() => (docs ?? []).filter((doc) => doc.status !== "pending"), [docs]);
|
||||
|
||||
async function handleCancel(transferId: number) {
|
||||
setCancellingId(transferId);
|
||||
try {
|
||||
const result = await cancelDivisionRemoval(transferId);
|
||||
if (result.success) {
|
||||
toast.success("Removal request cancelled");
|
||||
void load();
|
||||
} else {
|
||||
toast.error(result.error ?? "Could not cancel the removal request.");
|
||||
}
|
||||
} finally {
|
||||
setCancellingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Regular members: hide the section entirely while loading, on fetch errors,
|
||||
// and whenever they have no requests of their own.
|
||||
if (!canResolve) {
|
||||
if (docs === null || mine.length === 0) return null;
|
||||
return (
|
||||
<section aria-labelledby="leadership-transfers-heading" className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserMinusIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
<h2
|
||||
id="leadership-transfers-heading"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Leadership transfers
|
||||
</h2>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{mine.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{mine.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm"
|
||||
>
|
||||
<Badge variant={STATUS_VARIANTS[doc.status]}>{STATUS_LABELS[doc.status]}</Badge>
|
||||
<span>
|
||||
Removal of <span className="font-medium">{personRefLabel(doc.subject)}</span> from{" "}
|
||||
{assignmentRef(doc.fromAssignment)} to {assignmentRef(doc.toAssignment)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
requested {formatTransferDate(doc.createdAt)}
|
||||
</span>
|
||||
{doc.status === "pending" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="ml-auto"
|
||||
onClick={() => void handleCancel(doc.id)}
|
||||
disabled={cancellingId === doc.id}
|
||||
>
|
||||
Cancel request
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (docs === null && loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3" aria-busy="true" aria-label="Loading leadership transfers">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (docs === null && error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Leadership transfers unavailable</AlertTitle>
|
||||
<AlertDescription className="flex flex-wrap items-center gap-3">
|
||||
<span>{error}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => void load()}>
|
||||
Try again
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="leadership-transfers-heading" className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserMinusIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
<h2
|
||||
id="leadership-transfers-heading"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Leadership transfers
|
||||
</h2>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{pending.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Could not refresh leadership transfers</AlertTitle>
|
||||
<AlertDescription className="flex flex-wrap items-center gap-3">
|
||||
<span>{error}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => void load()}>
|
||||
Try again
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{pending.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>No pending removal requests need a final call.</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{pending.map((doc) => (
|
||||
<LeadershipTransferCard key={doc.id} transfer={doc} onUpdated={load} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resolved.length > 0 && (
|
||||
<Collapsible
|
||||
open={historyOpen}
|
||||
onOpenChange={setHistoryOpen}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="w-fit">
|
||||
History ({resolved.length})
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="flex flex-col gap-1.5">
|
||||
{resolved.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex flex-wrap items-center gap-2 border-b border-border/60 pb-1.5 text-sm"
|
||||
>
|
||||
<span className="font-medium">{personRefLabel(doc.initiator)}</span>
|
||||
<span className="text-muted-foreground">removed</span>
|
||||
<span>{personRefLabel(doc.subject)}</span>
|
||||
<span className="text-muted-foreground">
|
||||
from {assignmentRef(doc.fromAssignment)} to {assignmentRef(doc.toAssignment)}
|
||||
</span>
|
||||
<Badge variant={STATUS_VARIANTS[doc.status]}>{STATUS_LABELS[doc.status]}</Badge>
|
||||
{doc.resolvedAt && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{formatTransferDate(doc.resolvedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { TransferApprovalCard } from "@/components/frontend/transfers/TransferApprovalCard";
|
||||
import { TransferResolutionCard } from "@/components/frontend/transfers/TransferResolutionCard";
|
||||
import { TransferRequestCard } from "@/components/frontend/transfers/TransferRequestCard";
|
||||
import { LeadershipTransfersSection } from "@/components/frontend/transfers/LeadershipTransfersSection";
|
||||
import {
|
||||
relationId,
|
||||
assignmentLeaderId,
|
||||
|
|
@ -158,6 +159,7 @@ export function TransfersView({ currentUserId, canResolve }: TransfersViewProps)
|
|||
|
||||
if (!requests && error) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Transfer requests unavailable</AlertTitle>
|
||||
<AlertDescription className="flex flex-wrap items-center gap-3">
|
||||
|
|
@ -168,6 +170,8 @@ export function TransfersView({ currentUserId, canResolve }: TransfersViewProps)
|
|||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<LeadershipTransfersSection currentUserId={currentUserId} canResolve={canResolve} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -247,6 +251,8 @@ export function TransfersView({ currentUserId, canResolve }: TransfersViewProps)
|
|||
)}
|
||||
</TransferSection>
|
||||
)}
|
||||
|
||||
<LeadershipTransfersSection currentUserId={currentUserId} canResolve={canResolve} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import type { Payload } from "payload";
|
||||
import type { Payload, PayloadRequest } from "payload";
|
||||
import type { Rank, User } from "@/payload-types";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
import {
|
||||
compareRanks,
|
||||
isAdminOrDeveloper,
|
||||
resolvePromotionAuthority,
|
||||
resolveRankFromLadder,
|
||||
} from "./authority";
|
||||
|
|
@ -46,17 +48,30 @@ async function findProfileByUserId(payload: Payload, userId: number) {
|
|||
* own rank's promotionCeiling (clamped below their own rank), or unbounded for
|
||||
* admins/developers. Promotions only: the target rank must be strictly above
|
||||
* the member's current rank and strictly below the actor's own rank. Emits a
|
||||
* `personnel:promoted` event and notifies the promoted member.
|
||||
* `personnel:promoted` event and notifies the promoted member. Pass `req` to
|
||||
* join the rank write into an outer transaction (e.g. the nomination
|
||||
* approval flow).
|
||||
*
|
||||
* `ignoreActorRankLimit` lets an admin/developer or roleDocs superuser act as
|
||||
* unit command rather than as a rank-holder: the "strictly below the actor's
|
||||
* own rank" bound and their promotion ceiling stop applying, and the actor may
|
||||
* hold no ranked profile at all. Reserved for flows where the final decision
|
||||
* is gated elsewhere (promotion nomination approval).
|
||||
*/
|
||||
export async function promoteMember(
|
||||
payload: Payload,
|
||||
actor: User,
|
||||
username: string,
|
||||
newRankId: number,
|
||||
options?: { req?: Partial<PayloadRequest>; ignoreActorRankLimit?: boolean },
|
||||
): Promise<PromotionResult> {
|
||||
const { authority, ladder, actorRank } = await resolvePromotionAuthority(payload, actor);
|
||||
|
||||
if (!authority || !actorRank) {
|
||||
const commandBypass =
|
||||
options?.ignoreActorRankLimit === true &&
|
||||
(isAdminOrDeveloper(actor) || (await isSuperuser(payload, actor)));
|
||||
|
||||
if ((!authority || !actorRank) && !commandBypass) {
|
||||
throw new Error("You do not have promotion authority.");
|
||||
}
|
||||
|
||||
|
|
@ -85,13 +100,18 @@ export async function promoteMember(
|
|||
throw new Error("Promotions only: the new rank must be above the member's current rank.");
|
||||
}
|
||||
|
||||
// Never at or above the actor's own rank.
|
||||
if (compareRanks(ladder, toRank, actorRank) >= 0) {
|
||||
// Never at or above the actor's own rank. Command authority (superuser
|
||||
// approval flows) acts for the unit and bypasses this bound.
|
||||
if (!commandBypass && actorRank && compareRanks(ladder, toRank, actorRank) >= 0) {
|
||||
throw new Error("You cannot promote to a rank at or above your own.");
|
||||
}
|
||||
|
||||
// Ceiling bound for non-admin authority.
|
||||
if (authority.kind === "ceiling" && compareRanks(ladder, toRank, authority.maxRank) > 0) {
|
||||
if (
|
||||
!commandBypass &&
|
||||
authority?.kind === "ceiling" &&
|
||||
compareRanks(ladder, toRank, authority.maxRank) > 0
|
||||
) {
|
||||
throw new Error(`You cannot promote above ${authority.maxRank.name}.`);
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +120,7 @@ export async function promoteMember(
|
|||
id: targetProfile.id,
|
||||
data: { rank: toRank.id },
|
||||
overrideAccess: true,
|
||||
...(options?.req ? { req: options.req } : {}),
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
|
|
|
|||
510
src/lib/promotions/nominations.ts
Normal file
510
src/lib/promotions/nominations.ts
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
import type { Payload, PayloadRequest, Where } from "payload";
|
||||
import type { Assignment, PromotionNomination, User } from "@/payload-types";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
import { loadRankLadder, rankIndex, resolveRankFromLadder } from "./authority";
|
||||
import { promoteMember } from "./index";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants + internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NOMINATION_LINK = "/personnel/promotions";
|
||||
const MIN_ACCOLADES_LENGTH = 20;
|
||||
const OPEN_NOMINATION_STATUSES = ["pending", "awaiting_superuser"] as const;
|
||||
|
||||
/** Label for a possibly-unpopulated user reference. */
|
||||
function userLabelFromRef(ref: number | User | null | undefined): string | null {
|
||||
if (ref == null || typeof ref !== "object") return null;
|
||||
return ref.displayName || ref.username;
|
||||
}
|
||||
|
||||
/** Coerce a Payload relationship value (numeric id or resolved doc) to its numeric id. */
|
||||
function relId(value: number | User | null | undefined): number | null {
|
||||
if (value == null) return null;
|
||||
return typeof value === "number" ? value : value.id;
|
||||
}
|
||||
|
||||
function userLabel(user: Pick<User, "displayName" | "username">): string {
|
||||
return user.displayName || user.username;
|
||||
}
|
||||
|
||||
async function fetchUser(payload: Payload, userId: number): Promise<User | null> {
|
||||
const doc = await payload.findByID({
|
||||
collection: "users",
|
||||
id: userId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return doc ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare-and-set update on a nomination. Returns null when the where clause
|
||||
* matches nothing (the state changed underneath us); callers must throw a
|
||||
* descriptive error in that case.
|
||||
*/
|
||||
async function casUpdate(
|
||||
payload: Payload,
|
||||
where: Where,
|
||||
data: Partial<PromotionNomination>,
|
||||
req?: Partial<PayloadRequest>,
|
||||
): Promise<PromotionNomination | null> {
|
||||
const res = await payload.update({
|
||||
collection: "promotion-nominations",
|
||||
where,
|
||||
data,
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
...(req ? { req } : {}),
|
||||
});
|
||||
if (res.docs.length === 0) return null;
|
||||
return res.docs[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the open (not yet resolved) division-style assignment a user belongs to
|
||||
* as a member or leader, with the leader relationship populated.
|
||||
*/
|
||||
export async function resolveMemberDivision(
|
||||
payload: Payload,
|
||||
userId: number,
|
||||
): Promise<Assignment | null> {
|
||||
const res = await payload.find({
|
||||
collection: "assignments",
|
||||
where: {
|
||||
and: [
|
||||
{ type: { equals: "division" } },
|
||||
{
|
||||
or: [{ members: { equals: userId } }, { leader: { equals: userId } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return res.docs[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify every admin/developer about a nomination event. Mirrors the transfers
|
||||
* helper but lives here so the promotions lib owns no cross-domain import.
|
||||
*/
|
||||
async function notifySuperusersLocal(
|
||||
payload: Payload,
|
||||
opts: { type: string; title: string; message?: string; link?: string },
|
||||
): Promise<void> {
|
||||
const res = await payload.find({
|
||||
collection: "users",
|
||||
limit: 200,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const superuserIds = res.docs
|
||||
.filter((u) => u.roles?.some((role) => role === "admin" || role === "developer") === true)
|
||||
.map((u) => u.id);
|
||||
for (const id of superuserIds) {
|
||||
await notifyUser(payload, {
|
||||
userId: id,
|
||||
type: opts.type,
|
||||
title: opts.title,
|
||||
message: opts.message,
|
||||
link: opts.link ?? NOMINATION_LINK,
|
||||
bypassMute: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: single source of truth for the web actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Submit a promotion nomination. Guards: nominee exists and has a profile rank;
|
||||
* accolades carry at least a sentence; no already-open nomination for the
|
||||
* nominee; nominee is not at the top of the rank ladder. The nominee's division
|
||||
* leader endorses first; leaderless divisions (or a nomination by the leader
|
||||
* themselves) skip straight to final approval. Submissions stay private: no
|
||||
* public event is emitted for them.
|
||||
*/
|
||||
export async function submitNomination(
|
||||
payload: Payload,
|
||||
nominator: User,
|
||||
opts: { nomineeId: number; accolades: string },
|
||||
): Promise<PromotionNomination> {
|
||||
const accolades = opts.accolades.trim();
|
||||
if (accolades.length < MIN_ACCOLADES_LENGTH) {
|
||||
throw new Error("Provide at least a sentence describing their performance.");
|
||||
}
|
||||
|
||||
const nominee = await fetchUser(payload, opts.nomineeId);
|
||||
if (!nominee) {
|
||||
throw new Error("Nominee not found.");
|
||||
}
|
||||
|
||||
const profileRes = await payload.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: nominee.id } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const profile = profileRes.docs[0] ?? null;
|
||||
if (!profile?.rank) {
|
||||
throw new Error("This member does not have a rank on file.");
|
||||
}
|
||||
|
||||
const open = await payload.find({
|
||||
collection: "promotion-nominations",
|
||||
where: {
|
||||
and: [{ nominee: { equals: nominee.id } }, { status: { in: [...OPEN_NOMINATION_STATUSES] } }],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (open.docs.length > 0) {
|
||||
throw new Error("This member already has an open promotion nomination.");
|
||||
}
|
||||
|
||||
const ladder = await loadRankLadder(payload);
|
||||
const currentRank = resolveRankFromLadder(ladder, profile.rank);
|
||||
if (!currentRank) {
|
||||
throw new Error("This member does not have a rank on file.");
|
||||
}
|
||||
const currentIndex = rankIndex(ladder, currentRank.id);
|
||||
if (currentIndex + 1 >= ladder.length) {
|
||||
throw new Error(`${userLabel(nominee)} already holds the highest rank.`);
|
||||
}
|
||||
|
||||
const division = await resolveMemberDivision(payload, nominee.id);
|
||||
const divisionLeaderId = relId(division?.leader ?? null);
|
||||
const leaderMustDecide = divisionLeaderId != null && divisionLeaderId !== nominator.id;
|
||||
|
||||
const nominatorLabel = userLabel(nominator);
|
||||
const nomineeLabel = userLabel(nominee);
|
||||
|
||||
const created = await payload.create({
|
||||
collection: "promotion-nominations",
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
data: {
|
||||
nominee: nominee.id,
|
||||
nominator: nominator.id,
|
||||
accolades,
|
||||
status: leaderMustDecide ? "pending" : "awaiting_superuser",
|
||||
leaderDecision: leaderMustDecide ? "pending" : "not_required",
|
||||
},
|
||||
});
|
||||
|
||||
const message = `${nominatorLabel} nominated ${nomineeLabel} for promotion.`;
|
||||
if (leaderMustDecide && divisionLeaderId != null) {
|
||||
await notifyUser(payload, {
|
||||
userId: divisionLeaderId,
|
||||
type: "promotion:nomination",
|
||||
title: "Promotion nomination",
|
||||
message,
|
||||
link: NOMINATION_LINK,
|
||||
bypassMute: true,
|
||||
});
|
||||
} else {
|
||||
await notifySuperusersLocal(payload, {
|
||||
type: "promotion:nomination",
|
||||
title: "Promotion nomination",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the division leader's decision on a pending nomination. The actor
|
||||
* must be the nominee's CURRENT division leader (superuser bypass is for the
|
||||
* admin panel only). Endorsement moves the nomination to awaiting_superuser;
|
||||
* rejection is terminal. The nominator is notified either way.
|
||||
*/
|
||||
export async function recordLeaderDecision(
|
||||
payload: Payload,
|
||||
leader: User,
|
||||
nominationId: number,
|
||||
opts: { endorse: boolean; note?: string },
|
||||
): Promise<PromotionNomination> {
|
||||
const nomination = await payload.findByID({
|
||||
collection: "promotion-nominations",
|
||||
id: nominationId,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (!nomination) {
|
||||
throw new Error("Promotion nomination not found.");
|
||||
}
|
||||
if (nomination.status !== "pending" || nomination.leaderDecision !== "pending") {
|
||||
throw new Error("This nomination is not awaiting endorsement.");
|
||||
}
|
||||
|
||||
const nomineeId = relId(nomination.nominee);
|
||||
if (nomineeId == null) {
|
||||
throw new Error("This nomination has no nominee on file.");
|
||||
}
|
||||
const division = await resolveMemberDivision(payload, nomineeId);
|
||||
const currentLeaderId = relId(division?.leader ?? null);
|
||||
const actorIsSuperuser = await isSuperuser(payload, leader);
|
||||
if (!actorIsSuperuser && currentLeaderId !== leader.id) {
|
||||
throw new Error("Only the nominee's division leader can decide this nomination.");
|
||||
}
|
||||
|
||||
const trimmedNote = opts.note?.trim();
|
||||
const decidedAt = new Date().toISOString();
|
||||
const updated = await casUpdate(
|
||||
payload,
|
||||
{
|
||||
and: [
|
||||
{ id: { equals: nomination.id } },
|
||||
{ status: { equals: "pending" } },
|
||||
{ leaderDecision: { equals: "pending" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
status: opts.endorse ? "awaiting_superuser" : "rejected",
|
||||
leaderDecision: opts.endorse ? "endorsed" : "rejected",
|
||||
leaderDecisionBy: leader.id,
|
||||
leaderDecidedAt: decidedAt,
|
||||
...(trimmedNote ? { leaderNote: trimmedNote } : {}),
|
||||
},
|
||||
);
|
||||
if (!updated) {
|
||||
throw new Error("This nomination changed state before the decision could be recorded.");
|
||||
}
|
||||
|
||||
const nominatorId = relId(updated.nominator);
|
||||
if (nominatorId == null) {
|
||||
throw new Error("This nomination has no nominator on file.");
|
||||
}
|
||||
const nominatorDoc = await fetchUser(payload, nominatorId);
|
||||
const nominatorLabel = nominatorDoc ? userLabel(nominatorDoc) : `user #${nominatorId}`;
|
||||
const nomineeLabel = userLabelFromRef(nomination.nominee) ?? `user #${nomineeId}`;
|
||||
const leaderLabel = userLabel(leader);
|
||||
const noteSuffix = trimmedNote ? ` Note: "${trimmedNote}"` : "";
|
||||
|
||||
if (opts.endorse) {
|
||||
await notifyUser(payload, {
|
||||
userId: nominatorId,
|
||||
type: "promotion:endorsed",
|
||||
title: "Nomination endorsed",
|
||||
message: `${leaderLabel} endorsed your promotion nomination for ${nomineeLabel}.${noteSuffix}`,
|
||||
link: NOMINATION_LINK,
|
||||
bypassMute: true,
|
||||
});
|
||||
} else {
|
||||
await notifyUser(payload, {
|
||||
userId: nominatorId,
|
||||
type: "promotion:rejected",
|
||||
title: "Nomination declined",
|
||||
message: `${leaderLabel} declined the promotion nomination for ${nomineeLabel}.${noteSuffix}`,
|
||||
link: NOMINATION_LINK,
|
||||
bypassMute: true,
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final superuser call on an endorsed nomination. Approval bumps the nominee
|
||||
* exactly one rung up the ladder inside a single transaction (the nomination
|
||||
* update and the rank write commit together); a promoteMember failure rolls
|
||||
* the whole thing back and the nomination stays awaiting_superuser. Rejection
|
||||
* is terminal. Only the approval emits a public event; rejections stay private.
|
||||
*/
|
||||
export async function resolveNomination(
|
||||
payload: Payload,
|
||||
reviewer: User,
|
||||
nominationId: number,
|
||||
opts: { approve: boolean; note?: string },
|
||||
): Promise<PromotionNomination> {
|
||||
if (!(await isSuperuser(payload, reviewer))) {
|
||||
throw new Error("Only a superuser can resolve a promotion nomination.");
|
||||
}
|
||||
|
||||
const nomination = await payload.findByID({
|
||||
collection: "promotion-nominations",
|
||||
id: nominationId,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (!nomination) {
|
||||
throw new Error("Promotion nomination not found.");
|
||||
}
|
||||
if (nomination.status !== "awaiting_superuser") {
|
||||
throw new Error(
|
||||
`Only nominations awaiting final approval can be resolved (current status: ${nomination.status}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedNote = opts.note?.trim();
|
||||
const nomineeId = relId(nomination.nominee);
|
||||
const nominatorId = relId(nomination.nominator);
|
||||
if (nomineeId == null) {
|
||||
throw new Error("This nomination has no nominee on file.");
|
||||
}
|
||||
if (nominatorId == null) {
|
||||
throw new Error("This nomination has no nominator on file.");
|
||||
}
|
||||
|
||||
const nomineeDoc = await fetchUser(payload, nomineeId);
|
||||
const nominatorDoc = await fetchUser(payload, nominatorId);
|
||||
const nomineeLabel = nomineeDoc ? userLabel(nomineeDoc) : `user #${nomineeId}`;
|
||||
const nominatorLabel = nominatorDoc ? userLabel(nominatorDoc) : `user #${nominatorId}`;
|
||||
|
||||
if (!opts.approve) {
|
||||
const updated = await payload.update({
|
||||
collection: "promotion-nominations",
|
||||
id: nomination.id,
|
||||
data: {
|
||||
status: "rejected",
|
||||
reviewedBy: reviewer.id,
|
||||
reviewedAt: new Date().toISOString(),
|
||||
...(trimmedNote ? { reviewNote: trimmedNote } : {}),
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
await notifyUser(payload, {
|
||||
userId: nominatorId,
|
||||
type: "promotion:rejected",
|
||||
title: "Promotion declined",
|
||||
message: `The promotion nomination for ${nomineeLabel} was declined by final review.${
|
||||
trimmedNote ? ` Note: "${trimmedNote}"` : ""
|
||||
}`,
|
||||
link: NOMINATION_LINK,
|
||||
bypassMute: true,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
const txId = await payload.db.beginTransaction();
|
||||
if (txId == null) throw new Error("Failed to begin database transaction.");
|
||||
const req: Partial<PayloadRequest> = { transactionID: txId };
|
||||
let rolledBack = false;
|
||||
try {
|
||||
const approved = await casUpdate(
|
||||
payload,
|
||||
{
|
||||
and: [{ id: { equals: nomination.id } }, { status: { equals: "awaiting_superuser" } }],
|
||||
},
|
||||
{
|
||||
status: "approved",
|
||||
reviewedBy: reviewer.id,
|
||||
reviewedAt: new Date().toISOString(),
|
||||
...(trimmedNote ? { reviewNote: trimmedNote } : {}),
|
||||
},
|
||||
req,
|
||||
);
|
||||
if (!approved) {
|
||||
throw new Error("This nomination changed state before final approval could be recorded.");
|
||||
}
|
||||
|
||||
const profileRes = await payload.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: nomineeId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
});
|
||||
const profile = profileRes.docs[0] ?? null;
|
||||
const ladder = await loadRankLadder(payload);
|
||||
const currentRank = resolveRankFromLadder(ladder, profile?.rank);
|
||||
if (!profile?.rank || !currentRank) {
|
||||
throw new Error("The nominee does not have a rank on file.");
|
||||
}
|
||||
const nextRank = ladder[rankIndex(ladder, currentRank.id) + 1] ?? null;
|
||||
if (!nextRank) {
|
||||
throw new Error(
|
||||
"The nominee already holds the highest rank, so the approval cannot be applied.",
|
||||
);
|
||||
}
|
||||
if (!nomineeDoc) {
|
||||
throw new Error("The nominee could not be loaded for the promotion.");
|
||||
}
|
||||
|
||||
await promoteMember(payload, reviewer, nomineeDoc.username, nextRank.id, {
|
||||
req,
|
||||
ignoreActorRankLimit: true,
|
||||
});
|
||||
|
||||
await payload.db.commitTransaction(txId);
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.PromotionNominationApproved,
|
||||
message: `${nomineeLabel} was promoted to ${nextRank.name} via nomination by ${nominatorLabel}, approved by ${userLabel(reviewer)}.`,
|
||||
actor: reviewer.id,
|
||||
targetCollection: "promotion-nominations",
|
||||
targetId: nomination.id,
|
||||
data: {
|
||||
nominationId: nomination.id,
|
||||
nomineeId,
|
||||
nominatorId,
|
||||
fromRankId: currentRank.id,
|
||||
toRankId: nextRank.id,
|
||||
},
|
||||
});
|
||||
await notifyUser(payload, {
|
||||
userId: nominatorId,
|
||||
type: "promotion:approved",
|
||||
title: "Promotion approved",
|
||||
message: `Your promotion nomination for ${nomineeLabel} was approved: they now hold ${nextRank.name}.`,
|
||||
link: NOMINATION_LINK,
|
||||
bypassMute: true,
|
||||
});
|
||||
|
||||
return approved;
|
||||
} catch (err) {
|
||||
if (!rolledBack) await payload.db.rollbackTransaction(txId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw a nomination. Only the nominator, only while it is still awaiting
|
||||
* the division leader's endorsement. Silent by design: only the leader knew,
|
||||
* and a withdrawn nomination leaves no unit-visible trace.
|
||||
*/
|
||||
export async function cancelNomination(
|
||||
payload: Payload,
|
||||
nominator: User,
|
||||
nominationId: number,
|
||||
): Promise<PromotionNomination> {
|
||||
const nomination = await payload.findByID({
|
||||
collection: "promotion-nominations",
|
||||
id: nominationId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (!nomination) {
|
||||
throw new Error("Promotion nomination not found.");
|
||||
}
|
||||
if (nomination.status !== "pending") {
|
||||
throw new Error("Only pending nominations can be withdrawn.");
|
||||
}
|
||||
const nominationNominatorId = relId(nomination.nominator);
|
||||
if (nominationNominatorId !== nominator.id) {
|
||||
throw new Error("Only your own pending nominations can be withdrawn.");
|
||||
}
|
||||
|
||||
const updated = await casUpdate(
|
||||
payload,
|
||||
{
|
||||
and: [{ id: { equals: nomination.id } }, { status: { equals: "pending" } }],
|
||||
},
|
||||
{ status: "cancelled" },
|
||||
);
|
||||
if (!updated) {
|
||||
throw new Error("This nomination changed state before it could be withdrawn.");
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
|
@ -31,11 +31,15 @@ async function fetchTransfer(payload: Payload, requestId: number): Promise<Assig
|
|||
return doc;
|
||||
}
|
||||
|
||||
async function fetchAssignment(
|
||||
/**
|
||||
* Fetch an assignment's id/name/type/leader/members. Exported for the
|
||||
* leadership-transfer service, which shares the same re-validation needs.
|
||||
*/
|
||||
export async function fetchAssignment(
|
||||
payload: Payload,
|
||||
id: number,
|
||||
req?: Partial<PayloadRequest>,
|
||||
): Promise<{ id: number; name: string; leader: number | null; members: number[] }> {
|
||||
): Promise<{ id: number; name: string; type: string; leader: number | null; members: number[] }> {
|
||||
const doc = await payload.findByID({
|
||||
collection: "assignments",
|
||||
id,
|
||||
|
|
@ -46,6 +50,7 @@ async function fetchAssignment(
|
|||
return {
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
type: doc.type,
|
||||
leader: (doc.leader as number | null) ?? null,
|
||||
members: (doc.members as number[]) ?? [],
|
||||
};
|
||||
|
|
@ -65,7 +70,7 @@ async function superuserIds(payload: Payload): Promise<number[]> {
|
|||
return res.docs.filter((u) => hasRoles(["admin", "developer"], u)).map((u) => u.id);
|
||||
}
|
||||
|
||||
async function notifySuperusers(
|
||||
export async function notifySuperusers(
|
||||
payload: Payload,
|
||||
type: string,
|
||||
title: string,
|
||||
|
|
@ -106,6 +111,54 @@ async function casUpdate(
|
|||
return res.docs[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transactional member-move core shared by the assignment-transfer execution
|
||||
* path (executeTransfer) and the leadership-transfer service. Must be called
|
||||
* inside the caller's transaction (thread its `req`): re-reads both
|
||||
* assignments inside that transaction, removes the subject from the source
|
||||
* members array, and appends them to the destination members array (a no-op
|
||||
* append when the subject is already there).
|
||||
*/
|
||||
export async function moveMemberBetweenAssignments(
|
||||
payload: Payload,
|
||||
opts: {
|
||||
req: Partial<PayloadRequest>;
|
||||
subjectId: number;
|
||||
fromAssignmentId: number;
|
||||
toAssignmentId: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { req, subjectId, fromAssignmentId, toAssignmentId } = opts;
|
||||
const [fromAssignment, toAssignment] = await Promise.all([
|
||||
fetchAssignment(payload, fromAssignmentId, req),
|
||||
fetchAssignment(payload, toAssignmentId, req),
|
||||
]);
|
||||
|
||||
if (!fromAssignment.members.includes(subjectId)) {
|
||||
throw new Error("The subject is no longer a member of the source assignment.");
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "assignments",
|
||||
id: fromAssignment.id,
|
||||
data: { members: fromAssignment.members.filter((id) => id !== subjectId) },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
req,
|
||||
});
|
||||
|
||||
if (!toAssignment.members.includes(subjectId)) {
|
||||
await payload.update({
|
||||
collection: "assignments",
|
||||
id: toAssignment.id,
|
||||
data: { members: [...toAssignment.members, subjectId] },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
req,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isRequired(doc: AssignmentTransfer, side: TransferSide): boolean {
|
||||
const decision = side === "from" ? doc.fromLeaderDecision : doc.toLeaderDecision;
|
||||
return decision !== "not_required";
|
||||
|
|
@ -233,24 +286,15 @@ async function executeTransfer(
|
|||
throw new Error("The requester is no longer a member of the source assignment.");
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "assignments",
|
||||
id: fromAssignment.id,
|
||||
data: { members: fromAssignment.members.filter((id) => id !== fresh.requester) },
|
||||
overrideAccess: true,
|
||||
// The shared move core re-reads both assignments inside this transaction
|
||||
// and performs the same member-array splice this function used to inline.
|
||||
await moveMemberBetweenAssignments(payload, {
|
||||
req,
|
||||
subjectId: relId(fresh.requester),
|
||||
fromAssignmentId: fromAssignment.id,
|
||||
toAssignmentId: toAssignment.id,
|
||||
});
|
||||
|
||||
if (!toAssignment.members.includes(relId(fresh.requester))) {
|
||||
await payload.update({
|
||||
collection: "assignments",
|
||||
id: toAssignment.id,
|
||||
data: { members: [...toAssignment.members, fresh.requester] },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
const completionData: Partial<AssignmentTransfer> = { status: "completed" };
|
||||
if (opts.viaSuperuser) {
|
||||
completionData.resolvedBy = opts.resolvedBy;
|
||||
|
|
|
|||
341
src/lib/transfers/leadership.ts
Normal file
341
src/lib/transfers/leadership.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
import type { Payload, PayloadRequest } from "payload";
|
||||
import type { LeadershipTransfer, User } from "@/payload-types";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
import { fetchAssignment, moveMemberBetweenAssignments, notifySuperusers } from "./index";
|
||||
|
||||
/**
|
||||
* Notification type strings for the leadership-initiated removal flow. Defined
|
||||
* once here so the lib and any future consumers reference the same values; the
|
||||
* in-app label map in src/lib/notifications/index.ts already labels all four.
|
||||
* These deliberately bypass user mutes (removals are workflow-critical).
|
||||
*/
|
||||
export const LEADERSHIP_TRANSFER_NOTIFICATION_TYPES = {
|
||||
requested: "assignment:leadership-transfer-requested",
|
||||
transferred: "assignment:leadership-transfer",
|
||||
approved: "assignment:leadership-transfer-approved",
|
||||
denied: "assignment:leadership-transfer-denied",
|
||||
} as const;
|
||||
|
||||
const LT = LEADERSHIP_TRANSFER_NOTIFICATION_TYPES;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Coerce a Payload relationship value (numeric id or resolved doc) to its numeric id. */
|
||||
function relId(value: number | { id: number }): number {
|
||||
return typeof value === "number" ? value : value.id;
|
||||
}
|
||||
|
||||
function userLabel(
|
||||
user: Pick<User, "displayName" | "username"> | null | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (!user) return fallback;
|
||||
return user.displayName || user.username || fallback;
|
||||
}
|
||||
|
||||
async function fetchLeadershipTransfer(
|
||||
payload: Payload,
|
||||
transferId: number,
|
||||
req?: Partial<PayloadRequest>,
|
||||
): Promise<LeadershipTransfer> {
|
||||
const doc = await payload.findByID({
|
||||
collection: "leadership-transfers",
|
||||
id: transferId,
|
||||
depth: 0,
|
||||
...(req ? { req } : {}),
|
||||
});
|
||||
if (!doc) throw new Error("Removal request not found.");
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Infantry assignment id configured on the Game Rules global.
|
||||
* Returns null when command has not configured a destination yet.
|
||||
*/
|
||||
async function resolveInfantryAssignmentId(payload: Payload): Promise<number | null> {
|
||||
const rules = await payload.findGlobal({ slug: "game-rules", depth: 0 });
|
||||
const configured = rules?.infantryAssignment;
|
||||
if (configured == null) return null;
|
||||
return relId(configured);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A division leader requests removing a member from their division. The
|
||||
* destination is always the Infantry assignment configured on Game Rules.
|
||||
* Every removal takes effect only after a superuser approves.
|
||||
*
|
||||
* Guards: the initiator is the CURRENT leader of a type-"division" assignment;
|
||||
* the subject is one of its members and not the initiator; the Infantry
|
||||
* destination is configured and is not the source itself (Infantry-origin
|
||||
* removals are out of scope); no other pending request exists for the same
|
||||
* subject + source.
|
||||
*
|
||||
* Nothing public happens here: no subject notification and no event-log
|
||||
* entry while the request is pending. Only the superusers are notified.
|
||||
*/
|
||||
export async function requestLeadershipTransfer(
|
||||
payload: Payload,
|
||||
initiator: User,
|
||||
opts: { subjectId: number; fromAssignmentId: number; reason?: string },
|
||||
): Promise<LeadershipTransfer> {
|
||||
const { subjectId, fromAssignmentId } = opts;
|
||||
const reason = opts.reason?.trim();
|
||||
|
||||
const fromAssignment = await fetchAssignment(payload, fromAssignmentId);
|
||||
|
||||
if (fromAssignment.leader !== initiator.id) {
|
||||
throw new Error(`Only the current leader of ${fromAssignment.name} can request this removal.`);
|
||||
}
|
||||
if (fromAssignment.type !== "division") {
|
||||
throw new Error("Only divisions can remove members this way.");
|
||||
}
|
||||
if (subjectId === initiator.id) {
|
||||
throw new Error("You cannot remove yourself.");
|
||||
}
|
||||
if (!fromAssignment.members.includes(subjectId)) {
|
||||
throw new Error("That member is not a member of your division.");
|
||||
}
|
||||
|
||||
const infantryId = await resolveInfantryAssignmentId(payload);
|
||||
if (infantryId == null) {
|
||||
throw new Error("Command must configure the default transfer destination first (Game Rules).");
|
||||
}
|
||||
if (infantryId === fromAssignmentId) {
|
||||
throw new Error("Infantry-origin removals are not supported yet.");
|
||||
}
|
||||
|
||||
const open = await payload.find({
|
||||
collection: "leadership-transfers",
|
||||
where: {
|
||||
and: [
|
||||
{ subject: { equals: subjectId } },
|
||||
{ fromAssignment: { equals: fromAssignmentId } },
|
||||
{ status: { equals: "pending" } },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (open.docs.length > 0) {
|
||||
throw new Error("A removal request for this member is already pending.");
|
||||
}
|
||||
|
||||
const [subject, infantry] = await Promise.all([
|
||||
payload.findByID({ collection: "users", id: subjectId, depth: 0 }),
|
||||
fetchAssignment(payload, infantryId),
|
||||
]);
|
||||
|
||||
const created = await payload.create({
|
||||
collection: "leadership-transfers",
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
data: {
|
||||
initiator: initiator.id,
|
||||
subject: subjectId,
|
||||
fromAssignment: fromAssignmentId,
|
||||
toAssignment: infantryId,
|
||||
status: "pending",
|
||||
...(reason ? { reason } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
await notifySuperusers(
|
||||
payload,
|
||||
LT.requested,
|
||||
"Removal request",
|
||||
`${userLabel(initiator, `user #${initiator.id}`)} requested transferring ${userLabel(subject, `user #${subjectId}`)} from ${fromAssignment.name} to ${infantry.name}`,
|
||||
);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Superuser decision on a pending removal request.
|
||||
*
|
||||
* Approve: atomically (one transaction) stamps the request approved via a
|
||||
* compare-and-set update (status pending -> approved, with reviewedBy,
|
||||
* resolvedAt and the optional review note) and moves the member from the
|
||||
* source division into the configured Infantry assignment. Any failure rolls
|
||||
* the whole transaction back and the request stays pending. After the commit
|
||||
* the subject and the initiator are notified and a public event is emitted:
|
||||
* the user-facing attribution is always the LEADER (the initiator), never the
|
||||
* approving superuser, whose id is recorded in the event data for audit only.
|
||||
*
|
||||
* Deny: a plain terminal update; only the initiator is notified. The subject
|
||||
* learns nothing and no public event is emitted.
|
||||
*/
|
||||
export async function resolveLeadershipTransfer(
|
||||
payload: Payload,
|
||||
resolver: User,
|
||||
transferId: number,
|
||||
opts: { approve: boolean; note?: string },
|
||||
): Promise<LeadershipTransfer> {
|
||||
if (!(await isSuperuser(payload, resolver))) {
|
||||
throw new Error("Only a superuser can resolve removal requests.");
|
||||
}
|
||||
|
||||
const doc = await fetchLeadershipTransfer(payload, transferId);
|
||||
if (doc.status !== "pending") {
|
||||
throw new Error(`Only pending removal requests can be resolved (current status: ${doc.status}).`);
|
||||
}
|
||||
|
||||
const note = opts.note?.trim();
|
||||
|
||||
if (!opts.approve) {
|
||||
const denied = await payload.update({
|
||||
collection: "leadership-transfers",
|
||||
id: doc.id,
|
||||
data: {
|
||||
status: "denied",
|
||||
reviewedBy: resolver.id,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
...(note ? { reviewNote: note } : {}),
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
const subject = await payload.findByID({
|
||||
collection: "users",
|
||||
id: relId(doc.subject),
|
||||
depth: 0,
|
||||
});
|
||||
await notifyUser(payload, {
|
||||
userId: relId(doc.initiator),
|
||||
type: LT.denied,
|
||||
title: "Removal denied",
|
||||
message: `Your removal request for ${userLabel(subject, `user #${relId(doc.subject)}`)} was denied.${note ? ` Note: "${note}"` : ""}`,
|
||||
link: "/transfers",
|
||||
bypassMute: true,
|
||||
});
|
||||
|
||||
return denied;
|
||||
}
|
||||
|
||||
const txId = await payload.db.beginTransaction();
|
||||
if (txId == null) throw new Error("Failed to begin database transaction.");
|
||||
const req: Partial<PayloadRequest> = { transactionID: txId };
|
||||
let approvedDoc: LeadershipTransfer;
|
||||
try {
|
||||
const approved = await payload.update({
|
||||
collection: "leadership-transfers",
|
||||
where: {
|
||||
and: [{ id: { equals: doc.id } }, { status: { equals: "pending" } }],
|
||||
},
|
||||
data: {
|
||||
status: "approved",
|
||||
reviewedBy: resolver.id,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
...(note ? { reviewNote: note } : {}),
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
req,
|
||||
});
|
||||
if (approved.docs.length === 0) {
|
||||
throw new Error("The removal request changed state while it was being resolved.");
|
||||
}
|
||||
|
||||
await moveMemberBetweenAssignments(payload, {
|
||||
req,
|
||||
subjectId: relId(doc.subject),
|
||||
fromAssignmentId: relId(doc.fromAssignment),
|
||||
toAssignmentId: relId(doc.toAssignment),
|
||||
});
|
||||
|
||||
await payload.db.commitTransaction(txId);
|
||||
approvedDoc = approved.docs[0];
|
||||
} catch (err) {
|
||||
await payload.db.rollbackTransaction(txId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Post-commit side effects. Attribution is the leader, never the resolver;
|
||||
// failures here must not roll the committed transfer back.
|
||||
const [initiator, subject, fromAssignment, toAssignment] = await Promise.all([
|
||||
payload.findByID({ collection: "users", id: relId(doc.initiator), depth: 0 }),
|
||||
payload.findByID({ collection: "users", id: relId(doc.subject), depth: 0 }),
|
||||
fetchAssignment(payload, relId(doc.fromAssignment)),
|
||||
fetchAssignment(payload, relId(doc.toAssignment)),
|
||||
]);
|
||||
const leaderLabel = userLabel(initiator, `user #${relId(doc.initiator)}`);
|
||||
const subjectLabel = userLabel(subject, `user #${relId(doc.subject)}`);
|
||||
|
||||
await notifyUser(payload, {
|
||||
userId: relId(doc.subject),
|
||||
type: LT.transferred,
|
||||
title: "Transferred",
|
||||
message: `You have been transferred from ${fromAssignment.name} to ${toAssignment.name} by ${leaderLabel}.`,
|
||||
link: "/roster",
|
||||
bypassMute: true,
|
||||
});
|
||||
await notifyUser(payload, {
|
||||
userId: relId(doc.initiator),
|
||||
type: LT.approved,
|
||||
title: "Removal approved",
|
||||
message: `Your removal request for ${subjectLabel} was approved. ${subjectLabel} has been transferred to ${toAssignment.name}.`,
|
||||
link: "/transfers",
|
||||
bypassMute: true,
|
||||
});
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.PersonnelTransferred,
|
||||
message: `${leaderLabel} transferred ${subjectLabel} from ${fromAssignment.name} to ${toAssignment.name}.`,
|
||||
actor: relId(doc.initiator),
|
||||
targetCollection: "leadership-transfers",
|
||||
targetId: doc.id,
|
||||
data: {
|
||||
initiatorId: relId(doc.initiator),
|
||||
approvedById: resolver.id,
|
||||
subjectId: relId(doc.subject),
|
||||
fromAssignmentId: relId(doc.fromAssignment),
|
||||
toAssignmentId: relId(doc.toAssignment),
|
||||
reason: doc.reason ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return approvedDoc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending removal request. Only the initiator, only while pending.
|
||||
* No notifications and no public event: a cancelled removal never happened as
|
||||
* far as anyone else is concerned.
|
||||
*/
|
||||
export async function cancelLeadershipTransfer(
|
||||
payload: Payload,
|
||||
initiator: User,
|
||||
transferId: number,
|
||||
): Promise<LeadershipTransfer> {
|
||||
const doc = await fetchLeadershipTransfer(payload, transferId);
|
||||
if (relId(doc.initiator) !== initiator.id) {
|
||||
throw new Error("Only your own pending requests can be cancelled.");
|
||||
}
|
||||
if (doc.status !== "pending") {
|
||||
throw new Error("Only your own pending requests can be cancelled.");
|
||||
}
|
||||
|
||||
const updated = await payload.update({
|
||||
collection: "leadership-transfers",
|
||||
where: {
|
||||
and: [{ id: { equals: doc.id } }, { status: { equals: "pending" } }],
|
||||
},
|
||||
data: { status: "cancelled" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
if (updated.docs.length === 0) {
|
||||
throw new Error("The removal request changed state while it was being cancelled.");
|
||||
}
|
||||
|
||||
return updated.docs[0];
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,116 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_promotion_nominations_status" AS ENUM('pending', 'awaiting_superuser', 'approved', 'rejected', 'cancelled');
|
||||
CREATE TYPE "public"."enum_promotion_nominations_leader_decision" AS ENUM('pending', 'endorsed', 'rejected', 'not_required');
|
||||
CREATE TYPE "public"."enum_leadership_transfers_status" AS ENUM('pending', 'approved', 'denied', 'cancelled');
|
||||
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'promotion-nominations';
|
||||
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'leadership-transfers';
|
||||
CREATE TABLE "promotion_nominations" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"nominee_id" integer NOT NULL,
|
||||
"nominator_id" integer NOT NULL,
|
||||
"accolades" varchar NOT NULL,
|
||||
"status" "enum_promotion_nominations_status" DEFAULT 'pending' NOT NULL,
|
||||
"leader_decision" "enum_promotion_nominations_leader_decision" DEFAULT 'pending',
|
||||
"leader_decision_by_id" integer,
|
||||
"leader_decided_at" timestamp(3) with time zone,
|
||||
"leader_note" varchar,
|
||||
"review_note" varchar,
|
||||
"reviewed_by_id" integer,
|
||||
"reviewed_at" timestamp(3) with time zone,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "leadership_transfers" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"initiator_id" integer NOT NULL,
|
||||
"subject_id" integer NOT NULL,
|
||||
"from_assignment_id" integer NOT NULL,
|
||||
"to_assignment_id" integer NOT NULL,
|
||||
"reason" varchar,
|
||||
"status" "enum_leadership_transfers_status" DEFAULT 'pending' NOT NULL,
|
||||
"review_note" varchar,
|
||||
"reviewed_by_id" integer,
|
||||
"resolved_at" timestamp(3) with time zone,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "promotion_nominations_find" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "promotion_nominations_create" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "promotion_nominations_update" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "promotion_nominations_delete" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "leadership_transfers_find" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "leadership_transfers_create" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "leadership_transfers_update" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "leadership_transfers_delete" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "promotion_nominations_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "leadership_transfers_id" integer;
|
||||
ALTER TABLE "game_rules" ADD COLUMN "infantry_assignment_id" integer;
|
||||
ALTER TABLE "promotion_nominations" ADD CONSTRAINT "promotion_nominations_nominee_id_users_id_fk" FOREIGN KEY ("nominee_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "promotion_nominations" ADD CONSTRAINT "promotion_nominations_nominator_id_users_id_fk" FOREIGN KEY ("nominator_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "promotion_nominations" ADD CONSTRAINT "promotion_nominations_leader_decision_by_id_users_id_fk" FOREIGN KEY ("leader_decision_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "promotion_nominations" ADD CONSTRAINT "promotion_nominations_reviewed_by_id_users_id_fk" FOREIGN KEY ("reviewed_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "leadership_transfers" ADD CONSTRAINT "leadership_transfers_initiator_id_users_id_fk" FOREIGN KEY ("initiator_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "leadership_transfers" ADD CONSTRAINT "leadership_transfers_subject_id_users_id_fk" FOREIGN KEY ("subject_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "leadership_transfers" ADD CONSTRAINT "leadership_transfers_from_assignment_id_assignments_id_fk" FOREIGN KEY ("from_assignment_id") REFERENCES "public"."assignments"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "leadership_transfers" ADD CONSTRAINT "leadership_transfers_to_assignment_id_assignments_id_fk" FOREIGN KEY ("to_assignment_id") REFERENCES "public"."assignments"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "leadership_transfers" ADD CONSTRAINT "leadership_transfers_reviewed_by_id_users_id_fk" FOREIGN KEY ("reviewed_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
CREATE INDEX "promotion_nominations_nominee_idx" ON "promotion_nominations" USING btree ("nominee_id");
|
||||
CREATE INDEX "promotion_nominations_nominator_idx" ON "promotion_nominations" USING btree ("nominator_id");
|
||||
CREATE INDEX "promotion_nominations_leader_decision_by_idx" ON "promotion_nominations" USING btree ("leader_decision_by_id");
|
||||
CREATE INDEX "promotion_nominations_reviewed_by_idx" ON "promotion_nominations" USING btree ("reviewed_by_id");
|
||||
CREATE INDEX "promotion_nominations_updated_at_idx" ON "promotion_nominations" USING btree ("updated_at");
|
||||
CREATE INDEX "promotion_nominations_created_at_idx" ON "promotion_nominations" USING btree ("created_at");
|
||||
CREATE INDEX "leadership_transfers_initiator_idx" ON "leadership_transfers" USING btree ("initiator_id");
|
||||
CREATE INDEX "leadership_transfers_subject_idx" ON "leadership_transfers" USING btree ("subject_id");
|
||||
CREATE INDEX "leadership_transfers_from_assignment_idx" ON "leadership_transfers" USING btree ("from_assignment_id");
|
||||
CREATE INDEX "leadership_transfers_to_assignment_idx" ON "leadership_transfers" USING btree ("to_assignment_id");
|
||||
CREATE INDEX "leadership_transfers_reviewed_by_idx" ON "leadership_transfers" USING btree ("reviewed_by_id");
|
||||
CREATE INDEX "leadership_transfers_updated_at_idx" ON "leadership_transfers" USING btree ("updated_at");
|
||||
CREATE INDEX "leadership_transfers_created_at_idx" ON "leadership_transfers" USING btree ("created_at");
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_promotion_nominations_fk" FOREIGN KEY ("promotion_nominations_id") REFERENCES "public"."promotion_nominations"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_leadership_transfers_fk" FOREIGN KEY ("leadership_transfers_id") REFERENCES "public"."leadership_transfers"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "game_rules" ADD CONSTRAINT "game_rules_infantry_assignment_id_assignments_id_fk" FOREIGN KEY ("infantry_assignment_id") REFERENCES "public"."assignments"("id") ON DELETE set null ON UPDATE no action;
|
||||
CREATE INDEX "payload_locked_documents_rels_promotion_nominations_id_idx" ON "payload_locked_documents_rels" USING btree ("promotion_nominations_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_leadership_transfers_id_idx" ON "payload_locked_documents_rels" USING btree ("leadership_transfers_id");
|
||||
CREATE INDEX "game_rules_infantry_assignment_idx" ON "game_rules" USING btree ("infantry_assignment_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "promotion_nominations" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "leadership_transfers" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "promotion_nominations" CASCADE;
|
||||
DROP TABLE "leadership_transfers" CASCADE;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_promotion_nominations_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_leadership_transfers_fk";
|
||||
|
||||
ALTER TABLE "game_rules" DROP CONSTRAINT "game_rules_infantry_assignment_id_assignments_id_fk";
|
||||
|
||||
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE text;
|
||||
DROP TYPE "public"."enum_game_event_logs_target_collection";
|
||||
CREATE TYPE "public"."enum_game_event_logs_target_collection" AS ENUM('game-structures', 'game-vehicles', 'game-npcs', 'npc-staffing', 'structures', 'resources', 'assets', 'vehicles', 'factions', 'missions', 'campaigns', 'users', 'technologies', 'maps', 'shipments', 'bank-accounts', 'bank-transactions', 'ledger-entries', 'locker-storages', 'loadouts', 'market-listings', 'market-negotiations', 'market-state', 'evaluations', 'tickets', 'wiki-pages', 'wiki-revisions', 'wiki-templates', 'game-servers', 'assignment-transfers', 'voice-scripts', 'voice-submissions', 'story-beats', 'story-beat-states', 'custom-minefields', 'custom-minefield-scores', 'custom-radio-tests', 'custom-radio-test-scores', 'ribbon-submissions');
|
||||
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE "public"."enum_game_event_logs_target_collection" USING "target_collection"::"public"."enum_game_event_logs_target_collection";
|
||||
DROP INDEX "payload_locked_documents_rels_promotion_nominations_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_leadership_transfers_id_idx";
|
||||
DROP INDEX "game_rules_infantry_assignment_idx";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "promotion_nominations_find";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "promotion_nominations_create";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "promotion_nominations_update";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "promotion_nominations_delete";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "leadership_transfers_find";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "leadership_transfers_create";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "leadership_transfers_update";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "leadership_transfers_delete";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "promotion_nominations_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "leadership_transfers_id";
|
||||
ALTER TABLE "game_rules" DROP COLUMN "infantry_assignment_id";
|
||||
DROP TYPE "public"."enum_promotion_nominations_status";
|
||||
DROP TYPE "public"."enum_promotion_nominations_leader_decision";
|
||||
DROP TYPE "public"."enum_leadership_transfers_status";`)
|
||||
}
|
||||
|
|
@ -86,6 +86,8 @@ import { CustomMinefieldScores } from "@/collections/minigames/CustomMinefieldSc
|
|||
import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests";
|
||||
import { CustomRadioTestScores } from "@/collections/minigames/CustomRadioTestScores";
|
||||
import { RibbonSubmissions } from "@/collections/users/RibbonSubmissions";
|
||||
import { PromotionNominations } from "@/collections/users/PromotionNominations";
|
||||
import { LeadershipTransfers } from "@/collections/users/LeadershipTransfers";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { scopedAdminPageAccess } from "@/utils/access-control/divisionAccess";
|
||||
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
|
||||
|
|
@ -97,12 +99,24 @@ import { createOpenAI } from "@ai-sdk/openai";
|
|||
import { createTextStreamResponse, jsonSchema, streamObject } from "ai";
|
||||
import type { User } from "@/payload-types";
|
||||
|
||||
// Ornith-9B is served by the "Apex" Unsloth connection (a separate machine,
|
||||
// apex-lnx / 192.168.1.7), reached through Unsloth Studio's OpenAI-compatible
|
||||
// API. Point UNSLOTH_URL at that instance; the default is the LAN address of
|
||||
// the Apex box.
|
||||
const unslothProvider = createOpenAI({
|
||||
apiKey: process.env.UNSLOTH_API_KEY,
|
||||
baseURL: process.env.UNSLOTH_URL || "https://uns.onyxsimple.com/v1",
|
||||
baseURL: process.env.UNSLOTH_URL || "http://192.168.1.7:8888/v1",
|
||||
});
|
||||
|
||||
const DEFAULT_MODEL = "unsloth/gemma-4-26B-A4B-it-qat-GGUF";
|
||||
const DEFAULT_MODEL = "unsloth/Ornith-1.0-9B-GGUF";
|
||||
|
||||
// Ornith is a reasoning model: it emits a long `reasoning_content` block before
|
||||
// the actual answer, and with a small token budget the reasoning consumes the
|
||||
// whole allowance, leaving `content` empty. Unsloth Studio (llama.cpp) accepts
|
||||
// `reasoning_effort: "none"` to turn thinking off for the request, so the model
|
||||
// behaves like a plain instruct model and small budgets still produce output.
|
||||
// Sent on every request; harmless on models that do not support it.
|
||||
const REASONING_DISABLED = { reasoning_effort: "none" } as const;
|
||||
|
||||
// AI Compose allowlist: the plugin's default init makes an LLM call per field
|
||||
// to seed prompts. Returning false here skips a field (no instruction row, no
|
||||
|
|
@ -210,7 +224,7 @@ const stripCodeFences = (text: string): string =>
|
|||
.trim();
|
||||
|
||||
async function generatePlainText(prompt: string, system: string, model: string): Promise<string> {
|
||||
const baseUrl = process.env.UNSLOTH_URL || "https://uns.onyxsimple.com/v1";
|
||||
const baseUrl = process.env.UNSLOTH_URL || "http://192.168.1.7:8888/v1";
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -224,6 +238,7 @@ async function generatePlainText(prompt: string, system: string, model: string):
|
|||
{ role: "user", content: prompt },
|
||||
],
|
||||
stream: false,
|
||||
...REASONING_DISABLED,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -268,6 +283,8 @@ const collections = [
|
|||
UserNotifications,
|
||||
Evaluations,
|
||||
RibbonSubmissions,
|
||||
PromotionNominations,
|
||||
LeadershipTransfers,
|
||||
|
||||
// Intel
|
||||
Missions,
|
||||
|
|
@ -577,8 +594,8 @@ export default buildConfig({
|
|||
},
|
||||
generationModels: [
|
||||
{
|
||||
id: "unsloth/gemma-4-26B-A4B-it-qat-GGUF",
|
||||
name: "Gemma 4 26B A4B",
|
||||
id: "unsloth/Ornith-1.0-9B-GGUF",
|
||||
name: "Ornith 1.0 9B",
|
||||
fields: ["text", "textarea", "richText"],
|
||||
handler: async (
|
||||
prompt: string,
|
||||
|
|
@ -623,6 +640,7 @@ export default buildConfig({
|
|||
openai: {
|
||||
strictJsonSchema: true,
|
||||
structuredOutputs: true,
|
||||
...REASONING_DISABLED,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -650,11 +668,11 @@ export default buildConfig({
|
|||
},
|
||||
output: "text",
|
||||
settings: {
|
||||
name: "gemma-4-settings",
|
||||
name: "ornith-9b-settings",
|
||||
type: "group",
|
||||
admin: {
|
||||
condition(data) {
|
||||
return data["model-id"] === "unsloth/gemma-4-26B-A4B-it-qat-GGUF";
|
||||
return data["model-id"] === "unsloth/Ornith-1.0-9B-GGUF";
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
|
|
@ -665,7 +683,7 @@ export default buildConfig({
|
|||
label: "Model",
|
||||
},
|
||||
],
|
||||
label: "Gemma 4 Settings",
|
||||
label: "Ornith 9B Settings",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
471
tests/int/leadership-transfers.int.spec.ts
Normal file
471
tests/int/leadership-transfers.int.spec.ts
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import type { LeadershipTransfer, Role, User } from "@/payload-types";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
|
||||
import {
|
||||
cancelLeadershipTransfer,
|
||||
requestLeadershipTransfer,
|
||||
resolveLeadershipTransfer,
|
||||
} from "@/lib/transfers/leadership";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const RUN = `leadxfer-${Date.now().toString(36)}`;
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
describe("Leadership-initiated removals (leadership-transfers)", () => {
|
||||
const userIds: number[] = [];
|
||||
const roleIds: number[] = [];
|
||||
const assignmentIds: number[] = [];
|
||||
const transferIds: number[] = [];
|
||||
|
||||
const users: Record<string, User> = {};
|
||||
const assignmentIdsByName: Record<string, number> = {};
|
||||
|
||||
let pendingDoc: LeadershipTransfer | null = null;
|
||||
|
||||
const makeRole = async (label: string, extra: Partial<Role> = {}): Promise<Role> => {
|
||||
const role = (await payload.create({
|
||||
collection: "roles",
|
||||
data: { name: `${RUN}-${label}`, slug: `${RUN}-${label}`, ...extra },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Role;
|
||||
roleIds.push(role.id);
|
||||
return role;
|
||||
};
|
||||
|
||||
const makeUser = async (label: string, extra: Partial<User> = {}): Promise<User> => {
|
||||
const user = (await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username: `${RUN}-${label}`,
|
||||
discordUsername: `${RUN}-${label}`,
|
||||
displayName: label.toUpperCase(),
|
||||
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||
password: "Test123",
|
||||
roles: ["user"],
|
||||
...extra,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as User;
|
||||
userIds.push(user.id);
|
||||
return user;
|
||||
};
|
||||
|
||||
const makeAssignment = async (
|
||||
label: string,
|
||||
type: "division" | "squad",
|
||||
leader: number | null,
|
||||
members: number[],
|
||||
): Promise<number> => {
|
||||
const doc = await payload.create({
|
||||
collection: "assignments",
|
||||
data: { name: `${RUN} ${label}`, type, leader, members },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
assignmentIds.push(doc.id);
|
||||
assignmentIdsByName[label] = doc.id;
|
||||
return doc.id;
|
||||
};
|
||||
|
||||
const getAssignment = async (id: number): Promise<{ leader: number | null; members: number[] }> => {
|
||||
const doc = await payload.findByID({ collection: "assignments", id, depth: 0 });
|
||||
return {
|
||||
leader: (doc.leader as number | null) ?? null,
|
||||
members: (doc.members as number[]) ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
const notificationsFor = async (userId: number, type?: string) => {
|
||||
const res = await payload.find({
|
||||
collection: "user-notifications",
|
||||
where: type
|
||||
? { and: [{ user: { equals: userId } }, { type: { equals: type } }] }
|
||||
: { user: { equals: userId } },
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return res.docs;
|
||||
};
|
||||
|
||||
const leadershipEventsFor = async (transferId: number) => {
|
||||
const res = await payload.find({
|
||||
collection: "game-event-logs",
|
||||
where: {
|
||||
and: [
|
||||
{ targetCollection: { equals: "leadership-transfers" } },
|
||||
{ targetId: { equals: transferId } },
|
||||
],
|
||||
},
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return res.docs;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
// The per-user permission cache is keyed by numeric id, which can collide
|
||||
// with users from previously run spec files in the same serialized run.
|
||||
invalidatePermissionCache();
|
||||
|
||||
// The superuser needs BOTH sides of the RBAC split: the dynamic
|
||||
// isSuperuser role (roleDocs) for resolveLeadershipTransfer's guard and the
|
||||
// legacy admin role for the transfers lib's hasRoles-based notifySuperusers.
|
||||
const superRole = await makeRole("super", { isSuperuser: true });
|
||||
users.boss = await makeUser("boss-su", { roles: ["admin"], roleDocs: [superRole.id] });
|
||||
|
||||
// Make sure the destination starts unconfigured for the unset-error test,
|
||||
// regardless of what earlier files in the same serialized run left behind.
|
||||
await payload.updateGlobal({
|
||||
slug: "game-rules",
|
||||
data: { infantryAssignment: null },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
users.leaderX = await makeUser("lead-x");
|
||||
users.leaderY = await makeUser("lead-y");
|
||||
users.member1 = await makeUser("member-1");
|
||||
users.member2 = await makeUser("member-2");
|
||||
users.member3 = await makeUser("member-3");
|
||||
users.memberInf = await makeUser("member-inf");
|
||||
users.infantryLead = await makeUser("inf-lead");
|
||||
users.outsider = await makeUser("outsider");
|
||||
|
||||
await makeAssignment("Division X", "division", users.leaderX.id, [
|
||||
users.member1.id,
|
||||
users.member2.id,
|
||||
users.member3.id,
|
||||
]);
|
||||
await makeAssignment("Division Y", "division", users.leaderY.id, []);
|
||||
await makeAssignment("Squad S", "squad", users.leaderX.id, [users.member1.id]);
|
||||
await makeAssignment("Infantry", "division", users.infantryLead.id, [users.memberInf.id]);
|
||||
}, TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
if (!payload) return;
|
||||
|
||||
// Restore the global so other spec files in the same run are unaffected.
|
||||
await payload.updateGlobal({
|
||||
slug: "game-rules",
|
||||
data: { infantryAssignment: null },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const notifs = await payload
|
||||
.find({
|
||||
collection: "user-notifications",
|
||||
where: { user: { in: userIds } },
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const n of notifs?.docs ?? []) {
|
||||
await payload.delete({ collection: "user-notifications", id: n.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
|
||||
const events = await payload
|
||||
.find({
|
||||
collection: "game-event-logs",
|
||||
where: {
|
||||
or: [
|
||||
{ actor: { in: userIds } },
|
||||
{ targetCollection: { equals: "leadership-transfers" } },
|
||||
],
|
||||
},
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const e of events?.docs ?? []) {
|
||||
await payload.delete({ collection: "game-event-logs", id: e.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
|
||||
for (const id of transferIds) {
|
||||
await payload.delete({ collection: "leadership-transfers", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of assignmentIds) {
|
||||
await payload.delete({ collection: "assignments", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of roleIds) {
|
||||
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of userIds) {
|
||||
const profiles = await payload
|
||||
.find({ collection: "profiles", where: { user: { equals: id } }, limit: 5, depth: 0, overrideAccess: true })
|
||||
.catch(() => null);
|
||||
for (const p of profiles?.docs ?? []) {
|
||||
await payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
const accounts = await payload
|
||||
.find({
|
||||
collection: "bank-accounts",
|
||||
where: { ownerUser: { equals: id } },
|
||||
limit: 5,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const a of accounts?.docs ?? []) {
|
||||
await payload.delete({ collection: "bank-accounts", id: a.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces creation guards", async () => {
|
||||
const divisionX = assignmentIdsByName["Division X"];
|
||||
|
||||
// Non-leader initiator.
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.outsider, {
|
||||
subjectId: users.member1.id,
|
||||
fromAssignmentId: divisionX,
|
||||
}),
|
||||
).rejects.toThrow("Only the current leader of");
|
||||
|
||||
// Leader of a different assignment.
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.leaderY, {
|
||||
subjectId: users.member1.id,
|
||||
fromAssignmentId: divisionX,
|
||||
}),
|
||||
).rejects.toThrow("Only the current leader of");
|
||||
|
||||
// Squad-type source (leader is right, assignment type is wrong).
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.member1.id,
|
||||
fromAssignmentId: assignmentIdsByName["Squad S"],
|
||||
}),
|
||||
).rejects.toThrow("Only divisions can remove members this way.");
|
||||
|
||||
// Self-removal.
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.leaderX.id,
|
||||
fromAssignmentId: divisionX,
|
||||
}),
|
||||
).rejects.toThrow("You cannot remove yourself.");
|
||||
|
||||
// Subject is not a member of the division.
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.outsider.id,
|
||||
fromAssignmentId: divisionX,
|
||||
}),
|
||||
).rejects.toThrow("That member is not a member of your division.");
|
||||
}, TIMEOUT);
|
||||
|
||||
it("rejects requests while the Infantry destination is not configured", async () => {
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.member1.id,
|
||||
fromAssignmentId: assignmentIdsByName["Division X"],
|
||||
reason: "presence",
|
||||
}),
|
||||
).rejects.toThrow("Command must configure the default transfer destination first (Game Rules).");
|
||||
}, TIMEOUT);
|
||||
|
||||
it("rejects infantry-origin removals", async () => {
|
||||
const infantry = assignmentIdsByName["Infantry"];
|
||||
await payload.updateGlobal({
|
||||
slug: "game-rules",
|
||||
data: { infantryAssignment: infantry },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.infantryLead, {
|
||||
subjectId: users.memberInf.id,
|
||||
fromAssignmentId: infantry,
|
||||
}),
|
||||
).rejects.toThrow("Infantry-origin removals are not supported yet.");
|
||||
}, TIMEOUT);
|
||||
|
||||
it("creates a pending request, notifies only superusers, and emits no event", async () => {
|
||||
const doc = await requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.member1.id,
|
||||
fromAssignmentId: assignmentIdsByName["Division X"],
|
||||
reason: "presence",
|
||||
});
|
||||
transferIds.push(doc.id);
|
||||
|
||||
expect(doc.status).toBe("pending");
|
||||
expect(doc.toAssignment).toBe(assignmentIdsByName["Infantry"]);
|
||||
expect(doc.reason).toBe("presence");
|
||||
|
||||
const bossNotified = await notificationsFor(users.boss.id, "assignment:leadership-transfer-requested");
|
||||
expect(bossNotified.length).toBeGreaterThan(0);
|
||||
expect(bossNotified[0]?.title).toBe("Removal request");
|
||||
|
||||
// The subject learns nothing while the request is pending.
|
||||
const subjectNotified = await notificationsFor(users.member1.id);
|
||||
expect(subjectNotified.length).toBe(0);
|
||||
|
||||
// No public event-log entry while pending.
|
||||
expect((await leadershipEventsFor(doc.id)).length).toBe(0);
|
||||
|
||||
// A second open request for the same member + division is rejected.
|
||||
await expect(
|
||||
requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.member1.id,
|
||||
fromAssignmentId: assignmentIdsByName["Division X"],
|
||||
}),
|
||||
).rejects.toThrow("A removal request for this member is already pending.");
|
||||
|
||||
// Stash for the approval test.
|
||||
pendingDoc = doc;
|
||||
}, TIMEOUT);
|
||||
|
||||
it("superuser approval moves the member and attributes everything to the leader", async () => {
|
||||
const doc = pendingDoc!;
|
||||
const divisionX = assignmentIdsByName["Division X"];
|
||||
const infantry = assignmentIdsByName["Infantry"];
|
||||
|
||||
// Non-superusers cannot resolve.
|
||||
await expect(
|
||||
resolveLeadershipTransfer(payload, users.leaderY, doc.id, { approve: true }),
|
||||
).rejects.toThrow("Only a superuser can resolve removal requests.");
|
||||
|
||||
const approved = await resolveLeadershipTransfer(payload, users.boss, doc.id, {
|
||||
approve: true,
|
||||
note: "agreed",
|
||||
});
|
||||
expect(approved.status).toBe("approved");
|
||||
expect(approved.reviewedBy).toBe(users.boss.id);
|
||||
expect(approved.resolvedAt).toBeTruthy();
|
||||
expect(approved.reviewNote).toBe("agreed");
|
||||
|
||||
const from = await getAssignment(divisionX);
|
||||
const to = await getAssignment(infantry);
|
||||
expect(from.members).not.toContain(users.member1.id);
|
||||
expect(to.members).toContain(users.member1.id);
|
||||
|
||||
// The subject's notification names the LEADER, never the approving superuser.
|
||||
const subjectNotified = await notificationsFor(users.member1.id, "assignment:leadership-transfer");
|
||||
expect(subjectNotified.length).toBeGreaterThan(0);
|
||||
const subjectMessage = subjectNotified[0]?.message ?? "";
|
||||
expect(subjectMessage).toContain(users.leaderX.displayName);
|
||||
expect(subjectMessage).not.toContain(users.boss.displayName);
|
||||
|
||||
const initiatorNotified = await notificationsFor(
|
||||
users.leaderX.id,
|
||||
"assignment:leadership-transfer-approved",
|
||||
);
|
||||
expect(initiatorNotified.length).toBeGreaterThan(0);
|
||||
|
||||
// Exactly one public event, visibly authored by the leader; the resolver's
|
||||
// id lives in data for audit only.
|
||||
const events = await leadershipEventsFor(doc.id);
|
||||
expect(events.length).toBe(1);
|
||||
const event = events[0]!;
|
||||
expect(event.type).toBe(EventTypes.PersonnelTransferred);
|
||||
expect(event.actor).toBe(users.leaderX.id);
|
||||
const data = event.data as Record<string, unknown>;
|
||||
expect(data.initiatorId).toBe(users.leaderX.id);
|
||||
expect(data.approvedById).toBe(users.boss.id);
|
||||
expect(data.subjectId).toBe(users.member1.id);
|
||||
expect(data.fromAssignmentId).toBe(divisionX);
|
||||
expect(data.toAssignmentId).toBe(infantry);
|
||||
expect(data.reason).toBe("presence");
|
||||
|
||||
// A second resolution attempt is rejected (no longer pending).
|
||||
await expect(
|
||||
resolveLeadershipTransfer(payload, users.boss, doc.id, { approve: true }),
|
||||
).rejects.toThrow("Only pending removal requests can be resolved");
|
||||
|
||||
// The collection hook rejects illegal transitions out of the terminal state.
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: "leadership-transfers",
|
||||
id: doc.id,
|
||||
data: { status: "pending" },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
).rejects.toThrow("Illegal leadership transfer status transition");
|
||||
}, TIMEOUT);
|
||||
|
||||
it("denial moves nothing, notifies only the initiator, and emits no event", async () => {
|
||||
const divisionX = assignmentIdsByName["Division X"];
|
||||
|
||||
const doc = await requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.member2.id,
|
||||
fromAssignmentId: divisionX,
|
||||
});
|
||||
transferIds.push(doc.id);
|
||||
|
||||
const denied = await resolveLeadershipTransfer(payload, users.boss, doc.id, {
|
||||
approve: false,
|
||||
note: "keep them",
|
||||
});
|
||||
expect(denied.status).toBe("denied");
|
||||
expect(denied.reviewedBy).toBe(users.boss.id);
|
||||
expect(denied.resolvedAt).toBeTruthy();
|
||||
expect(denied.reviewNote).toBe("keep them");
|
||||
|
||||
const from = await getAssignment(divisionX);
|
||||
const to = await getAssignment(assignmentIdsByName["Infantry"]);
|
||||
expect(from.members).toContain(users.member2.id);
|
||||
expect(to.members).not.toContain(users.member2.id);
|
||||
|
||||
const initiatorNotified = await notificationsFor(
|
||||
users.leaderX.id,
|
||||
"assignment:leadership-transfer-denied",
|
||||
);
|
||||
expect(initiatorNotified.length).toBeGreaterThan(0);
|
||||
expect(initiatorNotified[0]?.message).toContain("keep them");
|
||||
|
||||
// The subject is not told about a denial, and nothing goes public.
|
||||
expect((await notificationsFor(users.member2.id, "assignment:leadership-transfer")).length).toBe(0);
|
||||
expect((await leadershipEventsFor(doc.id)).length).toBe(0);
|
||||
}, TIMEOUT);
|
||||
|
||||
it("cancels own pending requests only", async () => {
|
||||
const divisionX = assignmentIdsByName["Division X"];
|
||||
|
||||
const doc = await requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.member3.id,
|
||||
fromAssignmentId: divisionX,
|
||||
});
|
||||
transferIds.push(doc.id);
|
||||
|
||||
// Another leader cannot cancel someone else's request.
|
||||
await expect(
|
||||
cancelLeadershipTransfer(payload, users.leaderY, doc.id),
|
||||
).rejects.toThrow("Only your own pending requests can be cancelled.");
|
||||
|
||||
const cancelled = await cancelLeadershipTransfer(payload, users.leaderX, doc.id);
|
||||
expect(cancelled.status).toBe("cancelled");
|
||||
|
||||
// Cancelled is terminal: no second cancellation, and no membership moved.
|
||||
await expect(cancelLeadershipTransfer(payload, users.leaderX, doc.id)).rejects.toThrow(
|
||||
"Only your own pending requests can be cancelled.",
|
||||
);
|
||||
const from = await getAssignment(divisionX);
|
||||
const to = await getAssignment(assignmentIdsByName["Infantry"]);
|
||||
expect(from.members).toContain(users.member3.id);
|
||||
expect(to.members).not.toContain(users.member3.id);
|
||||
|
||||
// A cancelled request is not "open": a fresh one for the same member is allowed.
|
||||
const second = await requestLeadershipTransfer(payload, users.leaderX, {
|
||||
subjectId: users.member3.id,
|
||||
fromAssignmentId: divisionX,
|
||||
});
|
||||
transferIds.push(second.id);
|
||||
expect(second.status).toBe("pending");
|
||||
await cancelLeadershipTransfer(payload, users.leaderX, second.id);
|
||||
}, TIMEOUT);
|
||||
});
|
||||
599
tests/int/promotion-nominations.int.spec.ts
Normal file
599
tests/int/promotion-nominations.int.spec.ts
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { Assignment, Profile, Rank, Role, User } from "@/payload-types";
|
||||
import {
|
||||
cancelNomination,
|
||||
recordLeaderDecision,
|
||||
resolveNomination,
|
||||
submitNomination,
|
||||
} from "@/lib/promotions/nominations";
|
||||
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const RUN = `promnom-${Date.now().toString(36)}`;
|
||||
const TIMEOUT = 30_000;
|
||||
const ACCOLADES = "Consistently leads the squad under fire and never misses a rally point.";
|
||||
|
||||
/**
|
||||
* User deletion trips FK constraints unless the hook-provisioned personal bank
|
||||
* account and profile are removed first, and event/notification rows must go
|
||||
* before the users they reference.
|
||||
*/
|
||||
const deleteUserWithRelations = async (pg: Payload, id: number): Promise<void> => {
|
||||
const accounts = await pg
|
||||
.find({
|
||||
collection: "bank-accounts",
|
||||
where: { ownerUser: { equals: id } },
|
||||
limit: 5,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const account of accounts?.docs ?? []) {
|
||||
await pg
|
||||
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
const profiles = await pg
|
||||
.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: id } },
|
||||
limit: 5,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const profile of profiles?.docs ?? []) {
|
||||
await pg
|
||||
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
await pg.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
||||
};
|
||||
|
||||
describe("Promotion nominations", () => {
|
||||
const rankIds: number[] = [];
|
||||
const roleIds: number[] = [];
|
||||
const userIds: number[] = [];
|
||||
const assignmentIds: number[] = [];
|
||||
const nominationIds: number[] = [];
|
||||
|
||||
let pvt: Rank;
|
||||
let cpl: Rank;
|
||||
let sgt: Rank;
|
||||
let colonel: Rank;
|
||||
|
||||
let superuser: User;
|
||||
let leader: User;
|
||||
let outsider: User;
|
||||
let selfNominee: User;
|
||||
let divisionlessNominee: User;
|
||||
let topRankUser: User;
|
||||
let leaderNominee: User;
|
||||
let leaderRejectNominee: User;
|
||||
let cancelNominee: User;
|
||||
let hookNominee: User;
|
||||
let restNominee: User;
|
||||
|
||||
const makeUser = async (username: string, roles: User["roles"] = ["user"]): Promise<User> => {
|
||||
const user = (await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username,
|
||||
discordUsername: username,
|
||||
displayName: username,
|
||||
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||
password: "Test123",
|
||||
roles,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as User;
|
||||
userIds.push(user.id);
|
||||
return user;
|
||||
};
|
||||
|
||||
const setProfileRank = async (userId: number, rankId: number): Promise<void> => {
|
||||
const profiles = (await payload.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: userId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Array<{ id: number }> };
|
||||
expect(profiles.docs.length).toBeGreaterThan(0);
|
||||
await payload.update({
|
||||
collection: "profiles",
|
||||
id: profiles.docs[0].id,
|
||||
data: { rank: rankId },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const getProfile = async (userId: number): Promise<Profile> => {
|
||||
const profiles = (await payload.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: userId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Profile[] };
|
||||
return profiles.docs[0];
|
||||
};
|
||||
|
||||
const notificationsFor = async (userId: number, type: string) => {
|
||||
const res = await payload.find({
|
||||
collection: "user-notifications",
|
||||
where: {
|
||||
and: [{ user: { equals: userId } }, { type: { equals: type } }],
|
||||
},
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return res.docs;
|
||||
};
|
||||
|
||||
const eventsOfType = async (type: string) => {
|
||||
const res = await payload.find({
|
||||
collection: "game-event-logs",
|
||||
where: { type: { equals: type } },
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return res.docs;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
invalidatePermissionCache();
|
||||
|
||||
// Self-contained 4-rank ladder: Pvt < Cpl < Sgt < Col (orderable _order).
|
||||
for (const [name, abbreviation] of [
|
||||
["Pvt", "pvt"],
|
||||
["Cpl", "cpl"],
|
||||
["Sgt", "sgt"],
|
||||
["Col", "col"],
|
||||
] as const) {
|
||||
const rank = (await payload.create({
|
||||
collection: "ranks",
|
||||
data: {
|
||||
name: `${RUN} ${name}`,
|
||||
abbreviation: `${RUN}-${abbreviation}`,
|
||||
description: `${RUN} test rank`,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Rank;
|
||||
rankIds.push(rank.id);
|
||||
if (name === "Pvt") pvt = rank;
|
||||
if (name === "Cpl") cpl = rank;
|
||||
if (name === "Sgt") sgt = rank;
|
||||
if (name === "Col") colonel = rank;
|
||||
}
|
||||
|
||||
const superRole = (await payload.create({
|
||||
collection: "roles",
|
||||
data: { name: `${RUN}-super`, slug: `${RUN}-super`, isSuperuser: true },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Role;
|
||||
roleIds.push(superRole.id);
|
||||
|
||||
superuser = await makeUser(`${RUN}-superuser`, ["admin"]);
|
||||
await payload.update({
|
||||
collection: "users",
|
||||
id: superuser.id,
|
||||
data: { roleDocs: [superRole.id] },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
// promoteMember resolves the actor's authority from their profile rank;
|
||||
// a legacy admin gets unbounded authority but still needs a rank above the
|
||||
// granted rank.
|
||||
await setProfileRank(superuser.id, colonel.id);
|
||||
|
||||
leader = await makeUser(`${RUN}-leader`);
|
||||
outsider = await makeUser(`${RUN}-outsider`);
|
||||
selfNominee = await makeUser(`${RUN}-self`);
|
||||
divisionlessNominee = await makeUser(`${RUN}-divless`);
|
||||
topRankUser = await makeUser(`${RUN}-top`);
|
||||
leaderNominee = await makeUser(`${RUN}-leadnom`);
|
||||
leaderRejectNominee = await makeUser(`${RUN}-lerej`);
|
||||
cancelNominee = await makeUser(`${RUN}-cancel`);
|
||||
hookNominee = await makeUser(`${RUN}-hook`);
|
||||
restNominee = await makeUser(`${RUN}-rest`);
|
||||
|
||||
await setProfileRank(selfNominee.id, pvt.id);
|
||||
await setProfileRank(divisionlessNominee.id, pvt.id);
|
||||
await setProfileRank(topRankUser.id, colonel.id);
|
||||
await setProfileRank(leaderNominee.id, pvt.id);
|
||||
await setProfileRank(leaderRejectNominee.id, pvt.id);
|
||||
await setProfileRank(cancelNominee.id, pvt.id);
|
||||
await setProfileRank(hookNominee.id, pvt.id);
|
||||
await setProfileRank(restNominee.id, pvt.id);
|
||||
|
||||
const division = (await payload.create({
|
||||
collection: "assignments",
|
||||
data: {
|
||||
name: `${RUN} Division`,
|
||||
type: "division",
|
||||
leader: leader.id,
|
||||
members: [
|
||||
selfNominee.id,
|
||||
leaderNominee.id,
|
||||
leaderRejectNominee.id,
|
||||
cancelNominee.id,
|
||||
hookNominee.id,
|
||||
],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Assignment;
|
||||
assignmentIds.push(division.id);
|
||||
}, TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
if (!payload) return;
|
||||
for (const id of nominationIds) {
|
||||
await payload
|
||||
.delete({ collection: "promotion-nominations", id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
// Events and notifications reference users; remove them before the users.
|
||||
for (const collection of ["game-event-logs", "user-notifications"] as const) {
|
||||
const docs = await payload
|
||||
.find({
|
||||
collection,
|
||||
where: { id: { exists: true } },
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const doc of docs?.docs ?? []) {
|
||||
const event = doc as unknown as {
|
||||
actor?: number | { id: number } | null;
|
||||
user?: number | { id: number } | null;
|
||||
};
|
||||
const ref = event.actor ?? event.user;
|
||||
const refId = typeof ref === "object" ? ref?.id : ref;
|
||||
if (refId != null && userIds.includes(refId)) {
|
||||
await payload.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of assignmentIds) {
|
||||
await payload.delete({ collection: "assignments", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of userIds) {
|
||||
await deleteUserWithRelations(payload, id);
|
||||
}
|
||||
for (const id of roleIds) {
|
||||
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of rankIds) {
|
||||
await payload.delete({ collection: "ranks", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
}, TIMEOUT);
|
||||
|
||||
it("guards submissions: short accolades, open duplicate, and top of the ladder", async () => {
|
||||
// Short accolades are refused with the plain-language message.
|
||||
await expect(
|
||||
submitNomination(payload, outsider, {
|
||||
nomineeId: divisionlessNominee.id,
|
||||
accolades: "too short",
|
||||
}),
|
||||
).rejects.toThrow(/Provide at least a sentence/);
|
||||
|
||||
// A valid submission for a leaderless member skips straight to the superuser.
|
||||
const submitted = await submitNomination(payload, outsider, {
|
||||
nomineeId: divisionlessNominee.id,
|
||||
accolades: ACCOLADES,
|
||||
});
|
||||
nominationIds.push(submitted.id);
|
||||
expect(submitted.status).toBe("awaiting_superuser");
|
||||
expect(submitted.leaderDecision).toBe("not_required");
|
||||
|
||||
// Superusers are notified for the leaderless routing.
|
||||
const superuserNotified = (await notificationsFor(superuser.id, "promotion:nomination")).filter(
|
||||
(n) => n.message?.includes(divisionlessNominee.displayName),
|
||||
);
|
||||
expect(superuserNotified).toHaveLength(1);
|
||||
expect(superuserNotified[0].message).toContain(outsider.displayName);
|
||||
|
||||
// A second open nomination for the same nominee is refused.
|
||||
await expect(
|
||||
submitNomination(payload, leader, {
|
||||
nomineeId: divisionlessNominee.id,
|
||||
accolades: ACCOLADES,
|
||||
}),
|
||||
).rejects.toThrow(/already has an open promotion nomination/);
|
||||
|
||||
// A member at the top of the ladder cannot be nominated.
|
||||
await expect(
|
||||
submitNomination(payload, outsider, {
|
||||
nomineeId: topRankUser.id,
|
||||
accolades: ACCOLADES,
|
||||
}),
|
||||
).rejects.toThrow(/already holds the highest rank/);
|
||||
});
|
||||
|
||||
it("routes the leader stage: division leader notified and pending, self-request allowed", async () => {
|
||||
// Self-request by a member of a led division: pending with the leader.
|
||||
const selfRequest = await submitNomination(payload, selfNominee, {
|
||||
nomineeId: selfNominee.id,
|
||||
accolades: ACCOLADES,
|
||||
});
|
||||
nominationIds.push(selfRequest.id);
|
||||
expect(selfRequest.status).toBe("pending");
|
||||
expect(selfRequest.leaderDecision).toBe("pending");
|
||||
expect(selfRequest.nominator).toBe(selfRequest.nominee);
|
||||
|
||||
const leaderNotified = await notificationsFor(leader.id, "promotion:nomination");
|
||||
expect(leaderNotified).toHaveLength(1);
|
||||
expect(leaderNotified[0]).toMatchObject({
|
||||
title: "Promotion nomination",
|
||||
link: "/personnel/promotions",
|
||||
});
|
||||
|
||||
// The division leader nominating their own member needs no leader stage.
|
||||
const leaderNominated = await submitNomination(payload, leader, {
|
||||
nomineeId: leaderNominee.id,
|
||||
accolades: ACCOLADES,
|
||||
});
|
||||
nominationIds.push(leaderNominated.id);
|
||||
expect(leaderNominated.status).toBe("awaiting_superuser");
|
||||
expect(leaderNominated.leaderDecision).toBe("not_required");
|
||||
});
|
||||
|
||||
it("authorizes leader decisions and endorses through to the superuser stage", async () => {
|
||||
const selfRequest = (await payload.find({
|
||||
collection: "promotion-nominations",
|
||||
where: { nominee: { equals: selfNominee.id } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Array<{ id: number }> };
|
||||
const nominationId = selfRequest.docs[0].id;
|
||||
|
||||
// A member who is not the division leader cannot decide.
|
||||
await expect(
|
||||
recordLeaderDecision(payload, outsider, nominationId, { endorse: true }),
|
||||
).rejects.toThrow(/Only the nominee's division leader/);
|
||||
|
||||
// The current leader endorses: awaiting_superuser + stamped decision.
|
||||
const endorsed = await recordLeaderDecision(payload, leader, nominationId, {
|
||||
endorse: true,
|
||||
note: "Well earned.",
|
||||
});
|
||||
expect(endorsed.status).toBe("awaiting_superuser");
|
||||
expect(endorsed.leaderDecision).toBe("endorsed");
|
||||
expect(endorsed.leaderDecisionBy).toBe(leader.id);
|
||||
expect(endorsed.leaderNote).toBe("Well earned.");
|
||||
|
||||
// The nominator is notified of the endorsement.
|
||||
const endorsedNotes = await notificationsFor(selfNominee.id, "promotion:endorsed");
|
||||
expect(endorsedNotes).toHaveLength(1);
|
||||
expect(endorsedNotes[0].message).toContain(leader.displayName);
|
||||
|
||||
// The decision is one-shot: a second endorsement is refused.
|
||||
await expect(
|
||||
recordLeaderDecision(payload, leader, nominationId, { endorse: true }),
|
||||
).rejects.toThrow(/not awaiting endorsement/);
|
||||
});
|
||||
|
||||
it("rejects a non-superuser final resolution", async () => {
|
||||
const awaiting = (await payload.find({
|
||||
collection: "promotion-nominations",
|
||||
where: {
|
||||
and: [
|
||||
{ nominee: { equals: selfNominee.id } },
|
||||
{ status: { equals: "awaiting_superuser" } },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Array<{ id: number }> };
|
||||
expect(awaiting.docs).toHaveLength(1);
|
||||
|
||||
await expect(
|
||||
resolveNomination(payload, leader, awaiting.docs[0].id, { approve: true }),
|
||||
).rejects.toThrow(/Only a superuser/);
|
||||
});
|
||||
|
||||
it("approves through promoteMember: exactly one rung, event, and nominator notification", async () => {
|
||||
const awaiting = (await payload.find({
|
||||
collection: "promotion-nominations",
|
||||
where: {
|
||||
and: [
|
||||
{ nominee: { equals: selfNominee.id } },
|
||||
{ status: { equals: "awaiting_superuser" } },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Array<{ id: number }> };
|
||||
expect(awaiting.docs).toHaveLength(1);
|
||||
const nominationId = awaiting.docs[0].id;
|
||||
|
||||
const approved = await resolveNomination(payload, superuser, nominationId, {
|
||||
approve: true,
|
||||
note: "Approved by command.",
|
||||
});
|
||||
expect(approved.status).toBe("approved");
|
||||
expect(approved.reviewedBy).toBe(superuser.id);
|
||||
expect(approved.reviewNote).toBe("Approved by command.");
|
||||
|
||||
// The nominee moved EXACTLY one rung: Pvt -> Cpl.
|
||||
const profile = await getProfile(selfNominee.id);
|
||||
expect(profile.rank).toBe(cpl.id);
|
||||
|
||||
// The public event carries the promotion trace.
|
||||
const events = await eventsOfType("promotion:approved");
|
||||
const nominationEvent = events.find(
|
||||
(e) => (e as unknown as { targetId: number }).targetId === nominationId,
|
||||
);
|
||||
expect(nominationEvent).toBeDefined();
|
||||
const eventData = (nominationEvent as unknown as { data: Record<string, unknown> }).data;
|
||||
expect(eventData).toMatchObject({
|
||||
nominationId,
|
||||
nomineeId: selfNominee.id,
|
||||
nominatorId: selfNominee.id,
|
||||
fromRankId: pvt.id,
|
||||
toRankId: cpl.id,
|
||||
});
|
||||
|
||||
// The nominator is notified with the new rank.
|
||||
const approvedNotes = await notificationsFor(selfNominee.id, "promotion:approved");
|
||||
expect(approvedNotes).toHaveLength(1);
|
||||
expect(approvedNotes[0].message).toContain(cpl.name);
|
||||
});
|
||||
|
||||
it("rejects at final review without touching the nominee's rank", async () => {
|
||||
// The divisionless member's nomination is still awaiting final approval.
|
||||
const awaiting = (await payload.find({
|
||||
collection: "promotion-nominations",
|
||||
where: {
|
||||
and: [
|
||||
{ nominee: { equals: divisionlessNominee.id } },
|
||||
{ status: { equals: "awaiting_superuser" } },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Array<{ id: number }> };
|
||||
expect(awaiting.docs).toHaveLength(1);
|
||||
|
||||
const rejected = await resolveNomination(payload, superuser, awaiting.docs[0].id, {
|
||||
approve: false,
|
||||
note: "Not this cycle.",
|
||||
});
|
||||
expect(rejected.status).toBe("rejected");
|
||||
expect(rejected.reviewedBy).toBe(superuser.id);
|
||||
expect(rejected.reviewNote).toBe("Not this cycle.");
|
||||
|
||||
const profile = await getProfile(divisionlessNominee.id);
|
||||
expect(profile.rank).toBe(pvt.id);
|
||||
|
||||
const rejectedNotes = await notificationsFor(outsider.id, "promotion:rejected");
|
||||
expect(rejectedNotes).toHaveLength(1);
|
||||
expect(rejectedNotes[0].message).toContain("Not this cycle.");
|
||||
});
|
||||
|
||||
it("records the leader's rejection as terminal and notifies the nominator", async () => {
|
||||
const submitted = await submitNomination(payload, outsider, {
|
||||
nomineeId: leaderRejectNominee.id,
|
||||
accolades: ACCOLADES,
|
||||
});
|
||||
nominationIds.push(submitted.id);
|
||||
expect(submitted.status).toBe("pending");
|
||||
|
||||
const rejected = await recordLeaderDecision(payload, leader, submitted.id, {
|
||||
endorse: false,
|
||||
note: "Needs more time in grade.",
|
||||
});
|
||||
expect(rejected.status).toBe("rejected");
|
||||
expect(rejected.leaderDecision).toBe("rejected");
|
||||
expect(rejected.leaderDecisionBy).toBe(leader.id);
|
||||
|
||||
const rejectedNotes = (await notificationsFor(outsider.id, "promotion:rejected")).filter(
|
||||
(n) => n.message?.includes("Needs more time in grade."),
|
||||
);
|
||||
expect(rejectedNotes).toHaveLength(1);
|
||||
expect(rejectedNotes[0].message).toContain(leader.displayName);
|
||||
|
||||
const profile = await getProfile(leaderRejectNominee.id);
|
||||
expect(profile.rank).toBe(pvt.id);
|
||||
});
|
||||
|
||||
it("lets only the nominator cancel their own pending nomination", async () => {
|
||||
const submitted = await submitNomination(payload, outsider, {
|
||||
nomineeId: cancelNominee.id,
|
||||
accolades: ACCOLADES,
|
||||
});
|
||||
nominationIds.push(submitted.id);
|
||||
|
||||
await expect(cancelNomination(payload, leader, submitted.id)).rejects.toThrow(
|
||||
/Only your own pending nominations can be withdrawn/,
|
||||
);
|
||||
|
||||
const cancelled = await cancelNomination(payload, outsider, submitted.id);
|
||||
expect(cancelled.status).toBe("cancelled");
|
||||
|
||||
await expect(cancelNomination(payload, outsider, submitted.id)).rejects.toThrow(
|
||||
/Only pending nominations can be withdrawn/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects illegal status transitions at the collection hook", async () => {
|
||||
const submitted = await submitNomination(payload, outsider, {
|
||||
nomineeId: hookNominee.id,
|
||||
accolades: ACCOLADES,
|
||||
});
|
||||
nominationIds.push(submitted.id);
|
||||
|
||||
// pending -> approved skips the endorsement and final stages: illegal.
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: "promotion-nominations",
|
||||
id: submitted.id,
|
||||
data: { status: "approved" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
}),
|
||||
).rejects.toThrow(/Illegal promotion nomination status transition/);
|
||||
|
||||
const reread = await payload.findByID({
|
||||
collection: "promotion-nominations",
|
||||
id: submitted.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(reread.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("forces the nominator to the authenticated caller on a REST-style create", async () => {
|
||||
// A REST create claiming another nominator is refused by the hook.
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: "promotion-nominations",
|
||||
data: {
|
||||
nominee: restNominee.id,
|
||||
nominator: leader.id,
|
||||
accolades: ACCOLADES,
|
||||
status: "pending",
|
||||
},
|
||||
user: outsider,
|
||||
overrideAccess: false,
|
||||
depth: 0,
|
||||
}),
|
||||
).rejects.toThrow(/only submit a promotion nomination as yourself/);
|
||||
|
||||
// The caller is accepted as the nominator.
|
||||
const created = await payload.create({
|
||||
collection: "promotion-nominations",
|
||||
data: {
|
||||
nominee: restNominee.id,
|
||||
nominator: outsider.id,
|
||||
accolades: ACCOLADES,
|
||||
status: "pending",
|
||||
},
|
||||
user: outsider,
|
||||
overrideAccess: false,
|
||||
depth: 0,
|
||||
});
|
||||
nominationIds.push(created.id);
|
||||
expect(created.nominator).toBe(outsider.id);
|
||||
expect(created.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue