Add transfer workflow components and utilities: 1. Transfer types and core functions (src/lib/transfers/index.ts): - Type-safe transfer request and decision creation - Appeal, cancel, and resolve core functions - Server action conventions with error handling 2. Transfer UI components (src/components/frontend/transfers/): - TransferActionDialog.tsx: Handle approval/rejection appeals - TransferApprovalCard.tsx: Display approval decisions - TransferNoteDialog.tsx: Add notes for transfers/appeals - TransferRequestCard.tsx: Show transfer request details - TransferResolutionCard.tsx: Display superuser resolutions - TransfersView.tsx: Main transfers list page - transferTypes.ts: Shared TypeScript types 3. Notification types (src/lib/transfers/notificationTypes.ts): - Define notification type constants for transfer events - Include TRANSFER_NOTIFICATION_TYPES for bridge filtering 4. Integration tests (tests/int/): - transfer-requests.int.spec.ts: Test bot request polling - transfer-bot-delivery.int.spec.ts: Test Discord notification delivery These components and utilities complete the transfer workflow implementation including approvals, appeals, and delivery.
769 lines
26 KiB
TypeScript
769 lines
26 KiB
TypeScript
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<User, "displayName" | "username">): 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<AssignmentTransfer> {
|
|
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<PayloadRequest>,
|
|
): 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<number[]> {
|
|
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<void> {
|
|
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<AssignmentTransfer>,
|
|
req?: Partial<PayloadRequest>,
|
|
): Promise<AssignmentTransfer | null> {
|
|
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<AssignmentTransfer> {
|
|
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<ExecutionOutcome> {
|
|
const txId = await payload.db.beginTransaction();
|
|
if (txId == null) throw new Error("Failed to begin database transaction.");
|
|
const req: Partial<PayloadRequest> = { 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<ReturnType<typeof fetchAssignment>>;
|
|
let toAssignment: Awaited<ReturnType<typeof fetchAssignment>>;
|
|
|
|
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<AssignmentTransfer> = { 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<void> {
|
|
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<AssignmentTransfer> {
|
|
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<AssignmentTransfer> {
|
|
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<AssignmentTransfer> {
|
|
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<AssignmentTransfer> = {
|
|
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<AssignmentTransfer> {
|
|
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<AssignmentTransfer> = {
|
|
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<AssignmentTransfer> {
|
|
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;
|
|
}
|