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

+8
View File
@@ -0,0 +1,8 @@
node_modules
.next
.git
data
*.env
!.env.example
coverage
*.tsbuildinfo
+16
View File
@@ -0,0 +1,16 @@
# Copy to .env and fill in — never commit the real .env.
# AES-256-GCM key that encrypts channel credentials (Telegram token, and
# later IMAP/SMTP) at rest in the database. Generate with:
# npm run generate-key
CREDENTIALS_ENCRYPTION_KEY=
# Initial admin account, created on first start if it doesn't exist yet.
ADMIN_BOOTSTRAP_EMAIL=admin@top-sysops.ru
ADMIN_BOOTSTRAP_PASSWORD=
ADMIN_BOOTSTRAP_NAME=Admin
# Optional: seeds the Telegram channel on first start (only if no bot has
# been configured yet via the admin UI — Settings → Telegram always wins
# afterwards). Get this from @BotFather.
TELEGRAM_BOT_TOKEN=
+40
View File
@@ -0,0 +1,40 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# app data (SQLite db, WAL files)
/data/
# typescript
*.tsbuildinfo
next-env.d.ts
+22
View File
@@ -0,0 +1,22 @@
FROM node:20-bookworm-slim
# python3/make/g++ let npm fall back to compiling better-sqlite3/argon2 from
# source if no prebuilt binary matches this platform.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
ENV NODE_ENV=production
EXPOSE 8081
# Migrate the (volume-mounted) SQLite DB and bootstrap the admin account on
# every start — both are no-ops once already applied.
CMD ["sh", "-c", "npm run db:migrate && npm run bootstrap-admin && npm start"]
+30
View File
@@ -1 +1,31 @@
# top-tickets
Хелпдеск-система: тикеты, дашборд по статусам, канал Telegram, realtime-обновления без перезагрузки страницы, отдельный клиентский портал. MVP-1 — см. `/root/.claude/plans/refactored-discovering-corbato.md` за архитектурой и тем, что отложено на MVP-2 (email, встраиваемый виджет).
## Запуск в Docker
```bash
cp .env.example .env
npm run generate-key # вставить результат в CREDENTIALS_ENCRYPTION_KEY
# заполнить ADMIN_BOOTSTRAP_EMAIL / ADMIN_BOOTSTRAP_PASSWORD / TELEGRAM_BOT_TOKEN в .env
docker compose up --build -d
```
Приложение слушает `:8081` (уже проброшено внешним nginx на `help.top-sysops.ru`). При первом старте контейнер сам применяет миграции и создаёт админ-аккаунт из `.env`.
## Локальная разработка
```bash
npm install
cp .env.example .env # + generate-key, как выше
npm run db:migrate
npm run bootstrap-admin
npm run dev
```
## Telegram
Подключается через **Настройки → Telegram** в интерфейсе (или через `TELEGRAM_BOT_TOKEN` в `.env` — сработает только при первом старте, если бот ещё не настроен). Чтобы бот видел все сообщения в группе, а не только с упоминанием — `@BotFather` → `/setprivacy` → `Disable`.
Токен, который прислали в чат Claude Code, стоит перевыпустить (`@BotFather` → `/revoke`) — он засветился в истории сессии.
+16
View File
@@ -0,0 +1,16 @@
services:
app:
build: .
ports:
- "8081:8081"
env_file:
- .env
environment:
- PORT=8081
- DATA_DIR=/data
volumes:
- data:/data
restart: unless-stopped
volumes:
data:
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "drizzle-kit";
import path from "node:path";
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
export default defineConfig({
out: "./src/lib/db/migrations",
schema: "./src/lib/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: path.join(dataDir, "db.sqlite"),
},
});
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
globalIgnores([
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Not using output: "standalone" — file-tracing only copies argon2's
// native .node binaries, not its JS loader, causing a segfault in
// production (same issue hit in the sibling project-claude repo).
// Deploying with the full node_modules via `next start` is simple
// enough for a single-VM deployment.
};
export default nextConfig;
+9338
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "top-tickets",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start -p ${PORT:-8081}",
"lint": "eslint",
"test": "vitest run",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"generate-key": "tsx scripts/generate-key.ts",
"bootstrap-admin": "tsx scripts/bootstrap-admin.ts"
},
"dependencies": {
"argon2": "^0.45.1",
"better-sqlite3": "^12.11.1",
"drizzle-orm": "^0.45.2",
"framer-motion": "^12.4.7",
"lucide-react": "^0.545.0",
"next": "16.2.12",
"react": "19.2.4",
"react-dom": "19.2.4",
"telegraf": "^4.16.3",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.16",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.12",
"tailwindcss": "^4.1.16",
"tsx": "^4.23.1",
"typescript": "^5",
"vitest": "^3.2.7"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+46
View File
@@ -0,0 +1,46 @@
/**
* Creates the initial admin user from ADMIN_BOOTSTRAP_EMAIL /
* ADMIN_BOOTSTRAP_PASSWORD if no user with that email exists yet.
* Safe to run on every container start — idempotent.
*/
import { db } from "../src/lib/db/client";
import { users } from "../src/lib/db/schema";
import { hashPassword } from "../src/lib/auth/password";
async function main() {
const email = process.env.ADMIN_BOOTSTRAP_EMAIL;
const password = process.env.ADMIN_BOOTSTRAP_PASSWORD;
const name = process.env.ADMIN_BOOTSTRAP_NAME ?? "Admin";
if (!email || !password) {
console.log("ADMIN_BOOTSTRAP_EMAIL/PASSWORD not set — skipping admin bootstrap.");
return;
}
const normalizedEmail = email.toLowerCase().trim();
const existing = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.email, normalizedEmail),
});
if (existing) {
console.log(`Admin user ${normalizedEmail} already exists — skipping.`);
return;
}
const passwordHash = await hashPassword(password);
await db.insert(users).values({
email: normalizedEmail,
passwordHash,
name,
role: "admin",
});
console.log(`Bootstrapped admin user ${normalizedEmail}.`);
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error("Admin bootstrap failed:", err);
process.exit(1);
});
+7
View File
@@ -0,0 +1,7 @@
/**
* Generates a fresh AES-256-GCM key for CREDENTIALS_ENCRYPTION_KEY.
* Usage: npm run generate-key
*/
import crypto from "node:crypto";
console.log(crypto.randomBytes(32).toString("base64"));
@@ -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");
}
+6
View File
@@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { ensureTelegramBotStarted } = await import("@/lib/telegram/bot");
await ensureTelegramBotStarted();
}
}
+13
View File
@@ -0,0 +1,13 @@
import argon2 from "argon2";
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { getCurrentSession } from "./session";
/** Guard for API route handlers — returns the session, or a 401 response to return as-is. */
export async function requireSession() {
const session = await getCurrentSession();
if (!session) {
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
}
return { session, response: null };
}
+71
View File
@@ -0,0 +1,71 @@
import crypto from "node:crypto";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { sessions, users } from "@/lib/db/schema";
const SESSION_COOKIE = "session";
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export async function createSession(userId: string): Promise<string> {
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await db.insert(sessions).values({ tokenHash, userId, expiresAt });
return token;
}
export async function validateSessionToken(token: string) {
const tokenHash = hashToken(token);
const rows = await db
.select({ session: sessions, user: users })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.tokenHash, tokenHash))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.session.expiresAt.getTime() < Date.now()) {
await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
return null;
}
return row;
}
export async function destroySessionToken(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
}
export async function setSessionCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: SESSION_TTL_MS / 1000,
});
}
export async function clearSessionCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
export async function getSessionToken(): Promise<string | undefined> {
const cookieStore = await cookies();
return cookieStore.get(SESSION_COOKIE)?.value;
}
/** Reads the session cookie and validates it against the DB. Returns null if absent/invalid/expired. */
export async function getCurrentSession() {
const token = await getSessionToken();
if (!token) return null;
return validateSessionToken(token);
}
+50
View File
@@ -0,0 +1,50 @@
import crypto from "node:crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
interface EncryptedPayload {
iv: string;
ciphertext: string;
authTag: string;
}
function loadKey(): Buffer {
const raw = process.env.CREDENTIALS_ENCRYPTION_KEY;
if (!raw) {
throw new Error(
"CREDENTIALS_ENCRYPTION_KEY is not set — generate one with `npm run generate-key`",
);
}
const key = Buffer.from(raw, "base64");
if (key.length !== 32) {
throw new Error("CREDENTIALS_ENCRYPTION_KEY must decode to exactly 32 bytes");
}
return key;
}
export function encryptCredential(plaintext: string, key: Buffer = loadKey()): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
const payload: EncryptedPayload = {
iv: iv.toString("base64"),
ciphertext: ciphertext.toString("base64"),
authTag: authTag.toString("base64"),
};
return JSON.stringify(payload);
}
export function decryptCredential(encoded: string, key: Buffer = loadKey()): string {
const payload: EncryptedPayload = JSON.parse(encoded);
const iv = Buffer.from(payload.iv, "base64");
const ciphertext = Buffer.from(payload.ciphertext, "base64");
const authTag = Buffer.from(payload.authTag, "base64");
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return plaintext.toString("utf8");
}
+15
View File
@@ -0,0 +1,15 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import path from "node:path";
import fs from "node:fs";
import * as schema from "./schema";
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
fs.mkdirSync(dataDir, { recursive: true });
const sqlite = new Database(path.join(dataDir, "db.sqlite"));
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
export const db = drizzle(sqlite, { schema });
export type DB = typeof db;
@@ -0,0 +1,65 @@
CREATE TABLE `customers` (
`id` text PRIMARY KEY NOT NULL,
`display_name` text NOT NULL,
`email` text,
`telegram_chat_id` text,
`portal_token` text NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `customers_telegram_chat_id_unique` ON `customers` (`telegram_chat_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `customers_portal_token_unique` ON `customers` (`portal_token`);--> statement-breakpoint
CREATE TABLE `messages` (
`id` text PRIMARY KEY NOT NULL,
`ticket_id` text NOT NULL,
`author_type` text NOT NULL,
`author_id` text,
`author_name` text NOT NULL,
`body` text NOT NULL,
`direction` text NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`ticket_id`) REFERENCES `tickets`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`token_hash` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `telegram_config` (
`id` text PRIMARY KEY NOT NULL,
`bot_token_enc` text NOT NULL,
`bot_username` text,
`enabled` integer DEFAULT false NOT NULL,
`verified_at` integer,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `tickets` (
`id` text PRIMARY KEY NOT NULL,
`subject` text NOT NULL,
`status` text DEFAULT 'new' NOT NULL,
`priority` text DEFAULT 'normal' NOT NULL,
`channel` text NOT NULL,
`customer_id` text NOT NULL,
`assignee_id` text,
`last_message_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`assignee_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`password_hash` text NOT NULL,
`name` text NOT NULL,
`role` text DEFAULT 'agent' NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
@@ -0,0 +1,448 @@
{
"version": "6",
"dialect": "sqlite",
"id": "32259727-b523-472c-96c8-bf1260f7ac56",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"customers": {
"name": "customers",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"telegram_chat_id": {
"name": "telegram_chat_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"portal_token": {
"name": "portal_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"customers_telegram_chat_id_unique": {
"name": "customers_telegram_chat_id_unique",
"columns": [
"telegram_chat_id"
],
"isUnique": true
},
"customers_portal_token_unique": {
"name": "customers_portal_token_unique",
"columns": [
"portal_token"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"ticket_id": {
"name": "ticket_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author_type": {
"name": "author_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author_id": {
"name": "author_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_name": {
"name": "author_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"messages_ticket_id_tickets_id_fk": {
"name": "messages_ticket_id_tickets_id_fk",
"tableFrom": "messages",
"tableTo": "tickets",
"columnsFrom": [
"ticket_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"telegram_config": {
"name": "telegram_config",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"bot_token_enc": {
"name": "bot_token_enc",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bot_username": {
"name": "bot_username",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"verified_at": {
"name": "verified_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tickets": {
"name": "tickets",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"subject": {
"name": "subject",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'new'"
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'normal'"
},
"channel": {
"name": "channel",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"assignee_id": {
"name": "assignee_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_message_at": {
"name": "last_message_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"tickets_customer_id_customers_id_fk": {
"name": "tickets_customer_id_customers_id_fk",
"tableFrom": "tickets",
"tableTo": "customers",
"columnsFrom": [
"customer_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"tickets_assignee_id_users_id_fk": {
"name": "tickets_assignee_id_users_id_fk",
"tableFrom": "tickets",
"tableTo": "users",
"columnsFrom": [
"assignee_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'agent'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1785068349476,
"tag": "0000_superb_the_fallen",
"breakpoints": true
}
]
}
+99
View File
@@ -0,0 +1,99 @@
import { sql } from "drizzle-orm";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import crypto from "node:crypto";
const id = () =>
text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID());
const timestamps = {
createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch('subsec') * 1000)`),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch('subsec') * 1000)`),
};
/** Agents and admins — the people who work tickets. */
export const users = sqliteTable("users", {
id: id(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
name: text("name").notNull(),
role: text("role", { enum: ["admin", "agent"] })
.notNull()
.default("agent"),
createdAt: timestamps.createdAt,
});
export const sessions = sqliteTable("sessions", {
// primary key is the SHA-256 hash of the raw session token — the raw
// token only ever lives in the client's httpOnly cookie.
tokenHash: text("token_hash").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: timestamps.createdAt,
});
/** People who file tickets — identified by whichever channel they came in on. */
export const customers = sqliteTable("customers", {
id: id(),
displayName: text("display_name").notNull(),
email: text("email"),
telegramChatId: text("telegram_chat_id").unique(),
// Bearer token embedded in the customer's portal link — long, random,
// unguessable. Grants access to only this customer's tickets.
portalToken: text("portal_token")
.notNull()
.unique()
.$defaultFn(() => crypto.randomBytes(32).toString("base64url")),
createdAt: timestamps.createdAt,
});
export const tickets = sqliteTable("tickets", {
id: id(),
subject: text("subject").notNull(),
status: text("status", { enum: ["new", "open", "pending", "closed"] })
.notNull()
.default("new"),
priority: text("priority", { enum: ["low", "normal", "high", "urgent"] })
.notNull()
.default("normal"),
channel: text("channel", { enum: ["telegram", "portal", "manual"] }).notNull(),
customerId: text("customer_id")
.notNull()
.references(() => customers.id, { onDelete: "cascade" }),
assigneeId: text("assignee_id").references(() => users.id, { onDelete: "set null" }),
lastMessageAt: integer("last_message_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch('subsec') * 1000)`),
...timestamps,
});
export const messages = sqliteTable("messages", {
id: id(),
ticketId: text("ticket_id")
.notNull()
.references(() => tickets.id, { onDelete: "cascade" }),
authorType: text("author_type", { enum: ["customer", "agent", "system"] }).notNull(),
// References customers.id or users.id depending on authorType; null for system messages.
authorId: text("author_id"),
authorName: text("author_name").notNull(),
body: text("body").notNull(),
direction: text("direction", { enum: ["in", "out"] }).notNull(),
createdAt: timestamps.createdAt,
});
/** Single-row-ish config for the Telegram channel (one bot per deployment). */
export const telegramConfig = sqliteTable("telegram_config", {
id: id(),
botTokenEnc: text("bot_token_enc").notNull(),
botUsername: text("bot_username"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
createdAt: timestamps.createdAt,
});
+33
View File
@@ -0,0 +1,33 @@
import { EventEmitter } from "node:events";
import type { TicketDTO, MessageDTO } from "@/lib/tickets/types";
export type TicketEvent =
| { type: "ticket.created"; ticket: TicketDTO }
| { type: "ticket.updated"; ticket: TicketDTO }
| { type: "message.created"; ticketId: string; customerId: string; message: MessageDTO };
// Next.js dev mode reloads this module on every edit — stash the emitter on
// globalThis so hot reload doesn't spawn a second, disconnected bus.
const globalForBus = globalThis as unknown as { ticketEventBus?: EventEmitter };
const emitter = globalForBus.ticketEventBus ?? new EventEmitter();
emitter.setMaxListeners(200); // one per open SSE connection
if (process.env.NODE_ENV !== "production") {
globalForBus.ticketEventBus = emitter;
}
const CHANNEL = "ticket-event";
export function publishTicketEvent(event: TicketEvent): void {
emitter.emit(CHANNEL, event);
}
export function subscribeTicketEvents(listener: (event: TicketEvent) => void): () => void {
emitter.on(CHANNEL, listener);
return () => emitter.off(CHANNEL, listener);
}
export function ticketEventCustomerId(event: TicketEvent): string {
return event.type === "message.created" ? event.customerId : event.ticket.customerId;
}
+33
View File
@@ -0,0 +1,33 @@
import { subscribeTicketEvents, type TicketEvent } from "@/lib/events/bus";
/** Wraps the ticket event bus as a Server-Sent Events HTTP response. */
export function createEventStream(filter: (event: TicketEvent) => boolean = () => true): Response {
const encoder = new TextEncoder();
let unsubscribe: (() => void) | undefined;
let heartbeat: ReturnType<typeof setInterval> | undefined;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
unsubscribe = subscribeTicketEvents((event) => {
if (!filter(event)) return;
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
});
// Keeps intermediate proxies (nginx) from closing an idle connection.
heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(`: ping\n\n`));
}, 25_000);
},
cancel() {
unsubscribe?.();
if (heartbeat) clearInterval(heartbeat);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
import { useEffect, useLayoutEffect, useRef } from "react";
import type { TicketEvent } from "@/lib/events/bus";
/** Subscribes to a ticket-events SSE endpoint for the lifetime of the component. */
export function useTicketEvents(url: string, onEvent: (event: TicketEvent) => void) {
const handlerRef = useRef(onEvent);
useLayoutEffect(() => {
handlerRef.current = onEvent;
});
useEffect(() => {
const source = new EventSource(url);
source.onmessage = (e) => {
try {
handlerRef.current(JSON.parse(e.data) as TicketEvent);
} catch {
// malformed payload — ignore rather than crash the subscription
}
};
return () => source.close();
}, [url]);
}
+19
View File
@@ -0,0 +1,19 @@
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 1000 * 60 * 60 * 24 * 365],
["month", 1000 * 60 * 60 * 24 * 30],
["day", 1000 * 60 * 60 * 24],
["hour", 1000 * 60 * 60],
["minute", 1000 * 60],
];
const rtf = new Intl.RelativeTimeFormat("ru", { numeric: "auto" });
export function formatRelativeTime(timestampMs: number): string {
const diff = timestampMs - Date.now();
for (const [unit, unitMs] of UNITS) {
if (Math.abs(diff) >= unitMs) {
return rtf.format(Math.round(diff / unitMs), unit);
}
}
return rtf.format(Math.round(diff / 1000), "second");
}
+148
View File
@@ -0,0 +1,148 @@
import { Telegraf, type Context } from "telegraf";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { telegramConfig } from "@/lib/db/schema";
import { encryptCredential, decryptCredential } from "@/lib/crypto/credentials";
import {
findOrCreateCustomerByTelegramChatId,
recordTelegramInboundMessage,
} from "@/lib/tickets/service";
interface BotState {
bot: Telegraf | null;
}
// Survive Next.js dev-mode module reloads — one live long-polling
// connection per token, not one per HMR reload.
const globalForBot = globalThis as unknown as { telegramBotState?: BotState };
const state: BotState = globalForBot.telegramBotState ?? { bot: null };
if (process.env.NODE_ENV !== "production") {
globalForBot.telegramBotState = state;
}
function displayNameFromContext(ctx: Context): string {
if (ctx.chat?.type === "private") {
const from = ctx.from;
const name = [from?.first_name, from?.last_name].filter(Boolean).join(" ");
return name || (from?.username ? `@${from.username}` : "Telegram user");
}
return ctx.chat && "title" in ctx.chat ? ctx.chat.title : "Telegram group";
}
function attachHandlers(bot: Telegraf) {
bot.on("text", async (ctx) => {
const chatId = String(ctx.chat.id);
const displayName = displayNameFromContext(ctx);
const text = ctx.message.text;
const customer = await findOrCreateCustomerByTelegramChatId(chatId, displayName);
await recordTelegramInboundMessage({
customerId: customer.id,
body: text,
authorName: displayName,
subjectForNewTicket: text.slice(0, 80),
});
});
}
async function stopCurrentBot(): Promise<void> {
if (state.bot) {
state.bot.stop("reconfigured");
state.bot = null;
}
}
/** Validates the token against the Telegram API before touching any running bot. */
async function launchBot(token: string): Promise<{ username?: string }> {
const bot = new Telegraf(token);
const me = await bot.telegram.getMe(); // throws on an invalid/revoked token
attachHandlers(bot);
await stopCurrentBot();
void bot.launch(); // long-running promise that resolves on stop() — intentionally not awaited
state.bot = bot;
return { username: me.username };
}
/**
* Called once at process startup. Resumes polling if a bot was previously
* configured via the admin UI. If none was ever configured, falls back to
* TELEGRAM_BOT_TOKEN from the environment as a one-time seed — convenient
* for a fresh `docker compose up`, but the admin UI is the source of truth
* afterwards (this never overwrites an existing DB config).
*/
export async function ensureTelegramBotStarted(): Promise<void> {
const config = await db.query.telegramConfig.findFirst();
if (config) {
if (!config.enabled) return;
try {
await launchBot(decryptCredential(config.botTokenEnc));
} catch (err) {
console.error("Failed to resume Telegram bot from stored config:", err);
}
return;
}
const envToken = process.env.TELEGRAM_BOT_TOKEN;
if (!envToken) return;
try {
await configureTelegramBot(envToken);
console.log("Telegram bot configured from TELEGRAM_BOT_TOKEN.");
} catch (err) {
console.error("TELEGRAM_BOT_TOKEN was set but Telegram rejected it:", err);
}
}
/** Validates + saves a new bot token and starts polling. Throws if the token is invalid. */
export async function configureTelegramBot(token: string): Promise<{ username?: string }> {
const { username } = await launchBot(token);
const tokenEnc = encryptCredential(token);
const existing = await db.query.telegramConfig.findFirst();
if (existing) {
await db
.update(telegramConfig)
.set({ botTokenEnc: tokenEnc, botUsername: username, enabled: true, verifiedAt: new Date() })
.where(eq(telegramConfig.id, existing.id));
} else {
await db
.insert(telegramConfig)
.values({ botTokenEnc: tokenEnc, botUsername: username, enabled: true, verifiedAt: new Date() });
}
return { username };
}
export async function disableTelegramBot(): Promise<void> {
await stopCurrentBot();
const existing = await db.query.telegramConfig.findFirst();
if (existing) {
await db.update(telegramConfig).set({ enabled: false }).where(eq(telegramConfig.id, existing.id));
}
}
export async function getTelegramStatus() {
const config = await db.query.telegramConfig.findFirst();
return {
configured: Boolean(config),
enabled: Boolean(config?.enabled),
botUsername: config?.botUsername ?? null,
verifiedAt: config?.verifiedAt?.getTime() ?? null,
};
}
/** Sends an agent's reply back to the customer's Telegram chat. */
export async function sendTelegramMessage(chatId: string, text: string): Promise<void> {
if (state.bot) {
await state.bot.telegram.sendMessage(chatId, text);
return;
}
// Bot isn't running in this process (e.g. right after a restart, before
// startup has resumed it) — fall back to a one-off client.
const config = await db.query.telegramConfig.findFirst();
if (!config?.enabled) return;
const bot = new Telegraf(decryptCredential(config.botTokenEnc));
await bot.telegram.sendMessage(chatId, text);
}
+301
View File
@@ -0,0 +1,301 @@
import { eq, and, ne, desc } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { customers, tickets, messages, users } from "@/lib/db/schema";
import { publishTicketEvent } from "@/lib/events/bus";
import type { TicketDTO, MessageDTO, TicketChannel } from "@/lib/tickets/types";
function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string): TicketDTO {
return {
id: ticket.id,
subject: ticket.subject,
status: ticket.status,
priority: ticket.priority,
channel: ticket.channel,
customerId: ticket.customerId,
customerName,
assigneeId: ticket.assigneeId,
createdAt: ticket.createdAt.getTime(),
updatedAt: ticket.updatedAt.getTime(),
lastMessageAt: ticket.lastMessageAt.getTime(),
};
}
function toMessageDTO(message: typeof messages.$inferSelect): MessageDTO {
return {
id: message.id,
ticketId: message.ticketId,
authorType: message.authorType,
authorName: message.authorName,
body: message.body,
direction: message.direction,
createdAt: message.createdAt.getTime(),
};
}
async function customerName(customerId: string): Promise<string> {
const customer = await db.query.customers.findFirst({ where: eq(customers.id, customerId) });
return customer?.displayName ?? "Unknown";
}
async function touchTicket(ticketId: string, patch: Partial<typeof tickets.$inferInsert>) {
const [ticket] = await db
.update(tickets)
.set({ ...patch, updatedAt: new Date() })
.where(eq(tickets.id, ticketId))
.returning();
return ticket;
}
/** Inserts a message, bumps the ticket's lastMessageAt, and publishes both realtime events. */
async function appendMessage(params: {
ticket: typeof tickets.$inferSelect;
ticketIsNew: boolean;
authorType: "customer" | "agent" | "system";
authorId: string | null;
authorName: string;
body: string;
direction: "in" | "out";
}) {
const [message] = await db
.insert(messages)
.values({
ticketId: params.ticket.id,
authorType: params.authorType,
authorId: params.authorId,
authorName: params.authorName,
body: params.body,
direction: params.direction,
})
.returning();
const ticket = await touchTicket(params.ticket.id, { lastMessageAt: new Date() });
const name = await customerName(ticket.customerId);
const dto = toTicketDTO(ticket, name);
publishTicketEvent({
type: params.ticketIsNew ? "ticket.created" : "ticket.updated",
ticket: dto,
});
publishTicketEvent({
type: "message.created",
ticketId: ticket.id,
customerId: ticket.customerId,
message: toMessageDTO(message),
});
return { ticket: dto, message: toMessageDTO(message) };
}
export async function findOrCreateCustomerByTelegramChatId(chatId: string, displayName: string) {
const existing = await db.query.customers.findFirst({
where: eq(customers.telegramChatId, chatId),
});
if (existing) return existing;
const [created] = await db
.insert(customers)
.values({ displayName, telegramChatId: chatId })
.returning();
return created;
}
/** The customer's most recent ticket that isn't closed. */
async function findOpenTicketForCustomer(customerId: string) {
return db.query.tickets.findFirst({
where: and(eq(tickets.customerId, customerId), ne(tickets.status, "closed")),
orderBy: desc(tickets.lastMessageAt),
});
}
/**
* Telegram has no explicit ticket context — messages are a running chat
* stream. Reuse the customer's currently open ticket if one exists,
* otherwise open a new one. A closed ticket is reopened by a new message.
*/
export async function recordTelegramInboundMessage(params: {
customerId: string;
body: string;
authorName: string;
subjectForNewTicket: string;
}) {
let ticket = await findOpenTicketForCustomer(params.customerId);
let ticketIsNew = false;
if (!ticket) {
[ticket] = await db
.insert(tickets)
.values({
subject: params.subjectForNewTicket,
channel: "telegram" satisfies TicketChannel,
customerId: params.customerId,
})
.returning();
ticketIsNew = true;
} else if (ticket.status === "closed") {
ticket = await touchTicket(ticket.id, { status: "open" });
}
return appendMessage({
ticket,
ticketIsNew,
authorType: "customer",
authorId: params.customerId,
authorName: params.authorName,
body: params.body,
direction: "in",
});
}
/** Appends the customer's reply to a specific, already-known ticket (portal thread). */
export async function appendCustomerReply(params: {
ticketId: string;
customerId: string;
authorName: string;
body: string;
}) {
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
if (!ticket || ticket.customerId !== params.customerId) {
throw new Error("Ticket not found for this customer");
}
const reopened = ticket.status === "closed" ? await touchTicket(ticket.id, { status: "open" }) : ticket;
return appendMessage({
ticket: reopened,
ticketIsNew: false,
authorType: "customer",
authorId: params.customerId,
authorName: params.authorName,
body: params.body,
direction: "in",
});
}
/** Records an agent's reply on a ticket. Reopens "new" tickets to "open". */
export async function recordAgentReply(params: {
ticketId: string;
agentId: string;
agentName: string;
body: string;
}) {
const existing = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
if (!existing) throw new Error("Ticket not found");
const ticket =
existing.status === "new" ? await touchTicket(existing.id, { status: "open" }) : existing;
return appendMessage({
ticket,
ticketIsNew: false,
authorType: "agent",
authorId: params.agentId,
authorName: params.agentName,
body: params.body,
direction: "out",
});
}
export async function setTicketStatus(
ticketId: string,
status: (typeof tickets.$inferSelect)["status"],
) {
const ticket = await touchTicket(ticketId, { status });
const dto = toTicketDTO(ticket, await customerName(ticket.customerId));
publishTicketEvent({ type: "ticket.updated", ticket: dto });
return dto;
}
export async function assignTicket(ticketId: string, assigneeId: string | null) {
const ticket = await touchTicket(ticketId, { assigneeId });
const dto = toTicketDTO(ticket, await customerName(ticket.customerId));
publishTicketEvent({ type: "ticket.updated", ticket: dto });
return dto;
}
export async function listTickets(): Promise<TicketDTO[]> {
const rows = await db
.select({ ticket: tickets, customerName: customers.displayName })
.from(tickets)
.innerJoin(customers, eq(tickets.customerId, customers.id))
.orderBy(desc(tickets.lastMessageAt));
return rows.map((row) => toTicketDTO(row.ticket, row.customerName));
}
export async function getTicketWithMessages(ticketId: string) {
const row = await db
.select({ ticket: tickets, customerName: customers.displayName })
.from(tickets)
.innerJoin(customers, eq(tickets.customerId, customers.id))
.where(eq(tickets.id, ticketId))
.limit(1);
if (!row[0]) return null;
const ticketMessages = await db.query.messages.findMany({
where: eq(messages.ticketId, ticketId),
orderBy: messages.createdAt,
});
return {
ticket: toTicketDTO(row[0].ticket, row[0].customerName),
messages: ticketMessages.map(toMessageDTO),
};
}
export async function listAgents() {
return db.query.users.findMany({ orderBy: users.name });
}
export async function getCustomerByPortalToken(token: string) {
return db.query.customers.findFirst({ where: eq(customers.portalToken, token) });
}
export async function listTicketsForCustomer(customerId: string): Promise<TicketDTO[]> {
const rows = await db
.select({ ticket: tickets, customerName: customers.displayName })
.from(tickets)
.innerJoin(customers, eq(tickets.customerId, customers.id))
.where(eq(tickets.customerId, customerId))
.orderBy(desc(tickets.lastMessageAt));
return rows.map((row) => toTicketDTO(row.ticket, row.customerName));
}
/** Returns the ticket+messages only if it actually belongs to this customer. */
export async function getTicketForCustomer(customerId: string, ticketId: string) {
const result = await getTicketWithMessages(ticketId);
if (!result || result.ticket.customerId !== customerId) return null;
return result;
}
export async function createManualTicket(params: {
subject: string;
body: string;
customerName: string;
customerEmail?: string;
}) {
const [customer] = await db
.insert(customers)
.values({ displayName: params.customerName, email: params.customerEmail })
.returning();
const [ticket] = await db
.insert(tickets)
.values({
subject: params.subject,
channel: "manual" satisfies TicketChannel,
customerId: customer.id,
})
.returning();
return appendMessage({
ticket,
ticketIsNew: true,
authorType: "customer",
authorId: customer.id,
authorName: params.customerName,
body: params.body,
direction: "in",
});
}
+29
View File
@@ -0,0 +1,29 @@
export type TicketStatus = "new" | "open" | "pending" | "closed";
export type TicketPriority = "low" | "normal" | "high" | "urgent";
export type TicketChannel = "telegram" | "portal" | "manual";
export type MessageAuthorType = "customer" | "agent" | "system";
export type MessageDirection = "in" | "out";
export interface TicketDTO {
id: string;
subject: string;
status: TicketStatus;
priority: TicketPriority;
channel: TicketChannel;
customerId: string;
customerName: string;
assigneeId: string | null;
createdAt: number;
updatedAt: number;
lastMessageAt: number;
}
export interface MessageDTO {
id: string;
ticketId: string;
authorType: MessageAuthorType;
authorName: string;
body: string;
direction: MessageDirection;
createdAt: number;
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}