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 { useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
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 { useTicketEvents } from "@/lib/events/use-ticket-events";
|
||||||
import { formatRelativeTime } from "@/lib/format";
|
import { formatRelativeTime } from "@/lib/format";
|
||||||
import { Avatar } from "@/components/avatar";
|
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"}`}>
|
<p className={`text-[11px] ${isAgent ? "text-white/70" : "text-text-faint"}`}>
|
||||||
{message.authorName} · {formatRelativeTime(message.createdAt)}
|
{message.authorName} · {formatRelativeTime(message.createdAt)}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</motion.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,
|
"when": 1788763637424,
|
||||||
"tag": "0011_cute_boomer",
|
"tag": "0011_cute_boomer",
|
||||||
"breakpoints": true
|
"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
|
// 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.
|
// exact ticket via In-Reply-To/References, even if they have other tickets.
|
||||||
emailMessageId: text("email_message_id"),
|
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,
|
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
|
// 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
|
// misleading "Ticket not found". A broken mail/bot account should degrade
|
||||||
// to "reply saved internally, not delivered" — never silent data loss.
|
// 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 emailMessageId: string | undefined;
|
||||||
|
let deliveryFailed = false;
|
||||||
|
|
||||||
if (ticket.channel === "email") {
|
if (ticket.channel === "email") {
|
||||||
const context = await getEmailThreadContext(params.ticketId);
|
const context = await getEmailThreadContext(params.ticketId);
|
||||||
if (context) {
|
if (context) {
|
||||||
@@ -72,22 +78,12 @@ export async function deliverAgentMessage(params: {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to send reply email for ticket ${params.ticketId}:`, err);
|
console.error(`Failed to send reply email for ticket ${params.ticketId}:`, err);
|
||||||
|
deliveryFailed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else if (ticket.channel === "telegram") {
|
||||||
|
|
||||||
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") {
|
|
||||||
const customer = await db.query.customers.findFirst({
|
const customer = await db.query.customers.findFirst({
|
||||||
where: eq(customers.id, result.ticket.customerId),
|
where: eq(customers.id, ticket.customerId),
|
||||||
});
|
});
|
||||||
if (customer?.telegramChatId) {
|
if (customer?.telegramChatId) {
|
||||||
try {
|
try {
|
||||||
@@ -102,9 +98,20 @@ export async function deliverAgentMessage(params: {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to deliver reply via Telegram for ticket ${params.ticketId}:`, 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;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,7 @@ function toMessageDTO(message: typeof messages.$inferSelect, attachments: Attach
|
|||||||
visibility: message.visibility,
|
visibility: message.visibility,
|
||||||
attachments,
|
attachments,
|
||||||
createdAt: message.createdAt.getTime(),
|
createdAt: message.createdAt.getTime(),
|
||||||
|
deliveryFailed: message.deliveryFailed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@ async function appendMessage(params: {
|
|||||||
direction: "in" | "out";
|
direction: "in" | "out";
|
||||||
visibility?: "public" | "internal";
|
visibility?: "public" | "internal";
|
||||||
emailMessageId?: string;
|
emailMessageId?: string;
|
||||||
|
deliveryFailed?: boolean;
|
||||||
attachments?: AttachmentInput[];
|
attachments?: AttachmentInput[];
|
||||||
}) {
|
}) {
|
||||||
const [message] = await db
|
const [message] = await db
|
||||||
@@ -155,6 +157,7 @@ async function appendMessage(params: {
|
|||||||
bodyHtml: params.bodyHtml ?? null,
|
bodyHtml: params.bodyHtml ?? null,
|
||||||
direction: params.direction,
|
direction: params.direction,
|
||||||
visibility: params.visibility ?? "public",
|
visibility: params.visibility ?? "public",
|
||||||
|
deliveryFailed: params.deliveryFailed ?? false,
|
||||||
emailMessageId: params.emailMessageId,
|
emailMessageId: params.emailMessageId,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -398,6 +401,7 @@ export async function recordAgentReply(params: {
|
|||||||
agentName: string;
|
agentName: string;
|
||||||
body: string;
|
body: string;
|
||||||
emailMessageId?: string;
|
emailMessageId?: string;
|
||||||
|
deliveryFailed?: boolean;
|
||||||
attachments?: AttachmentInput[];
|
attachments?: AttachmentInput[];
|
||||||
}) {
|
}) {
|
||||||
const existing = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
|
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,
|
body: params.body,
|
||||||
direction: "out",
|
direction: "out",
|
||||||
emailMessageId: params.emailMessageId,
|
emailMessageId: params.emailMessageId,
|
||||||
|
deliveryFailed: params.deliveryFailed,
|
||||||
attachments: params.attachments,
|
attachments: params.attachments,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,4 +51,5 @@ export interface MessageDTO {
|
|||||||
visibility: MessageVisibility;
|
visibility: MessageVisibility;
|
||||||
attachments: AttachmentDTO[];
|
attachments: AttachmentDTO[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
deliveryFailed: boolean;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user