Skip to content

Commit bed5b64

Browse files
authored
Merge pull request #122 from HackRU/team-create
teams-create endpoint
2 parents e63e453 + 0888c2a commit bed5b64

7 files changed

Lines changed: 644 additions & 1 deletion

File tree

serverless.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import verifyEmail from '@functions/verify-email';
2121
import deleteUser from '@functions/delete';
2222
import userExists from '@functions/user-exists';
2323
import interestForm from '@functions/interest-form';
24+
import teamsCreate from '@functions/teams/create';
2425
import memberRemoval from '@functions/teams/member-removal';
2526
import declineInvitation from '@functions/teams/decline-invite';
2627
import teamsJoin from '@functions/teams/join';
@@ -73,6 +74,7 @@ const serverlessConfiguration: AWS = {
7374
deleteUser,
7475
userExists,
7576
interestForm,
77+
teamsCreate,
7678
memberRemoval,
7779
declineInvitation,
7880
teamsJoin,

src/functions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export { default as verifyEmail } from './verify-email';
1919
export { default as delete } from './delete';
2020
export { default as userExists } from './user-exists';
2121
export { default as interestForm } from './interest-form';
22+
export { default as teamsCreate } from './teams/create';
2223
export { default as teamsMemberRemoval } from './teams/member-removal';
2324
export { default as declineInvitation } from './teams/decline-invite';
2425
export { default as teamsJoin } from './teams/join';
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import type { ValidatedEventAPIGatewayProxyEvent } from '@libs/api-gateway';
2+
import { middyfy } from '@libs/lambda';
3+
import schema from './schema';
4+
import { MongoDB, validateToken, teamInviteLogic } from '../../../util';
5+
import { UserDocument, TeamDocument } from 'src/types';
6+
import { v4 as uuidv4 } from 'uuid';
7+
import * as path from 'path';
8+
import * as dotenv from 'dotenv';
9+
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
10+
11+
const teamsCreate: ValidatedEventAPIGatewayProxyEvent<typeof schema> = async (event) => {
12+
try {
13+
// Validate auth token
14+
const isValidToken = validateToken(event.body.auth_token, process.env.JWT_SECRET, event.body.auth_email);
15+
if (!isValidToken) {
16+
return {
17+
statusCode: 401,
18+
body: JSON.stringify({
19+
statusCode: 401,
20+
message: 'Unauthorized - Invalid token',
21+
}),
22+
};
23+
}
24+
25+
// Validate team name
26+
const teamName = event.body.team_name.trim();
27+
if (teamName.length === 0) {
28+
return {
29+
statusCode: 400,
30+
body: JSON.stringify({
31+
statusCode: 400,
32+
message: 'Team name cannot be empty',
33+
}),
34+
};
35+
}
36+
37+
if (teamName.length > 50) {
38+
return {
39+
statusCode: 400,
40+
body: JSON.stringify({
41+
statusCode: 400,
42+
message: 'Team name cannot exceed 50 characters',
43+
}),
44+
};
45+
}
46+
47+
// Check for invalid characters (only allow alphanumeric, spaces, hyphens, underscores)
48+
const validNamePattern = /^[a-zA-Z0-9\s\-_]+$/;
49+
if (!validNamePattern.test(teamName)) {
50+
return {
51+
statusCode: 400,
52+
body: JSON.stringify({
53+
statusCode: 400,
54+
message: 'Team name can only contain letters, numbers, spaces, hyphens, and underscores',
55+
}),
56+
};
57+
}
58+
59+
// Connect to database
60+
const db = MongoDB.getInstance(process.env.MONGO_URI);
61+
await db.connect();
62+
const users = db.getCollection<UserDocument>('users');
63+
const teams = db.getCollection<TeamDocument>('teams');
64+
65+
// Check if auth user exists
66+
const authUser = await users.findOne({ email: event.body.auth_email.toLowerCase() });
67+
if (!authUser) {
68+
return {
69+
statusCode: 404,
70+
body: JSON.stringify({
71+
statusCode: 404,
72+
message: 'Auth user not found',
73+
}),
74+
};
75+
}
76+
77+
// Check if user already leads a team
78+
if (authUser.team_info?.role === 'leader') {
79+
return {
80+
statusCode: 400,
81+
body: JSON.stringify({
82+
statusCode: 400,
83+
message: 'User already leads a team',
84+
}),
85+
};
86+
}
87+
88+
// Check if user is already a member of a team
89+
if (authUser.confirmed_team === true) {
90+
return {
91+
statusCode: 400,
92+
body: JSON.stringify({
93+
statusCode: 400,
94+
message: 'User is already part of a team',
95+
}),
96+
};
97+
}
98+
99+
// Validate all member emails exist using user-exists logic
100+
const memberEmails = event.body.members.map((email) => email.toLowerCase());
101+
const invalidEmails = [];
102+
const emailsAlreadyInTeams = [];
103+
104+
for (const email of memberEmails) {
105+
const user = await users.findOne({ email: email });
106+
if (!user) invalidEmails.push(email);
107+
else if (user.confirmed_team === true) emailsAlreadyInTeams.push(email);
108+
}
109+
110+
// Return errors if any validation failed
111+
if (invalidEmails.length > 0) {
112+
return {
113+
statusCode: 400,
114+
body: JSON.stringify({
115+
statusCode: 400,
116+
message: 'Some users do not exist',
117+
invalid_emails: invalidEmails,
118+
}),
119+
};
120+
}
121+
122+
if (emailsAlreadyInTeams.length > 0) {
123+
return {
124+
statusCode: 400,
125+
body: JSON.stringify({
126+
statusCode: 400,
127+
message: 'Some users are already part of teams',
128+
users_in_teams: emailsAlreadyInTeams,
129+
}),
130+
};
131+
}
132+
133+
// Validate team size (leader + members <= 4)
134+
if (memberEmails.length > 3) {
135+
return {
136+
statusCode: 400,
137+
body: JSON.stringify({
138+
statusCode: 400,
139+
message: 'Team size cannot exceed 4 members (including leader)',
140+
}),
141+
};
142+
}
143+
144+
// Generate unique team ID
145+
const teamId = uuidv4();
146+
147+
// Create team document (only with leader, members will be added via invitations)
148+
const teamDoc = {
149+
team_id: teamId,
150+
leader_email: event.body.auth_email.toLowerCase(),
151+
members: [],
152+
status: 'Active' as const,
153+
team_name: teamName,
154+
created: new Date(),
155+
updated: new Date(),
156+
};
157+
158+
// Use MongoDB transaction for atomic team creation
159+
const session = db.getClient().startSession();
160+
161+
try {
162+
await session.withTransaction(async () => {
163+
// Create team within transaction
164+
await teams.insertOne(teamDoc, { session });
165+
166+
// Update leader's user document within transaction
167+
await users.updateOne(
168+
{ email: event.body.auth_email.toLowerCase() },
169+
{
170+
$set: {
171+
confirmed_team: true,
172+
team_info: {
173+
team_id: teamId,
174+
role: 'leader' as const,
175+
pending_invites: [],
176+
},
177+
},
178+
},
179+
{ session }
180+
);
181+
182+
// Send invitations to all members via teamInviteLogic utility
183+
const inviteResult = await teamInviteLogic(event.body.auth_email, event.body.auth_token, teamId, memberEmails);
184+
185+
if (inviteResult.statusCode !== 200)
186+
throw new Error(`Team invite failed with status ${inviteResult.statusCode}: ${inviteResult.body}`);
187+
});
188+
} catch (transactionError) {
189+
console.error('Transaction failed:', transactionError);
190+
return {
191+
statusCode: 500,
192+
body: JSON.stringify({
193+
statusCode: 500,
194+
message: 'Failed to create team',
195+
error: transactionError.message,
196+
}),
197+
};
198+
} finally {
199+
await session.endSession();
200+
}
201+
202+
return {
203+
statusCode: 200,
204+
body: JSON.stringify({
205+
statusCode: 200,
206+
message: 'Team created successfully',
207+
team_id: teamId,
208+
}),
209+
};
210+
} catch (error) {
211+
console.error('Error creating team:', error);
212+
return {
213+
statusCode: 500,
214+
body: JSON.stringify({
215+
statusCode: 500,
216+
message: 'Internal server error',
217+
error: error.message,
218+
}),
219+
};
220+
}
221+
};
222+
223+
export const main = middyfy(teamsCreate);
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/create',
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_name: { type: 'string' },
7+
members: {
8+
type: 'array',
9+
items: { type: 'string', format: 'email' },
10+
maxItems: 3,
11+
},
12+
},
13+
required: ['auth_token', 'auth_email', 'team_name', 'members'],
14+
} as const;

src/util.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import * as jwt from 'jsonwebtoken';
66
import type { JwtPayload } from 'jsonwebtoken';
77
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
88
import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
9-
import { UserDocument, TeamDocument } from './types';
109
import * as path from 'path';
1110
import * as dotenv from 'dotenv';
1211

0 commit comments

Comments
 (0)