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)
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
import { FastifyInstance } from 'fastify';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import { z } from 'zod';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
const createZoneSchema = z.object({
|
|
name: z.string().min(2),
|
|
code: z.string().min(2).max(10),
|
|
description: z.string().optional(),
|
|
escortRequired: z.boolean().default(false),
|
|
badgeRequired: z.boolean().default(true),
|
|
ndaRequired: z.boolean().default(false),
|
|
allowedTypes: z.array(z.enum(['VISITOR', 'CONTRACTOR', 'INSPECTOR', 'VENDOR', 'DELIVERY', 'OTHER'])).optional(),
|
|
maxOccupancy: z.number().int().positive().optional(),
|
|
parentZoneId: z.string().uuid().optional()
|
|
});
|
|
|
|
export async function accessZoneRoutes(fastify: FastifyInstance) {
|
|
// Auth middleware
|
|
fastify.addHook('onRequest', async (request) => {
|
|
try {
|
|
await request.jwtVerify();
|
|
} catch (err) {
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /zones
|
|
* List all access zones
|
|
*/
|
|
fastify.get('/', {
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const zones = await prisma.accessZone.findMany({
|
|
include: {
|
|
parentZone: { select: { id: true, name: true, code: true } },
|
|
childZones: { select: { id: true, name: true, code: true } }
|
|
},
|
|
orderBy: { name: 'asc' }
|
|
});
|
|
|
|
return zones;
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
return reply.status(500).send({ error: 'Failed to fetch zones' });
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /zones
|
|
* Create new access zone
|
|
*/
|
|
fastify.post('/', {
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const data = createZoneSchema.parse(request.body);
|
|
|
|
const zone = await prisma.accessZone.create({
|
|
data: {
|
|
...data,
|
|
allowedTypes: data.allowedTypes || []
|
|
}
|
|
});
|
|
|
|
return zone;
|
|
} catch (error: any) {
|
|
if (error.code === 'P2002') {
|
|
return reply.status(400).send({ error: 'Zone code already exists' });
|
|
}
|
|
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 zone' });
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /zones/:id
|
|
* Get zone details with hierarchy
|
|
*/
|
|
fastify.get('/:id', {
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const { id } = request.params as any;
|
|
|
|
const zone = await prisma.accessZone.findUnique({
|
|
where: { id },
|
|
include: {
|
|
parentZone: true,
|
|
childZones: true
|
|
}
|
|
});
|
|
|
|
if (!zone) {
|
|
return reply.status(404).send({ error: 'Zone not found' });
|
|
}
|
|
|
|
return zone;
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
return reply.status(500).send({ error: 'Failed to fetch zone' });
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PUT /zones/:id
|
|
* Update access zone
|
|
*/
|
|
fastify.put('/:id', {
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const { id } = request.params as any;
|
|
const data = createZoneSchema.partial().parse(request.body);
|
|
|
|
const zone = await prisma.accessZone.update({
|
|
where: { id },
|
|
data
|
|
});
|
|
|
|
return zone;
|
|
} catch (error: any) {
|
|
if (error.code === 'P2025') {
|
|
return reply.status(404).send({ error: 'Zone not found' });
|
|
}
|
|
fastify.log.error(error);
|
|
return reply.status(500).send({ error: 'Failed to update zone' });
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /zones/:id
|
|
* Delete access zone
|
|
*/
|
|
fastify.delete('/:id', {
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const { id } = request.params as any;
|
|
|
|
// Check for child zones
|
|
const childCount = await prisma.accessZone.count({
|
|
where: { parentZoneId: id }
|
|
});
|
|
|
|
if (childCount > 0) {
|
|
return reply.status(400).send({
|
|
error: 'Cannot delete zone with child zones',
|
|
childCount
|
|
});
|
|
}
|
|
|
|
await prisma.accessZone.delete({ where: { id } });
|
|
|
|
return { success: true };
|
|
} catch (error: any) {
|
|
if (error.code === 'P2025') {
|
|
return reply.status(404).send({ error: 'Zone not found' });
|
|
}
|
|
fastify.log.error(error);
|
|
return reply.status(500).send({ error: 'Failed to delete zone' });
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /zones/:id/occupancy
|
|
* Get current occupancy of a zone
|
|
*/
|
|
fastify.get('/:id/occupancy', {
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const { id } = request.params as any;
|
|
|
|
const zone = await prisma.accessZone.findUnique({ where: { id } });
|
|
if (!zone) {
|
|
return reply.status(404).send({ error: 'Zone not found' });
|
|
}
|
|
|
|
// Count visitors currently in this zone
|
|
const activeVisitors = await prisma.visitorLog.findMany({
|
|
where: {
|
|
status: 'CHECKED_IN',
|
|
exitTime: null,
|
|
zones: { has: id }
|
|
},
|
|
include: {
|
|
visitor: {
|
|
select: { id: true, name: true, company: true, type: true }
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
zone: { id: zone.id, name: zone.name, code: zone.code },
|
|
maxOccupancy: zone.maxOccupancy,
|
|
currentOccupancy: activeVisitors.length,
|
|
atCapacity: zone.maxOccupancy ? activeVisitors.length >= zone.maxOccupancy : false,
|
|
visitors: activeVisitors.map(log => ({
|
|
logId: log.id,
|
|
...log.visitor,
|
|
entryTime: log.entryTime
|
|
}))
|
|
};
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
return reply.status(500).send({ error: 'Failed to get zone occupancy' });
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /zones/:id/log-entry
|
|
* Log a visitor entering a zone
|
|
*/
|
|
fastify.post('/:id/log-entry', {
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const { id } = request.params as any;
|
|
const { visitorLogId } = request.body as any;
|
|
|
|
const zone = await prisma.accessZone.findUnique({ where: { id } });
|
|
if (!zone) {
|
|
return reply.status(404).send({ error: 'Zone not found' });
|
|
}
|
|
|
|
const log = await prisma.visitorLog.findUnique({
|
|
where: { id: visitorLogId },
|
|
include: { visitor: true }
|
|
});
|
|
|
|
if (!log) {
|
|
return reply.status(404).send({ error: 'Visitor log not found' });
|
|
}
|
|
|
|
if (log.status !== 'CHECKED_IN') {
|
|
return reply.status(400).send({ error: 'Visitor not currently checked in' });
|
|
}
|
|
|
|
// Check if visitor type is allowed
|
|
if (zone.allowedTypes.length > 0 && !zone.allowedTypes.includes(log.visitor.type)) {
|
|
return reply.status(403).send({
|
|
error: 'Visitor type not allowed in this zone',
|
|
allowed: zone.allowedTypes,
|
|
visitorType: log.visitor.type
|
|
});
|
|
}
|
|
|
|
// Check NDA requirement
|
|
if (zone.ndaRequired && !log.visitor.ndaAccepted) {
|
|
return reply.status(403).send({ error: 'NDA required for this zone' });
|
|
}
|
|
|
|
// Check escort requirement
|
|
if (zone.escortRequired && !log.escortId) {
|
|
return reply.status(403).send({ error: 'Escort required for this zone' });
|
|
}
|
|
|
|
// Check capacity
|
|
if (zone.maxOccupancy) {
|
|
const count = await prisma.visitorLog.count({
|
|
where: {
|
|
status: 'CHECKED_IN',
|
|
exitTime: null,
|
|
zones: { has: id }
|
|
}
|
|
});
|
|
if (count >= zone.maxOccupancy) {
|
|
return reply.status(403).send({
|
|
error: 'Zone at maximum capacity',
|
|
maxOccupancy: zone.maxOccupancy
|
|
});
|
|
}
|
|
}
|
|
|
|
// Add zone to visitor's zones array
|
|
const updatedLog = await prisma.visitorLog.update({
|
|
where: { id: visitorLogId },
|
|
data: {
|
|
zones: [...new Set([...log.zones, id])]
|
|
}
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
zone: { id: zone.id, name: zone.name, code: zone.code },
|
|
zonesVisited: updatedLog.zones.length
|
|
};
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
return reply.status(500).send({ error: 'Failed to log zone entry' });
|
|
}
|
|
}
|
|
});
|
|
}
|