MVP-1: helpdesk core with dashboard, Telegram channel, customer portal

Next.js 16 + Drizzle/SQLite (matching ~/project-claude conventions), SSE-based
realtime updates, admin dashboard with status columns, ticket thread view,
Telegram long-polling integration (inbound + outbound), token-based customer
portal, Docker Compose packaging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-07-26 12:40:56 +00:00
1 parent 888b7b82ba
commit cf0b6cf58e
59 files changed
+12556

No files matched your search

@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
export function NewTicketModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [subject, setSubject] = useState("");
const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState("");
const [body, setBody] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
function reset() {
setSubject("");
setCustomerName("");
setCustomerEmail("");
setBody("");
setError(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/tickets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
subject,
customerName,
customerEmail: customerEmail || undefined,
body,
}),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось создать заявку");
return;
}
reset();
onClose();
}
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.form
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.98 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="card w-full max-w-md p-5"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold">Новая заявка</h2>
<button type="button" onClick={onClose} className="text-text-muted hover:text-text">
<X size={18} />
</button>
</div>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Тема</span>
<input
required
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="block text-sm">
<span className="mb-1 block font-medium text-text-muted">Клиент</span>
<input
required
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
<label className="block text-sm">
<span className="mb-1 block font-medium text-text-muted">Email</span>
<input
type="email"
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
</div>
<label className="mb-4 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Сообщение</span>
<textarea
required
rows={4}
value={body}
onChange={(e) => setBody(e.target.value)}
className="w-full resize-none rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
{loading ? "Создаём…" : "Создать заявку"}
</button>
</motion.form>
</motion.div>
)}
</AnimatePresence>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { listTickets } from "@/lib/tickets/service";
import { TicketBoard } from "./ticket-board";
export default async function DashboardPage() {
const tickets = await listTickets();
return <TicketBoard initialTickets={tickets} />;
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import { Send, Globe, UserRound, Plus } 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";
const COLUMNS: { status: TicketStatus; label: string }[] = [
{ status: "new", label: "Новые" },
{ status: "open", label: "Открытые" },
{ status: "pending", label: "В ожидании" },
{ status: "closed", label: "Закрытые" },
];
const CHANNEL_ICON = { telegram: Send, portal: Globe, manual: UserRound } as const;
const PRIORITY_COLOR: Record<TicketDTO["priority"], string> = {
low: "var(--text-faint)",
normal: "var(--border-strong)",
high: "var(--warning)",
urgent: "var(--danger)",
};
export function TicketBoard({ initialTickets }: { initialTickets: TicketDTO[] }) {
const [tickets, setTickets] = useState<TicketDTO[]>(initialTickets);
const [modalOpen, setModalOpen] = useState(false);
useTicketEvents("/api/events", (event) => {
if (event.type === "ticket.created" || event.type === "ticket.updated") {
setTickets((prev) => {
const withoutIt = prev.filter((t) => t.id !== event.ticket.id);
return [event.ticket, ...withoutIt];
});
}
});
const byStatus = useMemo(() => {
const grouped: Record<TicketStatus, TicketDTO[]> = { new: [], open: [], pending: [], closed: [] };
for (const ticket of tickets) {
grouped[ticket.status].push(ticket);
}
for (const list of Object.values(grouped)) {
list.sort((a, b) => b.lastMessageAt - a.lastMessageAt);
}
return grouped;
}, [tickets]);
return (
<div>
<div className="mb-5 flex items-center justify-between">
<h1 className="text-xl font-bold tracking-tight">Заявки</h1>
<button className="btn btn-primary" onClick={() => setModalOpen(true)}>
<Plus size={16} />
Новая заявка
</button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{COLUMNS.map((col) => (
<div key={col.status} className="min-w-0">
<div className="mb-2 flex items-center gap-2 px-1">
<h2 className="text-sm font-semibold text-text-muted">{col.label}</h2>
<span className="rounded-full bg-surface-hover px-1.5 text-xs text-text-faint">
{byStatus[col.status].length}
</span>
</div>
<div className="flex flex-col gap-2">
<AnimatePresence initial={false}>
{byStatus[col.status].map((ticket) => (
<TicketCard key={ticket.id} ticket={ticket} />
))}
</AnimatePresence>
{byStatus[col.status].length === 0 && (
<p className="rounded-md border border-dashed border-border px-3 py-6 text-center text-xs text-text-faint">
Пусто
</p>
)}
</div>
</div>
))}
</div>
<NewTicketModal open={modalOpen} onClose={() => setModalOpen(false)} />
</div>
);
}
function TicketCard({ ticket }: { ticket: TicketDTO }) {
const Icon = CHANNEL_ICON[ticket.channel];
return (
<motion.div
layout
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.97 }}
transition={{ duration: 0.18, ease: "easeOut" }}
>
<Link
href={`/tickets/${ticket.id}`}
className="card block p-3 transition-shadow hover:shadow-md"
style={{ borderLeft: `3px solid ${PRIORITY_COLOR[ticket.priority]}` }}
>
<p className="mb-1.5 line-clamp-2 text-sm font-medium">{ticket.subject}</p>
<div className="flex items-center justify-between text-xs text-text-muted">
<span className="flex items-center gap-1 truncate">
<Icon size={12} />
{ticket.customerName}
</span>
<span className="shrink-0 text-text-faint">{formatRelativeTime(ticket.lastMessageAt)}</span>
</div>
</Link>
</motion.div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
import { Ticket } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session";
import { LogoutButton } from "./logout-button";
import { NavLinks } from "./nav-links";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await getCurrentSession();
if (!session) {
redirect("/login");
}
return (
<div className="flex min-h-screen flex-col bg-bg">
<header className="border-b border-border bg-surface">
<div className="mx-auto flex max-w-7xl items-center gap-6 px-6 py-3">
<div className="flex items-center gap-2 font-bold tracking-tight">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-accent text-white">
<Ticket size={15} strokeWidth={2.5} />
</div>
top-tickets
</div>
<NavLinks />
<div className="ml-auto flex items-center gap-3">
<span className="text-sm text-text-muted">{session.user.name}</span>
<LogoutButton />
</div>
</div>
</header>
<main className="mx-auto w-full max-w-7xl flex-1 px-6 py-6">{children}</main>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { LogOut } from "lucide-react";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh();
}
return (
<button onClick={handleLogout} className="btn btn-ghost" title="Выйти">
<LogOut size={15} />
</button>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutDashboard, Settings } from "lucide-react";
const links = [
{ href: "/dashboard", label: "Дашборд", icon: LayoutDashboard },
{ href: "/settings/telegram", label: "Telegram", icon: Settings },
];
export function NavLinks() {
const pathname = usePathname();
return (
<nav className="flex items-center gap-1">
{links.map(({ href, label, icon: Icon }) => {
const active = pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
active ? "bg-accent-soft text-accent-soft-text" : "text-text-muted hover:bg-surface-hover hover:text-text"
}`}
>
<Icon size={15} />
{label}
</Link>
);
})}
</nav>
);
}
@@ -0,0 +1,15 @@
import { getTelegramStatus } from "@/lib/telegram/bot";
import { TelegramSettingsForm } from "./telegram-settings-form";
export default async function TelegramSettingsPage() {
const status = await getTelegramStatus();
return (
<div className="max-w-lg">
<h1 className="mb-1 text-xl font-bold tracking-tight">Telegram</h1>
<p className="mb-6 text-sm text-text-muted">
Подключите бота — личные сообщения и сообщения из групп, куда он добавлен, будут превращаться в заявки.
</p>
<TelegramSettingsForm initialStatus={status} />
</div>
);
}
@@ -0,0 +1,99 @@
"use client";
import { useState } from "react";
import { CheckCircle2, XCircle } from "lucide-react";
interface Status {
configured: boolean;
enabled: boolean;
botUsername: string | null;
verifiedAt: number | null;
}
export function TelegramSettingsForm({ initialStatus }: { initialStatus: Status }) {
const [status, setStatus] = useState(initialStatus);
const [token, setToken] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleConnect(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/telegram/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ botToken: token }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось подключить бота");
return;
}
const data = await res.json();
setStatus({ configured: true, enabled: true, botUsername: data.botUsername, verifiedAt: Date.now() });
setToken("");
}
async function handleDisable() {
setLoading(true);
await fetch("/api/telegram/config", { method: "DELETE" });
setLoading(false);
setStatus((s) => ({ ...s, enabled: false }));
}
return (
<div className="card p-5">
<div className="mb-4 flex items-center gap-2 text-sm">
{status.enabled ? (
<>
<CheckCircle2 size={16} className="text-success" />
<span>
Подключено{status.botUsername ? `: @${status.botUsername}` : ""}
</span>
</>
) : (
<>
<XCircle size={16} className="text-text-faint" />
<span className="text-text-muted">Бот не подключён</span>
</>
)}
</div>
<form onSubmit={handleConnect}>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Bot API token</span>
<input
required
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="123456:ABC-DEF..."
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</label>
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<div className="flex gap-2">
<button type="submit" disabled={loading || !token} className="btn btn-primary">
{status.configured ? "Обновить токен" : "Подключить"}
</button>
{status.enabled && (
<button type="button" onClick={handleDisable} disabled={loading} className="btn btn-ghost">
Отключить
</button>
)}
</div>
</form>
<p className="mt-4 text-xs text-text-faint">
Чтобы бот видел все сообщения в группе (а не только те, где его упомянули), отключите Group Privacy Mode
через @BotFather → /setprivacy → Disable.
</p>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { notFound } from "next/navigation";
import { getTicketWithMessages, listAgents } from "@/lib/tickets/service";
import { TicketThread } from "./ticket-thread";
export default async function TicketPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const result = await getTicketWithMessages(id);
if (!result) notFound();
const agents = await listAgents();
return (
<TicketThread
ticket={result.ticket}
initialMessages={result.messages}
agents={agents.map((a) => ({ id: a.id, name: a.name }))}
/>
);
}
@@ -0,0 +1,178 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowLeft, Send } from "lucide-react";
import { useTicketEvents } from "@/lib/events/use-ticket-events";
import { formatRelativeTime } from "@/lib/format";
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,
}: {
ticket: TicketDTO;
initialMessages: MessageDTO[];
agents: { id: string; name: string }[];
}) {
const [ticket, setTicket] = useState(initialTicket);
const [messages, setMessages] = useState(initialMessages);
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
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) {
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()) return;
setSending(true);
const res = await fetch(`/api/tickets/${ticket.id}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: draft }),
});
setSending(false);
if (res.ok) setDraft("");
}
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-6.5rem)] flex-col">
<div className="mb-4 flex items-start justify-between gap-4">
<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 text-lg font-bold">{ticket.subject}</h1>
<p className="text-sm text-text-muted">{ticket.customerName}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<select
value={ticket.status}
onChange={(e) => updateStatus(e.target.value as TicketStatus)}
className="rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
>
{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="rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
>
<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 flex gap-2">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendReply();
}
}}
rows={2}
placeholder="Ответить клиенту…"
className="flex-1 resize-none rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
<button onClick={sendReply} disabled={sending || !draft.trim()} className="btn btn-primary self-end">
<Send size={15} />
</button>
</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>
);
}
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>
<p className={`text-[11px] ${isAgent ? "text-white/70" : "text-text-faint"}`}>
{message.authorName} · {formatRelativeTime(message.createdAt)}
</p>
</div>
</motion.div>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { Ticket, LogIn } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось войти");
setLoading(false);
return;
}
router.push("/dashboard");
router.refresh();
}
return (
<main className="flex min-h-screen items-center justify-center bg-bg px-4">
<motion.form
onSubmit={handleSubmit}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="card w-full max-w-sm p-8"
>
<div className="mb-6 flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white">
<Ticket size={18} strokeWidth={2.5} />
</div>
<span className="text-lg font-bold tracking-tight">top-tickets</span>
</div>
<h1 className="mb-1 text-xl font-semibold">Вход для агентов</h1>
<p className="mb-6 text-sm text-text-muted">Войдите, чтобы открыть дашборд заявок.</p>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Email</span>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
autoFocus
/>
</label>
<label className="mb-5 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
{error && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text"
>
{error}
</motion.p>
)}
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
<LogIn size={16} />
{loading ? "Входим…" : "Войти"}
</button>
</motion.form>
</main>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { Ticket } from "lucide-react";
export default function PortalLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col bg-bg">
<header className="border-b border-border bg-surface">
<div className="mx-auto flex max-w-2xl items-center gap-2 px-6 py-3 font-bold tracking-tight">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-accent text-white">
<Ticket size={15} strokeWidth={2.5} />
</div>
Поддержка
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-6 py-6">{children}</main>
</div>
);
}
@@ -0,0 +1,25 @@
import { notFound } from "next/navigation";
import { getCustomerByPortalToken, getTicketForCustomer } from "@/lib/tickets/service";
import { PortalThread } from "./portal-thread";
export default async function PortalTicketPage({
params,
}: {
params: Promise<{ token: string; ticketId: string }>;
}) {
const { token, ticketId } = await params;
const customer = await getCustomerByPortalToken(token);
if (!customer) notFound();
const result = await getTicketForCustomer(customer.id, ticketId);
if (!result) notFound();
return (
<PortalThread
token={token}
ticket={result.ticket}
initialMessages={result.messages}
customerName={customer.displayName}
/>
);
}
@@ -0,0 +1,109 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowLeft, Send } from "lucide-react";
import { useTicketEvents } from "@/lib/events/use-ticket-events";
import { formatRelativeTime } from "@/lib/format";
import type { TicketDTO, MessageDTO } from "@/lib/tickets/types";
export function PortalThread({
token,
ticket,
initialMessages,
customerName,
}: {
token: string;
ticket: TicketDTO;
initialMessages: MessageDTO[];
customerName: string;
}) {
const [messages, setMessages] = useState(initialMessages);
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
useTicketEvents(`/api/portal/events?token=${encodeURIComponent(token)}`, (event) => {
if (event.type === "message.created" && event.ticketId === ticket.id) {
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()) return;
setSending(true);
const res = await fetch(`/api/portal/tickets/${ticket.id}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, body: draft }),
});
setSending(false);
if (res.ok) setDraft("");
}
return (
<div className="flex h-[calc(100vh-8rem)] flex-col">
<Link href={`/t/${token}`} className="mb-3 flex items-center gap-1 text-xs text-text-muted hover:text-text">
<ArrowLeft size={13} />
Все обращения
</Link>
<h1 className="mb-4 text-lg font-bold">{ticket.subject}</h1>
<div className="card flex-1 overflow-y-auto p-4">
<AnimatePresence initial={false}>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} isMine={message.authorType === "customer"} />
))}
</AnimatePresence>
<div ref={bottomRef} />
</div>
<div className="mt-3 flex gap-2">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendReply();
}
}}
rows={2}
placeholder={`Ответить как ${customerName}…`}
className="flex-1 resize-none rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
<button onClick={sendReply} disabled={sending || !draft.trim()} className="btn btn-primary self-end">
<Send size={15} />
</button>
</div>
</div>
);
}
function MessageBubble({ message, isMine }: { message: MessageDTO; isMine: boolean }) {
if (message.authorType === "system") {
return <p className="my-2 text-center text-xs text-text-faint">{message.body}</p>;
}
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18 }}
className={`mb-3 flex ${isMine ? "justify-end" : "justify-start"}`}
>
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${isMine ? "bg-accent text-white" : "bg-surface-hover"}`}>
<p className="mb-1 whitespace-pre-wrap">{message.body}</p>
<p className={`text-[11px] ${isMine ? "text-white/70" : "text-text-faint"}`}>
{message.authorName} · {formatRelativeTime(message.createdAt)}
</p>
</div>
</motion.div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getCustomerByPortalToken, listTicketsForCustomer } from "@/lib/tickets/service";
import { formatRelativeTime } from "@/lib/format";
const STATUS_LABEL: Record<string, string> = {
new: "Новая",
open: "Открыта",
pending: "В ожидании",
closed: "Закрыта",
};
export default async function PortalTicketListPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const customer = await getCustomerByPortalToken(token);
if (!customer) notFound();
const tickets = await listTicketsForCustomer(customer.id);
return (
<div>
<h1 className="mb-1 text-xl font-bold tracking-tight">Здравствуйте, {customer.displayName}</h1>
<p className="mb-6 text-sm text-text-muted">Ваши обращения в поддержку.</p>
{tickets.length === 0 && <p className="text-sm text-text-muted">Заявок пока нет.</p>}
<div className="flex flex-col gap-2">
{tickets.map((ticket) => (
<Link key={ticket.id} href={`/t/${token}/${ticket.id}`} className="card block p-4 hover:shadow-md">
<div className="mb-1 flex items-center justify-between gap-3">
<p className="truncate text-sm font-medium">{ticket.subject}</p>
<span className={`badge badge-${ticket.status}`}>{STATUS_LABEL[ticket.status]}</span>
</div>
<p className="text-xs text-text-faint">{formatRelativeTime(ticket.lastMessageAt)}</p>
</Link>
))}
</div>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db/client";
import { verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
// Simple in-memory rate limit — good enough at MVP scale, resets on restart.
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 15 * 60 * 1000;
function isRateLimited(key: string): boolean {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase().trim();
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (isRateLimited(`${ip}:${email}`)) {
return NextResponse.json({ error: "Too many attempts — try again later" }, { status: 429 });
}
const user = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.email, email),
});
// Always run verifyPassword (even against a placeholder hash) so the
// response timing doesn't reveal whether the email exists.
const ok = await verifyPassword(
user?.passwordHash ??
"$argon2id$v=19$m=65536,t=3,p=4$00000000000000000000000000$0000000000000000000000000000000000000000000000000000000000000000",
parsed.data.password,
);
if (!user || !ok) {
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
}
const token = await createSession(user.id);
await setSessionCookie(token);
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name, role: user.role } });
}
+13
View File
@@ -0,0 +1,13 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { destroySessionToken, getSessionToken, clearSessionCookie } from "@/lib/auth/session";
export async function POST() {
const token = await getSessionToken();
if (token) {
await destroySessionToken(token);
}
await clearSessionCookie();
return NextResponse.json({ ok: true });
}
+12
View File
@@ -0,0 +1,12 @@
export const runtime = "nodejs";
import { requireSession } from "@/lib/auth/require";
import { createEventStream } from "@/lib/events/sse";
/** Realtime feed for the admin/agent dashboard — sees every ticket event. */
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
return createEventStream();
}
+24
View File
@@ -0,0 +1,24 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { getCustomerByPortalToken } from "@/lib/tickets/service";
import { createEventStream } from "@/lib/events/sse";
import { ticketEventCustomerId } from "@/lib/events/bus";
/**
* Realtime feed for a customer's portal — scoped to their own tickets only.
* EventSource can't send custom headers, so the token travels as a query param.
*/
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get("token");
if (!token) {
return NextResponse.json({ error: "Missing token" }, { status: 400 });
}
const customer = await getCustomerByPortalToken(token);
if (!customer) {
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
}
return createEventStream((event) => ticketEventCustomerId(event) === customer.id);
}
@@ -0,0 +1,39 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getCustomerByPortalToken, appendCustomerReply } from "@/lib/tickets/service";
const schema = z.object({
token: z.string().min(1),
body: z.string().min(1).max(10_000),
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ ticketId: string }> },
) {
const { ticketId } = await params;
const body = await request.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const customer = await getCustomerByPortalToken(parsed.data.token);
if (!customer) {
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
}
try {
const result = await appendCustomerReply({
ticketId,
customerId: customer.id,
authorName: customer.displayName,
body: parsed.data.body,
});
return NextResponse.json(result, { status: 201 });
} catch {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
}
+44
View File
@@ -0,0 +1,44 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { configureTelegramBot, disableTelegramBot, getTelegramStatus } from "@/lib/telegram/bot";
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
return NextResponse.json(await getTelegramStatus());
}
const configureSchema = z.object({ botToken: z.string().min(1) });
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = configureSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
const { username } = await configureTelegramBot(parsed.data.botToken);
return NextResponse.json({ ok: true, botUsername: username });
} catch {
return NextResponse.json(
{ error: "Telegram rejected this token — check it and try again" },
{ status: 400 },
);
}
}
export async function DELETE() {
const { session, response } = await requireSession();
if (!session) return response;
await disableTelegramBot();
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,44 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { recordAgentReply } from "@/lib/tickets/service";
import { sendTelegramMessage } from "@/lib/telegram/bot";
import { db } from "@/lib/db/client";
import { customers } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
const messageSchema = z.object({
body: z.string().min(1).max(10_000),
});
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
const body = await request.json().catch(() => null);
const parsed = messageSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const result = await recordAgentReply({
ticketId: id,
agentId: session.user.id,
agentName: session.user.name,
body: parsed.data.body,
});
if (result.ticket.channel === "telegram") {
const customer = await db.query.customers.findFirst({
where: eq(customers.id, result.ticket.customerId),
});
if (customer?.telegramChatId) {
await sendTelegramMessage(customer.telegramChatId, parsed.data.body);
}
}
return NextResponse.json(result, { status: 201 });
}
+48
View File
@@ -0,0 +1,48 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { getTicketWithMessages, setTicketStatus, assignTicket } from "@/lib/tickets/service";
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
const result = await getTicketWithMessages(id);
if (!result) {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
return NextResponse.json(result);
}
const patchSchema = z.object({
status: z.enum(["new", "open", "pending", "closed"]).optional(),
assigneeId: z.string().nullable().optional(),
});
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
const body = await request.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
let ticket = null;
if (parsed.data.status) {
ticket = await setTicketStatus(id, parsed.data.status);
}
if (parsed.data.assigneeId !== undefined) {
ticket = await assignTicket(id, parsed.data.assigneeId);
}
if (!ticket) {
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
}
return NextResponse.json({ ticket });
}
+35
View File
@@ -0,0 +1,35 @@
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";
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
const tickets = await listTickets();
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(),
});
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 result = await createManualTicket(parsed.data);
return NextResponse.json(result, { status: 201 });
}
+234
View File
@@ -0,0 +1,234 @@
@import "tailwindcss";
@theme {
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-mono), ui-monospace, monospace;
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-surface-hover: var(--surface-hover);
--color-border: var(--border);
--color-border-strong: var(--border-strong);
--color-text: var(--text);
--color-text-muted: var(--text-muted);
--color-text-faint: var(--text-faint);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-soft: var(--accent-soft);
--color-accent-soft-text: var(--accent-soft-text);
--color-danger: var(--danger);
--color-danger-soft: var(--danger-soft);
--color-danger-soft-text: var(--danger-soft-text);
--color-warning: var(--warning);
--color-warning-soft: var(--warning-soft);
--color-warning-soft-text: var(--warning-soft-text);
--color-success: var(--success);
--color-success-soft: var(--success-soft);
--color-success-soft-text: var(--success-soft-text);
--radius-sm: var(--radius-sm-val);
--radius-md: var(--radius-md-val);
--radius-lg: var(--radius-lg-val);
}
:root {
--bg: #fafaf9;
--surface: #ffffff;
--surface-hover: #f6f5ff;
--border: #e8e6f5;
--border-strong: #d6d1ee;
--text: #191524;
--text-muted: #6c6685;
--text-faint: #a29cc0;
--accent: #7c3aed;
--accent-hover: #6d28d9;
--accent-soft: #f3edff;
--accent-soft-text: #6d28d9;
--danger: #dc2626;
--danger-soft: #fef2f2;
--danger-soft-text: #b91c1c;
--warning: #d97706;
--warning-soft: #fffbeb;
--warning-soft-text: #b45309;
--success: #0d9488;
--success-soft: #ecfdf9;
--success-soft-text: #0f766e;
--radius-sm-val: 8px;
--radius-md-val: 12px;
--radius-lg-val: 18px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f0d17;
--surface: #17141f;
--surface-hover: #1e1a2b;
--border: #2b2438;
--border-strong: #3a3150;
--text: #f1eefb;
--text-muted: #a79fc4;
--text-faint: #6f6790;
--accent: #a78bfa;
--accent-hover: #c4b5fd;
--accent-soft: #241b3d;
--accent-soft-text: #c4b5fd;
--danger: #f87171;
--danger-soft: #2a1517;
--danger-soft-text: #fca5a5;
--warning: #fbbf24;
--warning-soft: #2a2110;
--warning-soft-text: #fcd34d;
--success: #2dd4bf;
--success-soft: #0f2320;
--success-soft-text: #5eead4;
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0f0d17;
--surface: #17141f;
--surface-hover: #1e1a2b;
--border: #2b2438;
--border-strong: #3a3150;
--text: #f1eefb;
--text-muted: #a79fc4;
--text-faint: #6f6790;
--accent: #a78bfa;
--accent-hover: #c4b5fd;
--accent-soft: #241b3d;
--accent-soft-text: #c4b5fd;
--danger: #f87171;
--danger-soft: #2a1517;
--danger-soft-text: #fca5a5;
--warning: #fbbf24;
--warning-soft: #2a2110;
--warning-soft-text: #fcd34d;
--success: #2dd4bf;
--success-soft: #0f2320;
--success-soft-text: #5eead4;
color-scheme: dark;
}
* {
border-color: var(--border);
}
html,
body {
height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
::selection {
background: var(--accent-soft);
color: var(--accent-soft-text);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 999px;
}
::-webkit-scrollbar-track {
background: transparent;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.875rem;
padding: 0.5rem 0.9rem;
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease, transform 100ms ease;
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn-ghost:hover {
background: var(--surface-hover);
color: var(--text);
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.15rem 0.55rem;
border-radius: 999px;
}
.badge-new {
background: var(--accent-soft);
color: var(--accent-soft-text);
}
.badge-open {
background: var(--success-soft);
color: var(--success-soft-text);
}
.badge-pending {
background: var(--warning-soft);
color: var(--warning-soft-text);
}
.badge-closed {
background: var(--border);
color: var(--text-muted);
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { Golos_Text, JetBrains_Mono } from "next/font/google";
import "./globals.css";
const sans = Golos_Text({
variable: "--font-sans",
subsets: ["latin", "cyrillic"],
});
const mono = JetBrains_Mono({
variable: "--font-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "top-tickets",
description: "Helpdesk for support@top-sysops.ru, Telegram, and the web.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru" className={`${sans.variable} ${mono.variable}`} suppressHydrationWarning>
<body>{children}</body>
</html>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
export default async function RootPage() {
const session = await getCurrentSession();
redirect(session ? "/dashboard" : "/login");
}