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