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,
|
PERSONAL_EXCERPT_MAX_LENGTH,
|
||||||
} from "@/lib/profile/excerpt-limits";
|
} from "@/lib/profile/excerpt-limits";
|
||||||
import { promoteMember as promoteMemberService } from "@/lib/promotions";
|
import { promoteMember as promoteMemberService } from "@/lib/promotions";
|
||||||
|
import {
|
||||||
|
cancelNomination as cancelNominationCore,
|
||||||
|
submitNomination,
|
||||||
|
} from "@/lib/promotions/nominations";
|
||||||
|
|
||||||
interface ActionResult<T = undefined> {
|
interface ActionResult<T = undefined> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -290,3 +294,43 @@ export async function promoteMember(
|
||||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
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 { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection";
|
||||||
import { DossierExcerptEditors } from "@/components/frontend/profile/DossierExcerptEditors";
|
import { DossierExcerptEditors } from "@/components/frontend/profile/DossierExcerptEditors";
|
||||||
import { PromoteButton } from "@/components/frontend/profile/PromoteButton";
|
import { PromoteButton } from "@/components/frontend/profile/PromoteButton";
|
||||||
|
import { NominateForPromotionButton } from "@/components/frontend/profile/NominateForPromotionButton";
|
||||||
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
|
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
|
||||||
import { allowedPromotionTargets, resolvePromotionAuthority } from "@/lib/promotions";
|
import { allowedPromotionTargets, resolvePromotionAuthority } from "@/lib/promotions";
|
||||||
|
import { loadRankLadder, rankIndex } from "@/lib/promotions/authority";
|
||||||
import type {
|
import type {
|
||||||
EvaluationMissionOption,
|
EvaluationMissionOption,
|
||||||
EvaluationSummary,
|
EvaluationSummary,
|
||||||
|
|
@ -367,6 +369,27 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
const allowedTargetRanks = promotionResult
|
const allowedTargetRanks = promotionResult
|
||||||
? allowedPromotionTargets(promotionResult, rank)
|
? 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(
|
const awardEntries = (profile.progression?.awards ?? []).filter(
|
||||||
(entry): entry is ProfileAwardEntry => typeof entry === "object" && entry !== null,
|
(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 ──────────────────────────────────── */}
|
{/* ── Combat Record ──────────────────────────────────── */}
|
||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<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 { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
|
||||||
const currentUser = isPayloadUser(authUser) ? authUser : null;
|
const currentUser = isPayloadUser(authUser) ? authUser : null;
|
||||||
|
|
||||||
const [profileRes, assignmentRes] = await Promise.all([
|
const [profileRes, assignmentRes, rules] = await Promise.all([
|
||||||
payload.find({
|
payload.find({
|
||||||
collection: "profiles",
|
collection: "profiles",
|
||||||
limit: 500,
|
limit: 500,
|
||||||
|
|
@ -65,6 +65,7 @@ export default async function RosterPage() {
|
||||||
subAssignments: true,
|
subAssignments: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
payload.findGlobal({ slug: "game-rules", depth: 0 }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const profiles = profileRes.docs;
|
const profiles = profileRes.docs;
|
||||||
|
|
@ -132,6 +133,16 @@ export default async function RosterPage() {
|
||||||
.filter(Boolean) as number[],
|
.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 (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6">
|
<div className="p-5 flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-1" data-tour="page-header">
|
<div className="flex flex-col gap-1" data-tour="page-header">
|
||||||
|
|
@ -149,6 +160,8 @@ export default async function RosterPage() {
|
||||||
members={members}
|
members={members}
|
||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
leadOrActual={currentUser?.preferences?.display?.leadOrActual ?? "lead"}
|
leadOrActual={currentUser?.preferences?.display?.leadOrActual ?? "lead"}
|
||||||
|
currentUserId={currentUser?.id}
|
||||||
|
infantryName={infantryName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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,
|
Flag,
|
||||||
Kanban,
|
Kanban,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
|
Medal,
|
||||||
Map,
|
Map,
|
||||||
Package,
|
Package,
|
||||||
Radar,
|
Radar,
|
||||||
|
|
@ -153,6 +154,11 @@ export const sidebarData = {
|
||||||
url: "/personnel/statistics",
|
url: "/personnel/statistics",
|
||||||
icon: BarChart3,
|
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 { cn, pluralize } from "@/lib/utils";
|
||||||
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
|
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { RemoveMemberDialog } from "@/components/frontend/roster/RemoveMemberDialog";
|
||||||
import {
|
import {
|
||||||
Building2Icon,
|
Building2Icon,
|
||||||
CalendarDaysIcon,
|
CalendarDaysIcon,
|
||||||
|
|
@ -16,6 +17,23 @@ import {
|
||||||
|
|
||||||
const RosterDisplayContext = React.createContext<"lead" | "actual">("lead");
|
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> = {
|
const TYPE_ORDER: Record<string, number> = {
|
||||||
division: 0,
|
division: 0,
|
||||||
squad: 1,
|
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">
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground flex items-center gap-1 mb-1">
|
||||||
<CrownIcon className="size-3" /> Commanding
|
<CrownIcon className="size-3" /> Commanding
|
||||||
</span>
|
</span>
|
||||||
<MemberCard member={leader} />
|
<MemberCard member={leader} owningAssignment={node} />
|
||||||
<hr className="my-2 w-1/3 mx-auto"/>
|
<hr className="my-2 w-1/3 mx-auto"/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -226,7 +244,7 @@ function UnitBox({
|
||||||
{directMembers.length > 0 && (
|
{directMembers.length > 0 && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{directMembers.map((m) => (
|
{directMembers.map((m) => (
|
||||||
<MemberCard key={m.profileId} member={m} />
|
<MemberCard key={m.profileId} member={m} owningAssignment={node} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -286,7 +304,7 @@ function ByAssignmentView({
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5">
|
||||||
{groupMembers.map((m) => (
|
{groupMembers.map((m) => (
|
||||||
<MemberCard key={m.profileId} member={m} />
|
<MemberCard key={m.profileId} member={m} owningAssignment={assignment} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -476,8 +494,15 @@ function SeniorityView({ members }: { members: RosterMember[] }) {
|
||||||
|
|
||||||
// ─── Member Card ────────────────────────────────────────────────────
|
// ─── Member Card ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function MemberCard({ member }: { member: RosterMember }) {
|
function MemberCard({
|
||||||
|
member,
|
||||||
|
owningAssignment,
|
||||||
|
}: {
|
||||||
|
member: RosterMember;
|
||||||
|
owningAssignment?: RosterAssignment;
|
||||||
|
}) {
|
||||||
const leadOrActual = React.useContext(RosterDisplayContext);
|
const leadOrActual = React.useContext(RosterDisplayContext);
|
||||||
|
const viewer = React.useContext(RosterViewerContext);
|
||||||
const leadLabel = leadOrActual === "actual" ? "Actual" : "Lead";
|
const leadLabel = leadOrActual === "actual" ? "Actual" : "Lead";
|
||||||
const serviceYears = completedServiceYears(member.enlistmentDate);
|
const serviceYears = completedServiceYears(member.enlistmentDate);
|
||||||
const serviceLabel =
|
const serviceLabel =
|
||||||
|
|
@ -487,16 +512,30 @@ function MemberCard({ member }: { member: RosterMember }) {
|
||||||
? "Less than a year"
|
? "Less than a year"
|
||||||
: `${serviceYears} year${serviceYears === 1 ? "" : "s"}`;
|
: `${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 (
|
return (
|
||||||
<Link
|
<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">
|
||||||
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"
|
|
||||||
>
|
|
||||||
{serviceLabel && (
|
{serviceLabel && (
|
||||||
<span className="absolute top-1 right-2 text-[9px] leading-none text-muted-foreground/70 font-mono pointer-events-none">
|
<span className="absolute top-1 right-2 text-[9px] leading-none text-muted-foreground/70 font-mono pointer-events-none">
|
||||||
{serviceLabel} of service
|
{serviceLabel} of service
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/profile/${member.user.username}`}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2.5"
|
||||||
|
>
|
||||||
{member.rank.insigniaUrl && (
|
{member.rank.insigniaUrl && (
|
||||||
<img
|
<img
|
||||||
src={member.rank.insigniaUrl}
|
src={member.rank.insigniaUrl}
|
||||||
|
|
@ -510,19 +549,30 @@ function MemberCard({ member }: { member: RosterMember }) {
|
||||||
{member.rank.abbreviation} · @{member.user.username}
|
{member.rank.abbreviation} · @{member.user.username}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
{member.isLeader && (
|
{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">
|
<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}
|
{leadLabel}
|
||||||
</span>
|
</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">
|
<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}
|
{member.assignmentCallsign ? `[${member.assignmentCallsign}]` : member.assignmentName}
|
||||||
</span>
|
</span>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,12 +582,22 @@ export function RosterView({
|
||||||
members,
|
members,
|
||||||
assignments,
|
assignments,
|
||||||
leadOrActual = "lead",
|
leadOrActual = "lead",
|
||||||
|
currentUserId,
|
||||||
|
infantryName,
|
||||||
}: {
|
}: {
|
||||||
members: RosterMember[];
|
members: RosterMember[];
|
||||||
assignments: RosterAssignment[];
|
assignments: RosterAssignment[];
|
||||||
leadOrActual?: "lead" | "actual";
|
leadOrActual?: "lead" | "actual";
|
||||||
|
currentUserId?: number;
|
||||||
|
infantryName?: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
const viewer = React.useMemo<RosterViewerContextValue>(
|
||||||
|
() => ({ currentUserId: currentUserId ?? null, infantryName: infantryName ?? null }),
|
||||||
|
[currentUserId, infantryName],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<RosterViewerContext.Provider value={viewer}>
|
||||||
<RosterDisplayContext.Provider value={leadOrActual}>
|
<RosterDisplayContext.Provider value={leadOrActual}>
|
||||||
<Tabs defaultValue="org">
|
<Tabs defaultValue="org">
|
||||||
<TabsList variant="line">
|
<TabsList variant="line">
|
||||||
|
|
@ -580,5 +640,6 @@ export function RosterView({
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</RosterDisplayContext.Provider>
|
</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 { TransferApprovalCard } from "@/components/frontend/transfers/TransferApprovalCard";
|
||||||
import { TransferResolutionCard } from "@/components/frontend/transfers/TransferResolutionCard";
|
import { TransferResolutionCard } from "@/components/frontend/transfers/TransferResolutionCard";
|
||||||
import { TransferRequestCard } from "@/components/frontend/transfers/TransferRequestCard";
|
import { TransferRequestCard } from "@/components/frontend/transfers/TransferRequestCard";
|
||||||
|
import { LeadershipTransfersSection } from "@/components/frontend/transfers/LeadershipTransfersSection";
|
||||||
import {
|
import {
|
||||||
relationId,
|
relationId,
|
||||||
assignmentLeaderId,
|
assignmentLeaderId,
|
||||||
|
|
@ -158,6 +159,7 @@ export function TransfersView({ currentUserId, canResolve }: TransfersViewProps)
|
||||||
|
|
||||||
if (!requests && error) {
|
if (!requests && error) {
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTitle>Transfer requests unavailable</AlertTitle>
|
<AlertTitle>Transfer requests unavailable</AlertTitle>
|
||||||
<AlertDescription className="flex flex-wrap items-center gap-3">
|
<AlertDescription className="flex flex-wrap items-center gap-3">
|
||||||
|
|
@ -168,6 +170,8 @@ export function TransfersView({ currentUserId, canResolve }: TransfersViewProps)
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
<LeadershipTransfersSection currentUserId={currentUserId} canResolve={canResolve} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,6 +251,8 @@ export function TransfersView({ currentUserId, canResolve }: TransfersViewProps)
|
||||||
)}
|
)}
|
||||||
</TransferSection>
|
</TransferSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<LeadershipTransfersSection currentUserId={currentUserId} canResolve={canResolve} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import type { Payload } from "payload";
|
import type { Payload, PayloadRequest } from "payload";
|
||||||
import type { Rank, User } from "@/payload-types";
|
import type { Rank, User } from "@/payload-types";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
import { notifyUser } from "@/lib/notifications";
|
import { notifyUser } from "@/lib/notifications";
|
||||||
|
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
import {
|
import {
|
||||||
compareRanks,
|
compareRanks,
|
||||||
|
isAdminOrDeveloper,
|
||||||
resolvePromotionAuthority,
|
resolvePromotionAuthority,
|
||||||
resolveRankFromLadder,
|
resolveRankFromLadder,
|
||||||
} from "./authority";
|
} 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
|
* own rank's promotionCeiling (clamped below their own rank), or unbounded for
|
||||||
* admins/developers. Promotions only: the target rank must be strictly above
|
* 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
|
* 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(
|
export async function promoteMember(
|
||||||
payload: Payload,
|
payload: Payload,
|
||||||
actor: User,
|
actor: User,
|
||||||
username: string,
|
username: string,
|
||||||
newRankId: number,
|
newRankId: number,
|
||||||
|
options?: { req?: Partial<PayloadRequest>; ignoreActorRankLimit?: boolean },
|
||||||
): Promise<PromotionResult> {
|
): Promise<PromotionResult> {
|
||||||
const { authority, ladder, actorRank } = await resolvePromotionAuthority(payload, actor);
|
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.");
|
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.");
|
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.
|
// Never at or above the actor's own rank. Command authority (superuser
|
||||||
if (compareRanks(ladder, toRank, actorRank) >= 0) {
|
// 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.");
|
throw new Error("You cannot promote to a rank at or above your own.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ceiling bound for non-admin authority.
|
// 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}.`);
|
throw new Error(`You cannot promote above ${authority.maxRank.name}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,6 +120,7 @@ export async function promoteMember(
|
||||||
id: targetProfile.id,
|
id: targetProfile.id,
|
||||||
data: { rank: toRank.id },
|
data: { rank: toRank.id },
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
|
...(options?.req ? { req: options.req } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await emitGameEvent(payload, {
|
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;
|
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,
|
payload: Payload,
|
||||||
id: number,
|
id: number,
|
||||||
req?: Partial<PayloadRequest>,
|
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({
|
const doc = await payload.findByID({
|
||||||
collection: "assignments",
|
collection: "assignments",
|
||||||
id,
|
id,
|
||||||
|
|
@ -46,6 +50,7 @@ async function fetchAssignment(
|
||||||
return {
|
return {
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
name: doc.name,
|
name: doc.name,
|
||||||
|
type: doc.type,
|
||||||
leader: (doc.leader as number | null) ?? null,
|
leader: (doc.leader as number | null) ?? null,
|
||||||
members: (doc.members as number[]) ?? [],
|
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);
|
return res.docs.filter((u) => hasRoles(["admin", "developer"], u)).map((u) => u.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function notifySuperusers(
|
export async function notifySuperusers(
|
||||||
payload: Payload,
|
payload: Payload,
|
||||||
type: string,
|
type: string,
|
||||||
title: string,
|
title: string,
|
||||||
|
|
@ -106,6 +111,54 @@ async function casUpdate(
|
||||||
return res.docs[0];
|
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 {
|
function isRequired(doc: AssignmentTransfer, side: TransferSide): boolean {
|
||||||
const decision = side === "from" ? doc.fromLeaderDecision : doc.toLeaderDecision;
|
const decision = side === "from" ? doc.fromLeaderDecision : doc.toLeaderDecision;
|
||||||
return decision !== "not_required";
|
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.");
|
throw new Error("The requester is no longer a member of the source assignment.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await payload.update({
|
// The shared move core re-reads both assignments inside this transaction
|
||||||
collection: "assignments",
|
// and performs the same member-array splice this function used to inline.
|
||||||
id: fromAssignment.id,
|
await moveMemberBetweenAssignments(payload, {
|
||||||
data: { members: fromAssignment.members.filter((id) => id !== fresh.requester) },
|
|
||||||
overrideAccess: true,
|
|
||||||
req,
|
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" };
|
const completionData: Partial<AssignmentTransfer> = { status: "completed" };
|
||||||
if (opts.viaSuperuser) {
|
if (opts.viaSuperuser) {
|
||||||
completionData.resolvedBy = opts.resolvedBy;
|
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 { CustomRadioTests } from "@/collections/minigames/CustomRadioTests";
|
||||||
import { CustomRadioTestScores } from "@/collections/minigames/CustomRadioTestScores";
|
import { CustomRadioTestScores } from "@/collections/minigames/CustomRadioTestScores";
|
||||||
import { RibbonSubmissions } from "@/collections/users/RibbonSubmissions";
|
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 hasRoles from "@/utils/access-control/hasRoles";
|
||||||
import { scopedAdminPageAccess } from "@/utils/access-control/divisionAccess";
|
import { scopedAdminPageAccess } from "@/utils/access-control/divisionAccess";
|
||||||
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
|
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
|
||||||
|
|
@ -97,12 +99,24 @@ import { createOpenAI } from "@ai-sdk/openai";
|
||||||
import { createTextStreamResponse, jsonSchema, streamObject } from "ai";
|
import { createTextStreamResponse, jsonSchema, streamObject } from "ai";
|
||||||
import type { User } from "@/payload-types";
|
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({
|
const unslothProvider = createOpenAI({
|
||||||
apiKey: process.env.UNSLOTH_API_KEY,
|
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
|
// 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
|
// to seed prompts. Returning false here skips a field (no instruction row, no
|
||||||
|
|
@ -210,7 +224,7 @@ const stripCodeFences = (text: string): string =>
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
async function generatePlainText(prompt: string, system: string, model: string): Promise<string> {
|
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`, {
|
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -224,6 +238,7 @@ async function generatePlainText(prompt: string, system: string, model: string):
|
||||||
{ role: "user", content: prompt },
|
{ role: "user", content: prompt },
|
||||||
],
|
],
|
||||||
stream: false,
|
stream: false,
|
||||||
|
...REASONING_DISABLED,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -268,6 +283,8 @@ const collections = [
|
||||||
UserNotifications,
|
UserNotifications,
|
||||||
Evaluations,
|
Evaluations,
|
||||||
RibbonSubmissions,
|
RibbonSubmissions,
|
||||||
|
PromotionNominations,
|
||||||
|
LeadershipTransfers,
|
||||||
|
|
||||||
// Intel
|
// Intel
|
||||||
Missions,
|
Missions,
|
||||||
|
|
@ -577,8 +594,8 @@ export default buildConfig({
|
||||||
},
|
},
|
||||||
generationModels: [
|
generationModels: [
|
||||||
{
|
{
|
||||||
id: "unsloth/gemma-4-26B-A4B-it-qat-GGUF",
|
id: "unsloth/Ornith-1.0-9B-GGUF",
|
||||||
name: "Gemma 4 26B A4B",
|
name: "Ornith 1.0 9B",
|
||||||
fields: ["text", "textarea", "richText"],
|
fields: ["text", "textarea", "richText"],
|
||||||
handler: async (
|
handler: async (
|
||||||
prompt: string,
|
prompt: string,
|
||||||
|
|
@ -623,6 +640,7 @@ export default buildConfig({
|
||||||
openai: {
|
openai: {
|
||||||
strictJsonSchema: true,
|
strictJsonSchema: true,
|
||||||
structuredOutputs: true,
|
structuredOutputs: true,
|
||||||
|
...REASONING_DISABLED,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -650,11 +668,11 @@ export default buildConfig({
|
||||||
},
|
},
|
||||||
output: "text",
|
output: "text",
|
||||||
settings: {
|
settings: {
|
||||||
name: "gemma-4-settings",
|
name: "ornith-9b-settings",
|
||||||
type: "group",
|
type: "group",
|
||||||
admin: {
|
admin: {
|
||||||
condition(data) {
|
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: [
|
fields: [
|
||||||
|
|
@ -665,7 +683,7 @@ export default buildConfig({
|
||||||
label: "Model",
|
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