diff --git a/src/controllers/member.controller.ts b/src/controllers/member.controller.ts index ca5d5ca..c89ff64 100644 --- a/src/controllers/member.controller.ts +++ b/src/controllers/member.controller.ts @@ -46,7 +46,7 @@ export const createAMember = (supabase: SupabaseClient) => async (req: Request, res: Response) => { const {email, name, password, passoutYear, provider} = req.body; - if (!email || !name || !password || !passoutYear) { + if (!email || !name || !password || !passoutYear || !provider) { throw new ApiError("Required fields absent", 400); } @@ -89,7 +89,8 @@ export const updateAMember = } if (imageUrl) parsedBody.profilePhoto = imageUrl; - await memberService.updateMember(memberId, parsedBody); + if(parsedBody.password) await memberService.updatePassword(memberId, parsedBody.password); + else await memberService.updateMember(memberId, parsedBody); const updatedData = await memberService.getDetails(memberId); res diff --git a/src/routes/members.ts b/src/routes/members.ts index b2000b2..87d9962 100644 --- a/src/routes/members.ts +++ b/src/routes/members.ts @@ -15,6 +15,9 @@ export default function membersRouter( * @apiGroup Member * * @apiSuccess {Object[]} unapprovedMembers List of unapproved members. + * + * @apiExample {curl} Example usage: + * curl -X GET http://localhost:3000/members/unapproved */ router.get("/unapproved", memberCtrl.getUnapprovedMembers); @@ -27,6 +30,9 @@ export default function membersRouter( * * @apiSuccess {Object} user Member object. * @apiError (Error 400) BadRequest No memberId provided. + * + * @apiExample {curl} Example usage: + * curl -X GET http://localhost:3000/members/123 */ router.get("/:memberId", memberCtrl.getUserDetails); @@ -35,17 +41,23 @@ export default function membersRouter( * @apiName ListAllApprovedMembers * @apiGroup Member * - * @apiDescription + * @apiDescription * - Returns a list of all approved members if no email query parameter is provided. * - If `email` query parameter is provided, returns the member associated with that email. * - * @apiQuery {String} [email] Optional email to fetch a specific member. + * @apiQuery {String} [email] Optional email to fetch a specific member. * * @apiSuccess {Object} user Single user object when email provided. -+ * @apiSuccess {Object[]} user Array of approved members when no email provided. + * @apiSuccess {Object[]} user Array of approved members when no email provided. * @apiSuccess {String} [message] Message in case of full list fetch. * * @apiError (400) IncorrectEmail The provided email does not match any user. + * + * @apiExample {curl} Example usage (list all): + * curl -X GET http://localhost:3000/members + * + * @apiExample {curl} Example usage (get by email): + * curl -X GET "http://localhost:3000/members?email=john@example.com" */ router.get("/", memberCtrl.listAllApprovedMembers); @@ -54,14 +66,27 @@ export default function membersRouter( * @apiName CreateAMember * @apiGroup Member * - * @apiBody {String} email Email of the member. - * @apiBody {String} name Full name of the member. - * @apiBody {String} password Member's password. - * @apiBody {String} passoutYear Graduation year. - * @apiBody {String} imageUrl profile photo of the member. + * @apiBody {String} email Email of the member. (Required) + * @apiBody {String} name Full name of the member. (Required) + * @apiBody {String} password Member's password. (Required) + * @apiBody {String} passoutYear Graduation year (Required, e.g., "2026"). + * @apiBody {String} provider Authentication provider (Required, e.g., "local", "google"). + * @apiBody {File} [file] Profile photo file (field name: "file"). + * + * @apiSuccess {Boolean} success Request status. + * @apiSuccess {Object} user Created member object. * - * @apiSuccess {Object} user Created member object. - * @apiError (Error 402) ValidationError Required fields missing. + * @apiError (Error 400) ApiError Required fields absent. + * @apiError (Error 500) ServerError Error creating user. + * + * @apiExample {curl} Example usage: + * curl -X POST -F "file=@profile.jpg" \ + * -F "email=john@example.com" \ + * -F "name=John Doe" \ + * -F "password=securePass123" \ + * -F "passoutYear=2026" \ + * -F "provider=local" \ + * http://localhost:3000/members */ router.post("/", upload.single("file"), memberCtrl.createAMember(supabase)); @@ -72,24 +97,34 @@ export default function membersRouter( * * @apiParam {String} memberId Member's unique ID. * + * @apiBody {String} memberData JSON string containing the member's updated details. * @apiBody {File} [file] Profile photo file (field name: "file"). - * @apiBody {String} [name] Full name of the member. - * @apiBody {String} [email] Email address. - * @apiBody {String} [phone] Phone number. - * @apiBody {String} [bio] Short bio. - * @apiBody {String} [github] GitHub handle. - * @apiBody {String} [linkedin] LinkedIn handle. - * @apiBody {String} [twitter] Twitter handle. - * @apiBody {String} [geeksforgeeks] GeeksforGeeks username. - * @apiBody {String} [leetcode] LeetCode username. - * @apiBody {String} [codechef] CodeChef username. - * @apiBody {String} [codeforces] Codeforces username. - * @apiBody {Date} [passoutYear] Graduation year (ISO string format). - * - * @apiSuccess {Object} member Updated member object. + * + * @apiBody (memberData fields) {String} [name] Full name of the member. + * @apiBody (memberData fields) {String} [email] Email address. + * @apiBody (memberData fields) {String} [phone] Phone number. + * @apiBody (memberData fields) {String} [bio] Short bio. + * @apiBody (memberData fields) {String} [github] GitHub handle. + * @apiBody (memberData fields) {String} [linkedin] LinkedIn handle. + * @apiBody (memberData fields) {String} [twitter] Twitter handle. + * @apiBody (memberData fields) {String} [geeksforgeeks] GeeksforGeeks username. + * @apiBody (memberData fields) {String} [leetcode] LeetCode username. + * @apiBody (memberData fields) {String} [codechef] CodeChef username. + * @apiBody (memberData fields) {String} [codeforces] Codeforces username. + * @apiBody (memberData fields) {Date} [passoutYear] Graduation year (ISO string format). + * @apiBody (memberData fields) {String} [profilePhoto] (Auto-assigned if a new file is uploaded). + * + * @apiSuccess {Boolean} success Request status. + * @apiSuccess {Object} user Updated member object. + * + * @apiError (Error 400) ApiError No memberId provided or invalid request data. * @apiError (Error 404) NotFound Member not found. - * @apiError (Error 400) ValidationError Invalid or missing fields. * @apiError (Error 500) ServerError Unexpected error occurred during update. + * + * @apiExample {curl} Example usage: + * curl -X PATCH -F "file=@profile.jpg" \ + * -F 'memberData={"name":"John Doe","email":"john@example.com"}' \ + * http://localhost:3000/members/123 */ router.patch( "/:memberId", @@ -97,39 +132,52 @@ export default function membersRouter( memberCtrl.updateAMember(supabase), ); - /** - * @api {patch} /members/approve/:memberId Approve/reject a member + +/** + * @api {patch} /members/approve/:memberId Approve a member * @apiName UpdateApprovalRequest * @apiGroup Member * * @apiParam (URL Params) {String} memberId Member ID. - * @apiBody {Boolean} isApproved Approval status. - * @apiBody {String} adminId Admin who approved. + * @apiBody {Boolean} isApproved Approval status (true = approved, false = rejected). + * @apiBody {String} adminId ID of the admin who approved/rejected. * - * @apiSuccess {Object} update Approval status updated. + * @apiSuccess {Object} update Approval status update result. * @apiError (Error 400) BadRequest Missing required fields. + * + * @apiExample {curl} Example usage: + * curl -X PATCH http://localhost:3000/members/approve/123 \ + * -H "Content-Type: application/json" \ + * -d '{"isApproved": true, "adminId": "admin123"}' */ router.patch("/approve/:memberId", memberCtrl.updateRequest); - /** - * @api {get} /members/:memberId/achievements Get member's achievements - * @apiName GetUserAchievements - * @apiGroup Member - * - * @apiParam (URL Params) {String} memberId Member ID. - * - * @apiSuccess {Object[]} achievements List of achievements. - */ + +/** + * @api {get} /members/:memberId/achievements Get member's achievements + * @apiName GetUserAchievements + * @apiGroup Member + * + * @apiParam (URL Params) {String} memberId Member ID. + * + * @apiSuccess {Object[]} achievements List of achievements. + * + * @apiExample {curl} Example usage: + * curl -X GET http://localhost:3000/members/123/achievements + */ router.get("/:memberId/achievements", memberCtrl.getUserAchievements); /** - * @api {get} /api/members/:memberId/projects Get member's projects + * @api {get} /members/:memberId/projects Get member's projects * @apiName GetUserProjects * @apiGroup Member * * @apiParam (URL Params) {String} memberId Member ID. * * @apiSuccess {Object[]} projects List of projects. + * + * @apiExample {curl} Example usage: + * curl -X GET http://localhost:3000/members/123/projects */ router.get("/:memberId/projects", memberCtrl.getUserProjects); @@ -141,6 +189,9 @@ export default function membersRouter( * @apiParam (URL Params) {String} memberId Member ID. * * @apiSuccess {Object[]} interviews List of interviews. + * + * @apiExample {curl} Example usage: + * curl -X GET http://localhost:3000/members/123/interviews */ router.get("/:memberId/interviews", memberCtrl.getUserInterviews); diff --git a/src/services/member.service.ts b/src/services/member.service.ts index f473080..6f0e96b 100644 --- a/src/services/member.service.ts +++ b/src/services/member.service.ts @@ -66,26 +66,15 @@ export const createMember = async ( export const updateMember = async ( id: string, - payload: UpdateMemberPayload, + payload: UpdateMemberPayload ) => { const { name, ...rest } = payload; - const member = await prisma.member.findUnique({ - where: { - id: id, - }, - }); - - if (!member) { - throw new ApiError("Member not found", 404); - } - const dataToUpdate = Object.fromEntries( - Object.entries(rest).filter(([_, v]) => v !== undefined), + Object.entries({ name, ...rest }).filter(([_, v]) => v !== undefined) ); - if (JSON.stringify(dataToUpdate) === "{}") - throw new ApiError("No fields passed", 400); + if (JSON.stringify(dataToUpdate) === "{}") throw new ApiError("No fields passed", 400); return await prisma.member.update({ where: { id }, @@ -93,6 +82,19 @@ export const updateMember = async ( }); }; +export const updatePassword = async(id: string, password: string) => { + const account = await prisma.account.findFirst({ + where: { memberId: id }, + }); + + if (!account) throw new ApiError("Associated account not found", 404); + + return await prisma.account.update({ + where: { id: account.id }, + data: { password }, + }); +} + export const unapprovedMembers = async () => { return await prisma.member.findMany({ where: { isApproved: false }, diff --git a/src/types/members.d.ts b/src/types/members.d.ts index 1dae89b..4220941 100644 --- a/src/types/members.d.ts +++ b/src/types/members.d.ts @@ -15,5 +15,6 @@ declare global { leetcode?: string; codechef?: string; codeforces?: string; + password?: string } } diff --git a/tests/Member.test.ts b/tests/Member.test.ts index fb40180..b82e244 100644 --- a/tests/Member.test.ts +++ b/tests/Member.test.ts @@ -13,10 +13,15 @@ jest.mock('../src/db/client', () => ({ create: jest.fn(), delete: jest.fn(), }, + account: { + findFirst: jest.fn(), + update: jest.fn(), + }, }, })); jest.mock('../src/utils/imageUtils'); + const mockSupabase = {} as SupabaseClient; const mockResponse = (): Response => { @@ -31,13 +36,14 @@ describe('Member Controller, createAMember', () => { jest.clearAllMocks(); }); - it('should respond with 201 and created member', async () => { + it('should respond with 201 and created member', async () => { const req = { body: { email: 'shruti@example.com', name: 'Shruti', password: 'password123', passoutYear: '2026', + provider: 'credentials' }, file: undefined, } as unknown as Request; @@ -50,7 +56,7 @@ describe('Member Controller, createAMember', () => { const handler = createAMember(mockSupabase); await handler(req, res); - expect(res.status).toHaveBeenCalledWith(201); + expect(res.status).toHaveBeenCalledWith(201); expect(res.json).toHaveBeenCalledWith({ success: true, user: mockUser }); }); @@ -71,7 +77,7 @@ describe('Member Controller - updateAMember', () => { it('should update member and return updated data (no image)', async () => { const req = { params: { memberId: 'abc-123' }, - body: { memberData: JSON.stringify({ github: 'https://github.com/shrutii' }) }, + body: { memberData: JSON.stringify({ github: 'https://github.com/shrutii' }) }, file: undefined, } as unknown as Request; @@ -81,10 +87,10 @@ describe('Member Controller - updateAMember', () => { id: '123', name: 'Test User', email: 'test@example.com', + github: 'https://github.com/shrutii', + profilePhoto: null, phone: null, bio: null, - profilePhoto: null, - github: 'https://github.com/shrutii', linkedin: null, twitter: null, leetcode: null, @@ -100,14 +106,14 @@ describe('Member Controller - updateAMember', () => { updatedAt: new Date(), }; - const spyUpdate = jest.spyOn(memberService, 'updateMember').mockResolvedValue(updatedMember); - const spyGet = jest.spyOn(memberService, 'getDetails').mockResolvedValue(updatedMember); + jest.spyOn(memberService, 'updateMember').mockResolvedValue(updatedMember); + jest.spyOn(memberService, 'getDetails').mockResolvedValue(updatedMember); const handler = updateAMember(mockSupabase); await handler(req, res); - expect(spyUpdate).toHaveBeenCalledWith('abc-123', { github: 'https://github.com/shrutii' }); - expect(spyGet).toHaveBeenCalledTimes(1); + expect(memberService.updateMember).toHaveBeenCalledWith('abc-123', { github: 'https://github.com/shrutii' }); + expect(memberService.getDetails).toHaveBeenCalledTimes(1); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ success: true, @@ -152,15 +158,13 @@ describe('Member Controller - updateAMember', () => { profilePhoto: 'https://new.url/image.png', }; - (uploadImage as jest.Mock) - .mockResolvedValueOnce('https://new.url/image.png'); + (uploadImage as jest.Mock).mockResolvedValueOnce('https://new.url/image.png'); jest.spyOn(memberService, 'getDetails') - .mockResolvedValueOnce(oldMember) - .mockResolvedValueOnce(updatedMember); + .mockResolvedValueOnce(oldMember) + .mockResolvedValueOnce(updatedMember); - const spyUpdate = jest - .spyOn(memberService, 'updateMember') + jest.spyOn(memberService, 'updateMember') .mockResolvedValue(updatedMember); const handler = updateAMember(mockSupabase); @@ -173,11 +177,57 @@ describe('Member Controller - updateAMember', () => { 'https://old.url/image.png' ); + expect(memberService.updateMember).toHaveBeenCalledWith('abc-123', { + }); - expect(spyUpdate).toHaveBeenCalledWith('abc-123', { - + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + user: updatedMember, }); + }); + + it('should update password when password is provided in memberData', async () => { + const req = { + params: { memberId: 'abc-123' }, + body: { memberData: JSON.stringify({ password: 'newSecurePass123' }) }, + file: undefined, + } as unknown as Request; + + const res = mockResponse(); + + const updatedMember = { + id: '123', + name: 'Updated User', + email: 'updated@example.com', + profilePhoto: null, + github: null, + phone: null, + bio: null, + linkedin: null, + twitter: null, + leetcode: null, + codeforces: null, + codechef: null, + gfg: null, + geeksforgeeks: null, + passoutYear: new Date('2025-05-31'), + isManager: false, + isApproved: false, + approvedById: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + // Mock updatePassword and getDetails + jest.spyOn(memberService, 'updatePassword').mockResolvedValue({} as any); + jest.spyOn(memberService, 'getDetails').mockResolvedValue(updatedMember); + + const handler = updateAMember(mockSupabase); + await handler(req, res); + expect(memberService.updatePassword).toHaveBeenCalledWith('abc-123', 'newSecurePass123'); + expect(memberService.getDetails).toHaveBeenCalledTimes(1); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ success: true,