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

+301
View File
@@ -0,0 +1,301 @@
import { eq, and, ne, desc } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { customers, tickets, messages, users } from "@/lib/db/schema";
import { publishTicketEvent } from "@/lib/events/bus";
import type { TicketDTO, MessageDTO, TicketChannel } from "@/lib/tickets/types";
function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string): TicketDTO {
return {
id: ticket.id,
subject: ticket.subject,
status: ticket.status,
priority: ticket.priority,
channel: ticket.channel,
customerId: ticket.customerId,
customerName,
assigneeId: ticket.assigneeId,
createdAt: ticket.createdAt.getTime(),
updatedAt: ticket.updatedAt.getTime(),
lastMessageAt: ticket.lastMessageAt.getTime(),
};
}
function toMessageDTO(message: typeof messages.$inferSelect): MessageDTO {
return {
id: message.id,
ticketId: message.ticketId,
authorType: message.authorType,
authorName: message.authorName,
body: message.body,
direction: message.direction,
createdAt: message.createdAt.getTime(),
};
}
async function customerName(customerId: string): Promise<string> {
const customer = await db.query.customers.findFirst({ where: eq(customers.id, customerId) });
return customer?.displayName ?? "Unknown";
}
async function touchTicket(ticketId: string, patch: Partial<typeof tickets.$inferInsert>) {
const [ticket] = await db
.update(tickets)
.set({ ...patch, updatedAt: new Date() })
.where(eq(tickets.id, ticketId))
.returning();
return ticket;
}
/** Inserts a message, bumps the ticket's lastMessageAt, and publishes both realtime events. */
async function appendMessage(params: {
ticket: typeof tickets.$inferSelect;
ticketIsNew: boolean;
authorType: "customer" | "agent" | "system";
authorId: string | null;
authorName: string;
body: string;
direction: "in" | "out";
}) {
const [message] = await db
.insert(messages)
.values({
ticketId: params.ticket.id,
authorType: params.authorType,
authorId: params.authorId,
authorName: params.authorName,
body: params.body,
direction: params.direction,
})
.returning();
const ticket = await touchTicket(params.ticket.id, { lastMessageAt: new Date() });
const name = await customerName(ticket.customerId);
const dto = toTicketDTO(ticket, name);
publishTicketEvent({
type: params.ticketIsNew ? "ticket.created" : "ticket.updated",
ticket: dto,
});
publishTicketEvent({
type: "message.created",
ticketId: ticket.id,
customerId: ticket.customerId,
message: toMessageDTO(message),
});
return { ticket: dto, message: toMessageDTO(message) };
}
export async function findOrCreateCustomerByTelegramChatId(chatId: string, displayName: string) {
const existing = await db.query.customers.findFirst({
where: eq(customers.telegramChatId, chatId),
});
if (existing) return existing;
const [created] = await db
.insert(customers)
.values({ displayName, telegramChatId: chatId })
.returning();
return created;
}
/** The customer's most recent ticket that isn't closed. */
async function findOpenTicketForCustomer(customerId: string) {
return db.query.tickets.findFirst({
where: and(eq(tickets.customerId, customerId), ne(tickets.status, "closed")),
orderBy: desc(tickets.lastMessageAt),
});
}
/**
* Telegram has no explicit ticket context — messages are a running chat
* stream. Reuse the customer's currently open ticket if one exists,
* otherwise open a new one. A closed ticket is reopened by a new message.
*/
export async function recordTelegramInboundMessage(params: {
customerId: string;
body: string;
authorName: string;
subjectForNewTicket: string;
}) {
let ticket = await findOpenTicketForCustomer(params.customerId);
let ticketIsNew = false;
if (!ticket) {
[ticket] = await db
.insert(tickets)
.values({
subject: params.subjectForNewTicket,
channel: "telegram" satisfies TicketChannel,
customerId: params.customerId,
})
.returning();
ticketIsNew = true;
} else if (ticket.status === "closed") {
ticket = await touchTicket(ticket.id, { status: "open" });
}
return appendMessage({
ticket,
ticketIsNew,
authorType: "customer",
authorId: params.customerId,
authorName: params.authorName,
body: params.body,
direction: "in",
});
}
/** Appends the customer's reply to a specific, already-known ticket (portal thread). */
export async function appendCustomerReply(params: {
ticketId: string;
customerId: string;
authorName: string;
body: string;
}) {
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
if (!ticket || ticket.customerId !== params.customerId) {
throw new Error("Ticket not found for this customer");
}
const reopened = ticket.status === "closed" ? await touchTicket(ticket.id, { status: "open" }) : ticket;
return appendMessage({
ticket: reopened,
ticketIsNew: false,
authorType: "customer",
authorId: params.customerId,
authorName: params.authorName,
body: params.body,
direction: "in",
});
}
/** Records an agent's reply on a ticket. Reopens "new" tickets to "open". */
export async function recordAgentReply(params: {
ticketId: string;
agentId: string;
agentName: string;
body: string;
}) {
const existing = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
if (!existing) throw new Error("Ticket not found");
const ticket =
existing.status === "new" ? await touchTicket(existing.id, { status: "open" }) : existing;
return appendMessage({
ticket,
ticketIsNew: false,
authorType: "agent",
authorId: params.agentId,
authorName: params.agentName,
body: params.body,
direction: "out",
});
}
export async function setTicketStatus(
ticketId: string,
status: (typeof tickets.$inferSelect)["status"],
) {
const ticket = await touchTicket(ticketId, { status });
const dto = toTicketDTO(ticket, await customerName(ticket.customerId));
publishTicketEvent({ type: "ticket.updated", ticket: dto });
return dto;
}
export async function assignTicket(ticketId: string, assigneeId: string | null) {
const ticket = await touchTicket(ticketId, { assigneeId });
const dto = toTicketDTO(ticket, await customerName(ticket.customerId));
publishTicketEvent({ type: "ticket.updated", ticket: dto });
return dto;
}
export async function listTickets(): Promise<TicketDTO[]> {
const rows = await db
.select({ ticket: tickets, customerName: customers.displayName })
.from(tickets)
.innerJoin(customers, eq(tickets.customerId, customers.id))
.orderBy(desc(tickets.lastMessageAt));
return rows.map((row) => toTicketDTO(row.ticket, row.customerName));
}
export async function getTicketWithMessages(ticketId: string) {
const row = await db
.select({ ticket: tickets, customerName: customers.displayName })
.from(tickets)
.innerJoin(customers, eq(tickets.customerId, customers.id))
.where(eq(tickets.id, ticketId))
.limit(1);
if (!row[0]) return null;
const ticketMessages = await db.query.messages.findMany({
where: eq(messages.ticketId, ticketId),
orderBy: messages.createdAt,
});
return {
ticket: toTicketDTO(row[0].ticket, row[0].customerName),
messages: ticketMessages.map(toMessageDTO),
};
}
export async function listAgents() {
return db.query.users.findMany({ orderBy: users.name });
}
export async function getCustomerByPortalToken(token: string) {
return db.query.customers.findFirst({ where: eq(customers.portalToken, token) });
}
export async function listTicketsForCustomer(customerId: string): Promise<TicketDTO[]> {
const rows = await db
.select({ ticket: tickets, customerName: customers.displayName })
.from(tickets)
.innerJoin(customers, eq(tickets.customerId, customers.id))
.where(eq(tickets.customerId, customerId))
.orderBy(desc(tickets.lastMessageAt));
return rows.map((row) => toTicketDTO(row.ticket, row.customerName));
}
/** Returns the ticket+messages only if it actually belongs to this customer. */
export async function getTicketForCustomer(customerId: string, ticketId: string) {
const result = await getTicketWithMessages(ticketId);
if (!result || result.ticket.customerId !== customerId) return null;
return result;
}
export async function createManualTicket(params: {
subject: string;
body: string;
customerName: string;
customerEmail?: string;
}) {
const [customer] = await db
.insert(customers)
.values({ displayName: params.customerName, email: params.customerEmail })
.returning();
const [ticket] = await db
.insert(tickets)
.values({
subject: params.subject,
channel: "manual" satisfies TicketChannel,
customerId: customer.id,
})
.returning();
return appendMessage({
ticket,
ticketIsNew: true,
authorType: "customer",
authorId: customer.id,
authorName: params.customerName,
body: params.body,
direction: "in",
});
}