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)
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.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.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 {