1
0
Fork 0

feat(promotions): add rank promotion ceiling with GM promote flow

Promotion service lib, ceiling field on Ranks, permission gate, migration, and GM promote button with tests.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-14 15:37:05 -04:00
parent 8c3dbdbc98
commit 8d92e7aaa9
8 changed files with 27597 additions and 5 deletions

View file

@ -36,5 +36,14 @@ export const Ranks: CollectionConfig = {
type: "upload", type: "upload",
relationTo: "media", relationTo: "media",
}, },
{
name: "promotionCeiling",
type: "relationship",
relationTo: "ranks",
admin: {
description:
"Holders of this rank may promote members up to and including this rank. Leave empty for no promotion authority.",
},
},
], ],
}; };

View file

@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { promoteMember } from "@/app/(frontend)/profile/[username]/actions";
import { ArrowUpIcon, Loader2Icon, MedalIcon } from "lucide-react";
import { toast } from "sonner";
interface PromoteButtonProps {
username: string;
currentRankName: string;
targetRanks: readonly { id: number; name: string }[];
}
/**
* Profile-page section that lets a viewer with promotion authority promote the
* profile owner. Authority and the allowed target ranks are computed server-side
* by the page; this component only provides the entry point and submits the
* chosen rank id.
*/
export function PromoteButton({ username, currentRankName, targetRanks }: PromoteButtonProps) {
const [open, setOpen] = useState(false);
const [rankId, setRankId] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const selectedRankId = rankId ?? targetRanks[0]?.id ?? null;
const selectedRank = targetRanks.find((r) => r.id === selectedRankId);
async function handlePromote() {
if (selectedRankId == null || submitting) return;
setSubmitting(true);
setError(null);
const res = await promoteMember(username, selectedRankId);
if (res.success) {
toast.success("Promotion applied", {
description: `${username} promoted to ${selectedRank?.name ?? "new rank"}.`,
});
setOpen(false);
router.refresh();
} else {
setError(res.error ?? "Failed to promote member.");
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">
<MedalIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Promotion
</h2>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
setRankId(null);
setError(null);
setOpen(true);
}}
>
<ArrowUpIcon data-icon="inline-start" />
Promote
</Button>
</div>
<Dialog
open={open}
onOpenChange={(o) => {
if (!submitting) setOpen(o);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Promote {username}</DialogTitle>
<DialogDescription>
Current rank: {currentRankName}. Select the new rank, then confirm.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Select
value={selectedRankId != null ? String(selectedRankId) : undefined}
onValueChange={(v) => setRankId(Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Select a rank" />
</SelectTrigger>
<SelectContent>
{targetRanks.map((r) => (
<SelectItem key={r.id} value={String(r.id)}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={handlePromote} disabled={selectedRankId == null || submitting}>
{submitting && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
Confirm promotion
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}

View file

@ -0,0 +1,134 @@
import type { Payload } from "payload";
import type { Rank, User } from "@/payload-types";
export type PromotionAuthority =
| { kind: "unbounded" }
| { kind: "ceiling"; maxRank: Rank }
| null;
export interface PromotionAuthorityResult {
authority: PromotionAuthority;
/** Every rank ordered by ascending seniority (lowest first). */
ladder: Rank[];
/** The actor's own rank, or null when they have no profile/rank. */
actorRank: Rank | null;
}
/** Admin/developer bypass by legacy role, matching the profile page precedent. */
export function isAdminOrDeveloper(user: User): boolean {
return user.roles?.some((role) => role === "admin" || role === "developer") === true;
}
/** Load every rank ordered by ascending seniority (lowest first). */
export async function loadRankLadder(payload: Payload): Promise<Rank[]> {
const res = await payload.find({
collection: "ranks",
limit: 100,
depth: 0,
overrideAccess: true,
});
return [...res.docs].sort((a, b) => {
const ao = a._order ?? "";
const bo = b._order ?? "";
return ao < bo ? -1 : ao > bo ? 1 : 0;
});
}
/** Resolve a rank reference (id or populated object) against the ladder. */
export function resolveRankFromLadder(
ladder: Rank[],
rankRef: number | Rank | null | undefined,
): Rank | null {
if (rankRef == null) return null;
const id = typeof rankRef === "object" ? rankRef.id : rankRef;
return ladder.find((r) => r.id === id) ?? null;
}
/** Index of a rank in the ladder, or -1 when absent. */
export function rankIndex(ladder: Rank[], rankId: number): number {
return ladder.findIndex((r) => r.id === rankId);
}
/**
* Compare two ranks by ladder seniority. Negative when `a` is below `b`, zero
* when equal, positive when `a` is above `b`.
*/
export function compareRanks(ladder: Rank[], a: Rank, b: Rank): number {
return rankIndex(ladder, a.id) - rankIndex(ladder, b.id);
}
async function findProfileByUserId(payload: Payload, userId: number) {
const res = await payload.find({
collection: "profiles",
where: { user: { equals: userId } },
limit: 1,
depth: 0,
select: { rank: true },
overrideAccess: true,
});
return res.docs[0] ?? null;
}
/**
* Resolve a user's promotion authority. Admins/developers get unbounded
* authority; everyone else is limited by their rank's promotionCeiling, clamped
* to the rank directly below their own so a misconfigured ceiling can never
* allow promoting a peer, a superior, or oneself. Returns null authority when
* the user has no profile, no rank, or no ceiling.
*/
export async function resolvePromotionAuthority(
payload: Payload,
user: User,
): Promise<PromotionAuthorityResult> {
const ladder = await loadRankLadder(payload);
const profile = await findProfileByUserId(payload, user.id);
const actorRank = resolveRankFromLadder(ladder, profile?.rank);
if (!profile || !actorRank) {
return { authority: null, ladder, actorRank: null };
}
if (isAdminOrDeveloper(user)) {
return { authority: { kind: "unbounded" }, ladder, actorRank };
}
const ceiling = resolveRankFromLadder(ladder, actorRank.promotionCeiling);
if (!ceiling) {
return { authority: null, ladder, actorRank };
}
const actorIndex = rankIndex(ladder, actorRank.id);
const ceilingIndex = rankIndex(ladder, ceiling.id);
const maxIndex = Math.min(ceilingIndex, actorIndex - 1);
if (maxIndex < 0) {
return { authority: null, ladder, actorRank };
}
return { authority: { kind: "ceiling", maxRank: ladder[maxIndex] }, ladder, actorRank };
}
/** Ranks the given member may be promoted to under the resolved authority. */
export function allowedPromotionTargets(
result: PromotionAuthorityResult,
memberRank: Rank,
): Rank[] {
const { authority, ladder, actorRank } = result;
if (!authority) return [];
const memberIndex = rankIndex(ladder, memberRank.id);
if (memberIndex < 0) return [];
let maxIndex: number;
if (authority.kind === "unbounded") {
if (!actorRank) return [];
maxIndex = rankIndex(ladder, actorRank.id) - 1;
} else {
maxIndex = rankIndex(ladder, authority.maxRank.id);
}
if (maxIndex <= memberIndex) return [];
return ladder.filter((r) => {
const i = rankIndex(ladder, r.id);
return i > memberIndex && i <= maxIndex;
});
}

128
src/lib/promotions/index.ts Normal file
View file

@ -0,0 +1,128 @@
import type { Payload } 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 {
compareRanks,
resolvePromotionAuthority,
resolveRankFromLadder,
} from "./authority";
export { allowedPromotionTargets, resolvePromotionAuthority } from "./authority";
export type { PromotionAuthority, PromotionAuthorityResult } from "./authority";
export interface PromotionResult {
profileId: number;
fromRank: Rank;
toRank: Rank;
}
async function findUserByUsername(payload: Payload, username: string) {
const res = await payload.find({
collection: "users",
where: { username: { equals: username } },
limit: 1,
depth: 0,
overrideAccess: true,
});
return (res.docs[0] as { id: number; username: string } | undefined) ?? null;
}
async function findProfileByUserId(payload: Payload, userId: number) {
const res = await payload.find({
collection: "profiles",
where: { user: { equals: userId } },
limit: 1,
depth: 0,
select: { rank: true },
overrideAccess: true,
});
return res.docs[0] ?? null;
}
/**
* Promote a member to a new rank. The actor's authority is resolved from their
* 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.
*/
export async function promoteMember(
payload: Payload,
actor: User,
username: string,
newRankId: number,
): Promise<PromotionResult> {
const { authority, ladder, actorRank } = await resolvePromotionAuthority(payload, actor);
if (!authority || !actorRank) {
throw new Error("You do not have promotion authority.");
}
const targetUser = await findUserByUsername(payload, username);
if (!targetUser) {
throw new Error("User not found.");
}
const targetProfile = await findProfileByUserId(payload, targetUser.id);
if (!targetProfile) {
throw new Error("Profile not found.");
}
const memberRank = resolveRankFromLadder(ladder, targetProfile.rank);
if (!memberRank) {
throw new Error("The member does not have a rank on file.");
}
const toRank = resolveRankFromLadder(ladder, newRankId);
if (!toRank) {
throw new Error("Unknown rank.");
}
// Promotions only: strictly above the member's current rank.
if (compareRanks(ladder, toRank, memberRank) <= 0) {
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) {
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) {
throw new Error(`You cannot promote above ${authority.maxRank.name}.`);
}
await payload.update({
collection: "profiles",
id: targetProfile.id,
data: { rank: toRank.id },
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.PersonnelPromoted,
message: `${memberRank.abbreviation} ${targetUser.username} promoted from ${memberRank.name} to ${toRank.name} by ${actor.username}`,
actor: actor.id,
targetCollection: "users",
targetId: targetUser.id,
data: {
fromRankId: memberRank.id,
fromRankName: memberRank.name,
toRankId: toRank.id,
toRankName: toRank.name,
},
});
await notifyUser(payload, {
userId: targetUser.id,
type: "personnel:promoted",
title: "Promotion",
message: `You have been promoted from ${memberRank.name} to ${toRank.name}.`,
link: `/profile/${targetUser.username}`,
});
return { profileId: targetProfile.id, fromRank: memberRank, toRank };
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "ranks" ADD COLUMN "promotion_ceiling_id" integer;
ALTER TABLE "ranks" ADD CONSTRAINT "ranks_promotion_ceiling_id_ranks_id_fk" FOREIGN KEY ("promotion_ceiling_id") REFERENCES "public"."ranks"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "ranks_promotion_ceiling_idx" ON "ranks" USING btree ("promotion_ceiling_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "ranks" DROP CONSTRAINT "ranks_promotion_ceiling_id_ranks_id_fk";
DROP INDEX "ranks_promotion_ceiling_idx";
ALTER TABLE "ranks" DROP COLUMN "promotion_ceiling_id";`)
}

View file

@ -162,9 +162,9 @@ export function requirePermission(permission: Permission) {
* *
* @example * @example
* access: { * access: {
* update: requireAnyPermission("tickets:update", "tickets:staff"), * update: requireAnyPermission("tickets:update", "tickets:staff"),
* } * }
*/ */
export function requireAnyPermission(...permissions: Permission[]) { export function requireAnyPermission(...permissions: Permission[]) {
return async ({ req }: { req: PayloadRequest }): Promise<boolean> => { return async ({ req }: { req: PayloadRequest }): Promise<boolean> => {
return hasAnyPermission(req.payload, req.user, ...permissions); return hasAnyPermission(req.payload, req.user, ...permissions);
@ -189,7 +189,10 @@ export function requireCampaignOwnership(permission: Permission) {
if (!req.user) return false; if (!req.user) return false;
// Check permission first (grants access to all campaigns for managers) // Check permission first (grants access to all campaigns for managers)
const { permissions: userPerms, isSuperuser: su } = await loadUserPermissions(req.payload, req.user); const { permissions: userPerms, isSuperuser: su } = await loadUserPermissions(
req.payload,
req.user,
);
if (su || userPerms.has(permission)) return true; if (su || userPerms.has(permission)) return true;
// If no user permissions for manage, check ownership // If no user permissions for manage, check ownership
@ -210,7 +213,11 @@ export function requireCampaignOwnership(permission: Permission) {
// User owns the campaign if owner field matches their ID // User owns the campaign if owner field matches their ID
// campaign.owner can be number (ID) or User object // campaign.owner can be number (ID) or User object
const campaignOwnerId = campaign.owner ? (typeof campaign.owner === "object" ? campaign.owner.id : campaign.owner) : null; const campaignOwnerId = campaign.owner
? typeof campaign.owner === "object"
? campaign.owner.id
: campaign.owner
: null;
const userId = Number(req.user.id); const userId = Number(req.user.id);
if (campaignOwnerId && campaignOwnerId === userId) return true; if (campaignOwnerId && campaignOwnerId === userId) return true;
@ -219,3 +226,39 @@ export function requireCampaignOwnership(permission: Permission) {
return false; return false;
}; };
} }
/**
* Resolve the ids of every user holding a superuser role.
*
* Two cheap indexed queries (roles, then users by roleDocs membership); meant
* for server-rendered pages that want to surface superuser status on other
* players, where per-user permission checks would be too costly.
*/
export async function superuserUserIds(payload: Payload): Promise<number[]> {
try {
const superRoles = await payload.find({
collection: "roles",
where: { isSuperuser: { equals: true } },
limit: 100,
depth: 0,
overrideAccess: true,
});
const roleIds = superRoles.docs.map((role) => role.id);
if (roleIds.length === 0) return [];
const users = await payload.find({
collection: "users",
where: { roleDocs: { in: roleIds } },
limit: 500,
depth: 0,
overrideAccess: true,
});
return users.docs.map((user) => Number(user.id));
} catch (error) {
// Role or user lookup failed: show no shields rather than break the page.
payload.logger.error(
`[Access] superuserUserIds lookup failed: ${error instanceof Error ? error.message : "unknown"}`,
);
return [];
}
}

View file

@ -0,0 +1,385 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Rank, User } from "@/payload-types";
import {
allowedPromotionTargets,
promoteMember,
resolvePromotionAuthority,
} from "@/lib/promotions";
import { loadRankLadder } from "@/lib/promotions/authority";
let payload: Payload;
const RUN = `promo-${Date.now().toString(36)}`;
/**
* User deletion trips FK constraints unless the hook-provisioned personal bank
* account and profile are removed first.
*/
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("Promotions", () => {
const rankIds: number[] = [];
const userIds: number[] = [];
let pvt: Rank;
let cpl: Rank;
let sgt: Rank;
let ssgt: Rank;
let lt: Rank;
let bottomRankId: number;
let originalBottomCeiling: Rank["promotionCeiling"];
let promoter: User;
let admin: User;
let noCeiling: User;
let lowest: User;
let memberA: User;
let memberB: User;
let memberC: User;
let memberD: User;
let memberE: User;
let memberF: User;
const makeRank = async (name: string, abbreviation: string): Promise<Rank> => {
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);
return rank;
};
const makeUser = async (username: string, roles: User["roles"] = ["user"]): Promise<User> => {
const user = (await payload.create({
collection: "users",
data: {
username,
discordUsername: username,
displayName: "PROMO TEST",
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 }> };
await payload.update({
collection: "profiles",
id: profiles.docs[0].id,
data: { rank: rankId },
overrideAccess: true,
depth: 0,
});
};
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
// Self-contained 5-rank ladder: Pvt < Cpl < Sgt < SSgt < Lt.
pvt = await makeRank("Pvt", "pvt");
cpl = await makeRank("Cpl", "cpl");
sgt = await makeRank("Sgt", "sgt");
ssgt = await makeRank("SSgt", "ssgt");
lt = await makeRank("Lt", "lt");
await payload.update({
collection: "ranks",
id: ssgt.id,
data: { promotionCeiling: cpl.id },
overrideAccess: true,
depth: 0,
});
// True bottom of the ladder (seed ranks sit below our test ranks) with a
// self-ceiling, so the clamp must yield null authority.
const ladder = await loadRankLadder(payload);
const bottomRank = ladder[0];
bottomRankId = bottomRank.id;
originalBottomCeiling = bottomRank.promotionCeiling;
await payload.update({
collection: "ranks",
id: bottomRank.id,
data: { promotionCeiling: bottomRank.id },
overrideAccess: true,
depth: 0,
});
promoter = await makeUser(`${RUN}-promoter`);
admin = await makeUser(`${RUN}-admin`, ["admin"]);
noCeiling = await makeUser(`${RUN}-noceiling`);
lowest = await makeUser(`${RUN}-lowest`);
memberA = await makeUser(`${RUN}-member-a`);
memberB = await makeUser(`${RUN}-member-b`);
memberC = await makeUser(`${RUN}-member-c`);
memberD = await makeUser(`${RUN}-member-d`);
memberE = await makeUser(`${RUN}-member-e`);
memberF = await makeUser(`${RUN}-member-f`);
await setProfileRank(promoter.id, ssgt.id);
await setProfileRank(admin.id, lt.id);
await setProfileRank(noCeiling.id, sgt.id);
await setProfileRank(lowest.id, bottomRankId);
await setProfileRank(memberA.id, pvt.id);
await setProfileRank(memberB.id, pvt.id);
await setProfileRank(memberC.id, pvt.id);
await setProfileRank(memberD.id, sgt.id);
await setProfileRank(memberE.id, pvt.id);
await setProfileRank(memberF.id, pvt.id);
});
afterAll(async () => {
// Events and notifications reference users; remove them before the users.
const events = await payload
.find({
collection: "game-event-logs",
where: { type: { equals: "personnel:promoted" } },
limit: 500,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const event of events?.docs ?? []) {
await payload
.delete({ collection: "game-event-logs", id: event.id, overrideAccess: true })
.catch(() => {});
}
const notifications = await payload
.find({
collection: "user-notifications",
where: { type: { equals: "personnel:promoted" } },
limit: 500,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const notification of notifications?.docs ?? []) {
await payload
.delete({ collection: "user-notifications", id: notification.id, overrideAccess: true })
.catch(() => {});
}
for (const id of userIds) {
await deleteUserWithRelations(payload, id);
}
if (bottomRankId) {
await payload
.update({
collection: "ranks",
id: bottomRankId,
data: { promotionCeiling: originalBottomCeiling ?? null },
overrideAccess: true,
depth: 0,
})
.catch(() => {});
}
for (const id of rankIds) {
await payload
.delete({ collection: "ranks", id, overrideAccess: true })
.catch(() => {});
}
});
describe("resolvePromotionAuthority", () => {
it("resolves ceiling authority for a rank holder", async () => {
const result = await resolvePromotionAuthority(payload, promoter);
expect(result.authority).toEqual({
kind: "ceiling",
maxRank: expect.objectContaining({ id: cpl.id }),
});
});
it("resolves unbounded authority for admins", async () => {
const result = await resolvePromotionAuthority(payload, admin);
expect(result.authority).toEqual({ kind: "unbounded" });
});
it("returns null authority without a ceiling", async () => {
const result = await resolvePromotionAuthority(payload, noCeiling);
expect(result.authority).toBeNull();
});
it("returns null authority when the actor is the lowest rank", async () => {
const result = await resolvePromotionAuthority(payload, lowest);
expect(result.authority).toBeNull();
});
it("clamps a ceiling above the actor's own rank to the rank directly below", async () => {
await payload.update({
collection: "ranks",
id: ssgt.id,
data: { promotionCeiling: lt.id },
overrideAccess: true,
depth: 0,
});
const result = await resolvePromotionAuthority(payload, promoter);
expect(result.authority).toEqual({
kind: "ceiling",
maxRank: expect.objectContaining({ id: sgt.id }),
});
await payload.update({
collection: "ranks",
id: ssgt.id,
data: { promotionCeiling: cpl.id },
overrideAccess: true,
depth: 0,
});
});
});
describe("allowedPromotionTargets", () => {
it("lists only ranks within the ceiling for the member", async () => {
const result = await resolvePromotionAuthority(payload, promoter);
const targets = allowedPromotionTargets(result, pvt);
expect(targets.map((r) => r.id)).toEqual([cpl.id]);
});
it("lists all ranks below the admin's own rank", async () => {
const result = await resolvePromotionAuthority(payload, admin);
const targets = allowedPromotionTargets(result, pvt);
expect(targets.map((r) => r.id)).toEqual([cpl.id, sgt.id, ssgt.id]);
});
});
describe("promoteMember", () => {
it("promotes a member within the ceiling and records event + notification", async () => {
const result = await promoteMember(payload, promoter, memberA.username, cpl.id);
expect(result.toRank.id).toBe(cpl.id);
const profiles = (await payload.find({
collection: "profiles",
where: { user: { equals: memberA.id } },
limit: 1,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: Array<{ rank: number }> };
expect(profiles.docs[0].rank).toBe(cpl.id);
const events = (await payload.find({
collection: "game-event-logs",
where: { type: { equals: "personnel:promoted" }, targetId: { equals: memberA.id } },
limit: 10,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: Array<{ message: string }> };
expect(events.docs.length).toBeGreaterThan(0);
expect(events.docs[0].message).toBe(
`${pvt.abbreviation} ${memberA.username} promoted from ${pvt.name} to ${cpl.name} by ${promoter.username}`,
);
const notifications = (await payload.find({
collection: "user-notifications",
where: { user: { equals: memberA.id }, type: { equals: "personnel:promoted" } },
limit: 10,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: Array<{ link: string }> };
expect(notifications.docs.length).toBeGreaterThan(0);
expect(notifications.docs[0].link).toBe(`/profile/${memberA.username}`);
});
it("rejects a promotion above the ceiling", async () => {
await expect(promoteMember(payload, promoter, memberB.username, sgt.id)).rejects.toThrow(
`You cannot promote above ${cpl.name}.`,
);
});
it("rejects promoting to the actor's own rank or above", async () => {
await expect(promoteMember(payload, promoter, memberC.username, ssgt.id)).rejects.toThrow(
"You cannot promote to a rank at or above your own.",
);
});
it("rejects demotions and same-rank promotions", async () => {
await expect(promoteMember(payload, promoter, memberD.username, cpl.id)).rejects.toThrow(
"Promotions only: the new rank must be above the member's current rank.",
);
await expect(promoteMember(payload, promoter, memberD.username, sgt.id)).rejects.toThrow(
"Promotions only: the new rank must be above the member's current rank.",
);
});
it("allows admins to promote below their own rank", async () => {
const result = await promoteMember(payload, admin, memberE.username, sgt.id);
expect(result.toRank.id).toBe(sgt.id);
});
it("still prevents admins from promoting to their own rank", async () => {
await expect(promoteMember(payload, admin, memberF.username, lt.id)).rejects.toThrow(
"You cannot promote to a rank at or above your own.",
);
});
it("denies users without a ceiling", async () => {
await expect(promoteMember(payload, noCeiling, memberA.username, cpl.id)).rejects.toThrow(
"You do not have promotion authority.",
);
});
it("denies the lowest rank even with a ceiling", async () => {
await expect(promoteMember(payload, lowest, memberA.username, cpl.id)).rejects.toThrow(
"You do not have promotion authority.",
);
});
it("rejects unknown users and ranks", async () => {
await expect(promoteMember(payload, promoter, `${RUN}-nobody`, cpl.id)).rejects.toThrow(
"User not found.",
);
await expect(promoteMember(payload, promoter, memberA.username, 999999)).rejects.toThrow(
"Unknown rank.",
);
});
});
});