diff --git a/README.md b/README.md index 3d091ea..34dd197 100644 --- a/README.md +++ b/README.md @@ -110,3 +110,5 @@ base url: `http://localhost:3000` ### Activity Logs - Get all logs: `GET /api/organization/:organizationId/activity-logs` +- Get a specific log by id: `GET /api/organization/:organizationId/activity-logs/:logId` +- Get activity feed: `GET /api/organization/:organizationId/activity-feed` diff --git a/eslint.config.mjs b/eslint.config.mjs index 2b6fd91..091b8a5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -27,6 +27,7 @@ export default [ eqeqeq: 'error', 'no-var': 'error', 'prefer-const': 'error', + 'no-case-declarations': 'off', }, }, ]; diff --git a/src/controllers/activitylog.controller.js b/src/controllers/activitylog.controller.js index 15f92dd..669e8d4 100644 --- a/src/controllers/activitylog.controller.js +++ b/src/controllers/activitylog.controller.js @@ -264,3 +264,328 @@ export const getAllActivityLogs = async (req, res, next) => { next(error); } }; + +/** + * @desc Get a specific activity log by ID + * @route /api/organization/:organizationId/activity-logs/:logId + * @method GET + * @access private + */ +export const getActivityLogById = async (req, res, next) => { + try { + const { organizationId, logId } = req.params; + const user = req.user; + + // Check if organization exists + const orgCheck = await checkOrganization(organizationId); + if (!orgCheck.success) { + return res.status(404).json({ + success: false, + message: orgCheck.message, + }); + } + + // Check user permissions for viewing logs + const hasPermission = checkUserPermission( + user, + orgCheck.organization, + 'view activity logs', + ); + if (!hasPermission.success) { + return res.status(403).json({ + success: false, + message: 'You do not have permission to view activity logs', + }); + } + + // Find the activity log + const activityLog = await prisma.activityLog.findFirst({ + where: { + id: logId, + organizationId, + }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + profilePic: true, + }, + }, + organization: { + select: { + id: true, + name: true, + }, + }, + department: { + select: { + id: true, + name: true, + }, + }, + team: { + select: { + id: true, + name: true, + }, + }, + project: { + select: { + id: true, + name: true, + }, + }, + sprint: { + select: { + id: true, + name: true, + }, + }, + task: { + select: { + id: true, + title: true, + description: true, + status: true, + priority: true, + }, + }, + }, + }); + + if (!activityLog) { + return res.status(404).json({ + success: false, + message: 'Activity log not found', + }); + } + + return res.status(200).json({ + success: true, + activityLog, + }); + } catch (error) { + next(error); + } +}; + +/** + * @desc Get activity feed for a specific entity (e.g., for a task or project) + * @route /api/organization/:organizationId/activity-feed + * @method GET + * @access private + */ +export const getActivityFeed = async (req, res, next) => { + try { + const { organizationId } = req.params; + const { entityType, entityId, limit = 20, before } = req.query; + + // Check if organization exists + const orgCheck = await checkOrganization(organizationId); + if (!orgCheck.success) { + return res.status(404).json({ + success: false, + message: orgCheck.message, + }); + } + + // Build where conditions based on entity type and ID + const whereConditions = { + organizationId, + }; + + // Filter by entity type and ID + if (entityType && entityId) { + switch (entityType) { + case 'ORGANIZATION': + // No additional filter needed since we already filter by organizationId + break; + case 'DEPARTMENT': + whereConditions.departmentId = entityId; + break; + case 'TEAM': + whereConditions.teamId = entityId; + break; + case 'PROJECT': + whereConditions.projectId = entityId; + break; + case 'SPRINT': + whereConditions.sprintId = entityId; + break; + case 'TASK': + whereConditions.taskId = entityId; + break; + case 'USER': + whereConditions.userId = entityId; + break; + default: + return res.status(400).json({ + success: false, + message: 'Invalid entity type', + }); + } + } + + // For pagination using cursor-based approach (more efficient for feeds) + if (before) { + whereConditions.createdAt = { + lt: new Date(before), + }; + } + + // Get activity logs for the feed + const activityFeed = await prisma.activityLog.findMany({ + where: whereConditions, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + profilePic: true, + }, + }, + task: { + select: { + id: true, + title: true, + }, + }, + project: { + select: { + id: true, + name: true, + }, + }, + sprint: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: parseInt(limit), + }); + + // Get the last timestamp for next pagination + const lastTimestamp = + activityFeed.length > 0 + ? activityFeed[activityFeed.length - 1].createdAt.toISOString() + : null; + + // Format activity feed for display + const formattedFeed = activityFeed.map((log) => { + // Create a user-friendly message based on action type + const message = formatActivityLogMessage(log); + + return { + id: log.id, + message, + user: log.user, + entityType: log.entityType, + action: log.action, + details: log.details, + createdAt: log.createdAt, + entityData: getEntityData(log), + }; + }); + + return res.status(200).json({ + success: true, + activityFeed: formattedFeed, + pagination: { + nextCursor: lastTimestamp, + hasMore: activityFeed.length === parseInt(limit), + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * Helper function to format activity log messages + * @param {Object} log - Activity log entry + * @returns {String} Formatted message + */ +const formatActivityLogMessage = (log) => { + const userName = `${log.user.firstName} ${log.user.lastName}`; + + switch (log.action) { + case 'CREATED': + return `${userName} created a new ${log.entityType.toLowerCase()}${log.task ? ` "${log.task.title}"` : ''}`; + + case 'UPDATED': + return `${userName} updated ${log.entityType.toLowerCase()}${log.task ? ` "${log.task.title}"` : ''}`; + + case 'DELETED': + return `${userName} deleted ${log.entityType.toLowerCase()}${log.task ? ` "${log.task.title}"` : ''}`; + + case 'RESTORED': + return `${userName} restored ${log.entityType.toLowerCase()}${log.task ? ` "${log.task.title}"` : ''}`; + + case 'STATUS_CHANGED': + const oldStatus = log.details?.oldStatus || 'previous status'; + const newStatus = log.details?.newStatus || 'new status'; + return `${userName} changed status from ${oldStatus} to ${newStatus}${log.task ? ` for "${log.task.title}"` : ''}`; + + case 'ASSIGNED': + const assigneeName = log.details?.assigneeName || 'someone'; + return `${userName} assigned ${log.task ? `"${log.task.title}"` : 'a task'} to ${assigneeName}`; + + case 'UNASSIGNED': + return `${userName} unassigned ${log.task ? `"${log.task.title}"` : 'a task'}`; + + case 'COMMENTED': + return `${userName} commented on ${log.entityType.toLowerCase()}${log.task ? ` "${log.task.title}"` : ''}`; + + case 'ATTACHMENT_ADDED': + return `${userName} added an attachment to ${log.entityType.toLowerCase()}${log.task ? ` "${log.task.title}"` : ''}`; + + case 'ATTACHMENT_REMOVED': + return `${userName} removed an attachment from ${log.entityType.toLowerCase()}${log.task ? ` "${log.task.title}"` : ''}`; + + case 'SPRINT_STARTED': + return `${userName} started sprint${log.sprint ? ` "${log.sprint.name}"` : ''}`; + + case 'SPRINT_COMPLETED': + return `${userName} completed sprint${log.sprint ? ` "${log.sprint.name}"` : ''}`; + + case 'TASK_MOVED': + const fromSprint = log.details?.from?.sprintName || 'previous sprint'; + const toSprint = log.details?.to?.sprintName || 'new sprint'; + return `${userName} moved ${log.task ? `"${log.task.title}"` : 'a task'} from ${fromSprint} to ${toSprint}`; + + case 'LOGGED_TIME': + const time = log.details?.timeDetails?.hours || 'some time'; + return `${userName} logged ${time} hours on ${log.task ? `"${log.task.title}"` : 'a task'}`; + + default: + return `${userName} performed ${log.action.toLowerCase()} on ${log.entityType.toLowerCase()}`; + } +}; + +/** + * Helper function to extract relevant entity data from a log + * @param {Object} log - Activity log entry + * @returns {Object} Entity data + */ +const getEntityData = (log) => { + switch (log.entityType) { + case 'TASK': + return log.task; + case 'PROJECT': + return log.project; + case 'SPRINT': + return log.sprint; + // Add other entity types as needed + default: + return null; + } +}; diff --git a/src/docs/swagger.json b/src/docs/swagger.json index 1d45992..46c2903 100644 --- a/src/docs/swagger.json +++ b/src/docs/swagger.json @@ -6318,6 +6318,344 @@ } } } + }, + "/api/organization/{organizationId}/activity-logs/{logId}": { + "get": { + "tags": ["Activity Logs"], + "summary": "Get a specific activity log by ID", + "description": "Retrieves detailed information about a specific activity log. Requires 'view activity logs' permission.", + "security": [{ "BearerAuth": [] }], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "ID of the organization", + "schema": { "type": "string" } + }, + { + "name": "logId", + "in": "path", + "required": true, + "description": "ID of the activity log to retrieve", + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "Activity log retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true }, + "activityLog": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "entityType": { "type": "string" }, + "entityId": { "type": "string" }, + "action": { "type": "string" }, + "oldValue": { "type": "object" }, + "newValue": { "type": "object" }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "user": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "firstName": { "type": "string" }, + "lastName": { "type": "string" }, + "email": { "type": "string" }, + "profilePic": { "type": "string" } + } + }, + "organization": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + }, + "department": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + }, + "team": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + }, + "project": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + }, + "sprint": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + }, + "task": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "status": { "type": "string" }, + "priority": { "type": "string" } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden - insufficient permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": false }, + "message": { + "type": "string", + "example": "You do not have permission to view activity logs" + } + } + } + } + } + }, + "404": { + "description": "Not found - organization or activity log not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": false }, + "message": { + "oneOf": [ + { + "type": "string", + "example": "Organization not found" + }, + { + "type": "string", + "example": "Activity log not found" + } + ] + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": false }, + "message": { + "type": "string", + "example": "Internal server error" + } + } + } + } + } + } + } + } + }, + "/api/organization/{organizationId}/activity-feed": { + "get": { + "tags": ["Activity Logs"], + "summary": "Get activity feed for an entity", + "description": "Retrieves a paginated activity feed for a specific entity (task, project, etc.) with cursor-based pagination.", + "security": [{ "BearerAuth": [] }], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "ID of the organization", + "schema": { "type": "string" } + }, + { + "name": "entityType", + "in": "query", + "description": "Type of entity to filter by (ORGANIZATION, DEPARTMENT, TEAM, PROJECT, SPRINT, TASK, USER)", + "schema": { + "type": "string", + "enum": [ + "ORGANIZATION", + "DEPARTMENT", + "TEAM", + "PROJECT", + "SPRINT", + "TASK", + "USER" + ] + } + }, + { + "name": "entityId", + "in": "query", + "description": "ID of the entity to filter by (required if entityType is provided)", + "schema": { "type": "string" } + }, + { + "name": "limit", + "in": "query", + "description": "Number of items to return (default: 20)", + "schema": { + "type": "integer", + "default": 20 + } + }, + { + "name": "before", + "in": "query", + "description": "Cursor for pagination (ISO timestamp of the last item from previous request)", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "Activity feed retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true }, + "activityFeed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "message": { "type": "string" }, + "user": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "firstName": { "type": "string" }, + "lastName": { "type": "string" }, + "profilePic": { "type": "string" } + } + }, + "entityType": { "type": "string" }, + "action": { "type": "string" }, + "details": { "type": "object" }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "entityData": { "type": "object" } + } + } + }, + "pagination": { + "type": "object", + "properties": { + "nextCursor": { + "type": "string", + "format": "date-time", + "description": "Timestamp to use for next page pagination" + }, + "hasMore": { + "type": "boolean", + "description": "Whether there are more items available" + } + } + } + } + } + } + } + }, + "400": { + "description": "Bad request - invalid parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": false }, + "message": { + "oneOf": [ + { "type": "string", "example": "Invalid entity type" }, + { + "type": "string", + "example": "entityId is required when entityType is provided" + } + ] + } + } + } + } + } + }, + "404": { + "description": "Not found - organization not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": false }, + "message": { + "type": "string", + "example": "Organization not found" + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": false }, + "message": { + "type": "string", + "example": "Internal server error" + } + } + } + } + } + } + } + } } }, diff --git a/src/routes/activitylog.routes.js b/src/routes/activitylog.routes.js index f7dbfea..0b3f3ac 100644 --- a/src/routes/activitylog.routes.js +++ b/src/routes/activitylog.routes.js @@ -1,6 +1,10 @@ import { Router } from 'express'; import { verifyAccessToken } from '../middlewares/auth.middleware.js'; -import { getAllActivityLogs } from '../controllers/activitylog.controller.js'; +import { + getActivityFeed, + getActivityLogById, + getAllActivityLogs, +} from '../controllers/activitylog.controller.js'; const router = Router(); @@ -10,4 +14,16 @@ router.get( getAllActivityLogs, ); +router.get( + '/api/organization/:organizationId/activity-logs/:logId', + verifyAccessToken, + getActivityLogById, +); + +router.get( + '/api/organization/:organizationId/activity-feed', + verifyAccessToken, + getActivityFeed, +); + export default router; diff --git a/src/utils/activityLogs.utils.js b/src/utils/activityLogs.utils.js index 624ad71..d58ea7b 100644 --- a/src/utils/activityLogs.utils.js +++ b/src/utils/activityLogs.utils.js @@ -80,7 +80,7 @@ export const generateActivityDetails = ( }; case 'UPDATED': // Find what fields were changed - const changes = {}; /* eslint-disable-line */ + const changes = {}; if (oldData && newData) { Object.keys(newData).forEach((key) => { // Only include fields that were actually changed