1
0
Fork 0

Compare commits

...

8 commits

Author SHA1 Message Date
76e948fd59 feat: Add remaining transfer components and utilities
Add transfer workflow components and utilities:

1. Transfer types and core functions (src/lib/transfers/index.ts):
   - Type-safe transfer request and decision creation
   - Appeal, cancel, and resolve core functions
   - Server action conventions with error handling

2. Transfer UI components (src/components/frontend/transfers/):
   - TransferActionDialog.tsx: Handle approval/rejection appeals
   - TransferApprovalCard.tsx: Display approval decisions
   - TransferNoteDialog.tsx: Add notes for transfers/appeals
   - TransferRequestCard.tsx: Show transfer request details
   - TransferResolutionCard.tsx: Display superuser resolutions
   - TransfersView.tsx: Main transfers list page
   - transferTypes.ts: Shared TypeScript types

3. Notification types (src/lib/transfers/notificationTypes.ts):
   - Define notification type constants for transfer events
   - Include TRANSFER_NOTIFICATION_TYPES for bridge filtering

4. Integration tests (tests/int/):
   - transfer-requests.int.spec.ts: Test bot request polling
   - transfer-bot-delivery.int.spec.ts: Test Discord notification delivery

These components and utilities complete the transfer workflow
implementation including approvals, appeals, and delivery.
2026-08-31 23:41:29 -04:00
b9326cfc54 feat(account): Add assignment cards and current assignments display to account page
Add AssignmentCard component integration showing user's assignments with transfer button.
Add RequestTransferDialog integration for requesting transfers.
Display current assignments as badges with leader indication.
Include proper loading states and error handling.
2026-08-31 23:40:58 -04:00
f1a338c90b docs: Update design documentation and TODO list
DESIGN.md:
- Add Transfer workflow surface section documenting component structure
- Document component variants, states, accessibility guidelines, and layout requirements
- Specify shadcn UI usage and motion/interaction policies

TODO.md:
- Update assignment transfer system requirements
- Add clarification about leader requirements and fallback behavior
- Specify respawn ticket feature for future implementation

Changes reflect completed implementation details and future roadmap.
2026-08-31 23:40:45 -04:00
6c71e003c9 feat(config): Update payload config, notifications, and migrations
Update Payload configuration:
- Import AssignmentTransfers collection in src/payload.config.ts

Update notification system:
- src/lib/notifications/index.ts: Add transfer notification types
- Update notification helper to support new notification types

Update migrations index:
- src/migrations/index.ts: Register new assignment transfer migrations

These changes integrate the AssignmentTransfers collection into the
existing Payload configuration and notification infrastructure.
2026-08-31 23:40:41 -04:00
6fb8dee3f6 feat(frontend): Add assignment transfer UI components
Add React components for assignment transfer user interface:

1. AssignmentCard.tsx: Display user's current assignment with transfer button.
   Shows assignment details, current status, and allows initiating transfer requests.

2. RequestTransferDialog.tsx: Modal dialog for creating transfer requests.
   - Selects target assignment from assignment list
   - Optionally notes required for transfer
   - Submits request to AssignmentTransfers collection
   - Emits AssignmentTransferRequest event

3. Transfers page (src/app/(frontend)/transfers/): Transfer request list view.
   - Requests card showing pending requests with requestor, target, current
   - Approval cards for assignment leaders
   - Appeal cards for rejected requests
   - Completed/Cancelled status display

All components use shadcn/ui primitives, proper loading states, error handling,
and provide accessible keyboard navigation.
2026-08-31 23:40:37 -04:00
9001d5a796 feat(bot): Add assignment transfer bot services
Add Discord bot services for assignment transfer interactions:

1. transferInteractions.ts: Handle button interactions for transfer requests,
   approvals, rejections, appeals, completions, and cancellations.

2. transferRequests.ts: Poll for pending assignment transfers and trigger
   notification delivery to Discord when status changes to pending/approved/rejected.

3. transferDelivery.ts: Deliver transfer notifications to users via DMs with
   embeds showing status, details, and actions (approve/reject/appeal).

4. transferMessaging.ts: Provide Discord embed building for transfer messages
   with status badges and action buttons.

5. Update bot index.ts: Add transfer button interaction handler with customId prefix "ptf-xfer:"

6. Update notificationBridge.ts: Exclude transfer notification types from general
   notification polling to avoid duplication (Phase 2 feature).

Integration: Services are started via src/bot/services/index.ts and run alongside
existing mission and notification services.
2026-08-31 23:40:21 -04:00
a8854034d3 feat(events): Add assignment transfer event types and emit functions
Add event logging constants for assignment transfer events:
- AssignmentTransferRequest: Request initiated by user
- AssignmentTransferApprove: Leader approved transfer
- AssignmentTransferReject: Leader rejected transfer with reason
- AssignmentTransferAppeal: User appealed a rejection
- AssignmentTransferResolve: Final resolution by superuser
- AssignmentTransferComplete: Transfer completed successfully
- AssignmentTransferCancel: Transfer cancelled

Add assignment-transfers to GameEventLogs TARGET_COLLECTIONS

Add emitGameEvent support for all transfer event types in src/utils/event-log/emit.ts

This provides audit trail and notification support for the transfer workflow.
2026-08-31 23:40:08 -04:00
be459cde0f feat(collections): Add AssignmentTransfers collection and migrations
Add new AssignmentTransfers collection with fields for managing assignment transfer requests:
- requestor, requestedAssignment, currentAssignment (relationships to users/assignments)
- status (pending/approved/rejected/appealed/escalated/completed/cancelled)
- rejectionReason, appealReason (for rejections and appeals)
- transferAt, approvedAt, appealedAt, completedAt (audit timestamps)
- transferLead (relationship to users, current and requested assignment leaders)
- requestMessage, appealMessage (optional context messages)

Add database migrations:
- 20260831_180233_assignment_transfers: Create the main collection table
- 20260901_014006_add_assignment_transfer_event_target: Add assignment-transfers to GameEventLogs target collections

The collection enables the assignment transfer workflow where users can request transfers,
leaders can approve/reject with reasons, and users can appeal rejections.
2026-08-31 23:40:02 -04:00
38 changed files with 45207 additions and 6 deletions

View file

@ -123,6 +123,23 @@ Spacing follows Tailwind's 4px scale. Existing frontend stacks use `gap-1`, `gap
- **Motion**: control transition only.
- **Layout**: wraps on narrow screens without horizontal overflow.
### Transfer workflow surface
- **Structure**: the account `AssignmentCard` owns current-assignment context and opens the
`RequestTransferDialog`; `/transfers` uses a `stack` of `Card` sections containing request,
approval, and escalated-resolution cards.
- **Variants**: request cards, leader approval cards, and superuser resolution cards share the
neutral card surface; status meaning is carried by the existing `Badge` variants.
- **States**: loading, empty, pending, approved, rejected, appealed, escalated, completed,
cancelled, denied, error, and success feedback.
- **Accessibility**: every dialog has a visible title and description, every textarea has a label,
rejection requires a non-empty reason, and destructive actions remain keyboard reachable with
the existing focus ring.
- **Motion**: use the existing shadcn dialog, button, and toast transitions only; no decorative
section animation. Async buttons expose their pending state and disable competing actions.
- **Layout**: the transfer list is a single document scroll region inside the authenticated shell;
cards use an intrinsic one-column-to-two-column grid and remain readable at narrow widths.
## 6. Motion & Interaction
### Timing

View file

@ -4,10 +4,11 @@
- [x] **Leadership evaluations and performance rating system** - Members of the unit should be able to rate the performance of their direct team leaders (full performance rating) and submit opinions on separate leaders (a more limited rating system for non-direct supervisors.) These ratings should appear anonymously on the leader's service record page. The ratings are divided by mission, so that they can accumulate over time to form a more accurate representation of the overall delta of a leader's performance over the course of multiple operations. These ratings appear as bars, red-to-green gradients, and percentage scores on the service record page. A small table showing the latest missions and the leader's overall rating are also visible below this bar. Additionally, leaders should be able to rate the performance of their subordinates. However, these ratings appear in a far more limited fashion on the subordinate's service record page. Instead of a breakdown with bars, percentages, latest missions, etc., they instead just get a text label indicating a summary of their performance from leadership. For example, if a subordinate is rated very highly by their leaders, their text label might say "OPERATOR PERFORMANCE LEVEL: EXCELLENT" in green text while a moderate review may say "OPERATOR PERFORMANCE LEVEL: MODERATE" in grey or blue text and a low rating might say "OPERATOR PERFORMANCE LEVEL: UNDER REVIEW" in red text. Preferably, there'd be at least 5 levels of performance instead of the 3 I used as examples.
- [ ] **Ribbon overview and listing page** - There should be a page that lists all of the medals and ribbons that could be granted to a user (excluding commendations, which could arise on the fly and are not set in stone.) This would allow users to review the requirements for earning a specific medal or ribbon, and get to see the art of that award as well, if any.
- [ ] **Assignment transfer request system** - Allow users to request transfers to new assignments (i.e. transfer from infantry to aviation, medical to infantry, medical to aviation, infantry to intel, logistics to infantry, etc., covering all cases). The leader of the user's current assignment and the leader of the user's requested assignment should be notified of the request via app and via bot, and should have the option to "approve" or "reject with reason" in either interface. If both leaders approve, the assignment is automatically transferred and updated everywhere. If one leader rejects it, the rejection reason is sent to the other leader and to the individual making the request, and the individual is given an option to appeal via the app or the bot. If they appeal, both leaders are bypassed and the request goes directly to me (or a superuser) who can then make the final call.
- [ ] **Assignment transfer request system** - Allow users to request transfers to new assignments (i.e. transfer from infantry to aviation, medical to infantry, medical to aviation, infantry to intel, logistics to infantry, etc., covering all cases). The leader of the user's current assignment and the leader of the user's requested assignment should be notified of the request via app and via bot, and should have the option to "approve" or "reject with reason" in either interface. Approvals are only collected from assignments that actually have a leader: if the user's current assignment has no leader (e.g. it lost its leader), only the requested assignment's leader needs to approve — and vice versa if the requested assignment is leaderless. If neither assignment has a leader, skip the leaders entirely and send the request directly to me (or a superuser) for a final call. Once all required approvals are in, the assignment is automatically transferred and updated everywhere. If any required approval is rejected, the rejection reason is sent to the other leader (if one exists) and to the individual making the request, and the individual is given an option to appeal via the app or the bot — this applies even when only one approval was required. If they appeal, all remaining leaders are bypassed and the request goes directly to me (or a superuser) who can then make the final call.
- [ ] **Scoped admin UI pages** - The admin UI is fantastic, but it needs to be scoped so that users can only see and interact with certain pages even if they have read permissions for any/all pages. For example, if a user has read permissions for the Users collection, they can't just see the admin page for it - they need something like "admin:users:manage" or something like that (based on whatever format fits our current system best.) So long as they have BOTH the read and manage permissions, they can see the admin page. Otherwise, it's just hidden from them entirely.
- [ ] **Editable helpdesk tickets & user voting** - Having helpdesk tickets be editable by their authors after the fact would be very helpful. This would have to be auditable, of course. In addition, allow users to upvote specific tickets would help to prioritize desired features, pressing bugs, or other tickets wherein there is a shared desire for work from the userbase.
- [ ] **"Community" navigation group** - A "Community" nav group in the sidebar with subpages like "Contacts (NPCs)", "Statistics", etc., would be really helpful for future updates wherein the players will have interactions with NPCs via the market, in general conversation, long-standing faction interactions, etc.
- [ ] **Respawn tickets based on attendance** - I want to track respawn tickets as a "global variable" that is automatically incremented based on reported attendance for a mission compared against real attendance checked via the webapp<->Arma sync system. For every person who marks that they will attend and DOES attend, 1 respawn ticket is awarded. For every person who marks they MIGHT attend and DOES attend, 0.5 respawn tickets will be awarded. For every person who marks that they will NOT attend and does attend, 0.25 respawn tickets will be awarded. All respawn tickets are accumulated and applied to the next operation.
## Desirable Additions, Medium Priority

View file

@ -2,17 +2,27 @@ import config from "@payload-config";
import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import type { Assignment } from "@/payload-types";
import { UserCogIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { AccountProfileForm } from "@/components/frontend/account/AccountProfileForm";
import { AssignmentCard } from "@/components/frontend/account/AssignmentCard";
import type { AccountAssignment } from "@/components/frontend/transfers/transferTypes";
import { ChangePasswordForm } from "@/components/frontend/account/ChangePasswordForm";
import { DiscordUsernameRequestForm } from "@/components/frontend/account/DiscordUsernameRequestForm";
import { PreferencesForm } from "@/components/frontend/account/PreferencesForm";
import { RequestTransferDialog } from "@/components/frontend/account/RequestTransferDialog";
export const metadata = {
title: "Account — Polaris Task Force",
};
function relationId(value: Assignment["leader"]): number | null {
if (typeof value === "object" && value !== null) return value.id;
return value ?? null;
}
export default async function AccountPage() {
const payload = await getPayload({ config });
const { user } = await payload.auth({
@ -30,6 +40,46 @@ export default async function AccountPage() {
depth: 0,
overrideAccess: true,
});
const assignmentsRes = await payload.find({
collection: "assignments",
where: {
or: [{ members: { equals: user.id } }, { leader: { equals: user.id } }],
},
limit: 50,
depth: 0,
select: { name: true, type: true, callsign: true, leader: true },
});
const assignments: AccountAssignment[] = assignmentsRes.docs.map((assignment) => ({
id: assignment.id,
name: assignment.name,
type: assignment.type,
callsign: assignment.callsign ?? null,
leaderId:
typeof assignment.leader === "object" && assignment.leader !== null
? assignment.leader.id
: (assignment.leader ?? null),
isLeader: relationId(assignment.leader) === user.id,
}));
const assignmentRes = await payload.find({
collection: "assignments",
where: {
or: [{ members: { contains: user.id } }, { leader: { equals: user.id } }],
},
sort: "name",
limit: 100,
depth: 0,
select: { name: true, type: true, callsign: true, leader: true },
});
const currentAssignments: AccountAssignment[] = assignmentRes.docs.map((assignment) => ({
id: assignment.id,
name: assignment.name,
type: assignment.type,
callsign: assignment.callsign ?? null,
leaderId: relationId(assignment.leader),
isLeader: relationId(assignment.leader) === user.id,
}));
const notifications = fullUser.preferences?.notifications;
const discord = fullUser.preferences?.discord;
@ -57,6 +107,42 @@ export default async function AccountPage() {
</CardContent>
</Card>
<AssignmentCard assignments={assignments} requesterId={user.id} />
<Card>
<CardHeader className="gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<CardTitle>Assignment</CardTitle>
<CardDescription>Review your current assignment and request a move.</CardDescription>
</div>
<RequestTransferDialog
currentAssignments={currentAssignments}
currentUserId={fullUser.id}
/>
</div>
</CardHeader>
<CardContent>
{currentAssignments.length > 0 ? (
<div className="flex flex-wrap gap-2" aria-label="Current assignments">
{currentAssignments.map((assignment) => (
<Badge key={assignment.id} variant="outline">
{assignment.callsign
? `${assignment.name} · ${assignment.callsign}`
: assignment.name}
{assignment.isLeader ? " · Leader" : ""}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
You are not currently assigned to a unit. Ask leadership to add you before
requesting a transfer.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Password</CardTitle>

View file

@ -0,0 +1,156 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import type { AssignmentTransfer } from "@/payload-types";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import {
appealTransfer as appealTransferCore,
cancelTransfer as cancelTransferCore,
createTransferRequest,
recordTransferDecision,
resolveAppeal as resolveAppealCore,
} from "@/lib/transfers";
export interface ActionResult<T = undefined> {
readonly success: boolean;
readonly error?: string;
readonly data?: T;
}
/**
* Web action mapping: requestTransfer → createTransferRequest, approveTransfer and
* rejectTransfer → recordTransferDecision, appealTransfer → appealTransfer,
* resolveAppeal → resolveAppeal, and cancelTransfer → cancelTransfer. The authenticated
* user supplies each core function's actorUserId/requesterId.
*/
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!isPayloadUser(user)) {
throw new Error("Unauthorized");
}
return { payload, user };
}
function actionFailure<T>(caughtError: unknown): ActionResult<T> {
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
return {
success: false,
error: message === "Unauthorized" ? "You must be logged in." : message,
};
}
export async function requestTransfer(input: {
readonly fromAssignmentId: number;
readonly toAssignmentId: number;
readonly note?: string;
}): Promise<ActionResult<AssignmentTransfer>> {
try {
const { payload, user } = await authenticate();
const transfer = await createTransferRequest(payload, {
requesterId: user.id,
fromAssignmentId: input.fromAssignmentId,
toAssignmentId: input.toAssignmentId,
...(input.note !== undefined ? { note: input.note } : {}),
});
return { success: true, data: transfer };
} catch (caughtError) {
return actionFailure<AssignmentTransfer>(caughtError);
}
}
export async function approveTransfer(
requestId: number,
side: "from" | "to",
): Promise<ActionResult<AssignmentTransfer>> {
try {
const { payload, user } = await authenticate();
const transfer = await recordTransferDecision(payload, {
requestId,
side,
decision: "approved",
actorUserId: user.id,
});
return { success: true, data: transfer };
} catch (caughtError) {
return actionFailure<AssignmentTransfer>(caughtError);
}
}
export async function rejectTransfer(
requestId: number,
side: "from" | "to",
reason: string,
): Promise<ActionResult<AssignmentTransfer>> {
try {
const { payload, user } = await authenticate();
const transfer = await recordTransferDecision(payload, {
requestId,
side,
decision: "rejected",
reason,
actorUserId: user.id,
});
return { success: true, data: transfer };
} catch (caughtError) {
return actionFailure<AssignmentTransfer>(caughtError);
}
}
export async function appealTransfer(
requestId: number,
note?: string,
): Promise<ActionResult<AssignmentTransfer>> {
try {
const { payload, user } = await authenticate();
const transfer = await appealTransferCore(payload, {
requestId,
actorUserId: user.id,
...(note !== undefined ? { note } : {}),
});
return { success: true, data: transfer };
} catch (caughtError) {
return actionFailure<AssignmentTransfer>(caughtError);
}
}
export async function resolveAppeal(
requestId: number,
approve: boolean,
note?: string,
): Promise<ActionResult<AssignmentTransfer>> {
try {
const { payload, user } = await authenticate();
const transfer = await resolveAppealCore(payload, {
requestId,
approve,
actorUserId: user.id,
...(note !== undefined ? { note } : {}),
});
return { success: true, data: transfer };
} catch (caughtError) {
return actionFailure<AssignmentTransfer>(caughtError);
}
}
export async function cancelTransfer(
requestId: number,
): Promise<ActionResult<AssignmentTransfer>> {
try {
const { payload, user } = await authenticate();
const transfer = await cancelTransferCore(payload, {
requestId,
actorUserId: user.id,
});
return { success: true, data: transfer };
} catch (caughtError) {
return actionFailure<AssignmentTransfer>(caughtError);
}
}

View file

@ -0,0 +1,42 @@
import config from "@payload-config";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { getPayload } from "payload";
import { ArrowLeftRightIcon } from "lucide-react";
import { TransfersView } from "@/components/frontend/transfers/TransfersView";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { isSuperuser } from "@/utils/access-control/hasPermission";
export const metadata = {
title: "Transfers — Polaris Task Force",
};
export default async function TransfersPage() {
const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({
headers: await nextHeaders(),
canSetHeaders: false,
});
if (!isPayloadUser(authUser)) {
redirect("/");
}
const canResolve = await isSuperuser(payload, authUser);
return (
<div className="flex flex-col gap-6 p-5">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ArrowLeftRightIcon className="size-5 text-muted-foreground" aria-hidden="true" />
<h1 className="text-lg font-semibold">Transfers</h1>
</div>
<p className="text-sm text-muted-foreground">
Request an assignment move, review approvals, and resolve escalated appeals.
</p>
</div>
<TransfersView currentUserId={authUser.id} canResolve={canResolve} />
</div>
);
}

View file

@ -0,0 +1,220 @@
import {
type ButtonInteraction,
LabelBuilder,
MessageFlags,
ModalBuilder,
type ModalSubmitInteraction,
TextInputBuilder,
TextInputStyle,
} from "discord.js";
import type { Payload } from "payload";
import { findUserByDiscordId } from "@/bot/lib/resolve";
import {
decisionOutcomeEmbed,
findLeaderSide,
relId,
transferDecisionRow,
appealRow,
type DecisionOutcome,
} from "@/bot/lib/transferMessaging";
import { appealTransfer, recordTransferDecision, type TransferSide } from "@/lib/transfers";
type TransferInteraction = ButtonInteraction | ModalSubmitInteraction;
type TransferAction = "approve" | "reject" | "appeal";
type ParsedTransferInteraction = {
readonly requestId: number;
readonly action: TransferAction;
};
const REJECTION_REASON_ID = "rejectionReason";
const parseTransferInteraction = (customId: string): ParsedTransferInteraction | null => {
const [prefix, rawId, action, extra] = customId.split(":");
const requestId = Number(rawId);
if (
prefix !== "ptf-xfer" ||
extra !== undefined ||
!Number.isInteger(requestId) ||
(action !== "approve" && action !== "reject" && action !== "appeal")
) {
return null;
}
return { requestId, action };
};
const replyEphemeral = (interaction: TransferInteraction, content: string): Promise<unknown> =>
interaction.reply({ content, flags: MessageFlags.Ephemeral });
const currentLeaderSide = async (
payload: Payload,
requestId: number,
actorUserId: number,
): Promise<TransferSide | string> => {
const request = await payload.findByID({
collection: "assignment-transfers",
id: requestId,
depth: 0,
overrideAccess: true,
});
const [fromAssignment, toAssignment] = await Promise.all([
payload.findByID({
collection: "assignments",
id: relId(request.fromAssignment),
depth: 0,
overrideAccess: true,
}),
payload.findByID({
collection: "assignments",
id: relId(request.toAssignment),
depth: 0,
overrideAccess: true,
}),
]);
const check = findLeaderSide(
request,
fromAssignment.leader == null ? null : relId(fromAssignment.leader),
toAssignment.leader == null ? null : relId(toAssignment.leader),
actorUserId,
);
if (check.outcome === "authorized") return check.side;
if (check.outcome === "not_pending") return "This transfer request is already resolved.";
if (check.outcome === "already_decided") {
return "You have already recorded your decision on this transfer request.";
}
return "You are no longer the current leader for a pending side of this transfer request.";
};
type OutcomeEdit = {
readonly interaction: TransferInteraction;
readonly requestId: number;
readonly outcome: DecisionOutcome;
readonly status: string;
readonly reason?: string;
};
const editOutcome = async ({
interaction,
requestId,
outcome,
status,
reason,
}: OutcomeEdit): Promise<void> => {
if (!interaction.message) return;
const row =
outcome === "appealed"
? appealRow(requestId, true)
: transferDecisionRow(requestId, true);
await interaction.message.edit({
embeds: [decisionOutcomeEmbed(outcome, status, reason)],
components: [row],
});
};
const showRejectModal = async (interaction: ButtonInteraction, requestId: number): Promise<void> => {
const reason = new TextInputBuilder()
.setCustomId(REJECTION_REASON_ID)
.setStyle(TextInputStyle.Paragraph)
.setRequired(true)
.setMaxLength(1000);
const modal = new ModalBuilder()
.setCustomId(`ptf-xfer:${requestId}:reject`)
.setTitle("Reject assignment transfer")
.addLabelComponents(
new LabelBuilder().setLabel("Reason for rejection").setTextInputComponent(reason),
);
await interaction.showModal(modal);
};
export const handleTransferInteraction = async (
interaction: TransferInteraction,
payload: Payload,
): Promise<void> => {
const parsed = parseTransferInteraction(interaction.customId);
if (parsed === null) {
await replyEphemeral(interaction, "This transfer action is invalid or expired.");
return;
}
try {
const user = await findUserByDiscordId(payload, interaction.user.id);
if (user === null) {
await replyEphemeral(interaction, "Run /signup or /link before responding to transfers.");
return;
}
if (parsed.action === "appeal") {
const updated = await appealTransfer(payload, {
requestId: parsed.requestId,
actorUserId: user.id,
});
await editOutcome({
interaction,
requestId: parsed.requestId,
outcome: "appealed",
status: updated.status,
});
await replyEphemeral(interaction, "Your appeal was submitted for a superuser decision.");
return;
}
const side = await currentLeaderSide(payload, parsed.requestId, user.id);
if (side !== "from" && side !== "to") {
await replyEphemeral(interaction, side);
return;
}
if (parsed.action === "reject" && interaction.isButton()) {
await showRejectModal(interaction, parsed.requestId);
return;
}
if (parsed.action === "reject" && interaction.isModalSubmit()) {
const reason = interaction.fields.getTextInputValue(REJECTION_REASON_ID).trim();
if (reason.length === 0) {
await replyEphemeral(interaction, "A rejection reason is required.");
return;
}
const updated = await recordTransferDecision(payload, {
requestId: parsed.requestId,
side,
decision: "rejected",
reason,
actorUserId: user.id,
});
await editOutcome({
interaction,
requestId: parsed.requestId,
outcome: "rejected",
status: updated.status,
reason,
});
await replyEphemeral(interaction, "Your rejection was recorded.");
return;
}
if (parsed.action === "approve") {
const updated = await recordTransferDecision(payload, {
requestId: parsed.requestId,
side,
decision: "approved",
actorUserId: user.id,
});
await editOutcome({
interaction,
requestId: parsed.requestId,
outcome: "approved",
status: updated.status,
});
await replyEphemeral(interaction, "Your approval was recorded.");
return;
}
await replyEphemeral(interaction, "This transfer action is invalid or expired.");
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
payload.logger.error(
`[Bot] Transfer interaction failed for request #${parsed.requestId}: ${message}`,
);
if (!interaction.replied && !interaction.deferred) {
await replyEphemeral(interaction, message);
}
}
};

View file

@ -5,6 +5,7 @@ import { botConfig } from "./config";
import { commands } from "./commands";
import { services } from "./services";
import { handleButtonInteraction } from "./events/interactionCreate";
import { handleTransferInteraction } from "./events/transferInteractions";
// Usage: `bun run bot`. Standalone long-running process; connects to Discord
// and shares the Postgres database with the web app via the Payload config.
@ -72,6 +73,13 @@ const main = async () => {
await handleButtonInteraction(interaction, payload);
return;
}
if (
(interaction.isButton() || interaction.isModalSubmit()) &&
interaction.customId.startsWith("ptf-xfer:")
) {
await handleTransferInteraction(interaction, payload);
return;
}
if (!interaction.isChatInputCommand()) {
return;
}

View file

@ -0,0 +1,223 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
import type { AssignmentTransfer, User } from "@/payload-types";
import type { TransferSide } from "@/lib/transfers";
/**
* Pure delivery-ledger, authorization pre-check, and rendering helpers for the
* assignment-transfer Discord workflow. No Payload/client imports and no
* botConfig import (it throws at import time when Discord credentials are
* absent) — the polling service (services/transferRequests.ts) and the
* interaction handler (events/transferInteractions.ts) build on these, and
* tests/int/transfer-bot-delivery.int.spec.ts exercises them without a
* database or Discord connection. `appUrl` is always passed in by callers.
*/
// ---------------------------------------------------------------------------
// Delivery ledger — persisted in AssignmentTransfer.discordDeliveredKeys
// ---------------------------------------------------------------------------
/** One key per DM type; key membership makes every send idempotent across bot restarts. */
export const TRANSFER_DELIVERY_KEYS = {
requestFrom: "request:from",
requestTo: "request:to",
rejectedRequester: "rejected:requester",
escalated: "escalated",
completed: "completed",
cancelled: "cancelled",
resolved: "resolved",
} as const;
export type TransferDeliveryKey = (typeof TRANSFER_DELIVERY_KEYS)[keyof typeof TRANSFER_DELIVERY_KEYS];
export const deliveryKeyForSide = (side: TransferSide): TransferDeliveryKey =>
side === "from" ? TRANSFER_DELIVERY_KEYS.requestFrom : TRANSFER_DELIVERY_KEYS.requestTo;
/** Narrow the JSON `discordDeliveredKeys` field to a safe string[]. */
export const deliveredKeys = (doc: AssignmentTransfer): string[] => {
const raw = doc.discordDeliveredKeys;
return Array.isArray(raw) ? raw.filter((key): key is string => typeof key === "string") : [];
};
/** Coerce a Payload relationship value (numeric id or resolved doc) to its numeric id. */
export const relId = (value: number | { id: number }): number =>
typeof value === "number" ? value : value.id;
const notifiedToId = (value: number | User | null | undefined): number | null =>
typeof value === "number" ? value : (value?.id ?? null);
/**
* Leader-request DM idempotency contract: a side's request DM is due unless its
* key was already delivered AND the user it was delivered to is still the
* current leader of that side. A leadership change after a send makes it due
* again (fresh DM to the new leader; the old DM's buttons fail authorization
* with an ephemeral reply).
*/
export const isLeaderDeliveryDue = (
doc: AssignmentTransfer,
side: TransferSide,
currentLeaderId: number,
): boolean => {
if (!deliveredKeys(doc).includes(deliveryKeyForSide(side))) return true;
const notifiedTo = side === "from" ? doc.sourceNotifiedToUserId : doc.destinationNotifiedToUserId;
return notifiedToId(notifiedTo) !== currentLeaderId;
};
// ---------------------------------------------------------------------------
// Interaction authorization pre-check
// ---------------------------------------------------------------------------
export type LeaderSideCheck =
| { outcome: "authorized"; side: TransferSide }
| { outcome: "not_pending" }
| { outcome: "already_decided" }
| { outcome: "not_leader" };
/**
* Which side (if any) of a pending transfer the actor currently leads and can
* still decide on. The customId `ptf-xfer:{id}:{action}` carries no side, so
* the interaction handler re-fetches the current leaders of both assignments
* and uses this to pick the `side` argument for the lib's
* recordTransferDecision — which re-checks authorization server-side as the
* source of truth. This pre-check exists to give stale/non-leader clicks a
* precise ephemeral reply and to avoid showing the reject modal to someone who
* cannot use it.
*/
export const findLeaderSide = (
doc: AssignmentTransfer,
fromLeaderId: number | null,
toLeaderId: number | null,
actorUserId: number,
): LeaderSideCheck => {
if (doc.status !== "pending") return { outcome: "not_pending" };
const sides = [
{ side: "from" as const, leaderId: fromLeaderId, decision: doc.fromLeaderDecision },
{ side: "to" as const, leaderId: toLeaderId, decision: doc.toLeaderDecision },
];
for (const side of sides) {
if (side.leaderId === actorUserId && side.decision === "pending") {
return { outcome: "authorized", side: side.side };
}
}
if (sides.some((side) => side.leaderId === actorUserId)) {
return { outcome: "already_decided" };
}
return { outcome: "not_leader" };
};
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
export const TRANSFER_EMBED_COLORS = {
info: 0x3b82f6,
rejected: 0xef4444,
completed: 0x22c55e,
escalated: 0xf59e0b,
closed: 0x6b7280,
} as const;
export interface TransferContext {
requestId: number;
requesterLabel: string;
fromName: string;
toName: string;
note?: string | null;
appUrl: string;
}
const transfersLink = (appUrl: string): string => `${appUrl}/transfers`;
const requestLines = (ctx: TransferContext): string[] => [
`**Requester:** ${ctx.requesterLabel}`,
`**From:** ${ctx.fromName}`,
`**To:** ${ctx.toName}`,
...(ctx.note?.trim() ? [`**Note:** ${ctx.note.trim()}`] : []),
];
/** DM embed for a pending request, sent to each required side's current leader. */
export const requestEmbed = (ctx: TransferContext): EmbedBuilder =>
new EmbedBuilder()
.setTitle(`Transfer request #${ctx.requestId} needs your approval`)
.setDescription(requestLines(ctx).join("\n"))
.setColor(TRANSFER_EMBED_COLORS.info)
.addFields({ name: "Decide on the web", value: transfersLink(ctx.appUrl) });
/** DM embed for a rejected request, sent to the requester (with an Appeal button). */
export const rejectedRequesterEmbed = (ctx: TransferContext, reason: string): EmbedBuilder =>
new EmbedBuilder()
.setTitle(`Transfer request #${ctx.requestId} rejected`)
.setDescription([...requestLines(ctx), `**Reason:** ${reason}`].join("\n"))
.setColor(TRANSFER_EMBED_COLORS.rejected)
.addFields({ name: "Appeal or view details", value: transfersLink(ctx.appUrl) });
/** Plain informational DM embed (no buttons) for escalated/terminal outcomes. */
export const informationalEmbed = (
title: string,
description: string,
appUrl: string,
color: number = TRANSFER_EMBED_COLORS.info,
): EmbedBuilder =>
new EmbedBuilder()
.setTitle(title)
.setDescription(description)
.setColor(color)
.addFields({ name: "Open on the web", value: transfersLink(appUrl) });
export type DecisionOutcome = "approved" | "rejected" | "appealed";
/**
* Replacement embed edited into a DM after its button was used, so the message
* reflects the outcome instead of still soliciting a decision.
*/
export const decisionOutcomeEmbed = (
outcome: DecisionOutcome,
status: string,
reason?: string,
): EmbedBuilder => {
const summary: Record<DecisionOutcome, string> = {
approved: "You approved this transfer request.",
rejected: `You rejected this transfer request.${reason ? ` Reason: "${reason}"` : ""}`,
appealed: "You appealed this rejection. A superuser will make the final call.",
};
const color =
outcome === "approved"
? TRANSFER_EMBED_COLORS.completed
: outcome === "rejected"
? TRANSFER_EMBED_COLORS.rejected
: TRANSFER_EMBED_COLORS.escalated;
return new EmbedBuilder()
.setTitle("Decision recorded")
.setDescription(`${summary[outcome]}\n**Current status:** ${status}`)
.setColor(color);
};
// ---------------------------------------------------------------------------
// Buttons — customId contract `ptf-xfer:{requestId}:{action}`, routed on the
// "ptf-xfer:" prefix in src/bot/index.ts
// ---------------------------------------------------------------------------
export const transferDecisionRow = (
requestId: number,
disabled = false,
): ActionRowBuilder<ButtonBuilder> =>
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`ptf-xfer:${requestId}:approve`)
.setStyle(ButtonStyle.Success)
.setLabel("Approve")
.setDisabled(disabled),
new ButtonBuilder()
.setCustomId(`ptf-xfer:${requestId}:reject`)
.setStyle(ButtonStyle.Danger)
.setLabel("Reject")
.setDisabled(disabled),
);
export const appealRow = (requestId: number, disabled = false): ActionRowBuilder<ButtonBuilder> =>
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`ptf-xfer:${requestId}:appeal`)
.setStyle(ButtonStyle.Secondary)
.setLabel("Appeal")
.setDisabled(disabled),
);

View file

@ -2,7 +2,12 @@ import type { Client } from "discord.js";
import type { Payload } from "payload";
import { start as startMissionEmbeds } from "./missionEmbeds";
import { start as startNotificationBridge } from "./notificationBridge";
import { start as startTransferRequests } from "./transferRequests";
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
export const services: ServiceStarter[] = [startMissionEmbeds, startNotificationBridge];
export const services: ServiceStarter[] = [
startMissionEmbeds,
startNotificationBridge,
startTransferRequests,
];

View file

@ -2,11 +2,16 @@ import { type Client, EmbedBuilder } from "discord.js";
import type { Payload } from "payload";
import type { User, UserNotification } from "@/payload-types";
import { botConfig } from "@/bot/config";
import { TRANSFER_NOTIFICATION_TYPES } from "@/lib/transfers/notificationTypes";
const MAX_SENDS_PER_TICK = 5;
const SEND_DELAY_MS = 300;
const EMBED_COLOR = 0x3b82f6;
// Transfer notifications are delivered by the dedicated transfer bot service
// (Phase 2), not this generic bridge. Exclude exactly these types.
const NON_BRIDGED_TYPES = new Set<string>(Object.values(TRANSFER_NOTIFICATION_TYPES));
let cursor: number | null = null;
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
@ -54,6 +59,7 @@ const poll = async (client: Client, payload: Payload): Promise<void> => {
processed++;
cursor = notification.id;
if (sent >= MAX_SENDS_PER_TICK) break;
if (notification.type && NON_BRIDGED_TYPES.has(notification.type)) continue;
try {
const owner = (await payload.findByID({

View file

@ -0,0 +1,181 @@
import type { Client } from "discord.js";
import type { Payload } from "payload";
import { botConfig } from "@/bot/config";
import {
appealRow,
deliveredKeys,
deliveryKeyForSide,
informationalEmbed,
isLeaderDeliveryDue,
rejectedRequesterEmbed,
relId,
requestEmbed,
transferDecisionRow,
TRANSFER_DELIVERY_KEYS,
type TransferContext,
type TransferDeliveryKey,
} from "@/bot/lib/transferMessaging";
import type { Assignment, AssignmentTransfer, User } from "@/payload-types";
import type { TransferSide } from "@/lib/transfers";
const userLabel = (user: User): string => user.displayName || user.username;
const eligibleForDm = (user: User): user is User & { discordId: string } =>
typeof user.discordId === "string" &&
user.discordId.length > 0 &&
user.preferences?.discord?.enabled === true;
const appendKey = (doc: AssignmentTransfer, key: TransferDeliveryKey): string[] => [
...new Set([...deliveredKeys(doc), key]),
];
const fetchUser = (payload: Payload, id: number): Promise<User> =>
payload.findByID({ collection: "users", id, depth: 0, overrideAccess: true });
const fetchAssignment = (payload: Payload, id: number): Promise<Assignment> =>
payload.findByID({ collection: "assignments", id, depth: 0, overrideAccess: true });
const contextFor = async (
payload: Payload,
doc: AssignmentTransfer,
): Promise<TransferContext> => {
const [requester, fromAssignment, toAssignment] = await Promise.all([
fetchUser(payload, relId(doc.requester)),
fetchAssignment(payload, relId(doc.fromAssignment)),
fetchAssignment(payload, relId(doc.toAssignment)),
]);
return {
requestId: doc.id,
requesterLabel: userLabel(requester),
fromName: fromAssignment.name,
toName: toAssignment.name,
note: doc.requestNote,
appUrl: botConfig.appUrl,
};
};
type LeaderDelivery = {
readonly client: Client;
readonly payload: Payload;
readonly doc: AssignmentTransfer;
readonly side: TransferSide;
};
export const deliverLeaderRequest = async ({
client,
payload,
doc,
side,
}: LeaderDelivery): Promise<void> => {
const decision = side === "from" ? doc.fromLeaderDecision : doc.toLeaderDecision;
if (decision !== "pending") return;
const assignmentId = relId(side === "from" ? doc.fromAssignment : doc.toAssignment);
const assignment = await fetchAssignment(payload, assignmentId);
const leaderId = assignment.leader == null ? null : relId(assignment.leader);
if (leaderId === null || !isLeaderDeliveryDue(doc, side, leaderId)) return;
const leader = await fetchUser(payload, leaderId);
if (!eligibleForDm(leader)) return;
const target = await client.users.fetch(leader.discordId);
const message = await target.send({
embeds: [requestEmbed(await contextFor(payload, doc))],
components: [transferDecisionRow(doc.id)],
});
const key = deliveryKeyForSide(side);
await payload.update({
collection: "assignment-transfers",
id: doc.id,
data:
side === "from"
? {
sourceNotifiedToUserId: leaderId,
discordFromLeaderMessageId: message.id,
discordDeliveredKeys: appendKey(doc, key),
}
: {
destinationNotifiedToUserId: leaderId,
discordToLeaderMessageId: message.id,
discordDeliveredKeys: appendKey(doc, key),
},
overrideAccess: true,
});
};
type RequesterDelivery = Omit<LeaderDelivery, "side">;
export const deliverRequesterRejection = async ({
client,
payload,
doc,
}: RequesterDelivery): Promise<void> => {
const key = TRANSFER_DELIVERY_KEYS.rejectedRequester;
if (doc.status !== "rejected" || deliveredKeys(doc).includes(key)) return;
const requester = await fetchUser(payload, relId(doc.requester));
if (!eligibleForDm(requester)) return;
const target = await client.users.fetch(requester.discordId);
const message = await target.send({
embeds: [
rejectedRequesterEmbed(
await contextFor(payload, doc),
doc.rejectionReason ?? "No reason was provided.",
),
],
components: [appealRow(doc.id)],
});
await payload.update({
collection: "assignment-transfers",
id: doc.id,
data: {
discordRequesterMessageId: message.id,
discordDeliveredKeys: appendKey(doc, key),
},
overrideAccess: true,
});
};
export const fixedParticipantIds = (doc: AssignmentTransfer): number[] =>
[doc.requester, doc.fromLeaderSnapshot, doc.toLeaderSnapshot]
.flatMap((relation) => (relation == null ? [] : [relId(relation)]))
.filter((id, index, ids) => ids.indexOf(id) === index);
type InformationDelivery = RequesterDelivery & {
readonly key: TransferDeliveryKey;
readonly title: string;
readonly description: string;
readonly recipientIds: readonly number[];
readonly color: number;
};
export const deliverInformation = async ({
client,
payload,
doc,
key,
title,
description,
recipientIds,
color,
}: InformationDelivery): Promise<void> => {
if (deliveredKeys(doc).includes(key)) return;
let sent = false;
for (const recipientId of recipientIds) {
const recipient = await fetchUser(payload, recipientId);
if (!eligibleForDm(recipient)) continue;
const target = await client.users.fetch(recipient.discordId);
await target.send({ embeds: [informationalEmbed(title, description, botConfig.appUrl, color)] });
sent = true;
}
if (!sent) return;
await payload.update({
collection: "assignment-transfers",
id: doc.id,
data: { discordDeliveredKeys: appendKey(doc, key) },
overrideAccess: true,
});
};

View file

@ -0,0 +1,134 @@
import type { Client } from "discord.js";
import type { Payload } from "payload";
import { botConfig } from "@/bot/config";
import {
TRANSFER_DELIVERY_KEYS,
TRANSFER_EMBED_COLORS,
} from "@/bot/lib/transferMessaging";
import {
deliverInformation,
deliverLeaderRequest,
deliverRequesterRejection,
fixedParticipantIds,
} from "./transferDelivery";
import type { AssignmentTransfer } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles";
let pollRunning = false;
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : "Unknown error";
const processTransfer = async (
client: Client,
payload: Payload,
doc: AssignmentTransfer,
): Promise<void> => {
if (doc.status === "pending") {
await deliverLeaderRequest({ client, payload, doc, side: "from" });
const fresh = await payload.findByID({
collection: "assignment-transfers",
id: doc.id,
depth: 0,
overrideAccess: true,
});
await deliverLeaderRequest({ client, payload, doc: fresh, side: "to" });
return;
}
if (doc.status === "rejected") {
await deliverRequesterRejection({ client, payload, doc });
return;
}
if (doc.status === "awaiting_superuser") {
const admins = await payload.find({
collection: "users",
limit: 200,
depth: 0,
overrideAccess: true,
});
await deliverInformation({
client,
payload,
doc,
key: TRANSFER_DELIVERY_KEYS.escalated,
title: `Transfer request #${doc.id} needs a final call`,
description: "This request is awaiting an admin or developer decision on the web.",
recipientIds: admins.docs
.filter((user) => hasRoles(["admin", "developer"], user))
.map((user) => user.id),
color: TRANSFER_EMBED_COLORS.escalated,
});
return;
}
const participants = fixedParticipantIds(doc);
if (doc.status === "completed") {
await deliverInformation({
client,
payload,
doc,
key: TRANSFER_DELIVERY_KEYS.completed,
title: `Transfer request #${doc.id} completed`,
description: "The assignment transfer is complete.",
recipientIds: participants,
color: TRANSFER_EMBED_COLORS.completed,
});
} else if (doc.status === "cancelled") {
await deliverInformation({
client,
payload,
doc,
key: TRANSFER_DELIVERY_KEYS.cancelled,
title: `Transfer request #${doc.id} cancelled`,
description: "The requester cancelled this assignment transfer.",
recipientIds: participants,
color: TRANSFER_EMBED_COLORS.closed,
});
} else if (doc.status === "denied") {
await deliverInformation({
client,
payload,
doc,
key: TRANSFER_DELIVERY_KEYS.resolved,
title: `Transfer request #${doc.id} denied`,
description: "A superuser denied this assignment transfer.",
recipientIds: participants,
color: TRANSFER_EMBED_COLORS.rejected,
});
}
};
export const pollTransferRequests = async (client: Client, payload: Payload): Promise<void> => {
if (pollRunning) return;
pollRunning = true;
try {
const transfers = await payload.find({
collection: "assignment-transfers",
sort: "id",
limit: 200,
depth: 0,
overrideAccess: true,
});
for (const transfer of transfers.docs) {
try {
await processTransfer(client, payload, transfer);
} catch (error) {
payload.logger.error(
`[Bot] Transfer delivery failed for request #${transfer.id}: ${errorMessage(error)}`,
);
}
}
} catch (error) {
payload.logger.error(`[Bot] Transfer request poll failed: ${errorMessage(error)}`);
} finally {
pollRunning = false;
}
};
export const start = (client: Client, payload: Payload): void => {
payload.logger.info("[Bot] Assignment transfer service started");
void pollTransferRequests(client, payload);
setInterval(() => {
void pollTransferRequests(client, payload);
}, botConfig.notificationPollMs);
};

View file

@ -26,6 +26,7 @@ const TARGET_COLLECTIONS = [
{ label: "Evaluations", value: "evaluations" },
{ label: "Tickets", value: "tickets" },
{ label: "Game Servers", value: "game-servers" },
{ label: "Assignment Transfers", value: "assignment-transfers" },
] as const;
export const GameEventLogs: CollectionConfig = {

View file

@ -0,0 +1,316 @@
import { CollectionConfig, Where } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import hasRoles from "@/utils/access-control/hasRoles";
import type { User } from "@/payload-types";
export const TRANSFER_STATUSES = [
"pending",
"rejected",
"awaiting_superuser",
"completed",
"cancelled",
"denied",
] as const;
export type TransferStatus = (typeof TRANSFER_STATUSES)[number];
export const TRANSFER_DECISIONS = ["pending", "approved", "rejected", "not_required"] as const;
export type TransferDecision = (typeof TRANSFER_DECISIONS)[number];
export const TRANSFER_ESCALATION_REASONS = ["no_leaders", "appeal", "leader_changed"] as const;
export type TransferEscalationReason = (typeof TRANSFER_ESCALATION_REASONS)[number];
/**
* Single source of truth for the assignment-transfer state machine. Both the
* beforeChange hook below and the lib core in src/lib/transfers/index.ts must
* agree on which transitions are legal — edit only this table, never both.
*/
export const TRANSFER_STATUS_TRANSITIONS: Record<TransferStatus, readonly TransferStatus[]> = {
pending: ["completed", "rejected", "cancelled", "awaiting_superuser"],
rejected: ["awaiting_superuser"],
awaiting_superuser: ["completed", "denied"],
completed: [],
cancelled: [],
denied: [],
};
export const TRANSFER_DECISION_TRANSITIONS: Record<TransferDecision, readonly TransferDecision[]> = {
pending: ["approved", "rejected"],
approved: [],
rejected: [],
not_required: [],
};
const DECISION_FIELDS = ["fromLeaderDecision", "toLeaderDecision"] as const;
export const AssignmentTransfers: CollectionConfig = {
slug: "assignment-transfers",
admin: {
group: "Users",
useAsTitle: "id",
defaultColumns: ["id", "requester", "fromAssignment", "toAssignment", "status", "createdAt"],
listSearchableFields: ["id"],
pagination: { defaultLimit: 50 },
},
access: {
read: ({ req }) => {
if (!req.user) return false;
const user = req.user as User;
if (hasRoles(["admin", "developer"], user)) return true;
// Requester, or the current leader of either side. The dotted conditions
// follow each assignment relationship to its live leader field, so a newly
// appointed leader gains access to older pending requests automatically.
return {
or: [
{ requester: { equals: user.id } },
{ "fromAssignment.leader": { equals: user.id } },
{ "toAssignment.leader": { equals: user.id } },
],
} as Where;
},
create: ({ req }) => !!req.user,
update: isDeveloper,
delete: isDeveloper,
},
hooks: {
beforeChange: [
({ data, operation, context }) => {
if (operation !== "update") return data;
const original = context.originalDoc as Record<string, unknown> | undefined;
if (!original) return data;
const nextStatus = data?.status as TransferStatus | undefined;
const prevStatus = original.status as TransferStatus | undefined;
if (nextStatus !== undefined && prevStatus !== undefined && nextStatus !== prevStatus) {
const allowed = TRANSFER_STATUS_TRANSITIONS[prevStatus] ?? [];
if (!allowed.includes(nextStatus)) {
throw new Error(
`Illegal assignment-transfer status transition: ${prevStatus} -> ${nextStatus}.`,
);
}
}
for (const field of DECISION_FIELDS) {
const next = data?.[field] as TransferDecision | undefined;
const prev = original[field] as TransferDecision | undefined;
if (next !== undefined && next !== prev) {
const allowed = TRANSFER_DECISION_TRANSITIONS[prev ?? "pending"] ?? [];
if (!allowed.includes(next)) {
throw new Error(
`Illegal assignment-transfer decision transition on ${field}: ${prev} -> ${next}.`,
);
}
}
}
return data;
},
],
},
fields: [
{
name: "requester",
type: "relationship",
relationTo: "users",
required: true,
admin: {
description: "The user requesting the transfer.",
},
},
{
name: "fromAssignment",
type: "relationship",
relationTo: "assignments",
required: true,
admin: {
description: "The assignment the requester currently belongs to.",
},
},
{
name: "toAssignment",
type: "relationship",
relationTo: "assignments",
required: true,
admin: {
description: "The assignment the requester wants to join.",
},
},
{
name: "status",
type: "select",
required: true,
defaultValue: "pending",
index: true,
options: TRANSFER_STATUSES.map((value) => ({ label: value, value })),
},
{
name: "escalationReason",
type: "select",
options: TRANSFER_ESCALATION_REASONS.map((value) => ({ label: value, value })),
admin: {
description:
"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.",
},
},
{
name: "fromLeaderDecision",
type: "select",
required: true,
defaultValue: "pending",
options: TRANSFER_DECISIONS.map((value) => ({ label: value, value })),
admin: {
description:
"The source assignment leader's decision. not_required is stamped at creation when the source has no leader.",
},
},
{
name: "toLeaderDecision",
type: "select",
required: true,
defaultValue: "pending",
options: TRANSFER_DECISIONS.map((value) => ({ label: value, value })),
admin: {
description:
"The destination assignment leader's decision. not_required is stamped at creation when the destination has no leader.",
},
},
{
name: "fromDecisionBy",
type: "relationship",
relationTo: "users",
admin: {
description: "The user who recorded the source-side decision (audit).",
},
},
{
name: "toDecisionBy",
type: "relationship",
relationTo: "users",
admin: {
description: "The user who recorded the destination-side decision (audit).",
},
},
{
name: "fromDecidedAt",
type: "date",
admin: {
description: "When the source-side decision was recorded.",
},
},
{
name: "toDecidedAt",
type: "date",
admin: {
description: "When the destination-side decision was recorded.",
},
},
{
name: "fromLeaderSnapshot",
type: "relationship",
relationTo: "users",
admin: {
description:
"The source assignment's leader at creation time (audit snapshot; may be null).",
},
},
{
name: "toLeaderSnapshot",
type: "relationship",
relationTo: "users",
admin: {
description:
"The destination assignment's leader at creation time (audit snapshot; may be null).",
},
},
{
name: "rejectionReason",
type: "textarea",
admin: {
description: "Required when a side rejects the request.",
},
},
{
name: "requestNote",
type: "textarea",
admin: {
description: "Optional note from the requester explaining the transfer.",
},
},
{
name: "appealNote",
type: "textarea",
admin: {
description: "Optional note from the requester when appealing a rejection.",
},
},
{
name: "resolvedBy",
type: "relationship",
relationTo: "users",
admin: {
description: "The admin/developer who resolved an escalated request (audit).",
},
},
{
name: "resolutionNote",
type: "textarea",
admin: {
description: "Optional note from the resolver.",
},
},
{
name: "resolvedAt",
type: "date",
admin: {
description: "When the escalated request was resolved.",
},
},
{
name: "discordFromLeaderMessageId",
type: "text",
admin: {
description: "Discord message id of the notification sent to the source leader (Phase 2).",
},
},
{
name: "discordToLeaderMessageId",
type: "text",
admin: {
description: "Discord message id of the notification sent to the destination leader (Phase 2).",
},
},
{
name: "discordRequesterMessageId",
type: "text",
admin: {
description: "Discord message id of the notification sent to the requester (Phase 2).",
},
},
{
name: "sourceNotifiedToUserId",
type: "relationship",
relationTo: "users",
admin: {
description:
"The user actually notified on the source side (the leader at notification time; may differ from the snapshot).",
},
},
{
name: "destinationNotifiedToUserId",
type: "relationship",
relationTo: "users",
admin: {
description:
"The user actually notified on the destination side (the leader at notification time; may differ from the snapshot).",
},
},
{
name: "discordDeliveredKeys",
type: "json",
defaultValue: [],
admin: {
description:
"Delivery keys already sent to Discord, used by the Phase 2 bot service for idempotent delivery.",
},
},
],
};

View file

@ -0,0 +1,66 @@
import { ArrowLeftRightIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { RequestTransferDialog } from "@/components/frontend/account/RequestTransferDialog";
import type { AccountAssignment } from "@/components/frontend/transfers/transferTypes";
interface AssignmentCardProps {
readonly assignments: readonly AccountAssignment[];
readonly requesterId: number;
}
export function AssignmentCard({ assignments, requesterId }: AssignmentCardProps) {
const canRequestTransfer = assignments.some((assignment) => assignment.leaderId !== requesterId);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
Assignment
</CardTitle>
<CardDescription>Your current place in the unit structure.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{assignments.length === 0 ? (
<p className="text-sm text-muted-foreground">You are not assigned to a unit yet.</p>
) : (
<ul className="flex flex-col gap-2" aria-label="Current assignments">
{assignments.map((assignment) => (
<li
key={assignment.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-muted/30 px-3 py-2"
>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium">
{assignment.callsign ? `[${assignment.callsign}] ` : ""}
{assignment.name}
</span>
<span className="text-xs text-muted-foreground">{assignment.type}</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="outline">{assignment.type}</Badge>
{assignment.leaderId === requesterId && <Badge variant="secondary">Leader</Badge>}
</div>
</li>
))}
</ul>
)}
</CardContent>
{canRequestTransfer ? (
<CardFooter className="flex flex-col items-start gap-2">
<RequestTransferDialog currentUserId={requesterId} currentAssignments={assignments} />
<p className="text-xs text-muted-foreground">
Transfers require approval from the current assignment leaders.
</p>
</CardFooter>
) : assignments.length > 0 ? (
<CardFooter>
<p className="text-xs text-muted-foreground">
Leaders cannot request transfers while they hold an assignment leadership role.
</p>
</CardFooter>
) : null}
</Card>
);
}

View file

@ -0,0 +1,286 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { ArrowLeftRightIcon, Loader2Icon } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { requestTransfer } from "@/app/(frontend)/transfers/actions";
import {
parseAssignmentPage,
relationId,
type AccountAssignment,
type TransferAssignmentSummary,
} from "@/components/frontend/transfers/transferTypes";
interface RequestTransferDialogProps {
readonly currentAssignments: readonly AccountAssignment[];
readonly currentUserId: number;
}
function isMemberOf(assignment: TransferAssignmentSummary, userId: number): boolean {
if (relationId(assignment.leader) === userId) return true;
return (assignment.members ?? []).some((member) => relationId(member) === userId);
}
export function RequestTransferDialog({
currentAssignments,
currentUserId,
}: RequestTransferDialogProps) {
const router = useRouter();
const [internalOpen, setInternalOpen] = useState(false);
const [sourceId, setSourceId] = useState("");
const [destinationId, setDestinationId] = useState("");
const [destinations, setDestinations] = useState<readonly TransferAssignmentSummary[]>([]);
const [note, setNote] = useState("");
const [loadingDestinations, setLoadingDestinations] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestableAssignments = useMemo(
() => currentAssignments.filter((assignment) => assignment.leaderId !== currentUserId),
[currentAssignments, currentUserId],
);
const currentAssignmentIds = useMemo(
() => new Set(currentAssignments.map((assignment) => assignment.id)),
[currentAssignments],
);
const currentAssignmentKey = currentAssignments.map((assignment) => assignment.id).join(",");
const open = internalOpen;
useEffect(() => {
if (!requestableAssignments.some((assignment) => String(assignment.id) === sourceId)) {
setSourceId(String(requestableAssignments[0]?.id ?? ""));
}
}, [requestableAssignments, sourceId]);
useEffect(() => {
if (!open) return;
const controller = new AbortController();
setLoadingDestinations(true);
setError(null);
async function loadDestinations() {
try {
const response = await fetch("/api/assignments?depth=1&limit=100&sort=name", {
signal: controller.signal,
cache: "no-store",
});
if (!response.ok) throw new Error("Could not load eligible assignments.");
const body: unknown = await response.json();
const parsed = parseAssignmentPage(body);
if (!parsed) throw new Error("The assignment response was invalid.");
const available = parsed.filter(
(assignment) =>
!currentAssignmentIds.has(assignment.id) && !isMemberOf(assignment, currentUserId),
);
setDestinations(available);
setDestinationId((previous) =>
available.some((assignment) => String(assignment.id) === previous)
? previous
: String(available[0]?.id ?? ""),
);
} catch (caughtError) {
if (caughtError instanceof DOMException && caughtError.name === "AbortError") return;
setError(
caughtError instanceof Error
? caughtError.message
: "Could not load eligible assignments.",
);
} finally {
if (!controller.signal.aborted) setLoadingDestinations(false);
}
}
void loadDestinations();
return () => controller.abort();
}, [currentAssignmentIds, currentAssignmentKey, currentUserId, open]);
function reset() {
setDestinationId("");
setDestinations([]);
setNote("");
setError(null);
setPending(false);
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const fromAssignmentId = Number(sourceId);
const toAssignmentId = Number(destinationId);
if (!Number.isInteger(fromAssignmentId) || fromAssignmentId <= 0) {
setError("Choose a current assignment.");
return;
}
if (!Number.isInteger(toAssignmentId) || toAssignmentId <= 0) {
setError("Choose a destination assignment.");
return;
}
if (fromAssignmentId === toAssignmentId) {
setError("Choose an assignment different from your current one.");
return;
}
setPending(true);
setError(null);
try {
const result = await requestTransfer({
fromAssignmentId,
toAssignmentId,
note: note.trim() || undefined,
});
if (!result.success) {
setError(result.error ?? "Could not submit the transfer request.");
return;
}
toast.success("Transfer request submitted");
setInternalOpen(false);
reset();
router.refresh();
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
: "Could not submit the transfer request.",
);
} finally {
setPending(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setInternalOpen(nextOpen);
if (!nextOpen) reset();
}}
>
<DialogTrigger asChild>
<Button
size="sm"
disabled={requestableAssignments.length === 0}
title={
requestableAssignments.length === 0
? "Leaders cannot request assignment transfers"
: undefined
}
>
<ArrowLeftRightIcon data-icon="inline-start" />
Request transfer
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Request assignment transfer</DialogTitle>
<DialogDescription>
Choose an assignment you are not already part of. Leaders cannot request transfers.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-1.5">
<Label htmlFor="transfer-source">Current assignment</Label>
<Select value={sourceId} onValueChange={setSourceId} disabled={pending}>
<SelectTrigger id="transfer-source">
<SelectValue placeholder="Select current assignment" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{requestableAssignments.map((assignment) => (
<SelectItem key={assignment.id} value={String(assignment.id)}>
{assignment.callsign
? `${assignment.name} · ${assignment.callsign}`
: assignment.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5" aria-busy={loadingDestinations}>
<Label htmlFor="transfer-destination">Destination assignment</Label>
{loadingDestinations ? (
<Skeleton className="h-9 w-full" />
) : destinations.length > 0 ? (
<Select
value={destinationId}
onValueChange={setDestinationId}
disabled={pending}
>
<SelectTrigger id="transfer-destination">
<SelectValue placeholder="Select destination assignment" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{destinations.map((assignment) => (
<SelectItem key={assignment.id} value={String(assignment.id)}>
{assignment.callsign
? `${assignment.name} · ${assignment.callsign}`
: assignment.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<Alert>
<AlertDescription>
No eligible destination assignments are available right now.
</AlertDescription>
</Alert>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="transfer-note">Note (optional)</Label>
<Textarea
id="transfer-note"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="Tell both assignment leaders why you would like to move..."
rows={4}
maxLength={1000}
disabled={pending}
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setInternalOpen(false)} disabled={pending}>
Cancel
</Button>
<Button type="submit" disabled={pending || loadingDestinations || destinations.length === 0}>
{pending && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
{pending ? "Submitting..." : "Submit request"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -3,6 +3,8 @@
import * as React from "react";
import {
Award,
ArrowLeftRight,
ArrowLeftRightIcon,
Backpack,
Bolt,
Crosshair,
@ -54,6 +56,16 @@ const data = {
url: "/roster",
icon: Users,
},
{
title: "Transfers",
url: "/transfers",
icon: ArrowLeftRight,
},
{
title: "Transfers",
url: "/transfers",
icon: ArrowLeftRightIcon,
},
],
navHelpdesk: [
{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,6 +8,8 @@ export interface NotifyInput {
title: string;
message?: string;
link?: string;
/** Skip both mute suppression checks. Used for operationally critical notifications (assignment transfers). */
bypassMute?: boolean;
}
/** Create an in-app notification for a user. Fire-and-forget: failures are logged, not thrown. */
@ -26,8 +28,10 @@ export async function notifyUser(payload: PayloadType, input: NotifyInput): Prom
};
}
).preferences?.notifications;
if (prefs?.mutedAll) return;
if (input.type && prefs?.mutedTypes?.includes(input.type)) return;
if (!input.bypassMute) {
if (prefs?.mutedAll) return;
if (input.type && prefs?.mutedTypes?.includes(input.type)) return;
}
await payload.create({
collection: "user-notifications",
@ -87,6 +91,20 @@ export function notificationLabel(type?: string | null): string {
return "New reply";
case "ticket:status":
return "Ticket updated";
case "assignment-transfer:request":
return "Transfer request";
case "assignment-transfer:approved":
return "Transfer approved";
case "assignment-transfer:rejected":
return "Transfer rejected";
case "assignment-transfer:escalated":
return "Transfer escalated";
case "assignment-transfer:resolved":
return "Transfer resolved";
case "assignment-transfer:completed":
return "Transfer completed";
case "assignment-transfer:cancelled":
return "Transfer cancelled";
default:
return type ?? "Notification";
}

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

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,80 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_assignment_transfers_status" AS ENUM('pending', 'rejected', 'awaiting_superuser', 'completed', 'cancelled', 'denied');
CREATE TYPE "public"."enum_assignment_transfers_escalation_reason" AS ENUM('no_leaders', 'appeal', 'leader_changed');
CREATE TYPE "public"."enum_assignment_transfers_from_leader_decision" AS ENUM('pending', 'approved', 'rejected', 'not_required');
CREATE TYPE "public"."enum_assignment_transfers_to_leader_decision" AS ENUM('pending', 'approved', 'rejected', 'not_required');
CREATE TABLE "assignment_transfers" (
"id" serial PRIMARY KEY NOT NULL,
"requester_id" integer NOT NULL,
"from_assignment_id" integer NOT NULL,
"to_assignment_id" integer NOT NULL,
"status" "enum_assignment_transfers_status" DEFAULT 'pending' NOT NULL,
"escalation_reason" "enum_assignment_transfers_escalation_reason",
"from_leader_decision" "enum_assignment_transfers_from_leader_decision" DEFAULT 'pending' NOT NULL,
"to_leader_decision" "enum_assignment_transfers_to_leader_decision" DEFAULT 'pending' NOT NULL,
"from_decision_by_id" integer,
"to_decision_by_id" integer,
"from_decided_at" timestamp(3) with time zone,
"to_decided_at" timestamp(3) with time zone,
"from_leader_snapshot_id" integer,
"to_leader_snapshot_id" integer,
"rejection_reason" varchar,
"request_note" varchar,
"appeal_note" varchar,
"resolved_by_id" integer,
"resolution_note" varchar,
"resolved_at" timestamp(3) with time zone,
"discord_from_leader_message_id" varchar,
"discord_to_leader_message_id" varchar,
"discord_requester_message_id" varchar,
"source_notified_to_user_id_id" integer,
"destination_notified_to_user_id_id" integer,
"discord_delivered_keys" jsonb DEFAULT '[]'::jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "assignment_transfers_id" integer;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_requester_id_users_id_fk" FOREIGN KEY ("requester_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_from_assignment_id_assignments_id_fk" FOREIGN KEY ("from_assignment_id") REFERENCES "public"."assignments"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_to_assignment_id_assignments_id_fk" FOREIGN KEY ("to_assignment_id") REFERENCES "public"."assignments"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_from_decision_by_id_users_id_fk" FOREIGN KEY ("from_decision_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_to_decision_by_id_users_id_fk" FOREIGN KEY ("to_decision_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_from_leader_snapshot_id_users_id_fk" FOREIGN KEY ("from_leader_snapshot_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_to_leader_snapshot_id_users_id_fk" FOREIGN KEY ("to_leader_snapshot_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_resolved_by_id_users_id_fk" FOREIGN KEY ("resolved_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_source_notified_to_user_id_id_users_id_fk" FOREIGN KEY ("source_notified_to_user_id_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_destination_notified_to_user_id_id_users_id_fk" FOREIGN KEY ("destination_notified_to_user_id_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "assignment_transfers_requester_idx" ON "assignment_transfers" USING btree ("requester_id");
CREATE INDEX "assignment_transfers_from_assignment_idx" ON "assignment_transfers" USING btree ("from_assignment_id");
CREATE INDEX "assignment_transfers_to_assignment_idx" ON "assignment_transfers" USING btree ("to_assignment_id");
CREATE INDEX "assignment_transfers_status_idx" ON "assignment_transfers" USING btree ("status");
CREATE INDEX "assignment_transfers_from_decision_by_idx" ON "assignment_transfers" USING btree ("from_decision_by_id");
CREATE INDEX "assignment_transfers_to_decision_by_idx" ON "assignment_transfers" USING btree ("to_decision_by_id");
CREATE INDEX "assignment_transfers_from_leader_snapshot_idx" ON "assignment_transfers" USING btree ("from_leader_snapshot_id");
CREATE INDEX "assignment_transfers_to_leader_snapshot_idx" ON "assignment_transfers" USING btree ("to_leader_snapshot_id");
CREATE INDEX "assignment_transfers_resolved_by_idx" ON "assignment_transfers" USING btree ("resolved_by_id");
CREATE INDEX "assignment_transfers_source_notified_to_user_id_idx" ON "assignment_transfers" USING btree ("source_notified_to_user_id_id");
CREATE INDEX "assignment_transfers_destination_notified_to_user_id_idx" ON "assignment_transfers" USING btree ("destination_notified_to_user_id_id");
CREATE INDEX "assignment_transfers_updated_at_idx" ON "assignment_transfers" USING btree ("updated_at");
CREATE INDEX "assignment_transfers_created_at_idx" ON "assignment_transfers" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_assignment_transfers_fk" FOREIGN KEY ("assignment_transfers_id") REFERENCES "public"."assignment_transfers"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_assignment_transfers_id_idx" ON "payload_locked_documents_rels" USING btree ("assignment_transfers_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "assignment_transfers" DISABLE ROW LEVEL SECURITY;
DROP TABLE "assignment_transfers" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_assignment_transfers_fk";
DROP INDEX "payload_locked_documents_rels_assignment_transfers_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "assignment_transfers_id";
DROP TYPE "public"."enum_assignment_transfers_status";
DROP TYPE "public"."enum_assignment_transfers_escalation_reason";
DROP TYPE "public"."enum_assignment_transfers_from_leader_decision";
DROP TYPE "public"."enum_assignment_transfers_to_leader_decision";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,12 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from "@payloadcms/db-postgres";
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TYPE "public"."enum_game_event_logs_target_collection"
ADD VALUE IF NOT EXISTS 'assignment-transfers';
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
void db;
}

View file

@ -10,6 +10,8 @@ import * as migration_20260828_011500_add_background_texture_preference from './
import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
import * as migration_20260828_044506 from './20260828_044506';
import * as migration_20260831_142503 from './20260831_142503';
import * as migration_20260831_180233_assignment_transfers from './20260831_180233_assignment_transfers';
import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target';
export const migrations = [
{
@ -70,6 +72,16 @@ export const migrations = [
{
up: migration_20260831_142503.up,
down: migration_20260831_142503.down,
name: '20260831_142503'
name: '20260831_142503',
},
{
up: migration_20260831_180233_assignment_transfers.up,
down: migration_20260831_180233_assignment_transfers.down,
name: '20260831_180233_assignment_transfers',
},
{
up: migration_20260901_014006_add_assignment_transfer_event_target.up,
down: migration_20260901_014006_add_assignment_transfer_event_target.down,
name: '20260901_014006_add_assignment_transfer_event_target'
},
];

View file

@ -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".

View file

@ -30,6 +30,7 @@ import { Vehicles } from "@/collections/logistics/Vehicles";
import { Structures } from "@/collections/logistics/Structures";
import { Shipments } from "@/collections/logistics/Shipments";
import { Assignments } from "@/collections/users/Assignments";
import { AssignmentTransfers } from "@/collections/users/AssignmentTransfers";
import { Technologies } from "@/collections/intelligence/Technologies";
import { GameRules } from "@/collections/game/GameRules";
import { GameStructures } from "@/collections/game/GameStructures";
@ -202,6 +203,7 @@ export default buildConfig({
Awards,
Qualifications,
Assignments,
AssignmentTransfers,
Experience,
UserNotifications,
Evaluations,

View file

@ -30,7 +30,8 @@ interface GameEventInput {
| "market-negotiations"
| "evaluations"
| "tickets"
| "game-servers";
| "game-servers"
| "assignment-transfers";
targetId?: number;
data?: Record<string, unknown>;
}

View file

@ -120,6 +120,15 @@ export const EventTypes = {
ReleaseUpdate: "release:update",
ReleaseStart: "release:start",
ReleaseComplete: "release:complete",
// Assignment Transfers
AssignmentTransferRequest: "assignment-transfer:request",
AssignmentTransferApprove: "assignment-transfer:approve",
AssignmentTransferReject: "assignment-transfer:reject",
AssignmentTransferAppeal: "assignment-transfer:appeal",
AssignmentTransferResolve: "assignment-transfer:resolve",
AssignmentTransferComplete: "assignment-transfer:complete",
AssignmentTransferCancel: "assignment-transfer:cancel",
} as const;
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];

View file

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

View file

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