From b3729712fa9416996b37d5aae1b3ce2f1acdc920 Mon Sep 17 00:00:00 2001 From: jxuistrying Date: Sun, 31 May 2026 14:46:24 -0400 Subject: [PATCH 1/3] promote admin to volunteer feature --- apps/backend/src/auth/auth.service.ts | 40 ++++++ .../src/users/dtos/update-user-role.dto.ts | 8 ++ .../src/users/users.controller.spec.ts | 71 ++++++++++- apps/backend/src/users/users.controller.ts | 14 +++ apps/backend/src/users/users.service.spec.ts | 114 ++++++++++++++++++ apps/backend/src/users/users.service.ts | 50 +++++++- apps/frontend/src/api/apiClient.ts | 6 + .../forms/promoteVolunteerModal.tsx | 78 ++++++++++++ .../src/containers/volunteerManagement.tsx | 104 +++++++++++++--- 9 files changed, 462 insertions(+), 23 deletions(-) create mode 100644 apps/backend/src/users/dtos/update-user-role.dto.ts create mode 100644 apps/frontend/src/components/forms/promoteVolunteerModal.tsx diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index d03fcfde7..762edceaa 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -8,6 +8,11 @@ import { AdminCreateUserCommand, } from '@aws-sdk/client-cognito-identity-provider'; +import { + AdminAddUserToGroupCommand, + AdminRemoveUserFromGroupCommand, +} from '@aws-sdk/client-cognito-identity-provider'; + import CognitoAuthConfig from './aws-exports'; import { SignUpDto } from './dtos/sign-up.dto'; import { createHmac } from 'crypto'; @@ -70,4 +75,39 @@ export class AuthService { } } } + + async addUserToGroup(username: string, groupName: string): Promise { + const command = new AdminAddUserToGroupCommand({ + UserPoolId: CognitoAuthConfig.userPoolId, + Username: username, + GroupName: groupName, + }); + + try { + await this.providerClient.send(command); + } catch (error) { + throw new InternalServerErrorException( + `Failed to add user to group ${groupName}`, + ); + } + } + + async removeUserFromGroup( + username: string, + groupName: string, + ): Promise { + const command = new AdminRemoveUserFromGroupCommand({ + UserPoolId: CognitoAuthConfig.userPoolId, + Username: username, + GroupName: groupName, + }); + + try { + await this.providerClient.send(command); + } catch (error) { + throw new InternalServerErrorException( + `Failed to remove user from group ${groupName}`, + ); + } + } } diff --git a/apps/backend/src/users/dtos/update-user-role.dto.ts b/apps/backend/src/users/dtos/update-user-role.dto.ts new file mode 100644 index 000000000..f9cebe9f0 --- /dev/null +++ b/apps/backend/src/users/dtos/update-user-role.dto.ts @@ -0,0 +1,8 @@ +import { IsEnum, IsNotEmpty } from 'class-validator'; +import { Role } from '../types'; + +export class UpdateUserRoleDto { + @IsEnum(Role) + @IsNotEmpty() + role!: Role; +} diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index fd923d516..47ea754a1 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -6,7 +6,8 @@ import { userSchemaDto } from './dtos/userSchema.dto'; import { Test, TestingModule } from '@nestjs/testing'; import { mock } from 'jest-mock-extended'; import { UpdateUserInfoDto } from './dtos/update-user-info.dto'; -import { BadRequestException } from '@nestjs/common'; +import { UpdateUserRoleDto } from './dtos/update-user-role.dto'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { AuthenticatedRequest } from '../auth/authenticated-request'; const mockUserService = mock(); @@ -31,6 +32,7 @@ describe('UsersController', () => { mockUserService.create.mockReset(); mockUserService.getUserDashboardStats.mockReset(); mockUserService.getRecentPendingApplications.mockReset(); + mockUserService.promoteVolunteerToAdmin.mockReset(); const module: TestingModule = await Test.createTestingModule({ controllers: [UsersController], @@ -211,4 +213,71 @@ describe('UsersController', () => { expect(result).toEqual([]); }); }); + + describe('PATCH /:id/role', () => { + it('should promote volunteer to admin successfully', async () => { + const promotedUser: Partial = { + ...mockUser1, + role: Role.ADMIN, + }; + + const dto: UpdateUserRoleDto = { role: Role.ADMIN }; + + mockUserService.promoteVolunteerToAdmin.mockResolvedValueOnce( + promotedUser as User, + ); + + const result = await controller.promoteToAdmin(1, dto); + + expect(result).toEqual(promotedUser); + expect(result.role).toBe(Role.ADMIN); + expect(mockUserService.promoteVolunteerToAdmin).toHaveBeenCalledWith(1); + }); + + it('should throw BadRequestException when role is not admin', async () => { + const dto: UpdateUserRoleDto = { role: Role.VOLUNTEER }; + + await expect(controller.promoteToAdmin(1, dto)).rejects.toThrow( + new BadRequestException('Only promotion to admin is supported'), + ); + + expect(mockUserService.promoteVolunteerToAdmin).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException when role is pantry', async () => { + const dto: UpdateUserRoleDto = { role: Role.PANTRY }; + + await expect(controller.promoteToAdmin(1, dto)).rejects.toThrow( + new BadRequestException('Only promotion to admin is supported'), + ); + + expect(mockUserService.promoteVolunteerToAdmin).not.toHaveBeenCalled(); + }); + + it('should throw NotFoundException from service when user not found', async () => { + const dto: UpdateUserRoleDto = { role: Role.ADMIN }; + + mockUserService.promoteVolunteerToAdmin.mockRejectedValueOnce( + new NotFoundException('User 999 not found'), + ); + + await expect(controller.promoteToAdmin(999, dto)).rejects.toThrow( + new NotFoundException('User 999 not found'), + ); + }); + + it('should throw BadRequestException from service when user is not a volunteer', async () => { + const dto: UpdateUserRoleDto = { role: Role.ADMIN }; + + mockUserService.promoteVolunteerToAdmin.mockRejectedValueOnce( + new BadRequestException( + 'User 1 is not a volunteer. Current role: admin', + ), + ); + + await expect(controller.promoteToAdmin(1, dto)).rejects.toThrow( + BadRequestException, + ); + }); + }); }); diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index cf27de69b..713694b7a 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Controller, Delete, Get, @@ -14,6 +15,7 @@ import { UsersService } from './users.service'; import { User } from './users.entity'; import { userSchemaDto } from './dtos/userSchema.dto'; import { UpdateUserInfoDto } from './dtos/update-user-info.dto'; +import { UpdateUserRoleDto } from './dtos/update-user-role.dto'; import { PendingApplication, Role } from './types'; import { AuthenticatedRequest } from '../auth/authenticated-request'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; @@ -53,6 +55,18 @@ export class UsersController { return this.usersService.update(id, dto); } + @Patch('/:id/role') + @Roles(Role.ADMIN) + async promoteToAdmin( + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdateUserRoleDto, + ): Promise { + if (dto.role !== Role.ADMIN) { + throw new BadRequestException('Only promotion to admin is supported'); + } + return this.usersService.promoteVolunteerToAdmin(id); + } + // Keeping these two as functionality seems useful @Post('/') async createUser(@Body() createUserDto: userSchemaDto): Promise { diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index c81a6664b..306485866 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -37,6 +37,8 @@ jest.setTimeout(60000); const mockAuthService = { adminCreateUser: jest.fn().mockResolvedValue('mock-sub'), + addUserToGroup: jest.fn().mockResolvedValue(undefined), + removeUserFromGroup: jest.fn().mockResolvedValue(undefined), }; const mockEmailsService = mock(); @@ -125,6 +127,8 @@ describe('UsersService', () => { beforeEach(async () => { mockAuthService.adminCreateUser.mockClear(); + mockAuthService.addUserToGroup.mockClear(); + mockAuthService.removeUserFromGroup.mockClear(); mockEmailsService.sendEmails.mockClear(); await testDataSource.runMigrations(); }); @@ -730,4 +734,114 @@ describe('UsersService', () => { expect(types).toContain('food_manufacturer'); }); }); + + describe('promoteVolunteerToAdmin', () => { + it('should promote volunteer to admin successfully', async () => { + const volunteers = await testDataSource.getRepository(User).find({ + where: { role: Role.VOLUNTEER }, + }); + expect(volunteers.length).toBeGreaterThan(0); + const volunteer = volunteers[0]; + + const result = await service.promoteVolunteerToAdmin(volunteer.id); + + expect(result.role).toBe(Role.ADMIN); + expect(result.id).toBe(volunteer.id); + }); + + it('should clear volunteer pantry assignments after promotion', async () => { + const volunteer = await testDataSource.getRepository(User).findOne({ + where: { role: Role.VOLUNTEER }, + relations: ['pantries'], + }); + expect(volunteer).toBeDefined(); + + await service.promoteVolunteerToAdmin(volunteer!.id); + + const assignments = await testDataSource.query( + `SELECT * FROM volunteer_assignments WHERE volunteer_id = $1`, + [volunteer!.id], + ); + expect(assignments).toHaveLength(0); + }); + + it('should call Cognito addUserToGroup and removeUserFromGroup', async () => { + const volunteer = await testDataSource.getRepository(User).findOne({ + where: { role: Role.VOLUNTEER }, + }); + expect(volunteer).toBeDefined(); + + await service.promoteVolunteerToAdmin(volunteer!.id); + + if (volunteer!.userCognitoSub) { + expect(mockAuthService.addUserToGroup).toHaveBeenCalledWith( + volunteer!.email, + 'admin', + ); + expect(mockAuthService.removeUserFromGroup).toHaveBeenCalledWith( + volunteer!.email, + 'volunteer', + ); + } + }); + + it('should throw NotFoundException when user does not exist', async () => { + await expect(service.promoteVolunteerToAdmin(99999)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw BadRequestException when user is already admin', async () => { + const admin = await testDataSource.getRepository(User).findOne({ + where: { role: Role.ADMIN }, + }); + expect(admin).toBeDefined(); + + await expect(service.promoteVolunteerToAdmin(admin!.id)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException when user is pantry', async () => { + const pantryUser = await testDataSource.getRepository(User).findOne({ + where: { role: Role.PANTRY }, + }); + expect(pantryUser).toBeDefined(); + + await expect( + service.promoteVolunteerToAdmin(pantryUser!.id), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when user is food manufacturer', async () => { + const fmUser = await testDataSource.getRepository(User).findOne({ + where: { role: Role.FOODMANUFACTURER }, + }); + expect(fmUser).toBeDefined(); + + await expect(service.promoteVolunteerToAdmin(fmUser!.id)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should rollback if Cognito fails', async () => { + const volunteer = await testDataSource.getRepository(User).findOne({ + where: { role: Role.VOLUNTEER }, + }); + expect(volunteer).toBeDefined(); + + mockAuthService.addUserToGroup.mockRejectedValueOnce( + new Error('Cognito error'), + ); + + await expect( + service.promoteVolunteerToAdmin(volunteer!.id), + ).rejects.toThrow(InternalServerErrorException); + + const userAfter = await testDataSource.getRepository(User).findOne({ + where: { id: volunteer!.id }, + }); + expect(userAfter!.role).toBe(Role.VOLUNTEER); + }); + }); }); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index a5ea7db2d..65794e358 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -7,8 +7,8 @@ import { InternalServerErrorException, NotFoundException, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Between, In, Repository } from 'typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { Between, DataSource, In, Repository } from 'typeorm'; import { User } from './users.entity'; import { PendingApplication, Role } from './types'; import { validateId } from '../utils/validation.utils'; @@ -44,6 +44,8 @@ export class UsersService { private pantryRepo: Repository, @InjectRepository(FoodManufacturer) private fmRepo: Repository, + @InjectDataSource() + private dataSource: DataSource, private authService: AuthService, private emailsService: EmailsService, @Inject(forwardRef(() => PantriesService)) @@ -298,4 +300,48 @@ export class UsersService { throw new BadRequestException(`Unsupported role: ${user.role}`); } } + + async promoteVolunteerToAdmin(userId: number): Promise { + validateId(userId, 'User'); + + const user = await this.repo.findOne({ + where: { id: userId }, + relations: ['pantries'], + }); + + if (!user) { + throw new NotFoundException(`User ${userId} not found`); + } + + if (user.role !== Role.VOLUNTEER) { + throw new BadRequestException( + `User ${userId} is not a volunteer. Current role: ${user.role}`, + ); + } + + return this.dataSource.transaction(async (transactionManager) => { + const userRepo = transactionManager.getRepository(User); + + user.role = Role.ADMIN; + const savedUser = await userRepo.save(user); + + await transactionManager.query( + `DELETE FROM volunteer_assignments WHERE volunteer_id = $1`, + [userId], + ); + + if (user.userCognitoSub) { + try { + await this.authService.addUserToGroup(user.email, 'admin'); + await this.authService.removeUserFromGroup(user.email, 'volunteer'); + } catch (error) { + throw new InternalServerErrorException( + 'Failed to update Cognito groups. Please try again.', + ); + } + } + + return savedUser; + }); + } } diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 5e822dfb1..6d68018c2 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -282,6 +282,12 @@ export class ApiClient { .then((response) => response.data); } + public async promoteVolunteerToAdmin(userId: number): Promise { + return this.axiosInstance + .patch(`/api/users/${userId}/role`, { role: 'admin' }) + .then((response) => response.data); + } + public async getFoodRequest(requestId: number): Promise { return this.axiosInstance .get(`/api/requests/${requestId}`) diff --git a/apps/frontend/src/components/forms/promoteVolunteerModal.tsx b/apps/frontend/src/components/forms/promoteVolunteerModal.tsx new file mode 100644 index 000000000..53d574391 --- /dev/null +++ b/apps/frontend/src/components/forms/promoteVolunteerModal.tsx @@ -0,0 +1,78 @@ +import { Dialog, Text, Button, CloseButton } from '@chakra-ui/react'; +import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup'; + +interface PromoteVolunteerModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + volunteerName: string; +} + +const PromoteVolunteerModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + volunteerName, +}) => { + useModalBodyCleanup(); + + return ( + !e.open && onClose()} + > + + + + + + Promote user + + + + + + + + + Are you sure you want to promote {volunteerName} to admin status? + + + + + + + + + + + ); +}; + +export default PromoteVolunteerModal; diff --git a/apps/frontend/src/containers/volunteerManagement.tsx b/apps/frontend/src/containers/volunteerManagement.tsx index b0a2cb681..5bc70920e 100644 --- a/apps/frontend/src/containers/volunteerManagement.tsx +++ b/apps/frontend/src/containers/volunteerManagement.tsx @@ -12,11 +12,19 @@ import { ButtonGroup, IconButton, Link, + Menu, + Portal, } from '@chakra-ui/react'; -import { SearchIcon, ChevronRight, ChevronLeft } from 'lucide-react'; +import { + SearchIcon, + ChevronRight, + ChevronLeft, + EllipsisVertical, +} from 'lucide-react'; import { User } from '../types/types'; import ApiClient from '@api/apiClient'; import NewVolunteerModal from '@components/forms/addNewVolunteerModal'; +import PromoteVolunteerModal from '@components/forms/promoteVolunteerModal'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; import { getInitials, USER_ICON_COLORS } from '@utils/utils'; @@ -25,22 +33,24 @@ const VolunteerManagement: React.FC = () => { const [currentPage, setCurrentPage] = useState(1); const [volunteers, setVolunteers] = useState([]); const [searchName, setSearchName] = useState(''); + const [selectedVolunteer, setSelectedVolunteer] = useState(null); + const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false); const [errorAlertState, setErrorMessage] = useAlert(); const [successAlertState, setSuccessMessage] = useAlert(); const pageSize = 8; - useEffect(() => { - const fetchVolunteers = async () => { - try { - const allVolunteers = await ApiClient.getVolunteers(); - setVolunteers(allVolunteers); - } catch { - setErrorMessage('Error fetching volunteers'); - } - }; + const fetchVolunteers = async () => { + try { + const allVolunteers = await ApiClient.getVolunteers(); + setVolunteers(allVolunteers); + } catch { + setErrorMessage('Error fetching volunteers'); + } + }; + useEffect(() => { fetchVolunteers(); }, [setErrorMessage]); @@ -48,6 +58,20 @@ const VolunteerManagement: React.FC = () => { setCurrentPage(1); }, [searchName]); + const handlePromote = async () => { + if (!selectedVolunteer) return; + + try { + await ApiClient.promoteVolunteerToAdmin(selectedVolunteer.id); + setSuccessMessage( + `${selectedVolunteer.firstName} ${selectedVolunteer.lastName} has been promoted to admin.`, + ); + fetchVolunteers(); // Refresh list - promoted user will disappear + } catch { + setErrorMessage('Failed to promote volunteer to admin.'); + } + }; + const filteredVolunteers = volunteers.filter((a) => { const fullName = `${a.firstName} ${a.lastName}`.toLowerCase(); return fullName.includes(searchName.toLowerCase()); @@ -180,16 +204,44 @@ const VolunteerManagement: React.FC = () => { {volunteer.email} - - View Assigned Pantries - + + + View Assigned Pantries + + + + + + + + + + + { + setSelectedVolunteer(volunteer); + setIsPromoteModalOpen(true); + }} + > + Promote to Admin + + + + + + ))} @@ -249,6 +301,18 @@ const VolunteerManagement: React.FC = () => { + + {selectedVolunteer && ( + { + setIsPromoteModalOpen(false); + setSelectedVolunteer(null); + }} + onConfirm={handlePromote} + volunteerName={`${selectedVolunteer.firstName} ${selectedVolunteer.lastName}`} + /> + )} ); }; From 5b60321b97337a8afa12c1efc81288076640193e Mon Sep 17 00:00:00 2001 From: jxuistrying Date: Sun, 31 May 2026 15:07:53 -0400 Subject: [PATCH 2/3] fix pantries.service.spec.ts missing DataSource --- apps/backend/src/pantries/pantries.service.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 65a267ad6..28b2cbfe8 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PantriesService } from './pantries.service'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { In } from 'typeorm'; +import { DataSource, In } from 'typeorm'; import { Pantry } from './pantries.entity'; import { BadRequestException, @@ -145,6 +145,10 @@ describe('PantriesService', () => { provide: getRepositoryToken(FoodManufacturer), useValue: testDataSource.getRepository(FoodManufacturer), }, + { + provide: DataSource, + useValue: testDataSource, + }, ], }).compile(); From d932f0a3472a4c090bae15a6117a6c6b0c9e9149 Mon Sep 17 00:00:00 2001 From: jxuistrying Date: Sun, 31 May 2026 16:05:35 -0400 Subject: [PATCH 3/3] fix transaction rollback when Cognito fails --- apps/backend/src/users/users.service.spec.ts | 9 +++-- apps/backend/src/users/users.service.ts | 38 ++++++++++++-------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index 306485866..1f7625d47 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -825,11 +825,16 @@ describe('UsersService', () => { }); it('should rollback if Cognito fails', async () => { - const volunteer = await testDataSource.getRepository(User).findOne({ + const userRepo = testDataSource.getRepository(User); + const volunteer = await userRepo.findOne({ where: { role: Role.VOLUNTEER }, }); expect(volunteer).toBeDefined(); + // Set userCognitoSub so the Cognito code path is triggered + volunteer!.userCognitoSub = 'test-cognito-sub'; + await userRepo.save(volunteer!); + mockAuthService.addUserToGroup.mockRejectedValueOnce( new Error('Cognito error'), ); @@ -838,7 +843,7 @@ describe('UsersService', () => { service.promoteVolunteerToAdmin(volunteer!.id), ).rejects.toThrow(InternalServerErrorException); - const userAfter = await testDataSource.getRepository(User).findOne({ + const userAfter = await userRepo.findOne({ where: { id: volunteer!.id }, }); expect(userAfter!.role).toBe(Role.VOLUNTEER); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 65794e358..4d8fb3e51 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -319,29 +319,39 @@ export class UsersService { ); } - return this.dataSource.transaction(async (transactionManager) => { - const userRepo = transactionManager.getRepository(User); + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { user.role = Role.ADMIN; - const savedUser = await userRepo.save(user); + await queryRunner.manager.save(user); - await transactionManager.query( + await queryRunner.query( `DELETE FROM volunteer_assignments WHERE volunteer_id = $1`, [userId], ); if (user.userCognitoSub) { - try { - await this.authService.addUserToGroup(user.email, 'admin'); - await this.authService.removeUserFromGroup(user.email, 'volunteer'); - } catch (error) { - throw new InternalServerErrorException( - 'Failed to update Cognito groups. Please try again.', - ); - } + await this.authService.addUserToGroup(user.email, 'admin'); + await this.authService.removeUserFromGroup(user.email, 'volunteer'); } - return savedUser; - }); + await queryRunner.commitTransaction(); + return user; + } catch (error) { + await queryRunner.rollbackTransaction(); + if ( + error instanceof NotFoundException || + error instanceof BadRequestException + ) { + throw error; + } + throw new InternalServerErrorException( + 'Failed to promote volunteer to admin. Please try again.', + ); + } finally { + await queryRunner.release(); + } } }