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 | null | undefined, fallback: string, ): string { if (!user) return fallback; return user.displayName || user.username || fallback; } async function fetchLeadershipTransfer( payload: Payload, transferId: number, req?: Partial, ): Promise { 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 { 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 { 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 { 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 = { 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 { 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]; }