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,56 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { deliverAgentMessage } from "@/lib/tickets/delivery";
|
||||
import { saveAttachment, AttachmentTooLargeError, MAX_ATTACHMENT_BYTES } from "@/lib/attachments/storage";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const { id } = await params;
|
||||
const formData = await request.formData().catch(() => null);
|
||||
const file = formData?.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "Missing file" }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
return NextResponse.json({ error: "File too large" }, { status: 413 });
|
||||
}
|
||||
|
||||
const captionRaw = formData?.get("caption");
|
||||
const caption = typeof captionRaw === "string" ? captionRaw.trim() : "";
|
||||
const visibility = formData?.get("visibility") === "internal" ? "internal" : "public";
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
let saved;
|
||||
try {
|
||||
saved = await saveAttachment(buffer);
|
||||
} catch (err) {
|
||||
if (err instanceof AttachmentTooLargeError) {
|
||||
return NextResponse.json({ error: "File too large" }, { status: 413 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deliverAgentMessage({
|
||||
ticketId: id,
|
||||
agentId: session.user.id,
|
||||
agentName: session.user.name,
|
||||
body: caption || file.name || "Вложение",
|
||||
visibility,
|
||||
attachment: {
|
||||
filename: file.name || "file",
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
sizeBytes: saved.sizeBytes,
|
||||
storageKey: saved.storageKey,
|
||||
buffer,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user