- 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.
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
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];
|
|
}
|