Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
import {
CognitoIdentityProviderClient,
AdminCreateUserCommand,
AdminAddUserToGroupCommand,
AdminRemoveUserFromGroupCommand,
} from '@aws-sdk/client-cognito-identity-provider';

import CognitoAuthConfig from './aws-exports';
Expand Down Expand Up @@ -70,4 +72,39 @@ export class AuthService {
}
}
}

async addUserToGroup(username: string, groupName: string): Promise<void> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see slack comments

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<void> {
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}`,
);
}
}
}
5 changes: 5 additions & 0 deletions apps/backend/src/foodRequests/request.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { FoodRequest } from './request.entity';
import { RequestsService } from './request.service';
import { Pantry } from '../pantries/pantries.entity';
Expand Down Expand Up @@ -66,6 +67,10 @@ describe('RequestsService', () => {
provide: EmailsService,
useValue: mockEmailsService,
},
{
provide: DataSource,
useValue: testDataSource,
},
],
}).compile();

Expand Down
6 changes: 5 additions & 1 deletion apps/backend/src/pantries/pantries.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -145,6 +145,10 @@ describe('PantriesService', () => {
provide: getRepositoryToken(FoodManufacturer),
useValue: testDataSource.getRepository(FoodManufacturer),
},
{
provide: DataSource,
useValue: testDataSource,
},
],
}).compile();

Expand Down
35 changes: 34 additions & 1 deletion apps/backend/src/users/users.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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 { BadRequestException, NotFoundException } from '@nestjs/common';
import { AuthenticatedRequest } from '../auth/authenticated-request';

const mockUserService = mock<UsersService>();
Expand All @@ -31,6 +31,7 @@ describe('UsersController', () => {
mockUserService.create.mockReset();
mockUserService.getUserDashboardStats.mockReset();
mockUserService.getRecentPendingApplications.mockReset();
mockUserService.promoteVolunteerToAdmin.mockReset();

const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
Expand Down Expand Up @@ -211,4 +212,36 @@ describe('UsersController', () => {
expect(result).toEqual([]);
});
});

describe('PATCH /:id/promote-volunteer', () => {
it('should promote volunteer to admin successfully', async () => {
mockUserService.promoteVolunteerToAdmin.mockResolvedValueOnce(undefined);

await controller.promoteToAdmin(1);

expect(mockUserService.promoteVolunteerToAdmin).toHaveBeenCalledWith(1);
});

it('should throw NotFoundException from service when user not found', async () => {
Comment thread
jxuistrying marked this conversation as resolved.
mockUserService.promoteVolunteerToAdmin.mockRejectedValueOnce(
new NotFoundException('User 999 not found'),
);

await expect(controller.promoteToAdmin(999)).rejects.toThrow(
new NotFoundException('User 999 not found'),
);
});

it('should throw BadRequestException from service when user is not a volunteer', async () => {
mockUserService.promoteVolunteerToAdmin.mockRejectedValueOnce(
new BadRequestException(
'User 1 is not a volunteer. Current role: admin',
),
);

await expect(controller.promoteToAdmin(1)).rejects.toThrow(
BadRequestException,
);
});
});
});
6 changes: 6 additions & 0 deletions apps/backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ export class UsersController {
return this.usersService.update(id, dto);
}

@Patch('/:id/promote-volunteer')
@Roles(Role.ADMIN)
async promoteToAdmin(@Param('id', ParseIntPipe) id: number): Promise<void> {
await this.usersService.promoteVolunteerToAdmin(id);
}

// Keeping these two as functionality seems useful
@Post('/')
async createUser(@Body() createUserDto: userSchemaDto): Promise<User> {
Expand Down
116 changes: 116 additions & 0 deletions apps/backend/src/users/users.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EmailsService>();

Expand Down Expand Up @@ -125,6 +127,8 @@ describe('UsersService', () => {

beforeEach(async () => {
mockAuthService.adminCreateUser.mockClear();
mockAuthService.addUserToGroup.mockClear();
mockAuthService.removeUserFromGroup.mockClear();
mockEmailsService.sendEmails.mockClear();
await testDataSource.runMigrations();
});
Expand Down Expand Up @@ -730,4 +734,116 @@ describe('UsersService', () => {
expect(types).toContain('food_manufacturer');
});
});

describe('promoteVolunteerToAdmin', () => {
it('should promote volunteer to admin successfully', async () => {
const userRepo = testDataSource.getRepository(User);
const volunteers = await userRepo.find({
Comment thread
jxuistrying marked this conversation as resolved.
where: { role: Role.VOLUNTEER },
});
expect(volunteers.length).toBeGreaterThan(0);
const volunteer = volunteers[0];

await service.promoteVolunteerToAdmin(volunteer.id);

const updatedUser = await userRepo.findOne({
where: { id: volunteer.id },
});
expect(updatedUser!.role).toBe(Role.ADMIN);
});

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();
Comment thread
jxuistrying marked this conversation as resolved.

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({
Comment thread
jxuistrying marked this conversation as resolved.
where: { role: Role.VOLUNTEER },
});
expect(volunteer).toBeDefined();

await service.promoteVolunteerToAdmin(volunteer!.id);

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({
Comment thread
jxuistrying marked this conversation as resolved.
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 () => {
Comment thread
jxuistrying marked this conversation as resolved.
const pantryUser = await testDataSource.getRepository(User).findOne({
Comment thread
jxuistrying marked this conversation as resolved.
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 () => {
Comment thread
jxuistrying marked this conversation as resolved.
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 userRepo = testDataSource.getRepository(User);
const volunteer = await userRepo.findOne({
Comment thread
jxuistrying marked this conversation as resolved.
where: { role: Role.VOLUNTEER },
});
expect(volunteer).toBeDefined();

mockAuthService.addUserToGroup.mockRejectedValueOnce(
new InternalServerErrorException('Cognito error'),
);

await expect(
service.promoteVolunteerToAdmin(volunteer!.id),
).rejects.toThrow(InternalServerErrorException);

const userAfter = await userRepo.findOne({
where: { id: volunteer!.id },
});
expect(userAfter!.role).toBe(Role.VOLUNTEER);
});
});
});
36 changes: 34 additions & 2 deletions apps/backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -44,6 +44,8 @@ export class UsersService {
private pantryRepo: Repository<Pantry>,
@InjectRepository(FoodManufacturer)
private fmRepo: Repository<FoodManufacturer>,
@InjectDataSource()
private dataSource: DataSource,
private authService: AuthService,
private emailsService: EmailsService,
@Inject(forwardRef(() => PantriesService))
Expand Down Expand Up @@ -298,4 +300,34 @@ export class UsersService {
throw new BadRequestException(`Unsupported role: ${user.role}`);
}
}

async promoteVolunteerToAdmin(userId: number): Promise<void> {
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}`,
);
}

await this.dataSource.transaction(async (transactionManager) => {
const userRepo = transactionManager.getRepository(User);

user.role = Role.ADMIN;
user.pantries = [];
await userRepo.save(user);

await this.authService.addUserToGroup(user.email, 'admin');
await this.authService.removeUserFromGroup(user.email, 'volunteer');
Comment thread
jxuistrying marked this conversation as resolved.
});
}
}
4 changes: 4 additions & 0 deletions apps/frontend/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,10 @@ export class ApiClient {
.then((response) => response.data);
}

public async promoteVolunteerToAdmin(userId: number): Promise<void> {
await this.axiosInstance.patch(`/api/users/${userId}/promote-volunteer`);
}

public async getFoodRequest(requestId: number): Promise<FoodRequest> {
return this.axiosInstance
.get(`/api/requests/${requestId}`)
Expand Down
Loading
Loading