Skip to content

Commit 01a4937

Browse files
authored
Merge pull request #156 from HackYourFutureProjects/feat/teacher-public-visibility-toggle
Feat/teacher public visibility toggle
2 parents c710b95 + edeb178 commit 01a4937

16 files changed

Lines changed: 276 additions & 10 deletions

File tree

client/src/api/teacher/teacher.api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,7 @@ export async function updateMyProfileApi(data: UpdateTeacherProfileInput) {
7878
const res = await apiProtected.put<TeacherType>("/api/teachers/me", data);
7979
return res.data;
8080
}
81+
82+
export async function updateMyPublishApi(payload: { isPublic: boolean }) {
83+
return apiProtected.patch("/api/teachers/me/publish", payload);
84+
}

client/src/api/teacher/teacher.type.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export type TeacherType = {
6868
createdAt: Date;
6969
status: TeacherStatus;
7070
role: Role;
71+
isPublic: boolean;
7172
};
7273

7374
export type TeacherOutputModel = {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
2+
import { updateMyPublishApi } from "../../../api/teacher/teacher.api";
3+
import { queryKeys } from "../../queryKeys";
4+
import { useNotificationStore } from "../../../store/notification.store";
5+
import { getErrorMessage } from "../../../util/ErrorUtil";
6+
7+
export const useUpdateMyPublishMutation = () => {
8+
const qc = useQueryClient();
9+
const success = useNotificationStore((s) => s.success);
10+
const notifyError = useNotificationStore((s) => s.error);
11+
12+
return useMutation({
13+
mutationFn: (payload: { isPublic: boolean }) => updateMyPublishApi(payload),
14+
onSuccess: async (_data, variables) => {
15+
await qc.invalidateQueries({ queryKey: queryKeys.teachers.myProfile() });
16+
await qc.invalidateQueries({ queryKey: ["teachers", "publicList"] });
17+
if (variables.isPublic) {
18+
success(
19+
"Sent for review. Your profile will be visible after approval.",
20+
);
21+
return;
22+
}
23+
success("Profile is now not public.");
24+
},
25+
onError: (error) => notifyError(getErrorMessage(error)),
26+
});
27+
};

client/src/pages/privetTeachersPages/teacherProfile/TeacherProfile.tsx

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
} from "../../../api/teacher/teacher.api";
2020
import { useMyProfileQuery } from "../../../features/teachers/query/useMyProfileQuery";
2121
import { useUpdateMyProfileMutation } from "../../../features/teachers/mutations/useUpdateMyProfileMutation";
22+
import { useUpdateMyPublishMutation } from "../../../features/teachers/mutations/useUpdateMyPublishMutation";
23+
2224
import { useRegularStudentsQuery } from "../../../features/appointments/query/useRegularStudentsQuery";
2325
import { useModalStore } from "../../../store/modals.store";
2426
import { queryKeys } from "../../../features/queryKeys";
@@ -35,9 +37,40 @@ export interface TimeSlot {
3537
hour: number;
3638
}
3739

40+
const teacherStatusUi: Record<string, { label: string; className: string }> = {
41+
draft: {
42+
label: "Draft",
43+
className: "bg-yellow-500/20 text-yellow-300 border border-yellow-500/40",
44+
},
45+
pending: {
46+
label: "Pending Review",
47+
className: "bg-purple-500/20 text-purple-300 border border-purple-500/40",
48+
},
49+
active: {
50+
label: "Approved",
51+
className:
52+
"bg-emerald-500/20 text-emerald-300 border border-emerald-500/40",
53+
},
54+
rejected: {
55+
label: "Rejected",
56+
className: "bg-red-500/20 text-red-300 border border-red-500/40",
57+
},
58+
blocked: {
59+
label: "Blocked",
60+
className: "bg-gray-500/20 text-gray-200 border border-gray-500/40",
61+
},
62+
};
63+
3864
export const TeacherProfile = () => {
3965
const { data: profile, isLoading } = useMyProfileQuery();
4066
const updateProfileMutation = useUpdateMyProfileMutation();
67+
68+
const { mutate: updatePublish, isPending: isPublishPending } =
69+
useUpdateMyPublishMutation();
70+
71+
const handlePublishToggle = (nextPublic: boolean) =>
72+
updatePublish({ isPublic: nextPublic });
73+
4174
const { data: regularStudentsData } = useRegularStudentsQuery();
4275
const openModal = useModalStore((s) => s.open);
4376
const queryClient = useQueryClient();
@@ -415,9 +448,48 @@ export const TeacherProfile = () => {
415448
return (
416449
<div className="px-4 sm:px-6 lg:px-10 flex flex-col">
417450
<div className="pt-6 sm:pt-8 lg:pt-10 flex-1">
418-
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold bg-gradient-to-r from-[#7C86F7] to-[#E879F9] bg-clip-text text-transparent mb-8 sm:mb-10 lg:mb-12">
419-
My profile
420-
</h1>
451+
<div className="mb-8 sm:mb-10 lg:mb-12 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
452+
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold bg-gradient-to-r from-[#7C86F7] to-[#E879F9] bg-clip-text text-transparent">
453+
My profile
454+
</h1>
455+
<label
456+
htmlFor="teacher-visibility-toggle"
457+
className={`flex items-center gap-3 ${
458+
isPublishPending ? "opacity-70" : ""
459+
}`}
460+
>
461+
<span className="text-red-400 text-sm">Private profile</span>
462+
<input
463+
id="teacher-visibility-toggle"
464+
type="checkbox"
465+
className="peer sr-only"
466+
checked={Boolean(profile?.isPublic)}
467+
disabled={isPublishPending}
468+
onChange={(e) => handlePublishToggle(e.target.checked)}
469+
/>
470+
<span
471+
className="relative h-7 w-14 cursor-pointer rounded-full bg-red-500 transition-colors duration-200
472+
peer-checked:bg-green-500 peer-disabled:cursor-not-allowed
473+
after:absolute after:left-1 after:top-1 after:h-5 after:w-5 after:rounded-full after:bg-white after:shadow
474+
after:transition-transform after:duration-200 peer-checked:after:translate-x-7"
475+
aria-hidden="true"
476+
/>
477+
<span className="text-green-400 text-sm">Public profile</span>
478+
</label>
479+
</div>
480+
<div className="mb-6 sm:mb-8">
481+
<span
482+
className={`inline-flex items-center rounded-full px-3 py-1 text-xs sm:text-sm font-medium ${
483+
teacherStatusUi[profile?.status ?? "draft"]?.className ??
484+
"bg-light-500/20 text-light-100 border border-light-500/40"
485+
}`}
486+
>
487+
Account status:{" "}
488+
{teacherStatusUi[profile?.status ?? "draft"]?.label ??
489+
profile?.status ??
490+
"Draft"}
491+
</span>
492+
</div>
421493
<div className="flex flex-col lg:flex-row gap-6 lg:gap-12">
422494
<ProfileAvatar avatarUrl={profile?.profileImageUrl} />
423495
<div className="flex-1 space-y-4 sm:space-y-6">

server/src/controllers/teacher.controller.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
TeacherOutputModel,
1717
UpdateTeacherProfileInput,
1818
QueryTeacherForModeratorInput,
19+
UpdateTeacherVisibilityInput,
1920
} from "../types/teacher/teacher.types.js";
2021
import { validateAuthorization } from "../utils/validation/requestValidation.util.js";
2122

@@ -159,6 +160,16 @@ export class TeacherController {
159160
);
160161

161162
if (!updated) return res.sendStatus(404);
163+
164+
const teacher = await this.teacherQuery.getTeacherById(teacherId);
165+
// If teacher removes all schedule slots, auto-switch to private draft.
166+
if (teacher && !this.isProfileComplete(teacher)) {
167+
await this.teacherService.updateTeacherVisibility({
168+
teacherId,
169+
isPublic: false,
170+
});
171+
}
172+
162173
return res.status(200).json(updated);
163174
} catch (err) {
164175
return next(err);
@@ -196,9 +207,57 @@ export class TeacherController {
196207
return res.status(404).json({ message: "Teacher not found" });
197208
}
198209

210+
if (!this.isProfileComplete(updatedTeacher)) {
211+
// If profile becomes incomplete, force private + draft.
212+
await this.teacherService.updateTeacherVisibility({
213+
teacherId,
214+
isPublic: false,
215+
});
216+
217+
// Re-fetch to return the final persisted state (status/isPublic included).
218+
const refreshedTeacher =
219+
await this.teacherQuery.getTeacherById(teacherId);
220+
if (!refreshedTeacher) {
221+
return res.status(404).json({ message: "Teacher not found" });
222+
}
223+
return res.status(200).json(refreshedTeacher);
224+
}
225+
226+
// Profile is complete, so the updated profile snapshot is already valid.
199227
return res.status(200).json(updatedTeacher);
200228
} catch (err) {
201229
return next(err);
202230
}
203231
}
232+
233+
async updateMyVisibility(
234+
req: RequestWithBody<UpdateTeacherVisibilityInput>,
235+
res: Response,
236+
next: NextFunction,
237+
) {
238+
try {
239+
const teacherId = validateAuthorization(req.auth?.userId);
240+
const { isPublic } = req.body;
241+
// Teacher controls publish intent; service enforces completeness rules.
242+
await this.teacherService.updateTeacherVisibility({
243+
teacherId,
244+
isPublic,
245+
});
246+
return res.sendStatus(204);
247+
} catch (error) {
248+
return next(error);
249+
}
250+
}
251+
252+
private isProfileComplete(teacher: {
253+
subjects: unknown[];
254+
availability: Record<string, { start: string; end: string }[]>;
255+
}) {
256+
// complete profile means at least one subject and one schedule slot.
257+
const hasSubjects = teacher.subjects.length > 0;
258+
const hasSchedule = Object.values(teacher.availability).some(
259+
(slots) => slots.length > 0,
260+
);
261+
return hasSubjects && hasSchedule;
262+
}
204263
}

server/src/db/schemes/teacherSchema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export const TeacherSchema = new mongoose.Schema<TeacherTypeDB>(
9191
role: { type: String, required: true },
9292
authProvider: { type: String, required: true, default: "local" },
9393
googleSub: { type: String, required: false, default: null },
94+
isPublic: { type: Boolean, default: false },
9495
},
9596
{
9697
versionKey: false,

server/src/db/schemes/types/teacher.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,5 @@ export type TeacherTypeDB = {
7777
authProvider: "local" | "google";
7878
googleSub: string | null;
7979
status: TeacherStatus;
80+
isPublic: boolean;
8081
};

server/src/repositories/commandRepositories/teacher.command.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
TeacherStatus,
66
TeacherTypeDB,
77
} from "../../db/schemes/types/teacher.types.js";
8+
import { NotFoundError } from "../../utils/error.util.js";
89

910
@injectable()
1011
export class TeacherCommand {
@@ -127,4 +128,19 @@ export class TeacherCommand {
127128
throw new HttpError(500, "Password was not updated", { cause: error });
128129
}
129130
}
131+
132+
async updateTeacherVisibility(
133+
id: string,
134+
data: { isPublic: boolean; status: TeacherStatus },
135+
): Promise<void> {
136+
// Keep visibility preference and effective public status in sync atomically.
137+
const updated = await TeacherModel.updateOne(
138+
{ id },
139+
{ $set: { isPublic: data.isPublic, status: data.status } },
140+
);
141+
142+
if (updated.matchedCount === 0) {
143+
throw new NotFoundError("Teacher not found", { id });
144+
}
145+
}
130146
}

server/src/repositories/queryRepositories/teacher.query.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export class TeacherQuery {
6161
const filter: Record<string, unknown> = {};
6262
if (status !== "all") {
6363
filter.status = status;
64+
} else {
65+
// Default moderator queue excludes drafts.
66+
filter.status = { $ne: "draft" };
6467
}
6568

6669
const items = await TeacherModel.find(filter)
@@ -69,7 +72,7 @@ export class TeacherQuery {
6972
.limit(+pageSize)
7073
.lean();
7174

72-
const totalCount = await TeacherModel.countDocuments();
75+
const totalCount = await TeacherModel.countDocuments(filter);
7376

7477
const pagesCount = Math.ceil(totalCount / +pageSize);
7578

@@ -234,13 +237,12 @@ export class TeacherQuery {
234237
updateFields.profileImageUrl = updates.profileImageUrl;
235238
if (updates.education !== undefined)
236239
updateFields.education = updates.education;
237-
if (updates.subjects !== undefined)
240+
if (updates.subjects !== undefined) {
238241
updateFields.subjects = updates.subjects;
239-
240-
if (updates.subjects) {
241-
updateFields.priceFrom = Math.min(
242-
...updates.subjects.map((s) => s.hourlyRate),
243-
);
242+
updateFields.priceFrom =
243+
updates.subjects.length > 0
244+
? Math.min(...updates.subjects.map((s) => s.hourlyRate))
245+
: 0;
244246
}
245247

246248
const updatedTeacher = await TeacherModel.findOneAndUpdate(

server/src/routes/teacherRoute.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
validateWeekAvailabilityPayload,
1212
} from "../validation/availabilitySchedule/teacher/teacherScheduleValidationMiddleware.js";
1313
import { teacherProfileUpdateValidationMiddleware } from "../validation/profile/profileValidationMiddleware.js";
14+
import { teacherVisibilityValidationMiddleware } from "../validation/profile/teacherVisibilityValidationMiddleware.js";
1415

1516
export const teacherRouter = Router();
1617
const teacherController = container.get<TeacherController>(
@@ -81,3 +82,12 @@ teacherRouter.delete(
8182
requireSelf("id"),
8283
teacherController.deleteTeacher.bind(teacherController),
8384
);
85+
86+
teacherRouter.patch(
87+
"/me/publish",
88+
authMiddleware.handle,
89+
requireRole("teacher"),
90+
teacherVisibilityValidationMiddleware(),
91+
errorMiddleware,
92+
teacherController.updateMyVisibility.bind(teacherController),
93+
);

0 commit comments

Comments
 (0)