Phase 8: Visitor Management - Visitor/VisitorLog/AccessZone models - Check-in/out with badge generation - Zone occupancy tracking - Kiosk and management pages Phase 9: Messaging & Communication - Announcements with priority levels - Acknowledgement tracking - Shift notes for team handoffs - AnnouncementBanner component Phase 10: Compliance & Audit Trail - Immutable AuditLog model - Document versioning and approval workflow - Acknowledgement tracking for SOPs - CSV export for audit logs Phase 11: Accessibility & i18n - WCAG 2.1 AA compliance utilities - react-i18next with EN/ES translations - User preferences context (theme, font size, etc) - High contrast and reduced motion support Phase 12: Hardware Integration - QR code generation for batches/plants/visitors - Printable label system - Visitor badge printing Phase 13: Advanced Features - Environmental monitoring (sensors, readings, alerts) - Financial tracking (transactions, P&L reports) - AI/ML insights (yield predictions, anomaly detection)
387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
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<string, any[]> = {};
|
|
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' });
|
|
}
|
|
}
|
|
});
|
|
}
|