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
903 lines
30 KiB
TypeScript
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,
|
|
};
|
|
}
|