Skip to content

Commit 5fbfcff

Browse files
authored
Merge pull request #128 from HackRU/team-invitation-endpoint
Team invitation endpoint
2 parents 9f4406f + 398f5c9 commit 5fbfcff

7 files changed

Lines changed: 473 additions & 0 deletions

File tree

serverless.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import discord from '@functions/discord';
99
import read from '@functions/read';
1010
import waiver from '@functions/waiver';
1111
import resume from '@functions/resume';
12+
import teamsInvite from '@functions/teams/invite';
1213
import resetPassword from '@functions/reset-password';
1314
import forgotPassword from '@functions/forgot-password';
1415
import leaderboard from '@functions/leaderboard';
@@ -57,6 +58,7 @@ const serverlessConfiguration: AWS = {
5758
attendEvent,
5859
waiver,
5960
resume,
61+
teamsInvite,
6062
read,
6163
discord,
6264
forgotPassword,

src/functions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export { default as discord } from './discord';
77
export { default as read } from './read';
88
export { default as waiver } from './waiver';
99
export { default as resume } from './resume';
10+
export { default as teamsInvite } from './teams/invite';
1011
export { default as resetPassword } from './reset-password';
1112
export { default as forgotPassword } from './forgot-password';
1213
export { default as leaderboard } from './leaderboard';
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// src/functions/teams/invite/handler.ts
2+
import * as dotenv from 'dotenv';
3+
import * as path from 'path';
4+
import type { ValidatedEventAPIGatewayProxyEvent } from '@libs/api-gateway';
5+
import { middyfy } from '@libs/lambda';
6+
import schema from './schema';
7+
import type { Failure, TeamInvite, TeamDocument, UserDocument } from '../../../types';
8+
import { MongoDB, validateToken, userExistsLogic } from '../../../util';
9+
10+
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
11+
12+
const MAX_TEAM_SIZE = 4;
13+
14+
const teamInvite: ValidatedEventAPIGatewayProxyEvent<typeof schema> = async (event) => {
15+
// Alias snake_case request fields to camelCase locals to satisfy naming-convention
16+
const { auth_token: authToken, auth_email: authEmail, team_id: teamId, emails } = event.body;
17+
18+
// auth check
19+
if (!validateToken(authToken, process.env.JWT_SECRET!, authEmail)) {
20+
return {
21+
statusCode: 401,
22+
body: JSON.stringify({ message: 'Unauthorized' }),
23+
};
24+
}
25+
26+
// DB setup
27+
const db = MongoDB.getInstance(process.env.MONGO_URI!);
28+
await db.connect();
29+
const client = db.getClient();
30+
const users = db.getCollection<UserDocument>('users');
31+
const teams = db.getCollection<TeamDocument>('teams');
32+
33+
// verify auth user
34+
const authUser = await users.findOne({ email: authEmail });
35+
if (!authUser) {
36+
return {
37+
statusCode: 404,
38+
body: JSON.stringify({ message: 'Auth user not found' }),
39+
};
40+
}
41+
42+
// verify team & leadership
43+
const team = await teams.findOne({ team_id: teamId });
44+
if (!team) {
45+
return {
46+
statusCode: 404,
47+
body: JSON.stringify({ message: 'Team not found' }),
48+
};
49+
}
50+
if (team.leader_email !== authEmail) {
51+
return {
52+
statusCode: 403,
53+
body: JSON.stringify({ message: 'Auth user is not the team leader' }),
54+
};
55+
}
56+
57+
// check team status
58+
if (team.status !== 'Active') {
59+
return {
60+
statusCode: 400,
61+
body: JSON.stringify({ message: 'Team is not active' }),
62+
};
63+
}
64+
65+
// capacity check
66+
const confirmedCount = (Array.isArray(team.members) ? team.members.length : 0) + 1; // + 1 for the leader
67+
const pendingCount = await users.countDocuments({
68+
'team_info.pending_invites.team_id': teamId,
69+
});
70+
const availableSlots = MAX_TEAM_SIZE - confirmedCount - pendingCount;
71+
if (availableSlots <= 0) {
72+
return {
73+
statusCode: 400,
74+
body: JSON.stringify({ message: 'Team is already full' }),
75+
};
76+
}
77+
78+
const invited: string[] = [];
79+
const failed: Failure[] = [];
80+
81+
// use a transaction for all updates
82+
const session = client.startSession();
83+
try {
84+
await session.withTransaction(async () => {
85+
const uniqueEmails = Array.from(new Set(emails));
86+
87+
for (const email of uniqueEmails) {
88+
// stop when no slots remain
89+
if (invited.length >= availableSlots) {
90+
failed.push({ email, reason: 'No slots remaining' });
91+
continue;
92+
}
93+
94+
// check user exists via util
95+
const { statusCode: uxStatus } = await userExistsLogic(authEmail, authToken, email);
96+
if (uxStatus !== 200) {
97+
failed.push({ email, reason: 'User does not exist or unauthorized' });
98+
continue;
99+
}
100+
101+
// load user within transaction
102+
const user = await users.findOne({ email }, { session });
103+
if (!user) {
104+
failed.push({ email, reason: 'User record not found' });
105+
continue;
106+
}
107+
108+
// prevent users already on a team
109+
if (user.confirmed_team) {
110+
failed.push({ email, reason: 'Already a confirmed team member' });
111+
continue;
112+
}
113+
114+
// prevent duplicate invites (type the pending list instead of using any)
115+
const pending = (user.team_info?.pending_invites ?? []) as TeamInvite[];
116+
if (pending.some((inv) => inv.team_id === teamId)) {
117+
failed.push({ email, reason: 'Already invited to this team' });
118+
continue;
119+
}
120+
121+
// all checks pass -> enqueue invite
122+
await users.updateOne(
123+
{ email },
124+
{
125+
$push: {
126+
'team_info.pending_invites': {
127+
team_id: teamId,
128+
invited_by: authEmail,
129+
invited_at: new Date(),
130+
team_name: team.team_name,
131+
} as TeamInvite,
132+
},
133+
},
134+
{ session }
135+
);
136+
invited.push(email);
137+
}
138+
});
139+
} catch (err) {
140+
// eslint-disable-next-line no-console
141+
console.error('Transaction aborted:', err);
142+
return {
143+
statusCode: 500,
144+
body: JSON.stringify({
145+
message: 'Internal server error during invitation processing',
146+
}),
147+
};
148+
} finally {
149+
await session.endSession();
150+
}
151+
152+
return {
153+
statusCode: 200,
154+
body: JSON.stringify({
155+
message: 'Invitations sent successfully',
156+
invited,
157+
failed,
158+
}),
159+
};
160+
};
161+
162+
export const main = middyfy(teamInvite);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { handlerPath } from '@libs/handler-resolver';
2+
import schema from './schema';
3+
4+
export default {
5+
handler: `${handlerPath(__dirname)}/handler.main`,
6+
events: [
7+
{
8+
http: {
9+
method: 'post',
10+
path: 'teams/invite',
11+
cors: true,
12+
request: {
13+
schemas: {
14+
'application/json': schema,
15+
},
16+
},
17+
},
18+
},
19+
],
20+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export default {
2+
type: 'object',
3+
properties: {
4+
auth_token: { type: 'string' },
5+
auth_email: { type: 'string', format: 'email' },
6+
team_id: { type: 'string' },
7+
emails: {
8+
type: 'array',
9+
items: { type: 'string', format: 'email' },
10+
minItems: 1,
11+
},
12+
},
13+
required: ['auth_token', 'auth_email', 'team_id', 'emails'],
14+
} as const;

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ export interface TeamInvite {
55
team_name: string;
66
}
77

8+
export interface Failure {
9+
email: string;
10+
reason: string;
11+
}
12+
813
export interface UserTeamInfo {
914
team_id: string | null;
1015
role: 'leader' | 'member' | null;

0 commit comments

Comments
 (0)