Files
top-tickets/src/app/(admin)/tickets/[id]/ticket-thread.tsx
T
ogrechkoandClaude Sonnet 5 681f89000f Make internal notes admin-only, close a direct-URL ticket access gap
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
2026-08-05 18:22:46 +00:00

348 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowLeft, Send, StickyNote, Paperclip, X, MessageSquareText } from "lucide-react";
import { useTicketEvents } from "@/lib/events/use-ticket-events";
import { formatRelativeTime } from "@/lib/format";
import { Avatar } from "@/components/avatar";
import { AttachmentChip } from "@/components/attachment-chip";
import { TagPicker } from "./tag-picker";
import type { TicketDTO, MessageDTO, TicketStatus } from "@/lib/tickets/types";
const STATUS_LABEL: Record<TicketStatus, string> = {
new: "Новая",
open: "Открыта",
pending: "В ожидании",
closed: "Закрыта",
};
export function TicketThread({
ticket: initialTicket,
initialMessages,
agents,
currentUser,
}: {
ticket: TicketDTO;
initialMessages: MessageDTO[];
agents: { id: string; name: string }[];
currentUser: { id: string; role: "admin" | "agent" };
}) {
const isAdmin = currentUser.role === "admin";
const [ticket, setTicket] = useState(initialTicket);
const [messages, setMessages] = useState(initialMessages);
const [draft, setDraft] = useState("");
const [visibility, setVisibility] = useState<"public" | "internal">("public");
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [sending, setSending] = useState(false);
const [cannedResponses, setCannedResponses] = useState<{ id: string; title: string; body: string }[]>([]);
const [cannedOpen, setCannedOpen] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const cannedRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetch("/api/canned-responses")
.then((res) => res.json())
.then((data) => setCannedResponses(data.responses ?? []));
}, []);
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (cannedRef.current && !cannedRef.current.contains(e.target as Node)) setCannedOpen(false);
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
function insertCanned(body: string) {
setDraft((prev) => (prev.trim() ? `${prev}\n${body}` : body));
setCannedOpen(false);
}
useTicketEvents("/api/events", (event) => {
if (event.type === "ticket.updated" && event.ticket.id === ticket.id) {
setTicket(event.ticket);
}
if (event.type === "message.created" && event.ticketId === ticket.id) {
// Internal notes are admin-only — an agent's SSE connection still
// receives every event on the bus, so this has to be filtered here
// too, not just at the initial server-rendered load.
if (event.message.visibility === "internal" && !isAdmin) return;
setMessages((prev) => (prev.some((m) => m.id === event.message.id) ? prev : [...prev, event.message]));
}
});
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages.length]);
async function sendReply() {
if (!draft.trim() && !pendingFile) return;
setSending(true);
let res: Response;
if (pendingFile) {
const formData = new FormData();
formData.append("file", pendingFile);
formData.append("caption", draft);
formData.append("visibility", visibility);
res = await fetch(`/api/tickets/${ticket.id}/attachments`, { method: "POST", body: formData });
} else {
res = await fetch(`/api/tickets/${ticket.id}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: draft, visibility }),
});
}
setSending(false);
if (res.ok) {
setDraft("");
setPendingFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function updateStatus(status: TicketStatus) {
const res = await fetch(`/api/tickets/${ticket.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
if (res.ok) {
const { ticket: updated } = await res.json();
setTicket(updated);
}
}
async function updateAssignee(assigneeId: string) {
const res = await fetch(`/api/tickets/${ticket.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assigneeId: assigneeId || null }),
});
if (res.ok) {
const { ticket: updated } = await res.json();
setTicket(updated);
}
}
return (
<div className="flex h-[calc(100vh-11rem)] flex-col sm:h-[calc(100vh-6.5rem)]">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<Link href="/dashboard" className="mb-1 flex items-center gap-1 text-xs text-text-muted hover:text-text">
<ArrowLeft size={13} />
Дашборд
</Link>
<h1 className="truncate font-display text-lg font-bold">{ticket.subject}</h1>
<span className="mb-2 flex items-center gap-1.5">
<Avatar name={ticket.customerName} size={18} />
<span className="text-sm text-text-muted">{ticket.customerName}</span>
</span>
<TagPicker
ticketId={ticket.id}
selectedTags={ticket.tags}
onChange={(tags) => setTicket((t) => ({ ...t, tags }))}
/>
</div>
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto">
<select
value={ticket.status}
onChange={(e) => updateStatus(e.target.value as TicketStatus)}
className="min-w-0 flex-1 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent sm:flex-none"
>
{Object.entries(STATUS_LABEL).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<select
value={ticket.assigneeId ?? ""}
onChange={(e) => updateAssignee(e.target.value)}
className="min-w-0 flex-1 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent sm:flex-none"
>
<option value="">Не назначено</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
</div>
</div>
<div className="card flex-1 overflow-y-auto p-4">
<AnimatePresence initial={false}>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</AnimatePresence>
<div ref={bottomRef} />
</div>
<div className="mt-3">
{/* Internal notes are admin-only — agents only ever reply to the client, so there's nothing to toggle. */}
{isAdmin && (
<div className="mb-1.5 flex gap-1">
<button
type="button"
onClick={() => setVisibility("public")}
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
visibility === "public" ? "bg-accent-soft text-accent-soft-text" : "text-text-faint hover:text-text-muted"
}`}
>
Ответ клиенту
</button>
<button
type="button"
onClick={() => setVisibility("internal")}
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
visibility === "internal" ? "bg-warning-soft text-warning-soft-text" : "text-text-faint hover:text-text-muted"
}`}
>
Заметка для команды
</button>
</div>
)}
{pendingFile && (
<div className="mb-1.5 flex items-center gap-2 rounded-md border border-border bg-surface-hover px-2.5 py-1.5 text-xs">
<Paperclip size={12} />
<span className="flex-1 truncate">{pendingFile.name}</span>
<button type="button" onClick={() => setPendingFile(null)} className="text-text-muted hover:text-text">
<X size={13} />
</button>
</div>
)}
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => setPendingFile(e.target.files?.[0] ?? null)}
/>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendReply();
}
}}
rows={2}
placeholder={visibility === "internal" ? "Заметка — клиент её не увидит…" : "Ответить клиенту…"}
className={`flex-1 resize-none rounded-md border px-3 py-2 text-sm outline-none ${
visibility === "internal"
? "border-warning-soft-text/30 bg-warning-soft focus:border-warning"
: "border-border bg-surface focus:border-accent"
}`}
/>
<div ref={cannedRef} className="relative self-end">
<button
type="button"
onClick={() => setCannedOpen((v) => !v)}
className="btn btn-ghost"
title="Шаблоны ответов"
>
<MessageSquareText size={15} />
</button>
{cannedOpen && (
<div className="card absolute bottom-full right-0 z-20 mb-2 max-h-64 w-72 overflow-y-auto p-1">
{cannedResponses.length === 0 && (
<p className="p-2 text-xs text-text-faint">Шаблонов пока нет</p>
)}
{cannedResponses.map((c) => (
<button
key={c.id}
type="button"
onClick={() => insertCanned(c.body)}
className="block w-full rounded-md px-3 py-2 text-left text-sm hover:bg-surface-hover"
>
<p className="font-medium">{c.title}</p>
<p className="truncate text-xs text-text-faint">{c.body}</p>
</button>
))}
</div>
)}
</div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="btn btn-ghost self-end"
title="Прикрепить файл"
>
<Paperclip size={15} />
</button>
<button
onClick={sendReply}
disabled={sending || (!draft.trim() && !pendingFile)}
className={visibility === "internal" ? "btn self-end bg-warning text-white hover:brightness-95" : "btn btn-primary self-end"}
>
{visibility === "internal" ? <StickyNote size={15} /> : <Send size={15} />}
</button>
</div>
</div>
</div>
);
}
function MessageBubble({ message }: { message: MessageDTO }) {
const isAgent = message.authorType === "agent";
const isSystem = message.authorType === "system";
if (isSystem) {
return (
<p className="my-2 text-center text-xs text-text-faint">{message.body}</p>
);
}
if (message.visibility === "internal") {
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18 }}
className="mb-3 rounded-lg border border-dashed border-warning-soft-text/40 bg-warning-soft px-3 py-2"
>
<p className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold text-warning-soft-text">
<StickyNote size={11} />
Внутренняя заметка
</p>
<p className="mb-1 whitespace-pre-wrap text-sm">{message.body}</p>
{message.attachments.map((a) => (
<AttachmentChip key={a.id} attachment={a} />
))}
<p className="text-[11px] text-warning-soft-text/80">
{message.authorName} · {formatRelativeTime(message.createdAt)}
</p>
</motion.div>
);
}
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18 }}
className={`mb-3 flex ${isAgent ? "justify-end" : "justify-start"}`}
>
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${isAgent ? "bg-accent text-white" : "bg-surface-hover"}`}>
<p className="mb-1 whitespace-pre-wrap">{message.body}</p>
{message.attachments.map((a) => (
<AttachmentChip key={a.id} attachment={a} />
))}
<p className={`text-[11px] ${isAgent ? "text-white/70" : "text-text-faint"}`}>
{message.authorName} · {formatRelativeTime(message.createdAt)}
</p>
</div>
</motion.div>
);
}