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
33 changes: 29 additions & 4 deletions packages/backend/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const dayNames = [
"saturday",
"sunday",
];

//user's weekly availability, including days, start time, end time, and timezone
const scheduleSchema = new mongoose.Schema(
{
Expand Down Expand Up @@ -95,7 +96,6 @@ const userSchema = new mongoose.Schema(
default: null,
},

// From the diagram
firstName: {
type: String,
default: "",
Expand Down Expand Up @@ -142,16 +142,41 @@ const userSchema = new mongoose.Schema(
},

interests: {
type: String,
default: "",
trim: true,
type: [
{
type: String,
trim: true,
maxlength: [24, "Each interest must be 24 characters or less"],
match: [
/^[a-zA-Z0-9 '&-]+$/,
"Interests can only contain letters, numbers, spaces, apostrophes, ampersands, and hyphens",
],
},
],
default: [],
validate: {
validator: (interests) => interests.length <= 10,
message: "You can only add up to 10 interests",
},
},

profileVisibility: {
type: String,
enum: ["public", "groups", "private"],
default: "private",
},

featureSettings: {
groupsEnabled: {
type: Boolean,
default: true,
},

leaderboardEnabled: {
type: Boolean,
default: true,
},
},
},
{
timestamps: true,
Expand Down
17 changes: 10 additions & 7 deletions packages/backend/routes/leaderboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Group from "../models/group.js";
const DEFAULT_LIMIT = 5;
const MAX_LIMIT = 50;

//parse and cap leaderboard limit
//parses and caps leaderboard limit, falling back to default if invalid
export function getLeaderboardLimit(rawLimit) {
const parsedLimit = Number.parseInt(rawLimit, 10);

Expand All @@ -17,10 +17,12 @@ export function getLeaderboardLimit(rawLimit) {

return Math.min(parsedLimit, MAX_LIMIT);
}

//gets user id as a string from either a user object or raw id
function getUserId(user) {
return user?._id?.toString?.() || user?.toString?.() || String(user || "");
}

//converts users into ranked leaderboard entries and marks the current user
export function mapUsersToLeaderboardEntries(users, currentUserId) {
return users.map((user, index) => {
Expand Down Expand Up @@ -50,16 +52,15 @@ export async function leaderboardHandler(req, res) {
let users;

if (scope === "group" && groupId) {
// Validate groupId format
if (!mongoose.Types.ObjectId.isValid(groupId)) {
return res.status(400).json({ error: "Invalid group ID" });
}

// Fetch the group and get its users
const group = await Group.findById(groupId)
.populate({
path: "users",
select: "name email totalHours",
match: { profileVisibility: { $in: ["public", "groups"] } },
select: "name email totalHours profileVisibility",
options: { sort: { totalHours: -1, name: 1 }, limit },
})
.lean();
Expand All @@ -70,11 +71,13 @@ export async function leaderboardHandler(req, res) {

users = group.users || [];
} else {
// Global leaderboard
users = await User.find()
//global leaderboard only shows public users
users = await User.find({
profileVisibility: "public",
})
.sort({ totalHours: -1, name: 1 })
.limit(limit)
.select("name email totalHours")
.select("name email totalHours profileVisibility")
.lean();
}

Expand Down
74 changes: 47 additions & 27 deletions packages/backend/routes/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ const allowedUserUpdateFields = [
"schedule",
"commitmentLevel",
"age",
"interests",
"profileVisibility",
"featureSettings",
"totalHours",
"weeklyHours",
"todayHours",
"interests",
"isProfilePublic",
"profileVisibility",
];

const commitmentGoals = {
Expand Down Expand Up @@ -170,25 +170,42 @@ router.get("/:userParam/groups", async (req, res) => {
}
});

//get public profile for one user
//get profile viewer data for one user
router.get("/:userParam/public", requireAuth, async (req, res) => {
try {
const user = await User.findOne(getUserFilter(req.params.userParam)).select(
"name age interests isProfilePublic totalHours",
);
const profileUser = await User.findOne(
getUserFilter(req.params.userParam),
).select("name age interests profileVisibility totalHours groups");

if (!user) {
if (!profileUser) {
return res.status(404).json({ error: "User not found" });
}

const viewer = await User.findById(req.auth.userId).select("groups");

const visibility = profileUser.profileVisibility || "private";
const isSelf = profileUser._id.toString() === req.auth.userId;

const viewerGroups = new Set((viewer?.groups || []).map(String));
const profileUserGroups = (profileUser.groups || []).map(String);

const sharesGroup = profileUserGroups.some((groupId) =>
viewerGroups.has(groupId),
);

const canViewDetails =
isSelf ||
visibility === "public" ||
(visibility === "groups" && sharesGroup);

return res.json({
user: {
_id: user._id,
name: user.name,
totalHours: user.totalHours || 0,
isProfilePublic: user.isProfilePublic,
age: user.isProfilePublic ? user.age || null : null,
interests: user.isProfilePublic ? user.interests || "" : "",
_id: profileUser._id,
name: profileUser.name,
totalHours: profileUser.totalHours || 0,
profileVisibility: visibility,
age: canViewDetails ? profileUser.age || null : null,
interests: canViewDetails ? profileUser.interests || [] : [],
},
});
} catch (error) {
Expand All @@ -214,8 +231,19 @@ router.get("/:userParam", async (req, res) => {
});

//update user by id or email
router.put("/:userParam", async (req, res) => {
router.put("/:userParam", requireAuth, async (req, res) => {
try {
const filter = getUserFilter(req.params.userParam);
const targetUser = await User.findOne(filter).select("_id");

if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}

if (targetUser._id.toString() !== req.auth.userId) {
return res.status(403).json({ error: "You can only update yourself" });
}

const updates = getAllowedUpdates(req.body, allowedUserUpdateFields);

if (Object.keys(updates).length === 0) {
Expand All @@ -224,18 +252,10 @@ router.put("/:userParam", async (req, res) => {
});
}

const user = await User.findOneAndUpdate(
getUserFilter(req.params.userParam),
updates,
{
new: true,
runValidators: true,
},
).populate("groups");

if (!user) {
return res.status(404).json({ error: "User not found" });
}
const user = await User.findByIdAndUpdate(targetUser._id, updates, {
new: true,
runValidators: true,
}).populate("groups");

return res.json(user);
} catch (error) {
Expand Down
9 changes: 8 additions & 1 deletion packages/frontend/src/components/CreateGroup.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,14 @@ function CreateGroup() {
if (!res.ok) throw new Error("Not logged in");
return res.json();
})
.then((data) => setUser(data.user))
.then((data) => {
if (data.user?.featureSettings?.groupsEnabled === false) {
navigate("/dashboard");
return;
}

setUser(data.user);
})
.catch(() => navigate("/login"));
}, [navigate]);

Expand Down
12 changes: 8 additions & 4 deletions packages/frontend/src/components/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,11 @@ const Dashboard = () => {
<p className="current-date">{dateString}</p>

<div className="dashboard-header-actions">
<Link to="/groups" className="profile-btn">
Groups
</Link>
{user?.featureSettings?.groupsEnabled !== false && (
<Link to="/groups" className="profile-btn">
Groups
</Link>
)}

<Link to="/profile" className="profile-btn">
Profile
Expand Down Expand Up @@ -136,7 +138,9 @@ const Dashboard = () => {
</main>

<aside className="dashboard-sidebar">
<Leaderboard user={user} />
{user?.featureSettings?.leaderboardEnabled !== false && (
<Leaderboard user={user} />
)}{" "}
</aside>
</div>
</div>
Expand Down
9 changes: 8 additions & 1 deletion packages/frontend/src/components/GroupDetail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ function GroupDetail() {
if (!res.ok) throw new Error("Not logged in");
return res.json();
})
.then((data) => setUser(data.user))
.then((data) => {
if (data.user?.featureSettings?.groupsEnabled === false) {
navigate("/dashboard");
return;
}

setUser(data.user);
})
.catch(() => navigate("/login"));
}, [navigate]);

Expand Down
17 changes: 15 additions & 2 deletions packages/frontend/src/components/Leaderboard.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//loads and displays the global or group leaderboard with the current user's hours highlightedz
//loads and displays the global or group leaderboard with the current user's hours highlighted
import { useEffect, useState } from "react";

const AZURE_URL =
Expand All @@ -24,7 +24,14 @@ function Leaderboard({
const [status, setStatus] = useState("loading");
const [error, setError] = useState("");

const leaderboardDisabled =
user?.featureSettings?.leaderboardEnabled === false;

useEffect(() => {
if (leaderboardDisabled) {
return;
}

const controller = new AbortController();

async function loadLeaderboard() {
Expand Down Expand Up @@ -69,7 +76,11 @@ function Leaderboard({
});

return () => controller.abort();
}, [limit, scope, groupId]);
}, [limit, scope, groupId, leaderboardDisabled]);

if (leaderboardDisabled) {
return null;
}

const hasEntries = entries.length > 0;
const emptyMessage = "No study time yet. Start a session to claim a spot.";
Expand Down Expand Up @@ -117,12 +128,14 @@ function Leaderboard({
>
{entry.rank}
</span>

<span className="player-name">
{entry.name}
{entry.isCurrentUser && (
<span className="current-user-label">You</span>
)}
</span>

<span className="player-score">
{formatHours(entry.totalHours)}
</span>
Expand Down
Loading
Loading