| 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'; |
|
|
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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 || ''}` |
| }; |
| }; |
|
|
| |
| |
| |
| |
| export const activityLogger = async ( |
| req: Request, |
| res: Response, |
| next: NextFunction |
| ) => { |
| |
| if (!req.user) { |
| return next(); |
| } |
|
|
| |
| req.startTime = Date.now(); |
|
|
| |
| const originalJson = res.json.bind(res); |
| |
| res.json = function (body: any) { |
| |
| const duration = req.startTime ? Date.now() - req.startTime : 0; |
| |
| |
| if (!req.activityLogged) { |
| |
| 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(); |
| }; |
|
|
| |
| |
| |
| 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; |
| }; |
|
|
| |
| |
| |
| const mapRouteToActivity = (req: Request): ActivityLogOptions | null => { |
| const { method, path } = req; |
| const route = `${method} ${path}`; |
|
|
| |
| 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' |
| }; |
| } |
|
|
| |
| 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' |
| }; |
| } |
|
|
| |
| 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 } |
| }; |
| } |
|
|
| |
| 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 } |
| }; |
| } |
|
|
| |
| 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 } |
| }; |
| } |
|
|
| |
| if (route.includes('/location/update')) { |
| return { |
| action: ActivityAction.LOCATION_UPDATE, |
| category: ActivityCategory.PROFILE, |
| description: 'User updated location', |
| skipLog: true |
| }; |
| } |
|
|
| |
| if (route.includes('/analytics')) { |
| return { |
| action: ActivityAction.ANALYTICS_VIEW, |
| category: ActivityCategory.ANALYTICS, |
| description: 'User viewed analytics', |
| metadata: { endpoint: path, params: req.query } |
| }; |
| } |
|
|
| |
| 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; |
| }; |
|
|
| |
| |
| |
| 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 |
| } |
| }; |
|
|
| |
| await UserActivity.create(activityData); |
|
|
| |
| updateUserStats(req.user!._id, options.action).catch(err => { |
| console.error('Error updating user stats:', err); |
| }); |
|
|
| } catch (error) { |
| console.error('Failed to log activity:', error); |
| } |
| }; |
|
|
| |
| |
| |
| const updateUserStats = async (userId: string, action: ActivityAction) => { |
| const updateFields: Record<string, any> = { |
| lastActivityDate: new Date(), |
| lastActivityType: action |
| }; |
|
|
| |
| switch (action) { |
| case ActivityAction.LOGIN: |
| updateFields.$inc = { loginCount: 1, totalSessions: 1 }; |
| updateFields.lastLoginDate = new Date(); |
| |
| 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; |
| } |
|
|
| |
| await UserStats.findOneAndUpdate( |
| { user: userId }, |
| updateFields, |
| { upsert: true, new: true } |
| ); |
| }; |
|
|
| export default activityLogger; |