ca-grow-ops-manager/backend/src/routes/visitors.routes.ts
fullsizemalt 32fd739ccf
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
Test / backend-test (push) Failing after 0s
Test / frontend-test (push) Failing after 0s
feat: Complete Phases 8-13 implementation
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)
2025-12-11 00:26:25 -08:00

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' });
}
}
});
}