1
0
Fork 0

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.
This commit is contained in:
Jason Fraley 2026-08-31 23:41:29 -04:00
parent b9326cfc54
commit 76e948fd59
12 changed files with 2898 additions and 0 deletions

View file

@ -0,0 +1,138 @@
"use client";
import { useEffect, useId, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
export interface TransferActionResult {
readonly success: boolean;
readonly error?: string;
}
interface TransferActionDialogProps {
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
readonly title: string;
readonly description: string;
readonly label: string;
readonly placeholder: string;
readonly submitLabel: string;
readonly pendingLabel: string;
readonly successMessage: string;
readonly required?: boolean;
readonly onSubmit: (note: string) => Promise<TransferActionResult>;
readonly onSuccess?: () => Promise<void>;
}
export function TransferActionDialog({
open,
onOpenChange,
title,
description,
label,
placeholder,
submitLabel,
pendingLabel,
successMessage,
required = false,
onSubmit,
onSuccess,
}: TransferActionDialogProps) {
const noteId = useId();
const [note, setNote] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) {
setNote("");
setError(null);
setPending(false);
}
}, [open]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmedNote = note.trim();
if (required && !trimmedNote) {
setError("Add a reason before continuing.");
return;
}
setPending(true);
setError(null);
try {
const result = await onSubmit(trimmedNote);
if (!result.success) {
setError(result.error ?? "Could not complete this transfer action.");
return;
}
toast.success(successMessage);
await onSuccess?.();
onOpenChange(false);
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
: "Could not complete this transfer action.",
);
} finally {
setPending(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-1.5">
<Label htmlFor={noteId}>{label}</Label>
<Textarea
id={noteId}
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder={placeholder}
rows={4}
maxLength={1000}
disabled={pending}
aria-required={required}
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={pending}
>
Cancel
</Button>
<Button type="submit" variant={required ? "destructive" : "default"} disabled={pending}>
{pending ? pendingLabel : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { CheckIcon, XIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { approveTransfer, rejectTransfer } from "@/app/(frontend)/transfers/actions";
import { TransferNoteDialog } from "@/components/frontend/transfers/TransferNoteDialog";
import {
TRANSFER_DECISION_LABELS,
TRANSFER_STATUS_LABELS,
TRANSFER_STATUS_VARIANTS,
assignmentLabel,
formatTransferDate,
personLabel,
type TransferRecord,
type TransferSide,
} from "@/components/frontend/transfers/transferTypes";
interface TransferApprovalCardProps {
readonly transfer: TransferRecord;
readonly side: TransferSide;
readonly onUpdated: () => Promise<void>;
}
export function TransferApprovalCard({ transfer, side, onUpdated }: TransferApprovalCardProps) {
const [rejectOpen, setRejectOpen] = useState(false);
const [busy, setBusy] = useState<"approve" | null>(null);
const [error, setError] = useState<string | null>(null);
const sideLabel = side === "from" ? "source" : "destination";
const otherSide = side === "from" ? "to" : "from";
const otherDecision =
otherSide === "from" ? transfer.fromLeaderDecision : transfer.toLeaderDecision;
async function handleApprove() {
setBusy("approve");
setError(null);
try {
const result = await approveTransfer(transfer.id, side);
if (!result.success) {
setError(result.error ?? "Could not approve the transfer request.");
return;
}
toast.success("Transfer request approved");
await onUpdated();
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
: "Could not approve the transfer request.",
);
} finally {
setBusy(null);
}
}
async function handleReject(reason: string) {
const result = await rejectTransfer(transfer.id, side, reason);
if (result.success) void onUpdated();
return result;
}
return (
<Card>
<CardHeader className="flex-row items-start">
<div className="flex min-w-0 flex-col gap-1">
<CardTitle className="text-base">Transfer #{transfer.id}</CardTitle>
<CardDescription>
{personLabel(transfer.requester)} · requested {formatTransferDate(transfer.createdAt)}
</CardDescription>
</div>
<Badge variant={TRANSFER_STATUS_VARIANTS[transfer.status]}>
{TRANSFER_STATUS_LABELS[transfer.status]}
</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
From
</span>
<span className="truncate text-sm">{assignmentLabel(transfer.fromAssignment)}</span>
</div>
<div className="flex min-w-0 flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
To
</span>
<span className="truncate text-sm">{assignmentLabel(transfer.toAssignment)}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>Your {sideLabel} approval is required.</span>
<span aria-hidden="true">·</span>
<span>
Other side: {TRANSFER_DECISION_LABELS[otherDecision]}
</span>
</div>
{transfer.requestNote && (
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Request note
</span>
<p className="text-sm text-muted-foreground">{transfer.requestNote}</p>
</div>
)}
{error && (
<Alert variant="destructive">
<AlertTitle>Action failed</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => void handleApprove()} disabled={busy !== null}>
{busy === "approve" ? "Approving..." : <CheckIcon data-icon="inline-start" />}
{busy === "approve" ? null : "Approve"}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => setRejectOpen(true)}
disabled={busy !== null}
>
<XIcon data-icon="inline-start" />
Reject
</Button>
</CardFooter>
<TransferNoteDialog
open={rejectOpen}
onOpenChange={setRejectOpen}
title="Reject transfer request"
description={`Give ${personLabel(transfer.requester)} a reason for rejecting this transfer.`}
fieldId={`transfer-rejection-${transfer.id}-${side}`}
fieldLabel="Rejection reason"
placeholder="Explain the decision for the requester."
submitLabel="Reject request"
successMessage="Transfer request rejected"
required
submitVariant="destructive"
onSubmit={handleReject}
/>
</Card>
);
}

View file

@ -0,0 +1,152 @@
"use client";
import { useState, type FormEvent } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2Icon } from "lucide-react";
export type TransferActionResult = {
readonly success: boolean;
readonly error?: string;
};
interface TransferNoteDialogProps {
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
readonly title: string;
readonly description: string;
readonly fieldId: string;
readonly fieldLabel: string;
readonly placeholder: string;
readonly submitLabel: string;
readonly successMessage: string;
readonly required: boolean;
readonly submitVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
readonly onSubmit: (note: string) => Promise<TransferActionResult>;
}
export function TransferNoteDialog({
open,
onOpenChange,
title,
description,
fieldId,
fieldLabel,
placeholder,
submitLabel,
successMessage,
required,
submitVariant = "default",
onSubmit,
}: TransferNoteDialogProps) {
const [note, setNote] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
function handleOpenChange(nextOpen: boolean) {
onOpenChange(nextOpen);
if (!nextOpen) {
setNote("");
setError(null);
setSuccess(false);
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmedNote = note.trim();
if (required && !trimmedNote) {
setError("A reason is required.");
return;
}
setPending(true);
setError(null);
try {
const result = await onSubmit(trimmedNote);
if (result.success) {
setSuccess(true);
toast.success(successMessage);
} else {
setError(result.error ?? "Could not complete this transfer action.");
}
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
: "Could not complete this transfer action.",
);
} finally {
setPending(false);
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{success ? (
<div className="flex flex-col gap-4">
<p aria-live="polite" className="text-sm text-emerald-500">
{successMessage}
</p>
<DialogFooter>
<Button type="button" onClick={() => handleOpenChange(false)}>
Done
</Button>
</DialogFooter>
</div>
) : (
<form className="flex flex-col gap-4" onSubmit={(event) => void handleSubmit(event)}>
<div className="flex flex-col gap-1.5">
<Label htmlFor={fieldId}>
{fieldLabel}
{required ? " *" : " (optional)"}
</Label>
<Textarea
id={fieldId}
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder={placeholder}
rows={4}
required={required}
disabled={pending}
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button type="submit" variant={submitVariant} disabled={pending}>
{pending && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
{pending ? "Submitting..." : submitLabel}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,180 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { appealTransfer, cancelTransfer } from "@/app/(frontend)/transfers/actions";
import { TransferNoteDialog } from "@/components/frontend/transfers/TransferNoteDialog";
import {
TRANSFER_ESCALATION_LABELS,
TRANSFER_STATUS_LABELS,
TRANSFER_STATUS_VARIANTS,
assignmentLabel,
formatTransferDate,
personLabel,
relationId,
type TransferRecord,
} from "@/components/frontend/transfers/transferTypes";
interface TransferRequestCardProps {
readonly transfer: TransferRecord;
readonly currentUserId: number;
readonly onUpdated: () => Promise<void>;
}
export function TransferRequestCard({
transfer,
currentUserId,
onUpdated,
}: TransferRequestCardProps) {
const [appealOpen, setAppealOpen] = useState(false);
const [cancelBusy, setCancelBusy] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
const [error, setError] = useState<string | null>(null);
const isRequester = relationId(transfer.requester) === currentUserId;
const canCancel = isRequester && transfer.status === "pending";
const canAppeal = isRequester && transfer.status === "rejected";
async function handleCancel() {
if (!confirmCancel) {
setConfirmCancel(true);
return;
}
setCancelBusy(true);
setError(null);
try {
const result = await cancelTransfer(transfer.id);
if (!result.success) {
setError(result.error ?? "Could not cancel the transfer request.");
return;
}
setConfirmCancel(false);
toast.success("Transfer request cancelled");
await onUpdated();
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
: "Could not cancel the transfer request.",
);
} finally {
setCancelBusy(false);
}
}
async function handleAppeal(note: string) {
const result = await appealTransfer(transfer.id, note || undefined);
if (result.success) void onUpdated();
return result;
}
return (
<Card>
<CardHeader className="flex-row items-start">
<div className="flex min-w-0 flex-col gap-1">
<CardTitle className="text-base">Transfer #{transfer.id}</CardTitle>
<CardDescription>
Requested {formatTransferDate(transfer.createdAt)} by {personLabel(transfer.requester)}
</CardDescription>
</div>
<Badge variant={TRANSFER_STATUS_VARIANTS[transfer.status]}>
{TRANSFER_STATUS_LABELS[transfer.status]}
</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
From
</span>
<span className="truncate text-sm">{assignmentLabel(transfer.fromAssignment)}</span>
</div>
<div className="flex min-w-0 flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Requested destination
</span>
<span className="truncate text-sm">{assignmentLabel(transfer.toAssignment)}</span>
</div>
</div>
{transfer.requestNote && (
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Request note
</span>
<p className="text-sm text-muted-foreground">{transfer.requestNote}</p>
</div>
)}
{transfer.rejectionReason && (
<Alert variant="destructive">
<AlertTitle>Rejection reason</AlertTitle>
<AlertDescription>{transfer.rejectionReason}</AlertDescription>
</Alert>
)}
{transfer.status === "awaiting_superuser" && transfer.escalationReason && (
<Alert>
<AlertTitle>{TRANSFER_ESCALATION_LABELS[transfer.escalationReason]}</AlertTitle>
<AlertDescription>
A superuser will make the final decision. Remaining leader approvals are bypassed.
</AlertDescription>
</Alert>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
</CardContent>
{(canCancel || canAppeal) && (
<CardFooter className="flex flex-wrap gap-2">
{canCancel && (
<Button
variant="destructive"
size="sm"
onClick={() => void handleCancel()}
disabled={cancelBusy}
>
{cancelBusy ? "Cancelling..." : confirmCancel ? "Confirm cancellation?" : "Cancel request"}
</Button>
)}
{canAppeal && (
<Button variant="outline" size="sm" onClick={() => setAppealOpen(true)}>
Appeal rejection
</Button>
)}
</CardFooter>
)}
<TransferNoteDialog
open={appealOpen}
onOpenChange={setAppealOpen}
title="Appeal this rejection"
description="Explain why the rejected transfer should receive a final superuser review."
fieldId={`transfer-appeal-${transfer.id}`}
fieldLabel="Appeal note"
placeholder="Add context for the final reviewer."
submitLabel="Submit appeal"
successMessage="Appeal submitted for final review"
required={false}
onSubmit={handleAppeal}
/>
</Card>
);
}

View file

@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import { CheckIcon, GavelIcon, XIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { resolveAppeal } from "@/app/(frontend)/transfers/actions";
import { TransferNoteDialog } from "@/components/frontend/transfers/TransferNoteDialog";
import {
TRANSFER_ESCALATION_LABELS,
TRANSFER_STATUS_LABELS,
TRANSFER_STATUS_VARIANTS,
assignmentLabel,
formatTransferDate,
personLabel,
type TransferRecord,
} from "@/components/frontend/transfers/transferTypes";
type ResolutionMode = "approve" | "deny";
interface TransferResolutionCardProps {
readonly transfer: TransferRecord;
readonly onUpdated: () => Promise<void>;
}
export function TransferResolutionCard({ transfer, onUpdated }: TransferResolutionCardProps) {
const [resolutionMode, setResolutionMode] = useState<ResolutionMode | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleResolve(note: string) {
if (resolutionMode === null) {
return { success: false, error: "Choose a resolution first." };
}
const result = await resolveAppeal(transfer.id, resolutionMode === "approve", note || undefined);
if (result.success) void onUpdated();
return result;
}
function openResolution(mode: ResolutionMode) {
setError(null);
setResolutionMode(mode);
}
const isApproving = resolutionMode === "approve";
return (
<Card>
<CardHeader className="flex-row items-start">
<div className="flex min-w-0 flex-col gap-1">
<CardTitle className="flex items-center gap-2 text-base">
<GavelIcon className="size-4 text-muted-foreground" />
Transfer #{transfer.id}
</CardTitle>
<CardDescription>
{personLabel(transfer.requester)} · requested {formatTransferDate(transfer.createdAt)}
</CardDescription>
</div>
<Badge variant={TRANSFER_STATUS_VARIANTS[transfer.status]}>
{TRANSFER_STATUS_LABELS[transfer.status]}
</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{transfer.escalationReason && (
<Alert>
<AlertTitle>{TRANSFER_ESCALATION_LABELS[transfer.escalationReason]}</AlertTitle>
<AlertDescription>
The request is ready for a final admin or developer decision.
</AlertDescription>
</Alert>
)}
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
From
</span>
<span className="truncate text-sm">{assignmentLabel(transfer.fromAssignment)}</span>
</div>
<div className="flex min-w-0 flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
To
</span>
<span className="truncate text-sm">{assignmentLabel(transfer.toAssignment)}</span>
</div>
</div>
{transfer.rejectionReason && (
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Original rejection
</span>
<p className="text-sm text-muted-foreground">{transfer.rejectionReason}</p>
</div>
)}
{transfer.appealNote && (
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Requester appeal
</span>
<p className="text-sm text-muted-foreground">{transfer.appealNote}</p>
</div>
)}
{error && (
<Alert variant="destructive">
<AlertTitle>Action failed</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => openResolution("approve")}>
<CheckIcon data-icon="inline-start" />
Approve &amp; complete
</Button>
<Button variant="destructive" size="sm" onClick={() => openResolution("deny")}>
<XIcon data-icon="inline-start" />
Deny request
</Button>
</CardFooter>
<TransferNoteDialog
open={resolutionMode !== null}
onOpenChange={(open) => {
if (!open) setResolutionMode(null);
}}
title={isApproving ? "Approve escalated transfer" : "Deny escalated transfer"}
description={
isApproving
? "This completes the transfer and moves the requester into the destination assignment."
: "This is a terminal decision. The requester will not be able to appeal again."
}
fieldId={`transfer-resolution-${transfer.id}`}
fieldLabel="Resolution note"
placeholder="Add a note for the requester (optional)."
submitLabel={isApproving ? "Approve & complete" : "Deny request"}
successMessage={isApproving ? "Transfer completed" : "Transfer request denied"}
required={false}
submitVariant={isApproving ? "default" : "destructive"}
onSubmit={handleResolve}
/>
</Card>
);
}

View file

@ -0,0 +1,252 @@
"use client";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { TransferApprovalCard } from "@/components/frontend/transfers/TransferApprovalCard";
import { TransferResolutionCard } from "@/components/frontend/transfers/TransferResolutionCard";
import { TransferRequestCard } from "@/components/frontend/transfers/TransferRequestCard";
import {
relationId,
assignmentLeaderId,
parseTransferPage,
type TransferRequest,
type TransferSide,
} from "@/components/frontend/transfers/transferTypes";
import {
ClipboardCheckIcon,
FileClockIcon,
RefreshCwIcon,
ShieldAlertIcon,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
interface TransfersViewProps {
readonly currentUserId: number;
readonly canResolve: boolean;
}
interface ApprovalEntry {
readonly request: TransferRequest;
readonly sides: readonly TransferSide[];
}
const TRANSFER_QUERY = new URLSearchParams({
depth: "1",
limit: "100",
sort: "-createdAt",
});
function approvalSides(request: TransferRequest, currentUserId: number): readonly TransferSide[] {
if (request.status !== "pending") return [];
const sides: TransferSide[] = [];
if (
request.fromLeaderDecision === "pending" &&
assignmentLeaderId(request.fromAssignment) === currentUserId
) {
sides.push("from");
}
if (
request.toLeaderDecision === "pending" &&
assignmentLeaderId(request.toAssignment) === currentUserId
) {
sides.push("to");
}
return sides;
}
function TransferSection({
icon: Icon,
title,
count,
children,
}: {
readonly icon: LucideIcon;
readonly title: string;
readonly count: number;
readonly children: ReactNode;
}) {
return (
<section aria-labelledby={`${title}-heading`} className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Icon className="size-4 text-muted-foreground" aria-hidden="true" />
<h2 id={`${title}-heading`} className="text-sm font-semibold uppercase tracking-wider">
{title}
</h2>
<Badge variant="outline" className="text-xs">
{count}
</Badge>
</div>
{children}
</section>
);
}
function EmptyTransferSection({ message }: { readonly message: string }) {
return (
<Alert>
<AlertDescription>{message}</AlertDescription>
</Alert>
);
}
function LoadingTransferSections({ canResolve }: { readonly canResolve: boolean }) {
return (
<div className="flex flex-col gap-8" aria-busy="true" aria-label="Loading transfer requests">
{["my-requests", "approvals", ...(canResolve ? ["appeals"] : [])].map((section) => (
<div key={section} className="flex flex-col gap-3">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-48 w-full" />
</div>
))}
</div>
);
}
export function TransfersView({ currentUserId, canResolve }: TransfersViewProps) {
const [requests, setRequests] = useState<readonly TransferRequest[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadRequests = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/assignment-transfers?${TRANSFER_QUERY.toString()}`, {
cache: "no-store",
});
if (!response.ok) throw new Error("Could not load transfer requests.");
const body: unknown = await response.json();
const parsed = parseTransferPage(body);
if (!parsed) throw new Error("The transfer request response was invalid.");
setRequests(parsed);
} catch (caughtError) {
setError(
caughtError instanceof Error ? caughtError.message : "Could not load transfer requests.",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadRequests();
}, [loadRequests]);
const myRequests = useMemo(
() =>
(requests ?? []).filter((request) => relationId(request.requester) === currentUserId),
[currentUserId, requests],
);
const approvals = useMemo<readonly ApprovalEntry[]>(
() =>
(requests ?? [])
.map((request) => ({ request, sides: approvalSides(request, currentUserId) }))
.filter((entry) => entry.sides.length > 0),
[currentUserId, requests],
);
const appeals = useMemo(
() => (canResolve ? (requests ?? []).filter((request) => request.status === "awaiting_superuser") : []),
[canResolve, requests],
);
if (!requests && loading) return <LoadingTransferSections canResolve={canResolve} />;
if (!requests && error) {
return (
<Alert variant="destructive">
<AlertTitle>Transfer requests unavailable</AlertTitle>
<AlertDescription className="flex flex-wrap items-center gap-3">
<span>{error}</span>
<Button size="sm" variant="outline" onClick={() => void loadRequests()}>
<RefreshCwIcon data-icon="inline-start" />
Try again
</Button>
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col gap-8">
{error && (
<Alert variant="destructive">
<AlertTitle>Could not refresh transfer requests</AlertTitle>
<AlertDescription className="flex flex-wrap items-center gap-3">
<span>{error}</span>
<Button size="sm" variant="outline" onClick={() => void loadRequests()}>
<RefreshCwIcon data-icon="inline-start" />
Try again
</Button>
</AlertDescription>
</Alert>
)}
<div className="flex items-center justify-end" aria-live="polite">
{loading && <span className="text-xs text-muted-foreground">Refreshing requests...</span>}
</div>
<TransferSection icon={FileClockIcon} title="My Requests" count={myRequests.length}>
{myRequests.length === 0 ? (
<EmptyTransferSection message="You have not requested an assignment transfer." />
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{myRequests.map((request) => (
<TransferRequestCard
key={request.id}
transfer={request}
currentUserId={currentUserId}
onUpdated={loadRequests}
/>
))}
</div>
)}
</TransferSection>
<TransferSection
icon={ClipboardCheckIcon}
title="Awaiting Your Approval"
count={approvals.length}
>
{approvals.length === 0 ? (
<EmptyTransferSection message="No pending requests need your approval." />
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{approvals.map((entry) => (
entry.sides.map((side) => (
<TransferApprovalCard
key={`${entry.request.id}-${side}`}
transfer={entry.request}
side={side}
onUpdated={loadRequests}
/>
))
))}
</div>
)}
</TransferSection>
{canResolve && (
<TransferSection icon={ShieldAlertIcon} title="Appeals" count={appeals.length}>
{appeals.length === 0 ? (
<EmptyTransferSection message="No escalated transfer requests need a final call." />
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{appeals.map((request) => (
<TransferResolutionCard
key={request.id}
transfer={request}
onUpdated={loadRequests}
/>
))}
</div>
)}
</TransferSection>
)}
</div>
);
}

View file

@ -0,0 +1,250 @@
import type { AssignmentTransfer } from "@/payload-types";
export const TRANSFER_STATUSES = [
"pending",
"rejected",
"awaiting_superuser",
"completed",
"cancelled",
"denied",
] as const satisfies readonly AssignmentTransfer["status"][];
export type TransferStatus = (typeof TRANSFER_STATUSES)[number];
export type TransferDecision = AssignmentTransfer["fromLeaderDecision"];
export type TransferEscalationReason = Exclude<
NonNullable<AssignmentTransfer["escalationReason"]>,
null
>;
export type TransferSide = "from" | "to";
const TRANSFER_DECISIONS = ["pending", "approved", "rejected", "not_required"] as const satisfies readonly TransferDecision[];
const TRANSFER_ESCALATION_REASONS = ["no_leaders", "appeal", "leader_changed"] as const satisfies readonly TransferEscalationReason[];
export const TRANSFER_STATUS_LABELS = {
pending: "Pending approval",
rejected: "Rejected",
awaiting_superuser: "Awaiting final call",
completed: "Completed",
cancelled: "Cancelled",
denied: "Denied",
} as const satisfies Record<TransferStatus, string>;
export const TRANSFER_STATUS_VARIANTS = {
pending: "secondary",
rejected: "destructive",
awaiting_superuser: "outline",
completed: "default",
cancelled: "secondary",
denied: "destructive",
} as const satisfies Record<TransferStatus, "default" | "secondary" | "destructive" | "outline">;
export const TRANSFER_DECISION_LABELS = {
pending: "Pending",
approved: "Approved",
rejected: "Rejected",
not_required: "Not required",
} as const satisfies Record<TransferDecision, string>;
export const TRANSFER_ESCALATION_LABELS = {
no_leaders: "No assignment leaders",
appeal: "Requester appeal",
leader_changed: "Leadership changed",
} as const satisfies Record<TransferEscalationReason, string>;
export type TransferPerson =
| number
| {
readonly id: number;
readonly displayName: string | null;
readonly username: string | null;
};
export type TransferAssignmentDocument = {
readonly id: number;
readonly name: string;
readonly callsign: string | null;
readonly leader: TransferPerson | null;
};
export type TransferAssignment = number | TransferAssignmentDocument;
export type TransferAssignmentSummary = {
readonly id: number;
readonly name: string;
readonly callsign: string | null;
readonly leader: TransferPerson | null;
readonly members: readonly TransferPerson[];
};
export type CurrentAssignmentSummary = {
readonly id: number;
readonly name: string;
readonly type: string;
readonly callsign: string | null;
readonly leaderId: number | null;
readonly isLeader: boolean;
};
export type AccountAssignment = CurrentAssignmentSummary;
export type TransferRequest = TransferRecord;
export type TransferRecord = {
readonly id: number;
readonly requester: TransferPerson;
readonly fromAssignment: TransferAssignment;
readonly toAssignment: TransferAssignment;
readonly status: TransferStatus;
readonly escalationReason: TransferEscalationReason | null;
readonly fromLeaderDecision: TransferDecision;
readonly toLeaderDecision: TransferDecision;
readonly rejectionReason: string | null;
readonly requestNote: string | null;
readonly appealNote: string | null;
readonly resolutionNote: string | null;
readonly createdAt: string;
readonly updatedAt: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isInteger(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value);
}
function isTransferStatus(value: unknown): value is TransferStatus {
return typeof value === "string" && TRANSFER_STATUSES.some((status) => status === value);
}
function isTransferDecision(value: unknown): value is TransferDecision {
return typeof value === "string" && TRANSFER_DECISIONS.some((decision) => decision === value);
}
function parseEscalationReason(value: unknown): TransferEscalationReason | null {
const reason = TRANSFER_ESCALATION_REASONS.find((candidate) => candidate === value);
return reason ?? null;
}
function parsePerson(value: unknown): TransferPerson | null {
if (isInteger(value)) return value;
if (!isRecord(value) || !isInteger(value.id)) return null;
return {
id: value.id,
displayName: typeof value.displayName === "string" ? value.displayName : null,
username: typeof value.username === "string" ? value.username : null,
};
}
function parseAssignment(value: unknown): TransferAssignment | null {
if (isInteger(value)) return value;
if (!isRecord(value) || !isInteger(value.id) || typeof value.name !== "string") return null;
return {
id: value.id,
name: value.name,
callsign: typeof value.callsign === "string" ? value.callsign : null,
leader: parsePerson(value.leader),
};
}
function parseAssignmentSummary(value: unknown): TransferAssignmentSummary | null {
if (!isRecord(value) || !isInteger(value.id) || typeof value.name !== "string") return null;
const members = Array.isArray(value.members)
? value.members.flatMap((member) => {
const parsed = parsePerson(member);
return parsed ? [parsed] : [];
})
: [];
return {
id: value.id,
name: value.name,
callsign: typeof value.callsign === "string" ? value.callsign : null,
leader: parsePerson(value.leader),
members,
};
}
function parseTransfer(value: unknown): TransferRecord | null {
if (!isRecord(value) || !isInteger(value.id)) return null;
const requester = parsePerson(value.requester);
const fromAssignment = parseAssignment(value.fromAssignment);
const toAssignment = parseAssignment(value.toAssignment);
if (
requester === null ||
fromAssignment === null ||
toAssignment === null ||
!isTransferStatus(value.status) ||
!isTransferDecision(value.fromLeaderDecision) ||
!isTransferDecision(value.toLeaderDecision)
) {
return null;
}
return {
id: value.id,
requester,
fromAssignment,
toAssignment,
status: value.status,
escalationReason: parseEscalationReason(value.escalationReason),
fromLeaderDecision: value.fromLeaderDecision,
toLeaderDecision: value.toLeaderDecision,
rejectionReason: typeof value.rejectionReason === "string" ? value.rejectionReason : null,
requestNote: typeof value.requestNote === "string" ? value.requestNote : null,
appealNote: typeof value.appealNote === "string" ? value.appealNote : null,
resolutionNote: typeof value.resolutionNote === "string" ? value.resolutionNote : null,
createdAt: typeof value.createdAt === "string" ? value.createdAt : "",
updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : "",
};
}
export function parseTransferCollection(data: unknown): TransferRecord[] | null {
if (!isRecord(data) || !Array.isArray(data.docs)) return null;
return data.docs.flatMap((doc) => {
const transfer = parseTransfer(doc);
return transfer ? [transfer] : [];
});
}
export function parseTransferPage(data: unknown): TransferRequest[] | null {
return parseTransferCollection(data);
}
export function parseAssignmentPage(data: unknown): TransferAssignmentSummary[] | null {
if (!isRecord(data) || !Array.isArray(data.docs)) return null;
return data.docs.flatMap((doc) => {
const assignment = parseAssignmentSummary(doc);
return assignment ? [assignment] : [];
});
}
export function relationId(relation: TransferPerson | null): number | null {
return typeof relation === "number" ? relation : (relation?.id ?? null);
}
export function personLabel(person: TransferPerson, fallback = "Unknown member"): string {
if (typeof person === "number") return `User #${person}`;
return person.displayName || person.username || fallback;
}
export function assignmentLabel(assignment: TransferAssignment): string {
if (typeof assignment === "number") return `Assignment #${assignment}`;
return assignment.callsign ? `[${assignment.callsign}] ${assignment.name}` : assignment.name;
}
export function assignmentLeaderId(assignment: TransferAssignment): number | null {
return typeof assignment === "number" ? null : relationId(assignment.leader);
}
export function formatTransferDate(iso: string): string {
if (!iso) return "Unknown date";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "Unknown date";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
}

769
src/lib/transfers/index.ts Normal file
View file

@ -0,0 +1,769 @@
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;
}

View file

@ -0,0 +1,18 @@
/**
* Notification type strings for assignment-transfer lifecycle events. Defined
* once here so the transfer lib, the in-app label map, and the Discord bridge
* all reference the same values. These are intentionally NOT part of
* MUTEABLE_NOTIFICATION_TYPES — transfer notifications always bypass user mutes.
*/
export const TRANSFER_NOTIFICATION_TYPES = {
request: "assignment-transfer:request",
approved: "assignment-transfer:approved",
rejected: "assignment-transfer:rejected",
escalated: "assignment-transfer:escalated",
resolved: "assignment-transfer:resolved",
completed: "assignment-transfer:completed",
cancelled: "assignment-transfer:cancelled",
} as const;
export type TransferNotificationType =
(typeof TRANSFER_NOTIFICATION_TYPES)[keyof typeof TRANSFER_NOTIFICATION_TYPES];

View file

@ -81,6 +81,7 @@ export interface Config {
awards: Award; awards: Award;
qualifications: Qualification; qualifications: Qualification;
assignments: Assignment; assignments: Assignment;
'assignment-transfers': AssignmentTransfer;
experience: Experience; experience: Experience;
'user-notifications': UserNotification; 'user-notifications': UserNotification;
evaluations: Evaluation; evaluations: Evaluation;
@ -142,6 +143,7 @@ export interface Config {
awards: AwardsSelect<false> | AwardsSelect<true>; awards: AwardsSelect<false> | AwardsSelect<true>;
qualifications: QualificationsSelect<false> | QualificationsSelect<true>; qualifications: QualificationsSelect<false> | QualificationsSelect<true>;
assignments: AssignmentsSelect<false> | AssignmentsSelect<true>; assignments: AssignmentsSelect<false> | AssignmentsSelect<true>;
'assignment-transfers': AssignmentTransfersSelect<false> | AssignmentTransfersSelect<true>;
experience: ExperienceSelect<false> | ExperienceSelect<true>; experience: ExperienceSelect<false> | ExperienceSelect<true>;
'user-notifications': UserNotificationsSelect<false> | UserNotificationsSelect<true>; 'user-notifications': UserNotificationsSelect<false> | UserNotificationsSelect<true>;
evaluations: EvaluationsSelect<false> | EvaluationsSelect<true>; evaluations: EvaluationsSelect<false> | EvaluationsSelect<true>;
@ -1599,6 +1601,7 @@ export interface GameEventLog {
| 'evaluations' | 'evaluations'
| 'tickets' | 'tickets'
| 'game-servers' | 'game-servers'
| 'assignment-transfers'
) )
| null; | null;
targetId?: number | null; targetId?: number | null;
@ -1828,6 +1831,120 @@ export interface Qualification {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "assignment-transfers".
*/
export interface AssignmentTransfer {
id: number;
/**
* The user requesting the transfer.
*/
requester: number | User;
/**
* The assignment the requester currently belongs to.
*/
fromAssignment: number | Assignment;
/**
* The assignment the requester wants to join.
*/
toAssignment: number | Assignment;
status: 'pending' | 'rejected' | 'awaiting_superuser' | 'completed' | 'cancelled' | 'denied';
/**
* Only set when status is awaiting_superuser. no_leaders: neither side had a leader at creation; appeal: requester appealed a rejection; leader_changed: leadership changed mid-pending.
*/
escalationReason?: ('no_leaders' | 'appeal' | 'leader_changed') | null;
/**
* The source assignment leader's decision. not_required is stamped at creation when the source has no leader.
*/
fromLeaderDecision: 'pending' | 'approved' | 'rejected' | 'not_required';
/**
* The destination assignment leader's decision. not_required is stamped at creation when the destination has no leader.
*/
toLeaderDecision: 'pending' | 'approved' | 'rejected' | 'not_required';
/**
* The user who recorded the source-side decision (audit).
*/
fromDecisionBy?: (number | null) | User;
/**
* The user who recorded the destination-side decision (audit).
*/
toDecisionBy?: (number | null) | User;
/**
* When the source-side decision was recorded.
*/
fromDecidedAt?: string | null;
/**
* When the destination-side decision was recorded.
*/
toDecidedAt?: string | null;
/**
* The source assignment's leader at creation time (audit snapshot; may be null).
*/
fromLeaderSnapshot?: (number | null) | User;
/**
* The destination assignment's leader at creation time (audit snapshot; may be null).
*/
toLeaderSnapshot?: (number | null) | User;
/**
* Required when a side rejects the request.
*/
rejectionReason?: string | null;
/**
* Optional note from the requester explaining the transfer.
*/
requestNote?: string | null;
/**
* Optional note from the requester when appealing a rejection.
*/
appealNote?: string | null;
/**
* The admin/developer who resolved an escalated request (audit).
*/
resolvedBy?: (number | null) | User;
/**
* Optional note from the resolver.
*/
resolutionNote?: string | null;
/**
* When the escalated request was resolved.
*/
resolvedAt?: string | null;
/**
* Discord message id of the notification sent to the source leader (Phase 2).
*/
discordFromLeaderMessageId?: string | null;
/**
* Discord message id of the notification sent to the destination leader (Phase 2).
*/
discordToLeaderMessageId?: string | null;
/**
* Discord message id of the notification sent to the requester (Phase 2).
*/
discordRequesterMessageId?: string | null;
/**
* The user actually notified on the source side (the leader at notification time; may differ from the snapshot).
*/
sourceNotifiedToUserId?: (number | null) | User;
/**
* The user actually notified on the destination side (the leader at notification time; may differ from the snapshot).
*/
destinationNotifiedToUserId?: (number | null) | User;
/**
* Delivery keys already sent to Discord, used by the Phase 2 bot service for idempotent delivery.
*/
discordDeliveredKeys?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "experience". * via the `definition` "experience".
@ -3077,6 +3194,10 @@ export interface PayloadLockedDocument {
relationTo: 'assignments'; relationTo: 'assignments';
value: number | Assignment; value: number | Assignment;
} | null) } | null)
| ({
relationTo: 'assignment-transfers';
value: number | AssignmentTransfer;
} | null)
| ({ | ({
relationTo: 'experience'; relationTo: 'experience';
value: number | Experience; value: number | Experience;
@ -3601,6 +3722,39 @@ export interface AssignmentsSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "assignment-transfers_select".
*/
export interface AssignmentTransfersSelect<T extends boolean = true> {
requester?: T;
fromAssignment?: T;
toAssignment?: T;
status?: T;
escalationReason?: T;
fromLeaderDecision?: T;
toLeaderDecision?: T;
fromDecisionBy?: T;
toDecisionBy?: T;
fromDecidedAt?: T;
toDecidedAt?: T;
fromLeaderSnapshot?: T;
toLeaderSnapshot?: T;
rejectionReason?: T;
requestNote?: T;
appealNote?: T;
resolvedBy?: T;
resolutionNote?: T;
resolvedAt?: T;
discordFromLeaderMessageId?: T;
discordToLeaderMessageId?: T;
discordRequesterMessageId?: T;
sourceNotifiedToUserId?: T;
destinationNotifiedToUserId?: T;
discordDeliveredKeys?: T;
updatedAt?: T;
createdAt?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "experience_select". * via the `definition` "experience_select".

View file

@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
TRANSFER_DELIVERY_KEYS,
deliveredKeys,
findLeaderSide,
isLeaderDeliveryDue,
transferDecisionRow,
} from "@/bot/lib/transferMessaging";
import type { AssignmentTransfer } from "@/payload-types";
const transfer = (overrides: Partial<AssignmentTransfer> = {}): AssignmentTransfer => ({
id: 42,
requester: 10,
fromAssignment: 20,
toAssignment: 30,
status: "pending",
fromLeaderDecision: "pending",
toLeaderDecision: "pending",
updatedAt: "2026-08-31T00:00:00.000Z",
createdAt: "2026-08-31T00:00:00.000Z",
...overrides,
});
describe("assignment-transfer Discord helpers", () => {
it("requires a fresh leader DM when leadership changes", () => {
// Given
const request = transfer({
discordDeliveredKeys: [TRANSFER_DELIVERY_KEYS.requestFrom],
sourceNotifiedToUserId: 100,
});
// When
const due = isLeaderDeliveryDue(request, "from", 101);
// Then
expect(due).toBe(true);
});
it("skips a leader DM only when its key and recipient both match", () => {
// Given
const request = transfer({
discordDeliveredKeys: [TRANSFER_DELIVERY_KEYS.requestTo],
destinationNotifiedToUserId: 200,
});
// When
const due = isLeaderDeliveryDue(request, "to", 200);
// Then
expect(due).toBe(false);
});
it("chooses the actor's still-pending current-leader side", () => {
// Given
const request = transfer({ fromLeaderDecision: "approved" });
// When
const check = findLeaderSide(request, 100, 200, 200);
// Then
expect(check).toEqual({ outcome: "authorized", side: "to" });
});
it("marks stale leaders as unauthorized", () => {
// Given
const request = transfer();
// When
const check = findLeaderSide(request, 101, 200, 100);
// Then
expect(check).toEqual({ outcome: "not_leader" });
});
it("renders the transfer custom-id contract", () => {
// Given
const row = transferDecisionRow(42);
// When
const customIds = row.toJSON().components.map((component) =>
"custom_id" in component ? component.custom_id : null,
);
// Then
expect(customIds).toEqual(["ptf-xfer:42:approve", "ptf-xfer:42:reject"]);
});
it("narrows malformed delivery-ledger JSON to an empty list", () => {
// Given
const request = transfer({ discordDeliveredKeys: { invalid: true } });
// When
const keys = deliveredKeys(request);
// Then
expect(keys).toEqual([]);
});
});

View file

@ -0,0 +1,569 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { AssignmentTransfer, User } from "@/payload-types";
import { notifyUser } from "@/lib/notifications";
import { EventTypes } from "@/utils/event-log/eventTypes";
import {
appealTransfer,
cancelTransfer,
createTransferRequest,
recordTransferDecision,
resolveAppeal,
} from "@/lib/transfers";
let payload: Payload;
const RUN = `trf-${Date.now().toString(36)}`;
const TIMEOUT = 30_000;
const ROLE_NAMES = ["user", "admin", "guest", "trusted", "developer"] as const;
type RoleName = (typeof ROLE_NAMES)[number];
describe("Assignment transfer requests", () => {
const users: Record<string, User> = {};
const squads: Record<string, number> = {};
const userIds: number[] = [];
const assignmentIds: number[] = [];
const transferIds: number[] = [];
let noLeadersDoc: AssignmentTransfer | null = null;
const makeUser = async (
label: string,
roles: RoleName[] = ["user"],
extra?: Partial<User>,
): Promise<User> => {
const user = (await payload.create({
collection: "users",
data: {
username: `${RUN}-${label}`,
discordUsername: `${RUN}-${label}`,
displayName: label.toUpperCase(),
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles,
...extra,
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(user.id);
return user;
};
const makeSquad = async (label: string, leader: number | null, members: number[]): Promise<number> => {
const doc = await payload.create({
collection: "assignments",
data: { name: `${RUN} ${label}`, type: "squad", leader, members },
overrideAccess: true,
depth: 0,
});
assignmentIds.push(doc.id);
return doc.id;
};
const getAssignment = async (id: number): Promise<{ leader: number | null; members: number[] }> => {
const doc = await payload.findByID({ collection: "assignments", id, depth: 0 });
return {
leader: (doc.leader as number | null) ?? null,
members: (doc.members as number[]) ?? [],
};
};
const trackTransfer = (doc: AssignmentTransfer): AssignmentTransfer => {
transferIds.push(doc.id);
return doc;
};
const notificationsFor = async (userId: number, type?: string) => {
const res = await payload.find({
collection: "user-notifications",
where: type
? { and: [{ user: { equals: userId } }, { type: { equals: type } }] }
: { user: { equals: userId } },
limit: 100,
depth: 0,
overrideAccess: true,
});
return res.docs;
};
const eventsFor = async (requestId: number) => {
const res = await payload.find({
collection: "game-event-logs",
where: { targetCollection: { equals: "assignment-transfers" }, targetId: { equals: requestId } },
limit: 100,
depth: 0,
overrideAccess: true,
});
return res.docs;
};
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
users.req1 = await makeUser("req-1");
users.leadA = await makeUser("lead-a");
users.leadB = await makeUser("lead-b");
users.req2 = await makeUser("req-2");
users.leadR1 = await makeUser("lead-r1");
users.leadR2 = await makeUser("lead-r2");
users.admin = await makeUser("admin", ["admin"]);
users.outsider = await makeUser("outsider");
users.req3 = await makeUser("req-3");
users.req4 = await makeUser("req-4");
users.leadT = await makeUser("lead-t");
users.req5 = await makeUser("req-5");
users.leadU1 = await makeUser("lead-u1");
users.leadU2 = await makeUser("lead-u2");
users.reqLead = await makeUser("req-lead");
users.mutedLead = await makeUser("muted-lead", ["user"], {
preferences: { notifications: { mutedAll: true } },
});
users.reqM = await makeUser("req-m");
users.reqV = await makeUser("req-v");
users.leadV1 = await makeUser("lead-v1");
users.leadC = await makeUser("lead-c");
squads.squadA = await makeSquad("Squad A", users.leadA.id, [users.req1.id]);
squads.squadB = await makeSquad("Squad B", users.leadB.id, []);
squads.squadR1 = await makeSquad("Squad R1", users.leadR1.id, [users.req2.id]);
squads.squadR2 = await makeSquad("Squad R2", users.leadR2.id, []);
squads.squadC = await makeSquad("Squad C", null, [users.req3.id]);
squads.squadD = await makeSquad("Squad D", null, []);
squads.squadS1 = await makeSquad("Squad S1", null, [users.req4.id]);
squads.squadT = await makeSquad("Squad T", users.leadT.id, []);
squads.squadU1 = await makeSquad("Squad U1", users.leadU1.id, [users.req5.id]);
squads.squadU2 = await makeSquad("Squad U2", users.leadU2.id, []);
// squadL: the leader is also an explicit member so the membership guard passes
// before the leader guard fires.
squads.squadL = await makeSquad("Squad L", users.leadA.id, [users.leadA.id, users.req1.id]);
squads.squadP = await makeSquad("Squad P", null, [users.reqLead.id]);
// squadQ: the requester leads the destination side.
squads.squadQ = await makeSquad("Squad Q", users.reqLead.id, [users.reqLead.id]);
squads.squadM = await makeSquad("Squad M", users.mutedLead.id, []);
squads.squadN = await makeSquad("Squad N", null, [users.reqM.id]);
squads.squadV1 = await makeSquad("Squad V1", users.leadV1.id, [users.reqV.id]);
squads.squadV2 = await makeSquad("Squad V2", null, []);
}, TIMEOUT);
afterAll(async () => {
if (!payload) return;
// Notifications and event log rows first — they reference users and transfers.
const notifs = await payload
.find({
collection: "user-notifications",
where: { user: { in: userIds } },
limit: 500,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const n of notifs?.docs ?? []) {
await payload.delete({ collection: "user-notifications", id: n.id, overrideAccess: true }).catch(() => {});
}
const events = await payload
.find({
collection: "game-event-logs",
where: {
or: [
{ actor: { in: userIds } },
{ and: [{ targetCollection: { equals: "assignment-transfers" } }, { targetId: { in: transferIds } }] },
],
},
limit: 500,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const e of events?.docs ?? []) {
await payload.delete({ collection: "game-event-logs", id: e.id, overrideAccess: true }).catch(() => {});
}
for (const id of transferIds) {
await payload.delete({ collection: "assignment-transfers", id, overrideAccess: true }).catch(() => {});
}
for (const id of assignmentIds) {
await payload.delete({ collection: "assignments", id, overrideAccess: true }).catch(() => {});
}
for (const id of userIds) {
const profiles = await payload
.find({ collection: "profiles", where: { user: { equals: id } }, limit: 5, depth: 0, overrideAccess: true })
.catch(() => null);
for (const p of profiles?.docs ?? []) {
await payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }).catch(() => {});
}
const accounts = await payload
.find({
collection: "bank-accounts",
where: { ownerUser: { equals: id } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const a of accounts?.docs ?? []) {
await payload.delete({ collection: "bank-accounts", id: a.id, overrideAccess: true }).catch(() => {});
}
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
}
});
it("completes when both leaders approve and moves membership", async () => {
const doc = trackTransfer(
await createTransferRequest(payload, {
requesterId: users.req1.id,
fromAssignmentId: squads.squadA,
toAssignmentId: squads.squadB,
note: "relocating",
}),
);
expect(doc.status).toBe("pending");
expect(doc.fromLeaderDecision).toBe("pending");
expect(doc.toLeaderDecision).toBe("pending");
// A non-leader member cannot record a decision.
await expect(
recordTransferDecision(payload, {
requestId: doc.id,
side: "from",
decision: "approved",
actorUserId: users.req1.id,
}),
).rejects.toThrow("Only the current leader of");
const afterFrom = await recordTransferDecision(payload, {
requestId: doc.id,
side: "from",
decision: "approved",
actorUserId: users.leadA.id,
});
expect(afterFrom.status).toBe("pending");
expect((await notificationsFor(users.req1.id, "assignment-transfer:approved")).length).toBeGreaterThan(0);
const done = await recordTransferDecision(payload, {
requestId: doc.id,
side: "to",
decision: "approved",
actorUserId: users.leadB.id,
});
expect(done.status).toBe("completed");
const from = await getAssignment(squads.squadA);
const to = await getAssignment(squads.squadB);
expect(from.members).not.toContain(users.req1.id);
expect(to.members).toContain(users.req1.id);
expect((await notificationsFor(users.req1.id, "assignment-transfer:completed")).length).toBeGreaterThan(0);
const types = (await eventsFor(doc.id)).map((e) => e.type);
expect(types).toContain(EventTypes.AssignmentTransferRequest);
expect(types).toContain(EventTypes.AssignmentTransferApprove);
expect(types).toContain(EventTypes.AssignmentTransferComplete);
}, TIMEOUT);
it("rejects with a reason, escalates on appeal, and completes on superuser approval", async () => {
const doc = trackTransfer(
await createTransferRequest(payload, {
requesterId: users.req2.id,
fromAssignmentId: squads.squadR1,
toAssignmentId: squads.squadR2,
}),
);
// A rejection without a reason is rejected.
await expect(
recordTransferDecision(payload, {
requestId: doc.id,
side: "from",
decision: "rejected",
actorUserId: users.leadR1.id,
}),
).rejects.toThrow("A reason is required when rejecting a transfer request.");
const rejected = await recordTransferDecision(payload, {
requestId: doc.id,
side: "from",
decision: "rejected",
reason: "no room in the squad",
actorUserId: users.leadR1.id,
});
expect(rejected.status).toBe("rejected");
expect(rejected.rejectionReason).toBe("no room in the squad");
expect((await notificationsFor(users.req2.id, "assignment-transfer:rejected")).length).toBeGreaterThan(0);
// Only the requester can appeal.
await expect(appealTransfer(payload, { requestId: doc.id, actorUserId: users.leadR1.id })).rejects.toThrow(
"Only the requester can appeal a transfer request.",
);
const appealed = await appealTransfer(payload, {
requestId: doc.id,
actorUserId: users.req2.id,
note: "please reconsider",
});
expect(appealed.status).toBe("awaiting_superuser");
expect(appealed.escalationReason).toBe("appeal");
expect((await notificationsFor(users.admin.id, "assignment-transfer:escalated")).length).toBeGreaterThan(0);
// A second appeal is impossible (no longer rejected).
await expect(appealTransfer(payload, { requestId: doc.id, actorUserId: users.req2.id })).rejects.toThrow(
"Only rejected requests can be appealed",
);
// Non-superusers cannot resolve.
await expect(resolveAppeal(payload, { requestId: doc.id, approve: true, actorUserId: users.req2.id })).rejects.toThrow(
"Only an admin or developer can resolve a transfer request.",
);
const resolved = await resolveAppeal(payload, {
requestId: doc.id,
approve: true,
actorUserId: users.admin.id,
note: "approved on appeal",
});
expect(resolved.status).toBe("completed");
expect(resolved.resolvedBy).toBe(users.admin.id);
const from = await getAssignment(squads.squadR1);
const to = await getAssignment(squads.squadR2);
expect(from.members).not.toContain(users.req2.id);
expect(to.members).toContain(users.req2.id);
}, TIMEOUT);
it("creates straight into awaiting_superuser when both sides are leaderless", async () => {
const doc = trackTransfer(
await createTransferRequest(payload, {
requesterId: users.req3.id,
fromAssignmentId: squads.squadC,
toAssignmentId: squads.squadD,
}),
);
noLeadersDoc = doc;
expect(doc.status).toBe("awaiting_superuser");
expect(doc.escalationReason).toBe("no_leaders");
expect(doc.fromLeaderDecision).toBe("not_required");
expect(doc.toLeaderDecision).toBe("not_required");
expect((await notificationsFor(users.admin.id, "assignment-transfer:escalated")).length).toBeGreaterThan(0);
}, TIMEOUT);
it("superuser denial is terminal", async () => {
expect(noLeadersDoc).toBeTruthy();
const denied = await resolveAppeal(payload, {
requestId: noLeadersDoc!.id,
approve: false,
actorUserId: users.admin.id,
note: "stay where you are",
});
expect(denied.status).toBe("denied");
expect(denied.resolvedBy).toBe(users.admin.id);
expect((await notificationsFor(users.req3.id, "assignment-transfer:resolved")).length).toBeGreaterThan(0);
// Terminal: no further resolution or appeal is possible.
await expect(resolveAppeal(payload, { requestId: denied.id, approve: true, actorUserId: users.admin.id })).rejects.toThrow(
"Only escalated requests can be resolved",
);
await expect(appealTransfer(payload, { requestId: denied.id, actorUserId: users.req3.id })).rejects.toThrow(
"Only rejected requests can be appealed",
);
const from = await getAssignment(squads.squadC);
expect(from.members).toContain(users.req3.id);
}, TIMEOUT);
it("completes after destination approval alone when the source has no leader", async () => {
const doc = trackTransfer(
await createTransferRequest(payload, {
requesterId: users.req4.id,
fromAssignmentId: squads.squadS1,
toAssignmentId: squads.squadT,
}),
);
expect(doc.status).toBe("pending");
expect(doc.fromLeaderDecision).toBe("not_required");
expect(doc.toLeaderDecision).toBe("pending");
// The leaderless side cannot (and must not) record a decision.
await expect(
recordTransferDecision(payload, {
requestId: doc.id,
side: "from",
decision: "approved",
actorUserId: users.req4.id,
}),
).rejects.toThrow("This assignment has no leader, so no decision is required from it.");
const done = await recordTransferDecision(payload, {
requestId: doc.id,
side: "to",
decision: "approved",
actorUserId: users.leadT.id,
});
expect(done.status).toBe("completed");
const from = await getAssignment(squads.squadS1);
const to = await getAssignment(squads.squadT);
expect(from.members).not.toContain(users.req4.id);
expect(to.members).toContain(users.req4.id);
}, TIMEOUT);
it("cancels a pending request without membership changes", async () => {
const doc = trackTransfer(
await createTransferRequest(payload, {
requesterId: users.req5.id,
fromAssignmentId: squads.squadU1,
toAssignmentId: squads.squadU2,
}),
);
// Only the requester can cancel.
await expect(cancelTransfer(payload, { requestId: doc.id, actorUserId: users.leadU1.id })).rejects.toThrow(
"Only the requester can cancel a transfer request.",
);
const cancelled = await cancelTransfer(payload, { requestId: doc.id, actorUserId: users.req5.id });
expect(cancelled.status).toBe("cancelled");
await expect(cancelTransfer(payload, { requestId: doc.id, actorUserId: users.req5.id })).rejects.toThrow(
"Only pending requests can be cancelled",
);
const from = await getAssignment(squads.squadU1);
const to = await getAssignment(squads.squadU2);
expect(from.members).toContain(users.req5.id);
expect(to.members).not.toContain(users.req5.id);
}, TIMEOUT);
it("enforces creation guards", async () => {
// Non-member requester.
await expect(
createTransferRequest(payload, {
requesterId: users.outsider.id,
fromAssignmentId: squads.squadA,
toAssignmentId: squads.squadB,
}),
).rejects.toThrow("You are not a member of the source assignment.");
// Same assignment on both sides. (req1 sits in squadB after the first test.)
await expect(
createTransferRequest(payload, {
requesterId: users.req1.id,
fromAssignmentId: squads.squadB,
toAssignmentId: squads.squadB,
}),
).rejects.toThrow("The source and destination assignments must be different.");
// Requester is the leader of the source side.
await expect(
createTransferRequest(payload, {
requesterId: users.leadA.id,
fromAssignmentId: squads.squadL,
toAssignmentId: squads.squadB,
}),
).rejects.toThrow("Leaders cannot request transfers");
// Requester is the leader of the destination side.
await expect(
createTransferRequest(payload, {
requesterId: users.reqLead.id,
fromAssignmentId: squads.squadP,
toAssignmentId: squads.squadQ,
}),
).rejects.toThrow("Leaders cannot request transfers");
}, TIMEOUT);
it("blocks a second open request to the same destination and allows one after cancellation", async () => {
const first = trackTransfer(
await createTransferRequest(payload, {
requesterId: users.req1.id,
fromAssignmentId: squads.squadB,
toAssignmentId: squads.squadA,
}),
);
await expect(
createTransferRequest(payload, {
requesterId: users.req1.id,
fromAssignmentId: squads.squadB,
toAssignmentId: squads.squadA,
}),
).rejects.toThrow("You already have an open transfer request to this assignment.");
const cancelled = await cancelTransfer(payload, { requestId: first.id, actorUserId: users.req1.id });
expect(cancelled.status).toBe("cancelled");
// Cancelled is not "open" — a fresh request to the same destination is allowed again.
const second = trackTransfer(
await createTransferRequest(payload, {
requesterId: users.req1.id,
fromAssignmentId: squads.squadB,
toAssignmentId: squads.squadA,
}),
);
expect(second.status).toBe("pending");
await cancelTransfer(payload, { requestId: second.id, actorUserId: users.req1.id });
}, TIMEOUT);
it("escalates when leadership changes mid-pending", async () => {
const doc = trackTransfer(
await createTransferRequest(payload, {
requesterId: users.reqV.id,
fromAssignmentId: squads.squadV1,
toAssignmentId: squads.squadV2,
}),
);
expect(doc.status).toBe("pending");
// The source gets a new leader — the former one can no longer decide.
await payload.update({
collection: "assignments",
id: squads.squadV1,
data: { leader: users.leadC.id },
overrideAccess: true,
});
await expect(
recordTransferDecision(payload, {
requestId: doc.id,
side: "from",
decision: "approved",
actorUserId: users.leadV1.id,
}),
).rejects.toThrow("Only the current leader of");
// ...and when the side is left leaderless entirely, a decision attempt escalates
// instead of deadlocking.
await payload.update({ collection: "assignments", id: squads.squadV1, data: { leader: null }, overrideAccess: true });
const escalated = await recordTransferDecision(payload, {
requestId: doc.id,
side: "from",
decision: "approved",
actorUserId: users.leadC.id,
});
expect(escalated.status).toBe("awaiting_superuser");
expect(escalated.escalationReason).toBe("leader_changed");
expect((await notificationsFor(users.admin.id, "assignment-transfer:escalated")).length).toBeGreaterThan(0);
}, TIMEOUT);
it("delivers transfer notifications to muted users but suppresses ordinary ones", async () => {
const doc = trackTransfer(
await createTransferRequest(payload, {
requesterId: users.reqM.id,
fromAssignmentId: squads.squadN,
toAssignmentId: squads.squadM,
}),
);
expect(doc.status).toBe("pending");
// The destination leader has preferences.notifications.mutedAll = true, yet the
// workflow-critical request notification (sent with bypassMute) still lands.
const delivered = await notificationsFor(users.mutedLead.id, "assignment-transfer:request");
expect(delivered.length).toBeGreaterThan(0);
// A non-transfer notification sent without bypass is suppressed by the mute.
await notifyUser(payload, { userId: users.mutedLead.id, type: "market:offer", title: "Mute suppression check" });
const suppressed = await notificationsFor(users.mutedLead.id, "market:offer");
expect(suppressed.length).toBe(0);
}, TIMEOUT);
});