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:
parent
8c3dbdbc98
commit
8d92e7aaa9
8 changed files with 27597 additions and 5 deletions
|
|
@ -36,5 +36,14 @@ export const Ranks: CollectionConfig = {
|
|||
type: "upload",
|
||||
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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
130
src/components/frontend/profile/PromoteButton.tsx
Normal file
130
src/components/frontend/profile/PromoteButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
src/lib/promotions/authority.ts
Normal file
134
src/lib/promotions/authority.ts
Normal 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
128
src/lib/promotions/index.ts
Normal 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 };
|
||||
}
|
||||
26747
src/migrations/20260912_051752_add_rank_promotion_ceiling.json
Normal file
26747
src/migrations/20260912_051752_add_rank_promotion_ceiling.json
Normal file
File diff suppressed because it is too large
Load diff
16
src/migrations/20260912_051752_add_rank_promotion_ceiling.ts
Normal file
16
src/migrations/20260912_051752_add_rank_promotion_ceiling.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -162,9 +162,9 @@ export function requirePermission(permission: Permission) {
|
|||
*
|
||||
* @example
|
||||
* access: {
|
||||
* update: requireAnyPermission("tickets:update", "tickets:staff"),
|
||||
* }
|
||||
*/
|
||||
* update: requireAnyPermission("tickets:update", "tickets:staff"),
|
||||
* }
|
||||
*/
|
||||
export function requireAnyPermission(...permissions: Permission[]) {
|
||||
return async ({ req }: { req: PayloadRequest }): Promise<boolean> => {
|
||||
return hasAnyPermission(req.payload, req.user, ...permissions);
|
||||
|
|
@ -189,7 +189,10 @@ export function requireCampaignOwnership(permission: Permission) {
|
|||
if (!req.user) return false;
|
||||
|
||||
// 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 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
|
||||
// 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);
|
||||
|
||||
if (campaignOwnerId && campaignOwnerId === userId) return true;
|
||||
|
|
@ -219,3 +226,39 @@ export function requireCampaignOwnership(permission: Permission) {
|
|||
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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
385
tests/int/promotions.int.spec.ts
Normal file
385
tests/int/promotions.int.spec.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue