diff --git a/apps/api/src/controller/user.controller.ts b/apps/api/src/controller/user.controller.ts index a877336..c2ca9b6 100644 --- a/apps/api/src/controller/user.controller.ts +++ b/apps/api/src/controller/user.controller.ts @@ -23,7 +23,7 @@ const tutorProfileSchema = z.object(tutorProfileFields).strict(); const editableProfileFields = { name: z.string().trim().min(1, "Name is required").max(100, "Name is too long"), - profileUrl: z.string().trim().url("Please enter a valid profile image URL"), + profileUrl: z.string().trim().url("Please enter a valid profile image URL").nullable(), bio: z.string().trim().max(1000, "Bio must be at most 1000 characters").nullable(), } as const; @@ -38,12 +38,17 @@ const tutorProfileUpdateSchema = z .object({ ...editableProfileFields, hourlyRate: tutorProfileFields.hourlyRate.optional(), + subjectIds: z + .array(z.string().uuid("Each subject ID must be a valid UUID")) + .refine((ids) => new Set(ids).size === ids.length, "Subject IDs must be unique") + .optional(), }) .strict(); type ProfileData = z.infer | z.infer; -type ProfileUpdateData = - z.infer | z.infer; +type ProfileUpdateRequest = + | { role: typeof Role.student; data: z.infer } + | { role: typeof Role.tutor; data: z.infer }; export const userController = { async submitProfile(req: AuthRequest, res: Response, next: NextFunction) { @@ -95,20 +100,23 @@ export const userController = { throw new UnauthorizedError("User not found in request"); } - let role: Role; - let validatedData: ProfileUpdateData; + let profileUpdate: ProfileUpdateRequest; if (user.role === Role.student) { - role = Role.student; - validatedData = studentProfileUpdateSchema.parse(req.body); + profileUpdate = { + role: Role.student, + data: studentProfileUpdateSchema.parse(req.body), + }; } else if (user.role === Role.tutor) { - role = Role.tutor; - validatedData = tutorProfileUpdateSchema.parse(req.body); + profileUpdate = { + role: Role.tutor, + data: tutorProfileUpdateSchema.parse(req.body), + }; } else { throw new UnauthorizedError("Invalid authentication role"); } - const updatedUser = await userService.updateProfile(user.id, role, validatedData); + const updatedUser = await userService.updateProfile(user.id, profileUpdate); res.status(200).json(successResponse(updatedUser, "Profile updated successfully")); } catch (error) { next(error); diff --git a/apps/api/src/repository/subject.repository.ts b/apps/api/src/repository/subject.repository.ts index f720c11..e0a62c4 100644 --- a/apps/api/src/repository/subject.repository.ts +++ b/apps/api/src/repository/subject.repository.ts @@ -5,4 +5,15 @@ export const subjectRepository = { async findAll(): Promise { return prisma.subject.findMany({ orderBy: { name: "asc" } }); }, + + async findByIds(subjectIds: string[]): Promise[]> { + if (subjectIds.length === 0) { + return []; + } + + return prisma.subject.findMany({ + where: { id: { in: subjectIds } }, + select: { id: true }, + }); + }, }; diff --git a/apps/api/src/repository/user.repository.ts b/apps/api/src/repository/user.repository.ts index c00b4ba..9b5a7c6 100644 --- a/apps/api/src/repository/user.repository.ts +++ b/apps/api/src/repository/user.repository.ts @@ -74,11 +74,25 @@ export const userRepository = { }); }, - async updateExistingProfile(userId: string, data: ExistingProfileUpdate): Promise { + async updateExistingProfile( + userId: string, + data: ExistingProfileUpdate, + subjectIds?: string[] + ): Promise { return prisma.user.update({ where: { id: userId }, - data, - select: publicUserSelect, + data: { + ...data, + ...(subjectIds !== undefined && { + tutorSubjects: { + deleteMany: {}, + create: subjectIds.map((subjectId) => ({ + subject: { connect: { id: subjectId } }, + })), + }, + }), + }, + select: profileSelect, }); }, }; diff --git a/apps/api/src/router/user.router.ts b/apps/api/src/router/user.router.ts index f18e6ad..a491dad 100644 --- a/apps/api/src/router/user.router.ts +++ b/apps/api/src/router/user.router.ts @@ -71,6 +71,7 @@ import type { AuthRequest } from "../common/middleware/auth.middleware.js"; * profileUrl: * type: string * format: uri + * nullable: true * example: "https://cdn.example.com/student.jpg" * bio: * type: string @@ -100,6 +101,7 @@ import type { AuthRequest } from "../common/middleware/auth.middleware.js"; * profileUrl: * type: string * format: uri + * nullable: true * example: "https://cdn.example.com/tutor.jpg" * bio: * type: string @@ -111,6 +113,13 @@ import type { AuthRequest } from "../common/middleware/auth.middleware.js"; * exclusiveMinimum: 0 * description: Optional; retains its current value when omitted * example: 750.0 + * subjectIds: + * type: array + * uniqueItems: true + * description: Optional; omitted retains current subjects, an empty array removes all subjects, and a non-empty array replaces them. + * items: + * type: string + * format: uuid */ // Cast router to use AuthRequest @@ -186,8 +195,22 @@ router.get("/me", authMiddleware, userController.getProfile); * - $ref: '#/components/schemas/StudentProfileUpdate' * - $ref: '#/components/schemas/TutorProfileUpdate' * responses: - * 200: { description: Profile updated successfully } - * 400: { description: Missing, invalid, role-incompatible, or unknown request fields } + * 200: + * description: Profile updated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean, example: true } + * message: { type: string, example: Profile updated successfully } + * data: + * allOf: + * - $ref: '#/components/schemas/UserProfile' + * - type: object + * properties: + * profileComplete: { type: boolean, example: true } + * 400: { description: Missing, invalid, role-incompatible, duplicate, malformed, or unknown request fields or subject IDs } * 401: { description: Missing or invalid authentication, deleted user, or role mismatch } * 409: { description: Initial profile completion is required } * 500: { description: Internal server error } diff --git a/apps/api/src/service/user.service.ts b/apps/api/src/service/user.service.ts index cf76539..fad0901 100644 --- a/apps/api/src/service/user.service.ts +++ b/apps/api/src/service/user.service.ts @@ -1,5 +1,10 @@ import { Role, User, Prisma } from "@prisma/client"; -import { userRepository } from "../repository/user.repository.js"; +import { + userRepository, + type ExistingProfileUpdate, + type UserProfileRow, +} from "../repository/user.repository.js"; +import { subjectRepository } from "../repository/subject.repository.js"; import { NotFoundError, BadRequestError, @@ -13,12 +18,48 @@ type ProfileData = { hourlyRate?: number; }; -type ProfileUpdateData = ProfileData & { +type CommonProfileUpdateData = { name: string; - profileUrl: string; + profileUrl: string | null; bio: string | null; }; +type StudentProfileUpdateData = CommonProfileUpdateData & { + gradeLevel?: string; + goals?: string; + hourlyRate?: never; + subjectIds?: never; +}; + +type TutorProfileUpdateData = CommonProfileUpdateData & { + hourlyRate?: number; + subjectIds?: string[]; + gradeLevel?: never; + goals?: never; +}; + +type ProfileUpdateRequest = + | { role: typeof Role.student; data: StudentProfileUpdateData } + | { role: typeof Role.tutor; data: TutorProfileUpdateData }; + +function mapProfile(user: UserProfileRow) { + return { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + profileUrl: user.profileUrl, + bio: user.bio, + hourlyRate: user.hourlyRate === null ? null : Number(user.hourlyRate), + gradeLevel: user.gradeLevel, + goals: user.goals, + walletBalance: Number(user.walletBalance), + profileComplete: user.profileComplete, + createdAt: user.createdAt, + subjects: user.tutorSubjects.map((tutorSubject) => tutorSubject.subject), + }; +} + export const userService = { async completeProfile(userId: string, role: Role, data: ProfileData) { let updateData: Partial = { profileComplete: true }; @@ -46,23 +87,11 @@ export const userService = { throw new NotFoundError("User not found"); } - return { - id: user.id, - name: user.name, - email: user.email, - role: user.role, - profileUrl: user.profileUrl, - bio: user.bio, - hourlyRate: user.hourlyRate ? Number(user.hourlyRate) : null, - gradeLevel: user.gradeLevel, - goals: user.goals, - walletBalance: Number(user.walletBalance), - createdAt: user.createdAt, - subjects: user.tutorSubjects.map((ts) => ts.subject), - }; + return mapProfile(user); }, - async updateProfile(userId: string, role: Role, data: ProfileUpdateData) { + async updateProfile(userId: string, profileUpdate: ProfileUpdateRequest) { + const { role, data } = profileUpdate; const profileOwner = await userRepository.findProfileOwnerById(userId); if (!profileOwner) { @@ -83,7 +112,8 @@ export const userService = { bio: data.bio, }; - let updateData; + let updateData: ExistingProfileUpdate; + let subjectIds: string[] | undefined; if (role === Role.student) { updateData = { ...commonData, @@ -97,12 +127,30 @@ export const userService = { hourlyRate: new Prisma.Decimal(data.hourlyRate), }), }; + + subjectIds = data.subjectIds; + if (subjectIds !== undefined) { + const existingSubjects = await subjectRepository.findByIds(subjectIds); + const existingSubjectIds = new Set(existingSubjects.map((subject) => subject.id)); + const unknownSubjectIds = subjectIds.filter( + (subjectId) => !existingSubjectIds.has(subjectId) + ); + + if (unknownSubjectIds.length > 0) { + throw new BadRequestError(`Unknown subject IDs: ${unknownSubjectIds.join(", ")}`); + } + } } else { throw new UnauthorizedError("Invalid authentication role"); } try { - return await userRepository.updateExistingProfile(userId, updateData); + const updatedUser = await userRepository.updateExistingProfile( + userId, + updateData, + subjectIds + ); + return mapProfile(updatedUser); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { throw new UnauthorizedError("Authenticated user no longer exists"); diff --git a/apps/web/src/app/profile/edit/page.tsx b/apps/web/src/app/profile/edit/page.tsx new file mode 100644 index 0000000..8f9fe04 --- /dev/null +++ b/apps/web/src/app/profile/edit/page.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; + +import { ProfileEditForm } from "@/components/profile/profile-edit-form"; + +export const metadata: Metadata = { + title: "Edit profile | Tutorist", + description: "Update your Tutorist profile.", +}; + +export default function EditProfilePage() { + return ( +
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/Navbar.tsx b/apps/web/src/components/Navbar.tsx index 8137ad1..301dc82 100644 --- a/apps/web/src/components/Navbar.tsx +++ b/apps/web/src/components/Navbar.tsx @@ -16,6 +16,7 @@ const NAV_LINKS = [ { label: "Find tutors", href: "/tutors" }, { label: "My classroom", href: "/classroom" }, { label: "Message", href: "/message" }, + { label: "My profile", href: "/profile" }, ]; const FOCUSABLE_SELECTOR = @@ -178,13 +179,6 @@ export function Navbar({ isLoggedIn, userName, userMoney, profileUrl }: NavbarPr )} - ) : ( <> diff --git a/apps/web/src/components/profile/profile-completion-form.tsx b/apps/web/src/components/profile/profile-completion-form.tsx index 00792a9..0f7aeef 100644 --- a/apps/web/src/components/profile/profile-completion-form.tsx +++ b/apps/web/src/components/profile/profile-completion-form.tsx @@ -17,7 +17,7 @@ type ProfileCompletionFormProps = { role: UserRole; }; -const GRADE_LEVELS = [ +export const GRADE_LEVELS = [ "Grade 7", "Grade 8", "Grade 9", diff --git a/apps/web/src/components/profile/profile-details.tsx b/apps/web/src/components/profile/profile-details.tsx index fd4c455..2ab3f2d 100644 --- a/apps/web/src/components/profile/profile-details.tsx +++ b/apps/web/src/components/profile/profile-details.tsx @@ -1,9 +1,10 @@ "use client"; -import { LogOut, Mail } from "lucide-react"; +import { LogOut, Mail, Pencil } from "lucide-react"; +import Link from "next/link"; import { ProfileAvatar } from "@/components/profile/profile-avatar"; -import { Button } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { UserProfile } from "@/types/user"; @@ -95,6 +96,10 @@ export function ProfileDetails({ } /> )} + +