diff --git a/netlify/functions-src/functions/mentors.ts b/netlify/functions-src/functions/mentors.ts index a93ca490..2bfde8d9 100644 --- a/netlify/functions-src/functions/mentors.ts +++ b/netlify/functions-src/functions/mentors.ts @@ -6,11 +6,13 @@ import { handler as getMentorsHanler } from './modules/mentors/get'; import { handler as getApplicationsHandler } from './modules/mentors/applications/get'; import { handler as upsertApplicationHandler } from './modules/mentors/applications/post'; import { handler as updateApplicationHandler } from './modules/mentors/applications/put'; +import { handler as getInactiveMentorsHandler } from './modules/mentors/inactive'; import { Role } from './common/interfaces/user.interface'; export const handler: ApiHandler = withDB( withRouter([ ['/', 'GET', withAuth(getMentorsHanler, { authRequired: false })], + ['/inactive', 'GET', withAuth(getInactiveMentorsHandler, { role: Role.ADMIN })], ['/applications', 'GET', withAuth(getApplicationsHandler, { includeFullUser: true, role: Role.ADMIN })], ['/applications/:applicationId', 'PUT', withAuth(updateApplicationHandler, { includeFullUser: true, role: Role.ADMIN })], ['/applications', 'POST', withAuth(upsertApplicationHandler, { includeFullUser: true, })], diff --git a/netlify/functions-src/functions/modules/mentors/inactive.test.ts b/netlify/functions-src/functions/modules/mentors/inactive.test.ts new file mode 100644 index 00000000..ac19b749 --- /dev/null +++ b/netlify/functions-src/functions/modules/mentors/inactive.test.ts @@ -0,0 +1,82 @@ +import { handler } from './inactive'; +import { getCollection } from '../../utils/db'; +import { HandlerEvent, HandlerContext } from '@netlify/functions'; +import { AuthContext } from '../../types'; + +jest.mock('../../utils/db'); + +const baseEvent: HandlerEvent = { + body: null, + headers: {}, + multiValueHeaders: {}, + httpMethod: 'GET', + isBase64Encoded: false, + path: '/mentors/inactive', + queryStringParameters: null, + multiValueQueryStringParameters: null, + rawQuery: '', + rawUrl: '', +}; + +const baseContext: AuthContext = { + callbackWaitsForEmptyEventLoop: true, + functionName: 'mentors', + functionVersion: '1', + invokedFunctionArn: '', + memoryLimitInMB: '128', + awsRequestId: '', + logGroupName: '', + logStreamName: '', + getRemainingTimeInMillis: () => 0, + done: () => {}, + fail: () => {}, + succeed: () => {}, + user: { auth0Id: 'admin|123' }, +}; + +describe('getInactiveMentorsHandler', () => { + const projectMock = jest.fn(); + const toArrayMock = jest.fn(); + const findMock = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + toArrayMock.mockResolvedValue([]); + projectMock.mockReturnValue({ toArray: toArrayMock }); + findMock.mockReturnValue({ project: projectMock }); + (getCollection as unknown as jest.Mock).mockReturnValue({ find: findMock }); + }); + + it('returns a success response with an empty array when no inactive mentors exist', async () => { + const response = await handler(baseEvent, baseContext); + const body = JSON.parse(response.body || '{}'); + + expect(response.statusCode).toBe(200); + expect(body.success).toBe(true); + expect(body.data).toEqual([]); + }); + + it('queries for users with Mentor role and available !== true', async () => { + await handler(baseEvent, baseContext); + + expect(findMock).toHaveBeenCalledWith({ + roles: 'Mentor', + available: { $ne: true }, + }); + }); + + it('returns inactive mentors in the response', async () => { + const mockMentors = [ + { _id: 'id1', name: 'Alice', email: 'alice@example.com', createdAt: new Date().toISOString() }, + { _id: 'id2', name: 'Bob', email: 'bob@example.com', createdAt: new Date().toISOString() }, + ]; + toArrayMock.mockResolvedValue(mockMentors); + + const response = await handler(baseEvent, baseContext); + const body = JSON.parse(response.body || '{}'); + + expect(response.statusCode).toBe(200); + expect(body.success).toBe(true); + expect(body.data).toEqual(mockMentors); + }); +}); diff --git a/netlify/functions-src/functions/modules/mentors/inactive.ts b/netlify/functions-src/functions/modules/mentors/inactive.ts new file mode 100644 index 00000000..052c09c4 --- /dev/null +++ b/netlify/functions-src/functions/modules/mentors/inactive.ts @@ -0,0 +1,18 @@ +import { Role } from '../../common/interfaces/user.interface'; +import type { ApiHandler } from '../../types'; +import { getCollection } from '../../utils/db'; +import { success } from '../../utils/response'; + +export const handler: ApiHandler = async () => { + const collection = getCollection('users'); + + const mentors = await collection + .find({ + roles: Role.MENTOR, + available: { $ne: true }, + }) + .project({ _id: 1, name: 1, email: 1, createdAt: 1, avatar: 1 }) + .toArray(); + + return success({ data: mentors }); +}; diff --git a/src/Me/Routes/Admin.tsx b/src/Me/Routes/Admin.tsx index cca565d9..3ab236c7 100644 --- a/src/Me/Routes/Admin.tsx +++ b/src/Me/Routes/Admin.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { freezeMentor, getAllMentorshipRequests, + getInactiveMentors, getUserRecords, sendMentorNotActive, sendStaledRequestEmail, @@ -26,6 +27,8 @@ import { Loader } from '../../components/Loader'; import { getChannelInfo } from '../../channelProvider'; import { useApi } from '../../context/apiContext/ApiContext'; +type InactiveMentor = Pick; + const Mentee = styled.span` display: flex; align-items: center; @@ -46,6 +49,68 @@ const Filters = styled.div` const pending = (status: Status) => status === STATUS.new || status === STATUS.viewed; +const InactiveMentorsSection = () => { + const [mentors, setMentors] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const api = useApi(); + + useEffect(() => { + getInactiveMentors(api) + .then((response) => { + if (response?.success) { + setMentors(response.data); + } + }) + .finally(() => setIsLoading(false)); + }, [api]); + + return ( + + {isLoading ? ( + + ) : mentors.length === 0 ? ( +

No inactive mentors found.

+ ) : ( + + + + {['Name', 'Email', 'Joined', 'Profile'].map((col) => ( + + ))} + + + + {mentors.map(({ _id, name, email, createdAt, avatar }) => ( + + + + + + + ))} + +
{col}
+ {avatar && ( + + )} + {name} + + {email} + {formatTimeAgo(new Date(createdAt))} + + 🔗 + +
+ )} +
+ ); +}; + const UserDetails = ({ userId, mentorships, @@ -269,6 +334,7 @@ const Admin = () => { mentorships={filteredMentorshipRequests} /> )} + diff --git a/src/api/admin.ts b/src/api/admin.ts index 6cb80655..e04c7a67 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -1,5 +1,5 @@ import ApiService, { paths } from '.'; -import { MentorshipRequest, UserRecord } from '../types/models'; +import { MentorshipRequest, User, UserRecord } from '../types/models'; export function getAllMentorshipRequests(apiService: ApiService, numMonthAgo: number = 1) { const monthAgo = new Date(); @@ -34,4 +34,10 @@ export async function freezeMentor(apiService: any, mentorId: string) { export function getUserRecords(apiService: ApiService, userId: string) { return apiService.makeApiCall(`${paths.USERS}/${userId}/records`); +} + +export function getInactiveMentors(apiService: ApiService) { + return apiService.makeApiCall[]>( + `${paths.MENTORS}/inactive` + ); } \ No newline at end of file