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)
320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
import { FastifyInstance, FastifyRequest } from 'fastify';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import { z } from 'zod';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
// Helper to extract client info from request
|
|
function extractClientInfo(request: FastifyRequest) {
|
|
return {
|
|
ipAddress: request.ip,
|
|
userAgent: request.headers['user-agent'] || null,
|
|
sessionId: (request as any).sessionId || null
|
|
};
|
|
}
|
|
|
|
// Helper to create audit log entry
|
|
export async function createAuditLog(data: {
|
|
userId?: string;
|
|
userName?: string;
|
|
action: 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT' | 'ACCESS' | 'EXPORT' | 'APPROVE' | 'REJECT';
|
|
entity: string;
|
|
entityId?: string;
|
|
entityName?: string;
|
|
before?: any;
|
|
after?: any;
|
|
changes?: any;
|
|
metadata?: any;
|
|
ipAddress?: string;
|
|
userAgent?: string;
|
|
sessionId?: string;
|
|
}) {
|
|
try {
|
|
return await prisma.auditLog.create({ data });
|
|
} catch (error) {
|
|
console.error('Failed to create audit log:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Calculate changes between two objects
|
|
function calculateChanges(before: any, after: any): any {
|
|
if (!before || !after) return null;
|
|
|
|
const changes: any = {};
|
|
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
|
|
for (const key of allKeys) {
|
|
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
|
|
changes[key] = { from: before[key], to: after[key] };
|
|
}
|
|
}
|
|
|
|
return Object.keys(changes).length > 0 ? changes : null;
|
|
}
|
|
|
|
export async function auditRoutes(fastify: FastifyInstance) {
|
|
// Auth middleware
|
|
fastify.addHook('onRequest', async (request) => {
|
|
try {
|
|
await request.jwtVerify();
|
|
} catch (err) {
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /audit/logs
|
|
* Query audit logs with filters
|
|
*/
|
|
fastify.get('/logs', {
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const {
|
|
userId, entity, entityId, action,
|
|
startDate, endDate,
|
|
page = 1, limit = 50
|
|
} = request.query as any;
|
|
|
|
const where: any = {};
|
|
|
|
if (userId) where.userId = userId;
|
|
if (entity) where.entity = entity;
|
|
if (entityId) where.entityId = entityId;
|
|
if (action) where.action = action;
|
|
if (startDate || endDate) {
|
|
where.timestamp = {};
|
|
if (startDate) where.timestamp.gte = new Date(startDate);
|
|
if (endDate) where.timestamp.lte = new Date(endDate);
|
|
}
|
|
|
|
const [logs, total] = await Promise.all([
|
|
prisma.auditLog.findMany({
|
|
where,
|
|
orderBy: { timestamp: 'desc' },
|
|
skip: (page - 1) * limit,
|
|
take: parseInt(limit)
|
|
}),
|
|
prisma.auditLog.count({ where })
|
|
]);
|
|
|
|
return {
|
|
logs,
|
|
pagination: {
|
|
page: parseInt(page),
|
|
limit: parseInt(limit),
|
|
total,
|
|
pages: Math.ceil(total / limit)
|
|
}
|
|
};
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
return reply.status(500).send({ error: 'Failed to fetch audit logs' });
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /audit/logs/:id
|
|
* Get single audit log entry with full details
|
|
*/
|
|
fastify.get('/logs/:id', {
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const { id } = request.params as any;
|
|
|
|
const log = await prisma.auditLog.findUnique({ where: { id } });
|
|
if (!log) {
|
|
return reply.status(404).send({ error: 'Log not found' });
|
|
}
|
|
|
|
return log;
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
return reply.status(500).send({ error: 'Failed to fetch log' });
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /audit/entity/:entity/:entityId
|
|
* Get all audit logs for a specific entity
|
|
*/
|
|
fastify.get('/entity/:entity/:entityId', {
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const { entity, entityId } = request.params as any;
|
|
|
|
const logs = await prisma.auditLog.findMany({
|
|
where: { entity, entityId },
|
|
orderBy: { timestamp: 'desc' }
|
|
});
|
|
|
|
return {
|
|
entity,
|
|
entityId,
|
|
history: logs
|
|
};
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
return reply.status(500).send({ error: 'Failed to fetch entity history' });
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /audit/user/:userId
|
|
* Get all audit logs for a specific user
|
|
*/
|
|
fastify.get('/user/:userId', {
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const { userId } = request.params as any;
|
|
const { limit = 100 } = request.query as any;
|
|
|
|
const logs = await prisma.auditLog.findMany({
|
|
where: { userId },
|
|
orderBy: { timestamp: 'desc' },
|
|
take: parseInt(limit)
|
|
});
|
|
|
|
return {
|
|
userId,
|
|
activityLog: logs
|
|
};
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
return reply.status(500).send({ error: 'Failed to fetch user activity' });
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /audit/summary
|
|
* Get audit log summary/statistics
|
|
*/
|
|
fastify.get('/summary', {
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const { startDate, endDate } = request.query as any;
|
|
|
|
const where: any = {};
|
|
if (startDate || endDate) {
|
|
where.timestamp = {};
|
|
if (startDate) where.timestamp.gte = new Date(startDate);
|
|
if (endDate) where.timestamp.lte = new Date(endDate);
|
|
}
|
|
|
|
const [totalLogs, actionCounts, entityCounts, recentActivity] = await Promise.all([
|
|
prisma.auditLog.count({ where }),
|
|
prisma.auditLog.groupBy({
|
|
by: ['action'],
|
|
where,
|
|
_count: true
|
|
}),
|
|
prisma.auditLog.groupBy({
|
|
by: ['entity'],
|
|
where,
|
|
_count: true,
|
|
orderBy: { _count: { entity: 'desc' } },
|
|
take: 10
|
|
}),
|
|
prisma.auditLog.findMany({
|
|
where,
|
|
orderBy: { timestamp: 'desc' },
|
|
take: 10,
|
|
select: {
|
|
id: true,
|
|
action: true,
|
|
entity: true,
|
|
entityName: true,
|
|
userName: true,
|
|
timestamp: true
|
|
}
|
|
})
|
|
]);
|
|
|
|
return {
|
|
totalLogs,
|
|
byAction: actionCounts.reduce((acc: any, item) => {
|
|
acc[item.action] = item._count;
|
|
return acc;
|
|
}, {}),
|
|
byEntity: entityCounts.map(e => ({
|
|
entity: e.entity,
|
|
count: e._count
|
|
})),
|
|
recentActivity
|
|
};
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
return reply.status(500).send({ error: 'Failed to fetch summary' });
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /audit/export
|
|
* Export audit logs as CSV
|
|
*/
|
|
fastify.get('/export', {
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const user = request.user as any;
|
|
const { entity, startDate, endDate } = request.query as any;
|
|
|
|
const where: any = {};
|
|
if (entity) where.entity = entity;
|
|
if (startDate || endDate) {
|
|
where.timestamp = {};
|
|
if (startDate) where.timestamp.gte = new Date(startDate);
|
|
if (endDate) where.timestamp.lte = new Date(endDate);
|
|
}
|
|
|
|
const logs = await prisma.auditLog.findMany({
|
|
where,
|
|
orderBy: { timestamp: 'desc' },
|
|
take: 10000 // Limit export size
|
|
});
|
|
|
|
// Log the export action
|
|
await createAuditLog({
|
|
userId: user.id,
|
|
userName: user.name || user.email,
|
|
action: 'EXPORT',
|
|
entity: 'AuditLog',
|
|
metadata: { count: logs.length, filters: { entity, startDate, endDate } },
|
|
...extractClientInfo(request)
|
|
});
|
|
|
|
// Generate CSV
|
|
const headers = ['Timestamp', 'User', 'Action', 'Entity', 'Entity Name', 'Entity ID', 'IP Address'];
|
|
const rows = logs.map(log => [
|
|
log.timestamp.toISOString(),
|
|
log.userName || log.userId || 'System',
|
|
log.action,
|
|
log.entity,
|
|
log.entityName || '',
|
|
log.entityId || '',
|
|
log.ipAddress || ''
|
|
]);
|
|
|
|
const csv = [
|
|
headers.join(','),
|
|
...rows.map(r => r.map(c => `"${c}"`).join(','))
|
|
].join('\n');
|
|
|
|
reply.header('Content-Type', 'text/csv');
|
|
reply.header('Content-Disposition', `attachment; filename="audit-log-${new Date().toISOString().split('T')[0]}.csv"`);
|
|
|
|
return csv;
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
return reply.status(500).send({ error: 'Failed to export' });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Export helper for use in other routes
|
|
export { createAuditLog as logAuditEvent, calculateChanges };
|