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:
1 parent
888b7b82ba
commit
cf0b6cf58e
59 files changed
+12556
No files matched your search
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
data
|
||||
*.env
|
||||
!.env.example
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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`) — он засветился в истории сессии.
|
||||
@@ -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:
|
||||
@@ -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"),
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Generated
+9338
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { ensureTelegramBotStarted } = await import("@/lib/telegram/bot");
|
||||
await ensureTelegramBotStarted();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1785068349476,
|
||||
"tag": "0000_superb_the_fallen",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user