Skip to content
Merged
28 changes: 18 additions & 10 deletions apps/api/src/controller/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
});
},
};
27 changes: 25 additions & 2 deletions apps/api/src/router/user.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
88 changes: 68 additions & 20 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;
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
27 changes: 27 additions & 0 deletions apps/web/src/app/profile/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="min-h-[calc(100vh-64px)] bg-[#faf9fc] px-4 py-4 sm:px-6 lg:px-10">
<div className="mx-auto w-full max-w-5xl">
<Link
href="/profile"
className="mb-3 inline-flex min-h-10 items-center gap-2 rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-primary/[0.8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
>
<ArrowLeft aria-hidden="true" className="size-4" />
Back to profile
</Link>
<ProfileEditForm />
</div>
</main>
);
}
8 changes: 1 addition & 7 deletions apps/web/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -178,13 +179,6 @@ export function Navbar({ isLoggedIn, userName, userMoney, profileUrl }: NavbarPr
</Link>
)}
</div>
<Button
variant="outline"
className="text-sm font-semibold px-4 py-2"
onClick={() => router.push("/profile")}
>
Edit Profile
</Button>
</div>
) : (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type ProfileCompletionFormProps = {
role: UserRole;
};

const GRADE_LEVELS = [
export const GRADE_LEVELS = [
"Grade 7",
"Grade 8",
"Grade 9",
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/components/profile/profile-details.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -95,6 +96,10 @@ export function ProfileDetails({
}
/>
)}
<Link href="/profile/edit" className={cn(buttonVariants(), "w-full")}>
<Pencil aria-hidden="true" className="size-4 shrink-0" />
Edit Profile
</Link>
{onLogout && (
<Button
type="button"
Expand Down
Loading
Loading