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
91 changes: 67 additions & 24 deletions app/api/team/join-request/[requestId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import TeamJoinRequest from "@/models/TeamJoinRequest";

export const dynamic = 'force-dynamic';

// Max team members
const MAX_TEAM_MEMBERS = 2;

export async function PUT(
request: NextRequest,
{ params }: { params: { requestId: string } }
Expand Down Expand Up @@ -86,13 +89,6 @@ export async function PUT(
}

if (action === 'accept') {
if (team.memberCount >= 2) {
return NextResponse.json(
{ message: "Team is full" },
{ status: 409 }
);
}

const requestingUser = await User.findOne({ uid: joinRequest.userId });
if (!requestingUser) {
return NextResponse.json(
Expand All @@ -101,29 +97,76 @@ export async function PUT(
);
}

if (requestingUser.teamCode) {
joinRequest.status = 'declined';
joinRequest.respondedAt = new Date();
joinRequest.respondedBy = authResult.user.uid;
await joinRequest.save();
const targetCode = team.teamCode;

// Atomically seat the requesting user. The conditional update enforces the
// size cap (a seat must be free), guards against re-adding the same member,
// and respects the team status — all in one document write, so a racing
// accept/join cannot oversize the team. The unique teamMembers.uid index is
// the DB backstop that prevents seating a user who is already in another team.
let updatedTeam;
try {
const seatFilter: Record<string, any> = {
teamCode: targetCode,
teamStatus: { $nin: ['submitted', 'shortlisted', 'rsvped'] },
"teamMembers.uid": { $ne: joinRequest.userId },
};
seatFilter[`teamMembers.${MAX_TEAM_MEMBERS - 1}`] = { $exists: false };

updatedTeam = await Team.findOneAndUpdate(
seatFilter,
{
$push: {
teamMembers: {
uid: joinRequest.userId,
joinedAt: new Date(),
role: 'Member',
},
},
$inc: { memberCount: 1 },
},
{ new: true }
);
} catch (err: any) {
// Duplicate key on teamMembers.uid => user already belongs to another team.
if (err?.code === 11000) {
joinRequest.status = 'declined';
joinRequest.respondedAt = new Date();
joinRequest.respondedBy = authResult.user.uid;
await joinRequest.save();

return NextResponse.json(
{ message: "User is already in another team. Request/Invite declined." },
{ status: 409 }
);
}
throw err;
}

if (!updatedTeam) {
// No seat taken — distinguish "already a member here" from "team full".
const current = await Team.findOne({ teamCode: targetCode });
if (current?.teamMembers?.some((m: any) => m.uid === joinRequest.userId)) {
joinRequest.status = 'declined';
joinRequest.respondedAt = new Date();
joinRequest.respondedBy = authResult.user.uid;
await joinRequest.save();

return NextResponse.json(
{ message: "User is already in this team. Request/Invite declined." },
{ status: 409 }
);
}
return NextResponse.json(
{ message: "User is already in another team. Request/Invite declined." },
{ message: "Team is full" },
{ status: 409 }
);
}

team.teamMembers.push({
uid: joinRequest.userId,
joinedAt: new Date(),
role: 'Member',
});

await team.save();

// Keep the user's cached teamCode in sync (the team array is the authority).
await User.findOneAndUpdate(
{ uid: joinRequest.userId },
{ teamCode: team.teamCode, isLooking: false }
{ teamCode: targetCode, isLooking: false }
);

joinRequest.status = 'accepted';
Expand All @@ -148,8 +191,8 @@ export async function PUT(
message: isInvite ? "Invitation accepted. You have joined the team." : "Join request accepted. User added to team.",
data: {
requestId: joinRequest._id.toString(),
teamCode: team.teamCode,
teamName: team.teamName,
teamCode: updatedTeam.teamCode,
teamName: updatedTeam.teamName,
status: 'accepted',
},
});
Expand Down
79 changes: 59 additions & 20 deletions app/api/team/join/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,59 @@ export async function PUT(request: NextRequest) {
);
}

// Add user to team
team.teamMembers.push({
uid: authResult.user.uid,
joinedAt: new Date(),
role: 'Member',
});
const targetCode = team.teamCode;

let updatedTeam;
try {
const seatFilter: Record<string, any> = {
teamCode: targetCode,
teamStatus: { $ne: "submitted" },
"teamMembers.uid": { $ne: authResult.user.uid },
};
seatFilter[`teamMembers.${MAX_TEAM_MEMBERS - 1}`] = { $exists: false };

updatedTeam = await Team.findOneAndUpdate(
seatFilter,
{
$push: {
teamMembers: {
uid: authResult.user.uid,
joinedAt: new Date(),
role: "Member",
},
},
$inc: { memberCount: 1 },
},
{ new: true }
);
} catch (err: any) {
if (err?.code === 11000) {
return NextResponse.json(
{ message: "User already in a team" },
{ status: 400 }
);
}
throw err;
}

await team.save();
if (!updatedTeam) {
const current = await Team.findOne({ teamCode: targetCode });
if (!current) {
return NextResponse.json({ message: "Invalid team code" }, { status: 404 });
}
if (current.teamMembers?.some((m: any) => m.uid === authResult.user.uid)) {
return NextResponse.json({ message: "User already in a team" }, { status: 400 });
}
if (current.teamStatus === "submitted") {
return NextResponse.json({ message: "Team already submitted" }, { status: 409 });
}
return NextResponse.json({ message: "Team is full" }, { status: 409 });
}

await User.findOneAndUpdate(
{ uid: authResult.user.uid },
{ teamCode: targetCode, isLooking: false }
);

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

// Update user's teamCode and isLooking
await User.findOneAndUpdate(
{ uid: authResult.user.uid },
{ teamCode: team.teamCode, isLooking: false }
);

// Get team members with names
const memberUids = team.teamMembers.map((m: any) => m.uid);
// Get team members with names (from the authoritative post-update document)
const memberUids = updatedTeam.teamMembers.map((m: any) => m.uid);
const members = await User.find({ uid: { $in: memberUids } }).select('uid name');
const formattedMembers = team.teamMembers.map((member: any) => {

const formattedMembers = updatedTeam.teamMembers.map((member: any) => {
const userInfo = members.find((u: any) => u.uid === member.uid);
return {
id: member.uid,
Expand All @@ -154,10 +193,10 @@ export async function PUT(request: NextRequest) {
success: true,
message: "Successfully joined team",
data: {
teamCode: team.teamCode,
teamName: team.teamName,
teamCode: updatedTeam.teamCode,
teamName: updatedTeam.teamName,
teamMembers: formattedMembers,
memberCount: team.memberCount,
memberCount: updatedTeam.memberCount,
},
});

Expand Down
38 changes: 38 additions & 0 deletions app/api/users/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from "path";
import os from "os";
import dbConnect from "@/lib/db";
import User from "@/models/User";
import Team from "@/models/Team";
import {
authenticateUser,
createAuthErrorResponse,
Expand Down Expand Up @@ -175,6 +176,43 @@ export async function GET(
);
}

// A profile is visible when any of these hold:
// - the requester is an admin or evaluator
// - the requester is viewing their own profile
// - the requester and target are on the same team
// - the target is personally "looking for a team" (isLooking)
// - the target's team is discoverable (looking for members)
// Otherwise the profile is private.
const requester = authResult.user;
const isPrivileged =
requester.role === "admin" || requester.role === "evaluator";
const isSelf = requester.uid === user.uid;
const isTeammate =
!!requester.teamCode &&
!!user.teamCode &&
requester.teamCode === user.teamCode;

let canView = user.isLooking || isPrivileged || isSelf || isTeammate;

// Expose members of a team that is itself looking for members, so the team
// can be browsed from the Discover page.
if (!canView && user.teamCode) {
const team = await Team.findOne({ teamCode: user.teamCode }).select(
"isLooking",
);
canView = !!team?.isLooking;
}

if (!canView) {
return NextResponse.json(
{
message: "This profile is private",
status: "error",
},
{ status: 403 },
);
}

return NextResponse.json({
message: "User found",
status: "success",
Expand Down
26 changes: 13 additions & 13 deletions lib/firebase-admin.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import * as admin from 'firebase-admin';
import { initializeApp, getApps, cert, type App } from 'firebase-admin/app';
import { getAuth as getAdminAuth, type Auth } from 'firebase-admin/auth';

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

const getFirebaseAdmin = () => {
if (admin.apps.length > 0) {
return admin.apps[0]!;
const getFirebaseAdmin = (): App => {
const existing = getApps();
if (existing.length > 0) {
return existing[0]!;
}

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

if (projectId && clientEmail && privateKey) {
// Full credentials available - use them
return admin.initializeApp({
credential: admin.credential.cert({
return initializeApp({
credential: cert({
projectId,
clientEmail,
privateKey,
Expand All @@ -27,7 +29,7 @@ const getFirebaseAdmin = () => {

// Fallback: Initialize with just project ID (limited functionality)
if (projectId) {
return admin.initializeApp({
return initializeApp({
projectId,
});
}
Expand All @@ -36,17 +38,15 @@ const getFirebaseAdmin = () => {
};

// Lazy initialization
let firebaseAdmin: admin.app.App | null = null;
let firebaseAdmin: App | null = null;

export const getAdmin = () => {
export const getAdmin = (): App => {
if (!firebaseAdmin) {
firebaseAdmin = getFirebaseAdmin();
}
return firebaseAdmin;
};

export const getAuth = () => {
return getAdmin().auth();
export const getAuth = (): Auth => {
return getAdminAuth(getAdmin());
};

export default admin;
16 changes: 0 additions & 16 deletions middleware.ts

This file was deleted.

1 change: 1 addition & 0 deletions models/Team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ const TeamSchema: Schema = new Schema(
TeamSchema.index({ "evaluations.tier": 1 });
TeamSchema.index({ "memberRSVPs.uid": 1 });
TeamSchema.index({ createdAt: -1 });
TeamSchema.index({ "teamMembers.uid": 1 }, { unique: true });

TeamSchema.pre("save", async function (this: ITeam) {
this.memberCount = this.teamMembers.length;
Expand Down
14 changes: 10 additions & 4 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,18 @@ const nextConfig = {
async headers() {
return [
{
source: '/api/:path*',
source: '/:path*',
headers: [
{ key: 'Strict-Transport-Security', value: "max-age=63072000" },
{ key: 'Access-Control-Allow-Origin', value: process.env.NEXT_PUBLIC_DOMAIN },
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET,DELETE,PATCH,POST,PUT' },
{ 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' },
{ key: 'Access-Control-Allow-Methods', value: "GET,POST,PUT,PATCH,DELETE,OPTIONS" },
{
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'
},
{ key: 'Access-Control-Max-Age', value: '86400' },
{ key: 'Vary', value: 'Origin' },

],
},
];
Expand Down
Loading
Loading