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
This commit is contained in:
1 parent
8d1f91187a
commit
b92d9801ff
8 files changed
+1258
-14
No files matched your search
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowLeft, Send, StickyNote, Paperclip, X, MessageSquareText } from "lucide-react";
|
||||
import { ArrowLeft, Send, StickyNote, Paperclip, X, MessageSquareText, TriangleAlert } from "lucide-react";
|
||||
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
@@ -373,6 +373,15 @@ function MessageBubble({ message }: { message: MessageDTO }) {
|
||||
<p className={`text-[11px] ${isAgent ? "text-white/70" : "text-text-faint"}`}>
|
||||
{message.authorName} · {formatRelativeTime(message.createdAt)}
|
||||
</p>
|
||||
{message.deliveryFailed && (
|
||||
<p
|
||||
className="mt-1 flex items-center gap-1 text-[11px] font-medium text-danger"
|
||||
title="Сообщение сохранено, но не дошло до клиента — проверьте настройки почты/Telegram"
|
||||
>
|
||||
<TriangleAlert size={11} />
|
||||
Не доставлено клиенту
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `messages` ADD `delivery_failed` integer DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -85,6 +85,13 @@
|
||||
"when": 1788763637424,
|
||||
"tag": "0011_cute_boomer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1791361267938,
|
||||
"tag": "0012_flawless_lord_tyger",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -147,6 +147,11 @@ export const messages = sqliteTable("messages", {
|
||||
// email channel — lets a customer's future reply be matched back to the
|
||||
// exact ticket via In-Reply-To/References, even if they have other tickets.
|
||||
emailMessageId: text("email_message_id"),
|
||||
// Set when this is an outbound agent reply whose channel delivery
|
||||
// (email send / Telegram send) failed — the reply itself is always
|
||||
// recorded regardless (see deliverAgentMessage), but the agent needs a
|
||||
// way to tell "saved" from "the customer actually got this".
|
||||
deliveryFailed: integer("delivery_failed", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: timestamps.createdAt,
|
||||
});
|
||||
|
||||
|
||||
+20
-13
@@ -56,7 +56,13 @@ export async function deliverAgentMessage(params: {
|
||||
// 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) {
|
||||
@@ -72,22 +78,12 @@ export async function deliverAgentMessage(params: {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Failed to send reply email for ticket ${params.ticketId}:`, err);
|
||||
deliveryFailed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await recordAgentReply({
|
||||
ticketId: params.ticketId,
|
||||
agentId: params.agentId,
|
||||
agentName: params.agentName,
|
||||
body: params.body,
|
||||
emailMessageId,
|
||||
attachments: attachmentInput ? [attachmentInput] : undefined,
|
||||
});
|
||||
|
||||
if (result.ticket.channel === "telegram") {
|
||||
} else if (ticket.channel === "telegram") {
|
||||
const customer = await db.query.customers.findFirst({
|
||||
where: eq(customers.id, result.ticket.customerId),
|
||||
where: eq(customers.id, ticket.customerId),
|
||||
});
|
||||
if (customer?.telegramChatId) {
|
||||
try {
|
||||
@@ -102,9 +98,20 @@ export async function deliverAgentMessage(params: {
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
@@ -75,6 +75,7 @@ function toMessageDTO(message: typeof messages.$inferSelect, attachments: Attach
|
||||
visibility: message.visibility,
|
||||
attachments,
|
||||
createdAt: message.createdAt.getTime(),
|
||||
deliveryFailed: message.deliveryFailed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -142,6 +143,7 @@ async function appendMessage(params: {
|
||||
direction: "in" | "out";
|
||||
visibility?: "public" | "internal";
|
||||
emailMessageId?: string;
|
||||
deliveryFailed?: boolean;
|
||||
attachments?: AttachmentInput[];
|
||||
}) {
|
||||
const [message] = await db
|
||||
@@ -155,6 +157,7 @@ async function appendMessage(params: {
|
||||
bodyHtml: params.bodyHtml ?? null,
|
||||
direction: params.direction,
|
||||
visibility: params.visibility ?? "public",
|
||||
deliveryFailed: params.deliveryFailed ?? false,
|
||||
emailMessageId: params.emailMessageId,
|
||||
})
|
||||
.returning();
|
||||
@@ -398,6 +401,7 @@ export async function recordAgentReply(params: {
|
||||
agentName: string;
|
||||
body: string;
|
||||
emailMessageId?: string;
|
||||
deliveryFailed?: boolean;
|
||||
attachments?: AttachmentInput[];
|
||||
}) {
|
||||
const existing = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
|
||||
@@ -415,6 +419,7 @@ export async function recordAgentReply(params: {
|
||||
body: params.body,
|
||||
direction: "out",
|
||||
emailMessageId: params.emailMessageId,
|
||||
deliveryFailed: params.deliveryFailed,
|
||||
attachments: params.attachments,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,4 +51,5 @@ export interface MessageDTO {
|
||||
visibility: MessageVisibility;
|
||||
attachments: AttachmentDTO[];
|
||||
createdAt: number;
|
||||
deliveryFailed: boolean;
|
||||
}
|
||||
Reference in new issue
Block a user