Deferred backlog: attachments, canned responses, tags, search, notifications, password change
Attachments: file storage on the same data volume, serving route with dual auth (session or portal/widget token, internal-note attachments never token-servable), inbound ingestion from email (mailparser) and Telegram (photo/document handlers), outbound delivery via SMTP attachments and Telegram sendDocument/sendPhoto, upload UI (paperclip + pending-file chip) on all three reply surfaces. Extracted lib/tickets/delivery.ts so the text-only and attachment-upload admin routes share one channel-delivery code path instead of duplicating it. Canned responses: CRUD + a popover picker in the reply box that inserts a saved template into the draft. Tags: fixed 6-color palette reusing existing soft-badge tokens, a picker on the ticket page, colored chips + a filter row on the dashboard. Search: LIKE-based (not FTS5 — simpler and sufficient at this volume) across ticket subject, customer name, and message bodies; a debounced search box on the dashboard. Desktop notifications: permission toggle + a background subscriber that fires for new tickets and customer messages while the tab is hidden, clicking one navigates to the ticket. Password change: new account settings page, verifies the current password before updating. Nav: gear-menu settings list now includes Шаблоны ответов/Теги/Аккаунт. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
1 parent
7947633709
commit
89b9c5eaac
40 files changed
+2758
-113
No files matched your search
+202
-17
@@ -1,10 +1,29 @@
|
||||
import { eq, and, ne, desc } from "drizzle-orm";
|
||||
import { eq, and, ne, or, like, desc, inArray } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { customers, tickets, messages, users, widgetSites } from "@/lib/db/schema";
|
||||
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 type { TicketDTO, MessageDTO, TicketChannel, TicketStatus } from "@/lib/tickets/types";
|
||||
import type {
|
||||
TicketDTO,
|
||||
MessageDTO,
|
||||
TicketChannel,
|
||||
TicketStatus,
|
||||
TagDTO,
|
||||
AttachmentDTO,
|
||||
} from "@/lib/tickets/types";
|
||||
|
||||
function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string): TicketDTO {
|
||||
type AttachmentInput = { filename: string; mimeType: string; sizeBytes: number; storageKey: string };
|
||||
|
||||
function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string, tags: TagDTO[]): TicketDTO {
|
||||
return {
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
@@ -14,13 +33,14 @@ function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string):
|
||||
customerId: ticket.customerId,
|
||||
customerName,
|
||||
assigneeId: ticket.assigneeId,
|
||||
tags,
|
||||
createdAt: ticket.createdAt.getTime(),
|
||||
updatedAt: ticket.updatedAt.getTime(),
|
||||
lastMessageAt: ticket.lastMessageAt.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
function toMessageDTO(message: typeof messages.$inferSelect): MessageDTO {
|
||||
function toMessageDTO(message: typeof messages.$inferSelect, attachments: AttachmentDTO[] = []): MessageDTO {
|
||||
return {
|
||||
id: message.id,
|
||||
ticketId: message.ticketId,
|
||||
@@ -29,6 +49,7 @@ function toMessageDTO(message: typeof messages.$inferSelect): MessageDTO {
|
||||
body: message.body,
|
||||
direction: message.direction,
|
||||
visibility: message.visibility,
|
||||
attachments,
|
||||
createdAt: message.createdAt.getTime(),
|
||||
};
|
||||
}
|
||||
@@ -47,7 +68,45 @@ async function touchTicket(ticketId: string, patch: Partial<typeof tickets.$infe
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/** Inserts a message, bumps the ticket's lastMessageAt, and publishes both realtime events. */
|
||||
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 });
|
||||
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 });
|
||||
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;
|
||||
@@ -58,6 +117,7 @@ async function appendMessage(params: {
|
||||
direction: "in" | "out";
|
||||
visibility?: "public" | "internal";
|
||||
emailMessageId?: string;
|
||||
attachments?: AttachmentInput[];
|
||||
}) {
|
||||
const [message] = await db
|
||||
.insert(messages)
|
||||
@@ -73,9 +133,25 @@ async function appendMessage(params: {
|
||||
})
|
||||
.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,
|
||||
}));
|
||||
}
|
||||
|
||||
const ticket = await touchTicket(params.ticket.id, { lastMessageAt: new Date() });
|
||||
const name = await customerName(ticket.customerId);
|
||||
const dto = toTicketDTO(ticket, name);
|
||||
const ticketTagsList = await getTagsForTicket(ticket.id);
|
||||
const dto = toTicketDTO(ticket, name, ticketTagsList);
|
||||
const messageDto = toMessageDTO(message, attachmentDTOs);
|
||||
|
||||
publishTicketEvent({
|
||||
type: params.ticketIsNew ? "ticket.created" : "ticket.updated",
|
||||
@@ -85,10 +161,10 @@ async function appendMessage(params: {
|
||||
type: "message.created",
|
||||
ticketId: ticket.id,
|
||||
customerId: ticket.customerId,
|
||||
message: toMessageDTO(message),
|
||||
message: messageDto,
|
||||
});
|
||||
|
||||
return { ticket: dto, message: toMessageDTO(message) };
|
||||
return { ticket: dto, message: messageDto };
|
||||
}
|
||||
|
||||
export async function findOrCreateCustomerByTelegramChatId(chatId: string, displayName: string) {
|
||||
@@ -128,7 +204,7 @@ async function findTicketIdByEmailReferences(candidateMessageIds: string[]): Pro
|
||||
if (candidateMessageIds.length === 0) return null;
|
||||
|
||||
const match = await db.query.messages.findFirst({
|
||||
where: (m, { inArray }) => inArray(m.emailMessageId, candidateMessageIds),
|
||||
where: (m, { inArray: inArrayOp }) => inArrayOp(m.emailMessageId, candidateMessageIds),
|
||||
});
|
||||
return match?.ticketId ?? null;
|
||||
}
|
||||
@@ -153,6 +229,7 @@ export async function recordChatInboundMessage(params: {
|
||||
body: string;
|
||||
authorName: string;
|
||||
subjectForNewTicket: string;
|
||||
attachments?: AttachmentInput[];
|
||||
}) {
|
||||
let ticket = await findOpenTicketForCustomer(params.customerId);
|
||||
let ticketIsNew = false;
|
||||
@@ -179,6 +256,7 @@ export async function recordChatInboundMessage(params: {
|
||||
authorName: params.authorName,
|
||||
body: params.body,
|
||||
direction: "in",
|
||||
attachments: params.attachments,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,6 +273,7 @@ export async function recordEmailInboundMessage(params: {
|
||||
body: string;
|
||||
messageId: string | null;
|
||||
referencedMessageIds: string[];
|
||||
attachments?: AttachmentInput[];
|
||||
}) {
|
||||
const customer = await findOrCreateCustomerByEmail(params.fromEmail, params.fromName);
|
||||
|
||||
@@ -227,6 +306,7 @@ export async function recordEmailInboundMessage(params: {
|
||||
body: params.body,
|
||||
direction: "in",
|
||||
emailMessageId: params.messageId ?? undefined,
|
||||
attachments: params.attachments,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -236,6 +316,7 @@ export async function appendCustomerReply(params: {
|
||||
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) {
|
||||
@@ -252,6 +333,7 @@ export async function appendCustomerReply(params: {
|
||||
authorName: params.authorName,
|
||||
body: params.body,
|
||||
direction: "in",
|
||||
attachments: params.attachments,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -262,6 +344,7 @@ export async function recordAgentReply(params: {
|
||||
agentName: string;
|
||||
body: string;
|
||||
emailMessageId?: string;
|
||||
attachments?: AttachmentInput[];
|
||||
}) {
|
||||
const existing = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
|
||||
if (!existing) throw new Error("Ticket not found");
|
||||
@@ -278,6 +361,7 @@ export async function recordAgentReply(params: {
|
||||
body: params.body,
|
||||
direction: "out",
|
||||
emailMessageId: params.emailMessageId,
|
||||
attachments: params.attachments,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -292,6 +376,7 @@ export async function recordInternalNote(params: {
|
||||
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");
|
||||
@@ -305,6 +390,7 @@ export async function recordInternalNote(params: {
|
||||
body: params.body,
|
||||
direction: "out",
|
||||
visibility: "internal",
|
||||
attachments: params.attachments,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -313,26 +399,57 @@ export async function setTicketStatus(
|
||||
status: (typeof tickets.$inferSelect)["status"],
|
||||
) {
|
||||
const ticket = await touchTicket(ticketId, { status });
|
||||
const dto = toTicketDTO(ticket, await customerName(ticket.customerId));
|
||||
const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticket.id));
|
||||
publishTicketEvent({ type: "ticket.updated", ticket: dto });
|
||||
return dto;
|
||||
}
|
||||
|
||||
export async function assignTicket(ticketId: string, assigneeId: string | null) {
|
||||
const ticket = await touchTicket(ticketId, { assigneeId });
|
||||
const dto = toTicketDTO(ticket, await customerName(ticket.customerId));
|
||||
const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticket.id));
|
||||
publishTicketEvent({ type: "ticket.updated", ticket: dto });
|
||||
return dto;
|
||||
}
|
||||
|
||||
export async function listTickets(): Promise<TicketDTO[]> {
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export async function listTickets(query?: string): 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 })
|
||||
.from(tickets)
|
||||
.innerJoin(customers, eq(tickets.customerId, customers.id))
|
||||
.orderBy(desc(tickets.lastMessageAt));
|
||||
|
||||
return rows.map((row) => toTicketDTO(row.ticket, row.customerName));
|
||||
const idsToKeep = matchingTicketIds;
|
||||
const filteredRows = idsToKeep ? rows.filter((r) => idsToKeep.has(r.ticket.id)) : rows;
|
||||
|
||||
const tagsMap = await getTagsForTickets(filteredRows.map((r) => r.ticket.id));
|
||||
return filteredRows.map((row) => toTicketDTO(row.ticket, row.customerName, tagsMap.get(row.ticket.id) ?? []));
|
||||
}
|
||||
|
||||
export async function getTicketWithMessages(ticketId: string) {
|
||||
@@ -349,10 +466,12 @@ export async function getTicketWithMessages(ticketId: string) {
|
||||
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),
|
||||
messages: ticketMessages.map(toMessageDTO),
|
||||
ticket: toTicketDTO(row[0].ticket, row[0].customerName, ticketTagsList),
|
||||
messages: ticketMessages.map((m) => toMessageDTO(m, attachmentsMap.get(m.id) ?? [])),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -393,7 +512,8 @@ export async function listTicketsForCustomer(customerId: string): Promise<Ticket
|
||||
.where(eq(tickets.customerId, customerId))
|
||||
.orderBy(desc(tickets.lastMessageAt));
|
||||
|
||||
return rows.map((row) => toTicketDTO(row.ticket, row.customerName));
|
||||
const tagsMap = await getTagsForTickets(rows.map((r) => r.ticket.id));
|
||||
return rows.map((row) => toTicketDTO(row.ticket, row.customerName, tagsMap.get(row.ticket.id) ?? []));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -485,6 +605,71 @@ export async function createManualTicket(params: {
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 }));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/** 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));
|
||||
publishTicketEvent({ type: "ticket.updated", ticket: dto });
|
||||
}
|
||||
return getTagsForTicket(ticketId);
|
||||
}
|
||||
|
||||
export interface StatsOverview {
|
||||
totalTickets: number;
|
||||
openTickets: number;
|
||||
|
||||
Reference in new issue
Block a user