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
118 lines
4.2 KiB
TypeScript
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;
|
|
}
|