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,48 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { getAttachmentContext, getCustomerByPortalToken } from "@/lib/tickets/service";
|
||||
import { readAttachmentBuffer } from "@/lib/attachments/storage";
|
||||
|
||||
function contentDisposition(type: "inline" | "attachment", filename: string): string {
|
||||
const asciiFallback = filename.replace(/[^\x20-\x7E]/g, "_");
|
||||
return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
|
||||
}
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const context = await getAttachmentContext(id);
|
||||
if (!context) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const session = await getCurrentSession();
|
||||
let authorized = Boolean(session);
|
||||
|
||||
// Internal-note attachments are never servable via a customer token, only
|
||||
// an agent session — same rule getTicketForCustomer applies to messages.
|
||||
if (!authorized && context.message.visibility === "public") {
|
||||
const token = new URL(request.url).searchParams.get("token");
|
||||
if (token) {
|
||||
const customer = await getCustomerByPortalToken(token);
|
||||
authorized = Boolean(customer && customer.id === context.ticket.customerId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!authorized) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const buffer = await readAttachmentBuffer(context.attachment.storageKey);
|
||||
const isImage = context.attachment.mimeType.startsWith("image/");
|
||||
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
"Content-Type": context.attachment.mimeType,
|
||||
"Content-Disposition": contentDisposition(isImage ? "inline" : "attachment", context.attachment.filename),
|
||||
"Content-Length": String(context.attachment.sizeBytes),
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { verifyPassword, hashPassword } from "@/lib/auth/password";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { users } from "@/lib/db/schema";
|
||||
|
||||
const schema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8, "Пароль должен быть не короче 8 символов"),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const ok = await verifyPassword(user.passwordHash, parsed.data.currentPassword);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ error: "Текущий пароль неверен" }, { status: 401 });
|
||||
}
|
||||
|
||||
const newHash = await hashPassword(parsed.data.newPassword);
|
||||
await db.update(users).set({ passwordHash: newHash }).where(eq(users.id, user.id));
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { updateCannedResponse, deleteCannedResponse } from "@/lib/tickets/service";
|
||||
|
||||
const updateSchema = z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
body: z.string().min(1).max(5_000),
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const canned = await updateCannedResponse(id, parsed.data.title, parsed.data.body);
|
||||
return NextResponse.json({ response: canned });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const { id } = await params;
|
||||
await deleteCannedResponse(id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { listCannedResponses, createCannedResponse } from "@/lib/tickets/service";
|
||||
|
||||
export async function GET() {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
return NextResponse.json({ responses: await listCannedResponses() });
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
body: z.string().min(1).max(5_000),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const canned = await createCannedResponse(parsed.data.title, parsed.data.body);
|
||||
return NextResponse.json({ response: canned }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCustomerByPortalToken, appendCustomerReply } from "@/lib/tickets/service";
|
||||
import { saveAttachment, AttachmentTooLargeError, MAX_ATTACHMENT_BYTES } from "@/lib/attachments/storage";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ ticketId: string }> },
|
||||
) {
|
||||
const { ticketId } = await params;
|
||||
const formData = await request.formData().catch(() => null);
|
||||
|
||||
const token = formData?.get("token");
|
||||
if (typeof token !== "string" || !token) {
|
||||
return NextResponse.json({ error: "Missing token" }, { status: 400 });
|
||||
}
|
||||
|
||||
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 customer = await getCustomerByPortalToken(token);
|
||||
if (!customer) {
|
||||
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
|
||||
}
|
||||
|
||||
const captionRaw = formData?.get("caption");
|
||||
const caption = typeof captionRaw === "string" ? captionRaw.trim() : "";
|
||||
|
||||
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 appendCustomerReply({
|
||||
ticketId,
|
||||
customerId: customer.id,
|
||||
authorName: customer.displayName,
|
||||
body: caption || file.name || "Вложение",
|
||||
attachments: [
|
||||
{
|
||||
filename: file.name || "file",
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
sizeBytes: saved.sizeBytes,
|
||||
storageKey: saved.storageKey,
|
||||
},
|
||||
],
|
||||
});
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { deleteTag } from "@/lib/tickets/service";
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const { id } = await params;
|
||||
await deleteTag(id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { listTags, createTag } from "@/lib/tickets/service";
|
||||
|
||||
export async function GET() {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
return NextResponse.json({ tags: await listTags() });
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1).max(50),
|
||||
color: z.enum(["accent", "success", "warning", "info", "rose", "danger"]),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const tag = await createTag(parsed.data.name, parsed.data.color);
|
||||
return NextResponse.json({ tag }, { status: 201 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,7 @@ export const runtime = "nodejs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { recordAgentReply, recordInternalNote, getEmailThreadContext } from "@/lib/tickets/service";
|
||||
import { sendTelegramMessage } from "@/lib/telegram/bot";
|
||||
import { sendTicketReplyEmail } from "@/lib/mail/smtp";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { customers } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { deliverAgentMessage } from "@/lib/tickets/delivery";
|
||||
|
||||
const messageSchema = z.object({
|
||||
body: z.string().min(1).max(10_000),
|
||||
@@ -26,50 +21,16 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({ where: (t, { eq: eqOp }) => eqOp(t.id, id) });
|
||||
if (!ticket) {
|
||||
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (parsed.data.visibility === "internal") {
|
||||
const result = await recordInternalNote({
|
||||
try {
|
||||
const result = await deliverAgentMessage({
|
||||
ticketId: id,
|
||||
agentId: session.user.id,
|
||||
agentName: session.user.name,
|
||||
body: parsed.data.body,
|
||||
visibility: parsed.data.visibility,
|
||||
});
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
let emailMessageId: string | undefined;
|
||||
if (ticket.channel === "email") {
|
||||
const context = await getEmailThreadContext(id);
|
||||
if (context) {
|
||||
emailMessageId = await sendTicketReplyEmail({
|
||||
to: context.customerEmail,
|
||||
subject: context.subject,
|
||||
body: parsed.data.body,
|
||||
inReplyTo: context.inReplyTo,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await recordAgentReply({
|
||||
ticketId: id,
|
||||
agentId: session.user.id,
|
||||
agentName: session.user.name,
|
||||
body: parsed.data.body,
|
||||
emailMessageId,
|
||||
});
|
||||
|
||||
if (result.ticket.channel === "telegram") {
|
||||
const customer = await db.query.customers.findFirst({
|
||||
where: eq(customers.id, result.ticket.customerId),
|
||||
});
|
||||
if (customer?.telegramChatId) {
|
||||
await sendTelegramMessage(customer.telegramChatId, parsed.data.body);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { setTicketTags } from "@/lib/tickets/service";
|
||||
|
||||
const schema = z.object({ tagIds: z.array(z.string()) });
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const tags = await setTicketTags(id, parsed.data.tagIds);
|
||||
return NextResponse.json({ tags });
|
||||
}
|
||||
@@ -5,11 +5,12 @@ import { z } from "zod";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { listTickets, createManualTicket } from "@/lib/tickets/service";
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: Request) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const tickets = await listTickets();
|
||||
const query = new URL(request.url).searchParams.get("q") ?? undefined;
|
||||
const tickets = await listTickets(query);
|
||||
return NextResponse.json({ tickets });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getWidgetSiteByKey,
|
||||
getCustomerByPortalToken,
|
||||
renameCustomerIfGuest,
|
||||
recordChatInboundMessage,
|
||||
} from "@/lib/tickets/service";
|
||||
import { saveAttachment, AttachmentTooLargeError, MAX_ATTACHMENT_BYTES } from "@/lib/attachments/storage";
|
||||
|
||||
// Same shape as /api/widget/messages' limiter — kept separate rather than
|
||||
// shared since each route's rate budget is independent.
|
||||
const attempts = new Map<string, { count: number; resetAt: number }>();
|
||||
const MAX_ATTEMPTS = 10;
|
||||
const WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
function isRateLimited(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = attempts.get(key);
|
||||
if (!entry || entry.resetAt < now) {
|
||||
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count > MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const formData = await request.formData().catch(() => null);
|
||||
|
||||
const siteKey = formData?.get("siteKey");
|
||||
const token = formData?.get("token");
|
||||
if (typeof siteKey !== "string" || !siteKey || typeof token !== "string" || !token) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (isRateLimited(token)) {
|
||||
return NextResponse.json({ error: "Слишком много сообщений — подождите немного" }, { status: 429 });
|
||||
}
|
||||
|
||||
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 site = await getWidgetSiteByKey(siteKey);
|
||||
if (!site || !site.enabled) {
|
||||
return NextResponse.json({ error: "Unknown or disabled widget" }, { status: 404 });
|
||||
}
|
||||
|
||||
const customer = await getCustomerByPortalToken(token);
|
||||
if (!customer) {
|
||||
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
|
||||
}
|
||||
|
||||
const nameRaw = formData?.get("name");
|
||||
const name = typeof nameRaw === "string" ? nameRaw.trim() : "";
|
||||
if (name) {
|
||||
await renameCustomerIfGuest(customer.id, name);
|
||||
}
|
||||
|
||||
const captionRaw = formData?.get("caption");
|
||||
const caption = typeof captionRaw === "string" ? captionRaw.trim() : "";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const body = caption || file.name || "Вложение";
|
||||
const result = await recordChatInboundMessage({
|
||||
customerId: customer.id,
|
||||
channel: "widget",
|
||||
body,
|
||||
authorName: name || customer.displayName,
|
||||
subjectForNewTicket: `Чат (${site.name}): ${body.slice(0, 60)}`,
|
||||
attachments: [
|
||||
{
|
||||
filename: file.name || "file",
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
sizeBytes: saved.sizeBytes,
|
||||
storageKey: saved.storageKey,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
Reference in new issue
Block a user