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:
parent
b9326cfc54
commit
76e948fd59
12 changed files with 2898 additions and 0 deletions
138
src/components/frontend/transfers/TransferActionDialog.tsx
Normal file
138
src/components/frontend/transfers/TransferActionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
src/components/frontend/transfers/TransferApprovalCard.tsx
Normal file
161
src/components/frontend/transfers/TransferApprovalCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
src/components/frontend/transfers/TransferNoteDialog.tsx
Normal file
152
src/components/frontend/transfers/TransferNoteDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
src/components/frontend/transfers/TransferRequestCard.tsx
Normal file
180
src/components/frontend/transfers/TransferRequestCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
src/components/frontend/transfers/TransferResolutionCard.tsx
Normal file
157
src/components/frontend/transfers/TransferResolutionCard.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
252
src/components/frontend/transfers/TransfersView.tsx
Normal file
252
src/components/frontend/transfers/TransfersView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
src/components/frontend/transfers/transferTypes.ts
Normal file
250
src/components/frontend/transfers/transferTypes.ts
Normal 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
769
src/lib/transfers/index.ts
Normal 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;
|
||||
}
|
||||
18
src/lib/transfers/notificationTypes.ts
Normal file
18
src/lib/transfers/notificationTypes.ts
Normal 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];
|
||||
|
|
@ -81,6 +81,7 @@ export interface Config {
|
|||
awards: Award;
|
||||
qualifications: Qualification;
|
||||
assignments: Assignment;
|
||||
'assignment-transfers': AssignmentTransfer;
|
||||
experience: Experience;
|
||||
'user-notifications': UserNotification;
|
||||
evaluations: Evaluation;
|
||||
|
|
@ -142,6 +143,7 @@ export interface Config {
|
|||
awards: AwardsSelect<false> | AwardsSelect<true>;
|
||||
qualifications: QualificationsSelect<false> | QualificationsSelect<true>;
|
||||
assignments: AssignmentsSelect<false> | AssignmentsSelect<true>;
|
||||
'assignment-transfers': AssignmentTransfersSelect<false> | AssignmentTransfersSelect<true>;
|
||||
experience: ExperienceSelect<false> | ExperienceSelect<true>;
|
||||
'user-notifications': UserNotificationsSelect<false> | UserNotificationsSelect<true>;
|
||||
evaluations: EvaluationsSelect<false> | EvaluationsSelect<true>;
|
||||
|
|
@ -1599,6 +1601,7 @@ export interface GameEventLog {
|
|||
| 'evaluations'
|
||||
| 'tickets'
|
||||
| 'game-servers'
|
||||
| 'assignment-transfers'
|
||||
)
|
||||
| null;
|
||||
targetId?: number | null;
|
||||
|
|
@ -1828,6 +1831,120 @@ export interface Qualification {
|
|||
updatedAt: 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
|
||||
* via the `definition` "experience".
|
||||
|
|
@ -3077,6 +3194,10 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'assignments';
|
||||
value: number | Assignment;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'assignment-transfers';
|
||||
value: number | AssignmentTransfer;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'experience';
|
||||
value: number | Experience;
|
||||
|
|
@ -3601,6 +3722,39 @@ export interface AssignmentsSelect<T extends boolean = true> {
|
|||
updatedAt?: 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
|
||||
* via the `definition` "experience_select".
|
||||
|
|
|
|||
98
tests/int/transfer-bot-delivery.int.spec.ts
Normal file
98
tests/int/transfer-bot-delivery.int.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
569
tests/int/transfer-requests.int.spec.ts
Normal file
569
tests/int/transfer-requests.int.spec.ts
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue