From a6874e9da9434beab42b6b30088a1322679248d8 Mon Sep 17 00:00:00 2001 From: Sumit Kumar Date: Thu, 6 Aug 2026 17:18:44 +0530 Subject: [PATCH 1/2] fix(mentor-dashboard): show booked learner on session cards Include student_id and the related profile in the upcoming sessions query so cards no longer hardcode the same Learner placeholder. Co-authored-by: Cursor --- src/pages/MentorDashboard.tsx | 37 +++----------- src/pages/mentorSessionCard.test.ts | 77 +++++++++++++++++++++++++++++ src/pages/mentorSessionCard.ts | 46 +++++++++++++++++ 3 files changed, 131 insertions(+), 29 deletions(-) create mode 100644 src/pages/mentorSessionCard.test.ts create mode 100644 src/pages/mentorSessionCard.ts diff --git a/src/pages/MentorDashboard.tsx b/src/pages/MentorDashboard.tsx index 9bd9dc29..d974ee6e 100644 --- a/src/pages/MentorDashboard.tsx +++ b/src/pages/MentorDashboard.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { useEffect, useState } from "react"; import { useRole } from "@/contexts/RoleContext"; import { useAuth } from "@/contexts/useAuth"; @@ -7,14 +6,7 @@ import { supabase } from "@/integrations/supabase/client"; import SessionCard from "@/components/SessionCard"; import { MentorshipMilestones } from "@/components/mentorship/MentorshipMilestones"; import type { Session } from "@/types"; - -type MentorSessionRow = { - id: number; - title: string | null; - scheduled_at: string | null; - duration_minutes: number | null; - status: string | null; -}; +import { toSessionCardModel, type MentorSessionRow } from "./mentorSessionCard"; type MentorProfile = { name: string | null; @@ -23,22 +15,6 @@ type MentorProfile = { rating: number | null; }; -const toSessionCardModel = (session: MentorSessionRow): Session => { - const scheduledAt = session.scheduled_at ? new Date(session.scheduled_at) : null; - - return { - id: String(session.id), - peerId: "", - peerName: "Learner", - peerAvatar: "/placeholder.svg", - subject: session.title || "Mentorship session", - date: scheduledAt ? scheduledAt.toLocaleDateString() : "Not scheduled", - time: scheduledAt ? scheduledAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "", - duration: session.duration_minutes ?? 60, - status: session.status === "ended" || session.status === "completed" ? "completed" : "upcoming", - }; -}; - const MentorDashboard = () => { const { user, loading } = useAuth(); const { currentMode } = useRole(); @@ -69,16 +45,20 @@ const MentorDashboard = () => { setProfile(profileData); } - // Fetch Upcoming Mentor Sessions + // Fetch Upcoming Mentor Sessions with booked learner profiles const { data: sessionData } = await supabase .from("sessions") - .select("id,title,scheduled_at,duration_minutes,status") + .select( + "id,title,scheduled_at,duration_minutes,status,student_id, student:profiles!student_id(id, name, avatar_url)" + ) .eq("status", "scheduled") .eq("mentor_id", user.id) .limit(4); if (sessionData) { - setUpcomingSessions(sessionData.map(toSessionCardModel)); + setUpcomingSessions( + (sessionData as MentorSessionRow[]).map(toSessionCardModel) + ); } } catch (err) { console.error("Failed to fetch mentor dashboard data", err); @@ -158,4 +138,3 @@ const MentorDashboard = () => { }; export default MentorDashboard; - diff --git a/src/pages/mentorSessionCard.test.ts b/src/pages/mentorSessionCard.test.ts new file mode 100644 index 00000000..04aa19ba --- /dev/null +++ b/src/pages/mentorSessionCard.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import { toSessionCardModel } from "./mentorSessionCard"; + +describe("toSessionCardModel", () => { + it("maps distinct learner profiles onto different session cards", () => { + const alice = toSessionCardModel({ + id: 1, + title: "React fundamentals", + scheduled_at: "2026-08-10T15:00:00.000Z", + duration_minutes: 45, + status: "scheduled", + student_id: "learner-alice", + student: { + id: "learner-alice", + name: "Alice Chen", + avatar_url: "https://cdn.example/alice.png", + }, + }); + + const bob = toSessionCardModel({ + id: 2, + title: "System design", + scheduled_at: "2026-08-11T16:00:00.000Z", + duration_minutes: 60, + status: "scheduled", + student_id: "learner-bob", + student: { + id: "learner-bob", + name: "Bob Patel", + avatar_url: "https://cdn.example/bob.png", + }, + }); + + expect(alice.peerId).toBe("learner-alice"); + expect(alice.peerName).toBe("Alice Chen"); + expect(alice.peerAvatar).toBe("https://cdn.example/alice.png"); + + expect(bob.peerId).toBe("learner-bob"); + expect(bob.peerName).toBe("Bob Patel"); + expect(bob.peerAvatar).toBe("https://cdn.example/bob.png"); + + expect(alice.peerName).not.toBe(bob.peerName); + }); + + it("falls back to placeholder learner details when the profile is missing", () => { + const session = toSessionCardModel({ + id: 3, + title: null, + scheduled_at: null, + duration_minutes: null, + status: "scheduled", + student_id: null, + student: null, + }); + + expect(session.peerId).toBe(""); + expect(session.peerName).toBe("Learner"); + expect(session.peerAvatar).toBe("/placeholder.svg"); + expect(session.subject).toBe("Mentorship session"); + }); + + it("uses student_id when the nested profile relation is empty", () => { + const session = toSessionCardModel({ + id: 4, + title: "Algorithms", + scheduled_at: "2026-08-12T12:00:00.000Z", + duration_minutes: 30, + status: "scheduled", + student_id: "orphan-learner", + student: null, + }); + + expect(session.peerId).toBe("orphan-learner"); + expect(session.peerName).toBe("Learner"); + expect(session.peerAvatar).toBe("/placeholder.svg"); + }); +}); diff --git a/src/pages/mentorSessionCard.ts b/src/pages/mentorSessionCard.ts new file mode 100644 index 00000000..ac9eb0cb --- /dev/null +++ b/src/pages/mentorSessionCard.ts @@ -0,0 +1,46 @@ +import type { Session } from "@/types"; + +type LearnerProfile = { + id: string; + name: string | null; + avatar_url: string | null; +}; + +export type MentorSessionRow = { + id: number; + title: string | null; + scheduled_at: string | null; + duration_minutes: number | null; + status: string | null; + student_id: string | null; + student: LearnerProfile | LearnerProfile[] | null; +}; + +const resolveLearner = (session: MentorSessionRow): LearnerProfile | null => { + if (Array.isArray(session.student)) { + return session.student[0] ?? null; + } + return session.student ?? null; +}; + +export const toSessionCardModel = (session: MentorSessionRow): Session => { + const scheduledAt = session.scheduled_at ? new Date(session.scheduled_at) : null; + const learner = resolveLearner(session); + + return { + id: String(session.id), + peerId: learner?.id ?? session.student_id ?? "", + peerName: learner?.name?.trim() || "Learner", + peerAvatar: learner?.avatar_url || "/placeholder.svg", + subject: session.title || "Mentorship session", + date: scheduledAt ? scheduledAt.toLocaleDateString() : "Not scheduled", + time: scheduledAt + ? scheduledAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + : "", + duration: session.duration_minutes ?? 60, + status: + session.status === "ended" || session.status === "completed" + ? "completed" + : "upcoming", + }; +}; From 96aff309042a7ee61cd79cc3296d96c57641f9cc Mon Sep 17 00:00:00 2001 From: Sumit Kumar Date: Sat, 8 Aug 2026 03:20:03 +0530 Subject: [PATCH 2/2] fix(ci): align upload tests and docs with current upload API Update profile-photo assertions for Supabase storage paths and the 2MB limit, and document /api/upload plus /api/users/upload-photo so docs completeness checks pass. Co-authored-by: Cursor --- backend/tests/uploadPhoto.test.js | 11 +++++----- docs/api.md | 34 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/backend/tests/uploadPhoto.test.js b/backend/tests/uploadPhoto.test.js index 0fa23380..4936b5df 100644 --- a/backend/tests/uploadPhoto.test.js +++ b/backend/tests/uploadPhoto.test.js @@ -166,8 +166,9 @@ describe("POST /api/users/upload-photo", () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); - // Filename must contain the authenticated user's ID - expect(res.body.fileUrl).toMatch(new RegExp(`profile-${TEST_USER_ID}-`)); + // Supabase storage path is scoped to the authenticated user's ID + expect(res.body.fileUrl).toContain(`${TEST_USER_ID}/`); + expect(storageUploadMock).toHaveBeenCalled(); }); it("returns 200 when a valid JWT is supplied via HttpOnly cookie", async () => { @@ -221,9 +222,9 @@ describe("POST /api/users/upload-photo", () => { expect(res.body.error).toMatch(/no file/i); }); - it("returns 413 when the uploaded file exceeds the 5MB size limit", async () => { + it("returns 413 when the uploaded file exceeds the 2MB size limit", async () => { const token = makeToken(); - const oversized = Buffer.alloc(6 * 1024 * 1024, 0xff); // 6 MB of 0xFF bytes + const oversized = Buffer.alloc(3 * 1024 * 1024, 0xff); // 3 MB of 0xFF bytes const res = await request(app) .post("/api/users/upload-photo") .set("Authorization", `Bearer ${token}`) @@ -233,7 +234,7 @@ describe("POST /api/users/upload-photo", () => { }); expect(res.status).toBe(413); - expect(res.body.error).toMatch(/5mb/i); + expect(res.body.error).toMatch(/2mb/i); }); }); diff --git a/docs/api.md b/docs/api.md index d8cda768..00c71030 100644 --- a/docs/api.md +++ b/docs/api.md @@ -130,3 +130,37 @@ Sends a browser push notification to all subscribed devices for a given `user_id ``` **Security**: Standard users may only send push notifications to themselves (IDOR prevention). Webhook callers authenticated via `WEBHOOK_SECRET` may send to any user. + +## File Upload Routes + +Authenticated multipart uploads are written to Supabase Storage. Storage paths are generated on the server from the caller's user id — clients cannot choose arbitrary object keys. + +### `POST /api/upload` + +General-purpose upload for `avatars`, `profiles`, and `resources` buckets. + +**Auth**: valid Supabase JWT (`Authorization` header or `access_token` cookie) + +**Form fields**: +- `folder`: one of `avatars`, `profiles`, `resources` +- `file`: the file to upload + +**Validation**: +- MIME type must match the destination folder allow-list +- Magic byte / content-type verification rejects spoofed uploads +- Binary content and null bytes are rejected for text resource uploads + +### `POST /api/users/upload-photo` + +Profile-photo upload into the `profiles` bucket (2MB limit). + +**Auth**: valid Supabase JWT (`Authorization` header or `access_token` cookie) + +**Form fields**: +- `profilePhoto`: JPEG, PNG, WebP, or GIF image + +**Validation**: +- 2MB size limit +- Strict image MIME allow-list +- Magic byte verification that file content matches the declared image type +- Per-user rate limit (10 uploads per hour)