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
@@ -0,0 +1,95 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { customers, tickets } from "@/lib/db/schema";
|
||||
import { recordAgentReply, recordInternalNote, getEmailThreadContext } from "@/lib/tickets/service";
|
||||
import { sendTelegramMessage, sendTelegramFile } from "@/lib/telegram/bot";
|
||||
import { sendTicketReplyEmail } from "@/lib/mail/smtp";
|
||||
|
||||
interface AttachmentPayload {
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
storageKey: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by the text-reply and attachment-upload admin routes: creates the
|
||||
* message (public reply or internal note) and, for public replies, delivers
|
||||
* it over the ticket's channel (email/Telegram). Not used for portal/widget
|
||||
* replies — those never need outbound channel delivery.
|
||||
*/
|
||||
export async function deliverAgentMessage(params: {
|
||||
ticketId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
body: string;
|
||||
visibility: "public" | "internal";
|
||||
attachment?: AttachmentPayload;
|
||||
}) {
|
||||
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
|
||||
if (!ticket) throw new Error("Ticket not found");
|
||||
|
||||
const attachmentInput = params.attachment
|
||||
? {
|
||||
filename: params.attachment.filename,
|
||||
mimeType: params.attachment.mimeType,
|
||||
sizeBytes: params.attachment.sizeBytes,
|
||||
storageKey: params.attachment.storageKey,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (params.visibility === "internal") {
|
||||
return recordInternalNote({
|
||||
ticketId: params.ticketId,
|
||||
agentId: params.agentId,
|
||||
agentName: params.agentName,
|
||||
body: params.body,
|
||||
attachments: attachmentInput ? [attachmentInput] : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
let emailMessageId: string | undefined;
|
||||
if (ticket.channel === "email") {
|
||||
const context = await getEmailThreadContext(params.ticketId);
|
||||
if (context) {
|
||||
emailMessageId = await sendTicketReplyEmail({
|
||||
to: context.customerEmail,
|
||||
subject: context.subject,
|
||||
body: params.body,
|
||||
inReplyTo: context.inReplyTo,
|
||||
attachment: params.attachment
|
||||
? { filename: params.attachment.filename, content: params.attachment.buffer }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await recordAgentReply({
|
||||
ticketId: params.ticketId,
|
||||
agentId: params.agentId,
|
||||
agentName: params.agentName,
|
||||
body: params.body,
|
||||
emailMessageId,
|
||||
attachments: attachmentInput ? [attachmentInput] : undefined,
|
||||
});
|
||||
|
||||
if (result.ticket.channel === "telegram") {
|
||||
const customer = await db.query.customers.findFirst({
|
||||
where: eq(customers.id, result.ticket.customerId),
|
||||
});
|
||||
if (customer?.telegramChatId) {
|
||||
if (params.attachment) {
|
||||
await sendTelegramFile(
|
||||
customer.telegramChatId,
|
||||
{ buffer: params.attachment.buffer, filename: params.attachment.filename, mimeType: params.attachment.mimeType },
|
||||
params.body,
|
||||
);
|
||||
} else {
|
||||
await sendTelegramMessage(customer.telegramChatId, params.body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in new issue
Block a user