From 170ab6d1bef0f1e96afd6500932ddb001b05d7bb Mon Sep 17 00:00:00 2001 From: Kantinun Date: Sun, 11 Oct 2026 19:34:35 +0700 Subject: [PATCH 1/4] feat: add GET /tutors endpoint (#96) --- apps/api/src/controller/tutor.controller.ts | 57 +++++++++++++ apps/api/src/repository/tutor.repository.ts | 94 +++++++++++++++++++++ apps/api/src/router/tutor.router.ts | 86 +++++++++++++++++++ apps/api/src/routes.ts | 2 + apps/api/src/service/tutor.service.ts | 88 +++++++++++++++++++ 5 files changed, 327 insertions(+) create mode 100644 apps/api/src/controller/tutor.controller.ts create mode 100644 apps/api/src/repository/tutor.repository.ts create mode 100644 apps/api/src/router/tutor.router.ts create mode 100644 apps/api/src/service/tutor.service.ts diff --git a/apps/api/src/controller/tutor.controller.ts b/apps/api/src/controller/tutor.controller.ts new file mode 100644 index 0000000..d4ea6bb --- /dev/null +++ b/apps/api/src/controller/tutor.controller.ts @@ -0,0 +1,57 @@ +import type { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { successResponse } from "../common/utils/response.js"; +import { tutorService } from "../service/tutor.service.js"; + +const optionalPrice = (label: string) => + z.coerce + .number({ invalid_type_error: `${label} must be a number` }) + .finite(`${label} must be finite`) + .nonnegative(`${label} must not be negative`) + .optional(); + +const tutorListQuerySchema = z + .object({ + search: z + .string() + .trim() + .max(100, "Search term is too long") + .optional() + .transform((value) => value || undefined), + subjectId: z + .string() + .uuid("Subject ID must be a valid UUID") + .optional() + .or(z.literal("").transform(() => undefined)), + minPrice: z.preprocess( + (value) => (value === "" ? undefined : value), + optionalPrice("Minimum price") + ), + maxPrice: z.preprocess( + (value) => (value === "" ? undefined : value), + optionalPrice("Maximum price") + ), + sortBy: z + .enum(["rating", "price", "popularity"], { + message: 'Sort must be one of "rating", "price", or "popularity"', + }) + .optional(), + order: z.enum(["asc", "desc"], { message: 'Order must be "asc" or "desc"' }).optional(), + }) + .refine( + ({ minPrice, maxPrice }) => + minPrice === undefined || maxPrice === undefined || minPrice <= maxPrice, + "Minimum price must not exceed maximum price" + ); + +export const tutorController = { + async listTutors(req: Request, res: Response, next: NextFunction) { + try { + const query = tutorListQuerySchema.parse(req.query); + const tutors = await tutorService.listTutors(query); + res.status(200).json(successResponse(tutors, "Tutors retrieved successfully.")); + } catch (error) { + next(error); + } + }, +}; diff --git a/apps/api/src/repository/tutor.repository.ts b/apps/api/src/repository/tutor.repository.ts new file mode 100644 index 0000000..4f513c7 --- /dev/null +++ b/apps/api/src/repository/tutor.repository.ts @@ -0,0 +1,94 @@ +import { AccountStatus, Prisma, Role } from "@prisma/client"; +import { prisma } from "../config/prisma.js"; + +const tutorListSelect = { + id: true, + name: true, + profileUrl: true, + bio: true, + hourlyRate: true, + tutorSubjects: { + select: { + subject: { + select: { + id: true, + name: true, + }, + }, + }, + }, +} as const; + +export type TutorListRow = Prisma.UserGetPayload<{ select: typeof tutorListSelect }>; + +export type TutorListFilter = { + search?: string; + subjectId?: string; + minPrice?: number; + maxPrice?: number; +}; + +export type TutorRatingRow = { tutorId: string; averageRating: number | null; reviewCount: number }; +export type TutorPopularityRow = { tutorId: string; studentCount: number }; + +export const tutorRepository = { + /** Active tutors with a completed profile, narrowed by the given filters. */ + async findActive(filter: TutorListFilter): Promise { + const { search, subjectId, minPrice, maxPrice } = filter; + + return prisma.user.findMany({ + where: { + role: Role.tutor, + accountStatus: AccountStatus.ACTIVE, + profileComplete: true, + ...(search && { name: { contains: search, mode: "insensitive" } }), + ...(subjectId && { tutorSubjects: { some: { subjectId } } }), + ...((minPrice !== undefined || maxPrice !== undefined) && { + hourlyRate: { + ...(minPrice !== undefined && { gte: new Prisma.Decimal(minPrice) }), + ...(maxPrice !== undefined && { lte: new Prisma.Decimal(maxPrice) }), + }, + }), + }, + select: tutorListSelect, + }); + }, + + async findRatings(tutorIds: string[]): Promise { + if (tutorIds.length === 0) { + return []; + } + + const rows = await prisma.review.groupBy({ + by: ["tutorId"], + where: { tutorId: { in: tutorIds } }, + _avg: { rating: true }, + _count: { _all: true }, + }); + + return rows.map((row) => ({ + tutorId: row.tutorId, + averageRating: row._avg.rating, + reviewCount: row._count._all, + })); + }, + + /** Number of distinct students who have had a classroom with each tutor. */ + async findPopularity(tutorIds: string[]): Promise { + if (tutorIds.length === 0) { + return []; + } + + const pairs = await prisma.classroom.groupBy({ + by: ["tutorId", "studentId"], + where: { tutorId: { in: tutorIds } }, + }); + + const counts = new Map(); + for (const { tutorId } of pairs) { + counts.set(tutorId, (counts.get(tutorId) ?? 0) + 1); + } + + return [...counts].map(([tutorId, studentCount]) => ({ tutorId, studentCount })); + }, +}; diff --git a/apps/api/src/router/tutor.router.ts b/apps/api/src/router/tutor.router.ts new file mode 100644 index 0000000..f196a20 --- /dev/null +++ b/apps/api/src/router/tutor.router.ts @@ -0,0 +1,86 @@ +import { Router } from "express"; +import { tutorController } from "../controller/tutor.controller.js"; + +export const tutorRouter = Router(); + +/** + * @swagger + * components: + * schemas: + * TutorListItem: + * type: object + * properties: + * id: { type: string, format: uuid } + * name: { type: string, nullable: true, example: Tutor Name } + * profileUrl: { type: string, format: uri, nullable: true } + * bio: { type: string, nullable: true } + * hourlyRate: { type: number, nullable: true, example: 25 } + * subjects: + * type: array + * items: + * type: object + * properties: + * id: { type: string, format: uuid } + * name: { type: string, example: Mathematics } + * rating: + * type: number + * nullable: true + * description: Average review score out of 5, rounded to one decimal; null when the tutor has no reviews + * example: 4.8 + * reviewCount: { type: integer, example: 12 } + * studentCount: + * type: integer + * description: Number of distinct students who have learned with the tutor + * example: 30 + */ + +/** + * @swagger + * /tutors: + * get: + * summary: List active tutors + * description: > + * Public list of tutors whose account is active and whose profile is complete. + * Search, subject, and price filters combine (AND). Tutors with no value for the + * sort key (e.g. no reviews when sorting by rating) are listed last. + * tags: [Tutors] + * parameters: + * - in: query + * name: search + * schema: { type: string, maxLength: 100 } + * description: Case-insensitive match against the tutor's name + * - in: query + * name: subjectId + * schema: { type: string, format: uuid } + * - in: query + * name: minPrice + * schema: { type: number, minimum: 0 } + * description: Minimum hourly rate (inclusive) + * - in: query + * name: maxPrice + * schema: { type: number, minimum: 0 } + * description: Maximum hourly rate (inclusive) + * - in: query + * name: sortBy + * schema: { type: string, enum: [rating, price, popularity], default: rating } + * - in: query + * name: order + * schema: { type: string, enum: [asc, desc] } + * description: Defaults to desc for rating and popularity, asc for price + * responses: + * 200: + * description: Tutors retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: { type: boolean, example: true } + * message: { type: string, example: Tutors retrieved successfully. } + * data: + * type: array + * items: + * $ref: '#/components/schemas/TutorListItem' + * 400: { description: Invalid query parameters } + */ +tutorRouter.get("/", tutorController.listTutors); diff --git a/apps/api/src/routes.ts b/apps/api/src/routes.ts index b370c12..287147e 100644 --- a/apps/api/src/routes.ts +++ b/apps/api/src/routes.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { authRouter } from "./router/auth.router.js"; import { subjectRouter } from "./router/subject.router.js"; +import { tutorRouter } from "./router/tutor.router.js"; import { userRouter } from "./router/user.router.js"; import { walletRouter } from "./router/wallet.router.js"; @@ -8,5 +9,6 @@ export const apiRoutes = Router(); apiRoutes.use("/auth", authRouter); apiRoutes.use("/subjects", subjectRouter); +apiRoutes.use("/tutors", tutorRouter); apiRoutes.use("/users", userRouter); apiRoutes.use("/wallet", walletRouter); diff --git a/apps/api/src/service/tutor.service.ts b/apps/api/src/service/tutor.service.ts new file mode 100644 index 0000000..9bb2cf5 --- /dev/null +++ b/apps/api/src/service/tutor.service.ts @@ -0,0 +1,88 @@ +import { tutorRepository, type TutorListFilter } from "../repository/tutor.repository.js"; + +export type TutorSortBy = "rating" | "price" | "popularity"; +export type SortOrder = "asc" | "desc"; + +export type TutorListQuery = TutorListFilter & { + sortBy?: TutorSortBy; + order?: SortOrder; +}; + +export type TutorListItem = { + id: string; + name: string | null; + profileUrl: string | null; + bio: string | null; + hourlyRate: number | null; + subjects: { id: string; name: string }[]; + /** Average review score out of 5, rounded to one decimal; null when unreviewed. */ + rating: number | null; + reviewCount: number; + /** Distinct students who have had a classroom with this tutor. */ + studentCount: number; +}; + +const DEFAULT_ORDER: Record = { + rating: "desc", + price: "asc", + popularity: "desc", +}; + +const SORT_VALUE: Record number | null> = { + rating: (tutor) => tutor.rating, + price: (tutor) => tutor.hourlyRate, + popularity: (tutor) => tutor.studentCount, +}; + +function compareTutors(sortBy: TutorSortBy, order: SortOrder) { + const valueOf = SORT_VALUE[sortBy]; + const direction = order === "asc" ? 1 : -1; + + return (a: TutorListItem, b: TutorListItem) => { + const left = valueOf(a); + const right = valueOf(b); + + // Missing values (no reviews, no rate) always sink to the bottom. + if (left !== right) { + if (left === null) return 1; + if (right === null) return -1; + return (left - right) * direction; + } + return (a.name ?? "").localeCompare(b.name ?? ""); + }; +} + +export const tutorService = { + async listTutors(query: TutorListQuery): Promise { + const { sortBy, order, ...filter } = query; + + const tutors = await tutorRepository.findActive(filter); + const tutorIds = tutors.map((tutor) => tutor.id); + const [ratings, popularity] = await Promise.all([ + tutorRepository.findRatings(tutorIds), + tutorRepository.findPopularity(tutorIds), + ]); + + const ratingByTutor = new Map(ratings.map((row) => [row.tutorId, row])); + const studentsByTutor = new Map(popularity.map((row) => [row.tutorId, row.studentCount])); + + const items: TutorListItem[] = tutors.map((tutor) => { + const rating = ratingByTutor.get(tutor.id); + const averageRating = rating?.averageRating ?? null; + return { + id: tutor.id, + name: tutor.name, + profileUrl: tutor.profileUrl, + bio: tutor.bio, + hourlyRate: tutor.hourlyRate === null ? null : Number(tutor.hourlyRate), + subjects: tutor.tutorSubjects.map((tutorSubject) => tutorSubject.subject), + rating: averageRating === null ? null : Math.round(averageRating * 10) / 10, + reviewCount: rating?.reviewCount ?? 0, + studentCount: studentsByTutor.get(tutor.id) ?? 0, + }; + }); + + const effectiveSort = sortBy ?? "rating"; + return items.sort(compareTutors(effectiveSort, order ?? DEFAULT_ORDER[effectiveSort])); + }, +}; From d8cfb7a67b7980dfa1e266f147499611fb43ed13 Mon Sep 17 00:00:00 2001 From: Kantinun Date: Sun, 11 Oct 2026 19:34:42 +0700 Subject: [PATCH 2/4] feat: add tutor card and filter components (#97) --- apps/web/src/features/tutors/tutor-card.tsx | 56 ++++++++ .../web/src/features/tutors/tutor-filters.tsx | 133 ++++++++++++++++++ apps/web/src/types/tutor.ts | 28 ++++ 3 files changed, 217 insertions(+) create mode 100644 apps/web/src/features/tutors/tutor-card.tsx create mode 100644 apps/web/src/features/tutors/tutor-filters.tsx create mode 100644 apps/web/src/types/tutor.ts diff --git a/apps/web/src/features/tutors/tutor-card.tsx b/apps/web/src/features/tutors/tutor-card.tsx new file mode 100644 index 0000000..638e47e --- /dev/null +++ b/apps/web/src/features/tutors/tutor-card.tsx @@ -0,0 +1,56 @@ +import { Star, Users } from "lucide-react"; + +import { ProfileAvatar } from "@/features/profile/profile-avatar"; +import { formatCurrency } from "@/features/profile/profile-details"; +import type { TutorListItem } from "@/types/tutor"; + +export function TutorCard({ tutor }: { tutor: TutorListItem }) { + return ( +
  • +
    + +
    +

    {tutor.name ?? "Tutor"}

    +

    + {tutor.hourlyRate === null + ? "Rate not set" + : `${formatCurrency(tutor.hourlyRate)} / hour`} +

    +
    +
    + + {tutor.bio &&

    {tutor.bio}

    } + + {tutor.subjects.length > 0 && ( +
      + {tutor.subjects.map((subject) => ( +
    • + {subject.name} +
    • + ))} +
    + )} + +
    + + + + +
    +
  • + ); +} diff --git a/apps/web/src/features/tutors/tutor-filters.tsx b/apps/web/src/features/tutors/tutor-filters.tsx new file mode 100644 index 0000000..08f2eae --- /dev/null +++ b/apps/web/src/features/tutors/tutor-filters.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { ArrowDownWideNarrow, ArrowUpNarrowWide, Search } from "lucide-react"; + +import { Input } from "@/components/ui/input"; +import { Select } from "@/components/ui/select"; +import type { Subject } from "@/types/subject"; +import type { SortOrder, TutorListQuery, TutorSortBy } from "@/types/tutor"; + +const SORT_OPTIONS: { label: string; value: TutorSortBy }[] = [ + { label: "Rating", value: "rating" }, + { label: "Price", value: "price" }, + { label: "Popularity", value: "popularity" }, +]; + +/** Direction each sort starts in when picked: best-rated, cheapest, most popular first. */ +export const DEFAULT_SORT_ORDER: Record = { + rating: "desc", + price: "asc", + popularity: "desc", +}; + +export interface TutorFiltersProps { + query: TutorListQuery; + subjects: Subject[]; + onChange: (patch: Partial) => void; + priceError?: string | null; +} + +export function TutorFilters({ query, subjects, onChange, priceError }: TutorFiltersProps) { + const isAscending = query.order === "asc"; + + return ( +
    +
    +
    + +
    + onChange({ minPrice: event.target.value })} + error={Boolean(priceError)} + wrapperClassName="w-full md:w-28" + /> + + onChange({ maxPrice: event.target.value })} + error={Boolean(priceError)} + wrapperClassName="w-full md:w-28" + /> +
    + + +
    +