diff --git a/src/controllers/__tests__/activityLogController.test.js b/src/controllers/__tests__/activityLogController.test.js index fe6eb54ea..4bc2e42cb 100644 --- a/src/controllers/__tests__/activityLogController.test.js +++ b/src/controllers/__tests__/activityLogController.test.js @@ -1,190 +1,777 @@ -const mockSelect = jest.fn(); -const mockSort = jest.fn(() => ({ select: mockSelect })); -const mockFind = jest.fn(() => ({ sort: mockSort })); +jest.mock('../../models/activityLog', () => { + const mockActivityLog = jest.fn(); + mockActivityLog.find = jest.fn(); + mockActivityLog.findById = jest.fn(); + mockActivityLog.create = jest.fn(); + mockActivityLog.schema = { + path: jest.fn((field) => { + if (field === 'action_type') { + return { + enumValues: ['comment', 'note', 'announcement', 'task_upload', 'task_complete'], + }; + } + if (field === 'assisted_users') { + return { + schema: { + path: jest.fn(() => ({ + enumValues: ['created', 'edited'], + })), + }, + }; + } + return { enumValues: [] }; + }), + }; + return mockActivityLog; +}); + +jest.mock('../../models/userProfile', () => { + const mockUserProfile = jest.fn(); + mockUserProfile.find = jest.fn(); + return mockUserProfile; +}); -jest.mock('../../models/activityLog', () => ({ - find: (...args) => mockFind(...args), +jest.mock('../../startup/logger', () => ({ + logException: jest.fn(), })); const mongoose = require('mongoose'); -const controller = require('../activityLogController')(); +const ActivityLog = require('../../models/activityLog'); +const usersProfiles = require('../../models/userProfile'); +const activityLogControllerFactory = require('../activityLogController'); + +const resolvePromises = () => new Promise(setImmediate); + +const buildMockLog = (overrides = {}) => ({ + _id: '507f1f77bcf86cd799439011', + action_type: 'comment', + metadata: { text: 'hello' }, + created_at: new Date('2025-01-01'), + actor_id: '65cf6c3706d8ac105827bb2e', + is_assisted: false, + assisted_users: null, + ...overrides, +}); + +const buildAssistedLog = (overrides = {}) => + buildMockLog({ + is_assisted: true, + assisted_users: [ + { + user_id: '65cf6c3706d8ac105827bb30', + name: 'Jane Doe', + assisted_at: new Date('2025-01-01'), + assistance_type: 'edited', + }, + ], + ...overrides, + }); describe('activityLogController', () => { - let req; - let res; + let controller; + let mockRes; beforeEach(() => { jest.clearAllMocks(); - res = { + mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn(), }; + controller = activityLogControllerFactory(); }); + // ─── fetchStudentDailyLog ─────────────────────────────────────────── describe('fetchStudentDailyLog', () => { - beforeEach(() => { - req = { + it('returns logs for the requesting student', async () => { + const logs = [buildMockLog()]; + const chain = { + sort: jest.fn().mockReturnThis(), + select: jest.fn().mockResolvedValue(logs), + }; + ActivityLog.find.mockReturnValue(chain); + + const req = { + body: { requestor: { requestorId: '65cf6c3706d8ac105827bb2e' } }, + query: {}, + }; + + await controller.fetchStudentDailyLog(req, mockRes); + + expect(ActivityLog.find).toHaveBeenCalledWith({ + actor_id: expect.any(mongoose.Types.ObjectId), + }); + expect(chain.sort).toHaveBeenCalledWith({ created_at: -1 }); + expect(mockRes.json).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + log_id: '507f1f77bcf86cd799439011', + action_type: 'comment', + }), + ]), + ); + }); + + it('returns 403 when requesting another student log', async () => { + const req = { + body: { requestor: { requestorId: '65cf6c3706d8ac105827bb2e' } }, + query: { studentId: '65cf6c3706d8ac105827bb99' }, + }; + + await controller.fetchStudentDailyLog(req, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockRes.json).toHaveBeenCalledWith({ + error: "Forbidden: Cannot access another student's log", + }); + }); + + it('allows access when requested studentId matches own id', async () => { + const logs = [buildMockLog()]; + const chain = { + sort: jest.fn().mockReturnThis(), + select: jest.fn().mockResolvedValue(logs), + }; + ActivityLog.find.mockReturnValue(chain); + + const req = { + body: { requestor: { requestorId: '65cf6c3706d8ac105827bb2e' } }, + query: { studentId: '65cf6c3706d8ac105827bb2e' }, + }; + + await controller.fetchStudentDailyLog(req, mockRes); + + expect(mockRes.json).toHaveBeenCalled(); + }); + + it('returns 500 on database error', async () => { + const chain = { + sort: jest.fn().mockReturnThis(), + select: jest.fn().mockRejectedValue(new Error('DB fail')), + }; + ActivityLog.find.mockReturnValue(chain); + + const req = { + body: { requestor: { requestorId: '65cf6c3706d8ac105827bb2e' } }, + query: {}, + }; + + await controller.fetchStudentDailyLog(req, mockRes); + await resolvePromises(); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ error: 'An unexpected error occurred' }); + }); + }); + + // ─── createStudentDailyLog ────────────────────────────────────────── + describe('createStudentDailyLog', () => { + const validRequestor = { requestorId: '65cf6c3706d8ac105827bb2e', role: 'Volunteer' }; + + it('creates a log with valid data', async () => { + const savedLog = buildMockLog(); + ActivityLog.create.mockResolvedValue(savedLog); + + const req = { body: { - requestor: { requestorId: new mongoose.Types.ObjectId().toString() }, + requestor: validRequestor, + actionType: 'comment', + entityId: 'some-entity-id', + metadata: { text: 'hi' }, + }, + }; + + await controller.createStudentDailyLog(req, mockRes); + + expect(ActivityLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + actor_id: '65cf6c3706d8ac105827bb2e', + action_type: 'comment', + entity_id: 'some-entity-id', + metadata: { text: 'hi' }, + is_assisted: false, + }), + ); + expect(mockRes.status).toHaveBeenCalledWith(201); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Activity log created successfully' }), + ); + }); + + it('returns 400 when actionType is missing', async () => { + const req = { + body: { + requestor: validRequestor, + entityId: 'some-entity-id', }, - query: {}, }; + + await controller.createStudentDailyLog(req, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ error: 'actionType and entityId are required' }); }); - it('should return logs for the requestor', async () => { - const mockLogs = [{ action_type: 'comment', created_at: new Date() }]; - mockSelect.mockResolvedValue(mockLogs); + it('returns 400 when entityId is missing', async () => { + const req = { + body: { + requestor: validRequestor, + actionType: 'comment', + }, + }; - await controller.fetchStudentDailyLog(req, res); + await controller.createStudentDailyLog(req, mockRes); - expect(res.json).toHaveBeenCalledWith(mockLogs); + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ error: 'actionType and entityId are required' }); }); - it('should return 403 if requested studentId differs from requestorId', async () => { - req.query.studentId = new mongoose.Types.ObjectId().toString(); + it('returns 400 for invalid actionType', async () => { + const req = { + body: { + requestor: validRequestor, + actionType: 'invalid_type', + entityId: 'some-entity-id', + }, + }; - await controller.fetchStudentDailyLog(req, res); + await controller.createStudentDailyLog(req, mockRes); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ - error: "Forbidden: Cannot access another student's log", + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining('Invalid actionType') }), + ); + }); + + it('returns 403 when non-educator tries to set isAssisted', async () => { + const req = { + body: { + requestor: { ...validRequestor, role: 'Volunteer' }, + actionType: 'comment', + entityId: 'some-entity-id', + isAssisted: true, + assistedUsers: [{ userId: 'user1', assistanceType: 'edited' }], + }, + }; + + await controller.createStudentDailyLog(req, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Only educators or administrators can set the assisted flag', + }); + }); + + it('returns 400 when isAssisted is true but no assisted users provided', async () => { + const req = { + body: { + requestor: { ...validRequestor, role: 'Educator' }, + actionType: 'comment', + entityId: 'some-entity-id', + isAssisted: true, + }, + }; + + await controller.createStudentDailyLog(req, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'You must provide at least one assisted user if isAssisted is true', }); }); - it('should return 200 if requested studentId matches requestorId', async () => { - req.query.studentId = req.body.requestor.requestorId; - mockSelect.mockResolvedValue([]); + it('returns 400 when isAssisted is true but assistedUsers is empty array', async () => { + const req = { + body: { + requestor: { ...validRequestor, role: 'Educator' }, + actionType: 'comment', + entityId: 'some-entity-id', + isAssisted: true, + assistedUsers: [], + }, + }; - await controller.fetchStudentDailyLog(req, res); + await controller.createStudentDailyLog(req, mockRes); - expect(res.json).toHaveBeenCalledWith([]); + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'You must provide at least one assisted user if isAssisted is true', + }); }); - it('should return 400 for invalid studentId format', async () => { - req.body.requestor.requestorId = 'not-a-valid-id'; + it('creates a log with assisted users when educator sets isAssisted', async () => { + const profileUser = { + _id: '65cf6c3706d8ac105827bb30', + firstName: 'Jane', + lastName: 'Doe', + }; + usersProfiles.find.mockReturnValue({ + select: jest.fn().mockResolvedValue([profileUser]), + }); + + const savedLog = buildAssistedLog(); + ActivityLog.create.mockResolvedValue(savedLog); - await controller.fetchStudentDailyLog(req, res); + const req = { + body: { + requestor: { ...validRequestor, role: 'Educator' }, + actionType: 'comment', + entityId: 'some-entity-id', + isAssisted: true, + assistedUsers: [{ userId: '65cf6c3706d8ac105827bb30', assistanceType: 'edited' }], + }, + }; - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid studentId format' }); + await controller.createStudentDailyLog(req, mockRes); + + expect(usersProfiles.find).toHaveBeenCalledWith({ + _id: { $in: [expect.any(mongoose.Types.ObjectId)] }, + }); + expect(ActivityLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + is_assisted: true, + assisted_users: expect.arrayContaining([ + expect.objectContaining({ + user_id: '65cf6c3706d8ac105827bb30', + name: 'Jane Doe', + assistance_type: 'edited', + }), + ]), + }), + ); + expect(mockRes.status).toHaveBeenCalledWith(201); }); - it('should return 500 on database error', async () => { - mockSelect.mockRejectedValue(new Error('DB error')); + it('creates a log with assisted users when administrator sets isAssisted', async () => { + const profileUser = { + _id: '65cf6c3706d8ac105827bb30', + firstName: 'John', + lastName: 'Smith', + }; + usersProfiles.find.mockReturnValue({ + select: jest.fn().mockResolvedValue([profileUser]), + }); - await controller.fetchStudentDailyLog(req, res); + ActivityLog.create.mockResolvedValue(buildAssistedLog()); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ error: 'DB error' }); + const req = { + body: { + requestor: { ...validRequestor, role: 'Administrator' }, + actionType: 'note', + entityId: 'entity-2', + isAssisted: true, + assistedUsers: [{ userId: '65cf6c3706d8ac105827bb30', assistanceType: 'created' }], + }, + }; + + await controller.createStudentDailyLog(req, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(201); }); - it('should return 500 when req.body.requestor is undefined', async () => { - req.body = {}; + it('defaults metadata to empty object when not provided', async () => { + ActivityLog.create.mockResolvedValue(buildMockLog()); + + const req = { + body: { + requestor: validRequestor, + actionType: 'comment', + entityId: 'some-entity-id', + }, + }; - await controller.fetchStudentDailyLog(req, res); + await controller.createStudentDailyLog(req, mockRes); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) })); + expect(ActivityLog.create).toHaveBeenCalledWith(expect.objectContaining({ metadata: {} })); }); - it('should use requestorId when query studentId is empty string', async () => { - req.query.studentId = ''; - mockSelect.mockResolvedValue([{ action_type: 'comment' }]); + it('returns 500 on database error', async () => { + ActivityLog.create.mockRejectedValue(new Error('DB error')); + + const req = { + body: { + requestor: validRequestor, + actionType: 'comment', + entityId: 'some-entity-id', + }, + }; - await controller.fetchStudentDailyLog(req, res); + await controller.createStudentDailyLog(req, mockRes); + await resolvePromises(); - expect(mockFind).toHaveBeenCalledWith({ actor_id: expect.any(mongoose.Types.ObjectId) }); - expect(res.json).toHaveBeenCalledWith([{ action_type: 'comment' }]); + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ error: 'An unexpected error occurred' }); }); - it('should call find with correct filter, sort, and select', async () => { - mockSelect.mockResolvedValue([]); + it('returns formatted log in response', async () => { + const savedLog = buildMockLog({ is_assisted: false }); + ActivityLog.create.mockResolvedValue(savedLog); - await controller.fetchStudentDailyLog(req, res); + const req = { + body: { + requestor: validRequestor, + actionType: 'comment', + entityId: 'some-entity-id', + }, + }; - expect(mockFind).toHaveBeenCalledWith({ actor_id: expect.any(mongoose.Types.ObjectId) }); - expect(mockSort).toHaveBeenCalledWith({ created_at: -1 }); - expect(mockSelect).toHaveBeenCalledWith('action_type metadata created_at actor_id'); + await controller.createStudentDailyLog(req, mockRes); + + const responseBody = mockRes.json.mock.calls[0][0]; + expect(responseBody.log).toEqual( + expect.objectContaining({ + log_id: '507f1f77bcf86cd799439011', + action_type: 'comment', + is_assisted: false, + }), + ); + expect(responseBody.log.assisted_users).toBeUndefined(); }); }); - describe('fetchEducatorDailyLog', () => { - beforeEach(() => { - req = { - params: { studentId: new mongoose.Types.ObjectId().toString() }, + // ─── updateStudentDailyLog ────────────────────────────────────────── + describe('updateStudentDailyLog', () => { + it('returns 400 when logId is missing', async () => { + const req = { + params: {}, + body: { requestor: { role: 'Educator' } }, + }; + + await controller.updateStudentDailyLog(req, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ error: 'Invalid or missing logId' }); + }); + + it('returns 403 when non-educator tries to update', async () => { + const req = { + params: { logId: '507f1f77bcf86cd799439011' }, + body: { requestor: { role: 'Volunteer' } }, + }; + + await controller.updateStudentDailyLog(req, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Only educators or administrators can update the assisted flag', + }); + }); + + it('returns 404 when log is not found', async () => { + ActivityLog.findById.mockResolvedValue(null); + + const req = { + params: { logId: '507f1f77bcf86cd799439011' }, + body: { requestor: { role: 'Educator' } }, + }; + + await controller.updateStudentDailyLog(req, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockRes.json).toHaveBeenCalledWith({ error: 'Activity log not found' }); + }); + + it('updates is_assisted to false when isAssisted is not set', async () => { + const mockLog = { + _id: '507f1f77bcf86cd799439011', + is_assisted: true, + assisted_users: [], + save: jest.fn().mockResolvedValue(true), + }; + ActivityLog.findById.mockResolvedValue(mockLog); + + const req = { + params: { logId: '507f1f77bcf86cd799439011' }, + body: { requestor: { role: 'Educator' } }, + }; + + await controller.updateStudentDailyLog(req, mockRes); + + expect(mockLog.is_assisted).toBe(false); + expect(mockLog.assisted_users).toEqual([]); + expect(mockLog.save).toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith(200); + }); + + it('returns 400 when isAssisted is true but assistedUsers is missing', async () => { + const mockLog = { + _id: '507f1f77bcf86cd799439011', + is_assisted: false, + assisted_users: null, + save: jest.fn(), + }; + ActivityLog.findById.mockResolvedValue(mockLog); + + const req = { + params: { logId: '507f1f77bcf86cd799439011' }, + body: { + requestor: { role: 'Educator' }, + isAssisted: true, + }, + }; + + await controller.updateStudentDailyLog(req, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'You must provide at least one assisted user if isAssisted is true', + }); + }); + + it('returns 400 when isAssisted is true but assistedUsers is empty', async () => { + const mockLog = { + _id: '507f1f77bcf86cd799439011', + is_assisted: false, + assisted_users: null, + save: jest.fn(), + }; + ActivityLog.findById.mockResolvedValue(mockLog); + + const req = { + params: { logId: '507f1f77bcf86cd799439011' }, + body: { + requestor: { role: 'Educator' }, + isAssisted: true, + assistedUsers: [], + }, + }; + + await controller.updateStudentDailyLog(req, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'You must provide at least one assisted user if isAssisted is true', + }); + }); + + it('updates log with assisted users', async () => { + const profileUser = { + _id: '65cf6c3706d8ac105827bb30', + firstName: 'Jane', + lastName: 'Doe', + }; + usersProfiles.find.mockReturnValue({ + select: jest.fn().mockResolvedValue([profileUser]), + }); + + const mockLog = { + _id: '507f1f77bcf86cd799439011', + is_assisted: false, + assisted_users: null, + save: jest.fn().mockResolvedValue(true), + }; + ActivityLog.findById.mockResolvedValue(mockLog); + + const req = { + params: { logId: '507f1f77bcf86cd799439011' }, + body: { + requestor: { role: 'Educator' }, + isAssisted: true, + assistedUsers: [{ userId: '65cf6c3706d8ac105827bb30', assistanceType: 'edited' }], + }, + }; + + await controller.updateStudentDailyLog(req, mockRes); + + expect(mockLog.is_assisted).toBe(true); + expect(mockLog.assisted_users).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + user_id: '65cf6c3706d8ac105827bb30', + name: 'Jane Doe', + assistance_type: 'edited', + }), + ]), + ); + expect(mockLog.save).toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Activity log updated successfully' }), + ); + }); + + it('allows administrator to update assisted flag', async () => { + const mockLog = { + _id: '507f1f77bcf86cd799439011', + is_assisted: false, + assisted_users: null, + save: jest.fn().mockResolvedValue(true), + }; + ActivityLog.findById.mockResolvedValue(mockLog); + + const req = { + params: { logId: '507f1f77bcf86cd799439011' }, + body: { + requestor: { role: 'Administrator' }, + isAssisted: false, + }, }; + + await controller.updateStudentDailyLog(req, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); }); - it('should return logs for the given studentId', async () => { - const mockLogs = [{ action_type: 'note', created_at: new Date() }]; - mockSelect.mockResolvedValue(mockLogs); + it('returns 500 on database error', async () => { + ActivityLog.findById.mockRejectedValue(new Error('DB fail')); + + const req = { + params: { logId: '507f1f77bcf86cd799439011' }, + body: { requestor: { role: 'Educator' } }, + }; - await controller.fetchEducatorDailyLog(req, res); + await controller.updateStudentDailyLog(req, mockRes); + await resolvePromises(); - expect(res.json).toHaveBeenCalledWith(mockLogs); + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ error: 'An unexpected error occurred' }); }); + }); + + // ─── fetchEducatorDailyLog ────────────────────────────────────────── + describe('fetchEducatorDailyLog', () => { + it('returns logs for a student when educator is authorized', async () => { + const logs = [buildMockLog()]; + const chain = { + sort: jest.fn().mockReturnThis(), + select: jest.fn().mockResolvedValue(logs), + }; + ActivityLog.find.mockReturnValue(chain); - it('should return 400 when studentId is missing', async () => { - req.params = {}; + const req = { + params: { studentId: '65cf6c3706d8ac105827bb2e' }, + body: { requestor: { role: 'Educator' } }, + }; - await controller.fetchEducatorDailyLog(req, res); + await controller.fetchEducatorDailyLog(req, mockRes); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Missing studentId' }); + expect(ActivityLog.find).toHaveBeenCalledWith({ + actor_id: expect.any(mongoose.Types.ObjectId), + }); + expect(mockRes.json).toHaveBeenCalled(); }); - it('should return 400 for invalid studentId format', async () => { - req.params.studentId = 'invalid-id'; + it('returns logs for a student when administrator is authorized', async () => { + const logs = [buildMockLog()]; + const chain = { + sort: jest.fn().mockReturnThis(), + select: jest.fn().mockResolvedValue(logs), + }; + ActivityLog.find.mockReturnValue(chain); - await controller.fetchEducatorDailyLog(req, res); + const req = { + params: { studentId: '65cf6c3706d8ac105827bb2e' }, + body: { requestor: { role: 'Administrator' } }, + }; - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid studentId format' }); + await controller.fetchEducatorDailyLog(req, mockRes); + + expect(mockRes.json).toHaveBeenCalled(); }); - it('should return 500 on database error', async () => { - mockSelect.mockRejectedValue(new Error('DB failure')); + it('returns 400 when studentId is missing', async () => { + const req = { + params: {}, + body: { requestor: { role: 'Educator' } }, + }; - await controller.fetchEducatorDailyLog(req, res); + await controller.fetchEducatorDailyLog(req, mockRes); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ error: 'DB failure' }); + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ error: 'Missing studentId' }); }); - it('should return empty array when no logs exist', async () => { - mockSelect.mockResolvedValue([]); + it('returns 403 when user is not an educator or administrator', async () => { + const req = { + params: { studentId: '65cf6c3706d8ac105827bb2e' }, + body: { requestor: { role: 'Volunteer' } }, + }; - await controller.fetchEducatorDailyLog(req, res); + await controller.fetchEducatorDailyLog(req, mockRes); - expect(res.json).toHaveBeenCalledWith([]); + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Only Educators can view students logs', + }); }); - it('should return 400 when studentId is empty string', async () => { - req.params.studentId = ''; + it('returns 500 on database error', async () => { + const chain = { + sort: jest.fn().mockReturnThis(), + select: jest.fn().mockRejectedValue(new Error('DB fail')), + }; + ActivityLog.find.mockReturnValue(chain); - await controller.fetchEducatorDailyLog(req, res); + const req = { + params: { studentId: '65cf6c3706d8ac105827bb2e' }, + body: { requestor: { role: 'Educator' } }, + }; - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Missing studentId' }); + await controller.fetchEducatorDailyLog(req, mockRes); + await resolvePromises(); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ error: 'An unexpected error occurred' }); }); - it('should return 400 when studentId is null', async () => { - req.params.studentId = null; + it('returns formatted logs with correct fields', async () => { + const logs = [ + buildMockLog({ + action_type: 'note', + metadata: { key: 'value' }, + is_assisted: false, + }), + ]; + const chain = { + sort: jest.fn().mockReturnThis(), + select: jest.fn().mockResolvedValue(logs), + }; + ActivityLog.find.mockReturnValue(chain); - await controller.fetchEducatorDailyLog(req, res); + const req = { + params: { studentId: '65cf6c3706d8ac105827bb2e' }, + body: { requestor: { role: 'Educator' } }, + }; - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Missing studentId' }); + await controller.fetchEducatorDailyLog(req, mockRes); + + const response = mockRes.json.mock.calls[0][0]; + expect(response[0]).toEqual( + expect.objectContaining({ + log_id: '507f1f77bcf86cd799439011', + action_type: 'note', + metadata: { key: 'value' }, + is_assisted: false, + }), + ); + expect(response[0].assisted_users).toBeUndefined(); }); - it('should call find with correct filter, sort, and select', async () => { - mockSelect.mockResolvedValue([]); + it('includes assisted_users in formatted logs when is_assisted is true', async () => { + const logs = [buildAssistedLog()]; + const chain = { + sort: jest.fn().mockReturnThis(), + select: jest.fn().mockResolvedValue(logs), + }; + ActivityLog.find.mockReturnValue(chain); - await controller.fetchEducatorDailyLog(req, res); + const req = { + params: { studentId: '65cf6c3706d8ac105827bb2e' }, + body: { requestor: { role: 'Educator' } }, + }; - expect(mockFind).toHaveBeenCalledWith({ actor_id: expect.any(mongoose.Types.ObjectId) }); - expect(mockSort).toHaveBeenCalledWith({ created_at: -1 }); - expect(mockSelect).toHaveBeenCalledWith('action_type metadata created_at actor_id'); + await controller.fetchEducatorDailyLog(req, mockRes); + + const response = mockRes.json.mock.calls[0][0]; + expect(response[0].is_assisted).toBe(true); + expect(response[0].assisted_users).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + user_id: '65cf6c3706d8ac105827bb30', + name: 'Jane Doe', + assistance_type: 'edited', + }), + ]), + ); }); }); }); diff --git a/src/controllers/activityLogController.js b/src/controllers/activityLogController.js index ecedeb0c0..a576c45e4 100644 --- a/src/controllers/activityLogController.js +++ b/src/controllers/activityLogController.js @@ -1,61 +1,242 @@ const mongoose = require('mongoose'); const ActivityLog = require('../models/activityLog'); +const usersProfiles = require('../models/userProfile'); +const logger = require('../startup/logger'); const activityLogController = function () { + const validRoles = new Set(['Educator', 'Administrator']); + + const formatLogs = (logs) => + logs.map((log) => ({ + log_id: log._id, + action_type: log.action_type, + metadata: log.metadata, + created_at: log.created_at, + actor_id: log.actor_id, + is_assisted: log.is_assisted, + ...(log.is_assisted && + log.assisted_users && { + assisted_users: log.assisted_users.map((au) => ({ + user_id: au.user_id, + name: au.name, + assisted_at: au.assisted_at, + assistance_type: au.assistance_type, + })), + }), + })); + + const sanitizeObjectIds = (values) => + values + .filter((v) => mongoose.Types.ObjectId.isValid(v)) + .map((v) => new mongoose.Types.ObjectId(v)); + + const resolveAssistedUsers = async (assistedUsersFromClient) => { + const validAssistanceTypes = ActivityLog.schema + .path('assisted_users') + .schema.path('assistance_type').enumValues; + + const userIds = sanitizeObjectIds(assistedUsersFromClient.map((u) => u.userId)); + if (userIds.length !== assistedUsersFromClient.length) { + throw new Error('One or more provided userIds are invalid'); + } + + const profiles = await usersProfiles + .find({ _id: { $in: userIds } }) + .select('firstName lastName'); + + return profiles.map((user) => { + const clientObj = assistedUsersFromClient.find((u) => String(u.userId) === String(user._id)); + + const { assistanceType } = clientObj; + if (!validAssistanceTypes.includes(assistanceType)) { + throw new Error(`Invalid assistanceType for user ${user._id}: ${assistanceType}`); + } + + return { + user_id: user._id, + name: `${user.firstName} ${user.lastName}`, + assisted_at: new Date(), + assistance_type: assistanceType, + }; + }); + }; + async function fetchStudentDailyLog(req, res) { try { const studentId = req.body.requestor.requestorId; - const requestedStudentId = req.query.studentId; - if (requestedStudentId && requestedStudentId !== String(studentId)) { - return res.status(403).json({ error: "Forbidden: Cannot access another student's log" }); - } - const authorizedStudentId = requestedStudentId || String(studentId); - - let objectId; - try { - objectId = new mongoose.Types.ObjectId(authorizedStudentId); - } catch (e) { + if (!mongoose.Types.ObjectId.isValid(studentId)) { return res.status(400).json({ error: 'Invalid studentId format' }); } + const sanitizedStudentId = new mongoose.Types.ObjectId(studentId); + + if (requestedStudentId) { + if (!mongoose.Types.ObjectId.isValid(requestedStudentId)) { + return res.status(400).json({ error: 'Invalid studentId format' }); + } + if (requestedStudentId !== String(sanitizedStudentId)) { + return res.status(403).json({ error: "Forbidden: Cannot access another student's log" }); + } + } - const logs = await ActivityLog.find({ actor_id: objectId }) + const logs = await ActivityLog.find({ actor_id: sanitizedStudentId }) .sort({ created_at: -1 }) - .select('action_type metadata created_at actor_id'); + .select('action_type metadata created_at actor_id is_assisted assisted_users'); + + res.json(formatLogs(logs)); + } catch (err) { + logger.logException(err, 'fetchStudentDailyLog', { requestor: req.body.requestor }); + res.status(500).json({ error: 'An unexpected error occurred' }); + } + } + + async function createStudentDailyLog(req, res) { + try { + const currentUser = req.body.requestor; + const { + actionType, + entityId, + metadata, + isAssisted: isAssistedFromClient, + assistedUsers: assistedUsersFromClient, + } = req.body; + + if (!actionType || !entityId) { + return res.status(400).json({ error: 'actionType and entityId are required' }); + } + + const validActionTypes = ActivityLog.schema.path('action_type').enumValues; + + if (!validActionTypes.includes(actionType)) { + return res.status(400).json({ + error: `Invalid actionType. Must be one of: ${validActionTypes.join(', ')}`, + }); + } - res.json(logs); + let isAssisted = false; + let assistedUsers = null; + + if (isAssistedFromClient) { + if (!validRoles.has(currentUser.role)) { + return res.status(403).json({ + error: 'Only educators or administrators can set the assisted flag', + }); + } + + isAssisted = true; + + if (!assistedUsersFromClient || assistedUsersFromClient.length === 0) { + return res.status(400).json({ + error: 'You must provide at least one assisted user if isAssisted is true', + }); + } + + assistedUsers = await resolveAssistedUsers(assistedUsersFromClient); + } + + const logData = { + actor_id: currentUser.requestorId, + action_type: actionType, + entity_id: entityId, + metadata: metadata || {}, + created_at: new Date(), + is_assisted: isAssisted, + assisted_users: assistedUsers, + }; + + const newLog = await ActivityLog.create(logData); + + const formattedLogs = formatLogs([newLog]); + const responseLog = formattedLogs[0]; + + return res.status(201).json({ + message: 'Activity log created successfully', + log: responseLog, + }); + } catch (err) { + logger.logException(err, 'createStudentDailyLog', { requestor: req.body.requestor }); + return res.status(500).json({ error: 'An unexpected error occurred' }); + } + } + + async function updateStudentDailyLog(req, res) { + try { + const { logId } = req.params; + const currentUser = req.body.requestor; + const { isAssisted: isAssistedFromClient, assistedUsers: assistedUsersFromClient } = req.body; + + if (!logId || !mongoose.Types.ObjectId.isValid(logId)) { + return res.status(400).json({ error: 'Invalid or missing logId' }); + } + + if (!validRoles.has(currentUser.role)) { + return res.status(403).json({ + error: 'Only educators or administrators can update the assisted flag', + }); + } + + const log = await ActivityLog.findById(logId); + if (!log) return res.status(404).json({ error: 'Activity log not found' }); + + let assistedUsers = []; + if (isAssistedFromClient) { + if (!assistedUsersFromClient || assistedUsersFromClient.length === 0) { + return res.status(400).json({ + error: 'You must provide at least one assisted user if isAssisted is true', + }); + } + + assistedUsers = await resolveAssistedUsers(assistedUsersFromClient); + } + + log.is_assisted = Boolean(isAssistedFromClient); + log.assisted_users = assistedUsers; + await log.save(); + + const formattedLog = formatLogs([log])[0]; + + return res.status(200).json({ + message: 'Activity log updated successfully', + log: formattedLog, + }); } catch (err) { - res.status(500).json({ error: err.message }); + logger.logException(err, 'updateStudentDailyLog', { requestor: req.body.requestor }); + return res.status(500).json({ error: 'An unexpected error occurred' }); } } async function fetchEducatorDailyLog(req, res) { try { const { studentId } = req.params; - + const currentUser = req.body.requestor; if (!studentId) return res.status(400).json({ error: 'Missing studentId' }); - let objectId; - try { - objectId = new mongoose.Types.ObjectId(studentId); - } catch (e) { + if (!mongoose.Types.ObjectId.isValid(studentId)) { return res.status(400).json({ error: 'Invalid studentId format' }); } + const sanitizedStudentId = new mongoose.Types.ObjectId(studentId); + + if (!validRoles.has(currentUser.role)) { + return res.status(403).json({ error: 'Only Educators can view students logs' }); + } - const logs = await ActivityLog.find({ actor_id: objectId }) + const logs = await ActivityLog.find({ actor_id: sanitizedStudentId }) .sort({ created_at: -1 }) - .select('action_type metadata created_at actor_id'); + .select('action_type metadata created_at actor_id is_assisted assisted_users'); - res.json(logs); + res.json(formatLogs(logs)); } catch (err) { - res.status(500).json({ error: err.message }); + logger.logException(err, 'fetchEducatorDailyLog', { requestor: req.body.requestor }); + res.status(500).json({ error: 'An unexpected error occurred' }); } } return { fetchStudentDailyLog, fetchEducatorDailyLog, + createStudentDailyLog, + updateStudentDailyLog, }; }; diff --git a/src/models/activityLog.js b/src/models/activityLog.js index ec1dda09b..53491d902 100644 --- a/src/models/activityLog.js +++ b/src/models/activityLog.js @@ -1,6 +1,7 @@ const mongoose = require('mongoose'); -const { v4: uuidv4, validate: isUUID } = require('uuid'); +const { v4: uuidv4 } = require('uuid'); +const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const activityLogSchema = new mongoose.Schema( { log_id: { @@ -25,7 +26,7 @@ const activityLogSchema = new mongoose.Schema( }, validate: { validator(v) { - return isUUID(v); + return uuidRegex.test(v); }, message(props) { return `${props.value} is not a valid UUID!`; @@ -40,6 +41,36 @@ const activityLogSchema = new mongoose.Schema( type: Date, default: Date.now, }, + is_assisted: { + type: Boolean, + default: false, + }, + assisted_users: { + type: [ + { + user_id: { + type: mongoose.Schema.Types.ObjectId, + ref: 'UserProfile', + required: true, + }, + name: { + type: String, + required: true, + }, + assisted_at: { + type: Date, + default: Date.now, + }, + assistance_type: { + type: String, + enum: ['created', 'edited'], + default: 'edited', + }, + _id: false, + }, + ], + default: null, + }, }, { collection: 'ActivityLog', diff --git a/src/routes/activityLogRouter.js b/src/routes/activityLogRouter.js index 431e09c61..0b4dcc13c 100644 --- a/src/routes/activityLogRouter.js +++ b/src/routes/activityLogRouter.js @@ -4,9 +4,9 @@ const routes = function () { const activityLogRouter = express.Router(); const controller = require('../controllers/activityLogController')(); activityLogRouter.route('/student/daily-log').get(controller.fetchStudentDailyLog); - activityLogRouter.route('/educator/daily-log/:studentId').get(controller.fetchEducatorDailyLog); - + activityLogRouter.route('/student/daily-log').post(controller.createStudentDailyLog); + activityLogRouter.route('/student/daily-log/:logId').put(controller.updateStudentDailyLog); return activityLogRouter; };