diff --git a/apps/api/src/controller/user.controller.ts b/apps/api/src/controller/user.controller.ts index 540132b..c2ca9b6 100644 --- a/apps/api/src/controller/user.controller.ts +++ b/apps/api/src/controller/user.controller.ts @@ -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 456fad7..a491dad 100644 --- a/apps/api/src/router/user.router.ts +++ b/apps/api/src/router/user.router.ts @@ -113,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 @@ -188,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 e420c2f..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 | 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/components/profile/profile-edit-form.tsx b/apps/web/src/components/profile/profile-edit-form.tsx index 15f3e3e..02bc554 100644 --- a/apps/web/src/components/profile/profile-edit-form.tsx +++ b/apps/web/src/components/profile/profile-edit-form.tsx @@ -8,11 +8,13 @@ import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { MultiSelect } from "@/components/ui/multi-select"; import { Select } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { ApiError, apiClient } from "@/lib/api-client"; import { clearSession, getToken, saveSession } from "@/lib/auth-storage"; import { GRADE_LEVELS } from "@/lib/grade-levels"; +import type { Subject } from "@/types/subject"; import type { ProfileUpdateResponse, UserProfile } from "@/types/user"; type Fields = { gradeLevel: string; goals: string; hourlyRate: string }; @@ -25,12 +27,18 @@ function fieldsFromProfile(profile: UserProfile): Fields { }; } +function haveSameSubjectIds(left: string[], right: string[]) { + return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort()); +} + export function ProfileEditForm() { const router = useRouter(); const [profile, setProfile] = useState(null); const [name, setName] = useState(""); const [about, setAbout] = useState(""); const [fields, setFields] = useState({ gradeLevel: "", goals: "", hourlyRate: "" }); + const [availableSubjects, setAvailableSubjects] = useState([]); + const [selectedSubjectIds, setSelectedSubjectIds] = useState([]); const [touched, setTouched] = useState>>({}); const [loading, setLoading] = useState(true); const [needsLogin, setNeedsLogin] = useState(false); @@ -50,16 +58,24 @@ export function ProfileEditForm() { } setLoading(true); setLoadError(""); - apiClient - .get("/users/me") - .then((current) => { + setProfile(null); + setAvailableSubjects([]); + setSelectedSubjectIds([]); + + async function loadProfile() { + try { + const current = await apiClient.get("/users/me"); + const subjects = + current.role === "tutor" ? await apiClient.get("/subjects") : []; + if (!active) return; setProfile(current); setName(current.name ?? ""); setAbout(current.bio ?? ""); setFields(fieldsFromProfile(current)); - }) - .catch((error: unknown) => { + setAvailableSubjects(subjects); + setSelectedSubjectIds(current.subjects.map((subject) => subject.id)); + } catch (error) { if (active) { if (error instanceof ApiError && error.status === 401) { clearSession(); @@ -68,10 +84,12 @@ export function ProfileEditForm() { } setLoadError(error instanceof Error ? error.message : "Unable to load your profile."); } - }) - .finally(() => { + } finally { if (active) setLoading(false); - }); + } + } + + void loadProfile(); return () => { active = false; }; @@ -100,7 +118,11 @@ export function ProfileEditForm() { (isStudent ? fields.gradeLevel.trim() !== (profile.gradeLevel ?? "").trim() || fields.goals.trim() !== (profile.goals ?? "").trim() - : fields.hourlyRate.trim() !== "" && rate !== profile.hourlyRate)) + : (fields.hourlyRate.trim() !== "" && rate !== profile.hourlyRate) || + !haveSameSubjectIds( + selectedSubjectIds, + profile.subjects.map((subject) => subject.id) + ))) ); function edit(field: keyof Fields, value: string) { @@ -127,19 +149,14 @@ export function ProfileEditForm() { bio: about.trim() || null, ...(isStudent ? { gradeLevel: fields.gradeLevel.trim(), goals: fields.goals.trim() } - : { hourlyRate: rate }), + : { hourlyRate: rate, subjectIds: selectedSubjectIds }), }); - // Refresh from the persisted response, including database decimal rounding. - const current: UserProfile = { - ...profile, - ...updated, - hourlyRate: updated.hourlyRate === null ? null : Number(updated.hourlyRate), - walletBalance: Number(updated.walletBalance), - }; + const current: UserProfile = updated; setProfile(current); setName(current.name ?? ""); setAbout(current.bio ?? ""); setFields(fieldsFromProfile(current)); + setSelectedSubjectIds(current.subjects.map((subject) => subject.id)); setTouched({}); const token = getToken(); if (token) { @@ -306,20 +323,39 @@ export function ProfileEditForm() { /> ) : ( - edit("hourlyRate", event.target.value)} - onBlur={() => touch("hourlyRate")} - error={Boolean(touched.hourlyRate && errors.hourlyRate)} - errorMessage={errors.hourlyRate} - className="h-10 rounded-xl" - /> + <> + edit("hourlyRate", event.target.value)} + onBlur={() => touch("hourlyRate")} + error={Boolean(touched.hourlyRate && errors.hourlyRate)} + errorMessage={errors.hourlyRate} + className="h-10 rounded-xl" + /> + ({ + label: subject.name, + value: subject.id, + }))} + value={selectedSubjectIds} + onValueChange={(subjectIds) => { + setSelectedSubjectIds(subjectIds); + setSaved(false); + setSaveError(""); + }} + disabled={saving} + className="w-full gap-2 sm:col-start-2 sm:row-start-3 [&>button]:h-10 [&>button]:rounded-xl [&>button]:px-4 [&>button]:text-sm [&>button:focus-visible]:ring-4 [&>button:focus-visible]:ring-primary/10" + /> + )}