1
0
Fork 0

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:
Jason Fraley 2026-10-03 03:28:58 -04:00
parent d43b2ef658
commit 62573f74e6
28 changed files with 40393 additions and 111 deletions

View 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);
}
}

View 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>
);
}

View file

@ -21,6 +21,10 @@ import {
PERSONAL_EXCERPT_MAX_LENGTH,
} from "@/lib/profile/excerpt-limits";
import { promoteMember as promoteMemberService } from "@/lib/promotions";
import {
cancelNomination as cancelNominationCore,
submitNomination,
} from "@/lib/promotions/nominations";
interface ActionResult<T = undefined> {
success: boolean;
@ -290,3 +294,43 @@ export async function promoteMember(
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
}
}
/**
* Submit a promotion nomination for the given member (or a self-request when
* the nominee is the authenticated user). The division leader endorsement
* stage and the open-nomination guards are enforced by the service lib.
*/
export async function submitPromotionNomination(input: {
nomineeUsername: string;
accolades: string;
}): Promise<ActionResult<{ nominationId: number }>> {
try {
const { payload, user } = await authenticate();
const nominee = await findUserByUsername(payload, input.nomineeUsername);
if (!nominee) {
return { success: false, error: "User not found." };
}
const nomination = await submitNomination(payload, user, {
nomineeId: nominee.id,
accolades: input.accolades,
});
return { success: true, data: { nominationId: nomination.id } };
} catch (e) {
if (e instanceof Error && e.message === "Unauthorized") throw e;
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
}
}
/** Withdraw one of the caller's own pending promotion nominations. */
export async function cancelMyNomination(nominationId: number): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
await cancelNominationCore(payload, user, nominationId);
return { success: true };
} catch (e) {
if (e instanceof Error && e.message === "Unauthorized") throw e;
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
}
}

View file

@ -42,8 +42,10 @@ import { RibbonRack } from "@/components/frontend/account/RibbonRack";
import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection";
import { DossierExcerptEditors } from "@/components/frontend/profile/DossierExcerptEditors";
import { PromoteButton } from "@/components/frontend/profile/PromoteButton";
import { NominateForPromotionButton } from "@/components/frontend/profile/NominateForPromotionButton";
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
import { allowedPromotionTargets, resolvePromotionAuthority } from "@/lib/promotions";
import { loadRankLadder, rankIndex } from "@/lib/promotions/authority";
import type {
EvaluationMissionOption,
EvaluationSummary,
@ -367,6 +369,27 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const allowedTargetRanks = promotionResult
? allowedPromotionTargets(promotionResult, rank)
: [];
// Nomination visibility: any logged-in viewer can nominate (or self-request)
// while the member has a next rung to climb and no nomination is open.
const nomineeLadder = promotionResult?.ladder ?? (viewer ? await loadRankLadder(payload) : []);
const currentRankIndex = rankIndex(nomineeLadder, rank.id);
const hasNextRung = currentRankIndex >= 0 && currentRankIndex + 1 < nomineeLadder.length;
const openNominationRes = viewer
? await payload.find({
collection: "promotion-nominations",
where: {
and: [
{ nominee: { equals: user.id } },
{ status: { in: ["pending", "awaiting_superuser"] } },
],
},
limit: 1,
depth: 0,
overrideAccess: true,
})
: null;
const canRequestPromotion =
viewer !== null && hasNextRung && (openNominationRes?.docs.length ?? 0) === 0;
const awardEntries = (profile.progression?.awards ?? []).filter(
(entry): entry is ProfileAwardEntry => typeof entry === "object" && entry !== null,
);
@ -621,6 +644,14 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
/>
)}
{canRequestPromotion && (
<NominateForPromotionButton
username={username}
displayName={user.displayName}
isOwnProfile={isProfileOwner}
/>
)}
{/* ── Combat Record ──────────────────────────────────── */}
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">

View 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);
}
}

View file

@ -40,7 +40,7 @@ export default async function RosterPage() {
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
const currentUser = isPayloadUser(authUser) ? authUser : null;
const [profileRes, assignmentRes] = await Promise.all([
const [profileRes, assignmentRes, rules] = await Promise.all([
payload.find({
collection: "profiles",
limit: 500,
@ -65,6 +65,7 @@ export default async function RosterPage() {
subAssignments: true,
},
}),
payload.findGlobal({ slug: "game-rules", depth: 0 }),
]);
const profiles = profileRes.docs;
@ -132,6 +133,16 @@ export default async function RosterPage() {
.filter(Boolean) as number[],
}));
// The configured Infantry assignment (leadership-initiated removal
// destination), resolved to a display name when it exists.
const configuredInfantry = rules?.infantryAssignment ?? null;
const infantryId =
configuredInfantry != null && typeof configuredInfantry === "object"
? configuredInfantry.id
: configuredInfantry;
const infantryName =
infantryId != null ? (assignments.find((a) => a.id === infantryId)?.name ?? null) : null;
return (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1" data-tour="page-header">
@ -149,6 +160,8 @@ export default async function RosterPage() {
members={members}
assignments={assignments}
leadOrActual={currentUser?.preferences?.display?.leadOrActual ?? "lead"}
currentUserId={currentUser?.id}
infantryName={infantryName}
/>
</div>
);

View 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],
},
};

View 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],
},
};

View file

@ -13,6 +13,7 @@ import {
Flag,
Kanban,
LifeBuoy,
Medal,
Map,
Package,
Radar,
@ -153,6 +154,11 @@ export const sidebarData = {
url: "/personnel/statistics",
icon: BarChart3,
},
{
title: "Promotions",
url: "/personnel/promotions",
icon: Medal,
},
],
},
],

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
);
}

View 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>
);
}

View file

@ -5,6 +5,7 @@ import Link from "next/link";
import { cn, pluralize } from "@/lib/utils";
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { RemoveMemberDialog } from "@/components/frontend/roster/RemoveMemberDialog";
import {
Building2Icon,
CalendarDaysIcon,
@ -16,6 +17,23 @@ import {
const RosterDisplayContext = React.createContext<"lead" | "actual">("lead");
/**
* Viewer-scoped data threaded down for the leader-only removal affordance:
* the authenticated user's id (null for the typed guest case; the layout gates
* the page) and the configured Infantry assignment name for the removal dialog
* copy. Kept in a context (like RosterDisplayContext) so the card components
* keep their existing prop signatures.
*/
type RosterViewerContextValue = {
readonly currentUserId: number | null;
readonly infantryName: string | null;
};
const RosterViewerContext = React.createContext<RosterViewerContextValue>({
currentUserId: null,
infantryName: null,
});
const TYPE_ORDER: Record<string, number> = {
division: 0,
squad: 1,
@ -218,7 +236,7 @@ function UnitBox({
<span className="text-[10px] uppercase tracking-wider text-muted-foreground flex items-center gap-1 mb-1">
<CrownIcon className="size-3" /> Commanding
</span>
<MemberCard member={leader} />
<MemberCard member={leader} owningAssignment={node} />
<hr className="my-2 w-1/3 mx-auto"/>
</div>
)}
@ -226,7 +244,7 @@ function UnitBox({
{directMembers.length > 0 && (
<div className="flex flex-col gap-1">
{directMembers.map((m) => (
<MemberCard key={m.profileId} member={m} />
<MemberCard key={m.profileId} member={m} owningAssignment={node} />
))}
</div>
)}
@ -286,7 +304,7 @@ function ByAssignmentView({
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5">
{groupMembers.map((m) => (
<MemberCard key={m.profileId} member={m} />
<MemberCard key={m.profileId} member={m} owningAssignment={assignment} />
))}
</div>
</div>
@ -476,8 +494,15 @@ function SeniorityView({ members }: { members: RosterMember[] }) {
// ─── Member Card ────────────────────────────────────────────────────
function MemberCard({ member }: { member: RosterMember }) {
function MemberCard({
member,
owningAssignment,
}: {
member: RosterMember;
owningAssignment?: RosterAssignment;
}) {
const leadOrActual = React.useContext(RosterDisplayContext);
const viewer = React.useContext(RosterViewerContext);
const leadLabel = leadOrActual === "actual" ? "Actual" : "Lead";
const serviceYears = completedServiceYears(member.enlistmentDate);
const serviceLabel =
@ -487,42 +512,67 @@ function MemberCard({ member }: { member: RosterMember }) {
? "Less than a year"
: `${serviceYears} year${serviceYears === 1 ? "" : "s"}`;
// Leader-only removal affordance: the viewer must be the current leader of
// the member's division and cannot remove themself. The server action
// re-validates every guard, so this is a display gate only. Only shown in
// tabs that know the member's containing assignment (org chart, by
// assignment). The assignment chip is suppressed while the action is shown
// so the two do not collide at the card's right edge.
const canRemove =
viewer.currentUserId != null &&
owningAssignment != null &&
owningAssignment.type === "division" &&
owningAssignment.leaderId === viewer.currentUserId &&
member.user.id !== viewer.currentUserId;
return (
<Link
href={`/profile/${member.user.username}`}
className="relative flex items-center gap-2.5 rounded-md border border-border bg-muted/30 px-3 py-2 hover:bg-accent hover:border-accent-foreground/20 transition-colors"
>
<div className="relative flex items-center gap-2.5 rounded-md border border-border bg-muted/30 px-3 py-2 hover:bg-accent hover:border-accent-foreground/20 transition-colors">
{serviceLabel && (
<span className="absolute top-1 right-2 text-[9px] leading-none text-muted-foreground/70 font-mono pointer-events-none">
{serviceLabel} of service
</span>
)}
{member.rank.insigniaUrl && (
<img
src={member.rank.insigniaUrl}
alt={member.rank.name}
className="size-5 object-contain shrink-0"
/>
)}
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">{member.user.displayName}</span>
<span className="text-[10px] text-muted-foreground font-mono truncate">
{member.rank.abbreviation} · @{member.user.username}
</span>
</div>
<Link
href={`/profile/${member.user.username}`}
className="flex min-w-0 flex-1 items-center gap-2.5"
>
{member.rank.insigniaUrl && (
<img
src={member.rank.insigniaUrl}
alt={member.rank.name}
className="size-5 object-contain shrink-0"
/>
)}
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">{member.user.displayName}</span>
<span className="text-[10px] text-muted-foreground font-mono truncate">
{member.rank.abbreviation} · @{member.user.username}
</span>
</div>
</Link>
<div className="flex items-center gap-1.5 shrink-0">
{member.isLeader && (
<span className="text-[10px] uppercase tracking-wider text-amber-400 bg-amber-950/50 border border-amber-800/50 px-1.5 py-0.5 rounded font-semibold">
{leadLabel}
</span>
)}
{member.assignmentName && (
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-mono hidden sm:block">
{member.assignmentCallsign ? `[${member.assignmentCallsign}]` : member.assignmentName}
</span>
{canRemove ? (
<RemoveMemberDialog
memberId={member.user.id}
memberLabel={member.user.displayName}
assignmentId={owningAssignment.id}
assignmentName={owningAssignment.name}
infantryName={viewer.infantryName}
/>
) : (
member.assignmentName && (
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-mono hidden sm:block">
{member.assignmentCallsign ? `[${member.assignmentCallsign}]` : member.assignmentName}
</span>
)
)}
</div>
</Link>
</div>
);
}
@ -532,53 +582,64 @@ export function RosterView({
members,
assignments,
leadOrActual = "lead",
currentUserId,
infantryName,
}: {
members: RosterMember[];
assignments: RosterAssignment[];
leadOrActual?: "lead" | "actual";
currentUserId?: number;
infantryName?: string | null;
}) {
return (
<RosterDisplayContext.Provider value={leadOrActual}>
<Tabs defaultValue="org">
<TabsList variant="line">
<TabsTrigger value="org" className="gap-1.5">
<NetworkIcon className="size-3" />
Org Chart
</TabsTrigger>
<TabsTrigger value="assignment" className="gap-1.5">
<Building2Icon className="size-3" />
By Assignment
</TabsTrigger>
<TabsTrigger value="rank" className="gap-1.5">
<ShieldIcon className="size-3" />
By Rank
</TabsTrigger>
<TabsTrigger value="alpha" className="gap-1.5">
<ListIcon className="size-3" />
A-Z
</TabsTrigger>
<TabsTrigger value="seniority" className="gap-1.5">
<CalendarDaysIcon className="size-3" />
Years of Service
</TabsTrigger>
</TabsList>
const viewer = React.useMemo<RosterViewerContextValue>(
() => ({ currentUserId: currentUserId ?? null, infantryName: infantryName ?? null }),
[currentUserId, infantryName],
);
<TabsContent value="org" className="overflow-x-scroll">
<OrgChartView assignments={assignments} members={members} />
</TabsContent>
<TabsContent value="assignment">
<ByAssignmentView assignments={assignments} members={members} />
</TabsContent>
<TabsContent value="rank">
<ByRankView members={members} />
</TabsContent>
<TabsContent value="alpha">
<AlphabeticalView members={members} />
</TabsContent>
<TabsContent value="seniority">
<SeniorityView members={members} />
</TabsContent>
</Tabs>
</RosterDisplayContext.Provider>
return (
<RosterViewerContext.Provider value={viewer}>
<RosterDisplayContext.Provider value={leadOrActual}>
<Tabs defaultValue="org">
<TabsList variant="line">
<TabsTrigger value="org" className="gap-1.5">
<NetworkIcon className="size-3" />
Org Chart
</TabsTrigger>
<TabsTrigger value="assignment" className="gap-1.5">
<Building2Icon className="size-3" />
By Assignment
</TabsTrigger>
<TabsTrigger value="rank" className="gap-1.5">
<ShieldIcon className="size-3" />
By Rank
</TabsTrigger>
<TabsTrigger value="alpha" className="gap-1.5">
<ListIcon className="size-3" />
A-Z
</TabsTrigger>
<TabsTrigger value="seniority" className="gap-1.5">
<CalendarDaysIcon className="size-3" />
Years of Service
</TabsTrigger>
</TabsList>
<TabsContent value="org" className="overflow-x-scroll">
<OrgChartView assignments={assignments} members={members} />
</TabsContent>
<TabsContent value="assignment">
<ByAssignmentView assignments={assignments} members={members} />
</TabsContent>
<TabsContent value="rank">
<ByRankView members={members} />
</TabsContent>
<TabsContent value="alpha">
<AlphabeticalView members={members} />
</TabsContent>
<TabsContent value="seniority">
<SeniorityView members={members} />
</TabsContent>
</Tabs>
</RosterDisplayContext.Provider>
</RosterViewerContext.Provider>
);
}

View 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 &amp; 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>
);
}

View 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>
);
}

View file

@ -8,6 +8,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { TransferApprovalCard } from "@/components/frontend/transfers/TransferApprovalCard";
import { TransferResolutionCard } from "@/components/frontend/transfers/TransferResolutionCard";
import { TransferRequestCard } from "@/components/frontend/transfers/TransferRequestCard";
import { LeadershipTransfersSection } from "@/components/frontend/transfers/LeadershipTransfersSection";
import {
relationId,
assignmentLeaderId,
@ -158,16 +159,19 @@ export function TransfersView({ currentUserId, canResolve }: TransfersViewProps)
if (!requests && error) {
return (
<Alert variant="destructive">
<AlertTitle>Transfer requests unavailable</AlertTitle>
<AlertDescription className="flex flex-wrap items-center gap-3">
<span>{error}</span>
<Button size="sm" variant="outline" onClick={() => void loadRequests()}>
<RefreshCwIcon data-icon="inline-start" />
Try again
</Button>
</AlertDescription>
</Alert>
<div className="flex flex-col gap-8">
<Alert variant="destructive">
<AlertTitle>Transfer requests unavailable</AlertTitle>
<AlertDescription className="flex flex-wrap items-center gap-3">
<span>{error}</span>
<Button size="sm" variant="outline" onClick={() => void loadRequests()}>
<RefreshCwIcon data-icon="inline-start" />
Try again
</Button>
</AlertDescription>
</Alert>
<LeadershipTransfersSection currentUserId={currentUserId} canResolve={canResolve} />
</div>
);
}
@ -247,6 +251,8 @@ export function TransfersView({ currentUserId, canResolve }: TransfersViewProps)
)}
</TransferSection>
)}
<LeadershipTransfersSection currentUserId={currentUserId} canResolve={canResolve} />
</div>
);
}

View file

@ -1,10 +1,12 @@
import type { Payload } from "payload";
import type { Payload, PayloadRequest } from "payload";
import type { Rank, User } from "@/payload-types";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { notifyUser } from "@/lib/notifications";
import { isSuperuser } from "@/utils/access-control/hasPermission";
import {
compareRanks,
isAdminOrDeveloper,
resolvePromotionAuthority,
resolveRankFromLadder,
} from "./authority";
@ -46,17 +48,30 @@ async function findProfileByUserId(payload: Payload, userId: number) {
* own rank's promotionCeiling (clamped below their own rank), or unbounded for
* admins/developers. Promotions only: the target rank must be strictly above
* the member's current rank and strictly below the actor's own rank. Emits a
* `personnel:promoted` event and notifies the promoted member.
* `personnel:promoted` event and notifies the promoted member. Pass `req` to
* join the rank write into an outer transaction (e.g. the nomination
* approval flow).
*
* `ignoreActorRankLimit` lets an admin/developer or roleDocs superuser act as
* unit command rather than as a rank-holder: the "strictly below the actor's
* own rank" bound and their promotion ceiling stop applying, and the actor may
* hold no ranked profile at all. Reserved for flows where the final decision
* is gated elsewhere (promotion nomination approval).
*/
export async function promoteMember(
payload: Payload,
actor: User,
username: string,
newRankId: number,
options?: { req?: Partial<PayloadRequest>; ignoreActorRankLimit?: boolean },
): Promise<PromotionResult> {
const { authority, ladder, actorRank } = await resolvePromotionAuthority(payload, actor);
if (!authority || !actorRank) {
const commandBypass =
options?.ignoreActorRankLimit === true &&
(isAdminOrDeveloper(actor) || (await isSuperuser(payload, actor)));
if ((!authority || !actorRank) && !commandBypass) {
throw new Error("You do not have promotion authority.");
}
@ -85,13 +100,18 @@ export async function promoteMember(
throw new Error("Promotions only: the new rank must be above the member's current rank.");
}
// Never at or above the actor's own rank.
if (compareRanks(ladder, toRank, actorRank) >= 0) {
// Never at or above the actor's own rank. Command authority (superuser
// approval flows) acts for the unit and bypasses this bound.
if (!commandBypass && actorRank && compareRanks(ladder, toRank, actorRank) >= 0) {
throw new Error("You cannot promote to a rank at or above your own.");
}
// Ceiling bound for non-admin authority.
if (authority.kind === "ceiling" && compareRanks(ladder, toRank, authority.maxRank) > 0) {
if (
!commandBypass &&
authority?.kind === "ceiling" &&
compareRanks(ladder, toRank, authority.maxRank) > 0
) {
throw new Error(`You cannot promote above ${authority.maxRank.name}.`);
}
@ -100,6 +120,7 @@ export async function promoteMember(
id: targetProfile.id,
data: { rank: toRank.id },
overrideAccess: true,
...(options?.req ? { req: options.req } : {}),
});
await emitGameEvent(payload, {

View 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;
}

View file

@ -31,11 +31,15 @@ async function fetchTransfer(payload: Payload, requestId: number): Promise<Assig
return doc;
}
async function fetchAssignment(
/**
* Fetch an assignment's id/name/type/leader/members. Exported for the
* leadership-transfer service, which shares the same re-validation needs.
*/
export async function fetchAssignment(
payload: Payload,
id: number,
req?: Partial<PayloadRequest>,
): Promise<{ id: number; name: string; leader: number | null; members: number[] }> {
): Promise<{ id: number; name: string; type: string; leader: number | null; members: number[] }> {
const doc = await payload.findByID({
collection: "assignments",
id,
@ -46,6 +50,7 @@ async function fetchAssignment(
return {
id: doc.id,
name: doc.name,
type: doc.type,
leader: (doc.leader as number | null) ?? null,
members: (doc.members as number[]) ?? [],
};
@ -65,7 +70,7 @@ async function superuserIds(payload: Payload): Promise<number[]> {
return res.docs.filter((u) => hasRoles(["admin", "developer"], u)).map((u) => u.id);
}
async function notifySuperusers(
export async function notifySuperusers(
payload: Payload,
type: string,
title: string,
@ -106,6 +111,54 @@ async function casUpdate(
return res.docs[0];
}
/**
* Transactional member-move core shared by the assignment-transfer execution
* path (executeTransfer) and the leadership-transfer service. Must be called
* inside the caller's transaction (thread its `req`): re-reads both
* assignments inside that transaction, removes the subject from the source
* members array, and appends them to the destination members array (a no-op
* append when the subject is already there).
*/
export async function moveMemberBetweenAssignments(
payload: Payload,
opts: {
req: Partial<PayloadRequest>;
subjectId: number;
fromAssignmentId: number;
toAssignmentId: number;
},
): Promise<void> {
const { req, subjectId, fromAssignmentId, toAssignmentId } = opts;
const [fromAssignment, toAssignment] = await Promise.all([
fetchAssignment(payload, fromAssignmentId, req),
fetchAssignment(payload, toAssignmentId, req),
]);
if (!fromAssignment.members.includes(subjectId)) {
throw new Error("The subject is no longer a member of the source assignment.");
}
await payload.update({
collection: "assignments",
id: fromAssignment.id,
data: { members: fromAssignment.members.filter((id) => id !== subjectId) },
overrideAccess: true,
depth: 0,
req,
});
if (!toAssignment.members.includes(subjectId)) {
await payload.update({
collection: "assignments",
id: toAssignment.id,
data: { members: [...toAssignment.members, subjectId] },
overrideAccess: true,
depth: 0,
req,
});
}
}
function isRequired(doc: AssignmentTransfer, side: TransferSide): boolean {
const decision = side === "from" ? doc.fromLeaderDecision : doc.toLeaderDecision;
return decision !== "not_required";
@ -233,24 +286,15 @@ async function executeTransfer(
throw new Error("The requester is no longer a member of the source assignment.");
}
await payload.update({
collection: "assignments",
id: fromAssignment.id,
data: { members: fromAssignment.members.filter((id) => id !== fresh.requester) },
overrideAccess: true,
// The shared move core re-reads both assignments inside this transaction
// and performs the same member-array splice this function used to inline.
await moveMemberBetweenAssignments(payload, {
req,
subjectId: relId(fresh.requester),
fromAssignmentId: fromAssignment.id,
toAssignmentId: toAssignment.id,
});
if (!toAssignment.members.includes(relId(fresh.requester))) {
await payload.update({
collection: "assignments",
id: toAssignment.id,
data: { members: [...toAssignment.members, fresh.requester] },
overrideAccess: true,
req,
});
}
const completionData: Partial<AssignmentTransfer> = { status: "completed" };
if (opts.viaSuperuser) {
completionData.resolvedBy = opts.resolvedBy;

View 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];
}

View file

@ -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";`)
}

View file

@ -86,6 +86,8 @@ import { CustomMinefieldScores } from "@/collections/minigames/CustomMinefieldSc
import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests";
import { CustomRadioTestScores } from "@/collections/minigames/CustomRadioTestScores";
import { RibbonSubmissions } from "@/collections/users/RibbonSubmissions";
import { PromotionNominations } from "@/collections/users/PromotionNominations";
import { LeadershipTransfers } from "@/collections/users/LeadershipTransfers";
import hasRoles from "@/utils/access-control/hasRoles";
import { scopedAdminPageAccess } from "@/utils/access-control/divisionAccess";
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
@ -97,12 +99,24 @@ import { createOpenAI } from "@ai-sdk/openai";
import { createTextStreamResponse, jsonSchema, streamObject } from "ai";
import type { User } from "@/payload-types";
// Ornith-9B is served by the "Apex" Unsloth connection (a separate machine,
// apex-lnx / 192.168.1.7), reached through Unsloth Studio's OpenAI-compatible
// API. Point UNSLOTH_URL at that instance; the default is the LAN address of
// the Apex box.
const unslothProvider = createOpenAI({
apiKey: process.env.UNSLOTH_API_KEY,
baseURL: process.env.UNSLOTH_URL || "https://uns.onyxsimple.com/v1",
baseURL: process.env.UNSLOTH_URL || "http://192.168.1.7:8888/v1",
});
const DEFAULT_MODEL = "unsloth/gemma-4-26B-A4B-it-qat-GGUF";
const DEFAULT_MODEL = "unsloth/Ornith-1.0-9B-GGUF";
// Ornith is a reasoning model: it emits a long `reasoning_content` block before
// the actual answer, and with a small token budget the reasoning consumes the
// whole allowance, leaving `content` empty. Unsloth Studio (llama.cpp) accepts
// `reasoning_effort: "none"` to turn thinking off for the request, so the model
// behaves like a plain instruct model and small budgets still produce output.
// Sent on every request; harmless on models that do not support it.
const REASONING_DISABLED = { reasoning_effort: "none" } as const;
// AI Compose allowlist: the plugin's default init makes an LLM call per field
// to seed prompts. Returning false here skips a field (no instruction row, no
@ -210,7 +224,7 @@ const stripCodeFences = (text: string): string =>
.trim();
async function generatePlainText(prompt: string, system: string, model: string): Promise<string> {
const baseUrl = process.env.UNSLOTH_URL || "https://uns.onyxsimple.com/v1";
const baseUrl = process.env.UNSLOTH_URL || "http://192.168.1.7:8888/v1";
const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
@ -224,6 +238,7 @@ async function generatePlainText(prompt: string, system: string, model: string):
{ role: "user", content: prompt },
],
stream: false,
...REASONING_DISABLED,
}),
});
@ -268,6 +283,8 @@ const collections = [
UserNotifications,
Evaluations,
RibbonSubmissions,
PromotionNominations,
LeadershipTransfers,
// Intel
Missions,
@ -577,8 +594,8 @@ export default buildConfig({
},
generationModels: [
{
id: "unsloth/gemma-4-26B-A4B-it-qat-GGUF",
name: "Gemma 4 26B A4B",
id: "unsloth/Ornith-1.0-9B-GGUF",
name: "Ornith 1.0 9B",
fields: ["text", "textarea", "richText"],
handler: async (
prompt: string,
@ -623,6 +640,7 @@ export default buildConfig({
openai: {
strictJsonSchema: true,
structuredOutputs: true,
...REASONING_DISABLED,
},
},
});
@ -650,11 +668,11 @@ export default buildConfig({
},
output: "text",
settings: {
name: "gemma-4-settings",
name: "ornith-9b-settings",
type: "group",
admin: {
condition(data) {
return data["model-id"] === "unsloth/gemma-4-26B-A4B-it-qat-GGUF";
return data["model-id"] === "unsloth/Ornith-1.0-9B-GGUF";
},
},
fields: [
@ -665,7 +683,7 @@ export default buildConfig({
label: "Model",
},
],
label: "Gemma 4 Settings",
label: "Ornith 9B Settings",
},
},
],

View 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);
});

View 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");
});
});