1
0
Fork 0
polaris-task-force/src/lib/transfers/index.ts
Z8MB1E 76e948fd59 feat: Add remaining transfer components and utilities
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.
2026-08-31 23:41:29 -04:00

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