diff --git a/serverless.ts b/serverless.ts index ae07e8c..062442d 100644 --- a/serverless.ts +++ b/serverless.ts @@ -27,6 +27,7 @@ import declineInvitation from '@functions/teams/decline-invite'; import teamsJoin from '@functions/teams/join'; import teamsRead from '@functions/teams/read'; import disband from '@functions/teams/disband'; +import teamLeave from '@functions/teams/leave'; import * as path from 'path'; import * as dotenv from 'dotenv'; @@ -80,6 +81,7 @@ const serverlessConfiguration: AWS = { teamsJoin, teamsRead, disband, + teamLeave, }, package: { individually: true, patterns: ['!.env*', '.env.vault'] }, custom: { diff --git a/src/functions/teams/leave/handler.ts b/src/functions/teams/leave/handler.ts new file mode 100644 index 0000000..6ac441a --- /dev/null +++ b/src/functions/teams/leave/handler.ts @@ -0,0 +1,132 @@ +import type { ValidatedEventAPIGatewayProxyEvent } from '@libs/api-gateway'; +import { middyfy } from '@libs/lambda'; +import schema from './schema'; +import { UserDocument, TeamDocument } from '../../../types'; +import { MongoDB, validateToken, disbandTeam } from '../../../util'; //change to actual disband +import * as path from 'path'; +import * as dotenv from 'dotenv'; + +//import fetch from 'node-fetch'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +const teamLeave: ValidatedEventAPIGatewayProxyEvent = async (event) => { + try { + const { auth_token: authToken, auth_email: authEmail, team_id: teamId } = event.body; + + // 1. Validate auth token + const tokenValid = validateToken(authToken, process.env.JWT_SECRET, authEmail); + if (!tokenValid) { + return { + statusCode: 401, + body: JSON.stringify({ statusCode: 401, message: 'Unauthorized' }), + }; + } + + // 2. connect to mongoDB + const db = MongoDB.getInstance(process.env.MONGO_URI); + await db.connect(); + const users = db.getCollection('users'); + const teams = db.getCollection('teams'); + + // 3. check if user exisits + const authUser = await users.findOne({ email: authEmail }); + if (!authUser) { + return { + statusCode: 404, + body: JSON.stringify({ statusCode: 404, message: 'Auth user not found' }), + }; + } + + // 4. check if team exisits + const team = await teams.findOne({ team_id: teamId }); + if (!team) { + return { + statusCode: 404, + body: JSON.stringify({ statusCode: 404, message: 'Team not found' }), + }; + } + + // 5. Check if team is disbanded + if (team.status == 'Disbanded') { + return { + statusCode: 400, + body: JSON.stringify({ statusCode: 400, message: 'Team already disbanded' }), + }; + } + + // 6. Check is teamlead is real + const teamAuth = await users.findOne({ email: team.leader_email }); + if (!teamAuth) { + return { + statusCode: 400, + body: JSON.stringify({ statusCode: 400, message: 'Invalid team lead' }), + }; + } + + // 7. No team members + if (team.members.length + 1 == 0) { + return { + statusCode: 400, + body: JSON.stringify({ statusCode: 400, message: 'Empty team member list' }), + }; + } + + // 8. Check if user is in team + if (!team.members.includes(authEmail) && team.leader_email !== authEmail) { + return { + statusCode: 400, + body: JSON.stringify({ statusCode: 400, message: 'User not in team' }), + }; + } + + //grabs team info object + const teamInfo = authUser.team_info; + + // 9. Check if user is team lead + if (teamInfo.role == 'leader') return await disbandTeam(authToken, authEmail, teamId); + + // 10. Remove user from team + await teams.updateOne( + { team_id: teamId, members: authUser.email }, + { + $pull: { + members: authUser.email, + }, + } + ); + + // 11. clear team_info and set confirmed_team + + authUser.confirmed_team = false; + authUser.team_info = { + team_id: '', + role: null, + pending_invites: [], + }; + + // 12. update the MongoDB user + await users.updateOne( + { email: authEmail }, + { + $set: { + confirmed_team: authUser.confirmed_team, + team_info: authUser.team_info, + }, + } + ); + + return { + statusCode: 200, + body: JSON.stringify({ message: 'Successfully left team' }), + }; + } catch (error) { + console.error('Error deleting team member:', error); + return { + statusCode: 500, + body: JSON.stringify({ statusCode: 500, message: 'Internal server error' }), + }; + } +}; + +export const main = middyfy(teamLeave); diff --git a/src/functions/teams/leave/index.ts b/src/functions/teams/leave/index.ts new file mode 100644 index 0000000..ec36806 --- /dev/null +++ b/src/functions/teams/leave/index.ts @@ -0,0 +1,20 @@ +import { handlerPath } from '@libs/handler-resolver'; +import schema from './schema'; + +export default { + handler: `${handlerPath(__dirname)}/handler.main`, + events: [ + { + http: { + method: 'post', + path: 'teams/leave', + cors: true, + request: { + schemas: { + 'application/json': schema, + }, + }, + }, + }, + ], +}; diff --git a/src/functions/teams/leave/schema.ts b/src/functions/teams/leave/schema.ts new file mode 100644 index 0000000..249de03 --- /dev/null +++ b/src/functions/teams/leave/schema.ts @@ -0,0 +1,9 @@ +export default { + type: 'object', + properties: { + auth_token: { type: 'string' }, + auth_email: { type: 'string', format: 'email' }, + team_id: { type: 'string' }, + }, + required: ['auth_token', 'auth_email', 'team_id'], +} as const; diff --git a/tests/teams-leave.test.ts b/tests/teams-leave.test.ts new file mode 100644 index 0000000..aa96c23 --- /dev/null +++ b/tests/teams-leave.test.ts @@ -0,0 +1,244 @@ +import { main } from '../src/functions/teams/leave/handler'; +import { validateToken, MongoDB, disbandTeam } from '../src/util'; +import { UserDocument, TeamDocument } from '../src/types'; +import { createEvent, mockContext } from './helper'; + +jest.mock('../src/util'); + +describe('teamLeave Lambda', () => { + const path = '/teams/leave'; + const httpMethod = 'POST'; + + interface MockCollection { + findOne: jest.Mock | null>, unknown[]>; + updateOne: jest.Mock, unknown[]>; + } + + let mockUsers: MockCollection; + let mockTeams: MockCollection; + + beforeEach(() => { + (validateToken as jest.Mock).mockReturnValue(true); + + (disbandTeam as jest.Mock) = jest.fn(); + + (disbandTeam as jest.Mock).mockResolvedValue({ + statusCode: 200, + body: JSON.stringify({ statusCode: 200, message: 'Mocked up function' }), + }); + + // mock user info + mockUsers = { + findOne: jest.fn(), + updateOne: jest.fn(), + }; + + mockTeams = { + findOne: jest.fn(), + updateOne: jest.fn(), + }; + + // Mock MongoDB.getInstance and collection access + (MongoDB.getInstance as jest.Mock).mockReturnValue({ + connect: jest.fn(), + getCollection: (name: string) => { + if (name === 'users') return mockUsers; + if (name === 'teams') return mockTeams; + }, + }); + }); + + // Case 1: invalid volidToken + it('returns 401 if token is invalid', async () => { + const userData = { + auth_token: 'token', + auth_email: 'user@example.com', + team_id: 'team123', + }; + + const mockEvent = createEvent(userData, path, httpMethod); + const mockCallback = jest.fn(); + + (validateToken as jest.Mock).mockReturnValue(false); + + const result = await main(mockEvent, mockContext, mockCallback); + + expect(result.statusCode).toBe(401); + expect(JSON.parse(result.body).message).toBe('Unauthorized'); + }); + + // Case 2: Team already Disbanded + it('return 400 disbanded team', async () => { + const userData = { + auth_token: 'token', + auth_email: 'leader@gmail.com', + team_id: 'team123', + }; + + const mockEvent = createEvent(userData, path, httpMethod); + const mockCallback = jest.fn(); + + mockUsers.findOne.mockResolvedValue({ + confirmed_team: false, + team_info: { + team_id: 'team1234', + role: 'leader', + pending_invites: [ + { team_id: 'fake-team', invited_by: 'test@example.com', invited_at: new Date(), team_name: 'Fake Team' }, + { team_id: 'fake-team', invited_by: 'test@example.com', invited_at: new Date(), team_name: 'Fake Team' }, + ], + }, + }); + mockTeams.findOne.mockResolvedValue({ + team_id: 'team1234', + leader_email: 'leader@gmail.com', + members: [], + status: 'Disbanded', + created: new Date(), + updated: new Date(), + }); + const result = await main(mockEvent, mockContext, mockCallback); + expect(result.statusCode).toBe(400); + expect(JSON.parse(result.body).message).toBe('Team already disbanded'); + }); + + // Case 3: memeber list is empty + it('return 400 empty team', async () => { + const userData = { + auth_token: 'token', + auth_email: 'leader@gmail.com', + team_id: 'team123', + }; + + const mockEvent = createEvent(userData, path, httpMethod); + const mockCallback = jest.fn(); + + mockUsers.findOne.mockResolvedValue({ + confirmed_team: false, + team_info: { + team_id: 'team1234', + role: 'leader', + pending_invites: [ + { team_id: 'fake-team', invited_by: 'test@example.com', invited_at: new Date(), team_name: 'Fake Team' }, + { team_id: 'fake-team', invited_by: 'test@example.com', invited_at: new Date(), team_name: 'Fake Team' }, + ], + }, + }); + mockTeams.findOne.mockResolvedValue({ + team_id: 'team1234', + leader_email: 'leader@gmail.com', + members: [], + status: 'Active', + created: new Date(), + updated: new Date(), + }); + const result = await main(mockEvent, mockContext, mockCallback); + expect(result.statusCode).toBe(400); + expect(JSON.parse(result.body).message).toBe('Empty team member list'); + }); + + // Case 4: member not in the team + it('return 400 user not in team', async () => { + const userData = { + auth_token: 'token', + auth_email: 'leader@gmail.com', + team_id: 'team123', + }; + + const mockEvent = createEvent(userData, path, httpMethod); + const mockCallback = jest.fn(); + + mockUsers.findOne.mockResolvedValue({ + confirmed_team: false, + team_info: { + team_id: 'team1234', + role: 'leader', + pending_invites: [ + { team_id: 'fake-team', invited_by: 'test@example.com', invited_at: new Date(), team_name: 'Fake Team' }, + { team_id: 'fake-team', invited_by: 'test@example.com', invited_at: new Date(), team_name: 'Fake Team' }, + ], + }, + }); + mockTeams.findOne.mockResolvedValue({ + team_id: 'team1234', + leader_email: 'leader@gmail.com', + members: ['member1@gmail.com', 'member2@gmail.com'], + status: 'Active', + created: new Date(), + updated: new Date(), + }); + const result = await main(mockEvent, mockContext, mockCallback); + expect(result.statusCode).toBe(400); + expect(JSON.parse(result.body).message).toBe('User not in team'); + }); + + // Case 5: team lead leaves + it('return 200 team lead leaves', async () => { + const userData = { + auth_token: 'token', + auth_email: 'leader@gmail.com', + team_id: 'team123', + }; + + const mockEvent = createEvent(userData, path, httpMethod); + const mockCallback = jest.fn(); + + mockUsers.findOne.mockResolvedValue({ + confirmed_team: false, + team_info: { + team_id: 'team1234', + role: 'leader', + pending_invites: [ + { team_id: 'fake-team', invited_by: 'test@example.com', invited_at: new Date(), team_name: 'Fake Team' }, + { team_id: 'fake-team', invited_by: 'test@example.com', invited_at: new Date(), team_name: 'Fake Team' }, + ], + }, + }); + mockTeams.findOne.mockResolvedValue({ + team_id: 'team1234', + leader_email: 'leader@gmail.com', + members: ['leader@gmail.com'], + status: 'Active', + created: new Date(), + updated: new Date(), + }); + const result = await main(mockEvent, mockContext, mockCallback); + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body).message).toBe('Mocked up function'); + }); + + // Case 6: Success + it('return 200 success', async () => { + const userData = { + auth_token: 'token', + auth_email: 'leader@gmail.com', + team_id: 'team123', + }; + + const mockEvent = createEvent(userData, path, httpMethod); + const mockCallback = jest.fn(); + + mockUsers.findOne.mockResolvedValue({ + confirmed_team: false, + team_info: { + team_id: 'team1234', + role: 'member', + pending_invites: [ + { team_id: 'fake-team', invited_by: 'test@example.com', invited_at: new Date(), team_name: 'Fake Team' }, + { team_id: 'fake-team', invited_by: 'test@example.com', invited_at: new Date(), team_name: 'Fake Team' }, + ], + }, + }); + mockTeams.findOne.mockResolvedValue({ + team_id: 'team1234', + leader_email: 'leader@gmail.com', + members: ['leader@gmail.com'], + status: 'Active', + created: new Date(), + updated: new Date(), + }); + const result = await main(mockEvent, mockContext, mockCallback); + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body).message).toBe('Successfully left team'); + }); +});