Files
top-tickets/src/lib/tickets/delivery.ts
T
ogrechkoandClaude Sonnet 5 89b9c5eaac 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
2026-07-26 17:32:12 +00:00

96 lines
3.0 KiB
TypeScript

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;
}