The "New ticket" modal only took a plain-text message. It now matches the existing reply box on an open ticket: a "Прикрепить файл" button, and Ctrl+V paste of a clipboard screenshot attaches it directly. POST /api/tickets switched from JSON to multipart/form-data to carry the optional file, reusing the same saveAttachment/attachment-delivery path replies already use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
75 lines
2.6 KiB
TypeScript
75 lines
2.6 KiB
TypeScript
export const runtime = "nodejs";
|
|
|
|
import { NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import { requireSession } from "@/lib/auth/require";
|
|
import { listTickets, createManualTicket } from "@/lib/tickets/service";
|
|
import { saveAttachment, AttachmentTooLargeError, MAX_ATTACHMENT_BYTES } from "@/lib/attachments/storage";
|
|
|
|
export async function GET(request: Request) {
|
|
const { session, response } = await requireSession();
|
|
if (!session) return response;
|
|
|
|
const query = new URL(request.url).searchParams.get("q") ?? undefined;
|
|
const tickets = await listTickets(query, { id: session.user.id, role: session.user.role });
|
|
return NextResponse.json({ tickets });
|
|
}
|
|
|
|
const createSchema = z.object({
|
|
subject: z.string().min(1).max(200),
|
|
body: z.string().min(1).max(10_000),
|
|
customerName: z.string().min(1).max(200),
|
|
customerEmail: z.string().email().optional().or(z.literal("")),
|
|
});
|
|
|
|
// multipart/form-data rather than JSON — the modal supports an optional
|
|
// attachment (incl. Ctrl+V screenshot paste), same as replying on an
|
|
// existing ticket.
|
|
export async function POST(request: Request) {
|
|
const { session, response } = await requireSession();
|
|
if (!session) return response;
|
|
|
|
const formData = await request.formData().catch(() => null);
|
|
if (!formData) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
|
|
|
const parsed = createSchema.safeParse({
|
|
subject: formData.get("subject"),
|
|
body: formData.get("body"),
|
|
customerName: formData.get("customerName"),
|
|
customerEmail: formData.get("customerEmail") ?? undefined,
|
|
});
|
|
if (!parsed.success) {
|
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
|
}
|
|
|
|
const file = formData.get("file");
|
|
let attachment: { filename: string; mimeType: string; sizeBytes: number; storageKey: string } | undefined;
|
|
if (file instanceof File) {
|
|
if (file.size > MAX_ATTACHMENT_BYTES) {
|
|
return NextResponse.json({ error: "File too large" }, { status: 413 });
|
|
}
|
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
try {
|
|
const saved = await saveAttachment(buffer);
|
|
attachment = {
|
|
filename: file.name || "file",
|
|
mimeType: file.type || "application/octet-stream",
|
|
sizeBytes: saved.sizeBytes,
|
|
storageKey: saved.storageKey,
|
|
};
|
|
} catch (err) {
|
|
if (err instanceof AttachmentTooLargeError) {
|
|
return NextResponse.json({ error: "File too large" }, { status: 413 });
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
const result = await createManualTicket({
|
|
...parsed.data,
|
|
customerEmail: parsed.data.customerEmail || undefined,
|
|
attachment,
|
|
});
|
|
return NextResponse.json(result, { status: 201 });
|
|
}
|