import type { Payload, PayloadRequest, Where } from "payload"; import type { AssignmentTransfer, User } from "@/payload-types"; import hasRoles from "@/utils/access-control/hasRoles"; import { emitGameEvent } from "@/utils/event-log/emit"; import { EventTypes } from "@/utils/event-log/eventTypes"; import { notifyUser } from "@/lib/notifications"; import { TRANSFER_NOTIFICATION_TYPES as NT } from "./notificationTypes"; export type TransferSide = "from" | "to"; // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- function userLabel(user: Pick): string { return user.displayName || user.username; } /** 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; } async function fetchTransfer(payload: Payload, requestId: number): Promise { const doc = await payload.findByID({ collection: "assignment-transfers", id: requestId, depth: 0, }); if (!doc) throw new Error("Transfer request not found."); return doc; } async function fetchAssignment( payload: Payload, id: number, req?: Partial, ): Promise<{ id: number; name: string; leader: number | null; members: number[] }> { const doc = await payload.findByID({ collection: "assignments", id, depth: 0, ...(req ? { req } : {}), }); if (!doc) throw new Error("Assignment not found."); return { id: doc.id, name: doc.name, leader: (doc.leader as number | null) ?? null, members: (doc.members as number[]) ?? [], }; } function isSuperuser(user: User | null): boolean { return hasRoles(["admin", "developer"], user); } async function superuserIds(payload: Payload): Promise { const res = await payload.find({ collection: "users", limit: 200, depth: 0, overrideAccess: true, }); return res.docs.filter((u) => hasRoles(["admin", "developer"], u)).map((u) => u.id); } async function notifySuperusers( payload: Payload, type: string, title: string, message?: string, ): Promise { for (const id of await superuserIds(payload)) { await notifyUser(payload, { userId: id, type, title, message, link: "/transfers", bypassMute: true, }); } } /** * Compare-and-set update on a transfer request. Returns null when the where * clause matches nothing (the state changed underneath us); callers must * re-read and throw a descriptive error in that case. */ async function casUpdate( payload: Payload, where: Where, data: Partial, req?: Partial, ): Promise { const res = await payload.update({ collection: "assignment-transfers", where, data, overrideAccess: true, depth: 0, ...(req ? { req } : {}), }); if (res.docs.length === 0) return null; return res.docs[0]; } function isRequired(doc: AssignmentTransfer, side: TransferSide): boolean { const decision = side === "from" ? doc.fromLeaderDecision : doc.toLeaderDecision; return decision !== "not_required"; } function decisionWhere(doc: AssignmentTransfer, side: TransferSide): Where { const and: Where[] = [ { id: { equals: doc.id } }, { status: { equals: "pending" } }, ]; if (side === "from") { and.push({ fromLeaderDecision: { equals: "pending" } }); } else { and.push({ toLeaderDecision: { equals: "pending" } }); } return { and }; } function decisionData( side: TransferSide, decision: "approved" | "rejected", actorUserId: number, reason?: string, ): Partial { const at = new Date().toISOString(); if (side === "from") { return { fromLeaderDecision: decision, fromDecisionBy: actorUserId, fromDecidedAt: at, ...(decision === "rejected" ? { status: "rejected" as const } : {}), ...(decision === "rejected" && reason ? { rejectionReason: reason } : {}), }; } return { toLeaderDecision: decision, toDecisionBy: actorUserId, toDecidedAt: at, ...(decision === "rejected" ? { status: "rejected" as const } : {}), ...(decision === "rejected" && reason ? { rejectionReason: reason } : {}), }; } type ExecutionOutcome = | { executed: true; doc: AssignmentTransfer } | { executed: false; reason: "leader_changed"; doc: AssignmentTransfer }; /** * Atomically move the requester between assignments and mark the request * completed, all in one DB transaction. Re-validates state inside the tx; if a * recorded approver is no longer the current leader of their side, rolls back * and escalates to awaiting_superuser (leader_changed) instead of executing. */ async function executeTransfer( payload: Payload, transferId: number, opts: { viaSuperuser: boolean; resolvedBy?: number; resolutionNote?: string }, ): Promise { const txId = await payload.db.beginTransaction(); if (txId == null) throw new Error("Failed to begin database transaction."); const req: Partial = { transactionID: txId }; let rolledBack = false; try { const fresh = await payload.findByID({ collection: "assignment-transfers", id: transferId, depth: 0, req, }); if (!fresh) throw new Error("Transfer request not found."); const expectedStatus = opts.viaSuperuser ? "awaiting_superuser" : "pending"; if (fresh.status !== expectedStatus) { throw new Error(`Cannot execute a transfer request in status "${fresh.status}".`); } let fromAssignment: Awaited>; let toAssignment: Awaited>; if (!opts.viaSuperuser) { const fromOk = fresh.fromLeaderDecision === "not_required" || fresh.fromLeaderDecision === "approved"; const toOk = fresh.toLeaderDecision === "not_required" || fresh.toLeaderDecision === "approved"; if (!fromOk || !toOk) throw new Error("Not all required leader approvals are in place."); [fromAssignment, toAssignment] = await Promise.all([ fetchAssignment(payload, relId(fresh.fromAssignment), req), fetchAssignment(payload, relId(fresh.toAssignment), req), ]); const staleFrom = fresh.fromLeaderDecision === "approved" && fresh.fromDecisionBy != null && fromAssignment.leader !== fresh.fromDecisionBy; const staleTo = fresh.toLeaderDecision === "approved" && fresh.toDecisionBy != null && toAssignment.leader !== fresh.toDecisionBy; if (staleFrom || staleTo) { await payload.db.rollbackTransaction(txId); rolledBack = true; const escalated = await casUpdate( payload, { and: [{ id: { equals: fresh.id } }, { status: { equals: "pending" } }] }, { status: "awaiting_superuser", escalationReason: "leader_changed" }, ); if (escalated) { await notifySuperusers( payload, NT.escalated, "Transfer request needs a final call", `Leadership changed after approvals were recorded on transfer request #${fresh.id}; a superuser must decide.`, ); } return { executed: false, reason: "leader_changed", doc: escalated ?? fresh }; } } else { [fromAssignment, toAssignment] = await Promise.all([ fetchAssignment(payload, relId(fresh.fromAssignment), req), fetchAssignment(payload, relId(fresh.toAssignment), req), ]); } if (!fromAssignment.members.includes(relId(fresh.requester))) { throw new Error("The requester is no longer a member of the source assignment."); } await payload.update({ collection: "assignments", id: fromAssignment.id, data: { members: fromAssignment.members.filter((id) => id !== fresh.requester) }, overrideAccess: true, req, }); if (!toAssignment.members.includes(relId(fresh.requester))) { await payload.update({ collection: "assignments", id: toAssignment.id, data: { members: [...toAssignment.members, fresh.requester] }, overrideAccess: true, req, }); } const completionData: Partial = { status: "completed" }; if (opts.viaSuperuser) { completionData.resolvedBy = opts.resolvedBy; completionData.resolvedAt = new Date().toISOString(); if (opts.resolutionNote) completionData.resolutionNote = opts.resolutionNote; } const completed = await casUpdate( payload, { and: [{ id: { equals: fresh.id } }, { status: { equals: expectedStatus } }] }, completionData, req, ); if (!completed) { throw new Error("The transfer request changed state while the transfer was executing."); } await payload.db.commitTransaction(txId); return { executed: true, doc: completed }; } catch (err) { if (!rolledBack) await payload.db.rollbackTransaction(txId); throw err; } } async function notifyCompletion( payload: Payload, doc: AssignmentTransfer, message: string, ): Promise { const [fromAssignment, toAssignment] = await Promise.all([ fetchAssignment(payload, relId(doc.fromAssignment)), fetchAssignment(payload, relId(doc.toAssignment)), ]); await notifyUser(payload, { userId: relId(doc.requester), type: NT.completed, title: "Transfer completed", message, link: "/transfers", bypassMute: true, }); for (const assignment of [fromAssignment, toAssignment]) { if (assignment.leader != null) { await notifyUser(payload, { userId: assignment.leader, type: NT.completed, title: "Transfer completed", message, link: "/transfers", bypassMute: true, }); } } } // --------------------------------------------------------------------------- // Public API — single source of truth for the web actions and the bot // --------------------------------------------------------------------------- /** * Create a transfer request. Guards: requester is a member of the source; * to ≠ from; requester is not leader of either side; no already-open request * for this destination. Leaderless sides are stamped `not_required`; if both * sides are leaderless the request goes straight to awaiting_superuser. */ export async function createTransferRequest( payload: Payload, opts: { requesterId: number; fromAssignmentId: number; toAssignmentId: number; note?: string }, ): Promise { const { requesterId, fromAssignmentId, toAssignmentId, note } = opts; if (fromAssignmentId === toAssignmentId) { throw new Error("The source and destination assignments must be different."); } const requester = await payload.findByID({ collection: "users", id: requesterId, depth: 0 }); if (!requester) throw new Error("Requester not found."); const fromAssignment = await fetchAssignment(payload, fromAssignmentId); const toAssignment = await fetchAssignment(payload, toAssignmentId); if (!fromAssignment.members.includes(requesterId)) { throw new Error("You are not a member of the source assignment."); } if (fromAssignment.leader === requesterId || toAssignment.leader === requesterId) { throw new Error("Leaders cannot request transfers; leadership changes are handled separately."); } const open = await payload.find({ collection: "assignment-transfers", where: { and: [ { requester: { equals: requesterId } }, { toAssignment: { equals: toAssignmentId } }, { status: { in: ["pending", "rejected", "awaiting_superuser"] } }, ], }, limit: 1, overrideAccess: true, }); if (open.docs.length > 0) { throw new Error("You already have an open transfer request to this assignment."); } const fromLeader = fromAssignment.leader; const toLeader = toAssignment.leader; const bothLeaderless = fromLeader == null && toLeader == null; const label = userLabel(requester); const created = await payload.create({ collection: "assignment-transfers", overrideAccess: true, data: { requester: requesterId, fromAssignment: fromAssignmentId, toAssignment: toAssignmentId, status: bothLeaderless ? "awaiting_superuser" : "pending", ...(bothLeaderless ? { escalationReason: "no_leaders" as const } : {}), fromLeaderDecision: fromLeader != null ? ("pending" as const) : ("not_required" as const), toLeaderDecision: toLeader != null ? ("pending" as const) : ("not_required" as const), fromLeaderSnapshot: fromLeader, toLeaderSnapshot: toLeader, ...(note?.trim() ? { requestNote: note.trim() } : {}), }, }); if (bothLeaderless) { await notifySuperusers( payload, NT.escalated, "Transfer request needs a final call", `${label} requested to move from ${fromAssignment.name} to ${toAssignment.name}, but neither assignment has a leader.`, ); } else { const message = `${label} requested to move from ${fromAssignment.name} to ${toAssignment.name}.`; if (fromLeader != null) { await notifyUser(payload, { userId: fromLeader, type: NT.request, title: "New transfer request", message, link: "/transfers", bypassMute: true, }); } if (toLeader != null) { await notifyUser(payload, { userId: toLeader, type: NT.request, title: "New transfer request", message, link: "/transfers", bypassMute: true, }); } } await emitGameEvent(payload, { type: EventTypes.AssignmentTransferRequest, message: `${label} requested transfer from ${fromAssignment.name} to ${toAssignment.name}.`, actor: requesterId, targetCollection: "assignment-transfers", targetId: created.id, }); return created; } /** * Record a leader's decision on a pending request. The actor must be the * CURRENT leader of that side (re-fetched at call time). If the side has no * current leader the request escalates to awaiting_superuser (leader_changed) * instead of deadlocking. A rejection requires a reason and moves the request * to rejected; an approval that satisfies all required sides executes the * transfer atomically. */ export async function recordTransferDecision( payload: Payload, opts: { requestId: number; side: TransferSide; decision: "approved" | "rejected"; reason?: string; actorUserId: number; }, ): Promise { const { requestId, side, decision, reason, actorUserId } = opts; const doc = await fetchTransfer(payload, requestId); if (doc.status !== "pending") { throw new Error(`Decisions can only be recorded while a request is pending (current status: ${doc.status}).`); } if (!isRequired(doc, side)) { throw new Error("This assignment has no leader, so no decision is required from it."); } const trimmedReason = reason?.trim(); if (decision === "rejected" && !trimmedReason) { throw new Error("A reason is required when rejecting a transfer request."); } const fromAssignment = await fetchAssignment(payload, relId(doc.fromAssignment)); const toAssignment = await fetchAssignment(payload, relId(doc.toAssignment)); const assignment = side === "from" ? fromAssignment : toAssignment; const otherAssignment = side === "from" ? toAssignment : fromAssignment; const sideName = side === "from" ? "source" : "destination"; const currentLeader = assignment.leader; if (currentLeader == null) { const escalated = await casUpdate( payload, { and: [{ id: { equals: doc.id } }, { status: { equals: "pending" } }] }, { status: "awaiting_superuser", escalationReason: "leader_changed" }, ); if (!escalated) { const reread = await fetchTransfer(payload, requestId); throw new Error( `The request changed state before it could be escalated (current status: ${reread.status}).`, ); } await notifySuperusers( payload, NT.escalated, "Transfer request needs a final call", `The ${sideName} assignment of transfer request #${doc.id} no longer has a leader; a superuser must decide.`, ); return escalated; } if (currentLeader !== actorUserId) { throw new Error(`Only the current leader of ${assignment.name} can record a decision on this request.`); } const updated = await casUpdate(payload, decisionWhere(doc, side), decisionData(side, decision, actorUserId, trimmedReason)); if (!updated) { const reread = await fetchTransfer(payload, requestId); throw new Error( `The request changed state while the decision was being recorded (current status: ${reread.status}).`, ); } const requester = await payload.findByID({ collection: "users", id: relId(updated.requester), depth: 0 }); const label = requester ? userLabel(requester) : `user #${updated.requester}`; if (decision === "rejected") { await emitGameEvent(payload, { type: EventTypes.AssignmentTransferReject, message: `${label}'s transfer request was rejected by the ${sideName} assignment leader.`, actor: actorUserId, targetCollection: "assignment-transfers", targetId: updated.id, }); if (isRequired(updated, side === "from" ? "to" : "from") && otherAssignment.leader != null) { await notifyUser(payload, { userId: otherAssignment.leader, type: NT.rejected, title: "Transfer request rejected", message: `${label}'s transfer request was rejected by the ${sideName} assignment leader: "${trimmedReason}"`, link: "/transfers", bypassMute: true, }); } await notifyUser(payload, { userId: relId(updated.requester), type: NT.rejected, title: "Transfer request rejected", message: `Your transfer request was rejected by the ${sideName} assignment leader: "${trimmedReason}". You may appeal this decision.`, link: "/transfers", bypassMute: true, }); return updated; } await emitGameEvent(payload, { type: EventTypes.AssignmentTransferApprove, message: `The ${sideName} assignment leader approved ${label}'s transfer request.`, actor: actorUserId, targetCollection: "assignment-transfers", targetId: updated.id, }); const otherSide = side === "from" ? "to" : "from"; const allApproved = !isRequired(updated, otherSide) || (otherSide === "from" ? updated.fromLeaderDecision : updated.toLeaderDecision) === "approved"; if (!allApproved) { await notifyUser(payload, { userId: relId(updated.requester), type: NT.approved, title: "Transfer request approved", message: `The ${sideName} assignment approved your transfer request. Awaiting the other assignment's decision.`, link: "/transfers", bypassMute: true, }); return updated; } const outcome = await executeTransfer(payload, updated.id, { viaSuperuser: false }); if (!outcome.executed) { return outcome.doc; } await notifyCompletion( payload, outcome.doc, `Your transfer from ${fromAssignment.name} to ${toAssignment.name} is complete.`, ); await emitGameEvent(payload, { type: EventTypes.AssignmentTransferComplete, message: `${label} transferred from ${fromAssignment.name} to ${toAssignment.name}.`, actor: actorUserId, targetCollection: "assignment-transfers", targetId: outcome.doc.id, }); return outcome.doc; } /** * Appeal a rejected request. Only the requester, only from status rejected — * which structurally makes it exactly once (appealing leaves the rejected * state). Escalates to awaiting_superuser with escalationReason "appeal". */ export async function appealTransfer( payload: Payload, opts: { requestId: number; actorUserId: number; note?: string }, ): Promise { const { requestId, actorUserId, note } = opts; const doc = await fetchTransfer(payload, requestId); if (doc.status !== "rejected") { throw new Error(`Only rejected requests can be appealed (current status: ${doc.status}).`); } if (doc.requester !== actorUserId) { throw new Error("Only the requester can appeal a transfer request."); } const appealData: Partial = { status: "awaiting_superuser", escalationReason: "appeal", }; if (note?.trim()) appealData.appealNote = note.trim(); const updated = await casUpdate( payload, { and: [{ id: { equals: doc.id } }, { status: { equals: "rejected" } }] }, appealData, ); if (!updated) { const reread = await fetchTransfer(payload, requestId); throw new Error( `The request changed state while the appeal was being recorded (current status: ${reread.status}).`, ); } const requester = await payload.findByID({ collection: "users", id: relId(updated.requester), depth: 0 }); const label = requester ? userLabel(requester) : `user #${updated.requester}`; await notifySuperusers( payload, NT.escalated, "Transfer request appealed", `${label} appealed the rejection of transfer request #${updated.id}; a superuser must make the final call.`, ); await emitGameEvent(payload, { type: EventTypes.AssignmentTransferAppeal, message: `${label} appealed the rejection of transfer request #${updated.id}.`, actor: actorUserId, targetCollection: "assignment-transfers", targetId: updated.id, }); return updated; } /** * Final superuser call on an escalated request (any escalationReason). * Approve executes the transfer atomically with resolvedBy/resolvedAt set; * deny is terminal. Only admin/developer may resolve. */ export async function resolveAppeal( payload: Payload, opts: { requestId: number; approve: boolean; actorUserId: number; note?: string }, ): Promise { const { requestId, approve, actorUserId, note } = opts; const doc = await fetchTransfer(payload, requestId); if (doc.status !== "awaiting_superuser") { throw new Error(`Only escalated requests can be resolved (current status: ${doc.status}).`); } const actor = await payload.findByID({ collection: "users", id: actorUserId, depth: 0 }); if (!actor || !isSuperuser(actor)) { throw new Error("Only an admin or developer can resolve a transfer request."); } if (approve) { const outcome = await executeTransfer(payload, doc.id, { viaSuperuser: true, resolvedBy: actorUserId, resolutionNote: note?.trim() || undefined, }); if (!outcome.executed) { return outcome.doc; } const updated = outcome.doc; await notifyCompletion(payload, updated, "A superuser approved your transfer request; it is now complete."); await emitGameEvent(payload, { type: EventTypes.AssignmentTransferResolve, message: `A superuser approved escalated transfer request #${updated.id}.`, actor: actorUserId, targetCollection: "assignment-transfers", targetId: updated.id, }); await emitGameEvent(payload, { type: EventTypes.AssignmentTransferComplete, message: `Escalated transfer request #${updated.id} completed.`, actor: actorUserId, targetCollection: "assignment-transfers", targetId: updated.id, }); return updated; } const denyData: Partial = { status: "denied", resolvedBy: actorUserId, resolvedAt: new Date().toISOString(), }; if (note?.trim()) denyData.resolutionNote = note.trim(); const updated = await casUpdate( payload, { and: [{ id: { equals: doc.id } }, { status: { equals: "awaiting_superuser" } }] }, denyData, ); if (!updated) { const reread = await fetchTransfer(payload, requestId); throw new Error( `The request changed state while it was being resolved (current status: ${reread.status}).`, ); } const requester = await payload.findByID({ collection: "users", id: relId(updated.requester), depth: 0 }); const label = requester ? userLabel(requester) : `user #${updated.requester}`; await notifyUser(payload, { userId: relId(updated.requester), type: NT.resolved, title: "Transfer request denied", message: `A superuser denied your transfer request.${note?.trim() ? ` Note: "${note.trim()}"` : ""}`, link: "/transfers", bypassMute: true, }); await emitGameEvent(payload, { type: EventTypes.AssignmentTransferResolve, message: `A superuser denied escalated transfer request #${updated.id} (${label}).`, actor: actorUserId, targetCollection: "assignment-transfers", targetId: updated.id, }); return updated; } /** * Cancel a pending request. Only the requester, only while pending. No * membership changes occur. */ export async function cancelTransfer( payload: Payload, opts: { requestId: number; actorUserId: number }, ): Promise { const { requestId, actorUserId } = opts; const doc = await fetchTransfer(payload, requestId); if (doc.status !== "pending") { throw new Error(`Only pending requests can be cancelled (current status: ${doc.status}).`); } if (doc.requester !== actorUserId) { throw new Error("Only the requester can cancel a transfer request."); } const updated = await casUpdate( payload, { and: [{ id: { equals: doc.id } }, { status: { equals: "pending" } }] }, { status: "cancelled" }, ); if (!updated) { const reread = await fetchTransfer(payload, requestId); throw new Error( `The request changed state while it was being cancelled (current status: ${reread.status}).`, ); } const [fromAssignment, toAssignment] = await Promise.all([ fetchAssignment(payload, relId(updated.fromAssignment)), fetchAssignment(payload, relId(updated.toAssignment)), ]); for (const assignment of [fromAssignment, toAssignment]) { if (assignment.leader != null) { await notifyUser(payload, { userId: assignment.leader, type: NT.cancelled, title: "Transfer request cancelled", message: `The requester cancelled their transfer request involving ${assignment.name}.`, link: "/transfers", bypassMute: true, }); } } await emitGameEvent(payload, { type: EventTypes.AssignmentTransferCancel, message: `Transfer request #${updated.id} was cancelled by the requester.`, actor: actorUserId, targetCollection: "assignment-transfers", targetId: updated.id, }); return updated; }