Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions apps/api/src/controller/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof studentProfileSchema> | z.infer<typeof tutorProfileSchema>;
type ProfileUpdateData =
z.infer<typeof studentProfileUpdateSchema> | z.infer<typeof tutorProfileUpdateSchema>;
type ProfileUpdateRequest =
| { role: typeof Role.student; data: z.infer<typeof studentProfileUpdateSchema> }
| { role: typeof Role.tutor; data: z.infer<typeof tutorProfileUpdateSchema> };

export const userController = {
async submitProfile(req: AuthRequest, res: Response, next: NextFunction) {
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/repository/subject.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,15 @@ export const subjectRepository = {
async findAll(): Promise<Subject[]> {
return prisma.subject.findMany({ orderBy: { name: "asc" } });
},

async findByIds(subjectIds: string[]): Promise<Pick<Subject, "id">[]> {
if (subjectIds.length === 0) {
return [];
}

return prisma.subject.findMany({
where: { id: { in: subjectIds } },
select: { id: true },
});
},
};
20 changes: 17 additions & 3 deletions apps/api/src/repository/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,25 @@ export const userRepository = {
});
},

async updateExistingProfile(userId: string, data: ExistingProfileUpdate): Promise<PublicUser> {
async updateExistingProfile(
userId: string,
data: ExistingProfileUpdate,
subjectIds?: string[]
): Promise<UserProfileRow> {
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,
});
},
};
25 changes: 23 additions & 2 deletions apps/api/src/router/user.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
86 changes: 67 additions & 19 deletions apps/api/src/service/user.service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<User> = { profileComplete: true };
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -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");
Expand Down
Loading
Loading