From eaafed30a6ab223d83aea12b4476a20f4b0b9650 Mon Sep 17 00:00:00 2001 From: Mohamed Dawoud Date: Sat, 19 Apr 2025 13:26:37 +0200 Subject: [PATCH 1/9] feat: add `getActivityLogById` controller function --- src/controllers/activitylog.controller.js | 107 ++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/src/controllers/activitylog.controller.js b/src/controllers/activitylog.controller.js index 15f92dd..c33ed1f 100644 --- a/src/controllers/activitylog.controller.js +++ b/src/controllers/activitylog.controller.js @@ -264,3 +264,110 @@ 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) { + 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); + } +}; From 6abe49830b894169fd27a34d91c25c58f04e3331 Mon Sep 17 00:00:00 2001 From: Mohamed Dawoud Date: Sat, 19 Apr 2025 13:26:55 +0200 Subject: [PATCH 2/9] feat: add route for `getActivityLogById` controller function --- src/routes/activitylog.routes.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/routes/activitylog.routes.js b/src/routes/activitylog.routes.js index f7dbfea..8411ba6 100644 --- a/src/routes/activitylog.routes.js +++ b/src/routes/activitylog.routes.js @@ -1,6 +1,9 @@ import { Router } from 'express'; import { verifyAccessToken } from '../middlewares/auth.middleware.js'; -import { getAllActivityLogs } from '../controllers/activitylog.controller.js'; +import { + getActivityLogById, + getAllActivityLogs, +} from '../controllers/activitylog.controller.js'; const router = Router(); @@ -10,4 +13,10 @@ router.get( getAllActivityLogs, ); +router.get( + '/api/organization/:organizationId/activity-logs/:logId', + verifyAccessToken, + getActivityLogById, +); + export default router; From faabdaa67cbdfb9539825fb59f4922d43f95280c Mon Sep 17 00:00:00 2001 From: Mohamed Dawoud Date: Sat, 19 Apr 2025 13:28:09 +0200 Subject: [PATCH 3/9] docs: add swagger docs for `getActivityLogById` endpoint --- README.md | 1 + src/docs/swagger.json | 168 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/README.md b/README.md index 3d091ea..c97b618 100644 --- a/README.md +++ b/README.md @@ -110,3 +110,4 @@ 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` diff --git a/src/docs/swagger.json b/src/docs/swagger.json index 1d45992..b79c8a0 100644 --- a/src/docs/swagger.json +++ b/src/docs/swagger.json @@ -6318,6 +6318,174 @@ } } } + }, + "/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" + } + } + } + } + } + } + } + } } }, From 4acd5479618e06fc6ba4247d623f75c84f4a2797 Mon Sep 17 00:00:00 2001 From: Mohamed Dawoud Date: Sat, 19 Apr 2025 13:39:37 +0200 Subject: [PATCH 4/9] fix: disable `no-case-declarations` flag --- eslint.config.mjs | 1 + 1 file changed, 1 insertion(+) 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', }, }, ]; From 2fe23133a1ab24dd4e82e9940fe1f7ccb2299bb2 Mon Sep 17 00:00:00 2001 From: Mohamed Dawoud Date: Sat, 19 Apr 2025 13:40:24 +0200 Subject: [PATCH 5/9] feat: add `getActivityFeed` controller function --- src/controllers/activitylog.controller.js | 218 ++++++++++++++++++++++ 1 file changed, 218 insertions(+) diff --git a/src/controllers/activitylog.controller.js b/src/controllers/activitylog.controller.js index c33ed1f..c648dd4 100644 --- a/src/controllers/activitylog.controller.js +++ b/src/controllers/activitylog.controller.js @@ -371,3 +371,221 @@ export const getActivityLogById = async (req, res, next) => { 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; + } +}; From 0674861a1af8815217f775793a3135bc4b06097d Mon Sep 17 00:00:00 2001 From: Mohamed Dawoud Date: Sat, 19 Apr 2025 13:40:50 +0200 Subject: [PATCH 6/9] feat: add route for `getActivityFeed` controller function --- src/routes/activitylog.routes.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/routes/activitylog.routes.js b/src/routes/activitylog.routes.js index 8411ba6..0b3f3ac 100644 --- a/src/routes/activitylog.routes.js +++ b/src/routes/activitylog.routes.js @@ -1,6 +1,7 @@ import { Router } from 'express'; import { verifyAccessToken } from '../middlewares/auth.middleware.js'; import { + getActivityFeed, getActivityLogById, getAllActivityLogs, } from '../controllers/activitylog.controller.js'; @@ -19,4 +20,10 @@ router.get( getActivityLogById, ); +router.get( + '/api/organization/:organizationId/activity-feed', + verifyAccessToken, + getActivityFeed, +); + export default router; From 767342896dc56e399433782d6a6ea0a265f8a307 Mon Sep 17 00:00:00 2001 From: Mohamed Dawoud Date: Sat, 19 Apr 2025 13:41:40 +0200 Subject: [PATCH 7/9] fix: eslint --- src/utils/activityLogs.utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From af81ccd585130d32e126bca3c20ced2420fb374a Mon Sep 17 00:00:00 2001 From: Mohamed Dawoud Date: Sat, 19 Apr 2025 13:43:05 +0200 Subject: [PATCH 8/9] docs: add swagger docs for get activity feed endpoint --- README.md | 1 + src/docs/swagger.json | 170 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) diff --git a/README.md b/README.md index c97b618..34dd197 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,4 @@ base url: `http://localhost:3000` - 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/src/docs/swagger.json b/src/docs/swagger.json index b79c8a0..46c2903 100644 --- a/src/docs/swagger.json +++ b/src/docs/swagger.json @@ -6486,6 +6486,176 @@ } } } + }, + "/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" + } + } + } + } + } + } + } + } } }, From 30bfc28cec91cb6b37ebf28a5d6401d48d8200f3 Mon Sep 17 00:00:00 2001 From: Mohamed Dawoud Date: Sat, 19 Apr 2025 13:46:34 +0200 Subject: [PATCH 9/9] fix: permissions of `getActivityLogById` function --- src/controllers/activitylog.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/activitylog.controller.js b/src/controllers/activitylog.controller.js index c648dd4..669e8d4 100644 --- a/src/controllers/activitylog.controller.js +++ b/src/controllers/activitylog.controller.js @@ -291,7 +291,7 @@ export const getActivityLogById = async (req, res, next) => { orgCheck.organization, 'view activity logs', ); - if (!hasPermission) { + if (!hasPermission.success) { return res.status(403).json({ success: false, message: 'You do not have permission to view activity logs',