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,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 });
|
||||
}
|
||||
Reference in new issue
Block a user