Files
top-tickets/src/lib/tickets/service.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

903 lines
30 KiB
TypeScript

import { eq, and, ne, or, like, desc, inArray } from "drizzle-orm";
import { db } from "@/lib/db/client";
import {
customers,
tickets,
messages,
users,
widgetSites,
attachments as attachmentsTable,
cannedResponses,
tags as tagsTable,
ticketTags,
} from "@/lib/db/schema";
import { publishTicketEvent } from "@/lib/events/bus";
import { notifyPushForCustomerMessage } from "@/lib/push/service";
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
import type {
TicketDTO,
MessageDTO,
TicketChannel,
TicketStatus,
TagDTO,
AttachmentDTO,
} from "@/lib/tickets/types";
type AttachmentInput = {
id?: string;
filename: string;
mimeType: string;
sizeBytes: number;
storageKey: string;
contentId?: string | null;
isInline?: boolean;
};
function toTicketDTO(
ticket: typeof tickets.$inferSelect,
customerName: string,
tags: TagDTO[],
assigneeName: string | null = null,
): TicketDTO {
return {
id: ticket.id,
subject: ticket.subject,
status: ticket.status,
priority: ticket.priority,
channel: ticket.channel,
customerId: ticket.customerId,
customerName,
assigneeId: ticket.assigneeId,
assigneeName,
tags,
createdAt: ticket.createdAt.getTime(),
updatedAt: ticket.updatedAt.getTime(),
lastMessageAt: ticket.lastMessageAt.getTime(),
};
}
/** Single-ticket lookups only — listTickets/listTicketsForCustomer LEFT JOIN users directly instead, to avoid N+1 queries across a whole board. */
async function getAssigneeName(assigneeId: string | null): Promise<string | null> {
if (!assigneeId) return null;
const user = await db.query.users.findFirst({ where: eq(users.id, assigneeId) });
return user?.name ?? null;
}
function toMessageDTO(message: typeof messages.$inferSelect, attachments: AttachmentDTO[] = []): MessageDTO {
return {
id: message.id,
ticketId: message.ticketId,
authorType: message.authorType,
authorName: message.authorName,
body: message.body,
bodyHtml: message.bodyHtml,
direction: message.direction,
visibility: message.visibility,
attachments,
createdAt: message.createdAt.getTime(),
deliveryFailed: message.deliveryFailed,
};
}
async function customerName(customerId: string): Promise<string> {
const customer = await db.query.customers.findFirst({ where: eq(customers.id, customerId) });
return customer?.displayName ?? "Unknown";
}
async function touchTicket(ticketId: string, patch: Partial<typeof tickets.$inferInsert>) {
const [ticket] = await db
.update(tickets)
.set({ ...patch, updatedAt: new Date() })
.where(eq(tickets.id, ticketId))
.returning();
return ticket;
}
async function getTagsForTickets(ticketIds: string[]): Promise<Map<string, TagDTO[]>> {
const map = new Map<string, TagDTO[]>();
if (ticketIds.length === 0) return map;
const rows = await db
.select({ ticketId: ticketTags.ticketId, tag: tagsTable })
.from(ticketTags)
.innerJoin(tagsTable, eq(ticketTags.tagId, tagsTable.id))
.where(inArray(ticketTags.ticketId, ticketIds));
for (const row of rows) {
const list = map.get(row.ticketId) ?? [];
list.push({ id: row.tag.id, name: row.tag.name, color: row.tag.color, isDefault: row.tag.isDefault });
map.set(row.ticketId, list);
}
return map;
}
async function getTagsForTicket(ticketId: string): Promise<TagDTO[]> {
const map = await getTagsForTickets([ticketId]);
return map.get(ticketId) ?? [];
}
async function getAttachmentsForMessages(messageIds: string[]): Promise<Map<string, AttachmentDTO[]>> {
const map = new Map<string, AttachmentDTO[]>();
if (messageIds.length === 0) return map;
const rows = await db.query.attachments.findMany({
where: inArray(attachmentsTable.messageId, messageIds),
});
for (const row of rows) {
const list = map.get(row.messageId) ?? [];
list.push({ id: row.id, filename: row.filename, mimeType: row.mimeType, sizeBytes: row.sizeBytes, isInline: row.isInline });
map.set(row.messageId, list);
}
return map;
}
/** Inserts a message (+ any attachments), bumps the ticket's lastMessageAt, and publishes both realtime events. */
async function appendMessage(params: {
ticket: typeof tickets.$inferSelect;
ticketIsNew: boolean;
authorType: "customer" | "agent" | "system";
authorId: string | null;
authorName: string;
body: string;
bodyHtml?: string | null;
direction: "in" | "out";
visibility?: "public" | "internal";
emailMessageId?: string;
deliveryFailed?: boolean;
attachments?: AttachmentInput[];
}) {
const [message] = await db
.insert(messages)
.values({
ticketId: params.ticket.id,
authorType: params.authorType,
authorId: params.authorId,
authorName: params.authorName,
body: params.body,
bodyHtml: params.bodyHtml ?? null,
direction: params.direction,
visibility: params.visibility ?? "public",
deliveryFailed: params.deliveryFailed ?? false,
emailMessageId: params.emailMessageId,
})
.returning();
let attachmentDTOs: AttachmentDTO[] = [];
if (params.attachments && params.attachments.length > 0) {
const inserted = await db
.insert(attachmentsTable)
.values(params.attachments.map((a) => ({ messageId: message.id, ...a })))
.returning();
attachmentDTOs = inserted.map((a) => ({
id: a.id,
filename: a.filename,
mimeType: a.mimeType,
sizeBytes: a.sizeBytes,
isInline: a.isInline,
}));
}
if (params.ticketIsNew) {
const defaultTagId = await getDefaultTagId();
if (defaultTagId) {
await db.insert(ticketTags).values({ ticketId: params.ticket.id, tagId: defaultTagId });
}
}
const ticket = await touchTicket(params.ticket.id, { lastMessageAt: new Date() });
const name = await customerName(ticket.customerId);
const ticketTagsList = await getTagsForTicket(ticket.id);
const dto = toTicketDTO(ticket, name, ticketTagsList, await getAssigneeName(ticket.assigneeId));
const messageDto = toMessageDTO(message, attachmentDTOs);
publishTicketEvent({
type: params.ticketIsNew ? "ticket.created" : "ticket.updated",
ticket: dto,
});
publishTicketEvent({
type: "message.created",
ticketId: ticket.id,
customerId: ticket.customerId,
assigneeId: dto.assigneeId,
message: messageDto,
ticketIsNew: params.ticketIsNew,
});
// Fire-and-forget: push delivery is a best-effort side channel for
// backgrounded/frozen tabs, not part of the message-creation contract —
// callers (mail ingestion, the API routes) must not wait on or fail from
// a slow/unreachable push service. The .catch() here is load-bearing:
// this app has no global unhandledRejection handler, so an uncaught
// rejection from this call would otherwise crash the whole Node process
// (Node terminates on unhandled rejections by default) — taking down
// every in-flight request, not just this one message.
if (params.authorType === "customer") {
notifyPushForCustomerMessage(dto, messageDto, params.ticketIsNew).catch((err) => {
console.error("[push] notifyPushForCustomerMessage failed:", err);
});
}
return { ticket: dto, message: messageDto };
}
export async function findOrCreateCustomerByTelegramChatId(chatId: string, displayName: string) {
const existing = await db.query.customers.findFirst({
where: eq(customers.telegramChatId, chatId),
});
if (existing) return existing;
const [created] = await db
.insert(customers)
.values({ displayName, telegramChatId: chatId })
.returning();
return created;
}
export async function findOrCreateCustomerByEmail(email: string, displayName: string) {
const normalized = email.toLowerCase().trim();
const existing = await db.query.customers.findFirst({
where: eq(customers.email, normalized),
});
if (existing) return existing;
const [created] = await db
.insert(customers)
.values({ displayName, email: normalized })
.returning();
return created;
}
/**
* Finds the ticket a reply belongs to by matching the email's In-Reply-To /
* References headers against Message-IDs we've previously sent or received.
* Falls back to null if none match (caller then uses the open-ticket
* find-or-create heuristic instead).
*/
async function findTicketIdByEmailReferences(candidateMessageIds: string[]): Promise<string | null> {
if (candidateMessageIds.length === 0) return null;
const match = await db.query.messages.findFirst({
where: (m, { inArray: inArrayOp }) => inArrayOp(m.emailMessageId, candidateMessageIds),
});
return match?.ticketId ?? null;
}
/** The customer's most recent ticket that isn't closed. */
async function findOpenTicketForCustomer(customerId: string) {
return db.query.tickets.findFirst({
where: and(eq(tickets.customerId, customerId), ne(tickets.status, "closed")),
orderBy: desc(tickets.lastMessageAt),
});
}
/**
* Telegram and the website widget have no explicit ticket context —
* messages are a running chat stream. Reuse the customer's currently open
* ticket if one exists, otherwise open a new one. A closed ticket is
* reopened by a new message.
*/
export async function recordChatInboundMessage(params: {
customerId: string;
channel: TicketChannel;
body: string;
authorName: string;
subjectForNewTicket: string;
attachments?: AttachmentInput[];
}) {
let ticket = await findOpenTicketForCustomer(params.customerId);
let ticketIsNew = false;
if (!ticket) {
[ticket] = await db
.insert(tickets)
.values({
subject: params.subjectForNewTicket,
channel: params.channel,
customerId: params.customerId,
})
.returning();
ticketIsNew = true;
} else if (ticket.status === "closed") {
ticket = await touchTicket(ticket.id, { status: "open" });
}
return appendMessage({
ticket,
ticketIsNew,
authorType: "customer",
authorId: params.customerId,
authorName: params.authorName,
body: params.body,
direction: "in",
attachments: params.attachments,
});
}
/**
* Records an inbound email. Threads it onto an existing ticket only via
* exact In-Reply-To/References matching. Unlike Telegram/the website widget
* (a continuous chat stream with no subject), email always carries a
* distinct subject per conversation — so a message with no header match is
* always a new, unrelated request and must become its own ticket, even if
* the customer already has another ticket open.
*/
export async function recordEmailInboundMessage(params: {
fromEmail: string;
fromName: string;
subject: string;
body: string;
bodyHtml?: string | null;
messageId: string | null;
referencedMessageIds: string[];
attachments?: AttachmentInput[];
}) {
const customer = await findOrCreateCustomerByEmail(params.fromEmail, params.fromName);
const matchedTicketId = await findTicketIdByEmailReferences(params.referencedMessageIds);
let ticket = matchedTicketId
? await db.query.tickets.findFirst({ where: eq(tickets.id, matchedTicketId) })
: null;
let ticketIsNew = false;
if (!ticket) {
[ticket] = await db
.insert(tickets)
.values({
subject: params.subject || "(без темы)",
channel: "email" satisfies TicketChannel,
customerId: customer.id,
})
.returning();
ticketIsNew = true;
} else if (ticket.status === "closed") {
ticket = await touchTicket(ticket.id, { status: "open" });
}
return appendMessage({
ticket,
ticketIsNew,
authorType: "customer",
authorId: customer.id,
authorName: params.fromName,
body: params.body,
bodyHtml: params.bodyHtml,
direction: "in",
emailMessageId: params.messageId ?? undefined,
attachments: params.attachments,
});
}
/** Appends the customer's reply to a specific, already-known ticket (portal thread). */
export async function appendCustomerReply(params: {
ticketId: string;
customerId: string;
authorName: string;
body: string;
attachments?: AttachmentInput[];
}) {
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
if (!ticket || ticket.customerId !== params.customerId) {
throw new Error("Ticket not found for this customer");
}
const reopened = ticket.status === "closed" ? await touchTicket(ticket.id, { status: "open" }) : ticket;
return appendMessage({
ticket: reopened,
ticketIsNew: false,
authorType: "customer",
authorId: params.customerId,
authorName: params.authorName,
body: params.body,
direction: "in",
attachments: params.attachments,
});
}
/** Records an agent's reply on a ticket. Reopens "new" tickets to "open". */
export async function recordAgentReply(params: {
ticketId: string;
agentId: string;
agentName: string;
body: string;
emailMessageId?: string;
deliveryFailed?: boolean;
attachments?: AttachmentInput[];
}) {
const existing = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
if (!existing) throw new Error("Ticket not found");
const ticket =
existing.status === "new" ? await touchTicket(existing.id, { status: "open" }) : existing;
return appendMessage({
ticket,
ticketIsNew: false,
authorType: "agent",
authorId: params.agentId,
authorName: params.agentName,
body: params.body,
direction: "out",
emailMessageId: params.emailMessageId,
deliveryFailed: params.deliveryFailed,
attachments: params.attachments,
});
}
/**
* Internal, agent-only note — never delivered over any channel, never
* visible to the customer (filtered out of getTicketForCustomer). Unlike
* recordAgentReply, doesn't reopen the ticket — it's team communication,
* not customer-facing activity.
*/
export async function recordInternalNote(params: {
ticketId: string;
agentId: string;
agentName: string;
body: string;
attachments?: AttachmentInput[];
}) {
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
if (!ticket) throw new Error("Ticket not found");
return appendMessage({
ticket,
ticketIsNew: false,
authorType: "agent",
authorId: params.agentId,
authorName: params.agentName,
body: params.body,
direction: "out",
visibility: "internal",
attachments: params.attachments,
});
}
export async function setTicketStatus(
ticketId: string,
status: (typeof tickets.$inferSelect)["status"],
) {
const ticket = await touchTicket(ticketId, { status });
const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticket.id), await getAssigneeName(ticket.assigneeId));
publishTicketEvent({ type: "ticket.updated", ticket: dto });
return dto;
}
/**
* Changes status on many tickets at once, silently skipping any id that
* doesn't exist or isn't visible to the acting user (an agent selecting
* tickets only ever sees their own, so this is mostly a safety net against
* a stale client-side selection). Returns the ids actually updated.
*/
export async function bulkSetTicketStatus(
ticketIds: string[],
status: (typeof tickets.$inferSelect)["status"],
currentUser: { id: string; role: "admin" | "agent" },
): Promise<string[]> {
if (ticketIds.length === 0) return [];
const rows = await db.query.tickets.findMany({ where: inArray(tickets.id, ticketIds) });
const visible = rows.filter((row) => isTicketVisibleTo(row, currentUser));
const updated: string[] = [];
for (const row of visible) {
await setTicketStatus(row.id, status);
updated.push(row.id);
}
return updated;
}
export async function assignTicket(ticketId: string, assigneeId: string | null) {
const ticket = await touchTicket(ticketId, { assigneeId });
const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticket.id), await getAssigneeName(ticket.assigneeId));
publishTicketEvent({ type: "ticket.updated", ticket: dto });
return dto;
}
/**
* Optional `query` filters by ticket subject, customer name, or any message
* body in the thread — a plain LIKE search, not FTS5 (simpler and
* sufficient at this ticket volume; revisit if it gets slow).
*
* `visibility` restricts the result to what an agent should see: only
* tickets assigned to them, not the whole board. Admins see everything,
* so pass `undefined`/role "admin" for that case.
*/
export async function listTickets(
query?: string,
visibility?: { id: string; role: "admin" | "agent" },
): Promise<TicketDTO[]> {
let matchingTicketIds: Set<string> | null = null;
const trimmed = query?.trim();
if (trimmed) {
const pattern = `%${trimmed}%`;
const bySubjectOrCustomer = await db
.select({ id: tickets.id })
.from(tickets)
.innerJoin(customers, eq(tickets.customerId, customers.id))
.where(or(like(tickets.subject, pattern), like(customers.displayName, pattern)));
const byMessageBody = await db
.selectDistinct({ ticketId: messages.ticketId })
.from(messages)
.where(like(messages.body, pattern));
matchingTicketIds = new Set([
...bySubjectOrCustomer.map((r) => r.id),
...byMessageBody.map((r) => r.ticketId),
]);
}
const rows = await db
.select({ ticket: tickets, customerName: customers.displayName, assigneeName: users.name })
.from(tickets)
.innerJoin(customers, eq(tickets.customerId, customers.id))
.leftJoin(users, eq(tickets.assigneeId, users.id))
.orderBy(desc(tickets.lastMessageAt));
const idsToKeep = matchingTicketIds;
let filteredRows = idsToKeep ? rows.filter((r) => idsToKeep.has(r.ticket.id)) : rows;
if (visibility?.role === "agent") {
filteredRows = filteredRows.filter((r) => r.ticket.assigneeId === visibility.id);
}
const tagsMap = await getTagsForTickets(filteredRows.map((r) => r.ticket.id));
return filteredRows.map((row) =>
toTicketDTO(row.ticket, row.customerName, tagsMap.get(row.ticket.id) ?? [], row.assigneeName ?? null),
);
}
export async function getTicketWithMessages(ticketId: string) {
const row = await db
.select({ ticket: tickets, customerName: customers.displayName })
.from(tickets)
.innerJoin(customers, eq(tickets.customerId, customers.id))
.where(eq(tickets.id, ticketId))
.limit(1);
if (!row[0]) return null;
const ticketMessages = await db.query.messages.findMany({
where: eq(messages.ticketId, ticketId),
orderBy: messages.createdAt,
});
const attachmentsMap = await getAttachmentsForMessages(ticketMessages.map((m) => m.id));
const ticketTagsList = await getTagsForTicket(ticketId);
return {
ticket: toTicketDTO(row[0].ticket, row[0].customerName, ticketTagsList, await getAssigneeName(row[0].ticket.assigneeId)),
messages: ticketMessages.map((m) => toMessageDTO(m, attachmentsMap.get(m.id) ?? [])),
};
}
/** Recipient address + Message-ID to reply to, for sending an agent's email reply. */
export async function getEmailThreadContext(ticketId: string) {
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, ticketId) });
if (!ticket) return null;
const customer = await db.query.customers.findFirst({ where: eq(customers.id, ticket.customerId) });
if (!customer?.email) return null;
const lastEmailMessage = await db.query.messages.findFirst({
where: (m, { eq: eqOp, and: andOp, isNotNull }) =>
andOp(eqOp(m.ticketId, ticketId), isNotNull(m.emailMessageId)),
orderBy: desc(messages.createdAt),
});
return {
customerEmail: customer.email,
subject: ticket.subject,
inReplyTo: lastEmailMessage?.emailMessageId ?? null,
};
}
export async function listAgents() {
return db.query.users.findMany({ orderBy: users.name });
}
export async function getCustomerByPortalToken(token: string) {
return db.query.customers.findFirst({ where: eq(customers.portalToken, token) });
}
export async function listTicketsForCustomer(customerId: string): Promise<TicketDTO[]> {
const rows = await db
.select({ ticket: tickets, customerName: customers.displayName })
.from(tickets)
.innerJoin(customers, eq(tickets.customerId, customers.id))
.where(eq(tickets.customerId, customerId))
.orderBy(desc(tickets.lastMessageAt));
const tagsMap = await getTagsForTickets(rows.map((r) => r.ticket.id));
return rows.map((row) => toTicketDTO(row.ticket, row.customerName, tagsMap.get(row.ticket.id) ?? []));
}
/**
* Returns the ticket+messages only if it actually belongs to this customer,
* with internal-only notes stripped out — this is the customer-facing read
* path (portal, widget), unlike getTicketWithMessages which the admin
* thread view uses and which returns everything.
*/
export async function getTicketForCustomer(customerId: string, ticketId: string) {
const result = await getTicketWithMessages(ticketId);
if (!result || result.ticket.customerId !== customerId) return null;
return {
ticket: result.ticket,
messages: result.messages.filter((m) => m.visibility === "public"),
};
}
export async function createWidgetSite(name: string, allowedOrigin?: string) {
const [site] = await db
.insert(widgetSites)
.values({ name, allowedOrigin: allowedOrigin || null })
.returning();
return site;
}
export async function listWidgetSites() {
return db.query.widgetSites.findMany({ orderBy: desc(widgetSites.createdAt) });
}
export async function getWidgetSiteByKey(siteKey: string) {
return db.query.widgetSites.findFirst({ where: eq(widgetSites.siteKey, siteKey) });
}
export async function setWidgetSiteEnabled(id: string, enabled: boolean) {
const [site] = await db
.update(widgetSites)
.set({ enabled })
.where(eq(widgetSites.id, id))
.returning();
return site;
}
const GUEST_NAME = "Гость";
/** Only overwrites the placeholder name — never clobbers a name the visitor already gave. */
export async function renameCustomerIfGuest(customerId: string, name: string) {
const trimmed = name.trim();
if (!trimmed) return;
await db
.update(customers)
.set({ displayName: trimmed })
.where(and(eq(customers.id, customerId), eq(customers.displayName, GUEST_NAME)));
}
export async function createGuestCustomer() {
const [customer] = await db.insert(customers).values({ displayName: GUEST_NAME }).returning();
return customer;
}
export async function createManualTicket(params: {
subject: string;
body: string;
customerName: string;
customerEmail?: string;
attachment?: AttachmentInput;
}) {
const [customer] = await db
.insert(customers)
.values({ displayName: params.customerName, email: params.customerEmail })
.returning();
const [ticket] = await db
.insert(tickets)
.values({
subject: params.subject,
channel: "manual" satisfies TicketChannel,
customerId: customer.id,
})
.returning();
return appendMessage({
ticket,
ticketIsNew: true,
authorType: "customer",
authorId: customer.id,
authorName: params.customerName,
body: params.body,
direction: "in",
attachments: params.attachment ? [params.attachment] : undefined,
});
}
/** Auth context for serving a stored file — null if the attachment/message/ticket chain is broken. */
export async function getAttachmentContext(attachmentId: string) {
const attachment = await db.query.attachments.findFirst({ where: eq(attachmentsTable.id, attachmentId) });
if (!attachment) return null;
const message = await db.query.messages.findFirst({ where: eq(messages.id, attachment.messageId) });
if (!message) return null;
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, message.ticketId) });
if (!ticket) return null;
return { attachment, message, ticket };
}
export async function listCannedResponses() {
return db.query.cannedResponses.findMany({ orderBy: cannedResponses.title });
}
export async function createCannedResponse(title: string, body: string) {
const [response] = await db.insert(cannedResponses).values({ title, body }).returning();
return response;
}
export async function updateCannedResponse(id: string, title: string, body: string) {
const [response] = await db
.update(cannedResponses)
.set({ title, body })
.where(eq(cannedResponses.id, id))
.returning();
return response;
}
export async function deleteCannedResponse(id: string) {
await db.delete(cannedResponses).where(eq(cannedResponses.id, id));
}
export async function listTags(): Promise<TagDTO[]> {
const rows = await db.query.tags.findMany({ orderBy: tagsTable.name });
return rows.map((r) => ({ id: r.id, name: r.name, color: r.color, isDefault: r.isDefault }));
}
export async function createTag(name: string, color: TagDTO["color"]) {
const [tag] = await db.insert(tagsTable).values({ name, color }).returning();
return tag;
}
export async function deleteTag(id: string) {
await db.delete(tagsTable).where(eq(tagsTable.id, id));
}
/** At most one default at a time — pass null to clear it entirely. */
export async function setDefaultTag(id: string | null): Promise<TagDTO[]> {
await db.update(tagsTable).set({ isDefault: false }).where(eq(tagsTable.isDefault, true));
if (id) {
await db.update(tagsTable).set({ isDefault: true }).where(eq(tagsTable.id, id));
}
return listTags();
}
async function getDefaultTagId(): Promise<string | null> {
const row = await db.query.tags.findFirst({ where: eq(tagsTable.isDefault, true) });
return row?.id ?? null;
}
/** Full replace — the ticket ends up tagged with exactly this set. */
export async function setTicketTags(ticketId: string, tagIds: string[]): Promise<TagDTO[]> {
await db.delete(ticketTags).where(eq(ticketTags.ticketId, ticketId));
if (tagIds.length > 0) {
await db.insert(ticketTags).values(tagIds.map((tagId) => ({ ticketId, tagId })));
}
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, ticketId) });
if (ticket) {
const dto = toTicketDTO(
ticket,
await customerName(ticket.customerId),
await getTagsForTicket(ticketId),
await getAssigneeName(ticket.assigneeId),
);
publishTicketEvent({ type: "ticket.updated", ticket: dto });
}
return getTagsForTicket(ticketId);
}
export interface StatsOverview {
totalTickets: number;
openTickets: number;
newToday: number;
avgFirstResponseMinutes: number | null;
statusCounts: Record<TicketStatus, number>;
channelCounts: Record<TicketChannel, number>;
ticketsPerDay: { label: string; count: number }[];
agentWorkloadByStatus: Record<TicketStatus, { name: string; count: number }[]>;
}
/**
* All stats computed in JS over the full table contents rather than SQL
* aggregation — simplest correct option at this ticket volume. Move to
* GROUP BY / date() SQL if the table grows into the tens of thousands.
*/
export async function getStatsOverview(): Promise<StatsOverview> {
const allTickets = await db.query.tickets.findMany();
const statusCounts: Record<TicketStatus, number> = { new: 0, open: 0, pending: 0, closed: 0 };
const channelCounts: Record<TicketChannel, number> = {
telegram: 0,
email: 0,
portal: 0,
widget: 0,
manual: 0,
};
for (const t of allTickets) {
statusCounts[t.status] += 1;
channelCounts[t.channel] += 1;
}
const dayMs = 24 * 60 * 60 * 1000;
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const newToday = allTickets.filter((t) => t.createdAt.getTime() >= todayStart.getTime()).length;
const ticketsPerDay: { label: string; count: number }[] = [];
for (let i = 13; i >= 0; i--) {
const dayStart = todayStart.getTime() - i * dayMs;
const dayEnd = dayStart + dayMs;
const count = allTickets.filter((t) => {
const ts = t.createdAt.getTime();
return ts >= dayStart && ts < dayEnd;
}).length;
ticketsPerDay.push({
label: new Date(dayStart).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }),
count,
});
}
const agentMessages = await db.query.messages.findMany({
where: and(eq(messages.authorType, "agent"), eq(messages.visibility, "public")),
orderBy: messages.createdAt,
});
const firstAgentReplyAt = new Map<string, number>();
for (const m of agentMessages) {
if (!firstAgentReplyAt.has(m.ticketId)) {
firstAgentReplyAt.set(m.ticketId, m.createdAt.getTime());
}
}
const responseTimesMinutes: number[] = [];
for (const t of allTickets) {
const firstReply = firstAgentReplyAt.get(t.id);
if (firstReply) {
responseTimesMinutes.push((firstReply - t.createdAt.getTime()) / 60_000);
}
}
const avgFirstResponseMinutes =
responseTimesMinutes.length > 0
? responseTimesMinutes.reduce((a, b) => a + b, 0) / responseTimesMinutes.length
: null;
const agents = await listAgents();
/** Per-agent ticket count for one status, sorted busiest-first. */
function workloadForStatus(status: TicketStatus): { name: string; count: number }[] {
const countByAgent = new Map<string, number>();
let unassignedCount = 0;
for (const t of allTickets) {
if (t.status !== status) continue;
if (t.assigneeId) {
countByAgent.set(t.assigneeId, (countByAgent.get(t.assigneeId) ?? 0) + 1);
} else {
unassignedCount += 1;
}
}
const workload = agents.map((a) => ({ name: a.name, count: countByAgent.get(a.id) ?? 0 }));
if (unassignedCount > 0) {
workload.push({ name: "Не назначено", count: unassignedCount });
}
return workload.sort((a, b) => b.count - a.count);
}
const agentWorkloadByStatus: Record<TicketStatus, { name: string; count: number }[]> = {
new: workloadForStatus("new"),
open: workloadForStatus("open"),
pending: workloadForStatus("pending"),
closed: workloadForStatus("closed"),
};
return {
totalTickets: allTickets.length,
openTickets: allTickets.filter((t) => t.status !== "closed").length,
newToday,
avgFirstResponseMinutes,
statusCounts,
channelCounts,
ticketsPerDay,
agentWorkloadByStatus,
};
}