From b333f287ed51d0c67e6c335480efe081dce70249 Mon Sep 17 00:00:00 2001 From: nfracchia-pw Date: Sat, 9 Aug 2025 18:59:42 -0600 Subject: [PATCH 1/7] feat(mentorship): mentor dashboard with create session functionality --- .../backend/src/mentee/mentee.controller.ts | 56 +++++ packages/backend/src/mentee/mentee.service.ts | 171 ++++++++++++++ .../src/mentor/entities/mentor.entity.ts | 4 + packages/backend/src/mentor/mentor.service.ts | 2 + .../features/mentorship/api/mentorship-api.ts | 29 ++- .../common/modals/create-session-modal.tsx | 210 ++++++++++++++++++ .../components/mentee-dashboard.tsx | 188 +++++----------- .../components/mentor-dashboard.tsx | 11 +- .../src/features/mentorship/store/index.ts | 102 ++++++++- .../src/features/mentorship/types/index.ts | 108 ++++++++- 10 files changed, 741 insertions(+), 140 deletions(-) create mode 100644 packages/frontend/src/features/mentorship/components/common/modals/create-session-modal.tsx diff --git a/packages/backend/src/mentee/mentee.controller.ts b/packages/backend/src/mentee/mentee.controller.ts index b537a4d..8fe6a62 100644 --- a/packages/backend/src/mentee/mentee.controller.ts +++ b/packages/backend/src/mentee/mentee.controller.ts @@ -180,6 +180,62 @@ export class MenteeController { return this.menteeService.updateStatus(+menteeApplicationId, updatedMentee); } + @Get('my-dashboard') + @Roles('USER', 'MENTEE', 'ADMIN') + @ApiOkResponse({ description: 'Mentee dashboard payload' }) + @ApiInternalServerErrorResponse({ description: 'Internal server error' }) + @ApiOperation({ + summary: 'Get mentee dashboard', + description: + 'Returns current mentor profile, statistics, and next session info for the logged mentee. REQUIRED ROLES: USER | MENTEE | ADMIN', + }) + @ApiBearerAuth() + async getMyDashboard(@GetUser() user: JwtPayload) { + return await this.menteeService.getMyDashboard(user.sub); + } + + @Get('my-past-mentors') + @Roles('USER', 'MENTEE', 'ADMIN') + @ApiOkResponse({ description: 'List of past mentors' }) + @ApiInternalServerErrorResponse({ description: 'Internal server error' }) + @ApiOperation({ + summary: 'Get past mentors', + description: + 'Returns mentors previously matched with the mentee. REQUIRED ROLES: USER | MENTEE | ADMIN', + }) + @ApiBearerAuth() + async getMyPastMentors(@GetUser() user: JwtPayload) { + return await this.menteeService.getMyPastMentors(user.sub); + } + + @Get('my-notes') + @Roles('USER', 'MENTEE', 'ADMIN') + @ApiOkResponse({ description: 'List of mentee notes across sessions' }) + @ApiInternalServerErrorResponse({ description: 'Internal server error' }) + @ApiOperation({ + summary: 'Get mentee notes', + description: + 'Returns notes the mentee took for mentorship sessions. REQUIRED ROLES: USER | MENTEE | ADMIN', + }) + @ApiBearerAuth() + async getMyNotes(@GetUser() user: JwtPayload) { + return await this.menteeService.getMyNotes(user.sub); + } + + @Get('my-upcoming-session') + @Roles('USER', 'MENTEE', 'ADMIN') + @ApiOkResponse({ description: 'Next upcoming session for mentee' }) + @ApiInternalServerErrorResponse({ description: 'Internal server error' }) + @ApiOperation({ + summary: 'Get mentee upcoming session', + description: + 'Returns the next upcoming session for the mentee. REQUIRED ROLES: USER | MENTEE | ADMIN', + }) + @ApiBearerAuth() + async getMyUpcomingSession(@GetUser() user: JwtPayload) { + return await this.menteeService.getMyUpcomingSession(user.sub); + } + // TO-DO: REMOVE MENTEE INFORMATION? OR CHANGE STATUS TO "REJECTED" OR "DELETED" TO KEEP THE INFORMATION? @Delete(':id') diff --git a/packages/backend/src/mentee/mentee.service.ts b/packages/backend/src/mentee/mentee.service.ts index 8cffc2f..2c9d065 100644 --- a/packages/backend/src/mentee/mentee.service.ts +++ b/packages/backend/src/mentee/mentee.service.ts @@ -356,6 +356,177 @@ export class MenteeService { } } + async getMyDashboard(menteeId: number) { + try { + // Current approved match (mentor) + const currentMatch = await this.prisma.matchedMentorMentee.findFirst({ + where: { menteeId, status: 'APPROVED' }, + include: { + mentor: { + select: { + first_name: true, + last_name: true, + email: true, + profession: true, + company_name: true, + picture_upload_link: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + // Sessions attended + const sessionsAttended = await this.prisma.mentorshipSessions.count({ + where: { menteeId, dateEnd: { lt: new Date() } }, + }); + + // First match date as mentorship started + const firstMatch = await this.prisma.matchedMentorMentee.findFirst({ + where: { menteeId }, + orderBy: { createdAt: 'asc' }, + select: { createdAt: true }, + }); + + // Next upcoming session + const nextSession = await this.prisma.mentorshipSessions.findFirst({ + where: { menteeId, dateStart: { gt: new Date() } }, + orderBy: { dateStart: 'asc' }, + select: { dateStart: true }, + }); + + const mentor = currentMatch + ? { + firstName: currentMatch.mentor.first_name, + lastName: currentMatch.mentor.last_name, + email: currentMatch.mentor.email, + profession: currentMatch.mentor.profession ?? undefined, + company: currentMatch.mentor.company_name ?? undefined, + expertise: currentMatch.mentor.profession ?? undefined, + avatarUrl: currentMatch.mentor.picture_upload_link ?? undefined, + } + : null; + + return { + mentor, + mentorshipStarted: firstMatch?.createdAt?.toISOString() ?? null, + sessionsAttended, + nextSession: nextSession?.dateStart?.toISOString() ?? null, + }; + } catch (error) { + throw new InternalServerErrorException( + `Error building mentee dashboard: ${error.message}`, + ); + } + } + + async getMyPastMentors(menteeId: number) { + try { + const matches = await this.prisma.matchedMentorMentee.findMany({ + where: { menteeId }, + include: { + mentor: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + company_name: true, + picture_upload_link: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: 20, + }); + + return matches.map((m) => ({ + id: m.mentor.id, + firstName: m.mentor.first_name, + lastName: m.mentor.last_name, + profession: m.mentor.profession ?? 'Not specified', + company: m.mentor.company_name ?? undefined, + expertise: m.mentor.profession ?? undefined, + email: m.mentor.email, + avatarUrl: m.mentor.picture_upload_link ?? undefined, + })); + } catch (error) { + throw new InternalServerErrorException( + `Error getting past mentors: ${error.message}`, + ); + } + } + + async getMyNotes(menteeId: number) { + try { + // Get recent sessions with mentee notes + const sessions = await this.prisma.mentorshipSessions.findMany({ + where: { menteeId }, + include: { + menteeNotes: true, + }, + orderBy: { dateEnd: 'desc' }, + take: 50, + }); + + // Flatten notes + const notes = sessions.flatMap((s, idx) => + s.menteeNotes.map((n, noteIdx) => ({ + title: `Session ${idx + 1}${noteIdx > 0 ? ` - Note ${noteIdx + 1}` : ''}`, + content: n.notes, + date: s.dateEnd?.toISOString() ?? s.dateStart?.toISOString(), + })), + ); + + return notes; + } catch (error) { + throw new InternalServerErrorException( + `Error getting mentee notes: ${error.message}`, + ); + } + } + + async getMyUpcomingSession(menteeId: number) { + try { + const session = await this.prisma.mentorshipSessions.findFirst({ + where: { menteeId, dateStart: { gt: new Date() } }, + orderBy: { dateStart: 'asc' }, + include: { + mentor: { + select: { + first_name: true, + last_name: true, + email: true, + picture_upload_link: true, + profession: true, + }, + }, + }, + }); + + if (!session) return null; + + return { + id: session.id, + dateStart: session.dateStart.toISOString(), + dateEnd: session.dateEnd.toISOString(), + link: session.link ?? '', + mentor: { + firstName: session.mentor.first_name, + lastName: session.mentor.last_name, + email: session.mentor.email, + avatarUrl: session.mentor.picture_upload_link ?? undefined, + profession: session.mentor.profession ?? undefined, + }, + }; + } catch (error) { + throw new InternalServerErrorException( + `Error getting upcoming session: ${error.message}`, + ); + } + } + remove(id: number) { // TO-DO: REMOVE MENTEE INFORMATION? OR CHANGE STATUS TO "REJECTED" OR "DELETED" TO KEEP THE INFORMATION? return `This action removes a #${id} mentee`; diff --git a/packages/backend/src/mentor/entities/mentor.entity.ts b/packages/backend/src/mentor/entities/mentor.entity.ts index 7576c0c..c5d67ab 100644 --- a/packages/backend/src/mentor/entities/mentor.entity.ts +++ b/packages/backend/src/mentor/entities/mentor.entity.ts @@ -136,6 +136,10 @@ export class MyMentees { @IsNumber() id?: number; + @ApiProperty({ description: 'User ID of the mentee', example: '25' }) + @IsNumber() + menteeUserId?: number; + @ApiProperty({ description: 'Full name of the mentee', example: 'Jane Smith', diff --git a/packages/backend/src/mentor/mentor.service.ts b/packages/backend/src/mentor/mentor.service.ts index 8e592da..18dba52 100644 --- a/packages/backend/src/mentor/mentor.service.ts +++ b/packages/backend/src/mentor/mentor.service.ts @@ -632,6 +632,7 @@ export class MentorService { include: { mentee: { select: { + id: true, first_name: true, last_name: true, email: true, @@ -647,6 +648,7 @@ export class MentorService { // Transform the data to match the MyMentees entity const transformedMentees = mentees.map((matching) => ({ id: matching.id, + menteeUserId: matching.mentee.id, mentee: `${matching.mentee.first_name} ${matching.mentee.last_name}`, email: matching.mentee.email, profession: matching.mentee.profession || 'Not specified', diff --git a/packages/frontend/src/features/mentorship/api/mentorship-api.ts b/packages/frontend/src/features/mentorship/api/mentorship-api.ts index 2846279..c219c3c 100644 --- a/packages/frontend/src/features/mentorship/api/mentorship-api.ts +++ b/packages/frontend/src/features/mentorship/api/mentorship-api.ts @@ -10,7 +10,13 @@ import { MyMentorDashboard, MentorStatistics, MentorUpcomingSessions, - MyMentees + MyMentees, + MenteeDashboard, + MenteeNote, + PastMentor, + MenteeUpcomingSession, + CreateMentorshipSessionDto, + MentorshipSession } from '../types'; export const mentorshipApi = { @@ -58,5 +64,24 @@ export const mentorshipApi = { getMyUpcomingSessions: () => apiMethods.get('/mentors/my-upcoming-sessions'), - getMyMentees: () => apiMethods.get('/mentors/my-mentees') + getMyMentees: () => apiMethods.get('/mentors/my-mentees'), + + // Create mentorship session (mentor only) + createMentorshipSession: (body: CreateMentorshipSessionDto) => + apiMethods.post( + '/mentorship-sessions/mentorship-session', + body + ), + + // Mentee dashboard endpoints + getMenteeDashboard: () => + apiMethods.get('/mentees/my-dashboard'), + + getMenteeNotes: () => apiMethods.get('/mentees/my-notes'), + + getMenteePastMentors: () => + apiMethods.get('/mentees/my-past-mentors'), + + getMenteeUpcomingSession: () => + apiMethods.get('/mentees/my-upcoming-session') }; diff --git a/packages/frontend/src/features/mentorship/components/common/modals/create-session-modal.tsx b/packages/frontend/src/features/mentorship/components/common/modals/create-session-modal.tsx new file mode 100644 index 0000000..4ac6029 --- /dev/null +++ b/packages/frontend/src/features/mentorship/components/common/modals/create-session-modal.tsx @@ -0,0 +1,210 @@ +import { Button } from '@/shared/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/shared/components/ui/dialog'; +import { ScrollArea } from '@/shared/components/ui/scroll-area'; +import { useState } from 'react'; +import { useMentorshipStore } from '../../../store'; +import { CreateMentorshipSessionDto } from '../../../types'; + +interface CreateSessionModalProps { + isOpen: boolean; + setOpen: (open: boolean) => void; +} + +export function CreateSessionModal({ + isOpen, + setOpen +}: CreateSessionModalProps) { + const { mentorMentees, createMentorshipSession, getMyUpcomingSessions } = + useMentorshipStore(); + + const approvedMentees = mentorMentees.filter((m) => m.status === 'APPROVED'); + + const [form, setForm] = useState<{ + menteeId: number | ''; + link: string; + dateStart: string; + dateEnd: string; + description: string; + }>({ menteeId: '', link: '', dateStart: '', dateEnd: '', description: '' }); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleChange = ( + e: React.ChangeEvent< + HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement + > + ) => { + const { name, value } = e.target; + setForm((prev) => ({ + ...prev, + [name]: name === 'menteeId' ? (value === '' ? '' : Number(value)) : value + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!form.menteeId || !form.link || !form.dateStart || !form.dateEnd) { + setError('Please fill all required fields.'); + return; + } + + // Ensure selected mentee has an APPROVED matching + // const isApproved = approvedMentees.some((m) => m.id === form.menteeId); + // if (!isApproved) { + // setError('You can only schedule sessions with APPROVED mentees.'); + // return; + // } + + const payload: CreateMentorshipSessionDto = { + menteeId: Number(form.menteeId), + link: form.link, + dateStart: new Date(form.dateStart).toISOString(), + dateEnd: new Date(form.dateEnd).toISOString(), + description: form.description || undefined + }; + + try { + setSubmitting(true); + await createMentorshipSession(payload); + // Optionally refresh upcoming sessions list + await getMyUpcomingSessions(); + setOpen(false); + setForm({ + menteeId: '', + link: '', + dateStart: '', + dateEnd: '', + description: '' + }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create session'); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + Create Mentorship Session + + + Schedule a new mentorship session with a mentee. + + + + +
+
+ + + {approvedMentees.length === 0 && ( +

+ You currently have no approved mentees to schedule with. +

+ )} +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ +