diff --git a/src/bot/events/transferInteractions.ts b/src/bot/events/transferInteractions.ts new file mode 100644 index 0000000..d770181 --- /dev/null +++ b/src/bot/events/transferInteractions.ts @@ -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 => + interaction.reply({ content, flags: MessageFlags.Ephemeral }); + +const currentLeaderSide = async ( + payload: Payload, + requestId: number, + actorUserId: number, +): Promise => { + 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 => { + 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 => { + 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 => { + 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); + } + } +}; diff --git a/src/bot/index.ts b/src/bot/index.ts index aef7cf8..02d1d3c 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -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; } diff --git a/src/bot/lib/transferMessaging.ts b/src/bot/lib/transferMessaging.ts new file mode 100644 index 0000000..10af699 --- /dev/null +++ b/src/bot/lib/transferMessaging.ts @@ -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 = { + 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 => + new ActionRowBuilder().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 => + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`ptf-xfer:${requestId}:appeal`) + .setStyle(ButtonStyle.Secondary) + .setLabel("Appeal") + .setDisabled(disabled), + ); diff --git a/src/bot/services/index.ts b/src/bot/services/index.ts index 3c006c8..a444b5b 100644 --- a/src/bot/services/index.ts +++ b/src/bot/services/index.ts @@ -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; -export const services: ServiceStarter[] = [startMissionEmbeds, startNotificationBridge]; +export const services: ServiceStarter[] = [ + startMissionEmbeds, + startNotificationBridge, + startTransferRequests, +]; diff --git a/src/bot/services/notificationBridge.ts b/src/bot/services/notificationBridge.ts index 57f5b24..a846f87 100644 --- a/src/bot/services/notificationBridge.ts +++ b/src/bot/services/notificationBridge.ts @@ -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(Object.values(TRANSFER_NOTIFICATION_TYPES)); + let cursor: number | null = null; const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -54,6 +59,7 @@ const poll = async (client: Client, payload: Payload): Promise => { 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({ diff --git a/src/bot/services/transferDelivery.ts b/src/bot/services/transferDelivery.ts new file mode 100644 index 0000000..c0e2798 --- /dev/null +++ b/src/bot/services/transferDelivery.ts @@ -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 => + payload.findByID({ collection: "users", id, depth: 0, overrideAccess: true }); + +const fetchAssignment = (payload: Payload, id: number): Promise => + payload.findByID({ collection: "assignments", id, depth: 0, overrideAccess: true }); + +const contextFor = async ( + payload: Payload, + doc: AssignmentTransfer, +): Promise => { + 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 => { + 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; + +export const deliverRequesterRejection = async ({ + client, + payload, + doc, +}: RequesterDelivery): Promise => { + 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 => { + 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, + }); +}; diff --git a/src/bot/services/transferRequests.ts b/src/bot/services/transferRequests.ts new file mode 100644 index 0000000..574e30b --- /dev/null +++ b/src/bot/services/transferRequests.ts @@ -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 => { + 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 => { + 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); +};