diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 7fe3ff82..eb4a7e1d 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -18,6 +18,7 @@ import { APP_GUARD } from '@nestjs/core'; import { RolesGuard } from './auth/roles.guard'; import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { ScheduleModule } from '@nestjs/schedule'; +import { VolunteersModule } from './volunteers/volunteers.module'; @Module({ imports: [ @@ -43,6 +44,7 @@ import { ScheduleModule } from '@nestjs/schedule'; OrdersModule, ManufacturerModule, AllocationModule, + VolunteersModule, ], controllers: [AppController], providers: [ diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 808ef510..8d89fe1c 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -21,7 +21,7 @@ describe('OrdersService', () => { } // Clean database at the start - await testDataSource.query(`DROP SCHEMA public CASCADE`); + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); const module: TestingModule = await Test.createTestingModule({ diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index 97811a0e..cc314fce 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -7,7 +7,6 @@ import { userSchemaDto } from './dtos/userSchema.dto'; import { Test, TestingModule } from '@nestjs/testing'; import { mock } from 'jest-mock-extended'; -import { Pantry } from '../pantries/pantries.entity'; const mockUserService = mock(); @@ -20,35 +19,6 @@ const mockUser1: Partial = { role: Role.VOLUNTEER, }; -const mockUser2: Partial = { - id: 2543210, - email: 'bobsmith@example.com', - firstName: 'Bob', - lastName: 'Smith', - phone: '9876', - role: Role.VOLUNTEER, -}; - -const mockUser3: Partial = { - id: 3, - role: Role.VOLUNTEER, -}; - -const mockPantries: Partial[] = [ - { - pantryId: 1, - pantryUser: mockUser1 as User, - }, - { - pantryId: 2, - pantryUser: mockUser1 as User, - }, - { - pantryId: 3, - pantryUser: mockUser2 as User, - }, -]; - describe('UsersController', () => { let controller: UsersController; @@ -76,45 +46,6 @@ describe('UsersController', () => { expect(controller).toBeDefined(); }); - describe('GET /volunteers', () => { - it('should return all volunteers', async () => { - const users: (Omit, 'pantries'> & { - pantryIds: number[]; - })[] = [ - { - id: 1, - role: Role.VOLUNTEER, - pantryIds: [1], - }, - { - id: 2, - role: Role.VOLUNTEER, - pantryIds: [2], - }, - { - id: 3, - role: Role.ADMIN, - pantryIds: [3], - }, - ]; - - const volunteers = users.slice(0, 2); - - mockUserService.getVolunteersAndPantryAssignments.mockResolvedValue( - volunteers as (Omit & { pantryIds: number[] })[], - ); - - const result = await controller.getAllVolunteers(); - - expect(result).toEqual(volunteers); - expect(result.length).toBe(2); - expect(result.every((u) => u.role === Role.VOLUNTEER)).toBe(true); - expect( - mockUserService.getVolunteersAndPantryAssignments, - ).toHaveBeenCalled(); - }); - }); - describe('GET /:id', () => { it('should return a user by id', async () => { mockUserService.findOne.mockResolvedValue(mockUser1 as User); @@ -200,69 +131,4 @@ describe('UsersController', () => { ); }); }); - - describe('GET /volunteers', () => { - it('should return all volunteers with their pantry assignments', async () => { - const assignments: (User & { pantryIds: number[] })[] = [ - { ...(mockUser1 as User), pantryIds: [1, 2] }, - { ...(mockUser2 as User), pantryIds: [1] }, - { ...(mockUser3 as User), pantryIds: [] }, - ]; - - mockUserService.getVolunteersAndPantryAssignments.mockResolvedValue( - assignments, - ); - - const result = await controller.getAllVolunteers(); - - expect(result).toEqual(assignments); - expect(result).toHaveLength(3); - expect(result[0].id).toBe(1); - expect(result[0].pantryIds).toEqual([1, 2]); - expect(result[1].id).toBe(2543210); - expect(result[1].pantryIds).toEqual([1]); - expect(result[2].id).toBe(3); - expect(result[2].pantryIds).toEqual([]); - expect( - mockUserService.getVolunteersAndPantryAssignments, - ).toHaveBeenCalled(); - }); - }); - - describe('GET /:id/pantries', () => { - it('should return pantries assigned to a user', async () => { - mockUserService.getVolunteerPantries.mockResolvedValue( - mockPantries.slice(0, 2) as Pantry[], - ); - - const result = await controller.getVolunteerPantries(1); - - expect(result).toHaveLength(2); - expect(result).toEqual(mockPantries.slice(0, 2)); - expect(mockUserService.getVolunteerPantries).toHaveBeenCalledWith(1); - }); - }); - - describe('POST /:id/pantries', () => { - it('should assign pantries to a volunteer and return result', async () => { - const pantryIds = [1, 3]; - const updatedUser = { - ...mockUser3, - pantries: [mockPantries[0] as Pantry, mockPantries[2] as Pantry], - } as User; - - mockUserService.assignPantriesToVolunteer.mockResolvedValue(updatedUser); - - const result = await controller.assignPantries(3, pantryIds); - - expect(result).toEqual(updatedUser); - expect(result.pantries).toHaveLength(2); - expect(result.pantries[0].pantryId).toBe(1); - expect(result.pantries[1].pantryId).toBe(3); - expect(mockUserService.assignPantriesToVolunteer).toHaveBeenCalledWith( - 3, - pantryIds, - ); - }); - }); }); diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 7040fc37..8eb54206 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -13,31 +13,16 @@ import { UsersService } from './users.service'; import { User } from './user.entity'; import { Role } from './types'; import { userSchemaDto } from './dtos/userSchema.dto'; -import { Pantry } from '../pantries/pantries.entity'; @Controller('users') export class UsersController { constructor(private usersService: UsersService) {} - @Get('/volunteers') - async getAllVolunteers(): Promise< - (Omit & { pantryIds: number[] })[] - > { - return this.usersService.getVolunteersAndPantryAssignments(); - } - @Get('/:id') async getUser(@Param('id', ParseIntPipe) userId: number): Promise { return this.usersService.findOne(userId); } - @Get('/:id/pantries') - async getVolunteerPantries( - @Param('id', ParseIntPipe) id: number, - ): Promise { - return this.usersService.getVolunteerPantries(id); - } - @Delete('/:id') removeUser(@Param('id', ParseIntPipe) userId: number): Promise { return this.usersService.remove(userId); @@ -59,12 +44,4 @@ export class UsersController { const { email, firstName, lastName, phone, role } = createUserDto; return this.usersService.create(email, firstName, lastName, phone, role); } - - @Post('/:id/pantries') - async assignPantries( - @Param('id', ParseIntPipe) id: number, - @Body('pantryIds') pantryIds: number[], - ): Promise { - return this.usersService.assignPantriesToVolunteer(id, pantryIds); - } } diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index 64145f5a..371ea4e6 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -128,19 +128,6 @@ describe('UsersService', () => { }); }); - describe('findByEmail', () => { - it('should return user by email', async () => { - mockUserRepository.findOneBy.mockResolvedValue(mockUser as User); - - const result = await service.findByEmail('test@example.com'); - - expect(result).toEqual(mockUser); - expect(mockUserRepository.findOneBy).toHaveBeenCalledWith({ - email: 'test@example.com', - }); - }); - }); - describe('update', () => { it('should update user attributes', async () => { const updateData = { firstName: 'Updated', role: Role.ADMIN }; diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 8432ba1a..fa595105 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -1,23 +1,15 @@ -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { User } from './user.entity'; import { Role } from './types'; import { validateId } from '../utils/validation.utils'; -import { Pantry } from '../pantries/pantries.entity'; -import { PantriesService } from '../pantries/pantries.service'; @Injectable() export class UsersService { constructor( @InjectRepository(User) private repo: Repository, - - private pantriesService: PantriesService, ) {} async create( @@ -49,30 +41,6 @@ export class UsersService { return user; } - async findVolunteer(volunteerId: number): Promise { - validateId(volunteerId, 'Volunteer'); - - const volunteer = await this.repo.findOne({ - where: { id: volunteerId }, - relations: ['pantries'], - }); - - if (!volunteer) - throw new NotFoundException(`User ${volunteerId} not found`); - if (volunteer.role !== Role.VOLUNTEER) { - throw new BadRequestException(`User ${volunteerId} is not a volunteer`); - } - return volunteer; - } - - async findByEmail(email: string): Promise { - const user = await this.repo.findOneBy({ email }); - if (!user) { - throw new NotFoundException(`User with email ${email} not found`); - } - return user; - } - async update(id: number, attrs: Partial) { validateId(id, 'User'); @@ -106,43 +74,6 @@ export class UsersService { }); } - async getVolunteersAndPantryAssignments(): Promise< - (Omit & { pantryIds: number[] })[] - > { - const volunteers = await this.findUsersByRoles([Role.VOLUNTEER]); - - return volunteers.map((v) => { - const { pantries, ...volunteerWithoutPantries } = v; - return { - ...volunteerWithoutPantries, - pantryIds: pantries.map((p) => p.pantryId), - }; - }); - } - - async getVolunteerPantries(volunteerId: number): Promise { - const volunteer = await this.findVolunteer(volunteerId); - return volunteer.pantries; - } - - async assignPantriesToVolunteer( - volunteerId: number, - pantryIds: number[], - ): Promise { - pantryIds.forEach((id) => validateId(id, 'Pantry')); - - const volunteer = await this.findVolunteer(volunteerId); - - const pantries = await this.pantriesService.findByIds(pantryIds); - const existingPantryIds = volunteer.pantries.map((p) => p.pantryId); - const newPantries = pantries.filter( - (p) => !existingPantryIds.includes(p.pantryId), - ); - - volunteer.pantries = [...volunteer.pantries, ...newPantries]; - return this.repo.save(volunteer); - } - async findUserByCognitoId(cognitoId: string): Promise { const user = await this.repo.findOneBy({ userCognitoSub: cognitoId }); if (!user) { diff --git a/apps/backend/src/volunteers/volunteers.controller.spec.ts b/apps/backend/src/volunteers/volunteers.controller.spec.ts new file mode 100644 index 00000000..8513c998 --- /dev/null +++ b/apps/backend/src/volunteers/volunteers.controller.spec.ts @@ -0,0 +1,166 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { VolunteersController } from './volunteers.controller'; +import { UsersController } from '../users/users.controller'; +import { UsersService } from '../users/users.service'; +import { User } from '../users/user.entity'; +import { Role } from '../users/types'; +import { Test, TestingModule } from '@nestjs/testing'; +import { mock } from 'jest-mock-extended'; +import { Pantry } from '../pantries/pantries.entity'; +import { VolunteersService } from './volunteers.service'; + +const mockVolunteersService = mock(); + +const mockVolunteer1: Partial = { + id: 1, + email: 'john@example.com', + firstName: 'John', + lastName: 'Doe', + phone: '1234567890', + role: Role.VOLUNTEER, +}; + +const mockVolunteer2: Partial = { + id: 2543210, + email: 'bobsmith@example.com', + firstName: 'Bob', + lastName: 'Smith', + phone: '9876', + role: Role.VOLUNTEER, +}; + +const mockVolunteer3: Partial = { + id: 3, + role: Role.VOLUNTEER, +}; + +const mockPantries: Partial[] = [ + { + pantryId: 1, + pantryUser: mockVolunteer1 as User, + }, + { + pantryId: 2, + pantryUser: mockVolunteer1 as User, + }, + { + pantryId: 3, + pantryUser: mockVolunteer2 as User, + }, +]; + +describe('VolunteersController', () => { + let controller: VolunteersController; + + beforeEach(async () => { + mockVolunteersService.findOne.mockReset(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [VolunteersController], + providers: [ + { + provide: VolunteersService, + useValue: mockVolunteersService, + }, + ], + }).compile(); + + controller = module.get(VolunteersController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('GET /', () => { + it('should return all volunteers', async () => { + const volunteers: (Omit, 'pantries'> & { + pantryIds: number[]; + })[] = [ + { + id: 1, + role: Role.VOLUNTEER, + pantryIds: [1], + }, + { + id: 2, + role: Role.VOLUNTEER, + pantryIds: [2], + }, + { + id: 3, + role: Role.ADMIN, + pantryIds: [3], + }, + ]; + + const expectedVolunteers = volunteers.slice(0, 2); + + mockVolunteersService.getVolunteersAndPantryAssignments.mockResolvedValue( + expectedVolunteers as (Omit & { + pantryIds: number[]; + })[], + ); + + const result = await controller.getAllVolunteers(); + + expect(result).toEqual(expectedVolunteers); + expect(result.length).toBe(2); + expect(result.every((u) => u.role === Role.VOLUNTEER)).toBe(true); + expect( + mockVolunteersService.getVolunteersAndPantryAssignments, + ).toHaveBeenCalled(); + }); + }); + + describe('GET /:id', () => { + it('should return a user by id', async () => { + mockVolunteersService.findOne.mockResolvedValue(mockVolunteer1 as User); + + const result = await controller.getVolunteer(1); + + expect(result).toEqual(mockVolunteer1); + expect(mockVolunteersService.findOne).toHaveBeenCalledWith(1); + }); + }); + + describe('GET /:id/pantries', () => { + it('should return pantries assigned to a user', async () => { + mockVolunteersService.getVolunteerPantries.mockResolvedValue( + mockPantries.slice(0, 2) as Pantry[], + ); + + const result = await controller.getVolunteerPantries(1); + + expect(result).toHaveLength(2); + expect(result).toEqual(mockPantries.slice(0, 2)); + expect(mockVolunteersService.getVolunteerPantries).toHaveBeenCalledWith( + 1, + ); + }); + }); + + describe('POST /:id/pantries', () => { + it('should assign pantries to a volunteer and return result', async () => { + const pantryIds = [1, 3]; + const updatedUser = { + ...mockVolunteer3, + pantries: [mockPantries[0] as Pantry, mockPantries[2] as Pantry], + } as User; + + mockVolunteersService.assignPantriesToVolunteer.mockResolvedValue( + updatedUser, + ); + + const result = await controller.assignPantries(3, pantryIds); + + expect(result).toEqual(updatedUser); + expect(result.pantries).toHaveLength(2); + expect(result.pantries[0].pantryId).toBe(1); + expect(result.pantries[1].pantryId).toBe(3); + expect( + mockVolunteersService.assignPantriesToVolunteer, + ).toHaveBeenCalledWith(3, pantryIds); + }); + }); +}); diff --git a/apps/backend/src/volunteers/volunteers.controller.ts b/apps/backend/src/volunteers/volunteers.controller.ts new file mode 100644 index 00000000..ce6b9d62 --- /dev/null +++ b/apps/backend/src/volunteers/volunteers.controller.ts @@ -0,0 +1,43 @@ +import { + Controller, + Get, + Param, + ParseIntPipe, + Post, + Body, +} from '@nestjs/common'; +import { User } from '../users/user.entity'; +import { Pantry } from '../pantries/pantries.entity'; +import { VolunteersService } from './volunteers.service'; + +@Controller('volunteers') +export class VolunteersController { + constructor(private volunteersService: VolunteersService) {} + + @Get('/') + async getAllVolunteers(): Promise< + (Omit & { pantryIds: number[] })[] + > { + return this.volunteersService.getVolunteersAndPantryAssignments(); + } + + @Get('/:id') + async getVolunteer(@Param('id', ParseIntPipe) userId: number): Promise { + return this.volunteersService.findOne(userId); + } + + @Get('/:id/pantries') + async getVolunteerPantries( + @Param('id', ParseIntPipe) id: number, + ): Promise { + return this.volunteersService.getVolunteerPantries(id); + } + + @Post('/:id/pantries') + async assignPantries( + @Param('id', ParseIntPipe) id: number, + @Body('pantryIds') pantryIds: number[], + ): Promise { + return this.volunteersService.assignPantriesToVolunteer(id, pantryIds); + } +} diff --git a/apps/backend/src/volunteers/volunteers.module.ts b/apps/backend/src/volunteers/volunteers.module.ts new file mode 100644 index 00000000..c7147fcf --- /dev/null +++ b/apps/backend/src/volunteers/volunteers.module.ts @@ -0,0 +1,21 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from '../users/user.entity'; +import { PantriesModule } from '../pantries/pantries.module'; +import { AuthModule } from '../auth/auth.module'; +import { VolunteersController } from './volunteers.controller'; +import { VolunteersService } from './volunteers.service'; +import { UsersModule } from '../users/users.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([User]), + UsersModule, + forwardRef(() => PantriesModule), + forwardRef(() => AuthModule), + ], + controllers: [VolunteersController], + providers: [VolunteersService], + exports: [VolunteersService], +}) +export class VolunteersModule {} diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts new file mode 100644 index 00000000..20eed67f --- /dev/null +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -0,0 +1,192 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../users/user.entity'; +import { VolunteersService } from './volunteers.service'; +import { Pantry } from '../pantries/pantries.entity'; +import { testDataSource } from '../config/typeormTestDataSource'; +import { UsersService } from '../users/users.service'; +import { PantriesService } from '../pantries/pantries.service'; + +jest.setTimeout(60000); + +describe('VolunteersService', () => { + let service: VolunteersService; + + beforeAll(async () => { + // Initialize DataSource once + if (!testDataSource.isInitialized) { + await testDataSource.initialize(); + } + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + VolunteersService, + UsersService, + PantriesService, + { + provide: getRepositoryToken(User), + useValue: testDataSource.getRepository(User), + }, + { + provide: getRepositoryToken(Pantry), + useValue: testDataSource.getRepository(Pantry), + }, + ], + }).compile(); + + service = module.get(VolunteersService); + }); + + beforeEach(async () => { + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); + await testDataSource.runMigrations(); + }); + + afterEach(async () => { + await testDataSource.query(`DROP SCHEMA public CASCADE`); + }); + + afterAll(async () => { + if (testDataSource.isInitialized) { + await testDataSource.destroy(); + } + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findOne', () => { + it('should return a volunteer by id', async () => { + const volunteerId = 6; + const result = await service.findOne(volunteerId); + + expect(result).toBeDefined(); + expect(result.id).toBe(6); + }); + + it('should throw NotFoundException when volunteer is not found', async () => { + await expect(service.findOne(999)).rejects.toThrow( + new NotFoundException('Volunteer 999 not found'), + ); + }); + + it('should throw a NotFoundException when a non-volunteer is found', async () => { + await expect(service.findOne(1)).rejects.toThrow( + new NotFoundException('User 1 is not a volunteer'), + ); + }); + }); + + describe('getVolunteersAndPantryAssignments', () => { + it('returns an empty array when there are no volunteers', async () => { + // Delete all users with role 'volunteer' (CASCADE will handle related data) + await testDataSource.query( + `DELETE FROM "users" WHERE role = 'volunteer'`, + ); + + const result = await service.getVolunteersAndPantryAssignments(); + + expect(result).toEqual([]); + }); + + it('returns all volunteers with their pantry assignments', async () => { + const result = await service.getVolunteersAndPantryAssignments(); + + expect(result.length).toEqual(4); + expect(result).toEqual([ + { + id: 6, + firstName: 'James', + lastName: 'Thomas', + email: 'james.t@volunteer.org', + phone: '555-040-0401', + role: 'volunteer', + userCognitoSub: '', + pantryIds: [1], + }, + { + id: 7, + firstName: 'Maria', + lastName: 'Garcia', + email: 'maria.g@volunteer.org', + phone: '555-040-0402', + role: 'volunteer', + userCognitoSub: '', + pantryIds: [2, 3], + }, + { + id: 8, + firstName: 'William', + lastName: 'Moore', + email: 'william.m@volunteer.org', + phone: '555-040-0403', + role: 'volunteer', + userCognitoSub: '', + pantryIds: [3], + }, + { + id: 9, + firstName: 'Patricia', + lastName: 'Jackson', + email: 'patricia.j@volunteer.org', + phone: '555-040-0404', + role: 'volunteer', + userCognitoSub: '', + pantryIds: [1], + }, + ]); + }); + }); + + describe('getVolunteerPantries', () => { + it('returns an empty array when volunteer has no pantry assignments', async () => { + await testDataSource.query( + `DELETE FROM "volunteer_assignments" WHERE volunteer_id = 6`, + ); + + const result = await service.getVolunteerPantries(6); + + expect(result).toEqual([]); + }); + + it('returns all pantries assigned to a volunteer', async () => { + const result = await service.getVolunteerPantries(7); + + expect(result).toHaveLength(2); + + const pantryIds = result.map((p) => p.pantryId); + expect(pantryIds).toEqual([2, 3]); + }); + }); + + describe('assignPantriesToVolunteer', () => { + it('assigns new pantries to a volunteer with existing assignments', async () => { + const beforeAssignment = await service.getVolunteerPantries(7); + expect(beforeAssignment).toHaveLength(2); + const beforePantryIds = beforeAssignment.map((p) => p.pantryId); + expect(beforePantryIds).toEqual([2, 3]); + + const result = await service.assignPantriesToVolunteer(7, [1, 4]); + expect(result.pantries).toHaveLength(4); + const afterPantryIds = result.pantries.map((p) => p.pantryId); + expect(afterPantryIds).toEqual([2, 3, 1, 4]); + }); + + it('assigns pantries to a volunteer with no existing assignments', async () => { + await testDataSource.query( + `DELETE FROM "volunteer_assignments" WHERE volunteer_id = 6`, + ); + + const beforeAssignment = await service.getVolunteerPantries(6); + expect(beforeAssignment).toEqual([]); + + const result = await service.assignPantriesToVolunteer(6, [2, 3]); + expect(result.pantries).toHaveLength(2); + const pantryIds = result.pantries.map((p) => p.pantryId); + expect(pantryIds).toEqual([2, 3]); + }); + }); +}); diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts new file mode 100644 index 00000000..5fd9d3b4 --- /dev/null +++ b/apps/backend/src/volunteers/volunteers.service.ts @@ -0,0 +1,76 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../users/user.entity'; +import { Role } from '../users/types'; +import { validateId } from '../utils/validation.utils'; +import { Pantry } from '../pantries/pantries.entity'; +import { PantriesService } from '../pantries/pantries.service'; +import { UsersService } from '../users/users.service'; + +@Injectable() +export class VolunteersService { + constructor( + @InjectRepository(User) + private repo: Repository, + private usersService: UsersService, + private pantriesService: PantriesService, + ) {} + + async findOne(id: number): Promise { + validateId(id, 'Volunteer'); + + const volunteer = await this.repo.findOne({ + where: { id: id }, + relations: ['pantries'], + }); + + if (!volunteer) { + throw new NotFoundException(`Volunteer ${id} not found`); + } + if (volunteer.role !== Role.VOLUNTEER) { + throw new NotFoundException(`User ${id} is not a volunteer`); + } + return volunteer; + } + + async getVolunteersAndPantryAssignments(): Promise< + (Omit & { pantryIds: number[] })[] + > { + const volunteers = await this.usersService.findUsersByRoles([ + Role.VOLUNTEER, + ]); + + return volunteers.map((v) => { + const { pantries, ...volunteerWithoutPantries } = v; + return { + ...volunteerWithoutPantries, + pantryIds: pantries.map((p) => p.pantryId), + }; + }); + } + + async getVolunteerPantries(volunteerId: number): Promise { + validateId(volunteerId, 'Volunteer'); + const volunteer = await this.findOne(volunteerId); + return volunteer.pantries; + } + + async assignPantriesToVolunteer( + volunteerId: number, + pantryIds: number[], + ): Promise { + pantryIds.forEach((id) => validateId(id, 'Pantry')); + + const volunteer = await this.findOne(volunteerId); + + const pantries = await this.pantriesService.findByIds(pantryIds); + const existingPantryIds = volunteer.pantries.map((p) => p.pantryId); + const newPantries = pantries.filter( + (p) => !existingPantryIds.includes(p.pantryId), + ); + + volunteer.pantries = [...volunteer.pantries, ...newPantries]; + return this.repo.save(volunteer); + } +} diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 0378afb9..dfc4f495 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -170,7 +170,7 @@ export class ApiClient { } public async getVolunteers(): Promise { - return this.get('/api/users/volunteers') as Promise; + return this.get('/api/volunteers/') as Promise; } public async updateUserVolunteerRole( diff --git a/package.json b/package.json index d5f75f46..2ea81af1 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "format": "prettier --no-error-on-unmatched-pattern --write apps/{frontend,backend}/src/**/*.{js,ts,tsx}", "lint:check": "eslint apps/frontend --ext .ts,.tsx && eslint apps/backend --ext .ts,.tsx", "lint": "eslint apps/frontend --ext .ts,.tsx --fix && eslint apps/backend --ext .ts,.tsx --fix", - "test": "jest", + "test": "jest --runInBand", "prepush": "yarn run format:check && yarn run lint:check", "prepush:fix": "yarn run format && yarn run lint", "prepare": "husky install",