diff --git a/packages/backend/prisma/migrations/20250706231936_mentorship_sessions/migration.sql b/packages/backend/prisma/migrations/20250706231936_mentorship_sessions/migration.sql new file mode 100644 index 0000000..52cad54 --- /dev/null +++ b/packages/backend/prisma/migrations/20250706231936_mentorship_sessions/migration.sql @@ -0,0 +1,85 @@ +-- CreateTable +CREATE TABLE "MentorshipSessions" ( + "id" SERIAL NOT NULL, + "mentor_id" INTEGER NOT NULL, + "description" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MentorshipSessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MentorshipSessionsMentees" ( + "id" SERIAL NOT NULL, + "mentee_id" INTEGER NOT NULL, + "session_id" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MentorshipSessionsMentees_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MentorshipSessionsDates" ( + "id" SERIAL NOT NULL, + "session_id" INTEGER NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "link" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MentorshipSessionsDates_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MentorshipSessionsDatesNotes" ( + "id" SERIAL NOT NULL, + "session_date_id" INTEGER NOT NULL, + "user_id" INTEGER NOT NULL, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MentorshipSessionsDatesNotes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MentorshipSessionsRating" ( + "id" SERIAL NOT NULL, + "session_id" INTEGER NOT NULL, + "rating_user_id" INTEGER NOT NULL, + "rating_user_role" "users_roles" NOT NULL, + "rated_user_id" INTEGER NOT NULL, + "rated_user_role" "users_roles" NOT NULL, + "comments" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MentorshipSessionsRating_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "MentorshipSessions" ADD CONSTRAINT "MentorshipSessions_mentor_id_fkey" FOREIGN KEY ("mentor_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MentorshipSessionsMentees" ADD CONSTRAINT "MentorshipSessionsMentees_mentee_id_fkey" FOREIGN KEY ("mentee_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MentorshipSessionsMentees" ADD CONSTRAINT "MentorshipSessionsMentees_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "MentorshipSessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MentorshipSessionsDates" ADD CONSTRAINT "MentorshipSessionsDates_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "MentorshipSessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MentorshipSessionsDatesNotes" ADD CONSTRAINT "MentorshipSessionsDatesNotes_session_date_id_fkey" FOREIGN KEY ("session_date_id") REFERENCES "MentorshipSessionsDates"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MentorshipSessionsDatesNotes" ADD CONSTRAINT "MentorshipSessionsDatesNotes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MentorshipSessionsRating" ADD CONSTRAINT "MentorshipSessionsRating_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "MentorshipSessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MentorshipSessionsRating" ADD CONSTRAINT "MentorshipSessionsRating_rating_user_id_fkey" FOREIGN KEY ("rating_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MentorshipSessionsRating" ADD CONSTRAINT "MentorshipSessionsRating_rated_user_id_fkey" FOREIGN KEY ("rated_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/backend/prisma/migrations/20250707001658_mentorship_sessions_restructure/migration.sql b/packages/backend/prisma/migrations/20250707001658_mentorship_sessions_restructure/migration.sql new file mode 100644 index 0000000..4bca645 --- /dev/null +++ b/packages/backend/prisma/migrations/20250707001658_mentorship_sessions_restructure/migration.sql @@ -0,0 +1,154 @@ +/* + Warnings: + + - You are about to drop the column `created_at` on the `MentorshipSessions` table. All the data in the column will be lost. + - You are about to drop the column `description` on the `MentorshipSessions` table. All the data in the column will be lost. + - You are about to drop the column `mentor_id` on the `MentorshipSessions` table. All the data in the column will be lost. + - You are about to drop the `MentorshipSessionsDates` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `MentorshipSessionsDatesNotes` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `MentorshipSessionsMentees` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `MentorshipSessionsRating` table. If the table is not empty, all the data it contains will be lost. + - Added the required column `dateEnd` to the `MentorshipSessions` table without a default value. This is not possible if the table is not empty. + - Added the required column `dateStart` to the `MentorshipSessions` table without a default value. This is not possible if the table is not empty. + - Added the required column `menteeId` to the `MentorshipSessions` table without a default value. This is not possible if the table is not empty. + - Added the required column `mentorId` to the `MentorshipSessions` table without a default value. This is not possible if the table is not empty. + - Added the required column `updatedAt` to the `MentorshipSessions` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "matching_status" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'SUSPENDED'); + +-- DropForeignKey +ALTER TABLE "MentorshipSessions" DROP CONSTRAINT "MentorshipSessions_mentor_id_fkey"; + +-- DropForeignKey +ALTER TABLE "MentorshipSessionsDates" DROP CONSTRAINT "MentorshipSessionsDates_session_id_fkey"; + +-- DropForeignKey +ALTER TABLE "MentorshipSessionsDatesNotes" DROP CONSTRAINT "MentorshipSessionsDatesNotes_session_date_id_fkey"; + +-- DropForeignKey +ALTER TABLE "MentorshipSessionsDatesNotes" DROP CONSTRAINT "MentorshipSessionsDatesNotes_user_id_fkey"; + +-- DropForeignKey +ALTER TABLE "MentorshipSessionsMentees" DROP CONSTRAINT "MentorshipSessionsMentees_mentee_id_fkey"; + +-- DropForeignKey +ALTER TABLE "MentorshipSessionsMentees" DROP CONSTRAINT "MentorshipSessionsMentees_session_id_fkey"; + +-- DropForeignKey +ALTER TABLE "MentorshipSessionsRating" DROP CONSTRAINT "MentorshipSessionsRating_rated_user_id_fkey"; + +-- DropForeignKey +ALTER TABLE "MentorshipSessionsRating" DROP CONSTRAINT "MentorshipSessionsRating_rating_user_id_fkey"; + +-- DropForeignKey +ALTER TABLE "MentorshipSessionsRating" DROP CONSTRAINT "MentorshipSessionsRating_session_id_fkey"; + +-- AlterTable +ALTER TABLE "MentorshipSessions" DROP COLUMN "created_at", +DROP COLUMN "description", +DROP COLUMN "mentor_id", +ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "dateEnd" TIMESTAMP(3) NOT NULL, +ADD COLUMN "dateStart" TIMESTAMP(3) NOT NULL, +ADD COLUMN "link" TEXT, +ADD COLUMN "menteeId" INTEGER NOT NULL, +ADD COLUMN "mentorId" INTEGER NOT NULL, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; + +-- DropTable +DROP TABLE "MentorshipSessionsDates"; + +-- DropTable +DROP TABLE "MentorshipSessionsDatesNotes"; + +-- DropTable +DROP TABLE "MentorshipSessionsMentees"; + +-- DropTable +DROP TABLE "MentorshipSessionsRating"; + +-- CreateTable +CREATE TABLE "MentorshipSessionsDescriptions" ( + "id" SERIAL NOT NULL, + "sessionId" INTEGER NOT NULL, + "description" TEXT NOT NULL, + + CONSTRAINT "MentorshipSessionsDescriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MentorshipSessionsMenteeNotes" ( + "id" SERIAL NOT NULL, + "sessionId" INTEGER NOT NULL, + "notes" TEXT NOT NULL, + + CONSTRAINT "MentorshipSessionsMenteeNotes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MentorshipSessionsMentorRating" ( + "id" SERIAL NOT NULL, + "sessionId" INTEGER NOT NULL, + "rate" INTEGER NOT NULL, + "comment" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MentorshipSessionsMentorRating_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MentorshipSessionsMenteeRating" ( + "id" SERIAL NOT NULL, + "sessionId" INTEGER NOT NULL, + "rate" INTEGER NOT NULL, + "comment" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MentorshipSessionsMenteeRating_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MatchedMentorMentee" ( + "id" SERIAL NOT NULL, + "mentorId" INTEGER NOT NULL, + "menteeId" INTEGER NOT NULL, + "status" "matching_status" NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MatchedMentorMentee_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "MentorshipSessionsMentorRating_sessionId_key" ON "MentorshipSessionsMentorRating"("sessionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "MentorshipSessionsMenteeRating_sessionId_key" ON "MentorshipSessionsMenteeRating"("sessionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "MatchedMentorMentee_mentorId_menteeId_key" ON "MatchedMentorMentee"("mentorId", "menteeId"); + +-- AddForeignKey +ALTER TABLE "MentorshipSessions" ADD CONSTRAINT "MentorshipSessions_mentorId_fkey" FOREIGN KEY ("mentorId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MentorshipSessions" ADD CONSTRAINT "MentorshipSessions_menteeId_fkey" FOREIGN KEY ("menteeId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MentorshipSessionsDescriptions" ADD CONSTRAINT "MentorshipSessionsDescriptions_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "MentorshipSessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MentorshipSessionsMenteeNotes" ADD CONSTRAINT "MentorshipSessionsMenteeNotes_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "MentorshipSessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MentorshipSessionsMentorRating" ADD CONSTRAINT "MentorshipSessionsMentorRating_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "MentorshipSessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MentorshipSessionsMenteeRating" ADD CONSTRAINT "MentorshipSessionsMenteeRating_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "MentorshipSessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MatchedMentorMentee" ADD CONSTRAINT "MatchedMentorMentee_mentorId_fkey" FOREIGN KEY ("mentorId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MatchedMentorMentee" ADD CONSTRAINT "MatchedMentorMentee_menteeId_fkey" FOREIGN KEY ("menteeId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index 8b07e37..64922a9 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -54,10 +54,20 @@ model users { created_at DateTime? provider String? deleted_at Boolean @default(false) - // MENTOR RELATIONSHIP + + // MENTOR RELATIONSHIP mentor mentors[] // MENTEE RELATIONSHIP mentee mentees[] + + // MENTORSHIP SESSIONS - Updated relations + mentorSessions MentorshipSessions[] @relation("MentorSessions") + menteeSessions MentorshipSessions[] @relation("MenteeSessions") + + // MATCHING RELATIONS + matchedAsMentor MatchedMentorMentee[] @relation("MatchedMentor") + matchedAsMentee MatchedMentorMentee[] @relation("MatchedMentee") + // EVENTS MANAGERS RELATIONSHIP event_manager EventsManagers[] // EVENTS SUBSCRIPTIONS RELATIONSHIP @@ -548,4 +558,73 @@ model NewsletterSubscriptions { } +// MENTORSHIP SESSIONS +model MentorshipSessions { + id Int @id @default(autoincrement()) + mentorId Int + mentor users @relation("MentorSessions", fields: [mentorId], references: [id]) + menteeId Int + mentee users @relation("MenteeSessions", fields: [menteeId], references: [id]) + link String? + dateStart DateTime + dateEnd DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + descriptions MentorshipSessionsDescriptions[] + menteeNotes MentorshipSessionsMenteeNotes[] + mentorRating MentorshipSessionsMentorRating? + menteeRating MentorshipSessionsMenteeRating? +} + +model MentorshipSessionsDescriptions { + id Int @id @default(autoincrement()) + sessionId Int + session MentorshipSessions @relation(fields: [sessionId], references: [id]) + description String +} + +model MentorshipSessionsMenteeNotes { + id Int @id @default(autoincrement()) + sessionId Int + session MentorshipSessions @relation(fields: [sessionId], references: [id]) + notes String +} + +model MentorshipSessionsMentorRating { + id Int @id @default(autoincrement()) + sessionId Int @unique + session MentorshipSessions @relation(fields: [sessionId], references: [id]) + rate Int + comment String? + createdAt DateTime @default(now()) +} + +model MentorshipSessionsMenteeRating { + id Int @id @default(autoincrement()) + sessionId Int @unique + session MentorshipSessions @relation(fields: [sessionId], references: [id]) + rate Int + comment String? + createdAt DateTime @default(now()) +} +enum matching_status { + PENDING + APPROVED + REJECTED + SUSPENDED +} + +model MatchedMentorMentee { + id Int @id @default(autoincrement()) + mentorId Int + mentor users @relation("MatchedMentor", fields: [mentorId], references: [id]) + menteeId Int + mentee users @relation("MatchedMentee", fields: [menteeId], references: [id]) + status matching_status @default(PENDING) + createdAt DateTime @default(now()) + + @@unique([mentorId, menteeId]) +} diff --git a/packages/backend/src/admin/admin.service.ts b/packages/backend/src/admin/admin.service.ts index b93ddf2..cd23a2f 100644 --- a/packages/backend/src/admin/admin.service.ts +++ b/packages/backend/src/admin/admin.service.ts @@ -796,7 +796,8 @@ export class AdminService { const mentorApplications = await this.prisma.mentors.findMany({ orderBy: { created_at: 'desc' }, select: { - id: true, + id: true, // This is the mentor application ID + resume: true, user: { select: { id: true, @@ -818,7 +819,8 @@ export class AdminService { const mappedMentorApplications: MentorshipAdmin[] = mentorApplications.map((mentor) => ({ - id: mentor.user.id, + id: mentor.user.id, // Keep user ID for backward compatibility + mentorApplicationId: mentor.id, // Add mentor application ID identity: { avatar: mentor.user.picture_upload_link, firstName: mentor.user.first_name, @@ -831,6 +833,7 @@ export class AdminService { status: mentor.status, capacity: mentor.max_mentees, availability: mentor.availability, + resume: mentor.resume, })); return mappedMentorApplications; @@ -845,7 +848,8 @@ export class AdminService { const menteeApplications = await this.prisma.mentees.findMany({ orderBy: { created_at: 'desc' }, select: { - id: true, + id: true, // This is the mentee application ID + resume: true, // Add resume field user: { select: { id: true, @@ -865,7 +869,8 @@ export class AdminService { const mappedMenteeApplications: MenteeAdmin[] = menteeApplications.map( (mentee) => ({ - id: mentee.user.id, + id: mentee.user.id, // Keep user ID for backward compatibility + menteeApplicationId: mentee.id, // Add mentee application ID identity: { avatar: mentee.user.picture_upload_link, firstName: mentee.user.first_name, @@ -877,6 +882,7 @@ export class AdminService { email: mentee.user.email, status: mentee.status, experience: mentee.user.experience, + resume: mentee.resume, // Add resume field }), ); diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index 8cd2fd2..b6d5aab 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -30,6 +30,7 @@ import { SettingsModule } from './settings/settings.module'; import { ContactUsModule } from './contact_us/contact_us.module'; import { SkillsModule } from './skills/skills.module'; import { AdminModule } from './admin/admin.module'; +import { MentorshipSessionsModule } from './mentorship_sessions/mentorship_sessions.module'; @Module({ imports: [ ConfigModule.forRoot({ @@ -67,6 +68,7 @@ import { AdminModule } from './admin/admin.module'; ContactUsModule, SkillsModule, AdminModule, + MentorshipSessionsModule, ], controllers: [AppController], providers: [ diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index 4ba2d44..89d40a5 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -31,6 +31,7 @@ import { LoginUserDto, ResendVerificationEmailDto, ResetPasswordDto, + ResetPasswordWithTokenDto, } from './dto'; import { GetUser } from './decorators'; import { GoogleUser, LoginResponse } from './types'; @@ -216,6 +217,15 @@ export class AuthController { return this.authService.changePassword(userId, changePasswordDto); } + @Post('reset-password-with-token') + @Public() + @ApiOperation({ summary: 'Reset password using token from email' }) + async resetPasswordWithToken( + @Body() resetPasswordWithTokenDto: ResetPasswordWithTokenDto, + ) { + return this.authService.resetPasswordWithToken(resetPasswordWithTokenDto); + } + @Public() @Get('google') @UseGuards(AuthGuard('google')) diff --git a/packages/backend/src/auth/dto/auth.dto.ts b/packages/backend/src/auth/dto/auth.dto.ts index ab4e36b..c6efce0 100644 --- a/packages/backend/src/auth/dto/auth.dto.ts +++ b/packages/backend/src/auth/dto/auth.dto.ts @@ -111,6 +111,31 @@ export class ResendVerificationEmailDto { email: string; } +export class ResetPasswordWithTokenDto { + @ApiProperty({ + description: 'Reset token from email', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + @IsString() + @IsNotEmpty() + token: string; + + @ApiProperty({ + description: 'New password', + example: 'NewPassword123!', + }) + @IsString() + @MinLength(9) + newPassword: string; + + @ApiProperty({ + description: 'Confirm new password', + example: 'NewPassword123!', + }) + @IsString() + confirmPassword: string; +} + export class ChangePasswordDto { @ApiProperty({ description: 'Current password', diff --git a/packages/backend/src/auth/services/auth.service.ts b/packages/backend/src/auth/services/auth.service.ts index 95c4c68..6423dd9 100644 --- a/packages/backend/src/auth/services/auth.service.ts +++ b/packages/backend/src/auth/services/auth.service.ts @@ -9,6 +9,7 @@ import { GenerateResetTokenDto, VerifyPasswordDto, ResendVerificationEmailDto, + ResetPasswordWithTokenDto, ChangePasswordDto, } from '../dto/auth.dto'; import { AuthResponse, GoogleUser, LoginResponse, Tokens } from '../types'; @@ -525,4 +526,28 @@ export class AuthService { throw new UnauthorizedException('Failed to authenticate with Google'); } } + + async resetPasswordWithToken( + resetPasswordWithTokenDto: ResetPasswordWithTokenDto, + ) { + const { token, newPassword, confirmPassword } = resetPasswordWithTokenDto; + + // Verify token + const decoded = this.jwtService.verify(token) as any; + const userId = decoded.userId; + + // Validate password + if (newPassword !== confirmPassword) { + throw new UnauthorizedException('Passwords do not match'); + } + + // Update password + const hashedPassword = await this.hashPassword(newPassword); + await this.prisma.users.update({ + where: { id: userId }, + data: { password_hash: hashedPassword }, + }); + + return { message: 'Password reset successfully' }; + } } diff --git a/packages/backend/src/auth/services/email.service.ts b/packages/backend/src/auth/services/email.service.ts index d51e894..fac61a4 100644 --- a/packages/backend/src/auth/services/email.service.ts +++ b/packages/backend/src/auth/services/email.service.ts @@ -283,7 +283,8 @@ export class EmailService {

${content}

- ${buttonText} + ${buttonText}

+ Or copy and paste the following link into your browser: ${buttonUrl}
${footerText ? `

${footerText}

` : ''} diff --git a/packages/backend/src/mentee/mentee.service.ts b/packages/backend/src/mentee/mentee.service.ts index 1f389c8..8cffc2f 100644 --- a/packages/backend/src/mentee/mentee.service.ts +++ b/packages/backend/src/mentee/mentee.service.ts @@ -8,7 +8,7 @@ import { CreateMenteeDto } from './dto/create-mentee.dto'; import { UpdateMenteeDto } from './dto/update-mentee.dto'; import { PrismaService } from 'src/database'; import { FilterMenteeDto } from './dto/filter-mentee.dto'; -import { Prisma, mentees_status } from '@prisma/client'; +import { Prisma, mentees_status, users_roles } from '@prisma/client'; import { FilesService } from 'src/files/files.service'; import { FileValidationEnum } from 'src/files/util/files-validation.enum'; @@ -317,7 +317,28 @@ export class MenteeService { //const userId = updatedMentee.user_id; //const userRole: users_roles = updatedMentee.status === 'APPROVED' ? 'MENTEE' : 'USER'; - return updatedMentee; + const userId = updatedMentee.user_id; + const userRole: users_roles = + updatedMentee.status === 'APPROVED' ? 'MENTEE' : 'USER'; + + const updatedMenteeUser = await this.prisma.users.update({ + where: { id: userId }, + data: { role: userRole }, + select: { + id: true, + first_name: true, + last_name: true, + middle_name: true, + mentor: true, + interests: { + select: { + interest: true, + }, + }, + }, + }); + + return updatedMenteeUser; } else { throw new InternalServerErrorException( `There was an error updating the mentee application with ID: ${menteeApplicationId}`, diff --git a/packages/backend/src/mentorship_sessions/dto/create.dto.ts b/packages/backend/src/mentorship_sessions/dto/create.dto.ts new file mode 100644 index 0000000..8cb6da2 --- /dev/null +++ b/packages/backend/src/mentorship_sessions/dto/create.dto.ts @@ -0,0 +1,85 @@ +import { IsString, IsOptional, IsUrl, IsInt, IsDate } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateMentorshipSessionDto { + @ApiProperty({ description: 'User ID of the mentee', example: '10' }) + @IsInt() + menteeId: number; + + @ApiProperty({ description: 'Call link', example: 'https://meet.google.com' }) + @IsString() + @IsUrl() + link: string; + + @ApiProperty({ + description: 'Start date and time', + example: '2025-01-01T10:00:00Z', + }) + @IsDate() + dateStart: Date; + + @ApiProperty({ + description: 'End date and time', + example: '2025-01-01T10:00:00Z', + }) + @IsDate() + dateEnd: Date; + + @ApiPropertyOptional({ + description: 'Description', + example: 'Description of the session', + }) + @IsString() + @IsOptional() + description?: string; +} + +export class CreateSessionMenteeNoteDto { + @ApiProperty({ + description: 'ID for the session to take notes', + example: '10', + }) + @IsInt() + sessionId: number; + + @ApiProperty({ + description: 'Mentee notes for the session', + example: 'Mentee note', + }) + @IsString() + note: string; +} + +export class CreateSessionRatingDto { + @ApiProperty({ description: 'ID for the rated session', example: '10' }) + @IsInt() + sessionId: number; + + @ApiProperty({ description: 'Mentor rating for the mentee', example: 5 }) + @IsInt() + rate: number; + + @ApiPropertyOptional({ + description: 'Mentor notes for the mentee', + example: 'Mentor note', + }) + @IsString() + @IsOptional() + note?: string; +} + +export class CreateMatchingDto { + @ApiProperty({ + description: 'ID of the mentor to match with a mentee', + example: '10', + }) + @IsInt() + mentorId: number; + + @ApiProperty({ + description: 'ID of the mentee to match with a mentor', + example: '10', + }) + @IsInt() + menteeId: number; +} diff --git a/packages/backend/src/mentorship_sessions/dto/filter.dto.ts b/packages/backend/src/mentorship_sessions/dto/filter.dto.ts new file mode 100644 index 0000000..622bafd --- /dev/null +++ b/packages/backend/src/mentorship_sessions/dto/filter.dto.ts @@ -0,0 +1,78 @@ +import { IsInt, IsOptional, IsDate } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class FilterMentorshipSessionsDto { + @ApiPropertyOptional({ + description: 'Sessions after the inserted date', + example: '2025-01-01T10:00:00Z', + }) + @IsDate() + @IsOptional() + dateStartBefore?: Date; + + @ApiPropertyOptional({ + description: 'Sessions before the inserted date', + example: '2025-01-01T10:00:00Z', + }) + @IsDate() + @IsOptional() + dateStartAfter?: Date; + + @ApiPropertyOptional({ + description: 'Sessions with this mentor ID', + example: '10', + }) + @IsInt() + @IsOptional() + mentorId?: number; + + @ApiPropertyOptional({ + description: 'Sessions with this mentee ID', + example: '10', + }) + @IsInt() + @IsOptional() + menteeId?: number; +} + +export class FilterMentorshipSessionRatingsDto { + @ApiPropertyOptional({ + description: 'Ratings after the inserted date', + example: '2025-01-01T10:00:00Z', + }) + @IsDate() + @IsOptional() + dateStartBefore?: Date; + + @ApiPropertyOptional({ + description: 'Ratings before the inserted date', + example: '2025-01-01T10:00:00Z', + }) + @IsDate() + @IsOptional() + dateStartAfter?: Date; + + @ApiPropertyOptional({ + description: 'Ratings for this session ID', + example: '10', + }) + @IsInt() + @IsOptional() + sessionId?: number; + + @ApiPropertyOptional({ + description: 'Ratings with this mentor ID', + example: '10', + }) + @IsInt() + @IsOptional() + mentorId?: number; + + @ApiPropertyOptional({ + description: 'Ratings with this mentee ID', + example: '10', + }) + @IsInt() + @IsOptional() + menteeId?: number; +} diff --git a/packages/backend/src/mentorship_sessions/dto/update.dto.ts b/packages/backend/src/mentorship_sessions/dto/update.dto.ts new file mode 100644 index 0000000..f3bd0fe --- /dev/null +++ b/packages/backend/src/mentorship_sessions/dto/update.dto.ts @@ -0,0 +1,54 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDate, IsEnum, IsOptional, IsString, IsUrl } from 'class-validator'; +import { matching_status } from '@prisma/client'; + +export class UpdateMentorshipSessionDto { + @ApiPropertyOptional({ + description: 'Call link', + example: 'https://meet.google.com', + }) + @IsString() + @IsUrl() + @IsOptional() + link?: string; + + @ApiPropertyOptional({ + description: 'Start date and time', + example: '2025-01-01T10:00:00Z', + }) + @IsDate() + @IsOptional() + dateStart?: Date; + + @ApiPropertyOptional({ + description: 'End date and time', + example: '2025-01-01T10:00:00Z', + }) + @IsDate() + @IsOptional() + dateEnd?: Date; + + @ApiPropertyOptional({ + description: 'Description', + example: 'Description of the session', + }) + @IsString() + @IsOptional() + description?: string; +} + +export class UpdateSessionMenteeNoteDto { + @ApiPropertyOptional({ + description: 'Mentee notes for the session', + example: 'Mentee note', + }) + @IsString() + @IsOptional() + note?: string; +} + +export class UpdateMatchingDto { + @ApiProperty({ description: 'New matching status', example: 'APPROVED' }) + @IsEnum(matching_status) + status: matching_status; +} diff --git a/packages/backend/src/mentorship_sessions/entities/mentorship_sessions.entity.ts b/packages/backend/src/mentorship_sessions/entities/mentorship_sessions.entity.ts new file mode 100644 index 0000000..adbd5ee --- /dev/null +++ b/packages/backend/src/mentorship_sessions/entities/mentorship_sessions.entity.ts @@ -0,0 +1,290 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsOptional, + IsString, + IsInt, + IsNotEmpty, + IsUrl, + IsDate, + IsEnum, +} from 'class-validator'; +import { matching_status } from '@prisma/client'; + +export class MentorshipSessionEntity { + @ApiProperty({ + description: 'Unique identifier for the mentorship session', + example: 1, + }) + @IsInt() + id: number; + + @ApiProperty({ + description: 'ID of the mentor user', + example: 10, + }) + @IsInt() + @IsNotEmpty() + mentorId: number; + + @ApiProperty({ + description: 'ID of the mentee user', + example: 15, + }) + @IsInt() + @IsNotEmpty() + menteeId: number; + + @ApiProperty({ + description: 'Meeting link for the session', + example: 'https://meet.google.com/abc-defg-hij', + required: false, + }) + @IsOptional() + @IsUrl() + link?: string; + + @ApiProperty({ + description: 'Start date and time of the session', + example: '2024-01-15T14:00:00Z', + }) + @IsDate() + @IsNotEmpty() + dateStart: Date; + + @ApiProperty({ + description: 'End date and time of the session', + example: '2024-01-15T15:00:00Z', + }) + @IsDate() + @IsNotEmpty() + dateEnd: Date; + + @ApiProperty({ + description: 'Creation timestamp', + example: '2024-01-10T10:00:00Z', + }) + createdAt: Date; + + @ApiProperty({ + description: 'Last update timestamp', + example: '2024-01-10T10:00:00Z', + }) + updatedAt: Date; +} + +export class MentorshipSessionDescriptionEntity { + @ApiProperty({ + description: 'Unique identifier for the session description', + example: 1, + }) + @IsInt() + id: number; + + @ApiProperty({ + description: 'ID of the mentorship session', + example: 1, + }) + @IsInt() + @IsNotEmpty() + sessionId: number; + + @ApiProperty({ + description: 'Description of the session', + example: + 'Technical interview preparation focusing on system design questions', + }) + @IsString() + @IsNotEmpty() + description: string; +} + +export class MentorshipSessionMenteeNotesEntity { + @ApiProperty({ + description: 'Unique identifier for the mentee notes', + example: 1, + }) + @IsInt() + id: number; + + @ApiProperty({ + description: 'ID of the mentorship session', + example: 1, + }) + @IsInt() + @IsNotEmpty() + sessionId: number; + + @ApiProperty({ + description: 'Notes taken by the mentee during the session', + example: + 'Learned about microservices architecture and best practices for scaling applications', + }) + @IsString() + @IsNotEmpty() + notes: string; +} + +export class MentorshipSessionRatingEntity { + @ApiProperty({ + description: 'Unique identifier for the session rating', + example: 1, + }) + @IsInt() + id: number; + + @ApiProperty({ description: 'ID of the mentorship session', example: 1 }) + @IsInt() + @IsNotEmpty() + sessionId: number; + + @ApiProperty({ + description: 'Rating given (1-5)', + example: 4, + minimum: 1, + maximum: 5, + }) + @IsInt() + @IsNotEmpty() + rate: number; + + @ApiProperty({ + description: 'Comment about the session', + example: 'Great session!', + required: false, + }) + @IsOptional() + @IsString() + comment?: string; + + @ApiProperty({ + description: 'Creation timestamp of the rating', + example: '2024-01-15T16:00:00Z', + }) + createdAt: Date; + + // Add session property + @ApiProperty({ description: 'Session information', required: false }) + @IsOptional() + session?: any; +} + +export class MatchedMentorMenteeEntity { + @ApiProperty({ + description: 'Unique identifier for the mentor-mentee match', + example: 1, + }) + @IsInt() + id: number; + + @ApiProperty({ + description: 'ID of the mentor user', + example: 10, + }) + @IsInt() + @IsNotEmpty() + mentorId: number; + + @ApiProperty({ + description: 'ID of the mentee user', + example: 15, + }) + @IsInt() + @IsNotEmpty() + menteeId: number; + + @ApiProperty({ + description: 'Status of the mentor-mentee match', + example: 'APPROVED', + enum: matching_status, + }) + @IsEnum(matching_status) + @IsNotEmpty() + status: matching_status; + + @ApiProperty({ + description: 'Creation timestamp of the match', + example: '2024-01-10T10:00:00Z', + }) + createdAt: Date; +} + +// Response DTOs for better API documentation +export class MentorshipSessionResponseEntity extends MentorshipSessionEntity { + @ApiProperty({ + description: 'Mentor user information', + type: 'object', + properties: { + id: { type: 'number', example: 10 }, + firstName: { type: 'string', example: 'John' }, + lastName: { type: 'string', example: 'Doe' }, + email: { type: 'string', example: 'john.doe@example.com' }, + }, + }) + mentor?: any; + + @ApiProperty({ + description: 'Mentee user information', + type: 'object', + properties: { + id: { type: 'number', example: 15 }, + firstName: { type: 'string', example: 'Jane' }, + lastName: { type: 'string', example: 'Smith' }, + email: { type: 'string', example: 'jane.smith@example.com' }, + }, + }) + mentee?: any; + + @ApiProperty({ + description: 'Session description', + type: MentorshipSessionDescriptionEntity, + required: false, + }) + description?: MentorshipSessionDescriptionEntity; + + @ApiProperty({ + description: 'Mentee notes for the session', + type: MentorshipSessionMenteeNotesEntity, + required: false, + }) + menteeNotes?: MentorshipSessionMenteeNotesEntity; + + @ApiProperty({ + description: 'Mentor rating for the session', + type: MentorshipSessionRatingEntity, + required: false, + }) + mentorRating?: MentorshipSessionRatingEntity; + + @ApiProperty({ + description: 'Mentee rating for the session', + type: MentorshipSessionRatingEntity, + required: false, + }) + menteeRating?: MentorshipSessionRatingEntity; +} + +export class MatchedMentorMenteeResponseEntity extends MatchedMentorMenteeEntity { + @ApiProperty({ + description: 'Mentor user information', + type: 'object', + properties: { + id: { type: 'number', example: 10 }, + firstName: { type: 'string', example: 'John' }, + lastName: { type: 'string', example: 'Doe' }, + email: { type: 'string', example: 'john.doe@example.com' }, + }, + }) + mentor?: any; + + @ApiProperty({ + description: 'Mentee user information', + type: 'object', + properties: { + id: { type: 'number', example: 15 }, + firstName: { type: 'string', example: 'Jane' }, + lastName: { type: 'string', example: 'Smith' }, + email: { type: 'string', example: 'jane.smith@example.com' }, + }, + }) + mentee?: any; +} diff --git a/packages/backend/src/mentorship_sessions/mentorship_sessions.controller.ts b/packages/backend/src/mentorship_sessions/mentorship_sessions.controller.ts new file mode 100644 index 0000000..2c45a7c --- /dev/null +++ b/packages/backend/src/mentorship_sessions/mentorship_sessions.controller.ts @@ -0,0 +1,417 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Delete, + UseGuards, + Query, + Patch, + Put, +} from '@nestjs/common'; +import { MentorshipSessionsService } from './mentorship_sessions.service'; +import { + MentorshipSessionEntity, + MentorshipSessionMenteeNotesEntity, + MentorshipSessionRatingEntity, + MatchedMentorMenteeEntity, +} from './entities/mentorship_sessions.entity'; +import { + CreateMentorshipSessionDto, + CreateSessionMenteeNoteDto, + CreateSessionRatingDto, + CreateMatchingDto, +} from './dto/create.dto'; +import { + FilterMentorshipSessionsDto, + FilterMentorshipSessionRatingsDto, +} from './dto/filter.dto'; +import { + UpdateMentorshipSessionDto, + UpdateSessionMenteeNoteDto, + UpdateMatchingDto, +} from './dto/update.dto'; +import { Roles, GetUser } from 'src/auth/decorators'; +import { JwtAuthGuard, RolesGuard } from 'src/auth/guards'; +import { JwtPayload } from 'src/auth/util/JwtPayload.interface'; +import { + ApiTags, + ApiBody, + ApiOperation, + ApiCreatedResponse, + ApiInternalServerErrorResponse, + ApiBearerAuth, + ApiQuery, + ApiOkResponse, + ApiNotFoundResponse, +} from '@nestjs/swagger'; +import { User } from 'src/users/entities/user.entity'; + +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiTags('Mentorship Sessions') +@Controller('mentorship-sessions') +export class MentorshipSessionsController { + constructor( + private readonly mentorshipSessionsService: MentorshipSessionsService, + ) {} + + // MENTORSHIP SESSIONS + @Roles('MENTOR') + @Post('/mentorship-session') + @ApiBody({ type: CreateMentorshipSessionDto }) + @ApiCreatedResponse({ type: MentorshipSessionEntity }) + @ApiInternalServerErrorResponse({ + description: 'Error creating the mentorship session: [ERROR MESSAGE]', + }) + @ApiOperation({ + summary: 'Create mentorship session', + description: + 'Creates mentorship session or throws Internal Server Error Exception. \n\n REQUIRED ROLES: **MENTOR**', + }) + @ApiBearerAuth() + create( + @GetUser() user: JwtPayload, + @Body() createMentorshipSessionDto: CreateMentorshipSessionDto, + ) { + const newMentorshipSession: CreateMentorshipSessionDto = { + menteeId: createMentorshipSessionDto.menteeId, + link: createMentorshipSessionDto.link, + dateStart: createMentorshipSessionDto.dateStart, + dateEnd: createMentorshipSessionDto.dateEnd, + description: createMentorshipSessionDto.description, + }; + return this.mentorshipSessionsService.createMentorshipSession( + user.sub, + newMentorshipSession, + ); + } + + @Roles('MENTOR') + @Put('/mentorship-session/:id') + @ApiBody({ type: UpdateMentorshipSessionDto }) + @ApiOkResponse({ type: MentorshipSessionEntity }) + @ApiNotFoundResponse({ + description: 'There is no mentorship session with ID #[:id]', + }) + @ApiInternalServerErrorResponse({ + description: + 'Error updating mentorship session with ID #[:id]: [ERROR MESSAGE]', + }) + @ApiOperation({ + summary: 'Update mentorship session', + description: + 'Update mentorship session with ID. \n\n Only the mentor who created the session can update the mentorship session. \n\n REQUIRED ROLES: **MENTOR**', + }) + @ApiBearerAuth() + update( + @Param('id') id: string, + @GetUser() user: JwtPayload, + @Body() updateMentorshipSessionDto: UpdateMentorshipSessionDto, + ) { + const updateMentorshipSession: UpdateMentorshipSessionDto = { + link: updateMentorshipSessionDto.link, + dateStart: updateMentorshipSessionDto.dateStart, + dateEnd: updateMentorshipSessionDto.dateEnd, + description: updateMentorshipSessionDto.description, + }; + + return this.mentorshipSessionsService.updateMentorshipSession( + +id, + user.sub, + updateMentorshipSession, + ); + } + + @Roles('MENTOR') + @Delete('/mentorship-session/:id') + @ApiOkResponse({ type: Number }) + @ApiNotFoundResponse({ + description: 'There is no mentorship session with ID #[:id] to delete', + }) + @ApiInternalServerErrorResponse({ description: '[ERROR MESSAGE]' }) + @ApiOperation({ + summary: 'Delete mentorship session', + description: + 'Delete mentorship session with ID. \n\n Only the mentor who created the session can delete the mentorship session. \n\n REQUIRED ROLES: **MENTOR**', + }) + @ApiBearerAuth() + remove(@Param('id') id: string, @GetUser() user: JwtPayload) { + return this.mentorshipSessionsService.removeMentorshipSession( + +id, + user.sub, + ); + } + + @Roles('MENTOR', 'MENTEE', 'USER') + @Get('/mentorship-session') + @ApiQuery({ type: FilterMentorshipSessionsDto }) + @ApiOkResponse({ type: MentorshipSessionEntity, isArray: true }) + @ApiInternalServerErrorResponse({ + description: 'Error fetching mentorship sessions: [ERROR MESSAGE]', + }) + @ApiOperation({ + summary: 'Fetch mentorship sessions', + description: + 'Fetch mentorship sessions. \n\n REQUIRED ROLES: **MENTOR**, **MENTEE**, **USER** \n\n If you are a mentor, you can fetch all sessions you created. \n\n If you are a mentee, you can fetch all sessions you are matched with. \n\n If you are a user, you can fetch all sessions you are matched with.', + }) + findAllMentorshipSessions( + @GetUser() user: JwtPayload, + @Query() filterMentorshipSessionsDto: FilterMentorshipSessionsDto, + ) { + return this.mentorshipSessionsService.findAllMentorshipSessions( + filterMentorshipSessionsDto, + user.sub, + ); + } + + @Roles('MENTOR', 'MENTEE', 'USER') + @Get('/mentorship-session/:id') + @ApiOkResponse({ type: MentorshipSessionEntity }) + @ApiInternalServerErrorResponse({ + description: 'Error fetching mentorship sessions by ID: [ERROR MESSAGE]', + }) + @ApiOperation({ + summary: 'Fetch mentorship session by ID', + description: + 'Fetch mentorship sessions by ID. \n\n REQUIRED ROLES: **MENTOR**, **MENTEE**, **USER** \n\n If you are a mentor, you can fetch all sessions you created. \n\n If you are a mentee, you can fetch all sessions you are matched with. \n\n If you are a user, you can fetch all sessions you are matched with.', + }) + findMentorshipSessionsById( + @GetUser() user: JwtPayload, + @Param('id') id: string, + ) { + return this.mentorshipSessionsService.findMentorshipSessionsById( + +id, + user.sub, + ); + } + + // MENTEE NOTES + @Roles('MENTEE') + @Post('/mentee-notes') + @ApiBody({ type: CreateSessionMenteeNoteDto }) + @ApiCreatedResponse({ type: MentorshipSessionMenteeNotesEntity }) + @ApiInternalServerErrorResponse({ + description: + 'Error creating mentorship session mentee notes: [ERROR MESSAGE]', + }) + @ApiOperation({ + summary: 'Create mentorship session mentee notes', + description: + 'Create mentorship session mentee notes. \n\n REQUIRED ROLES: **MENTEE**', + }) + @ApiBearerAuth() + createMentorshipSessionMenteeNote( + @GetUser() user: JwtPayload, + @Param('id') id: string, + @Body() createSessionMenteeNoteDto: CreateSessionMenteeNoteDto, + ) { + return this.mentorshipSessionsService.createMentorshipSessionMenteeNote( + +id, + user.sub, + createSessionMenteeNoteDto, + ); + } + + @Roles('MENTEE') + @Put('/mentee-notes/:id') + @ApiBody({ type: UpdateSessionMenteeNoteDto }) + @ApiOkResponse({ type: MentorshipSessionMenteeNotesEntity }) + @ApiNotFoundResponse({ + description: 'There is no mentorship session mentee notes with ID #[:id]', + }) + @ApiInternalServerErrorResponse({ + description: + 'Error updating mentorship session mentee notes with ID #[:id]: [ERROR MESSAGE]', + }) + @ApiOperation({ + summary: 'Update mentorship session mentee notes', + description: + 'Update mentorship session mentee notes. \n\n REQUIRED ROLES: **MENTEE**', + }) + @ApiBearerAuth() + updateMentorshipSessionMenteeNote( + @GetUser() user: JwtPayload, + @Param('id') id: string, + @Body() updateSessionMenteeNoteDto: UpdateSessionMenteeNoteDto, + ) { + return this.mentorshipSessionsService.updateMentorshipSessionMenteeNote( + +id, + user.sub, + updateSessionMenteeNoteDto, + ); + } + + @Roles('MENTEE') + @Delete('/mentee-notes/:id') + @ApiOkResponse({ type: Number }) + @ApiNotFoundResponse({ + description: + 'There is no mentorship session mentee notes with ID #[:id] to delete', + }) + @ApiInternalServerErrorResponse({ description: '[ERROR MESSAGE]' }) + @ApiOperation({ + summary: 'Delete mentorship session mentee notes', + description: + 'Delete mentorship session mentee notes. \n\n REQUIRED ROLES: **MENTEE**', + }) + @ApiBearerAuth() + deleteMentorshipSessionMenteeNote( + @GetUser() user: JwtPayload, + @Param('id') id: string, + ) { + return this.mentorshipSessionsService.deleteMentorshipSessionMenteeNote( + +id, + user.sub, + ); + } + + @Roles('MENTEE', 'USER') + @Get('/mentee-notes/:mentorshipSessionId') + @ApiOkResponse({ type: MentorshipSessionMenteeNotesEntity, isArray: true }) + @ApiInternalServerErrorResponse({ + description: + 'Error fetching mentorship session mentee notes: [ERROR MESSAGE]', + }) + @ApiOperation({ + summary: 'Fetch mentorship session mentee notes', + description: + 'Fetch mentorship session mentee notes. \n\n REQUIRED ROLES: **MENTEE**, **USER** \n\n If you are a mentee, you can fetch all notes for all sessions you are matched with. \n\n If you are a user, you can fetch all notes for all sessions you are matched with.', + }) + findAllMenteeNotes( + @GetUser() user: JwtPayload, + @Param('mentorshipSessionId') mentorshipSessionId: string, + ) { + return this.mentorshipSessionsService.findAllMentorshipSessionMenteeNotes( + +mentorshipSessionId, + user.sub, + ); + } + + //RATINGS (MENTOR AND MENTEE) + + @Roles('MENTOR', 'MENTEE', 'USER') + @Post('/session-ratings') + @ApiBody({ type: CreateSessionRatingDto }) + @ApiCreatedResponse({ type: MentorshipSessionRatingEntity }) + @ApiInternalServerErrorResponse({ + description: 'Error creating mentorship session rating: [ERROR MESSAGE]', + }) + @ApiOperation({ + summary: 'Create mentorship session rating', + description: + 'Create mentorship session rating. \n\n Only users involved in a session can rate', + }) + @ApiBearerAuth() + createMentorshipSessionRating( + @GetUser() user: JwtPayload, + @Body() createSessionRatingDto: CreateSessionRatingDto, + ) { + return this.mentorshipSessionsService.createMentorshipSessionRating( + user.sub, + createSessionRatingDto, + ); + } + + @Roles('ADMIN', 'MENTOR', 'MENTEE', 'USER') + @Get('/session-ratings') + @ApiQuery({ type: FilterMentorshipSessionRatingsDto }) + @ApiOkResponse({ type: MentorshipSessionRatingEntity, isArray: true }) + @ApiInternalServerErrorResponse({ + description: 'Error fetching mentorship session rating: [ERROR MESSAGE]', + }) + @ApiOperation({ + summary: 'Fetch mentorship session ratings', + description: + 'Fetch mentorship session ratings. \n\n REQUIRED ROLES: **ADMIN**, **MENTOR**, **MENTEE**, **USER** \n\n If you are a mentor, you can fetch all ratings for all sessions you created. \n\n If you are a mentee, you can fetch all ratings for all sessions you are matched with. \n\n If you are a user, you can fetch all ratings for all sessions you are matched with.', + }) + findAllMentorshipSessionRatings( + @GetUser() user: JwtPayload, + @Query() + filterMentorshipSessionRatingsDto: FilterMentorshipSessionRatingsDto, + ) { + return this.mentorshipSessionsService.findAllMentorshipSessionRatings( + filterMentorshipSessionRatingsDto, + user.sub, + ); + } + + // MATCHING + + @Roles('ADMIN') + @Post('/matching') + @ApiBody({ type: CreateMatchingDto }) + @ApiCreatedResponse({ type: MatchedMentorMenteeEntity }) + @ApiInternalServerErrorResponse({ + description: 'Error matching MENTOR with MENTEE: [ERROR MESSAGE]', + }) + @ApiOperation({ + summary: 'Create matching', + description: + 'Create matching between MENTOR and MENTEE. \n\n REQUIRED ROLES: **ADMIN**', + }) + @ApiBearerAuth() + createMatching(@Body() createMatchingDto: CreateMatchingDto) { + return this.mentorshipSessionsService.createMatching(createMatchingDto); + } + + @Roles('MENTOR') + @Patch('/matching/:id') + @ApiBody({ type: UpdateMatchingDto }) + @ApiOkResponse({ type: MatchedMentorMenteeEntity }) + @ApiNotFoundResponse({ + description: 'There is no matching with ID #[:id] to update', + }) + @ApiInternalServerErrorResponse({ + description: 'Error updating matching with ID #[:id]: [ERROR MESSAGE]', + }) + @ApiOperation({ + summary: 'Update matching status', + description: + 'Update the status of a match between MENTOR and MENTEE. \n\n REQUIRED ROLES: **MENTOR**', + }) + @ApiBearerAuth() + updateMatchingStatus( + @Param('id') id: string, + @GetUser() user: JwtPayload, + @Body() updateMatchingDto: UpdateMatchingDto, + ) { + return this.mentorshipSessionsService.updateMatchingStatus( + +id, + updateMatchingDto, + user.sub, + ); + } + + @Roles('ADMIN') + @Get('/matching/mentor/:mentorId') + @ApiOkResponse({ type: User, isArray: true }) + @ApiInternalServerErrorResponse({ + description: 'Error fetching matching users: [ERROR MESSAGE]', + }) + @ApiOperation({ + summary: 'Get match suggestions for a mentor', + description: + 'Get match suggestions for a mentor. \n\n REQUIRED ROLES: **ADMIN**', + }) + @ApiBearerAuth() + findAllMatchingForMentor(@Param('mentorId') mentorId: string) { + return this.mentorshipSessionsService.findAllMatchingForMentor(+mentorId); + } + + @Roles('ADMIN') + @Get('/matching/mentee/:menteeId') + @ApiOkResponse({ type: User, isArray: true }) + @ApiInternalServerErrorResponse({ + description: 'Error fetching matching users: [ERROR MESSAGE]', + }) + @ApiOperation({ + summary: 'Get match suggestions for a mentee', + description: + 'Get match suggestions for a mentee. \n\n REQUIRED ROLES: **ADMIN**', + }) + @ApiBearerAuth() + findAllMatchingForMentee(@Param('menteeId') menteeId: string) { + return this.mentorshipSessionsService.findAllMatchingForMentee(+menteeId); + } +} diff --git a/packages/backend/src/mentorship_sessions/mentorship_sessions.module.ts b/packages/backend/src/mentorship_sessions/mentorship_sessions.module.ts new file mode 100644 index 0000000..e89de2c --- /dev/null +++ b/packages/backend/src/mentorship_sessions/mentorship_sessions.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MentorshipSessionsService } from './mentorship_sessions.service'; +import { MentorshipSessionsController } from './mentorship_sessions.controller'; +import { PrismaService } from 'src/database'; + +@Module({ + controllers: [MentorshipSessionsController], + providers: [PrismaService, MentorshipSessionsService], +}) +export class MentorshipSessionsModule {} diff --git a/packages/backend/src/mentorship_sessions/mentorship_sessions.service.ts b/packages/backend/src/mentorship_sessions/mentorship_sessions.service.ts new file mode 100644 index 0000000..98d288a --- /dev/null +++ b/packages/backend/src/mentorship_sessions/mentorship_sessions.service.ts @@ -0,0 +1,1568 @@ +import { + InternalServerErrorException, + NotFoundException, + Injectable, +} from '@nestjs/common'; +import { + MentorshipSessionMenteeNotesEntity, + MentorshipSessionRatingEntity, + MentorshipSessionResponseEntity, + MatchedMentorMenteeResponseEntity, +} from './entities/mentorship_sessions.entity'; +import { + CreateMentorshipSessionDto, + CreateSessionMenteeNoteDto, + CreateSessionRatingDto, + CreateMatchingDto, +} from './dto/create.dto'; +import { + FilterMentorshipSessionsDto, + FilterMentorshipSessionRatingsDto, +} from './dto/filter.dto'; +import { PrismaService } from 'src/database/prisma.service'; +import { + UpdateMentorshipSessionDto, + UpdateSessionMenteeNoteDto, + UpdateMatchingDto, +} from './dto/update.dto'; + +@Injectable() +export class MentorshipSessionsService { + constructor(private prisma: PrismaService) {} + + async createMentorshipSession( + mentorId: number, + createMentorshipSessionDto: CreateMentorshipSessionDto, + ): Promise { + try { + // Check if there's already a matching between this mentor and mentee + const existingMatching = await this.prisma.matchedMentorMentee.findFirst({ + where: { + mentorId: mentorId, + menteeId: createMentorshipSessionDto.menteeId, + status: 'APPROVED', + }, + }); + + if (!existingMatching) { + throw new NotFoundException( + 'No approved matching found between this mentor and mentee', + ); + } + + // Create the mentorship session + const mentorshipSession = await this.prisma.mentorshipSessions.create({ + data: { + mentorId: mentorId, + menteeId: createMentorshipSessionDto.menteeId, + link: createMentorshipSessionDto.link, + dateStart: createMentorshipSessionDto.dateStart, + dateEnd: createMentorshipSessionDto.dateEnd, + }, + include: { + mentor: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + mentee: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + }, + }); + + // If description is provided, create the description record + if (createMentorshipSessionDto.description) { + await this.prisma.mentorshipSessionsDescriptions.create({ + data: { + sessionId: mentorshipSession.id, + description: createMentorshipSessionDto.description, + }, + }); + } + + // Fetch the complete session with all related data + const completeSession = await this.prisma.mentorshipSessions.findUnique({ + where: { id: mentorshipSession.id }, + include: { + mentor: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + mentee: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + descriptions: true, + menteeNotes: true, + mentorRating: true, + menteeRating: true, + }, + }); + + // Transform the response to match the entity structure + const response: MentorshipSessionResponseEntity = { + id: completeSession.id, + mentorId: completeSession.mentorId, + menteeId: completeSession.menteeId, + link: completeSession.link, + dateStart: completeSession.dateStart, + dateEnd: completeSession.dateEnd, + createdAt: completeSession.createdAt, + updatedAt: completeSession.updatedAt, + mentor: { + id: completeSession.mentor.id, + firstName: completeSession.mentor.first_name, + lastName: completeSession.mentor.last_name, + email: completeSession.mentor.email, + profession: completeSession.mentor.profession, + avatar: completeSession.mentor.picture_upload_link, + }, + mentee: { + id: completeSession.mentee.id, + firstName: completeSession.mentee.first_name, + lastName: completeSession.mentee.last_name, + email: completeSession.mentee.email, + profession: completeSession.mentee.profession, + avatar: completeSession.mentee.picture_upload_link, + }, + description: completeSession.descriptions[0] || undefined, + menteeNotes: completeSession.menteeNotes[0] || undefined, + mentorRating: completeSession.mentorRating || undefined, + menteeRating: completeSession.menteeRating || undefined, + }; + + return response; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException( + `Error creating the mentorship session: ${error.message}`, + ); + } + } + + async updateMentorshipSession( + id: number, + mentorId: number, + updateMentorshipSessionDto: UpdateMentorshipSessionDto, + ): Promise { + try { + // Check if the mentorship session exists and belongs to the mentor + const existingSession = await this.prisma.mentorshipSessions.findFirst({ + where: { + id: id, + mentorId: mentorId, + }, + }); + + if (!existingSession) { + throw new NotFoundException( + `There is no mentorship session with ID #${id}`, + ); + } + + // Prepare the update data + const updateData: any = {}; + + if (updateMentorshipSessionDto.link !== undefined) { + updateData.link = updateMentorshipSessionDto.link; + } + + if (updateMentorshipSessionDto.dateStart !== undefined) { + updateData.dateStart = updateMentorshipSessionDto.dateStart; + } + + if (updateMentorshipSessionDto.dateEnd !== undefined) { + updateData.dateEnd = updateMentorshipSessionDto.dateEnd; + } + + // Update the mentorship session + await this.prisma.mentorshipSessions.update({ + where: { id: id }, + data: updateData, + include: { + mentor: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + mentee: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + }, + }); + + // Update description if provided + if (updateMentorshipSessionDto.description !== undefined) { + // Check if description already exists + const existingDescription = + await this.prisma.mentorshipSessionsDescriptions.findFirst({ + where: { sessionId: id }, + }); + + if (existingDescription) { + // Update existing description + await this.prisma.mentorshipSessionsDescriptions.update({ + where: { id: existingDescription.id }, + data: { description: updateMentorshipSessionDto.description }, + }); + } else { + // Create new description + await this.prisma.mentorshipSessionsDescriptions.create({ + data: { + sessionId: id, + description: updateMentorshipSessionDto.description, + }, + }); + } + } + + // Fetch the complete updated session with all related data + const completeSession = await this.prisma.mentorshipSessions.findUnique({ + where: { id: id }, + include: { + mentor: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + mentee: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + descriptions: true, + menteeNotes: true, + mentorRating: true, + menteeRating: true, + }, + }); + + // Transform the response to match the entity structure + const response: MentorshipSessionResponseEntity = { + id: completeSession.id, + mentorId: completeSession.mentorId, + menteeId: completeSession.menteeId, + link: completeSession.link, + dateStart: completeSession.dateStart, + dateEnd: completeSession.dateEnd, + createdAt: completeSession.createdAt, + updatedAt: completeSession.updatedAt, + mentor: { + id: completeSession.mentor.id, + firstName: completeSession.mentor.first_name, + lastName: completeSession.mentor.last_name, + email: completeSession.mentor.email, + profession: completeSession.mentor.profession, + avatar: completeSession.mentor.picture_upload_link, + }, + mentee: { + id: completeSession.mentee.id, + firstName: completeSession.mentee.first_name, + lastName: completeSession.mentee.last_name, + email: completeSession.mentee.email, + profession: completeSession.mentee.profession, + avatar: completeSession.mentee.picture_upload_link, + }, + description: completeSession.descriptions[0] || undefined, + menteeNotes: completeSession.menteeNotes[0] || undefined, + mentorRating: completeSession.mentorRating || undefined, + menteeRating: completeSession.menteeRating || undefined, + }; + + return response; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException( + `Error updating mentorship session with ID #${id}: ${error.message}`, + ); + } + } + + async removeMentorshipSession(id: number, mentorId: number): Promise { + try { + // Check if the mentorship session exists and belongs to the mentor + const existingSession = await this.prisma.mentorshipSessions.findFirst({ + where: { + id: id, + mentorId: mentorId, + }, + }); + + if (!existingSession) { + throw new NotFoundException( + `There is no mentorship session with ID #${id} to delete`, + ); + } + + // Delete related records first (due to foreign key constraints) + // Delete session descriptions + await this.prisma.mentorshipSessionsDescriptions.deleteMany({ + where: { sessionId: id }, + }); + + // Delete mentee notes + await this.prisma.mentorshipSessionsMenteeNotes.deleteMany({ + where: { sessionId: id }, + }); + + // Delete mentor rating + await this.prisma.mentorshipSessionsMentorRating.deleteMany({ + where: { sessionId: id }, + }); + + // Delete mentee rating + await this.prisma.mentorshipSessionsMenteeRating.deleteMany({ + where: { sessionId: id }, + }); + + // Finally, delete the mentorship session + await this.prisma.mentorshipSessions.delete({ + where: { id: id }, + }); + + return id; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException( + `Error deleting mentorship session with ID #${id}: ${error.message}`, + ); + } + } + + async findAllMentorshipSessions( + filterMentorshipSessionsDto: FilterMentorshipSessionsDto, + userId: number, + ): Promise { + try { + // Get user role to determine what sessions they can access + const user = await this.prisma.users.findFirst({ + where: { id: userId }, + select: { role: true }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Build the base where clause + const whereClause: any = {}; + + // Add role-based filtering + if (user.role === 'MENTOR') { + whereClause.mentorId = userId; + } else if (user.role === 'MENTEE') { + whereClause.menteeId = userId; + } else if (user.role === 'USER') { + // For regular users, they can see sessions where they are either mentor or mentee + whereClause.OR = [{ mentorId: userId }, { menteeId: userId }]; + } + + // Add date filters + if (filterMentorshipSessionsDto.dateStartBefore) { + whereClause.dateStart = { + ...whereClause.dateStart, + lte: filterMentorshipSessionsDto.dateStartBefore, + }; + } + + if (filterMentorshipSessionsDto.dateStartAfter) { + whereClause.dateStart = { + ...whereClause.dateStart, + gte: filterMentorshipSessionsDto.dateStartAfter, + }; + } + + // Add mentor/mentee ID filters + if (filterMentorshipSessionsDto.mentorId) { + whereClause.mentorId = filterMentorshipSessionsDto.mentorId; + } + + if (filterMentorshipSessionsDto.menteeId) { + whereClause.menteeId = filterMentorshipSessionsDto.menteeId; + } + + // Fetch sessions with all related data + const sessions = await this.prisma.mentorshipSessions.findMany({ + where: whereClause, + include: { + mentor: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + mentee: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + descriptions: true, + menteeNotes: true, + mentorRating: true, + menteeRating: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + // Transform the response to match the entity structure + const response: MentorshipSessionResponseEntity[] = sessions.map( + (session) => ({ + id: session.id, + mentorId: session.mentorId, + menteeId: session.menteeId, + link: session.link, + dateStart: session.dateStart, + dateEnd: session.dateEnd, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + mentor: { + id: session.mentor.id, + firstName: session.mentor.first_name, + lastName: session.mentor.last_name, + email: session.mentor.email, + profession: session.mentor.profession, + avatar: session.mentor.picture_upload_link, + }, + mentee: { + id: session.mentee.id, + firstName: session.mentee.first_name, + lastName: session.mentee.last_name, + email: session.mentee.email, + profession: session.mentee.profession, + avatar: session.mentee.picture_upload_link, + }, + description: session.descriptions[0] || undefined, + menteeNotes: session.menteeNotes[0] || undefined, + mentorRating: session.mentorRating || undefined, + menteeRating: session.menteeRating || undefined, + }), + ); + + return response; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException( + `Error fetching mentorship sessions: ${error.message}`, + ); + } + } + + async findMentorshipSessionsById( + id: number, + userId: number, + ): Promise { + try { + // Get user role to determine what sessions they can access + const user = await this.prisma.users.findFirst({ + where: { id: userId }, + select: { role: true }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Build the base where clause + const whereClause: any = { id: id }; + + // Add role-based filtering + if (user.role === 'MENTOR') { + whereClause.mentorId = userId; + } else if (user.role === 'MENTEE') { + whereClause.menteeId = userId; + } else if (user.role === 'USER') { + // For regular users, they can see sessions where they are either mentor or mentee + whereClause.OR = [ + { id: id, mentorId: userId }, + { id: id, menteeId: userId }, + ]; + } + + // Fetch the session with all related data + const session = await this.prisma.mentorshipSessions.findFirst({ + where: whereClause, + include: { + mentor: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + mentee: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + descriptions: true, + menteeNotes: true, + mentorRating: true, + menteeRating: true, + }, + }); + + if (!session) { + throw new NotFoundException( + `There is no mentorship session with ID #${id}`, + ); + } + + // Transform the response to match the entity structure + const response: MentorshipSessionResponseEntity = { + id: session.id, + mentorId: session.mentorId, + menteeId: session.menteeId, + link: session.link, + dateStart: session.dateStart, + dateEnd: session.dateEnd, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + mentor: { + id: session.mentor.id, + firstName: session.mentor.first_name, + lastName: session.mentor.last_name, + email: session.mentor.email, + profession: session.mentor.profession, + avatar: session.mentor.picture_upload_link, + }, + mentee: { + id: session.mentee.id, + firstName: session.mentee.first_name, + lastName: session.mentee.last_name, + email: session.mentee.email, + profession: session.mentee.profession, + avatar: session.mentee.picture_upload_link, + }, + description: session.descriptions[0] || undefined, + menteeNotes: session.menteeNotes[0] || undefined, + mentorRating: session.mentorRating || undefined, + menteeRating: session.menteeRating || undefined, + }; + + return response; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException( + `Error fetching mentorship sessions by ID: ${error.message}`, + ); + } + } + + async createMentorshipSessionMenteeNote( + sessionId: number, + menteeId: number, + createSessionMenteeNoteDto: CreateSessionMenteeNoteDto, + ): Promise { + try { + // Check if the mentorship session exists and the user is the mentee + const existingSession = await this.prisma.mentorshipSessions.findFirst({ + where: { + id: sessionId, + menteeId: menteeId, + }, + }); + + if (!existingSession) { + throw new NotFoundException( + `There is no mentorship session with ID #${sessionId} for this mentee`, + ); + } + + // Check if notes already exist for this session + const existingNotes = + await this.prisma.mentorshipSessionsMenteeNotes.findFirst({ + where: { sessionId: sessionId }, + }); + + if (existingNotes) { + throw new NotFoundException( + `Notes already exist for session #${sessionId}. Use update instead.`, + ); + } + + // Create the mentee notes + const menteeNotes = + await this.prisma.mentorshipSessionsMenteeNotes.create({ + data: { + sessionId: sessionId, + notes: createSessionMenteeNoteDto.note, + }, + }); + + return { + id: menteeNotes.id, + sessionId: menteeNotes.sessionId, + notes: menteeNotes.notes, + }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException( + `Error creating mentorship session mentee notes: ${error.message}`, + ); + } + } + + async updateMentorshipSessionMenteeNote( + id: number, + menteeId: number, + updateSessionMenteeNoteDto: UpdateSessionMenteeNoteDto, + ): Promise { + try { + // Check if the mentee notes exist and belong to the mentee + const existingNotes = + await this.prisma.mentorshipSessionsMenteeNotes.findFirst({ + where: { + id: id, + session: { + menteeId: menteeId, + }, + }, + include: { + session: true, + }, + }); + + if (!existingNotes) { + throw new NotFoundException( + `There is no mentorship session mentee notes with ID #${id}`, + ); + } + + // Prepare the update data + const updateData: any = {}; + + if (updateSessionMenteeNoteDto.note !== undefined) { + updateData.notes = updateSessionMenteeNoteDto.note; + } + + // Update the mentee notes + const updatedNotes = + await this.prisma.mentorshipSessionsMenteeNotes.update({ + where: { id: id }, + data: updateData, + }); + + return { + id: updatedNotes.id, + sessionId: updatedNotes.sessionId, + notes: updatedNotes.notes, + }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException( + `Error updating mentorship session mentee notes with ID #${id}: ${error.message}`, + ); + } + } + + async deleteMentorshipSessionMenteeNote( + id: number, + menteeId: number, + ): Promise { + try { + // Check if the mentee notes exist and belong to the mentee + const existingNotes = + await this.prisma.mentorshipSessionsMenteeNotes.findFirst({ + where: { + id: id, + session: { + menteeId: menteeId, + }, + }, + include: { + session: true, + }, + }); + + if (!existingNotes) { + throw new NotFoundException( + `There is no mentorship session mentee notes with ID #${id} to delete`, + ); + } + + // Delete the mentee notes + await this.prisma.mentorshipSessionsMenteeNotes.delete({ + where: { id: id }, + }); + + return id; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException( + `Error deleting mentorship session mentee notes with ID #${id}: ${error.message}`, + ); + } + } + + async findAllMentorshipSessionMenteeNotes( + mentorshipSessionId: number, + userId: number, + ): Promise { + try { + // Check if the mentorship session exists and user has access to it as a mentee + const session = await this.prisma.mentorshipSessions.findFirst({ + where: { + id: mentorshipSessionId, + menteeId: userId, + }, + }); + + if (!session) { + throw new NotFoundException( + `There is no mentorship session with ID #${mentorshipSessionId} for this user`, + ); + } + + // Fetch all mentee notes for the session + const menteeNotes = + await this.prisma.mentorshipSessionsMenteeNotes.findMany({ + where: { sessionId: mentorshipSessionId }, + orderBy: { + id: 'desc', + }, + }); + + // Transform the response to match the entity structure + const response: MentorshipSessionMenteeNotesEntity[] = menteeNotes.map( + (note) => ({ + id: note.id, + sessionId: note.sessionId, + notes: note.notes, + }), + ); + + return response; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException( + `Error fetching mentorship session mentee notes: ${error.message}`, + ); + } + } + + async createMentorshipSessionRating( + userId: number, + createSessionRatingDto: CreateSessionRatingDto, + ): Promise { + try { + // Check if the mentorship session exists and the user is involved + const session = await this.prisma.mentorshipSessions.findFirst({ + where: { + id: createSessionRatingDto.sessionId, + OR: [{ mentorId: userId }, { menteeId: userId }], + }, + }); + + if (!session) { + throw new NotFoundException( + `There is no mentorship session with ID #${createSessionRatingDto.sessionId} for this user`, + ); + } + + // Determine if user is mentor or mentee and create appropriate rating + if (session.mentorId === userId) { + // User is the mentor, so they're rating the mentee + // Check if mentor rating already exists + const existingMentorRating = + await this.prisma.mentorshipSessionsMentorRating.findFirst({ + where: { sessionId: createSessionRatingDto.sessionId }, + }); + + if (existingMentorRating) { + throw new NotFoundException( + `Mentor rating already exists for session #${createSessionRatingDto.sessionId}`, + ); + } + + // Create mentor rating (mentor rating the mentee) + const mentorRating = + await this.prisma.mentorshipSessionsMentorRating.create({ + data: { + sessionId: createSessionRatingDto.sessionId, + rate: createSessionRatingDto.rate, + comment: createSessionRatingDto.note, + }, + }); + + return { + id: mentorRating.id, + sessionId: mentorRating.sessionId, + rate: mentorRating.rate, + comment: mentorRating.comment, + createdAt: mentorRating.createdAt, + }; + } else if (session.menteeId === userId) { + // User is the mentee, so they're rating the mentor + // Check if mentee rating already exists + const existingMenteeRating = + await this.prisma.mentorshipSessionsMenteeRating.findFirst({ + where: { sessionId: createSessionRatingDto.sessionId }, + }); + + if (existingMenteeRating) { + throw new NotFoundException( + `Mentee rating already exists for session #${createSessionRatingDto.sessionId}`, + ); + } + + // Create mentee rating (mentee rating the mentor) + const menteeRating = + await this.prisma.mentorshipSessionsMenteeRating.create({ + data: { + sessionId: createSessionRatingDto.sessionId, + rate: createSessionRatingDto.rate, + comment: createSessionRatingDto.note, + }, + }); + + return { + id: menteeRating.id, + sessionId: menteeRating.sessionId, + rate: menteeRating.rate, + comment: menteeRating.comment, + createdAt: menteeRating.createdAt, + }; + } else { + throw new NotFoundException( + 'User is not involved in this mentorship session', + ); + } + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException( + `Error creating mentorship session rating: ${error.message}`, + ); + } + } + + async findAllMentorshipSessionRatings( + filterMentorshipSessionRatingsDto: FilterMentorshipSessionRatingsDto, + userId: number, + ): Promise { + try { + // Get user role to determine what ratings they can access + const user = await this.prisma.users.findFirst({ + where: { id: userId }, + select: { role: true }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + let ratings: MentorshipSessionRatingEntity[] = []; + + if (user.role === 'ADMIN') { + // ADMIN can fetch all ratings from both tables + + // Build filter conditions for mentor ratings + const mentorRatingWhere: any = {}; + if (filterMentorshipSessionRatingsDto.sessionId) { + mentorRatingWhere.sessionId = + filterMentorshipSessionRatingsDto.sessionId; + } + if (filterMentorshipSessionRatingsDto.dateStartBefore) { + mentorRatingWhere.createdAt = { + ...mentorRatingWhere.createdAt, + lte: filterMentorshipSessionRatingsDto.dateStartBefore, + }; + } + if (filterMentorshipSessionRatingsDto.dateStartAfter) { + mentorRatingWhere.createdAt = { + ...mentorRatingWhere.createdAt, + gte: filterMentorshipSessionRatingsDto.dateStartAfter, + }; + } + + // Fetch mentor ratings + const mentorRatings = + await this.prisma.mentorshipSessionsMentorRating.findMany({ + where: mentorRatingWhere, + include: { + session: { + include: { + mentor: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + }, + }, + mentee: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + // Build filter conditions for mentee ratings + const menteeRatingWhere: any = {}; + if (filterMentorshipSessionRatingsDto.sessionId) { + menteeRatingWhere.sessionId = + filterMentorshipSessionRatingsDto.sessionId; + } + if (filterMentorshipSessionRatingsDto.dateStartBefore) { + menteeRatingWhere.createdAt = { + ...menteeRatingWhere.createdAt, + lte: filterMentorshipSessionRatingsDto.dateStartBefore, + }; + } + if (filterMentorshipSessionRatingsDto.dateStartAfter) { + menteeRatingWhere.createdAt = { + ...menteeRatingWhere.createdAt, + gte: filterMentorshipSessionRatingsDto.dateStartAfter, + }; + } + + // Fetch mentee ratings + const menteeRatings = + await this.prisma.mentorshipSessionsMenteeRating.findMany({ + where: menteeRatingWhere, + include: { + session: { + include: { + mentor: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + }, + }, + mentee: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + // Transform and combine ratings + ratings.push( + ...mentorRatings.map((rating) => ({ + id: rating.id, + sessionId: rating.sessionId, + rate: rating.rate, + comment: rating.comment, + createdAt: rating.createdAt, + type: 'MENTOR_RATING' as const, + session: rating.session, + })), + ...menteeRatings.map((rating) => ({ + id: rating.id, + sessionId: rating.sessionId, + rate: rating.rate, + comment: rating.comment, + createdAt: rating.createdAt, + type: 'MENTEE_RATING' as const, + session: rating.session, + })), + ); + } else { + // Non-admin users can only fetch ratings they gave or received + + // Get sessions where user is involved + const userSessions = await this.prisma.mentorshipSessions.findMany({ + where: { + OR: [{ mentorId: userId }, { menteeId: userId }], + }, + select: { id: true, mentorId: true, menteeId: true }, + }); + + const sessionIds = userSessions.map((session) => session.id); + + // Build filter conditions + const whereClause: any = { + sessionId: { in: sessionIds }, + }; + + if (filterMentorshipSessionRatingsDto.sessionId) { + whereClause.sessionId = filterMentorshipSessionRatingsDto.sessionId; + } + if (filterMentorshipSessionRatingsDto.dateStartBefore) { + whereClause.createdAt = { + ...whereClause.createdAt, + lte: filterMentorshipSessionRatingsDto.dateStartBefore, + }; + } + if (filterMentorshipSessionRatingsDto.dateStartAfter) { + whereClause.createdAt = { + ...whereClause.createdAt, + gte: filterMentorshipSessionRatingsDto.dateStartAfter, + }; + } + + // Fetch mentor ratings for user's sessions + const mentorRatings = + await this.prisma.mentorshipSessionsMentorRating.findMany({ + where: whereClause, + include: { + session: { + include: { + mentor: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + }, + }, + mentee: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + // Fetch mentee ratings for user's sessions + const menteeRatings = + await this.prisma.mentorshipSessionsMenteeRating.findMany({ + where: whereClause, + include: { + session: { + include: { + mentor: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + }, + }, + mentee: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + // Transform and combine ratings + ratings.push( + ...mentorRatings.map((rating) => ({ + id: rating.id, + sessionId: rating.sessionId, + rate: rating.rate, + comment: rating.comment, + createdAt: rating.createdAt, + type: 'MENTOR_RATING' as const, + session: rating.session, + })), + ...menteeRatings.map((rating) => ({ + id: rating.id, + sessionId: rating.sessionId, + rate: rating.rate, + comment: rating.comment, + createdAt: rating.createdAt, + type: 'MENTEE_RATING' as const, + session: rating.session, + })), + ); + } + + // Apply additional filters for mentor/mentee IDs if provided + if ( + filterMentorshipSessionRatingsDto.mentorId || + filterMentorshipSessionRatingsDto.menteeId + ) { + ratings = ratings.filter((rating) => { + if ( + filterMentorshipSessionRatingsDto.mentorId && + rating.session.mentor.id !== + filterMentorshipSessionRatingsDto.mentorId + ) { + return false; + } + if ( + filterMentorshipSessionRatingsDto.menteeId && + rating.session.mentee.id !== + filterMentorshipSessionRatingsDto.menteeId + ) { + return false; + } + return true; + }); + } + + return ratings; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException( + `Error fetching mentorship session rating: ${error.message}`, + ); + } + } + + async createMatching( + createMatchingDto: CreateMatchingDto, + ): Promise { + try { + // Validate that the mentor exists and has MENTOR role + const mentor = await this.prisma.users.findFirst({ + where: { + id: createMatchingDto.mentorId, + deleted_at: false, + role: 'MENTOR', + }, + }); + + if (!mentor) { + throw new NotFoundException( + `Mentor with ID ${createMatchingDto.mentorId} not found or not authorized`, + ); + } + + // Validate that the mentee exists and has MENTEE role + const mentee = await this.prisma.users.findFirst({ + where: { + id: createMatchingDto.menteeId, + deleted_at: false, + role: 'MENTEE', + }, + }); + + if (!mentee) { + throw new NotFoundException( + `Mentee with ID ${createMatchingDto.menteeId} not found or not authorized`, + ); + } + + // Check if a matching already exists between this mentor and mentee + const existingMatching = await this.prisma.matchedMentorMentee.findFirst({ + where: { + mentorId: createMatchingDto.mentorId, + menteeId: createMatchingDto.menteeId, + }, + }); + + if (existingMatching) { + throw new NotFoundException( + 'A matching already exists between this mentor and mentee', + ); + } + + // Create the matching + const matching = await this.prisma.matchedMentorMentee.create({ + data: { + mentorId: createMatchingDto.mentorId, + menteeId: createMatchingDto.menteeId, + status: 'PENDING', + }, + include: { + mentor: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + mentee: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + }, + }); + + // Transform the response to match the entity structure + const response: MatchedMentorMenteeResponseEntity = { + id: matching.id, + mentorId: matching.mentorId, + menteeId: matching.menteeId, + status: matching.status, + createdAt: matching.createdAt, + mentor: { + id: matching.mentor.id, + firstName: matching.mentor.first_name, + lastName: matching.mentor.last_name, + email: matching.mentor.email, + profession: matching.mentor.profession, + avatar: matching.mentor.picture_upload_link, + }, + mentee: { + id: matching.mentee.id, + firstName: matching.mentee.first_name, + lastName: matching.mentee.last_name, + email: matching.mentee.email, + profession: matching.mentee.profession, + avatar: matching.mentee.picture_upload_link, + }, + }; + + return response; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException( + `Error matching MENTOR with MENTEE: ${error.message}`, + ); + } + } + + async updateMatchingStatus( + id: number, + updateMatchingDto: UpdateMatchingDto, + mentorId: number, + ): Promise { + try { + // Check if the matching exists and belongs to the mentor + const existingMatching = await this.prisma.matchedMentorMentee.findFirst({ + where: { + id: id, + mentorId: mentorId, + }, + }); + + if (!existingMatching) { + throw new NotFoundException( + `There is no matching with ID #${id} to update`, + ); + } + + // Update the matching status + const updatedMatching = await this.prisma.matchedMentorMentee.update({ + where: { id: id }, + data: { + status: updateMatchingDto.status, + }, + include: { + mentor: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + mentee: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + }, + }, + }, + }); + + // Transform the response to match the entity structure + const response: MatchedMentorMenteeResponseEntity = { + id: updatedMatching.id, + mentorId: updatedMatching.mentorId, + menteeId: updatedMatching.menteeId, + status: updatedMatching.status, + createdAt: updatedMatching.createdAt, + mentor: { + id: updatedMatching.mentor.id, + firstName: updatedMatching.mentor.first_name, + lastName: updatedMatching.mentor.last_name, + email: updatedMatching.mentor.email, + profession: updatedMatching.mentor.profession, + avatar: updatedMatching.mentor.picture_upload_link, + }, + mentee: { + id: updatedMatching.mentee.id, + firstName: updatedMatching.mentee.first_name, + lastName: updatedMatching.mentee.last_name, + email: updatedMatching.mentee.email, + profession: updatedMatching.mentee.profession, + avatar: updatedMatching.mentee.picture_upload_link, + }, + }; + + return response; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException( + `Error updating matching with ID #${id}: ${error.message}`, + ); + } + } + + async findAllMatchingForMentor(mentorId: number): Promise { + try { + // Validate that the mentor exists + const mentor = await this.prisma.users.findFirst({ + where: { + id: mentorId, + deleted_at: false, + role: 'MENTOR', + }, + }); + + if (!mentor) { + throw new NotFoundException(`Mentor with ID ${mentorId} not found`); + } + + // Get all mentees that are matched with this mentor + const matchings = await this.prisma.matchedMentorMentee.findMany({ + where: { + mentorId: mentorId, + }, + include: { + mentee: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + bio: true, + experience: true, + company_name: true, + linkedin_link: true, + github_link: true, + portfolio_link: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + // Transform the response to match the User entity structure + const mentees = matchings.map((matching) => ({ + id: matching.mentee.id, + first_name: matching.mentee.first_name, + last_name: matching.mentee.last_name, + email: matching.mentee.email, + profession: matching.mentee.profession, + picture_upload_link: matching.mentee.picture_upload_link, + bio: matching.mentee.bio, + experience: matching.mentee.experience, + company_name: matching.mentee.company_name, + linkedin_link: matching.mentee.linkedin_link, + github_link: matching.mentee.github_link, + portfolio_link: matching.mentee.portfolio_link, + })); + + return mentees; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException( + `Error fetching matching users: ${error.message}`, + ); + } + } + + async findAllMatchingForMentee(menteeId: number): Promise { + try { + // Validate that the mentee exists + const mentee = await this.prisma.users.findFirst({ + where: { + id: menteeId, + deleted_at: false, + role: 'MENTEE', + }, + }); + + if (!mentee) { + throw new NotFoundException(`Mentee with ID ${menteeId} not found`); + } + + // Get all mentors that are matched with this mentee + const matchings = await this.prisma.matchedMentorMentee.findMany({ + where: { + menteeId: menteeId, + }, + include: { + mentor: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + profession: true, + picture_upload_link: true, + bio: true, + experience: true, + company_name: true, + linkedin_link: true, + github_link: true, + portfolio_link: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + // Transform the response to match the User entity structure + const mentors = matchings.map((matching) => ({ + id: matching.mentor.id, + first_name: matching.mentor.first_name, + last_name: matching.mentor.last_name, + email: matching.mentor.email, + profession: matching.mentor.profession, + picture_upload_link: matching.mentor.picture_upload_link, + bio: matching.mentor.bio, + experience: matching.mentor.experience, + company_name: matching.mentor.company_name, + linkedin_link: matching.mentor.linkedin_link, + github_link: matching.mentor.github_link, + portfolio_link: matching.mentor.portfolio_link, + })); + + return mentors; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException( + `Error fetching matching users: ${error.message}`, + ); + } + } + + // ... other methods will be implemented here +} diff --git a/packages/frontend/src/app/auth/forgot-password/page.tsx b/packages/frontend/src/app/auth/forgot-password/page.tsx index 1026ecb..394f1d2 100644 --- a/packages/frontend/src/app/auth/forgot-password/page.tsx +++ b/packages/frontend/src/app/auth/forgot-password/page.tsx @@ -1,4 +1,5 @@ import { Metadata } from 'next'; +import { Suspense } from 'react'; import { AuthCarousel } from '@/features/auth/components'; import { Separator } from '@/shared/components/ui/separator'; import { ForgotPasswordForm } from '@/features/auth/components'; @@ -12,7 +13,9 @@ const ForgotPasswordPage = () => { return (
- + Loading...
}> + +
diff --git a/packages/frontend/src/app/auth/reset-password/page.tsx b/packages/frontend/src/app/auth/reset-password/page.tsx index 3f9d2cc..636d988 100644 --- a/packages/frontend/src/app/auth/reset-password/page.tsx +++ b/packages/frontend/src/app/auth/reset-password/page.tsx @@ -28,7 +28,9 @@ const ResetPasswordContent = () => { return (
- + Loading...
}> + +
diff --git a/packages/frontend/src/features/auth/api/auth-api.ts b/packages/frontend/src/features/auth/api/auth-api.ts index 2d9b1d3..23e51ad 100644 --- a/packages/frontend/src/features/auth/api/auth-api.ts +++ b/packages/frontend/src/features/auth/api/auth-api.ts @@ -3,13 +3,14 @@ import { LoginCredentials, AuthResponse, RegisterCredentials, - ResetPasswordCredentials, + // ResetPasswordCredentials, ForgotPasswordCredentials, UpdatePasswordCredentials, AccessToken, RefreshToken, ChangePasswordCredentials } from '@/features/auth/types'; +import { ResetPasswordWithTokenFormValues } from '@/features/auth/validations/auth.schema'; export const authApi = { login: (credentials: LoginCredentials) => @@ -27,8 +28,13 @@ export const authApi = { forgotPassword: (credentials: ForgotPasswordCredentials) => apiMethods.post('/auth/forgot-password', credentials), - resetPassword: (credentials: ResetPasswordCredentials) => - apiMethods.post('/auth/reset-password', credentials), + resetPassword: ( + credentials: ResetPasswordWithTokenFormValues & { token?: string } + ) => + apiMethods.post( + '/auth/reset-password-with-token', + credentials + ), changePassword: (credentials: ChangePasswordCredentials) => apiMethods.post('/auth/change-password', credentials), diff --git a/packages/frontend/src/features/auth/components/forgot-password-form.tsx b/packages/frontend/src/features/auth/components/forgot-password-form.tsx index 94069d5..9e2dd25 100644 --- a/packages/frontend/src/features/auth/components/forgot-password-form.tsx +++ b/packages/frontend/src/features/auth/components/forgot-password-form.tsx @@ -16,17 +16,22 @@ import { Icons } from '@/features/auth/components/icons'; import { useAuth } from '@/features/auth/hooks/use-auth'; import { forgotPasswordSchema, - resetPasswordSchema, + resetPasswordWithTokenSchema, ForgotPasswordFormValues, - ResetPasswordFormValues + ResetPasswordWithTokenFormValues } from '@/features/auth/validations/auth.schema'; import Link from 'next/link'; import { IconInput } from '@/shared/components/ui/icon-input'; import { cn } from '@/shared/lib/utils'; import { AlertDialogUI } from '@/shared/components/notification/alert-dialog'; -type RetrievePasswordFormValues = ForgotPasswordFormValues & - ResetPasswordFormValues; +// Create a union type that includes all possible fields +type FormValues = { + email?: string; + token?: string; + newPassword?: string; + confirmPassword?: string; +}; const ForgotPasswordFormContent = () => { const pathname = usePathname(); @@ -37,32 +42,51 @@ const ForgotPasswordFormContent = () => { const isForgotPasswordPage = pathname === '/auth/forgot-password'; const token = searchParams.get('token') || ''; - const form = useForm({ - resolver: zodResolver( - isForgotPasswordPage ? forgotPasswordSchema : resetPasswordSchema - ), + // Use the appropriate schema based on the page + const schema = isForgotPasswordPage + ? forgotPasswordSchema + : resetPasswordWithTokenSchema; + + const form = useForm({ + resolver: zodResolver(schema), defaultValues: { email: '', - token: token, newPassword: '', confirmPassword: '' } }); - const onSubmit = async (data: RetrievePasswordFormValues) => { - console.log('rest password button clicked'); + // Add token validation AFTER the hook + if (!isForgotPasswordPage && !token) { + return ( +
+
+

Invalid Reset Link

+

+ This password reset link is invalid or has expired. +

+ + Request a new reset link + +
+
+ ); + } + + const onSubmit = async (data: FormValues) => { + console.log('Form submitted with data:', data); + console.log('isForgotPasswordPage:', isForgotPasswordPage); + console.log('token:', token); if (isForgotPasswordPage) { - await forgotPassword(data as ForgotPasswordFormValues); + await forgotPassword({ email: data.email! }); } else { - const resetPasswordData = { - ...data, + // Include token in reset password call + await resetPassword({ + newPassword: data.newPassword!, + confirmPassword: data.confirmPassword!, token: token - }; - - console.log('reset password data', resetPasswordData); - - await resetPassword(resetPasswordData as ResetPasswordFormValues); + }); } }; @@ -85,13 +109,6 @@ const ForgotPasswordFormContent = () => {
- {!isForgotPasswordPage && ( - } - /> - )} {isForgotPasswordPage && ( { state={showConfirmPassword} className={cn( 'w-full', - form.formState.errors.newPassword && + form.formState.errors.confirmPassword && 'border-red-500 focus-visible:ring-red-100' )} {...field} diff --git a/packages/frontend/src/features/auth/hooks/use-auth.ts b/packages/frontend/src/features/auth/hooks/use-auth.ts index f082561..d06e361 100644 --- a/packages/frontend/src/features/auth/hooks/use-auth.ts +++ b/packages/frontend/src/features/auth/hooks/use-auth.ts @@ -8,13 +8,15 @@ import { LoginCredentials, RefreshToken, RegisterCredentials, - ResetPasswordCredentials, + // ResetPasswordCredentials, UpdatePasswordCredentials } from '@/features/auth/types'; import { userApi } from '@/features/user-profile/api/user-api'; import Cookies from 'js-cookie'; import { useAlertDialog } from '@/shared/hooks/use-alert-dialog'; import { ApiError } from '@/shared/types'; +// import { useSearchParams } from 'next/navigation'; +import { ResetPasswordWithTokenFormValues } from '../validations/auth.schema'; const USER_STORAGE_KEY = 'user_data'; @@ -186,23 +188,24 @@ export function useAuth() { } }; - const resetPassword = async (credentials: ResetPasswordCredentials) => { + const resetPassword = async ( + credentials: ResetPasswordWithTokenFormValues & { token?: string } + ) => { try { setIsLoading(true); const response = await authApi.resetPassword(credentials); if (response.success) { showAlert({ - title: 'Password updated successfully!', - description: 'You can now login with your new password.', + title: 'Password reset successfully!', + description: + 'Your password has been reset. You can now login with your new password.', type: 'success', redirect: '/auth/login' }); - return response; } } catch (error) { const apiError = error as ApiError; - showAlert({ title: 'Failed to reset password', description: apiError.response?.data?.message || 'Please try again.', diff --git a/packages/frontend/src/features/auth/validations/auth.schema.ts b/packages/frontend/src/features/auth/validations/auth.schema.ts index 6588396..92509e9 100644 --- a/packages/frontend/src/features/auth/validations/auth.schema.ts +++ b/packages/frontend/src/features/auth/validations/auth.schema.ts @@ -39,7 +39,23 @@ export const resetPasswordSchema = z path: ['confirmPassword'] }); +export const resetPasswordWithTokenSchema = z + .object({ + newPassword: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex(passwordPattern, 'Password must meet minimum requirements'), + confirmPassword: z.string() + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "Passwords don't match", + path: ['confirmPassword'] + }); + export type LoginFormValues = z.infer; export type RegisterFormValues = z.infer; export type ForgotPasswordFormValues = z.infer; export type ResetPasswordFormValues = z.infer; +export type ResetPasswordWithTokenFormValues = z.infer< + typeof resetPasswordWithTokenSchema +>; diff --git a/packages/frontend/src/features/mentorship/api/mentorship-api.ts b/packages/frontend/src/features/mentorship/api/mentorship-api.ts index df24a10..804896a 100644 --- a/packages/frontend/src/features/mentorship/api/mentorship-api.ts +++ b/packages/frontend/src/features/mentorship/api/mentorship-api.ts @@ -33,5 +33,12 @@ export const mentorshipApi = { apiMethods.get(`/admin/mentorship/mentor-applications`), getAdminMenteeApplications: () => - apiMethods.get(`/admin/mentorship/mentee-applications`) + apiMethods.get(`/admin/mentorship/mentee-applications`), + + updateMentorStatus: (mentorId: number, status: string) => + apiMethods.put(`/mentors/${mentorId}`, { status }), + + // Add mentee status update method + updateMenteeStatus: (menteeId: number, status: string) => + apiMethods.put(`/mentees/${menteeId}`, { status }) }; diff --git a/packages/frontend/src/features/mentorship/components/common/modals/admin-mentee-modal.tsx b/packages/frontend/src/features/mentorship/components/common/modals/admin-mentee-modal.tsx new file mode 100644 index 0000000..f914882 --- /dev/null +++ b/packages/frontend/src/features/mentorship/components/common/modals/admin-mentee-modal.tsx @@ -0,0 +1,269 @@ +import { cn } from '@/shared/lib/utils'; +import { + Avatar, + AvatarFallback, + AvatarImage +} from '@/shared/components/ui/avatar'; +import { Button } from '@/shared/components/ui/button'; +import { FileText, MessageSquare, Download } from 'lucide-react'; +import { Mentee } from '../../../types'; +import { UserRoundIcon } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogTrigger +} from '@/shared/components/ui/dialog'; +import { useState } from 'react'; +import { PdfPreviewModal } from '@/shared/components/pdf/pdf-preview-modal'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/shared/components/ui/select'; +import { mentorshipApi } from '../../../api/mentorship-api'; +import { useAlertDialog } from '@/shared/hooks/use-alert-dialog'; + +interface AdminMenteeModalCardProps { + data: Mentee; + onStatusUpdate?: () => void; // Add callback to refresh data +} + +export const AdminMenteeModalCard = ({ + data, + onStatusUpdate +}: AdminMenteeModalCardProps) => { + const { + identity, + profession, + experience, + email, + reason, + resume, + status, + menteeApplicationId // Add this + } = data; + const [isOpen, setIsModalOpen] = useState(false); + const [isResumeModalOpen, setIsResumeModalOpen] = useState(false); + const [selectedStatus, setSelectedStatus] = useState( + status || 'PENDING' + ); + const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); + const { showAlert } = useAlertDialog(); + + const handleViewResume = () => { + if (resume) { + setIsResumeModalOpen(true); + } + }; + + const handleStatusUpdate = async () => { + if (selectedStatus === status || !menteeApplicationId) return; + + setIsUpdatingStatus(true); + try { + await mentorshipApi.updateMenteeStatus( + menteeApplicationId, + selectedStatus + ); + + showAlert({ + type: 'success', + title: 'Status Updated', + description: `Mentee status has been updated to ${selectedStatus}.` + }); + + // Refresh the data + onStatusUpdate?.(); + setIsModalOpen(false); + } catch (error) { + showAlert({ + type: 'error', + title: 'Update Failed', + description: 'Failed to update mentee status. Please try again.' + }); + } finally { + setIsUpdatingStatus(false); + } + }; + + const resumeActions = [ + { + label: 'Close', + onClick: () => setIsResumeModalOpen(false), + variant: 'outline' as const + }, + ...(resume + ? [ + { + label: 'Download Resume', + href: resume.startsWith('http') + ? resume + : `${process.env.NEXT_PUBLIC_API_URL}/files/${resume}`, + variant: 'default' as const, + icon: , + external: true + } + ] + : []) + ]; + + const isApproved = status?.toString().toLowerCase() === 'approved'; + + return ( + <> + + + + + + + + Mentee Profile + + + View the mentee profile. + + + +
+
+
+ + + + + + + + + +
+
+ {identity.firstName} {identity.lastName} +
+

+ {profession} +

+ +

+ Email: {email} +

+ +

+ Experience:{' '} + + {' '} + {experience || 'Not specified'} + +

+ +

+ Why do you want to be mentored? + {reason} +

+
+ +
+ + +
+
+
+ + {/* Status Management Section */} +
+
+
+ Status: + +
+
+ + +
+
+ {isApproved ? ( + + ) : null} +
+ +
+ + {/* Resume Preview Modal */} + {resume && ( + setIsResumeModalOpen(false)} + title={`${identity.firstName} ${identity.lastName}'s Resume`} + description="Preview and download the resume" + height="h-full" + filePath={ + resume.startsWith('http') + ? resume + : `${process.env.NEXT_PUBLIC_API_URL}/files/${resume}` + } + actions={resumeActions} + /> + )} + + ); +}; diff --git a/packages/frontend/src/features/mentorship/components/common/modals/admin-mentor-modal.tsx b/packages/frontend/src/features/mentorship/components/common/modals/admin-mentor-modal.tsx index 0a98e14..8c1efc9 100644 --- a/packages/frontend/src/features/mentorship/components/common/modals/admin-mentor-modal.tsx +++ b/packages/frontend/src/features/mentorship/components/common/modals/admin-mentor-modal.tsx @@ -5,7 +5,7 @@ import { AvatarImage } from '@/shared/components/ui/avatar'; import { Button } from '@/shared/components/ui/button'; -import { FileText, MessageSquare } from 'lucide-react'; +import { FileText, MessageSquare, Download } from 'lucide-react'; import { MentorshipAdmin } from '../../../types'; import { UserRoundIcon } from 'lucide-react'; import { @@ -18,15 +18,27 @@ import { } from '@/shared/components/ui/dialog'; import { useState } from 'react'; import { Rating } from '@/shared/components/ui/rating'; +import { PdfPreviewModal } from '@/shared/components/pdf/pdf-preview-modal'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/shared/components/ui/select'; +import { mentorshipApi } from '../../../api/mentorship-api'; +import { useAlertDialog } from '@/shared/hooks/use-alert-dialog'; interface AdminMentorModalCardProps { data: MentorshipAdmin; isRating?: boolean; + onStatusUpdate?: () => void; // Add callback to refresh data } export const AdminMentorModalCard = ({ data, - isRating = false + isRating = false, + onStatusUpdate }: AdminMentorModalCardProps) => { const { identity, @@ -35,132 +47,261 @@ export const AdminMentorModalCard = ({ email, experienceDescription, review, - ratingsGroup + ratingsGroup, + resume, + status, + mentorApplicationId // Add this } = data; const [isOpen, setIsModalOpen] = useState(false); + const [isResumeModalOpen, setIsResumeModalOpen] = useState(false); + const [selectedStatus, setSelectedStatus] = useState(status || 'PENDING'); + const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); + const { showAlert } = useAlertDialog(); + + const handleViewResume = () => { + if (resume) { + setIsResumeModalOpen(true); + } + }; + + const handleStatusUpdate = async () => { + if (selectedStatus === status || !mentorApplicationId) return; + + setIsUpdatingStatus(true); + try { + await mentorshipApi.updateMentorStatus( + mentorApplicationId, + selectedStatus + ); + + showAlert({ + type: 'success', + title: 'Status Updated', + description: `Mentor status has been updated to ${selectedStatus}.` + }); + + // Refresh the data + onStatusUpdate?.(); + setIsModalOpen(false); + } catch (error) { + showAlert({ + type: 'error', + title: 'Update Failed', + description: 'Failed to update mentor status. Please try again.' + }); + } finally { + setIsUpdatingStatus(false); + } + }; + + const resumeActions = [ + { + label: 'Close', + onClick: () => setIsResumeModalOpen(false), + variant: 'outline' as const + }, + ...(resume + ? [ + { + label: 'Download Resume', + href: resume.startsWith('http') + ? resume + : `${process.env.NEXT_PUBLIC_API_URL}/files/${resume}`, + variant: 'default' as const, + icon: , + external: true + } + ] + : []) + ]; + + const isApproved = status?.toString().toLowerCase() === 'approved'; return ( - - - - - - - - {isRating ? 'Session Review' : 'Mentor Profile'} - - - View the mentees profile. - - - -
-
-
+ <> + + + + + + + + {isRating ? 'Session Review' : 'Mentor Profile'} + + + View the mentees profile. + + - - +
+
- - - - - -
-
- {identity.firstName} {identity.lastName} -
-

{profession}

- -

- Email: {email} -

- -

- Years of Experience:{' '} - {experience} -

- -

- {isRating ? 'Review' : 'Why do you want a mentor?'} - - {isRating ? review : experienceDescription} - -

-
+ + + + + + + + +
+
+ {identity.firstName} {identity.lastName} +
+

+ {profession} +

- {isRating && ( -
- Ratings -
- - Motivational +

+ Email: {email} +

+ +

+ Years of Experience:{' '} + {experience} +

+ +

+ + {isRating ? 'Review' : 'Why do you want to be a mentor?'} - -

-
- - Communication + + {isRating ? review : experienceDescription} - +

+
+ + {isRating && ( +
+ Ratings +
+ + Motivational + + +
+
+ + Communication + + +
+
+ + Knowledge + + +
+
+ + Problem Solving + + +
-
- - Knowledge - - + )} + + {!isRating && ( +
+ +
-
- - Problem Solving - - + )} +
+
+ + {/* Status Management Section */} + {!isRating && ( +
+
+
+ Status: + +
+
+ +
- )} - - {!isRating && ( -
- + {isApproved ? ( -
- )} -
-
-
- -
- -
+ ) : null} +
+ )} + +
+ + {/* Resume Preview Modal */} + {resume && ( + setIsResumeModalOpen(false)} + title={`${identity.firstName} ${identity.lastName}'s Resume`} + description="Preview and download the resume" + height="h-full" + filePath={ + resume.startsWith('http') + ? resume + : `${process.env.NEXT_PUBLIC_API_URL}/files/${resume}` + } + actions={resumeActions} + /> + )} + ); }; diff --git a/packages/frontend/src/features/mentorship/components/mentorship-admin-page.tsx b/packages/frontend/src/features/mentorship/components/mentorship-admin-page.tsx index 4f2cee0..279a6df 100644 --- a/packages/frontend/src/features/mentorship/components/mentorship-admin-page.tsx +++ b/packages/frontend/src/features/mentorship/components/mentorship-admin-page.tsx @@ -3,7 +3,7 @@ import { useUserStore } from '@/features/user-profile/store'; import { MentorshipSection } from './common/mentorship-section'; import { DataTable } from './table/data-table'; -import { mentorshipAdminColumns } from './table/mentoship-admin-columns'; +import { createMentorshipAdminColumns } from './table/mentoship-admin-columns'; // import { mentorshipAdminData } from './table/data'; import MentorCard from './common/mentor-card'; import { @@ -25,6 +25,7 @@ import { bestMatchesColumns } from './table/best-matches-column'; import { IconInput } from '@/shared/components/ui/icon-input'; import { useMentorshipStore } from '../store'; import { menteesColumns } from './table/mentees-column'; +import { createMenteeColumns } from './table/mentees-column'; export const MentorshipAdminPage = () => { const { user } = useUserStore(); @@ -46,6 +47,12 @@ export const MentorshipAdminPage = () => { getAdminMenteeApplications(); }, []); + // Add refresh function + const handleRefreshData = () => { + getAdminMentorApplications(); + getAdminMenteeApplications(); + }; + const filteredDataMentors = useMemo(() => { if (selectedStatus === 'All') return adminMentorApplications; return adminMentorApplications.filter( @@ -121,6 +128,11 @@ export const MentorshipAdminPage = () => { return `${((lastMonth / currentMonth) * 100).toFixed(2)}%`; }, [adminMentorshipTotals]); + // Create columns with callback + const mentorshipAdminColumns = + createMentorshipAdminColumns(handleRefreshData); + const menteeColumns = createMenteeColumns(handleRefreshData); + return (
@@ -238,7 +250,7 @@ export const MentorshipAdminPage = () => { {totalPagesMentees > 1 && ( diff --git a/packages/frontend/src/features/mentorship/components/table/mentees-column.tsx b/packages/frontend/src/features/mentorship/components/table/mentees-column.tsx index 31fafda..b337ea3 100644 --- a/packages/frontend/src/features/mentorship/components/table/mentees-column.tsx +++ b/packages/frontend/src/features/mentorship/components/table/mentees-column.tsx @@ -8,9 +8,15 @@ import { } from '@/shared/components/ui/avatar'; import { Mentee } from '../../types'; import { Badge } from '@/shared/components/ui/badge'; -import Link from 'next/link'; +import { AdminMenteeModalCard } from '../common/modals/admin-mentee-modal'; -export const menteesColumns: ColumnDef[] = [ +interface MenteeColumnsProps { + onStatusUpdate?: () => void; +} + +export const createMenteeColumns = ( + onStatusUpdate?: () => void +): ColumnDef[] => [ { accessorKey: 'identity.firstName', header: () => ( @@ -62,9 +68,9 @@ export const menteesColumns: ColumnDef[] = [ cell: ({ row }) => ( [] = [ ), cell: ({ row }) => (
- - View - +
) } ]; + +// Keep the original export for backward compatibility +export const menteesColumns = createMenteeColumns(); diff --git a/packages/frontend/src/features/mentorship/components/table/mentoship-admin-columns.tsx b/packages/frontend/src/features/mentorship/components/table/mentoship-admin-columns.tsx index 23e3259..90a39f4 100644 --- a/packages/frontend/src/features/mentorship/components/table/mentoship-admin-columns.tsx +++ b/packages/frontend/src/features/mentorship/components/table/mentoship-admin-columns.tsx @@ -8,13 +8,19 @@ import { AvatarImage } from '@/shared/components/ui/avatar'; import { Badge } from '@/shared/components/ui/badge'; -import Link from 'next/link'; +import { AdminMentorModalCard } from '../common/modals/admin-mentor-modal'; -export const mentorshipAdminColumns: ColumnDef[] = [ +interface MentorshipAdminColumnsProps { + onStatusUpdate?: () => void; +} + +export const createMentorshipAdminColumns = ( + onStatusUpdate?: () => void +): ColumnDef[] => [ { accessorKey: 'identity.firstName', header: () => ( -
Mentee
+
Mentor
), cell: ({ row }) => (
@@ -62,9 +68,11 @@ export const mentorshipAdminColumns: ColumnDef[] = [ cell: ({ row }) => ( [] = [ ), cell: ({ row }) => (
- - View - +
) } ]; + +// Keep the original export for backward compatibility +export const mentorshipAdminColumns = createMentorshipAdminColumns(); diff --git a/packages/frontend/src/features/mentorship/types/index.ts b/packages/frontend/src/features/mentorship/types/index.ts index ae9b1cc..6a317b1 100644 --- a/packages/frontend/src/features/mentorship/types/index.ts +++ b/packages/frontend/src/features/mentorship/types/index.ts @@ -66,6 +66,7 @@ export type RatingsGroup = { export interface MentorshipAdmin { id: string; + mentorApplicationId?: number; // Add this field identity: { avatar: string; firstName: string; @@ -83,10 +84,12 @@ export interface MentorshipAdmin { ratings?: number; review?: string; ratingsGroup?: RatingsGroup; + resume?: string; } export type Mentee = { id: string; + menteeApplicationId?: number; // Add this field identity: { avatar: string; firstName: string; @@ -99,4 +102,5 @@ export type Mentee = { email: string; reason: string; experience?: string; + resume?: string; // Add this field };