Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions backend/tests/uploadPhoto.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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}`)
Expand All @@ -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);
});
});

Expand Down
34 changes: 34 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
65 changes: 65 additions & 0 deletions src/components/SessionCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<SessionCard session={session} joinHref={joinHref} />
</MemoryRouter>
);

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();
});
});
90 changes: 53 additions & 37 deletions src/components/SessionCard.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,44 +10,59 @@ const statusStyles = {
cancelled: "bg-destructive/10 text-destructive",
};

const SessionCard = ({ session }: { session: Session }) => (
<div className="flex items-center gap-4 rounded-xl border border-border bg-card p-4 shadow-card">
<img
src={session.peerAvatar}
alt={session.peerName}
className="h-11 w-11 rounded-lg bg-muted"
/>
<div className="flex-1 min-w-0">
<h4 className="font-heading font-bold text-card-foreground truncate">
{session.subject}
</h4>
<p className="text-sm text-muted-foreground">with {session.peerName}</p>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{session.date}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{session.time}
</span>
<span>{session.duration} min</span>
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 (
<div className="flex items-center gap-4 rounded-xl border border-border bg-card p-4 shadow-card">
<img
src={session.peerAvatar}
alt={session.peerName}
className="h-11 w-11 rounded-lg bg-muted"
/>
<div className="flex-1 min-w-0">
<h4 className="font-heading font-bold text-card-foreground truncate">
{session.subject}
</h4>
<p className="text-sm text-muted-foreground">with {session.peerName}</p>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{session.date}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{session.time}
</span>
<span>{session.duration} min</span>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<Badge className={statusStyles[session.status]}>{session.status}</Badge>
{session.status === "upcoming" &&
(destination ? (
<Button asChild size="sm" variant="outline" className="text-xs">
<Link to={destination}>Join</Link>
</Button>
) : (
<Button size="sm" variant="outline" className="text-xs" disabled>
Unavailable
</Button>
))}
{session.status === "completed" && session.rating && (
<span className="flex items-center gap-1 text-xs text-warning">
<CheckCircle2 className="h-3 w-3" /> {session.rating}/5
</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-2">
<Badge className={statusStyles[session.status]}>{session.status}</Badge>
{session.status === "upcoming" && (
<Button size="sm" variant="outline" className="text-xs">
Join
</Button>
)}
{session.status === "completed" && session.rating && (
<span className="flex items-center gap-1 text-xs text-warning">
<CheckCircle2 className="h-3 w-3" /> {session.rating}/5
</span>
)}
</div>
</div>
);
);
};

export default SessionCard;
18 changes: 15 additions & 3 deletions src/hooks/useSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const TAB_TO_STATUS: Record<string, string[]> = {
Completed: ["ended"],
};

export function useSessions(user: any) {
export function useSessions(user: any, deepLinkSessionId?: string | null) {
const { mutate: awardXP } = useAwardXP();
const { toast } = useToast();

Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/lib/sessionJoinPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const sessionJoinPath = (sessionId: string | number): string =>
`/sessions?session=${encodeURIComponent(String(sessionId))}`;
39 changes: 32 additions & 7 deletions src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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());
Expand Down Expand Up @@ -466,7 +491,7 @@ const Dashboard = () => {

{upcomingSessions.length > 0 ? (
upcomingSessions.map((s) => (
<SessionCard key={s.id} session={s} />
<SessionCard key={s.id} session={toDashboardSessionCard(s)} />
))
) : (
<p className="py-8 text-center text-slate-400">
Expand Down
5 changes: 4 additions & 1 deletion src/pages/MentorDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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",
Expand All @@ -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,
};
};

Expand Down
5 changes: 4 additions & 1 deletion src/pages/Sessions.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand All @@ -36,7 +39,7 @@ export default function Sessions() {
handleLeaveVideo,
handleJoinVideo,
togglePinMessage,
} = useSessions(user);
} = useSessions(user, deepLinkSessionId);

return (
<div className="min-h-screen bg-[#020617] text-white overflow-hidden">
Expand Down
Loading
Loading