import { Request, Response, NextFunction } from 'express'; import UserActivity, { ActivityAction, ActivityCategory } from '../model/userActivity.model'; import UserStats from '../model/userStats.model'; import UAParser from 'ua-parser-js'; // Extend Express Request type to include custom properties declare global { namespace Express { interface Request { user?: any; activityLogged?: boolean; startTime?: number; } } } interface ActivityLogOptions { action: ActivityAction; category: ActivityCategory; description: string; metadata?: Record; skipLog?: boolean; } /** * Helper function to extract device and browser info from user agent */ const parseUserAgent = (userAgent: string) => { const parser = new (UAParser as any)(userAgent); const result = parser.getResult(); return { device: `${result.device.vendor || 'Unknown'} ${result.device.model || 'Device'}`, browser: `${result.browser.name || 'Unknown'} ${result.browser.version || ''}` }; }; /** * Main activity logger middleware * Automatically logs all authenticated API calls */ export const activityLogger = async ( req: Request, res: Response, next: NextFunction ) => { // Skip if no user (unauthenticated routes) if (!req.user) { return next(); } // Track request start time for duration calculation req.startTime = Date.now(); // Store original res.json to intercept response const originalJson = res.json.bind(res); res.json = function (body: any) { // Calculate request duration const duration = req.startTime ? Date.now() - req.startTime : 0; // Don't log if already logged (prevents double logging) if (!req.activityLogged) { // Map route to activity action const activityInfo = mapRouteToActivity(req); if (activityInfo && !activityInfo.skipLog) { logActivity(req, res, duration, activityInfo, body).catch(err => { console.error('Error logging activity:', err); }); } } return originalJson(body); }; next(); }; /** * Manual activity logging for custom events */ export const logCustomActivity = async ( req: Request, options: ActivityLogOptions ) => { if (!req.user) return; const duration = req.startTime ? Date.now() - req.startTime : 0; await logActivity(req, null, duration, options, null); req.activityLogged = true; }; /** * Map API routes to activity actions */ const mapRouteToActivity = (req: Request): ActivityLogOptions | null => { const { method, path } = req; const route = `${method} ${path}`; // Authentication routes if (route.includes('/login')) { return { action: ActivityAction.LOGIN, category: ActivityCategory.AUTH, description: 'User logged in' }; } if (route.includes('/signup')) { return { action: ActivityAction.SIGNUP, category: ActivityCategory.AUTH, description: 'User signed up' }; } if (route.includes('/logout')) { return { action: ActivityAction.LOGOUT, category: ActivityCategory.AUTH, description: 'User logged out' }; } // Profile routes if (route.includes('/me') && method === 'PUT') { return { action: ActivityAction.PROFILE_UPDATE, category: ActivityCategory.PROFILE, description: 'User updated profile', metadata: { changes: req.body } }; } if (route.includes('/upload-photo')) { return { action: ActivityAction.PROFILE_PHOTO_UPLOAD, category: ActivityCategory.PROFILE, description: 'User uploaded profile photo' }; } if (route.includes('/change-password')) { return { action: ActivityAction.PASSWORD_CHANGE, category: ActivityCategory.AUTH, description: 'User changed password' }; } // Event routes if (route.includes('/events/create')) { return { action: ActivityAction.EVENT_CREATE, category: ActivityCategory.EVENT, description: 'User created an event', metadata: { eventData: req.body } }; } if (route.match(/PUT .*\/events\/[^/]+$/)) { return { action: ActivityAction.EVENT_UPDATE, category: ActivityCategory.EVENT, description: 'User updated an event', metadata: { eventId: req.params.eventId, changes: req.body } }; } if (route.match(/DELETE .*\/events\/[^/]+$/)) { return { action: ActivityAction.EVENT_DELETE, category: ActivityCategory.EVENT, description: 'User deleted an event', metadata: { eventId: req.params.eventId } }; } if (route.includes('/events/join')) { return { action: ActivityAction.EVENT_JOIN, category: ActivityCategory.EVENT, description: 'User joined an event', metadata: { joinData: req.body } }; } if (route.includes('/events/search') || route.includes('/events/nearby')) { return { action: ActivityAction.EVENT_SEARCH, category: ActivityCategory.EVENT, description: 'User searched for events', metadata: { searchParams: req.query } }; } if (route.match(/GET .*\/events\/[^/]+$/) && !route.includes('my-events')) { return { action: ActivityAction.EVENT_VIEW, category: ActivityCategory.EVENT, description: 'User viewed an event', metadata: { eventId: req.params.eventId } }; } // Form routes if (route.includes('/forms/create')) { return { action: ActivityAction.FORM_CREATE, category: ActivityCategory.FORM, description: 'User created a form', metadata: { formData: req.body } }; } if (route.match(/PUT .*\/forms\/[^/]+$/)) { return { action: ActivityAction.FORM_UPDATE, category: ActivityCategory.FORM, description: 'User updated a form', metadata: { formId: req.params.formId, changes: req.body } }; } if (route.match(/DELETE .*\/forms\/[^/]+$/)) { return { action: ActivityAction.FORM_DELETE, category: ActivityCategory.FORM, description: 'User deleted a form', metadata: { formId: req.params.formId } }; } if (route.includes('/forms/') && route.includes('/submit')) { return { action: ActivityAction.FORM_SUBMIT, category: ActivityCategory.FORM, description: 'User submitted a form', metadata: { formId: req.params.formId } }; } if (route.includes('/forms/') && route.includes('/export')) { return { action: ActivityAction.FORM_EXPORT, category: ActivityCategory.FORM, description: 'User exported form responses', metadata: { formId: req.params.formId } }; } // Verification routes if (route.includes('/verify-trainer')) { return { action: ActivityAction.TRAINER_VERIFY, category: ActivityCategory.VERIFICATION, description: 'Organization verified a trainer', metadata: { trainerId: req.body.trainerId } }; } if (route.includes('/verify-organization')) { return { action: ActivityAction.ORG_VERIFY, category: ActivityCategory.VERIFICATION, description: 'Admin verified an organization', metadata: { organizationId: req.body.organizationId } }; } // Location updates if (route.includes('/location/update')) { return { action: ActivityAction.LOCATION_UPDATE, category: ActivityCategory.PROFILE, description: 'User updated location', skipLog: true // Too frequent, skip individual logs }; } // Analytics routes if (route.includes('/analytics')) { return { action: ActivityAction.ANALYTICS_VIEW, category: ActivityCategory.ANALYTICS, description: 'User viewed analytics', metadata: { endpoint: path, params: req.query } }; } // Skip logging for health checks and static resources if (route.includes('/health') || route.includes('/my-events') || route.includes('/my-forms')) { return { action: ActivityAction.API_ERROR, category: ActivityCategory.SYSTEM, description: '', skipLog: true }; } return null; }; /** * Core function to save activity to database */ const logActivity = async ( req: Request, res: Response | null, duration: number, options: ActivityLogOptions, responseBody: any ) => { try { const userAgent = req.headers['user-agent'] || ''; const { device, browser } = parseUserAgent(userAgent); const activityData: any = { user: req.user!._id, action: options.action, category: options.category, description: options.description, timestamp: new Date(), success: res ? res.statusCode < 400 : true, metadata: { ipAddress: req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress, userAgent, device, browser, method: req.method, endpoint: req.path, queryParams: req.query, duration, statusCode: res?.statusCode, responseSize: responseBody ? JSON.stringify(responseBody).length : 0, ...options.metadata } }; // Save activity log await UserActivity.create(activityData); // Update user stats asynchronously (don't wait) updateUserStats(req.user!._id, options.action).catch(err => { console.error('Error updating user stats:', err); }); } catch (error) { console.error('Failed to log activity:', error); } }; /** * Update aggregated user statistics */ const updateUserStats = async (userId: string, action: ActivityAction) => { const updateFields: Record = { lastActivityDate: new Date(), lastActivityType: action }; // Increment specific counters based on action switch (action) { case ActivityAction.LOGIN: updateFields.$inc = { loginCount: 1, totalSessions: 1 }; updateFields.lastLoginDate = new Date(); // Calculate login streak logic here break; case ActivityAction.EVENT_CREATE: updateFields.$inc = { eventsCreated: 1 }; break; case ActivityAction.EVENT_JOIN: updateFields.$inc = { eventsJoined: 1 }; break; case ActivityAction.FORM_CREATE: updateFields.$inc = { formsCreated: 1 }; break; case ActivityAction.FORM_SUBMIT: updateFields.$inc = { formsSubmitted: 1 }; break; case ActivityAction.PROFILE_UPDATE: updateFields.$inc = { profileUpdates: 1 }; break; case ActivityAction.LOCATION_UPDATE: updateFields.$inc = { locationUpdates: 1 }; break; case ActivityAction.TRAINER_VERIFY: updateFields.$inc = { trainersVerified: 1 }; break; case ActivityAction.ORG_VERIFY: updateFields.$inc = { organizationsVerified: 1 }; break; } // Upsert user stats await UserStats.findOneAndUpdate( { user: userId }, updateFields, { upsert: true, new: true } ); }; export default activityLogger;