Fix duplicate new-ticket notification (toast + desktop push both fired)

appendMessage() publishes ticket.created and message.created for the
same first message of a new ticket. TicketToasts and DesktopNotifications
listened to both independently, so one new customer ticket produced two
toasts/two chimes/two OS notifications. message.created now carries
ticketIsNew so both subscribers can skip it when ticket.created already
covered the same event.
This commit is contained in:
ogrechko committed 2026-08-14 05:50:29 +00:00
1 parent 87cf045715
commit b2534b7ce8
4 files changed
+16 -3

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@ export function DesktopNotifications({ currentUser }: { currentUser: { id: strin
window.focus(); window.focus();
router.push(`/tickets/${event.ticket.id}`); router.push(`/tickets/${event.ticket.id}`);
}; };
} else if (event.type === "message.created" && event.message.authorType === "customer") { } else if (event.type === "message.created" && event.message.authorType === "customer" && !event.ticketIsNew) {
if (!isTicketVisibleTo({ assigneeId: event.assigneeId }, currentUser)) return; if (!isTicketVisibleTo({ assigneeId: event.assigneeId }, currentUser)) return;
const notification = new Notification(`Сообщение от ${event.message.authorName}`, { const notification = new Notification(`Сообщение от ${event.message.authorName}`, {
body: event.message.body.slice(0, 120), body: event.message.body.slice(0, 120),
+1 -1
View File
@@ -92,7 +92,7 @@ export function TicketToasts({ currentUser }: { currentUser: { id: string; role:
if (event.type === "ticket.created") { if (event.type === "ticket.created") {
if (!isTicketVisibleTo(event.ticket, currentUser)) return; if (!isTicketVisibleTo(event.ticket, currentUser)) return;
pushToast("Новая заявка", event.ticket.subject, `/tickets/${event.ticket.id}`); pushToast("Новая заявка", event.ticket.subject, `/tickets/${event.ticket.id}`);
} else if (event.type === "message.created" && event.message.authorType === "customer") { } else if (event.type === "message.created" && event.message.authorType === "customer" && !event.ticketIsNew) {
if (!isTicketVisibleTo({ assigneeId: event.assigneeId }, currentUser)) return; if (!isTicketVisibleTo({ assigneeId: event.assigneeId }, currentUser)) return;
pushToast(`Сообщение от ${event.message.authorName}`, event.message.body.slice(0, 120), `/tickets/${event.ticketId}`); pushToast(`Сообщение от ${event.message.authorName}`, event.message.body.slice(0, 120), `/tickets/${event.ticketId}`);
} }
+13 -1
View File
@@ -4,7 +4,19 @@ import type { TicketDTO, MessageDTO } from "@/lib/tickets/types";
export type TicketEvent = export type TicketEvent =
| { type: "ticket.created"; ticket: TicketDTO } | { type: "ticket.created"; ticket: TicketDTO }
| { type: "ticket.updated"; ticket: TicketDTO } | { type: "ticket.updated"; ticket: TicketDTO }
| { type: "message.created"; ticketId: string; customerId: string; assigneeId: string | null; message: MessageDTO }; | {
type: "message.created";
ticketId: string;
customerId: string;
assigneeId: string | null;
message: MessageDTO;
// True when this message is the ticket's very first — subscribers that
// already show a "new ticket" toast/notification off the sibling
// ticket.created event (published for the same message, see
// appendMessage in lib/tickets/service.ts) should skip this one too,
// otherwise a single incoming ticket doubles up as two notifications.
ticketIsNew: boolean;
};
// Stashed on globalThis rather than relying on Node's module cache to give // Stashed on globalThis rather than relying on Node's module cache to give
// us a singleton: Next.js dev mode reloads this module on every edit (hot // us a singleton: Next.js dev mode reloads this module on every edit (hot
+1
View File
@@ -164,6 +164,7 @@ async function appendMessage(params: {
customerId: ticket.customerId, customerId: ticket.customerId,
assigneeId: dto.assigneeId, assigneeId: dto.assigneeId,
message: messageDto, message: messageDto,
ticketIsNew: params.ticketIsNew,
}); });
// Fire-and-forget: push delivery is a best-effort side channel for // Fire-and-forget: push delivery is a best-effort side channel for