1
0
Fork 0

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.
This commit is contained in:
Jason Fraley 2026-08-31 23:40:21 -04:00
parent a8854034d3
commit 9001d5a796
7 changed files with 778 additions and 1 deletions

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 { commands } from "./commands";
import { services } from "./services"; import { services } from "./services";
import { handleButtonInteraction } from "./events/interactionCreate"; import { handleButtonInteraction } from "./events/interactionCreate";
import { handleTransferInteraction } from "./events/transferInteractions";
// Usage: `bun run bot`. Standalone long-running process; connects to Discord // Usage: `bun run bot`. Standalone long-running process; connects to Discord
// and shares the Postgres database with the web app via the Payload config. // and shares the Postgres database with the web app via the Payload config.
@ -72,6 +73,13 @@ const main = async () => {
await handleButtonInteraction(interaction, payload); await handleButtonInteraction(interaction, payload);
return; return;
} }
if (
(interaction.isButton() || interaction.isModalSubmit()) &&
interaction.customId.startsWith("ptf-xfer:")
) {
await handleTransferInteraction(interaction, payload);
return;
}
if (!interaction.isChatInputCommand()) { if (!interaction.isChatInputCommand()) {
return; 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 type { Payload } from "payload";
import { start as startMissionEmbeds } from "./missionEmbeds"; import { start as startMissionEmbeds } from "./missionEmbeds";
import { start as startNotificationBridge } from "./notificationBridge"; import { start as startNotificationBridge } from "./notificationBridge";
import { start as startTransferRequests } from "./transferRequests";
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void; 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 { Payload } from "payload";
import type { User, UserNotification } from "@/payload-types"; import type { User, UserNotification } from "@/payload-types";
import { botConfig } from "@/bot/config"; import { botConfig } from "@/bot/config";
import { TRANSFER_NOTIFICATION_TYPES } from "@/lib/transfers/notificationTypes";
const MAX_SENDS_PER_TICK = 5; const MAX_SENDS_PER_TICK = 5;
const SEND_DELAY_MS = 300; const SEND_DELAY_MS = 300;
const EMBED_COLOR = 0x3b82f6; 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; let cursor: number | null = null;
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); 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++; processed++;
cursor = notification.id; cursor = notification.id;
if (sent >= MAX_SENDS_PER_TICK) break; if (sent >= MAX_SENDS_PER_TICK) break;
if (notification.type && NON_BRIDGED_TYPES.has(notification.type)) continue;
try { try {
const owner = (await payload.findByID({ 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);
};