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 { 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 { const customer = await db.query.customers.findFirst({ where: eq(customers.id, customerId) }); return customer?.displayName ?? "Unknown"; } async function touchTicket(ticketId: string, patch: Partial) { 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> { const map = new Map(); 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 { const map = await getTagsForTickets([ticketId]); return map.get(ticketId) ?? []; } async function getAttachmentsForMessages(messageIds: string[]): Promise> { const map = new Map(); 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 { 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 { 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 { let matchingTicketIds: Set | 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 { 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 { 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 { 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 { 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 { 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; channelCounts: Record; ticketsPerDay: { label: string; count: number }[]; agentWorkloadByStatus: Record; } /** * 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 { const allTickets = await db.query.tickets.findMany(); const statusCounts: Record = { new: 0, open: 0, pending: 0, closed: 0 }; const channelCounts: Record = { 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(); 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(); 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 = { 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, }; }