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)
206 changes: 180 additions & 26 deletions src/components/dashboard/LearningProgress.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,142 @@
import { useEffect, useState } from "react";
import { Target } from "lucide-react";
import { motion } from "framer-motion";
import { Link } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/contexts/useAuth";

type Goal = {
name: string;
progress: number;
color: string;
};

const COLOR_PALETTE = [
"bg-cyan-400",
"bg-blue-500",
"bg-purple-500",
"bg-emerald-500",
"bg-amber-500",
];

const parseLearningGoalsText = (value: string | null | undefined): string[] => {
if (!value?.trim()) return [];
return value
.split(/[\n,;|]+/)
.map((part) => part.trim())
.filter(Boolean);
};

const clampProgress = (completed: number, goal: number): number => {
if (!Number.isFinite(completed) || !Number.isFinite(goal) || goal <= 0) return 0;
return Math.max(0, Math.min(100, Math.round((completed / goal) * 100)));
};

export default function LearningProgress() {
const goals = [
{ name: "Frontend Development", progress: 80, color: "bg-cyan-400" },
{ name: "Backend Development", progress: 60, color: "bg-blue-500" },
{ name: "Open Source Contributions", progress: 75, color: "bg-purple-500" },
];
const { user } = useAuth();
const [goals, setGoals] = useState<Goal[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let mounted = true;

const fetchGoals = async () => {
if (!user) {
if (mounted) {
setGoals([]);
setLoading(false);
setError(null);
}
return;
}

setLoading(true);
setError(null);

try {
const [profileResult, portfolioResult] = await Promise.all([
supabase
.from("profiles")
.select("learn_subjects, learning_goals")
.eq("id", user.id)
.maybeSingle(),
supabase
.from("portfolio_profiles")
.select("learning_progress")
.eq("profile_id", user.id)
.maybeSingle(),
]);

if (profileResult.error) throw profileResult.error;
if (portfolioResult.error) throw portfolioResult.error;

const learnSubjects = Array.isArray(profileResult.data?.learn_subjects)
? profileResult.data.learn_subjects.filter(
(item: unknown): item is string =>
typeof item === "string" && item.trim().length > 0
)
: [];

const textGoals = parseLearningGoalsText(
profileResult.data?.learning_goals as string | null | undefined
);

const progressRaw = portfolioResult.data?.learning_progress as
| { focus?: string; completed?: number; goal?: number }
| null
| undefined;

const focus = progressRaw?.focus?.trim() || "";
const focusProgress = clampProgress(
Number(progressRaw?.completed ?? 0),
Number(progressRaw?.goal ?? 0)
);

const names: string[] = [];
for (const name of [...learnSubjects, ...textGoals]) {
const trimmed = name.trim();
if (
trimmed &&
!names.some((existing) => existing.toLowerCase() === trimmed.toLowerCase())
) {
names.push(trimmed);
}
}

if (
focus &&
!names.some((existing) => existing.toLowerCase() === focus.toLowerCase())
) {
names.push(focus);
}

if (!mounted) return;

setGoals(
names.map((name, index) => ({
name,
progress:
focus && name.toLowerCase() === focus.toLowerCase() ? focusProgress : 0,
color: COLOR_PALETTE[index % COLOR_PALETTE.length],
}))
);
} catch (err) {
console.error("Failed to fetch learning goals:", err);
if (mounted) {
setGoals([]);
setError("Couldn't load your learning goals.");
}
} finally {
if (mounted) setLoading(false);
}
};

fetchGoals();
return () => {
mounted = false;
};
}, [user]);

return (
<div className="rounded-3xl border border-slate-800 bg-slate-900/50 p-6 flex flex-col h-full">
Expand All @@ -18,29 +148,53 @@ export default function LearningProgress() {
</div>

<div className="space-y-6 flex-1">
{goals.map((goal, index) => (
<div key={goal.name} className="group cursor-pointer">
<div className="flex justify-between mb-2">
<span className="text-sm font-medium text-slate-300 group-hover:text-white transition-colors">
{goal.name}
</span>
<span className="text-sm font-bold text-slate-200">
{goal.progress}%
</span>
</div>
{loading && (
<p className="text-sm text-slate-400 py-6 text-center">Loading goals…</p>
)}

<div className="w-full bg-slate-800 rounded-full h-2 overflow-hidden border border-slate-700/50">
<motion.div
initial={{ width: 0 }}
whileInView={{ width: `${goal.progress}%` }}
viewport={{ once: true }}
transition={{ duration: 1, delay: index * 0.2 }}
className={`h-full rounded-full ${goal.color}`}
/>
</div>
{!loading && error && (
<p className="text-sm text-rose-300 py-6 text-center">{error}</p>
)}

{!loading && !error && goals.length === 0 && (
<div className="py-6 text-center space-y-3">
<p className="text-sm text-slate-400">
No learning goals yet. Add subjects you want to learn to get started.
</p>
<Link
to="/edit-profile"
className="inline-flex text-sm text-cyan-400 hover:text-cyan-300"
>
Set up your goals
</Link>
</div>
))}
)}

{!loading &&
!error &&
goals.map((goal, index) => (
<div key={goal.name} className="group">
<div className="flex justify-between mb-2">
<span className="text-sm font-medium text-slate-300 group-hover:text-white transition-colors">
{goal.name}
</span>
<span className="text-sm font-bold text-slate-200">
{goal.progress}%
</span>
</div>

<div className="w-full bg-slate-800 rounded-full h-2 overflow-hidden border border-slate-700/50">
<motion.div
initial={{ width: 0 }}
whileInView={{ width: `${goal.progress}%` }}
viewport={{ once: true }}
transition={{ duration: 1, delay: index * 0.2 }}
className={`h-full rounded-full ${goal.color}`}
/>
</div>
</div>
))}
</div>
</div>
);
}
}
Loading