From 18d906ff27d23d20e5bbd34d289a0754353e681f Mon Sep 17 00:00:00 2001 From: Alaa Date: Sun, 8 Mar 2026 16:12:06 +0100 Subject: [PATCH 1/4] Add teacher publish flow with pending review --- server/src/controllers/teacher.controller.ts | 59 +++++++++++++++++++ server/src/db/schemes/teacherSchema.ts | 1 + server/src/db/schemes/types/teacher.types.ts | 1 + .../commandRepositories/teacher.command.ts | 16 +++++ .../queryRepositories/teacher.query.ts | 11 ++-- server/src/routes/teacherRoute.ts | 10 ++++ server/src/scripts/seedTeachers.ts | 2 + .../src/services/teacher/teacher.service.ts | 57 ++++++++++++++++++ server/src/types/teacher/teacher.types.ts | 3 + server/src/utils/builders/user.builders.ts | 1 + server/src/utils/mappers/teacher.mapper.ts | 1 + .../teacherVisibilityValidationMiddleware.ts | 9 +++ 12 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 server/src/validation/profile/teacherVisibilityValidationMiddleware.ts diff --git a/server/src/controllers/teacher.controller.ts b/server/src/controllers/teacher.controller.ts index 7c1fda53..760c6005 100644 --- a/server/src/controllers/teacher.controller.ts +++ b/server/src/controllers/teacher.controller.ts @@ -16,6 +16,7 @@ import { TeacherOutputModel, UpdateTeacherProfileInput, QueryTeacherForModeratorInput, + UpdateTeacherVisibilityInput, } from "../types/teacher/teacher.types.js"; import { validateAuthorization } from "../utils/validation/requestValidation.util.js"; @@ -159,6 +160,16 @@ export class TeacherController { ); if (!updated) return res.sendStatus(404); + + const teacher = await this.teacherQuery.getTeacherById(teacherId); + // If teacher removes all schedule slots, auto-switch to private draft. + if (teacher && !this.isProfileComplete(teacher)) { + await this.teacherService.updateTeacherVisibility({ + teacherId, + isPublic: false, + }); + } + return res.status(200).json(updated); } catch (err) { return next(err); @@ -196,9 +207,57 @@ export class TeacherController { return res.status(404).json({ message: "Teacher not found" }); } + if (!this.isProfileComplete(updatedTeacher)) { + // If profile becomes incomplete, force private + draft. + await this.teacherService.updateTeacherVisibility({ + teacherId, + isPublic: false, + }); + + // Re-fetch to return the final persisted state (status/isPublic included). + const refreshedTeacher = + await this.teacherQuery.getTeacherById(teacherId); + if (!refreshedTeacher) { + return res.status(404).json({ message: "Teacher not found" }); + } + return res.status(200).json(refreshedTeacher); + } + + // Profile is complete, so the updated profile snapshot is already valid. return res.status(200).json(updatedTeacher); } catch (err) { return next(err); } } + + async updateMyVisibility( + req: RequestWithBody, + res: Response, + next: NextFunction, + ) { + try { + const teacherId = validateAuthorization(req.auth?.userId); + const { isPublic } = req.body; + // Teacher controls publish intent; service enforces completeness rules. + await this.teacherService.updateTeacherVisibility({ + teacherId, + isPublic, + }); + return res.sendStatus(204); + } catch (error) { + return next(error); + } + } + + private isProfileComplete(teacher: { + subjects: unknown[]; + availability: Record; + }) { + // complete profile means at least one subject and one schedule slot. + const hasSubjects = teacher.subjects.length > 0; + const hasSchedule = Object.values(teacher.availability).some( + (slots) => slots.length > 0, + ); + return hasSubjects && hasSchedule; + } } diff --git a/server/src/db/schemes/teacherSchema.ts b/server/src/db/schemes/teacherSchema.ts index 871f9ad7..e50c5a1f 100644 --- a/server/src/db/schemes/teacherSchema.ts +++ b/server/src/db/schemes/teacherSchema.ts @@ -91,6 +91,7 @@ export const TeacherSchema = new mongoose.Schema( role: { type: String, required: true }, authProvider: { type: String, required: true, default: "local" }, googleSub: { type: String, required: false, default: null }, + isPublic: { type: Boolean, default: false }, }, { versionKey: false, diff --git a/server/src/db/schemes/types/teacher.types.ts b/server/src/db/schemes/types/teacher.types.ts index f31a9c39..e1baa390 100644 --- a/server/src/db/schemes/types/teacher.types.ts +++ b/server/src/db/schemes/types/teacher.types.ts @@ -77,4 +77,5 @@ export type TeacherTypeDB = { authProvider: "local" | "google"; googleSub: string | null; status: TeacherStatus; + isPublic: boolean; }; diff --git a/server/src/repositories/commandRepositories/teacher.command.ts b/server/src/repositories/commandRepositories/teacher.command.ts index 2859bdf3..0737af3d 100644 --- a/server/src/repositories/commandRepositories/teacher.command.ts +++ b/server/src/repositories/commandRepositories/teacher.command.ts @@ -5,6 +5,7 @@ import { TeacherStatus, TeacherTypeDB, } from "../../db/schemes/types/teacher.types.js"; +import { NotFoundError } from "../../utils/error.util.js"; @injectable() export class TeacherCommand { @@ -127,4 +128,19 @@ export class TeacherCommand { throw new HttpError(500, "Password was not updated", { cause: error }); } } + + async updateTeacherVisibility( + id: string, + data: { isPublic: boolean; status: TeacherStatus }, + ): Promise { + // Keep visibility preference and effective public status in sync atomically. + const updated = await TeacherModel.updateOne( + { id }, + { $set: { isPublic: data.isPublic, status: data.status } }, + ); + + if (updated.matchedCount === 0) { + throw new NotFoundError("Teacher not found", { id }); + } + } } diff --git a/server/src/repositories/queryRepositories/teacher.query.ts b/server/src/repositories/queryRepositories/teacher.query.ts index 35675464..b6c44992 100644 --- a/server/src/repositories/queryRepositories/teacher.query.ts +++ b/server/src/repositories/queryRepositories/teacher.query.ts @@ -234,13 +234,12 @@ export class TeacherQuery { updateFields.profileImageUrl = updates.profileImageUrl; if (updates.education !== undefined) updateFields.education = updates.education; - if (updates.subjects !== undefined) + if (updates.subjects !== undefined) { updateFields.subjects = updates.subjects; - - if (updates.subjects) { - updateFields.priceFrom = Math.min( - ...updates.subjects.map((s) => s.hourlyRate), - ); + updateFields.priceFrom = + updates.subjects.length > 0 + ? Math.min(...updates.subjects.map((s) => s.hourlyRate)) + : 0; } const updatedTeacher = await TeacherModel.findOneAndUpdate( diff --git a/server/src/routes/teacherRoute.ts b/server/src/routes/teacherRoute.ts index a6008c3d..720e463d 100644 --- a/server/src/routes/teacherRoute.ts +++ b/server/src/routes/teacherRoute.ts @@ -11,6 +11,7 @@ import { validateWeekAvailabilityPayload, } from "../validation/availabilitySchedule/teacher/teacherScheduleValidationMiddleware.js"; import { teacherProfileUpdateValidationMiddleware } from "../validation/profile/profileValidationMiddleware.js"; +import { teacherVisibilityValidationMiddleware } from "../validation/profile/teacherVisibilityValidationMiddleware.js"; export const teacherRouter = Router(); const teacherController = container.get( @@ -81,3 +82,12 @@ teacherRouter.delete( requireSelf("id"), teacherController.deleteTeacher.bind(teacherController), ); + +teacherRouter.patch( + "/me/publish", + authMiddleware.handle, + requireRole("teacher"), + teacherVisibilityValidationMiddleware(), + errorMiddleware, + teacherController.updateMyVisibility.bind(teacherController), +); diff --git a/server/src/scripts/seedTeachers.ts b/server/src/scripts/seedTeachers.ts index 9fd60d3b..67e0c12a 100644 --- a/server/src/scripts/seedTeachers.ts +++ b/server/src/scripts/seedTeachers.ts @@ -134,6 +134,7 @@ const createTeacherDoc = async (index: number): Promise => { authProvider: "local", googleSub: null, status: "draft", + isPublic: false, }; }; @@ -179,6 +180,7 @@ export const seedTeachers = async () => { availability: teacher.availability, address: teacher.address, role: teacher.role, + isPublic: teacher.isPublic, }, $setOnInsert: { id: teacher.id, diff --git a/server/src/services/teacher/teacher.service.ts b/server/src/services/teacher/teacher.service.ts index cdfe21cd..2f7c3fb7 100644 --- a/server/src/services/teacher/teacher.service.ts +++ b/server/src/services/teacher/teacher.service.ts @@ -86,6 +86,7 @@ export class TeacherService { createdAt: new Date(), status: "draft", role, + isPublic: false, }; try { @@ -123,6 +124,62 @@ export class TeacherService { return await this.teacherCommand.updateTeacherStatus(id, status); } + async updateTeacherVisibility({ + teacherId, + isPublic, + }: { + teacherId: string; + isPublic: boolean; + }): Promise { + const teacher = await this.teacherQuery.getTeacherById(teacherId); + + if (!teacher) { + throw new NotFoundError("Teacher not found", { id: teacherId }); + } + + // Teacher cannot self-publish when moderated to blocked/rejected. + if ( + isPublic && + (teacher.status === "blocked" || teacher.status === "rejected") + ) { + throw new HttpError(403, "You cannot publish this profile"); + } + + // explicit unpublish: always make profile private and hidden from public list. + if (!isPublic) { + // Preserve moderated statuses; only active should move back to draft. + const nextStatus: TeacherStatus = + teacher.status === "blocked" || teacher.status === "rejected" + ? teacher.status + : "draft"; + + await this.teacherCommand.updateTeacherVisibility(teacherId, { + isPublic: false, + status: nextStatus, + }); + return; + } + + // publish is allowed only for complete profiles. + const hasSubjects = teacher.subjects.length > 0; + const hasSchedule = Object.values(teacher.availability).some( + (slots) => slots.length > 0, + ); + + if (!hasSubjects || !hasSchedule) { + throw new HttpError( + 400, + "Add at least 1 subject and 1 schedule slot before publishing", + ); + } + + // profile is complete and teacher requested publish. + await this.teacherCommand.updateTeacherVisibility(teacherId, { + isPublic: true, + status: "pending", + }); + } + async _generateHash(password: string, salt: string) { return await bcrypt.hash(password, salt); } diff --git a/server/src/types/teacher/teacher.types.ts b/server/src/types/teacher/teacher.types.ts index 877b697f..f0144988 100644 --- a/server/src/types/teacher/teacher.types.ts +++ b/server/src/types/teacher/teacher.types.ts @@ -70,6 +70,7 @@ export type TeacherViewType = { authProvider: "local" | "google"; googleSub: string | null; status: TeacherStatus; + isPublic: boolean; }; type SortDirection = "asc" | "desc"; @@ -119,3 +120,5 @@ export type UpdateTeacherProfileInput = { hourlyRate: number; }>; }; + +export type UpdateTeacherVisibilityInput = { isPublic: boolean }; diff --git a/server/src/utils/builders/user.builders.ts b/server/src/utils/builders/user.builders.ts index 14651832..b822cab8 100644 --- a/server/src/utils/builders/user.builders.ts +++ b/server/src/utils/builders/user.builders.ts @@ -76,5 +76,6 @@ export function buildGoogleTeacher(args: { authProvider: "google", googleSub: args.googleSub, status: "draft", + isPublic: false, }; } diff --git a/server/src/utils/mappers/teacher.mapper.ts b/server/src/utils/mappers/teacher.mapper.ts index bb0d99eb..007f8152 100644 --- a/server/src/utils/mappers/teacher.mapper.ts +++ b/server/src/utils/mappers/teacher.mapper.ts @@ -37,5 +37,6 @@ export const teacherMapper = ( authProvider: teacher.authProvider, googleSub: teacher.googleSub, status: teacher.status, + isPublic: teacher.isPublic, }; }; diff --git a/server/src/validation/profile/teacherVisibilityValidationMiddleware.ts b/server/src/validation/profile/teacherVisibilityValidationMiddleware.ts new file mode 100644 index 00000000..4d7eb5e1 --- /dev/null +++ b/server/src/validation/profile/teacherVisibilityValidationMiddleware.ts @@ -0,0 +1,9 @@ +import { body } from "express-validator"; + +export const teacherVisibilityValidationMiddleware = () => [ + body("isPublic") + .exists() + .withMessage("isPublic is required") + .isBoolean() + .withMessage("isPublic must be boolean"), +]; From 9e815097caff854ed419bf06b0ffd1013fc380b7 Mon Sep 17 00:00:00 2001 From: Alaa Date: Sun, 8 Mar 2026 21:34:56 +0100 Subject: [PATCH 2/4] Add teacher visibility toggle with pending-review publish flow --- client/src/api/teacher/teacher.api.ts | 4 ++ client/src/api/teacher/teacher.type.ts | 1 + .../mutations/useUpdateMyPublishMutation.ts | 27 ++++++++++++ .../teacherProfile/TeacherProfile.tsx | 41 +++++++++++++++++-- 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 client/src/features/teachers/mutations/useUpdateMyPublishMutation.ts diff --git a/client/src/api/teacher/teacher.api.ts b/client/src/api/teacher/teacher.api.ts index ff89df7b..ddfa1777 100644 --- a/client/src/api/teacher/teacher.api.ts +++ b/client/src/api/teacher/teacher.api.ts @@ -78,3 +78,7 @@ export async function updateMyProfileApi(data: UpdateTeacherProfileInput) { const res = await apiProtected.put("/api/teachers/me", data); return res.data; } + +export async function updateMyPublishApi(payload: { isPublic: boolean }) { + return apiProtected.patch("/api/teachers/me/publish", payload); +} diff --git a/client/src/api/teacher/teacher.type.ts b/client/src/api/teacher/teacher.type.ts index 6217e4e5..dafeb901 100644 --- a/client/src/api/teacher/teacher.type.ts +++ b/client/src/api/teacher/teacher.type.ts @@ -68,6 +68,7 @@ export type TeacherType = { createdAt: Date; status: TeacherStatus; role: Role; + isPublic: boolean; }; export type TeacherOutputModel = { diff --git a/client/src/features/teachers/mutations/useUpdateMyPublishMutation.ts b/client/src/features/teachers/mutations/useUpdateMyPublishMutation.ts new file mode 100644 index 00000000..a794aa86 --- /dev/null +++ b/client/src/features/teachers/mutations/useUpdateMyPublishMutation.ts @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { updateMyPublishApi } from "../../../api/teacher/teacher.api"; +import { queryKeys } from "../../queryKeys"; +import { useNotificationStore } from "../../../store/notification.store"; +import { getErrorMessage } from "../../../util/ErrorUtil"; + +export const useUpdateMyPublishMutation = () => { + const qc = useQueryClient(); + const success = useNotificationStore((s) => s.success); + const notifyError = useNotificationStore((s) => s.error); + + return useMutation({ + mutationFn: (payload: { isPublic: boolean }) => updateMyPublishApi(payload), + onSuccess: async (_data, variables) => { + await qc.invalidateQueries({ queryKey: queryKeys.teachers.myProfile() }); + await qc.invalidateQueries({ queryKey: ["teachers", "publicList"] }); + if (variables.isPublic) { + success( + "Sent for review. Your profile will be visible after approval.", + ); + return; + } + success("Profile is now not public."); + }, + onError: (error) => notifyError(getErrorMessage(error)), + }); +}; diff --git a/client/src/pages/privetTeachersPages/teacherProfile/TeacherProfile.tsx b/client/src/pages/privetTeachersPages/teacherProfile/TeacherProfile.tsx index b01acaa9..c47efb3f 100644 --- a/client/src/pages/privetTeachersPages/teacherProfile/TeacherProfile.tsx +++ b/client/src/pages/privetTeachersPages/teacherProfile/TeacherProfile.tsx @@ -19,6 +19,8 @@ import { } from "../../../api/teacher/teacher.api"; import { useMyProfileQuery } from "../../../features/teachers/query/useMyProfileQuery"; import { useUpdateMyProfileMutation } from "../../../features/teachers/mutations/useUpdateMyProfileMutation"; +import { useUpdateMyPublishMutation } from "../../../features/teachers/mutations/useUpdateMyPublishMutation"; + import { useRegularStudentsQuery } from "../../../features/appointments/query/useRegularStudentsQuery"; import { useModalStore } from "../../../store/modals.store"; import { queryKeys } from "../../../features/queryKeys"; @@ -38,6 +40,13 @@ export interface TimeSlot { export const TeacherProfile = () => { const { data: profile, isLoading } = useMyProfileQuery(); const updateProfileMutation = useUpdateMyProfileMutation(); + + const { mutate: updatePublish, isPending: isPublishPending } = + useUpdateMyPublishMutation(); + + const handlePublishToggle = (nextPublic: boolean) => + updatePublish({ isPublic: nextPublic }); + const { data: regularStudentsData } = useRegularStudentsQuery(); const openModal = useModalStore((s) => s.open); const queryClient = useQueryClient(); @@ -415,9 +424,35 @@ export const TeacherProfile = () => { return (
-

- My profile -

+
+

+ My profile +

+ +
From c9d9cac63e2cf5b71c24d164a9f5120727132004 Mon Sep 17 00:00:00 2001 From: Alaa Date: Mon, 9 Mar 2026 00:31:09 +0100 Subject: [PATCH 3/4] Exclude draft teachers from default moderator list query --- server/src/repositories/queryRepositories/teacher.query.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/src/repositories/queryRepositories/teacher.query.ts b/server/src/repositories/queryRepositories/teacher.query.ts index b6c44992..7fa8c6f2 100644 --- a/server/src/repositories/queryRepositories/teacher.query.ts +++ b/server/src/repositories/queryRepositories/teacher.query.ts @@ -61,6 +61,9 @@ export class TeacherQuery { const filter: Record = {}; if (status !== "all") { filter.status = status; + } else { + // Default moderator queue excludes drafts. + filter.status = { $ne: "draft" }; } const items = await TeacherModel.find(filter) @@ -69,7 +72,7 @@ export class TeacherQuery { .limit(+pageSize) .lean(); - const totalCount = await TeacherModel.countDocuments(); + const totalCount = await TeacherModel.countDocuments(filter); const pagesCount = Math.ceil(totalCount / +pageSize); From edeb178fc287cb454ceb273243cb66cee4e64b86 Mon Sep 17 00:00:00 2001 From: Alaa Date: Mon, 9 Mar 2026 00:41:11 +0100 Subject: [PATCH 4/4] Add teacher account status --- .../teacherProfile/TeacherProfile.tsx | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/client/src/pages/privetTeachersPages/teacherProfile/TeacherProfile.tsx b/client/src/pages/privetTeachersPages/teacherProfile/TeacherProfile.tsx index c47efb3f..8d2ad9a4 100644 --- a/client/src/pages/privetTeachersPages/teacherProfile/TeacherProfile.tsx +++ b/client/src/pages/privetTeachersPages/teacherProfile/TeacherProfile.tsx @@ -37,6 +37,30 @@ export interface TimeSlot { hour: number; } +const teacherStatusUi: Record = { + draft: { + label: "Draft", + className: "bg-yellow-500/20 text-yellow-300 border border-yellow-500/40", + }, + pending: { + label: "Pending Review", + className: "bg-purple-500/20 text-purple-300 border border-purple-500/40", + }, + active: { + label: "Approved", + className: + "bg-emerald-500/20 text-emerald-300 border border-emerald-500/40", + }, + rejected: { + label: "Rejected", + className: "bg-red-500/20 text-red-300 border border-red-500/40", + }, + blocked: { + label: "Blocked", + className: "bg-gray-500/20 text-gray-200 border border-gray-500/40", + }, +}; + export const TeacherProfile = () => { const { data: profile, isLoading } = useMyProfileQuery(); const updateProfileMutation = useUpdateMyProfileMutation(); @@ -453,6 +477,19 @@ export const TeacherProfile = () => { Public profile
+
+ + Account status:{" "} + {teacherStatusUi[profile?.status ?? "draft"]?.label ?? + profile?.status ?? + "Draft"} + +