Skip to content

Commit 347e10b

Browse files
authored
Update Dependency, Add Security Headers, Make User Profile Route Private (#37)
1 parent 86d0b18 commit 347e10b

9 files changed

Lines changed: 1876 additions & 1394 deletions

File tree

app/api/team/join-request/[requestId]/route.ts

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import TeamJoinRequest from "@/models/TeamJoinRequest";
77

88
export const dynamic = 'force-dynamic';
99

10+
// Max team members
11+
const MAX_TEAM_MEMBERS = 2;
12+
1013
export async function PUT(
1114
request: NextRequest,
1215
{ params }: { params: { requestId: string } }
@@ -86,13 +89,6 @@ export async function PUT(
8689
}
8790

8891
if (action === 'accept') {
89-
if (team.memberCount >= 2) {
90-
return NextResponse.json(
91-
{ message: "Team is full" },
92-
{ status: 409 }
93-
);
94-
}
95-
9692
const requestingUser = await User.findOne({ uid: joinRequest.userId });
9793
if (!requestingUser) {
9894
return NextResponse.json(
@@ -101,29 +97,76 @@ export async function PUT(
10197
);
10298
}
10399

104-
if (requestingUser.teamCode) {
105-
joinRequest.status = 'declined';
106-
joinRequest.respondedAt = new Date();
107-
joinRequest.respondedBy = authResult.user.uid;
108-
await joinRequest.save();
100+
const targetCode = team.teamCode;
101+
102+
// Atomically seat the requesting user. The conditional update enforces the
103+
// size cap (a seat must be free), guards against re-adding the same member,
104+
// and respects the team status — all in one document write, so a racing
105+
// accept/join cannot oversize the team. The unique teamMembers.uid index is
106+
// the DB backstop that prevents seating a user who is already in another team.
107+
let updatedTeam;
108+
try {
109+
const seatFilter: Record<string, any> = {
110+
teamCode: targetCode,
111+
teamStatus: { $nin: ['submitted', 'shortlisted', 'rsvped'] },
112+
"teamMembers.uid": { $ne: joinRequest.userId },
113+
};
114+
seatFilter[`teamMembers.${MAX_TEAM_MEMBERS - 1}`] = { $exists: false };
115+
116+
updatedTeam = await Team.findOneAndUpdate(
117+
seatFilter,
118+
{
119+
$push: {
120+
teamMembers: {
121+
uid: joinRequest.userId,
122+
joinedAt: new Date(),
123+
role: 'Member',
124+
},
125+
},
126+
$inc: { memberCount: 1 },
127+
},
128+
{ new: true }
129+
);
130+
} catch (err: any) {
131+
// Duplicate key on teamMembers.uid => user already belongs to another team.
132+
if (err?.code === 11000) {
133+
joinRequest.status = 'declined';
134+
joinRequest.respondedAt = new Date();
135+
joinRequest.respondedBy = authResult.user.uid;
136+
await joinRequest.save();
137+
138+
return NextResponse.json(
139+
{ message: "User is already in another team. Request/Invite declined." },
140+
{ status: 409 }
141+
);
142+
}
143+
throw err;
144+
}
109145

146+
if (!updatedTeam) {
147+
// No seat taken — distinguish "already a member here" from "team full".
148+
const current = await Team.findOne({ teamCode: targetCode });
149+
if (current?.teamMembers?.some((m: any) => m.uid === joinRequest.userId)) {
150+
joinRequest.status = 'declined';
151+
joinRequest.respondedAt = new Date();
152+
joinRequest.respondedBy = authResult.user.uid;
153+
await joinRequest.save();
154+
155+
return NextResponse.json(
156+
{ message: "User is already in this team. Request/Invite declined." },
157+
{ status: 409 }
158+
);
159+
}
110160
return NextResponse.json(
111-
{ message: "User is already in another team. Request/Invite declined." },
161+
{ message: "Team is full" },
112162
{ status: 409 }
113163
);
114164
}
115165

116-
team.teamMembers.push({
117-
uid: joinRequest.userId,
118-
joinedAt: new Date(),
119-
role: 'Member',
120-
});
121-
122-
await team.save();
123-
166+
// Keep the user's cached teamCode in sync (the team array is the authority).
124167
await User.findOneAndUpdate(
125168
{ uid: joinRequest.userId },
126-
{ teamCode: team.teamCode, isLooking: false }
169+
{ teamCode: targetCode, isLooking: false }
127170
);
128171

129172
joinRequest.status = 'accepted';
@@ -148,8 +191,8 @@ export async function PUT(
148191
message: isInvite ? "Invitation accepted. You have joined the team." : "Join request accepted. User added to team.",
149192
data: {
150193
requestId: joinRequest._id.toString(),
151-
teamCode: team.teamCode,
152-
teamName: team.teamName,
194+
teamCode: updatedTeam.teamCode,
195+
teamName: updatedTeam.teamName,
153196
status: 'accepted',
154197
},
155198
});

app/api/team/join/route.ts

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,59 @@ export async function PUT(request: NextRequest) {
110110
);
111111
}
112112

113-
// Add user to team
114-
team.teamMembers.push({
115-
uid: authResult.user.uid,
116-
joinedAt: new Date(),
117-
role: 'Member',
118-
});
113+
const targetCode = team.teamCode;
114+
115+
let updatedTeam;
116+
try {
117+
const seatFilter: Record<string, any> = {
118+
teamCode: targetCode,
119+
teamStatus: { $ne: "submitted" },
120+
"teamMembers.uid": { $ne: authResult.user.uid },
121+
};
122+
seatFilter[`teamMembers.${MAX_TEAM_MEMBERS - 1}`] = { $exists: false };
123+
124+
updatedTeam = await Team.findOneAndUpdate(
125+
seatFilter,
126+
{
127+
$push: {
128+
teamMembers: {
129+
uid: authResult.user.uid,
130+
joinedAt: new Date(),
131+
role: "Member",
132+
},
133+
},
134+
$inc: { memberCount: 1 },
135+
},
136+
{ new: true }
137+
);
138+
} catch (err: any) {
139+
if (err?.code === 11000) {
140+
return NextResponse.json(
141+
{ message: "User already in a team" },
142+
{ status: 400 }
143+
);
144+
}
145+
throw err;
146+
}
119147

120-
await team.save();
148+
if (!updatedTeam) {
149+
const current = await Team.findOne({ teamCode: targetCode });
150+
if (!current) {
151+
return NextResponse.json({ message: "Invalid team code" }, { status: 404 });
152+
}
153+
if (current.teamMembers?.some((m: any) => m.uid === authResult.user.uid)) {
154+
return NextResponse.json({ message: "User already in a team" }, { status: 400 });
155+
}
156+
if (current.teamStatus === "submitted") {
157+
return NextResponse.json({ message: "Team already submitted" }, { status: 409 });
158+
}
159+
return NextResponse.json({ message: "Team is full" }, { status: 409 });
160+
}
161+
162+
await User.findOneAndUpdate(
163+
{ uid: authResult.user.uid },
164+
{ teamCode: targetCode, isLooking: false }
165+
);
121166

122167
// Cancel all pending join requests for this user
123168
await TeamJoinRequest.updateMany(
@@ -131,17 +176,11 @@ export async function PUT(request: NextRequest) {
131176
}
132177
);
133178

134-
// Update user's teamCode and isLooking
135-
await User.findOneAndUpdate(
136-
{ uid: authResult.user.uid },
137-
{ teamCode: team.teamCode, isLooking: false }
138-
);
139-
140-
// Get team members with names
141-
const memberUids = team.teamMembers.map((m: any) => m.uid);
179+
// Get team members with names (from the authoritative post-update document)
180+
const memberUids = updatedTeam.teamMembers.map((m: any) => m.uid);
142181
const members = await User.find({ uid: { $in: memberUids } }).select('uid name');
143-
144-
const formattedMembers = team.teamMembers.map((member: any) => {
182+
183+
const formattedMembers = updatedTeam.teamMembers.map((member: any) => {
145184
const userInfo = members.find((u: any) => u.uid === member.uid);
146185
return {
147186
id: member.uid,
@@ -154,10 +193,10 @@ export async function PUT(request: NextRequest) {
154193
success: true,
155194
message: "Successfully joined team",
156195
data: {
157-
teamCode: team.teamCode,
158-
teamName: team.teamName,
196+
teamCode: updatedTeam.teamCode,
197+
teamName: updatedTeam.teamName,
159198
teamMembers: formattedMembers,
160-
memberCount: team.memberCount,
199+
memberCount: updatedTeam.memberCount,
161200
},
162201
});
163202

app/api/users/[id]/route.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import path from "path";
55
import os from "os";
66
import dbConnect from "@/lib/db";
77
import User from "@/models/User";
8+
import Team from "@/models/Team";
89
import {
910
authenticateUser,
1011
createAuthErrorResponse,
@@ -175,6 +176,43 @@ export async function GET(
175176
);
176177
}
177178

179+
// A profile is visible when any of these hold:
180+
// - the requester is an admin or evaluator
181+
// - the requester is viewing their own profile
182+
// - the requester and target are on the same team
183+
// - the target is personally "looking for a team" (isLooking)
184+
// - the target's team is discoverable (looking for members)
185+
// Otherwise the profile is private.
186+
const requester = authResult.user;
187+
const isPrivileged =
188+
requester.role === "admin" || requester.role === "evaluator";
189+
const isSelf = requester.uid === user.uid;
190+
const isTeammate =
191+
!!requester.teamCode &&
192+
!!user.teamCode &&
193+
requester.teamCode === user.teamCode;
194+
195+
let canView = user.isLooking || isPrivileged || isSelf || isTeammate;
196+
197+
// Expose members of a team that is itself looking for members, so the team
198+
// can be browsed from the Discover page.
199+
if (!canView && user.teamCode) {
200+
const team = await Team.findOne({ teamCode: user.teamCode }).select(
201+
"isLooking",
202+
);
203+
canView = !!team?.isLooking;
204+
}
205+
206+
if (!canView) {
207+
return NextResponse.json(
208+
{
209+
message: "This profile is private",
210+
status: "error",
211+
},
212+
{ status: 403 },
213+
);
214+
}
215+
178216
return NextResponse.json({
179217
message: "User found",
180218
status: "success",

lib/firebase-admin.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import * as admin from 'firebase-admin';
1+
import { initializeApp, getApps, cert, type App } from 'firebase-admin/app';
2+
import { getAuth as getAdminAuth, type Auth } from 'firebase-admin/auth';
23

34
// Initialize Firebase Admin SDK
45
// For production: Use service account credentials from environment variables
56
// For development: Uses application default credentials or can work without full verification
67

7-
const getFirebaseAdmin = () => {
8-
if (admin.apps.length > 0) {
9-
return admin.apps[0]!;
8+
const getFirebaseAdmin = (): App => {
9+
const existing = getApps();
10+
if (existing.length > 0) {
11+
return existing[0]!;
1012
}
1113

1214
// Check if we have service account credentials
@@ -16,8 +18,8 @@ const getFirebaseAdmin = () => {
1618

1719
if (projectId && clientEmail && privateKey) {
1820
// Full credentials available - use them
19-
return admin.initializeApp({
20-
credential: admin.credential.cert({
21+
return initializeApp({
22+
credential: cert({
2123
projectId,
2224
clientEmail,
2325
privateKey,
@@ -27,7 +29,7 @@ const getFirebaseAdmin = () => {
2729

2830
// Fallback: Initialize with just project ID (limited functionality)
2931
if (projectId) {
30-
return admin.initializeApp({
32+
return initializeApp({
3133
projectId,
3234
});
3335
}
@@ -36,17 +38,15 @@ const getFirebaseAdmin = () => {
3638
};
3739

3840
// Lazy initialization
39-
let firebaseAdmin: admin.app.App | null = null;
41+
let firebaseAdmin: App | null = null;
4042

41-
export const getAdmin = () => {
43+
export const getAdmin = (): App => {
4244
if (!firebaseAdmin) {
4345
firebaseAdmin = getFirebaseAdmin();
4446
}
4547
return firebaseAdmin;
4648
};
4749

48-
export const getAuth = () => {
49-
return getAdmin().auth();
50+
export const getAuth = (): Auth => {
51+
return getAdminAuth(getAdmin());
5052
};
51-
52-
export default admin;

middleware.ts

Lines changed: 0 additions & 16 deletions
This file was deleted.

models/Team.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ const TeamSchema: Schema = new Schema(
176176
TeamSchema.index({ "evaluations.tier": 1 });
177177
TeamSchema.index({ "memberRSVPs.uid": 1 });
178178
TeamSchema.index({ createdAt: -1 });
179+
TeamSchema.index({ "teamMembers.uid": 1 }, { unique: true });
179180

180181
TeamSchema.pre("save", async function (this: ITeam) {
181182
this.memberCount = this.teamMembers.length;

next.config.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,18 @@ const nextConfig = {
2929
async headers() {
3030
return [
3131
{
32-
source: '/api/:path*',
32+
source: '/:path*',
3333
headers: [
34+
{ key: 'Strict-Transport-Security', value: "max-age=63072000" },
35+
{ key: 'Access-Control-Allow-Origin', value: process.env.NEXT_PUBLIC_DOMAIN },
3436
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
35-
{ key: 'Access-Control-Allow-Origin', value: '*' },
36-
{ key: 'Access-Control-Allow-Methods', value: 'GET,DELETE,PATCH,POST,PUT' },
37-
{ key: 'Access-Control-Allow-Headers', value: 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization' },
37+
{ key: 'Access-Control-Allow-Methods', value: "GET,POST,PUT,PATCH,DELETE,OPTIONS" },
38+
{
39+
key: 'Access-Control-Allow-Headers', value: 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization'
40+
},
41+
{ key: 'Access-Control-Max-Age', value: '86400' },
42+
{ key: 'Vary', value: 'Origin' },
43+
3844
],
3945
},
4046
];

0 commit comments

Comments
 (0)