From f107528d1d67e3924c2299aa7fd489ad5f1def82 Mon Sep 17 00:00:00 2001 From: Sumit Kumar Date: Thu, 6 Aug 2026 17:22:32 +0530 Subject: [PATCH 1/2] fix(sessions): wire SessionCard Join to session deep link Join was a dead button on dashboard session cards. Point it at /sessions?session=:id, select that session on load, and show Unavailable when no destination exists. Co-authored-by: Cursor --- src/components/SessionCard.test.tsx | 65 +++++++++++++++++++++ src/components/SessionCard.tsx | 90 +++++++++++++++++------------ src/hooks/useSessions.ts | 18 +++++- src/lib/sessionJoinPath.ts | 2 + src/pages/Dashboard.tsx | 39 ++++++++++--- src/pages/MentorDashboard.tsx | 5 +- src/pages/Sessions.tsx | 5 +- src/types/index.ts | 2 + 8 files changed, 177 insertions(+), 49 deletions(-) create mode 100644 src/components/SessionCard.test.tsx create mode 100644 src/lib/sessionJoinPath.ts diff --git a/src/components/SessionCard.test.tsx b/src/components/SessionCard.test.tsx new file mode 100644 index 00000000..77db2342 --- /dev/null +++ b/src/components/SessionCard.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, it, expect } from "vitest"; +import SessionCard from "./SessionCard"; +import { sessionJoinPath } from "@/lib/sessionJoinPath"; +import type { Session } from "@/types"; + +const baseSession: Session = { + id: "42", + peerId: "peer-1", + peerName: "Alex Mentor", + peerAvatar: "/avatar.png", + subject: "React hooks", + date: "8/10/2026", + time: "03:00 PM", + duration: 45, + status: "upcoming", +}; + +const renderCard = (session: Session, joinHref?: string | null) => + render( + + + + ); + +describe("sessionJoinPath", () => { + it("builds a sessions deep-link for the given id", () => { + expect(sessionJoinPath(42)).toBe("/sessions?session=42"); + expect(sessionJoinPath("abc/def")).toBe("/sessions?session=abc%2Fdef"); + }); +}); + +describe("SessionCard Join action", () => { + it("renders a Join link to the session destination", () => { + renderCard({ ...baseSession, joinHref: sessionJoinPath(42) }); + + const join = screen.getByRole("link", { name: "Join" }); + expect(join).toHaveAttribute("href", "/sessions?session=42"); + }); + + it("allows an explicit joinHref prop to override the session value", () => { + renderCard({ ...baseSession, joinHref: "/sessions?session=old" }, "/sessions?session=new"); + + expect(screen.getByRole("link", { name: "Join" })).toHaveAttribute( + "href", + "/sessions?session=new" + ); + }); + + it("disables Join when no destination is available", () => { + renderCard({ ...baseSession, joinHref: null }); + + const unavailable = screen.getByRole("button", { name: "Unavailable" }); + expect(unavailable).toBeDisabled(); + expect(screen.queryByRole("link", { name: "Join" })).not.toBeInTheDocument(); + }); + + it("hides Join for completed sessions", () => { + renderCard({ ...baseSession, status: "completed", rating: 5, joinHref: sessionJoinPath(42) }); + + expect(screen.queryByRole("link", { name: "Join" })).not.toBeInTheDocument(); + expect(screen.getByText("5/5")).toBeInTheDocument(); + }); +}); diff --git a/src/components/SessionCard.tsx b/src/components/SessionCard.tsx index 747b5a63..94f4d6e1 100644 --- a/src/components/SessionCard.tsx +++ b/src/components/SessionCard.tsx @@ -1,4 +1,5 @@ import { Calendar, Clock, CheckCircle2 } from "lucide-react"; +import { Link } from "react-router-dom"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import type { Session } from "@/types"; @@ -9,44 +10,59 @@ const statusStyles = { cancelled: "bg-destructive/10 text-destructive", }; -const SessionCard = ({ session }: { session: Session }) => ( -
- {session.peerName} -
-

- {session.subject} -

-

with {session.peerName}

-
- - - {session.date} - - - - {session.time} - - {session.duration} min +type SessionCardProps = { + session: Session; + /** Overrides `session.joinHref` when provided (including explicit null). */ + joinHref?: string | null; +}; + +const SessionCard = ({ session, joinHref }: SessionCardProps) => { + const destination = joinHref !== undefined ? joinHref : session.joinHref ?? null; + + return ( +
+ {session.peerName} +
+

+ {session.subject} +

+

with {session.peerName}

+
+ + + {session.date} + + + + {session.time} + + {session.duration} min +
+
+
+ {session.status} + {session.status === "upcoming" && + (destination ? ( + + ) : ( + + ))} + {session.status === "completed" && session.rating && ( + + {session.rating}/5 + + )}
-
- {session.status} - {session.status === "upcoming" && ( - - )} - {session.status === "completed" && session.rating && ( - - {session.rating}/5 - - )} -
-
-); + ); +}; export default SessionCard; diff --git a/src/hooks/useSessions.ts b/src/hooks/useSessions.ts index f3286825..08ef2ef3 100644 --- a/src/hooks/useSessions.ts +++ b/src/hooks/useSessions.ts @@ -11,7 +11,7 @@ const TAB_TO_STATUS: Record = { Completed: ["ended"], }; -export function useSessions(user: any) { +export function useSessions(user: any, deepLinkSessionId?: string | null) { const { mutate: awardXP } = useAwardXP(); const { toast } = useToast(); @@ -59,13 +59,25 @@ export function useSessions(user: any) { if (!error && data) { setSessions(data); if (data.length > 0) { - setSelectedSession(data[0]); + const deepLinked = deepLinkSessionId + ? data.find((s) => String(s.id) === String(deepLinkSessionId)) + : null; + + if (deepLinked) { + const status = deepLinked.status?.toLowerCase(); + if (status === "live") setSelectedTab("Joined"); + else if (status === "ended" || status === "completed") setSelectedTab("Completed"); + else setSelectedTab("Upcoming"); + setSelectedSession(deepLinked); + } else { + setSelectedSession(data[0]); + } } } }; fetchSessions(); - }, []); + }, [deepLinkSessionId]); const filteredSessions = useMemo(() => { let filtered = sessions; diff --git a/src/lib/sessionJoinPath.ts b/src/lib/sessionJoinPath.ts new file mode 100644 index 00000000..352c7999 --- /dev/null +++ b/src/lib/sessionJoinPath.ts @@ -0,0 +1,2 @@ +export const sessionJoinPath = (sessionId: string | number): string => + `/sessions?session=${encodeURIComponent(String(sessionId))}`; diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 9a49c9bd..83976e53 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -9,6 +9,8 @@ import { useAuth } from "@/contexts/useAuth"; import { useRole } from "@/contexts/RoleContext"; import { supabase } from "@/integrations/supabase/client"; import { API_BASE_URL } from "@/config/api"; +import { sessionJoinPath } from "@/lib/sessionJoinPath"; +import type { Session as SessionCardModel } from "@/types"; const AnalyticsCharts = lazy(() => import("@/components/AnalyticsCharts")); interface Profile { @@ -32,12 +34,35 @@ interface Profile { timezone: string | null; focus_time_this_week: number | null; } -interface Session { - id: string; - status: string; - title?: string; - date?: string; -} + +const toDashboardSessionCard = (row: { + id: string | number; + title?: string | null; + scheduled_at?: string | null; + duration_minutes?: number | null; + status?: string | null; + mentor_id?: string | null; + student_id?: string | null; +}): SessionCardModel => { + const scheduledAt = row.scheduled_at ? new Date(row.scheduled_at) : null; + const id = String(row.id); + + return { + id, + peerId: row.mentor_id || row.student_id || "", + peerName: "Peer", + peerAvatar: "/placeholder.svg", + subject: row.title || "Session", + date: scheduledAt ? scheduledAt.toLocaleDateString() : "Not scheduled", + time: scheduledAt + ? scheduledAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + : "", + duration: row.duration_minutes ?? 60, + status: + row.status === "ended" || row.status === "completed" ? "completed" : "upcoming", + joinHref: row.id != null && row.id !== "" ? sessionJoinPath(row.id) : null, + }; +}; const Clock = () => { const [currentTime, setCurrentTime] = useState(new Date()); @@ -466,7 +491,7 @@ const Dashboard = () => { {upcomingSessions.length > 0 ? ( upcomingSessions.map((s) => ( - + )) ) : (

diff --git a/src/pages/MentorDashboard.tsx b/src/pages/MentorDashboard.tsx index 9bd9dc29..dbdf0b3c 100644 --- a/src/pages/MentorDashboard.tsx +++ b/src/pages/MentorDashboard.tsx @@ -6,6 +6,7 @@ import { Link } from "react-router-dom"; import { supabase } from "@/integrations/supabase/client"; import SessionCard from "@/components/SessionCard"; import { MentorshipMilestones } from "@/components/mentorship/MentorshipMilestones"; +import { sessionJoinPath } from "@/lib/sessionJoinPath"; import type { Session } from "@/types"; type MentorSessionRow = { @@ -25,9 +26,10 @@ type MentorProfile = { const toSessionCardModel = (session: MentorSessionRow): Session => { const scheduledAt = session.scheduled_at ? new Date(session.scheduled_at) : null; + const id = String(session.id); return { - id: String(session.id), + id, peerId: "", peerName: "Learner", peerAvatar: "/placeholder.svg", @@ -36,6 +38,7 @@ const toSessionCardModel = (session: MentorSessionRow): Session => { time: scheduledAt ? scheduledAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "", duration: session.duration_minutes ?? 60, status: session.status === "ended" || session.status === "completed" ? "completed" : "upcoming", + joinHref: session.id != null ? sessionJoinPath(session.id) : null, }; }; diff --git a/src/pages/Sessions.tsx b/src/pages/Sessions.tsx index ab525f5f..efdee30a 100644 --- a/src/pages/Sessions.tsx +++ b/src/pages/Sessions.tsx @@ -1,4 +1,5 @@ import { Flame } from "lucide-react"; +import { useSearchParams } from "react-router-dom"; import { useAuth } from "@/contexts/useAuth"; import { useSessions } from "@/hooks/useSessions"; import { SessionFilters } from "@/components/sessions/SessionFilters"; @@ -10,6 +11,8 @@ const tabs = ["Upcoming", "Joined", "Completed"]; export default function Sessions() { const { user } = useAuth(); + const [searchParams] = useSearchParams(); + const deepLinkSessionId = searchParams.get("session"); const { filteredSessions, @@ -36,7 +39,7 @@ export default function Sessions() { handleLeaveVideo, handleJoinVideo, togglePinMessage, - } = useSessions(user); + } = useSessions(user, deepLinkSessionId); return (

diff --git a/src/types/index.ts b/src/types/index.ts index 37f1140a..b7746c59 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,6 +25,8 @@ export interface Session { duration: number; status: "upcoming" | "completed" | "cancelled"; rating?: number; + /** Destination for the Join action; omit or null when the session cannot be joined. */ + joinHref?: string | null; } export interface Message { From 48da313b6b9ee05ff954e54adaa6f2b8995fcc9a 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)