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:
ogrechkoandClaude Sonnet 5 committed 2026-07-26 17:32:12 +00:00
1 parent 7947633709
commit 89b9c5eaac
40 files changed
+2758 -113

No files matched your search

+202 -17
View File
@@ -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;