import { Router, Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import { prisma } from '../services/prisma'; import { logAudit } from '../services/audit'; import { authenticate } from '../middleware/auth'; import { AppError } from '../middleware/errorHandler'; export const ticketRouter = Router(); ticketRouter.use(authenticate); const createTicketSchema = z.object({ emailAccountId: z.string().uuid(), subject: z.string().min(1).max(500), customerEmail: z.string().email(), customerName: z.string().optional(), body: z.string().min(1), priority: z.enum(['low', 'medium', 'high', 'critical']).optional(), }); const updateTicketSchema = z.object({ status: z.enum(['incoming', 'in_the_chamber', 'cleared_hot', 'misfire', 'standby', 'resolved', 'archived']).optional(), priority: z.enum(['low', 'medium', 'high', 'critical']).optional(), assigneeId: z.string().uuid().nullable().optional(), tags: z.array(z.string()).optional(), }); const replySchema = z.object({ body: z.string().min(1), bodyHtml: z.string().optional(), }); // Helper to generate next ticket number async function nextTicketNumber(tenantId: string): Promise { const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } }); const settings = (tenant?.settings as any) || {}; const prefix = settings.ticketPrefix || 'FA'; const lastTicket = await prisma.ticket.findFirst({ where: { tenantId }, orderBy: { createdAt: 'desc' }, select: { ticketNumber: true }, }); let nextNum = 1; if (lastTicket) { const match = lastTicket.ticketNumber.match(/(\d+)$/); if (match) nextNum = parseInt(match[1], 10) + 1; } return `${prefix}-${String(nextNum).padStart(4, '0')}`; } // GET /api/tickets — List tickets with filters ticketRouter.get('/', async (req: Request, res: Response, next: NextFunction) => { try { const tenantId = req.tenantId!; const page = Math.max(1, parseInt(req.query.page as string) || 1); const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 25)); const sortBy = (req.query.sortBy as string) || 'lastMessageAt'; const sortOrder = (req.query.sortOrder as string) === 'asc' ? 'asc' : 'desc'; const where: any = { tenantId }; // Status filter if (req.query.status) { const statuses = (req.query.status as string).split(','); where.status = { in: statuses }; } // Priority filter if (req.query.priority) { const priorities = (req.query.priority as string).split(','); where.priority = { in: priorities }; } // Assignee filter if (req.query.assigneeId) { where.assigneeId = req.query.assigneeId; } // Email account filter if (req.query.emailAccountId) { where.emailAccountId = req.query.emailAccountId; } // Search if (req.query.search) { const search = req.query.search as string; where.OR = [ { subject: { contains: search, mode: 'insensitive' } }, { customerEmail: { contains: search, mode: 'insensitive' } }, { customerName: { contains: search, mode: 'insensitive' } }, { ticketNumber: { contains: search, mode: 'insensitive' } }, ]; } const [data, total] = await Promise.all([ prisma.ticket.findMany({ where, include: { assignee: { select: { id: true, name: true, email: true, avatarUrl: true } }, emailAccount: { select: { id: true, name: true, emailAddress: true } }, }, orderBy: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, take: pageSize, }), prisma.ticket.count({ where }), ]); res.json({ data, total, page, pageSize, totalPages: Math.ceil(total / pageSize), }); } catch (error) { next(error); } }); // GET /api/tickets/:id — Get single ticket with messages ticketRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => { try { const ticket = await prisma.ticket.findFirst({ where: { id: req.params.id, tenantId: req.tenantId! }, include: { messages: { orderBy: { createdAt: 'asc' }, include: { attachments: true }, }, assignee: { select: { id: true, name: true, email: true, avatarUrl: true } }, emailAccount: { select: { id: true, name: true, emailAddress: true } }, customerProfile: true, }, }); if (!ticket) throw new AppError('Ticket not found', 404); res.json(ticket); } catch (error) { next(error); } }); // POST /api/tickets — Create a manual ticket ticketRouter.post('/', async (req: Request, res: Response, next: NextFunction) => { try { const body = createTicketSchema.parse(req.body); const tenantId = req.tenantId!; // Verify email account belongs to tenant const emailAccount = await prisma.emailAccount.findFirst({ where: { id: body.emailAccountId, tenantId }, }); if (!emailAccount) throw new AppError('Email account not found', 404); const ticketNumber = await nextTicketNumber(tenantId); // Find or create customer profile let customerProfile = await prisma.customerProfile.findUnique({ where: { tenantId_email: { tenantId, email: body.customerEmail } }, }); if (!customerProfile) { customerProfile = await prisma.customerProfile.create({ data: { tenantId, email: body.customerEmail, name: body.customerName, }, }); } const ticket = await prisma.ticket.create({ data: { tenantId, ticketNumber, emailAccountId: body.emailAccountId, subject: body.subject, status: 'incoming', priority: body.priority || 'medium', customerEmail: body.customerEmail, customerName: body.customerName, customerProfileId: customerProfile.id, messageCount: 1, lastMessageAt: new Date(), messages: { create: { tenantId, direction: 'inbound', fromEmail: body.customerEmail, fromName: body.customerName, toEmail: emailAccount.emailAddress, subject: body.subject, bodyText: body.body, sentAt: new Date(), }, }, }, include: { messages: true, assignee: { select: { id: true, name: true, email: true } }, }, }); // Update customer profile ticket count await prisma.customerProfile.update({ where: { id: customerProfile.id }, data: { ticketCount: { increment: 1 }, lastContactAt: new Date(), }, }); await logAudit({ tenantId, userId: req.user!.userId, action: 'create_ticket', entity: 'ticket', entityId: ticket.id, details: { ticketNumber, subject: body.subject }, req, }); res.status(201).json(ticket); } catch (error) { next(error); } }); // PATCH /api/tickets/:id — Update ticket ticketRouter.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { try { const body = updateTicketSchema.parse(req.body); const tenantId = req.tenantId!; const existing = await prisma.ticket.findFirst({ where: { id: req.params.id, tenantId }, }); if (!existing) throw new AppError('Ticket not found', 404); const updateData: any = {}; if (body.status !== undefined) { updateData.status = body.status; if (body.status === 'resolved') updateData.resolvedAt = new Date(); } if (body.priority !== undefined) updateData.priority = body.priority; if (body.assigneeId !== undefined) updateData.assigneeId = body.assigneeId; if (body.tags !== undefined) updateData.tags = body.tags; const ticket = await prisma.ticket.update({ where: { id: req.params.id }, data: updateData, include: { assignee: { select: { id: true, name: true, email: true } }, emailAccount: { select: { id: true, name: true, emailAddress: true } }, }, }); await logAudit({ tenantId, userId: req.user!.userId, action: 'update_ticket', entity: 'ticket', entityId: ticket.id, details: body, req, }); res.json(ticket); } catch (error) { next(error); } }); // POST /api/tickets/:id/reply — Send a reply ticketRouter.post('/:id/reply', async (req: Request, res: Response, next: NextFunction) => { try { const body = replySchema.parse(req.body); const tenantId = req.tenantId!; const ticket = await prisma.ticket.findFirst({ where: { id: req.params.id, tenantId }, include: { emailAccount: true }, }); if (!ticket) throw new AppError('Ticket not found', 404); const user = await prisma.user.findUnique({ where: { id: req.user!.userId } }); const message = await prisma.message.create({ data: { tenantId, ticketId: ticket.id, direction: 'outbound', fromEmail: ticket.emailAccount.emailAddress, fromName: user?.name, toEmail: ticket.customerEmail, subject: `Re: ${ticket.subject}`, bodyText: body.body, bodyHtml: body.bodyHtml, sentAt: new Date(), }, }); // Update ticket await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'cleared_hot', messageCount: { increment: 1 }, lastMessageAt: new Date(), }, }); await logAudit({ tenantId, userId: req.user!.userId, action: 'reply_ticket', entity: 'message', entityId: message.id, details: { ticketId: ticket.id, ticketNumber: ticket.ticketNumber }, req, }); res.status(201).json(message); } catch (error) { next(error); } }); // GET /api/tickets/:id/messages — Get messages for a ticket ticketRouter.get('/:id/messages', async (req: Request, res: Response, next: NextFunction) => { try { const ticket = await prisma.ticket.findFirst({ where: { id: req.params.id, tenantId: req.tenantId! }, }); if (!ticket) throw new AppError('Ticket not found', 404); const messages = await prisma.message.findMany({ where: { ticketId: ticket.id }, include: { attachments: true }, orderBy: { createdAt: 'asc' }, }); res.json(messages); } catch (error) { next(error); } });