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

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

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

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db/client";
import { verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
// Simple in-memory rate limit — good enough at MVP scale, resets on restart.
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 15 * 60 * 1000;
function isRateLimited(key: string): boolean {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase().trim();
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (isRateLimited(`${ip}:${email}`)) {
return NextResponse.json({ error: "Too many attempts — try again later" }, { status: 429 });
}
const user = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.email, email),
});
// Always run verifyPassword (even against a placeholder hash) so the
// response timing doesn't reveal whether the email exists.
const ok = await verifyPassword(
user?.passwordHash ??
"$argon2id$v=19$m=65536,t=3,p=4$00000000000000000000000000$0000000000000000000000000000000000000000000000000000000000000000",
parsed.data.password,
);
if (!user || !ok) {
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
}
const token = await createSession(user.id);
await setSessionCookie(token);
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name, role: user.role } });
}
+13
View File
@@ -0,0 +1,13 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { destroySessionToken, getSessionToken, clearSessionCookie } from "@/lib/auth/session";
export async function POST() {
const token = await getSessionToken();
if (token) {
await destroySessionToken(token);
}
await clearSessionCookie();
return NextResponse.json({ ok: true });
}
+12
View File
@@ -0,0 +1,12 @@
export const runtime = "nodejs";
import { requireSession } from "@/lib/auth/require";
import { createEventStream } from "@/lib/events/sse";
/** Realtime feed for the admin/agent dashboard — sees every ticket event. */
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
return createEventStream();
}
+24
View File
@@ -0,0 +1,24 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { getCustomerByPortalToken } from "@/lib/tickets/service";
import { createEventStream } from "@/lib/events/sse";
import { ticketEventCustomerId } from "@/lib/events/bus";
/**
* Realtime feed for a customer's portal — scoped to their own tickets only.
* EventSource can't send custom headers, so the token travels as a query param.
*/
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get("token");
if (!token) {
return NextResponse.json({ error: "Missing token" }, { status: 400 });
}
const customer = await getCustomerByPortalToken(token);
if (!customer) {
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
}
return createEventStream((event) => ticketEventCustomerId(event) === customer.id);
}
@@ -0,0 +1,39 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getCustomerByPortalToken, appendCustomerReply } from "@/lib/tickets/service";
const schema = z.object({
token: z.string().min(1),
body: z.string().min(1).max(10_000),
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ ticketId: string }> },
) {
const { ticketId } = await params;
const body = await request.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const customer = await getCustomerByPortalToken(parsed.data.token);
if (!customer) {
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
}
try {
const result = await appendCustomerReply({
ticketId,
customerId: customer.id,
authorName: customer.displayName,
body: parsed.data.body,
});
return NextResponse.json(result, { status: 201 });
} catch {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
}
+44
View File
@@ -0,0 +1,44 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { configureTelegramBot, disableTelegramBot, getTelegramStatus } from "@/lib/telegram/bot";
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
return NextResponse.json(await getTelegramStatus());
}
const configureSchema = z.object({ botToken: z.string().min(1) });
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = configureSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
const { username } = await configureTelegramBot(parsed.data.botToken);
return NextResponse.json({ ok: true, botUsername: username });
} catch {
return NextResponse.json(
{ error: "Telegram rejected this token — check it and try again" },
{ status: 400 },
);
}
}
export async function DELETE() {
const { session, response } = await requireSession();
if (!session) return response;
await disableTelegramBot();
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,44 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { recordAgentReply } from "@/lib/tickets/service";
import { sendTelegramMessage } from "@/lib/telegram/bot";
import { db } from "@/lib/db/client";
import { customers } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
const messageSchema = z.object({
body: z.string().min(1).max(10_000),
});
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
const body = await request.json().catch(() => null);
const parsed = messageSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const result = await recordAgentReply({
ticketId: id,
agentId: session.user.id,
agentName: session.user.name,
body: parsed.data.body,
});
if (result.ticket.channel === "telegram") {
const customer = await db.query.customers.findFirst({
where: eq(customers.id, result.ticket.customerId),
});
if (customer?.telegramChatId) {
await sendTelegramMessage(customer.telegramChatId, parsed.data.body);
}
}
return NextResponse.json(result, { status: 201 });
}
+48
View File
@@ -0,0 +1,48 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { getTicketWithMessages, setTicketStatus, assignTicket } from "@/lib/tickets/service";
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
const result = await getTicketWithMessages(id);
if (!result) {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
return NextResponse.json(result);
}
const patchSchema = z.object({
status: z.enum(["new", "open", "pending", "closed"]).optional(),
assigneeId: z.string().nullable().optional(),
});
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
const body = await request.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
let ticket = null;
if (parsed.data.status) {
ticket = await setTicketStatus(id, parsed.data.status);
}
if (parsed.data.assigneeId !== undefined) {
ticket = await assignTicket(id, parsed.data.assigneeId);
}
if (!ticket) {
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
}
return NextResponse.json({ ticket });
}
+35
View File
@@ -0,0 +1,35 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { listTickets, createManualTicket } from "@/lib/tickets/service";
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
const tickets = await listTickets();
return NextResponse.json({ tickets });
}
const createSchema = z.object({
subject: z.string().min(1).max(200),
body: z.string().min(1).max(10_000),
customerName: z.string().min(1).max(200),
customerEmail: z.string().email().optional(),
});
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const result = await createManualTicket(parsed.data);
return NextResponse.json(result, { status: 201 });
}