Files
top-tickets/src/lib/tickets/delivery.ts
T
ogrechkoandClaude Sonnet 5 b92d9801ff Surface channel delivery failures on agent replies instead of swallowing them
The previous fix made a broken SMTP/Telegram account non-fatal — the
reply gets recorded either way now — but that also made delivery
failures completely invisible: an agent sees their reply "sent"
successfully with no indication the customer never actually received it.
Added messages.deliveryFailed, set whenever the outbound send throws, and
a warning badge on the message bubble so this is now visible instead of
silent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-10-07 08:26:54 +00:00

118 lines
4.2 KiB
TypeScript

import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { customers, tickets } from "@/lib/db/schema";
import { recordAgentReply, recordInternalNote, getEmailThreadContext } from "@/lib/tickets/service";
import { sendTelegramMessage, sendTelegramFile } from "@/lib/telegram/bot";
import { sendTicketReplyEmail } from "@/lib/mail/smtp";
interface AttachmentPayload {
filename: string;
mimeType: string;
sizeBytes: number;
storageKey: string;
buffer: Buffer;
}
/**
* Shared by the text-reply and attachment-upload admin routes: creates the
* message (public reply or internal note) and, for public replies, delivers
* it over the ticket's channel (email/Telegram). Not used for portal/widget
* replies — those never need outbound channel delivery.
*/
export async function deliverAgentMessage(params: {
ticketId: string;
agentId: string;
agentName: string;
body: string;
visibility: "public" | "internal";
attachment?: AttachmentPayload;
}) {
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
if (!ticket) throw new Error("Ticket not found");
const attachmentInput = params.attachment
? {
filename: params.attachment.filename,
mimeType: params.attachment.mimeType,
sizeBytes: params.attachment.sizeBytes,
storageKey: params.attachment.storageKey,
}
: undefined;
if (params.visibility === "internal") {
return recordInternalNote({
ticketId: params.ticketId,
agentId: params.agentId,
agentName: params.agentName,
body: params.body,
attachments: attachmentInput ? [attachmentInput] : undefined,
});
}
// Channel delivery (email/Telegram) is best-effort: the agent's reply is
// the thing that must never be lost. Previously an SMTP/Telegram failure
// here threw straight out of this function, so recordAgentReply below was
// never reached — the reply vanished entirely (not recorded, nothing
// shown in the thread) and the API route's catch-all turned it into a
// misleading "Ticket not found". A broken mail/bot account should degrade
// to "reply saved internally, not delivered" — never silent data loss.
// A ticket only ever has one channel, so at most one of the two branches
// below runs — this single flag is the outcome of whichever one does, and
// is what lets the agent tell "saved" from "the customer actually got
// this" in the UI (see messages.deliveryFailed).
let emailMessageId: string | undefined;
let deliveryFailed = false;
if (ticket.channel === "email") {
const context = await getEmailThreadContext(params.ticketId);
if (context) {
try {
emailMessageId = await sendTicketReplyEmail({
to: context.customerEmail,
subject: context.subject,
body: params.body,
inReplyTo: context.inReplyTo,
attachment: params.attachment
? { filename: params.attachment.filename, content: params.attachment.buffer }
: undefined,
});
} catch (err) {
console.error(`Failed to send reply email for ticket ${params.ticketId}:`, err);
deliveryFailed = true;
}
}
} else if (ticket.channel === "telegram") {
const customer = await db.query.customers.findFirst({
where: eq(customers.id, ticket.customerId),
});
if (customer?.telegramChatId) {
try {
if (params.attachment) {
await sendTelegramFile(
customer.telegramChatId,
{ buffer: params.attachment.buffer, filename: params.attachment.filename, mimeType: params.attachment.mimeType },
params.body,
);
} else {
await sendTelegramMessage(customer.telegramChatId, params.body);
}
} catch (err) {
console.error(`Failed to deliver reply via Telegram for ticket ${params.ticketId}:`, err);
deliveryFailed = true;
}
}
}
const result = await recordAgentReply({
ticketId: params.ticketId,
agentId: params.agentId,
agentName: params.agentName,
body: params.body,
emailMessageId,
deliveryFailed,
attachments: attachmentInput ? [attachmentInput] : undefined,
});
return result;
}