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
33 lines
1.0 KiB
TypeScript
33 lines
1.0 KiB
TypeScript
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 });
|
|
}
|