import { FastifyInstance } from 'fastify'; import { PrismaClient } from '@prisma/client'; import { z } from 'zod'; const prisma = new PrismaClient(); // Validation schemas const createVisitorSchema = z.object({ name: z.string().min(2), company: z.string().optional(), email: z.string().email().optional(), phone: z.string().optional(), photoUrl: z.string().url().optional(), idType: z.string().optional(), idNumber: z.string().optional(), type: z.enum(['VISITOR', 'CONTRACTOR', 'INSPECTOR', 'VENDOR', 'DELIVERY', 'OTHER']).default('VISITOR'), purpose: z.string(), notes: z.string().optional() }); const checkInSchema = z.object({ escortId: z.string().uuid().optional(), zones: z.array(z.string()).optional(), temperatureF: z.number().optional(), signature: z.string().optional(), ndaAccepted: z.boolean().optional(), notes: z.string().optional() }); export async function visitorRoutes(fastify: FastifyInstance) { // Auth middleware fastify.addHook('onRequest', async (request) => { try { await request.jwtVerify(); } catch (err) { throw err; } }); /** * GET /visitors * List all visitors with optional filters */ fastify.get('/', { handler: async (request, reply) => { try { const { status, type, search, page = 1, limit = 50 } = request.query as any; const where: any = {}; if (type) where.type = type; if (search) { where.OR = [ { name: { contains: search, mode: 'insensitive' } }, { company: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } } ]; } const visitors = await prisma.visitor.findMany({ where, include: { logs: { orderBy: { entryTime: 'desc' }, take: 1 } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit }); const total = await prisma.visitor.count({ where }); return { visitors, pagination: { page, limit, total, pages: Math.ceil(total / limit) } }; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to fetch visitors' }); } } }); /** * GET /visitors/active * Get currently checked-in visitors */ fastify.get('/active', { handler: async (request, reply) => { try { const activeVisitors = await prisma.visitorLog.findMany({ where: { status: 'CHECKED_IN', exitTime: null }, include: { visitor: true, escort: { select: { id: true, name: true } } }, orderBy: { entryTime: 'desc' } }); return { count: activeVisitors.length, visitors: activeVisitors.map(log => ({ logId: log.id, visitorId: log.visitor.id, name: log.visitor.name, company: log.visitor.company, type: log.visitor.type, purpose: log.visitor.purpose, entryTime: log.entryTime, escort: log.escort, badgeNumber: log.badgeNumber, zones: log.zones })) }; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to fetch active visitors' }); } } }); /** * POST /visitors * Create new visitor (pre-register) */ fastify.post('/', { handler: async (request, reply) => { try { const data = createVisitorSchema.parse(request.body); const visitor = await prisma.visitor.create({ data }); return visitor; } catch (error: any) { if (error.name === 'ZodError') { return reply.status(400).send({ error: 'Validation failed', details: error.errors }); } fastify.log.error(error); return reply.status(500).send({ error: 'Failed to create visitor' }); } } }); /** * GET /visitors/:id * Get visitor details with history */ fastify.get('/:id', { handler: async (request, reply) => { try { const { id } = request.params as any; const visitor = await prisma.visitor.findUnique({ where: { id }, include: { logs: { include: { escort: { select: { id: true, name: true } }, approvedBy: { select: { id: true, name: true } } }, orderBy: { entryTime: 'desc' } } } }); if (!visitor) { return reply.status(404).send({ error: 'Visitor not found' }); } return visitor; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to fetch visitor' }); } } }); /** * POST /visitors/:id/check-in * Check in a visitor */ fastify.post('/:id/check-in', { handler: async (request, reply) => { try { const { id } = request.params as any; const data = checkInSchema.parse(request.body); const userId = (request.user as any)?.id; const visitor = await prisma.visitor.findUnique({ where: { id } }); if (!visitor) { return reply.status(404).send({ error: 'Visitor not found' }); } // Check if already checked in const existingLog = await prisma.visitorLog.findFirst({ where: { visitorId: id, status: 'CHECKED_IN', exitTime: null } }); if (existingLog) { return reply.status(400).send({ error: 'Visitor already checked in' }); } // Generate badge number const today = new Date().toISOString().split('T')[0].replace(/-/g, ''); const count = await prisma.visitorLog.count({ where: { entryTime: { gte: new Date(new Date().setHours(0, 0, 0, 0)) } } }); const badgeNumber = `V-${today}-${String(count + 1).padStart(3, '0')}`; // Update NDA if accepted if (data.ndaAccepted) { await prisma.visitor.update({ where: { id }, data: { ndaAccepted: true, ndaAcceptedAt: new Date() } }); } const log = await prisma.visitorLog.create({ data: { visitorId: id, status: 'CHECKED_IN', escortId: data.escortId, approvedById: userId, badgeNumber, badgeExpiry: new Date(Date.now() + 12 * 60 * 60 * 1000), // 12 hours zones: data.zones || [], signature: data.signature, temperatureF: data.temperatureF, notes: data.notes }, include: { visitor: true, escort: { select: { id: true, name: true } } } }); return { success: true, badgeNumber, log }; } catch (error: any) { if (error.name === 'ZodError') { return reply.status(400).send({ error: 'Validation failed', details: error.errors }); } fastify.log.error(error); return reply.status(500).send({ error: 'Failed to check in visitor' }); } } }); /** * POST /visitors/:id/check-out * Check out a visitor */ fastify.post('/:id/check-out', { handler: async (request, reply) => { try { const { id } = request.params as any; const { notes } = request.body as any; const log = await prisma.visitorLog.findFirst({ where: { visitorId: id, status: 'CHECKED_IN', exitTime: null } }); if (!log) { return reply.status(400).send({ error: 'Visitor not checked in' }); } const updatedLog = await prisma.visitorLog.update({ where: { id: log.id }, data: { status: 'CHECKED_OUT', exitTime: new Date(), notes: notes ? `${log.notes || ''}\nCheckout: ${notes}`.trim() : log.notes }, include: { visitor: true } }); return { success: true, duration: Math.round((updatedLog.exitTime!.getTime() - updatedLog.entryTime.getTime()) / 60000), log: updatedLog }; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to check out visitor' }); } } }); /** * GET /visitors/report * Generate visitor report for compliance */ fastify.get('/report', { handler: async (request, reply) => { try { const { startDate, endDate, type } = request.query as any; const where: any = { entryTime: { gte: startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), lte: endDate ? new Date(endDate) : new Date() } }; if (type) { where.visitor = { type }; } const logs = await prisma.visitorLog.findMany({ where, include: { visitor: true, escort: { select: { id: true, name: true } }, approvedBy: { select: { id: true, name: true } } }, orderBy: { entryTime: 'desc' } }); // Group by date const byDate: Record = {}; logs.forEach(log => { const date = log.entryTime.toISOString().split('T')[0]; if (!byDate[date]) byDate[date] = []; byDate[date].push(log); }); return { generatedAt: new Date().toISOString(), dateRange: { start: where.entryTime.gte.toISOString(), end: where.entryTime.lte.toISOString() }, summary: { totalVisits: logs.length, uniqueVisitors: new Set(logs.map(l => l.visitorId)).size, byType: logs.reduce((acc: any, l) => { acc[l.visitor.type] = (acc[l.visitor.type] || 0) + 1; return acc; }, {}), averageDurationMinutes: logs .filter(l => l.exitTime) .reduce((sum, l) => sum + (l.exitTime!.getTime() - l.entryTime.getTime()) / 60000, 0) / (logs.filter(l => l.exitTime).length || 1) }, byDate, logs: logs.slice(0, 100) }; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to generate report' }); } } }); }