Prashikshak / API /src /middleware /activityLogger.middleware.ts
Abhisingh-18's picture
Initial commit: Prashikshak - disaster management training platform
9a92a42
Raw
History Blame Contribute Delete
10.9 kB
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<string, any>;
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<string, any> = {
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;