From 89b9c5eaace20151a2e947c21f8b9153ac571cdd Mon Sep 17 00:00:00 2001 From: Oleg Date: Sun, 26 Jul 2026 17:32:12 +0000 Subject: [PATCH] Deferred backlog: attachments, canned responses, tags, search, notifications, password change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH --- src/app/(admin)/dashboard/ticket-board.tsx | 96 +- src/app/(admin)/desktop-notifications.tsx | 38 + src/app/(admin)/layout.tsx | 4 + src/app/(admin)/notification-toggle.tsx | 30 + src/app/(admin)/settings-menu.tsx | 7 +- .../settings/account/change-password-form.tsx | 90 ++ src/app/(admin)/settings/account/page.tsx | 14 + .../canned/canned-responses-manager.tsx | 161 ++++ src/app/(admin)/settings/canned/page.tsx | 15 + src/app/(admin)/settings/tags/page.tsx | 13 + .../(admin)/settings/tags/tags-manager.tsx | 95 ++ src/app/(admin)/tickets/[id]/tag-picker.tsx | 87 ++ .../(admin)/tickets/[id]/ticket-thread.tsx | 123 ++- .../t/[token]/[ticketId]/portal-thread.tsx | 79 +- src/app/api/attachments/[id]/route.ts | 48 + src/app/api/auth/change-password/route.ts | 40 + src/app/api/canned-responses/[id]/route.ts | 35 + src/app/api/canned-responses/route.ts | 32 + .../tickets/[ticketId]/attachments/route.ts | 65 ++ src/app/api/tags/[id]/route.ts | 14 + src/app/api/tags/route.ts | 32 + src/app/api/tickets/[id]/attachments/route.ts | 56 ++ src/app/api/tickets/[id]/messages/route.ts | 51 +- src/app/api/tickets/[id]/tags/route.ts | 23 + src/app/api/tickets/route.ts | 5 +- src/app/api/widget/attachments/route.ts | 98 ++ src/app/widget/chat/widget-chat.tsx | 65 +- src/components/attachment-chip.tsx | 40 + src/lib/attachments/storage.ts | 28 + .../db/migrations/0004_familiar_kronos.sql | 33 + src/lib/db/migrations/meta/0004_snapshot.json | 837 ++++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 7 + src/lib/db/schema.ts | 47 +- src/lib/mail/imap.ts | 17 + src/lib/mail/smtp.ts | 2 + src/lib/tags/colors.ts | 23 + src/lib/telegram/bot.ts | 91 +- src/lib/tickets/delivery.ts | 95 ++ src/lib/tickets/service.ts | 219 ++++- src/lib/tickets/types.ts | 16 + 40 files changed, 2758 insertions(+), 113 deletions(-) create mode 100644 src/app/(admin)/desktop-notifications.tsx create mode 100644 src/app/(admin)/notification-toggle.tsx create mode 100644 src/app/(admin)/settings/account/change-password-form.tsx create mode 100644 src/app/(admin)/settings/account/page.tsx create mode 100644 src/app/(admin)/settings/canned/canned-responses-manager.tsx create mode 100644 src/app/(admin)/settings/canned/page.tsx create mode 100644 src/app/(admin)/settings/tags/page.tsx create mode 100644 src/app/(admin)/settings/tags/tags-manager.tsx create mode 100644 src/app/(admin)/tickets/[id]/tag-picker.tsx create mode 100644 src/app/api/attachments/[id]/route.ts create mode 100644 src/app/api/auth/change-password/route.ts create mode 100644 src/app/api/canned-responses/[id]/route.ts create mode 100644 src/app/api/canned-responses/route.ts create mode 100644 src/app/api/portal/tickets/[ticketId]/attachments/route.ts create mode 100644 src/app/api/tags/[id]/route.ts create mode 100644 src/app/api/tags/route.ts create mode 100644 src/app/api/tickets/[id]/attachments/route.ts create mode 100644 src/app/api/tickets/[id]/tags/route.ts create mode 100644 src/app/api/widget/attachments/route.ts create mode 100644 src/components/attachment-chip.tsx create mode 100644 src/lib/attachments/storage.ts create mode 100644 src/lib/db/migrations/0004_familiar_kronos.sql create mode 100644 src/lib/db/migrations/meta/0004_snapshot.json create mode 100644 src/lib/tags/colors.ts create mode 100644 src/lib/tickets/delivery.ts diff --git a/src/app/(admin)/dashboard/ticket-board.tsx b/src/app/(admin)/dashboard/ticket-board.tsx index ee574a2..bc78686 100644 --- a/src/app/(admin)/dashboard/ticket-board.tsx +++ b/src/app/(admin)/dashboard/ticket-board.tsx @@ -1,14 +1,15 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { AnimatePresence, motion } from "framer-motion"; -import { Send, Globe, UserRound, Mail, MessageSquare, Plus, Inbox } from "lucide-react"; +import { Send, Globe, UserRound, Mail, MessageSquare, Plus, Inbox, Search, X } from "lucide-react"; import { useTicketEvents } from "@/lib/events/use-ticket-events"; import { formatRelativeTime } from "@/lib/format"; import type { TicketDTO, TicketStatus } from "@/lib/tickets/types"; import { NewTicketModal } from "./new-ticket-modal"; import { Avatar } from "@/components/avatar"; +import { TAG_COLOR_CLASSES } from "@/lib/tags/colors"; const COLUMNS: { status: TicketStatus; label: string; empty: string }[] = [ { status: "new", label: "Новые", empty: "Новых заявок нет" }, @@ -44,6 +45,10 @@ const PRIORITY_COLOR: Record = { export function TicketBoard({ initialTickets }: { initialTickets: TicketDTO[] }) { const [tickets, setTickets] = useState(initialTickets); const [modalOpen, setModalOpen] = useState(false); + const [activeTagId, setActiveTagId] = useState(null); + const [query, setQuery] = useState(""); + const [searchResults, setSearchResults] = useState(null); + const [searching, setSearching] = useState(false); useTicketEvents("/api/events", (event) => { if (event.type === "ticket.created" || event.type === "ticket.updated") { @@ -54,20 +59,50 @@ export function TicketBoard({ initialTickets }: { initialTickets: TicketDTO[] }) } }); + // Search hits the server (message bodies aren't loaded client-side), + // debounced. An empty query needs no fetch — baseTickets below just falls + // back to the live SSE-backed list, so there's nothing to reset here. + useEffect(() => { + if (!query.trim()) return; + const handle = setTimeout(() => { + setSearching(true); + fetch(`/api/tickets?q=${encodeURIComponent(query)}`) + .then((res) => res.json()) + .then((data) => setSearchResults(data.tickets ?? [])) + .finally(() => setSearching(false)); + }, 300); + return () => clearTimeout(handle); + }, [query]); + + const baseTickets = query.trim() ? (searchResults ?? tickets) : tickets; + + const availableTags = useMemo(() => { + const map = new Map(); + for (const ticket of baseTickets) { + for (const tag of ticket.tags) map.set(tag.id, tag); + } + return [...map.values()].sort((a, b) => a.name.localeCompare(b.name)); + }, [baseTickets]); + + const filteredTickets = useMemo(() => { + if (!activeTagId) return baseTickets; + return baseTickets.filter((t) => t.tags.some((tag) => tag.id === activeTagId)); + }, [baseTickets, activeTagId]); + const byStatus = useMemo(() => { const grouped: Record = { new: [], open: [], pending: [], closed: [] }; - for (const ticket of tickets) { + for (const ticket of filteredTickets) { grouped[ticket.status].push(ticket); } for (const list of Object.values(grouped)) { list.sort((a, b) => b.lastMessageAt - a.lastMessageAt); } return grouped; - }, [tickets]); + }, [filteredTickets]); return (
-
+

Заявки

+
+ + setQuery(e.target.value)} + placeholder="Поиск по заявкам…" + className="w-full rounded-md border border-border bg-surface py-2 pl-9 pr-8 text-sm outline-none focus:border-accent" + /> + {query && ( + + )} +
+ + {query.trim() && searchResults && ( +

+ {searching ? "Ищем…" : `Найдено: ${searchResults.length}`} +

+ )} + + {availableTags.length > 0 && ( +
+ {availableTags.map((tag) => ( + + ))} +
+ )} +
{COLUMNS.map((col) => (
@@ -122,7 +197,16 @@ function TicketCard({ ticket }: { ticket: TicketDTO }) { className="card block p-3 shadow-sm transition-shadow hover:shadow-md" style={{ borderLeft: `3px solid ${PRIORITY_COLOR[ticket.priority]}` }} > -

{ticket.subject}

+

{ticket.subject}

+ {ticket.tags.length > 0 && ( +
+ {ticket.tags.map((tag) => ( + + {tag.name} + + ))} +
+ )}
diff --git a/src/app/(admin)/desktop-notifications.tsx b/src/app/(admin)/desktop-notifications.tsx new file mode 100644 index 0000000..0c05c0f --- /dev/null +++ b/src/app/(admin)/desktop-notifications.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useTicketEvents } from "@/lib/events/use-ticket-events"; + +/** + * No visible UI — just a permanently-mounted subscriber (in the admin + * layout, so it's alive regardless of which admin page is open) that fires + * a desktop notification for events that mean "a customer needs a + * response," while the tab is in the background. Agent-authored messages + * are deliberately skipped — that's the agent's own action, not a signal. + */ +export function DesktopNotifications() { + const router = useRouter(); + + useTicketEvents("/api/events", (event) => { + if (typeof window === "undefined" || !("Notification" in window)) return; + if (Notification.permission !== "granted" || !document.hidden) return; + + if (event.type === "ticket.created") { + const notification = new Notification("Новая заявка", { body: event.ticket.subject }); + notification.onclick = () => { + window.focus(); + router.push(`/tickets/${event.ticket.id}`); + }; + } else if (event.type === "message.created" && event.message.authorType === "customer") { + const notification = new Notification(`Сообщение от ${event.message.authorName}`, { + body: event.message.body.slice(0, 120), + }); + notification.onclick = () => { + window.focus(); + router.push(`/tickets/${event.ticketId}`); + }; + } + }); + + return null; +} diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx index 3b46997..5e47295 100644 --- a/src/app/(admin)/layout.tsx +++ b/src/app/(admin)/layout.tsx @@ -5,6 +5,8 @@ import { LogoutButton } from "./logout-button"; import { NavLinks } from "./nav-links"; import { ThemeToggle } from "./theme-toggle"; import { SettingsMenu } from "./settings-menu"; +import { NotificationToggle } from "./notification-toggle"; +import { DesktopNotifications } from "./desktop-notifications"; export default async function AdminLayout({ children }: { children: React.ReactNode }) { const session = await getCurrentSession(); @@ -24,6 +26,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
{session.user.name} + @@ -34,6 +37,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
{children}
+
); } diff --git a/src/app/(admin)/notification-toggle.tsx b/src/app/(admin)/notification-toggle.tsx new file mode 100644 index 0000000..c17e03e --- /dev/null +++ b/src/app/(admin)/notification-toggle.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useState } from "react"; +import { Bell, BellOff } from "lucide-react"; + +function initialPermission(): NotificationPermission | "unsupported" { + if (typeof window === "undefined" || !("Notification" in window)) return "unsupported"; + return Notification.permission; +} + +export function NotificationToggle() { + const [permission, setPermission] = useState(initialPermission); + + if (permission === "unsupported") return null; + + async function requestPermission() { + if (permission === "granted") return; // no API to revoke it programmatically — only the browser's own site settings can + setPermission(await Notification.requestPermission()); + } + + return ( + + ); +} diff --git a/src/app/(admin)/settings-menu.tsx b/src/app/(admin)/settings-menu.tsx index b30c307..566a5af 100644 --- a/src/app/(admin)/settings-menu.tsx +++ b/src/app/(admin)/settings-menu.tsx @@ -3,12 +3,15 @@ import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Settings, Send, Mail, Code2 } from "lucide-react"; +import { Settings, Send, Mail, Code2, MessageSquareText, Tags, UserCog } from "lucide-react"; const items = [ { href: "/settings/telegram", label: "Telegram", icon: Send }, { href: "/settings/email", label: "Почта", icon: Mail }, { href: "/settings/widget", label: "Виджет", icon: Code2 }, + { href: "/settings/canned", label: "Шаблоны ответов", icon: MessageSquareText }, + { href: "/settings/tags", label: "Теги", icon: Tags }, + { href: "/settings/account", label: "Аккаунт", icon: UserCog }, ]; export function SettingsMenu() { @@ -39,7 +42,7 @@ export function SettingsMenu() { {open && ( -
+
{items.map(({ href, label, icon: Icon }) => { const active = pathname.startsWith(href); return ( diff --git a/src/app/(admin)/settings/account/change-password-form.tsx b/src/app/(admin)/settings/account/change-password-form.tsx new file mode 100644 index 0000000..1f48911 --- /dev/null +++ b/src/app/(admin)/settings/account/change-password-form.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useState } from "react"; +import { KeyRound } from "lucide-react"; + +export function ChangePasswordForm() { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSuccess(false); + + if (newPassword !== confirmPassword) { + setError("Пароли не совпадают"); + return; + } + + setLoading(true); + const res = await fetch("/api/auth/change-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ currentPassword, newPassword }), + }); + setLoading(false); + + if (!res.ok) { + const data = await res.json().catch(() => null); + setError(data?.error ?? "Не удалось сменить пароль"); + return; + } + + setSuccess(true); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } + + return ( +
+ + + + + {error &&

{error}

} + {success && ( +

Пароль изменён

+ )} + + +
+ ); +} diff --git a/src/app/(admin)/settings/account/page.tsx b/src/app/(admin)/settings/account/page.tsx new file mode 100644 index 0000000..7301876 --- /dev/null +++ b/src/app/(admin)/settings/account/page.tsx @@ -0,0 +1,14 @@ +import { getCurrentSession } from "@/lib/auth/session"; +import { ChangePasswordForm } from "./change-password-form"; + +export default async function AccountSettingsPage() { + const session = await getCurrentSession(); + + return ( +
+

Аккаунт

+

{session?.user.email}

+ +
+ ); +} diff --git a/src/app/(admin)/settings/canned/canned-responses-manager.tsx b/src/app/(admin)/settings/canned/canned-responses-manager.tsx new file mode 100644 index 0000000..8e3aa68 --- /dev/null +++ b/src/app/(admin)/settings/canned/canned-responses-manager.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useState } from "react"; +import { Plus, Pencil, Trash2, X, Check } from "lucide-react"; + +interface CannedResponse { + id: string; + title: string; + body: string; +} + +export function CannedResponsesManager({ initialResponses }: { initialResponses: CannedResponse[] }) { + const [responses, setResponses] = useState(initialResponses); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [editingId, setEditingId] = useState(null); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + + const res = await fetch("/api/canned-responses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, body }), + }); + + setLoading(false); + if (!res.ok) { + const data = await res.json().catch(() => null); + setError(data?.error ?? "Не удалось создать шаблон"); + return; + } + + const { response } = await res.json(); + setResponses((prev) => [...prev, response].sort((a, b) => a.title.localeCompare(b.title))); + setTitle(""); + setBody(""); + } + + async function handleDelete(id: string) { + await fetch(`/api/canned-responses/${id}`, { method: "DELETE" }); + setResponses((prev) => prev.filter((r) => r.id !== id)); + } + + async function handleUpdate(id: string, newTitle: string, newBody: string) { + const res = await fetch(`/api/canned-responses/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: newTitle, body: newBody }), + }); + if (res.ok) { + const { response } = await res.json(); + setResponses((prev) => prev.map((r) => (r.id === id ? response : r))); + setEditingId(null); + } + } + + return ( +
+
+ +