Phase 1: Forward Assist initial build

Multi-tenant AI help desk SaaS for the firearms industry.
Full monorepo: API (Express/Prisma), Worker (BullMQ), Frontend (React/Vite/Tailwind).
PostgreSQL 16 + pgvector, Redis 7, JWT auth, RLS tenant isolation.
Dark Armory theme with tactical branding throughout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Jungbauer
2026-03-20 01:45:13 +00:00
parent 0bae347e65
commit 05aad75272
56 changed files with 11815 additions and 0 deletions
+350
View File
@@ -0,0 +1,350 @@
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<string> {
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);
}
});