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
+376
View File
@@ -0,0 +1,376 @@
import { Router, Request, Response, NextFunction } from 'express';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
import { prisma } from '../services/prisma';
import { generateTokenPair, verifyRefreshToken, TokenPayload } from '../services/jwt';
import { logAudit } from '../services/audit';
import { authenticate, requireRole } from '../middleware/auth';
import { authRateLimiter } from '../middleware/rateLimiter';
import { AppError } from '../middleware/errorHandler';
import { v4 as uuidv4 } from 'uuid';
export const authRouter = Router();
// Validation schemas
const registerSchema = z.object({
tenantName: z.string().min(2).max(100),
email: z.string().email(),
password: z.string().min(8).max(128),
name: z.string().min(1).max(100),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
const refreshSchema = z.object({
refreshToken: z.string().min(1),
});
const inviteSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(['admin', 'agent', 'viewer']),
});
// Slugify helper
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 64);
}
// POST /api/auth/register — Create tenant + owner account
authRouter.post('/register', authRateLimiter, async (req: Request, res: Response, next: NextFunction) => {
try {
const body = registerSchema.parse(req.body);
// Get trial plan
const trialPlan = await prisma.plan.findUnique({ where: { slug: 'trial' } });
if (!trialPlan) throw new AppError('Trial plan not configured', 500);
// Check if email already exists
const existingUser = await prisma.user.findFirst({ where: { email: body.email } });
if (existingUser) throw new AppError('Email already registered', 409);
// Create tenant + user in transaction
const slug = slugify(body.tenantName);
const passwordHash = await bcrypt.hash(body.password, 12);
const result = await prisma.$transaction(async (tx) => {
const tenant = await tx.tenant.create({
data: {
name: body.tenantName,
slug: slug + '-' + uuidv4().substring(0, 6),
planId: trialPlan.id,
settings: {
timezone: 'America/New_York',
businessHours: {
monday: { enabled: true, start: '09:00', end: '17:00' },
tuesday: { enabled: true, start: '09:00', end: '17:00' },
wednesday: { enabled: true, start: '09:00', end: '17:00' },
thursday: { enabled: true, start: '09:00', end: '17:00' },
friday: { enabled: true, start: '09:00', end: '17:00' },
saturday: { enabled: false, start: '10:00', end: '14:00' },
sunday: { enabled: false, start: '10:00', end: '14:00' },
},
autoReplyEnabled: false,
autoReplyMessage: 'Thank you for contacting us. We will respond within 24 hours.',
aiDraftEnabled: false,
aiTone: 'professional',
ticketPrefix: 'FA',
maxTicketsPerDay: 100,
},
},
});
const user = await tx.user.create({
data: {
tenantId: tenant.id,
email: body.email,
passwordHash,
name: body.name,
role: 'owner',
isActive: true,
},
});
// Create default notification preferences
await tx.notificationPreference.create({
data: {
userId: user.id,
tenantId: tenant.id,
},
});
return { tenant, user };
});
const tokenPayload: TokenPayload = {
userId: result.user.id,
tenantId: result.tenant.id,
email: result.user.email,
role: result.user.role,
};
const tokens = generateTokenPair(tokenPayload);
// Store refresh token
await prisma.user.update({
where: { id: result.user.id },
data: { refreshToken: tokens.refreshToken },
});
await logAudit({
tenantId: result.tenant.id,
userId: result.user.id,
action: 'register',
entity: 'tenant',
entityId: result.tenant.id,
req,
});
res.status(201).json({
user: {
id: result.user.id,
email: result.user.email,
name: result.user.name,
role: result.user.role,
isActive: result.user.isActive,
createdAt: result.user.createdAt,
updatedAt: result.user.updatedAt,
},
tenant: {
id: result.tenant.id,
name: result.tenant.name,
slug: result.tenant.slug,
},
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
});
} catch (error) {
next(error);
}
});
// POST /api/auth/login
authRouter.post('/login', authRateLimiter, async (req: Request, res: Response, next: NextFunction) => {
try {
const body = loginSchema.parse(req.body);
const user = await prisma.user.findFirst({
where: { email: body.email, isActive: true },
include: { tenant: true },
});
if (!user) throw new AppError('Invalid email or password', 401);
const validPassword = await bcrypt.compare(body.password, user.passwordHash);
if (!validPassword) throw new AppError('Invalid email or password', 401);
const tokenPayload: TokenPayload = {
userId: user.id,
tenantId: user.tenantId,
email: user.email,
role: user.role,
};
const tokens = generateTokenPair(tokenPayload);
// Update refresh token and last login
await prisma.user.update({
where: { id: user.id },
data: {
refreshToken: tokens.refreshToken,
lastLoginAt: new Date(),
},
});
await logAudit({
tenantId: user.tenantId,
userId: user.id,
action: 'login',
entity: 'user',
entityId: user.id,
req,
});
res.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isActive: user.isActive,
avatarUrl: user.avatarUrl,
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
},
tenant: {
id: user.tenant.id,
name: user.tenant.name,
slug: user.tenant.slug,
},
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
});
} catch (error) {
next(error);
}
});
// POST /api/auth/refresh
authRouter.post('/refresh', async (req: Request, res: Response, next: NextFunction) => {
try {
const body = refreshSchema.parse(req.body);
let payload: TokenPayload;
try {
payload = verifyRefreshToken(body.refreshToken);
} catch {
throw new AppError('Invalid refresh token', 401);
}
// Verify token matches stored token (rotation)
const user = await prisma.user.findUnique({
where: { id: payload.userId },
include: { tenant: true },
});
if (!user || !user.isActive || user.refreshToken !== body.refreshToken) {
throw new AppError('Invalid refresh token', 401);
}
const newPayload: TokenPayload = {
userId: user.id,
tenantId: user.tenantId,
email: user.email,
role: user.role,
};
const tokens = generateTokenPair(newPayload);
await prisma.user.update({
where: { id: user.id },
data: { refreshToken: tokens.refreshToken },
});
res.json({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
});
} catch (error) {
next(error);
}
});
// POST /api/auth/invite — Invite a user (owner/admin only)
authRouter.post('/invite', authenticate, requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
try {
const body = inviteSchema.parse(req.body);
const tenantId = req.tenantId!;
// Check if user already exists in this tenant
const existing = await prisma.user.findUnique({
where: { tenantId_email: { tenantId, email: body.email } },
});
if (existing) throw new AppError('User already exists in this tenant', 409);
// Check plan limits
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
include: { plan: true, users: { where: { isActive: true } } },
});
if (!tenant) throw new AppError('Tenant not found', 404);
if (tenant.users.length >= tenant.plan.maxUsers) {
throw new AppError(`Plan limit reached. Maximum ${tenant.plan.maxUsers} users on ${tenant.plan.name} plan.`, 403);
}
// Create user with temporary password
const tempPassword = uuidv4().substring(0, 12);
const passwordHash = await bcrypt.hash(tempPassword, 12);
const user = await prisma.user.create({
data: {
tenantId,
email: body.email,
passwordHash,
name: body.name,
role: body.role,
isActive: true,
},
});
await prisma.notificationPreference.create({
data: {
userId: user.id,
tenantId,
},
});
await logAudit({
tenantId,
userId: req.user!.userId,
action: 'invite_user',
entity: 'user',
entityId: user.id,
details: { email: body.email, role: body.role },
req,
});
res.status(201).json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isActive: user.isActive,
createdAt: user.createdAt,
},
temporaryPassword: tempPassword, // In production, this would be emailed
});
} catch (error) {
next(error);
}
});
// GET /api/auth/me — Get current user
authRouter.get('/me', authenticate, async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user!.userId },
include: { tenant: { include: { plan: true } } },
});
if (!user) throw new AppError('User not found', 404);
res.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isActive: user.isActive,
avatarUrl: user.avatarUrl,
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
},
tenant: {
id: user.tenant.id,
name: user.tenant.name,
slug: user.tenant.slug,
plan: {
name: user.tenant.plan.name,
slug: user.tenant.plan.slug,
},
},
});
} catch (error) {
next(error);
}
});
+261
View File
@@ -0,0 +1,261 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { prisma } from '../services/prisma';
import { logAudit } from '../services/audit';
import { authenticate, requireRole } from '../middleware/auth';
import { AppError } from '../middleware/errorHandler';
export const emailAccountRouter = Router();
emailAccountRouter.use(authenticate);
const emailAccountSchema = z.object({
name: z.string().min(1).max(100),
emailAddress: z.string().email(),
imapHost: z.string().min(1),
imapPort: z.number().int().min(1).max(65535),
imapUser: z.string().min(1),
imapPassword: z.string().min(1),
imapTls: z.boolean().default(true),
smtpHost: z.string().min(1),
smtpPort: z.number().int().min(1).max(65535),
smtpUser: z.string().min(1),
smtpPassword: z.string().min(1),
smtpTls: z.boolean().default(true),
pollIntervalSeconds: z.number().int().min(30).max(3600).optional(),
});
const updateEmailAccountSchema = emailAccountSchema.partial().extend({
isActive: z.boolean().optional(),
});
// GET /api/email-accounts — List all email accounts for tenant
emailAccountRouter.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const accounts = await prisma.emailAccount.findMany({
where: { tenantId: req.tenantId! },
orderBy: { createdAt: 'asc' },
select: {
id: true,
name: true,
emailAddress: true,
imapHost: true,
imapPort: true,
imapUser: true,
imapTls: true,
smtpHost: true,
smtpPort: true,
smtpUser: true,
smtpTls: true,
isActive: true,
lastPollAt: true,
lastError: true,
pollIntervalSeconds: true,
createdAt: true,
updatedAt: true,
// Deliberately omit passwords
},
});
res.json(accounts);
} catch (error) {
next(error);
}
});
// GET /api/email-accounts/:id
emailAccountRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const account = await prisma.emailAccount.findFirst({
where: { id: req.params.id, tenantId: req.tenantId! },
select: {
id: true,
name: true,
emailAddress: true,
imapHost: true,
imapPort: true,
imapUser: true,
imapTls: true,
smtpHost: true,
smtpPort: true,
smtpUser: true,
smtpTls: true,
isActive: true,
lastPollAt: true,
lastError: true,
pollIntervalSeconds: true,
createdAt: true,
updatedAt: true,
},
});
if (!account) throw new AppError('Email account not found', 404);
res.json(account);
} catch (error) {
next(error);
}
});
// POST /api/email-accounts — Create email account
emailAccountRouter.post('/', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
try {
const body = emailAccountSchema.parse(req.body);
const tenantId = req.tenantId!;
// Check plan limits
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
include: { plan: true, emailAccounts: { where: { isActive: true } } },
});
if (!tenant) throw new AppError('Tenant not found', 404);
if (tenant.emailAccounts.length >= tenant.plan.maxEmailAccounts) {
throw new AppError(`Plan limit reached. Maximum ${tenant.plan.maxEmailAccounts} email accounts on ${tenant.plan.name} plan.`, 403);
}
const account = await prisma.emailAccount.create({
data: {
tenantId,
name: body.name,
emailAddress: body.emailAddress,
imapHost: body.imapHost,
imapPort: body.imapPort,
imapUser: body.imapUser,
imapPassword: body.imapPassword,
imapTls: body.imapTls,
smtpHost: body.smtpHost,
smtpPort: body.smtpPort,
smtpUser: body.smtpUser,
smtpPassword: body.smtpPassword,
smtpTls: body.smtpTls,
pollIntervalSeconds: body.pollIntervalSeconds || 60,
},
});
await logAudit({
tenantId,
userId: req.user!.userId,
action: 'create_email_account',
entity: 'email_account',
entityId: account.id,
details: { emailAddress: body.emailAddress },
req,
});
res.status(201).json({
id: account.id,
name: account.name,
emailAddress: account.emailAddress,
imapHost: account.imapHost,
imapPort: account.imapPort,
imapUser: account.imapUser,
imapTls: account.imapTls,
smtpHost: account.smtpHost,
smtpPort: account.smtpPort,
smtpUser: account.smtpUser,
smtpTls: account.smtpTls,
isActive: account.isActive,
pollIntervalSeconds: account.pollIntervalSeconds,
createdAt: account.createdAt,
});
} catch (error) {
next(error);
}
});
// PATCH /api/email-accounts/:id — Update email account
emailAccountRouter.patch('/:id', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
try {
const body = updateEmailAccountSchema.parse(req.body);
const tenantId = req.tenantId!;
const existing = await prisma.emailAccount.findFirst({
where: { id: req.params.id, tenantId },
});
if (!existing) throw new AppError('Email account not found', 404);
const account = await prisma.emailAccount.update({
where: { id: req.params.id },
data: body,
select: {
id: true,
name: true,
emailAddress: true,
imapHost: true,
imapPort: true,
imapUser: true,
imapTls: true,
smtpHost: true,
smtpPort: true,
smtpUser: true,
smtpTls: true,
isActive: true,
lastPollAt: true,
lastError: true,
pollIntervalSeconds: true,
updatedAt: true,
},
});
await logAudit({
tenantId,
userId: req.user!.userId,
action: 'update_email_account',
entity: 'email_account',
entityId: account.id,
req,
});
res.json(account);
} catch (error) {
next(error);
}
});
// DELETE /api/email-accounts/:id
emailAccountRouter.delete('/:id', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
try {
const tenantId = req.tenantId!;
const existing = await prisma.emailAccount.findFirst({
where: { id: req.params.id, tenantId },
});
if (!existing) throw new AppError('Email account not found', 404);
// Soft delete — just deactivate
await prisma.emailAccount.update({
where: { id: req.params.id },
data: { isActive: false },
});
await logAudit({
tenantId,
userId: req.user!.userId,
action: 'delete_email_account',
entity: 'email_account',
entityId: req.params.id,
req,
});
res.json({ message: 'Email account deactivated' });
} catch (error) {
next(error);
}
});
// POST /api/email-accounts/:id/test — Test connection
emailAccountRouter.post('/:id/test', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
try {
const account = await prisma.emailAccount.findFirst({
where: { id: req.params.id, tenantId: req.tenantId! },
});
if (!account) throw new AppError('Email account not found', 404);
// In Phase 1, we just return a success stub.
// Phase 2 will actually test IMAP/SMTP connections.
res.json({
imap: { success: true, message: 'IMAP connection test not yet implemented (Phase 2)' },
smtp: { success: true, message: 'SMTP connection test not yet implemented (Phase 2)' },
});
} catch (error) {
next(error);
}
});
+100
View File
@@ -0,0 +1,100 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { prisma } from '../services/prisma';
import { logAudit } from '../services/audit';
import { authenticate, requireRole } from '../middleware/auth';
import { AppError } from '../middleware/errorHandler';
export const settingsRouter = Router();
settingsRouter.use(authenticate);
const dayHoursSchema = z.object({
enabled: z.boolean(),
start: z.string().regex(/^\d{2}:\d{2}$/),
end: z.string().regex(/^\d{2}:\d{2}$/),
});
const settingsSchema = z.object({
timezone: z.string().optional(),
businessHours: z.object({
monday: dayHoursSchema,
tuesday: dayHoursSchema,
wednesday: dayHoursSchema,
thursday: dayHoursSchema,
friday: dayHoursSchema,
saturday: dayHoursSchema,
sunday: dayHoursSchema,
}).optional(),
autoReplyEnabled: z.boolean().optional(),
autoReplyMessage: z.string().max(1000).optional(),
aiDraftEnabled: z.boolean().optional(),
aiTone: z.enum(['professional', 'friendly', 'technical']).optional(),
ticketPrefix: z.string().min(1).max(5).optional(),
maxTicketsPerDay: z.number().int().min(1).max(10000).optional(),
}).partial();
// GET /api/settings — Get tenant settings
settingsRouter.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const tenant = await prisma.tenant.findUnique({
where: { id: req.tenantId! },
include: { plan: true },
});
if (!tenant) throw new AppError('Tenant not found', 404);
res.json({
tenant: {
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
createdAt: tenant.createdAt,
},
plan: {
name: tenant.plan.name,
slug: tenant.plan.slug,
maxUsers: tenant.plan.maxUsers,
maxEmailAccounts: tenant.plan.maxEmailAccounts,
maxTicketsPerMonth: tenant.plan.maxTicketsPerMonth,
aiDraftsEnabled: tenant.plan.aiDraftsEnabled,
knowledgeBaseEnabled: tenant.plan.knowledgeBaseEnabled,
},
settings: tenant.settings,
});
} catch (error) {
next(error);
}
});
// PATCH /api/settings — Update tenant settings
settingsRouter.patch('/', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
try {
const body = settingsSchema.parse(req.body);
const tenantId = req.tenantId!;
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant) throw new AppError('Tenant not found', 404);
// Merge with existing settings
const currentSettings = (tenant.settings as Record<string, unknown>) || {};
const newSettings = { ...currentSettings, ...body };
const updated = await prisma.tenant.update({
where: { id: tenantId },
data: { settings: newSettings },
});
await logAudit({
tenantId,
userId: req.user!.userId,
action: 'update_settings',
entity: 'tenant',
entityId: tenantId,
details: body,
req,
});
res.json({ settings: updated.settings });
} catch (error) {
next(error);
}
});
+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);
}
});
+199
View File
@@ -0,0 +1,199 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import { prisma } from '../services/prisma';
import { logAudit } from '../services/audit';
import { authenticate, requireRole } from '../middleware/auth';
import { AppError } from '../middleware/errorHandler';
export const userRouter = Router();
userRouter.use(authenticate);
const updateUserSchema = z.object({
name: z.string().min(1).max(100).optional(),
role: z.enum(['admin', 'agent', 'viewer']).optional(),
isActive: z.boolean().optional(),
});
const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8).max(128),
});
// GET /api/users — List users in tenant
userRouter.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const users = await prisma.user.findMany({
where: { tenantId: req.tenantId! },
select: {
id: true,
email: true,
name: true,
role: true,
avatarUrl: true,
isActive: true,
lastLoginAt: true,
createdAt: true,
updatedAt: true,
},
orderBy: { createdAt: 'asc' },
});
res.json(users);
} catch (error) {
next(error);
}
});
// GET /api/users/:id
userRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await prisma.user.findFirst({
where: { id: req.params.id, tenantId: req.tenantId! },
select: {
id: true,
email: true,
name: true,
role: true,
avatarUrl: true,
isActive: true,
lastLoginAt: true,
createdAt: true,
updatedAt: true,
},
});
if (!user) throw new AppError('User not found', 404);
res.json(user);
} catch (error) {
next(error);
}
});
// PATCH /api/users/:id — Update user (owner/admin only, or self for name)
userRouter.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const body = updateUserSchema.parse(req.body);
const tenantId = req.tenantId!;
const requestingUser = req.user!;
const target = await prisma.user.findFirst({
where: { id: req.params.id, tenantId },
});
if (!target) throw new AppError('User not found', 404);
// Permission check
const isSelf = requestingUser.userId === target.id;
const isAdmin = ['owner', 'admin'].includes(requestingUser.role);
if (!isSelf && !isAdmin) {
throw new AppError('Forbidden', 403);
}
// Non-admins can only update their own name
if (!isAdmin && (body.role || body.isActive !== undefined)) {
throw new AppError('Only admins can change roles or status', 403);
}
// Cannot change owner role
if (target.role === 'owner' && body.role && body.role !== 'owner') {
throw new AppError('Cannot change owner role', 403);
}
const updated = await prisma.user.update({
where: { id: req.params.id },
data: body,
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
updatedAt: true,
},
});
await logAudit({
tenantId,
userId: requestingUser.userId,
action: 'update_user',
entity: 'user',
entityId: target.id,
details: body,
req,
});
res.json(updated);
} catch (error) {
next(error);
}
});
// POST /api/users/:id/change-password
userRouter.post('/:id/change-password', async (req: Request, res: Response, next: NextFunction) => {
try {
const body = changePasswordSchema.parse(req.body);
const requestingUser = req.user!;
// Can only change own password
if (requestingUser.userId !== req.params.id) {
throw new AppError('Can only change your own password', 403);
}
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!user) throw new AppError('User not found', 404);
const valid = await bcrypt.compare(body.currentPassword, user.passwordHash);
if (!valid) throw new AppError('Current password is incorrect', 400);
const newHash = await bcrypt.hash(body.newPassword, 12);
await prisma.user.update({
where: { id: req.params.id },
data: { passwordHash: newHash, refreshToken: null },
});
await logAudit({
tenantId: req.tenantId!,
userId: requestingUser.userId,
action: 'change_password',
entity: 'user',
entityId: req.params.id,
req,
});
res.json({ message: 'Password changed successfully' });
} catch (error) {
next(error);
}
});
// DELETE /api/users/:id — Deactivate user
userRouter.delete('/:id', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
try {
const tenantId = req.tenantId!;
const target = await prisma.user.findFirst({
where: { id: req.params.id, tenantId },
});
if (!target) throw new AppError('User not found', 404);
if (target.role === 'owner') throw new AppError('Cannot delete the owner account', 403);
await prisma.user.update({
where: { id: req.params.id },
data: { isActive: false, refreshToken: null },
});
await logAudit({
tenantId,
userId: req.user!.userId,
action: 'delete_user',
entity: 'user',
entityId: req.params.id,
req,
});
res.json({ message: 'User deactivated' });
} catch (error) {
next(error);
}
});