Internal notes ("Внутренняя заметка") were visible to any agent who
opened the ticket — the compose toggle and the SSE-delivered live
updates had no role check. Agents now never see the internal/public
toggle (they only ever reply publicly) and internal messages are
filtered out of both the initial server-rendered load and live SSE
message.created events.
Fixing that surfaced a bigger gap: the ticket detail page and every
per-ticket API route (GET/PATCH /api/tickets/[id], POST .../messages,
POST .../attachments, PUT .../tags) had no ownership check at all — an
agent could open, reply to, tag, reassign, or read the full message
history of *any* ticket by URL/API, not just their own, regardless of
the dashboard-level filtering added earlier. All five now reuse
isTicketVisibleTo() to 404/redirect for tickets an agent doesn't own.
Verified live: an agent opening a ticket assigned to them sees public
messages but not an admin's internal note or the note-vs-reply toggle;
opening a ticket assigned to someone else redirects to /dashboard on
the page and returns 404 from the API. Test accounts/data removed after.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
67 lines
2.4 KiB
TypeScript
67 lines
2.4 KiB
TypeScript
export const runtime = "nodejs";
|
|
|
|
import { NextResponse } from "next/server";
|
|
import { requireSession } from "@/lib/auth/require";
|
|
import { deliverAgentMessage } from "@/lib/tickets/delivery";
|
|
import { getTicketWithMessages } from "@/lib/tickets/service";
|
|
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
|
|
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 currentUser = { id: session.user.id, role: session.user.role };
|
|
const { id } = await params;
|
|
|
|
const existing = await getTicketWithMessages(id);
|
|
if (!existing || !isTicketVisibleTo(existing.ticket, currentUser)) {
|
|
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
|
|
}
|
|
|
|
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() : "";
|
|
// Internal notes are admin-only — see messages/route.ts for the same rule.
|
|
const visibility = currentUser.role === "admin" && 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 });
|
|
}
|
|
}
|