Skip to content

Commit e63e453

Browse files
authored
Merge pull request #132 from HackRU/team-invite-util-function
Refactor team invite handler to use util logic
2 parents d42111f + 4524e8f commit e63e453

3 files changed

Lines changed: 205 additions & 328 deletions

File tree

Lines changed: 2 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -1,162 +1,15 @@
1-
// src/functions/teams/invite/handler.ts
21
import * as dotenv from 'dotenv';
32
import * as path from 'path';
43
import type { ValidatedEventAPIGatewayProxyEvent } from '@libs/api-gateway';
54
import { middyfy } from '@libs/lambda';
65
import schema from './schema';
7-
import type { Failure, TeamInvite, TeamDocument, UserDocument } from '../../../types';
8-
import { MongoDB, validateToken, userExistsLogic } from '../../../util';
6+
import { teamInviteLogic } from '../../../util';
97

108
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
119

12-
const MAX_TEAM_SIZE = 4;
13-
1410
const teamInvite: ValidatedEventAPIGatewayProxyEvent<typeof schema> = async (event) => {
15-
// Alias snake_case request fields to camelCase locals to satisfy naming-convention
1611
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-
};
12+
return teamInviteLogic(authEmail, authToken, teamId, emails);
16013
};
16114

16215
export const main = middyfy(teamInvite);

src/util.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
22
import { MongoClient } from 'mongodb';
3+
import { UserDocument, TeamInvite, TeamDocument, Failure } from './types';
34
import type { Document } from 'mongodb';
45
import * as jwt from 'jsonwebtoken';
56
import type { JwtPayload } from 'jsonwebtoken';
@@ -168,6 +169,158 @@ export async function userExistsLogic(
168169
}
169170
}
170171

172+
export async function teamInviteLogic(
173+
authEmail: string,
174+
authToken: string,
175+
teamId: string,
176+
emails: string[]
177+
): Promise<{ statusCode: number; body: string }> {
178+
const MAX_TEAM_SIZE = 4;
179+
180+
// auth check
181+
if (!validateToken(authToken, process.env.JWT_SECRET!, authEmail)) {
182+
return {
183+
statusCode: 401,
184+
body: JSON.stringify({ message: 'Unauthorized' }),
185+
};
186+
}
187+
188+
// DB setup
189+
const db = MongoDB.getInstance(process.env.MONGO_URI!);
190+
await db.connect();
191+
const client = db.getClient();
192+
const users = db.getCollection<UserDocument>('users');
193+
const teams = db.getCollection<TeamDocument>('teams');
194+
195+
// verify auth user
196+
const authUser = await users.findOne({ email: authEmail });
197+
if (!authUser) {
198+
return {
199+
statusCode: 404,
200+
body: JSON.stringify({ message: 'Auth user not found' }),
201+
};
202+
}
203+
204+
// verify team & leadership
205+
const team = await teams.findOne({ team_id: teamId });
206+
if (!team) {
207+
return {
208+
statusCode: 404,
209+
body: JSON.stringify({ message: 'Team not found' }),
210+
};
211+
}
212+
if (team.leader_email !== authEmail) {
213+
return {
214+
statusCode: 403,
215+
body: JSON.stringify({ message: 'Auth user is not the team leader' }),
216+
};
217+
}
218+
219+
// check team status
220+
if (team.status !== 'Active') {
221+
return {
222+
statusCode: 400,
223+
body: JSON.stringify({ message: 'Team is not active' }),
224+
};
225+
}
226+
227+
// capacity check
228+
const confirmedCount = (Array.isArray(team.members) ? team.members.length : 0) + 1; // + 1 for the leader
229+
const pendingCount = await users.countDocuments({
230+
'team_info.pending_invites.team_id': teamId,
231+
});
232+
const availableSlots = MAX_TEAM_SIZE - confirmedCount - pendingCount;
233+
if (availableSlots <= 0) {
234+
return {
235+
statusCode: 400,
236+
body: JSON.stringify({ message: 'Team is already full' }),
237+
};
238+
}
239+
240+
const invited: string[] = [];
241+
const failed: Failure[] = [];
242+
243+
// use a transaction for all updates
244+
const session = client.startSession();
245+
try {
246+
await session.withTransaction(async () => {
247+
const uniqueEmails = Array.from(new Set(emails));
248+
249+
for (const email of uniqueEmails) {
250+
// stop when no slots remain
251+
if (invited.length >= availableSlots) {
252+
failed.push({ email, reason: 'No slots remaining' });
253+
continue;
254+
}
255+
256+
// check user exists via util
257+
const { statusCode: uxStatus } = await userExistsLogic(authEmail, authToken, email);
258+
if (uxStatus !== 200) {
259+
failed.push({ email, reason: 'User does not exist or unauthorized' });
260+
continue;
261+
}
262+
263+
// load user within transaction
264+
const user = await users.findOne({ email }, { session });
265+
if (!user) {
266+
failed.push({ email, reason: 'User record not found' });
267+
continue;
268+
}
269+
270+
// prevent users already on a team
271+
if (user.confirmed_team) {
272+
failed.push({ email, reason: 'Already a confirmed team member' });
273+
continue;
274+
}
275+
276+
// prevent duplicate invites (type the pending list instead of using any)
277+
const pending = (user.team_info?.pending_invites ?? []) as TeamInvite[];
278+
if (pending.some((inv) => inv.team_id === teamId)) {
279+
failed.push({ email, reason: 'Already invited to this team' });
280+
continue;
281+
}
282+
283+
// all checks pass -> enqueue invite
284+
await users.updateOne(
285+
{ email },
286+
{
287+
$push: {
288+
'team_info.pending_invites': {
289+
team_id: teamId,
290+
invited_by: authEmail,
291+
invited_at: new Date(),
292+
team_name: team.team_name,
293+
} as TeamInvite,
294+
},
295+
},
296+
{ session }
297+
);
298+
invited.push(email);
299+
}
300+
});
301+
} catch (err) {
302+
// eslint-disable-next-line no-console
303+
console.error('Transaction aborted:', err);
304+
return {
305+
statusCode: 500,
306+
body: JSON.stringify({
307+
message: 'Internal server error during invitation processing',
308+
}),
309+
};
310+
} finally {
311+
await session.endSession();
312+
}
313+
314+
return {
315+
statusCode: 200,
316+
body: JSON.stringify({
317+
message: 'Invitations sent successfully',
318+
invited,
319+
failed,
320+
}),
321+
};
322+
}
323+
171324
export async function disbandTeam(
172325
auth_token: string,
173326
auth_email: string,

0 commit comments

Comments
 (0)