1
0
Fork 0
polaris-task-force/src/lib/transfers/leadership.ts
Z8MB1E 62573f74e6 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.
2026-10-03 03:29:24 -04:00

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