diff --git a/backend/controllers/cronController.js b/backend/controllers/cronController.js index bfafd7d4..ef55b6df 100644 --- a/backend/controllers/cronController.js +++ b/backend/controllers/cronController.js @@ -2,6 +2,10 @@ import { createClient } from "@supabase/supabase-js"; import webpush from "web-push"; import { sanitizeNotificationActionUrl } from "../utils/notificationActionUrl.js"; import { collectExpiredSubscriptionIds } from "../utils/pushDeliveryCleanup.js"; +import { + isPushAllowedForCategory, + notificationTypeToCategory, +} from "../utils/notificationPreferences.js"; const getSupabaseClient = () => { const supabaseUrl = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL; @@ -89,7 +93,7 @@ export const dispatchPushNotifications = async (req, res, next) => { .is("push_sent_at", null) .is("push_failed_at", null) .or(`push_claimed_at.is.null,push_claimed_at.lt.${claimExpiryThreshold}`) - .select("id,user_id,title,body,action_url,push_attempts") + .select("id,user_id,title,body,action_url,push_attempts,type") .limit(100); if (claimError) { @@ -123,6 +127,27 @@ export const dispatchPushNotifications = async (req, res, next) => { return res.status(500).json({ error: subError.message }); } + const { data: profiles, error: prefsError } = await supabase + .from("profiles") + .select("id, notification_preferences") + .in("id", userIds); + + if (prefsError) { + const notificationIds = notifications.map((n) => n.id); + if (notificationIds.length > 0) { + await supabase + .from("notifications") + .update({ push_claimed_at: null }) + .in("id", notificationIds); + } + return res.status(500).json({ error: prefsError.message }); + } + + const prefsByUser = {}; + for (const profile of profiles || []) { + prefsByUser[profile.id] = profile.notification_preferences; + } + // Group subscriptions by user_id for O(1) lookup per notification. const subsByUser = {}; for (const sub of allSubscriptions || []) { @@ -134,6 +159,16 @@ export const dispatchPushNotifications = async (req, res, next) => { const expiredSubscriptionIds = new Set(); for (const notification of notifications) { + const category = notificationTypeToCategory(notification.type); + if (!isPushAllowedForCategory(prefsByUser[notification.user_id], category)) { + // Opted out — mark handled so the claim doesn't retry forever. + await supabase + .from("notifications") + .update({ push_sent_at: new Date().toISOString() }) + .eq("id", notification.id); + continue; + } + const subscriptions = subsByUser[notification.user_id] || []; const pushResults = await Promise.allSettled( diff --git a/backend/controllers/notificationController.js b/backend/controllers/notificationController.js index 5ad65bbd..48d387e8 100644 --- a/backend/controllers/notificationController.js +++ b/backend/controllers/notificationController.js @@ -2,6 +2,10 @@ import { createClient } from "@supabase/supabase-js"; import webpush from "web-push"; import { sanitizeNotificationActionUrl } from "../utils/notificationActionUrl.js"; import { collectExpiredSubscriptionIds } from "../utils/pushDeliveryCleanup.js"; +import { + isPushAllowedForCategory, + resolvePushCategory, +} from "../utils/notificationPreferences.js"; const getSupabaseClient = () => { const supabaseUrl = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL; @@ -27,7 +31,7 @@ export const sendPushNotification = async (req, res, next) => { // Auth is already handled by either requireAuth or webhookSecret middleware in the route. // Assuming requireAuth sets req.user - const { user_id, title, body, action_url } = req.body; + const { user_id, title, body, action_url, type, category } = req.body; if (!user_id || !title || !body) { return res.status(400).json({ @@ -60,6 +64,26 @@ export const sendPushNotification = async (req, res, next) => { webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey); const supabase = getSupabaseClient(); const safeActionUrl = sanitizeNotificationActionUrl(action_url); + const pushCategory = resolvePushCategory({ type, category }); + + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("notification_preferences") + .eq("id", user_id) + .maybeSingle(); + + if (profileError) { + return res.status(500).json({ error: profileError.message }); + } + + if (!isPushAllowedForCategory(profile?.notification_preferences, pushCategory)) { + return res.json({ + sent: 0, + failed: 0, + skipped: true, + reason: "preference_disabled", + }); + } const { data: subscriptions, error } = await supabase .from("push_subscriptions") @@ -111,4 +135,4 @@ export const sendPushNotification = async (req, res, next) => { } catch (error) { next(error); } -}; \ No newline at end of file +}; diff --git a/backend/tests/dispatchPushNotifications.test.js b/backend/tests/dispatchPushNotifications.test.js index a38a1bb8..2cb6c624 100644 --- a/backend/tests/dispatchPushNotifications.test.js +++ b/backend/tests/dispatchPushNotifications.test.js @@ -8,6 +8,7 @@ const MAX_PUSH_ATTEMPTS = 5; // ─── Shared mutable state across mock DB calls ─────────────────────────────── let dbRows = []; let subscriptionStore = []; // { id, user_id, endpoint, p256dh, auth } +let profileStore = []; // { id, notification_preferences } let endpointBehavior = {}; // endpoint -> "success" | "fail" | "expired" const makeSupabaseMock = () => { @@ -104,6 +105,13 @@ const makeSupabaseMock = () => { return resolve({ data: subs, error: null }); } + // Preference lookup for claimed notification recipients. + if (table === "profiles" && !_operation) { + const userIds = _filters["id__in"] || []; + const rows = profileStore.filter((p) => userIds.includes(p.id)); + return resolve({ data: rows, error: null }); + } + return resolve({ data: [], error: null }); }, }; @@ -164,6 +172,7 @@ const seedRow = (overrides = {}) => ({ title: "Title", body: "Body", action_url: "/notifications", + type: "message", push_sent_at: null, push_claimed_at: null, push_failed_at: null, @@ -184,6 +193,7 @@ describe("dispatchPushNotifications", () => { endpointBehavior = {}; dbRows = []; subscriptionStore = []; + profileStore = []; app = await buildApp(); }); @@ -361,4 +371,43 @@ describe("dispatchPushNotifications", () => { expect(res2.body).toEqual({ sent: 1, processed: 1 }); }); }); -}); \ No newline at end of file + + + describe("notification preferences (issue #1900)", () => { + it("skips push delivery when the category inApp preference is disabled", async () => { + dbRows = [ + seedRow({ id: "notif-muted", user_id: "user-muted", type: "message" }), + ]; + subscriptionStore = [ + { + id: "sub-muted", + user_id: "user-muted", + endpoint: "ep-muted", + p256dh: "k", + auth: "a", + }, + ]; + profileStore = [ + { + id: "user-muted", + notification_preferences: { + messages: { inApp: false }, + sessions: { inApp: true }, + friends: { inApp: true }, + }, + }, + ]; + endpointBehavior["ep-muted"] = "success"; + + const webpush = (await import("web-push")).default; + webpush.sendNotification.mockClear(); + + const res = await request(app).post("/dispatch"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ sent: 0, processed: 1 }); + expect(dbRows[0].push_sent_at).not.toBeNull(); + expect(webpush.sendNotification).not.toHaveBeenCalled(); + }); + }); + +}); diff --git a/backend/tests/notificationPreferences.test.js b/backend/tests/notificationPreferences.test.js new file mode 100644 index 00000000..2418c32d --- /dev/null +++ b/backend/tests/notificationPreferences.test.js @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + isPushAllowedForCategory, + notificationTypeToCategory, + resolvePushCategory, +} from "../utils/notificationPreferences.js"; + +describe("notificationTypeToCategory", () => { + it("maps message and session-related types onto settings categories", () => { + expect(notificationTypeToCategory("message")).toBe("messages"); + expect(notificationTypeToCategory("session_reminder")).toBe("sessions"); + expect(notificationTypeToCategory("mentorship_reminder")).toBe("sessions"); + expect(notificationTypeToCategory("announcement")).toBe("sessions"); + expect(notificationTypeToCategory("friend_request")).toBe("friends"); + }); + + it("returns null for unmapped types", () => { + expect(notificationTypeToCategory("system")).toBeNull(); + expect(notificationTypeToCategory(undefined)).toBeNull(); + }); +}); + +describe("isPushAllowedForCategory", () => { + it("allows delivery when preferences are missing or the category is unknown", () => { + expect(isPushAllowedForCategory(null, "messages")).toBe(true); + expect(isPushAllowedForCategory({}, null)).toBe(true); + }); + + it("suppresses push when the category inApp channel is disabled", () => { + const prefs = { + messages: { inApp: false }, + sessions: { inApp: true }, + friends: { inApp: true }, + }; + + expect(isPushAllowedForCategory(prefs, "messages")).toBe(false); + expect(isPushAllowedForCategory(prefs, "sessions")).toBe(true); + }); +}); + +describe("resolvePushCategory", () => { + it("prefers an explicit category over type mapping", () => { + expect(resolvePushCategory({ type: "message", category: "friends" })).toBe("friends"); + expect(resolvePushCategory({ type: "session_reminder" })).toBe("sessions"); + }); +}); 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/backend/utils/notificationPreferences.js b/backend/utils/notificationPreferences.js new file mode 100644 index 00000000..a25869a5 --- /dev/null +++ b/backend/utils/notificationPreferences.js @@ -0,0 +1,53 @@ +export const DEFAULT_NOTIFICATION_PREFERENCES = { + messages: { email: false, inApp: true }, + sessions: { email: false, inApp: true }, + friends: { email: false, inApp: true }, +}; + +/** + * Map a notifications.type enum value to a Settings preference category. + * Unmapped types (e.g. system) return null and are treated as allowed. + */ +export function notificationTypeToCategory(type) { + switch (type) { + case "message": + return "messages"; + case "session_reminder": + case "mentorship_reminder": + case "mentorship_reminder_overdue": + case "announcement": + return "sessions"; + case "friend_request": + case "connection_request": + return "friends"; + default: + return null; + } +} + +/** + * Push delivery follows the inApp channel for the given category. + * Missing prefs / unknown categories default to allowing delivery. + */ +export function isPushAllowedForCategory(preferences, category) { + if (!category) return true; + + const prefs = preferences && typeof preferences === "object" + ? preferences + : DEFAULT_NOTIFICATION_PREFERENCES; + + const channel = prefs[category]; + if (!channel || typeof channel !== "object") return true; + + return channel.inApp !== false; +} + +export function resolvePushCategory({ type, category } = {}) { + if (typeof category === "string" && category.length > 0) { + return category; + } + if (typeof type === "string" && type.length > 0) { + return notificationTypeToCategory(type); + } + return null; +} 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/pages/Settings.tsx b/src/pages/Settings.tsx index 7ed11291..9ed69881 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -2,21 +2,31 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { motion } from "framer-motion"; -import { ArrowLeft, Bell, Mail, Smartphone, Save, Moon, Sun } from "lucide-react"; +import { ArrowLeft, Bell, Smartphone, Save, Moon, Sun } from "lucide-react"; import { supabase } from "@/integrations/supabase/client"; import { useTheme } from "next-themes"; type NotificationCategory = "messages" | "sessions" | "friends"; type NotificationChannels = { - email: boolean; inApp: boolean; }; type NotificationPreferences = Record; const DEFAULT_PREFERENCES: NotificationPreferences = { - messages: { email: true, inApp: true }, - sessions: { email: true, inApp: true }, - friends: { email: false, inApp: true }, + messages: { inApp: true }, + sessions: { inApp: true }, + friends: { inApp: true }, +}; + +const normalizePreferences = (raw: unknown): NotificationPreferences => { + const source = + raw && typeof raw === "object" ? (raw as Record) : {}; + + return { + messages: { inApp: source.messages?.inApp !== false }, + sessions: { inApp: source.sessions?.inApp !== false }, + friends: { inApp: source.friends?.inApp !== false }, + }; }; const Settings = () => { @@ -38,8 +48,8 @@ const Settings = () => { .eq("id", user.id) .maybeSingle(); - if (data && data.notification_preferences) { - setPreferences(data.notification_preferences as NotificationPreferences); + if (!error && data?.notification_preferences) { + setPreferences(normalizePreferences(data.notification_preferences)); return; } } @@ -51,7 +61,7 @@ const Settings = () => { const saved = localStorage.getItem("notification_preferences"); if (saved) { try { - setPreferences(JSON.parse(saved)); + setPreferences(normalizePreferences(JSON.parse(saved))); } catch (e) { console.error("Failed to parse preferences", e); } @@ -61,12 +71,12 @@ const Settings = () => { loadPreferences(); }, []); - const handleToggle = (category: NotificationCategory, channel: keyof NotificationChannels) => { + const handleToggle = (category: NotificationCategory) => { setPreferences((prev) => ({ ...prev, [category]: { ...prev[category], - [channel]: !prev[category][channel], + inApp: !prev[category].inApp, }, })); }; @@ -120,7 +130,7 @@ const Settings = () => {

Notification Preferences

-

Control how and when you want to be notified.

+

Control which push notifications you receive.

@@ -128,72 +138,48 @@ const Settings = () => { {/* Preferences Table */}
-
Event
-
- Email -
-
- In-App +
Event
+
+ Push
- {/* New Messages */}
-
+

New Messages

Direct messages from peers and mentors.

-
- handleToggle("messages", "email")} - /> -
-
+
handleToggle("messages", "inApp")} + onChange={() => handleToggle("messages")} />
- {/* Upcoming Sessions */}
-
+

Upcoming Sessions

Reminders before your study sessions start.

-
- handleToggle("sessions", "email")} - /> -
-
+
handleToggle("sessions", "inApp")} + onChange={() => handleToggle("sessions")} />
- {/* Friend Requests */}
-
+

Friend Requests

When someone sends you a connection request.

-
- handleToggle("friends", "email")} - /> -
-
+
handleToggle("friends", "inApp")} + onChange={() => handleToggle("friends")} />