diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 5c0294a8..3c0ce87a 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -2,7 +2,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { RequestsModule } from './foodRequests/request.module'; import { PantriesModule } from './pantries/pantries.module'; -import { AssignmentsModule } from './volunteerAssignments/volunteerAssignments.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UsersModule } from './users/users.module'; @@ -34,7 +33,6 @@ import { AllocationModule } from './allocations/allocations.module'; AuthModule, PantriesModule, RequestsModule, - AssignmentsModule, DonationModule, DonationItemsModule, OrdersModule, diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts index 09f5965c..eac8a5b6 100644 --- a/apps/backend/src/auth/auth.module.ts +++ b/apps/backend/src/auth/auth.module.ts @@ -1,19 +1,14 @@ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; import { PassportModule } from '@nestjs/passport'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; -import { UsersService } from '../users/users.service'; -import { User } from '../users/user.entity'; import { JwtStrategy } from './jwt.strategy'; +import { UsersModule } from '../users/users.module'; @Module({ - imports: [ - TypeOrmModule.forFeature([User]), - PassportModule.register({ defaultStrategy: 'jwt' }), - ], + imports: [UsersModule, PassportModule.register({ defaultStrategy: 'jwt' })], controllers: [AuthController], - providers: [AuthService, UsersService, JwtStrategy], + providers: [AuthService, JwtStrategy], }) export class AuthModule {} diff --git a/apps/backend/src/config/typeorm.ts b/apps/backend/src/config/typeorm.ts index b356b698..dc992819 100644 --- a/apps/backend/src/config/typeorm.ts +++ b/apps/backend/src/config/typeorm.ts @@ -20,6 +20,7 @@ import { AddingEnumValues1760538239997 } from '../migrations/1760538239997-Addin import { UpdateColsToUseEnumType1760886499863 } from '../migrations/1760886499863-UpdateColsToUseEnumType'; import { UpdatePantriesTable1742739750279 } from '../migrations/1742739750279-updatePantriesTable'; import { RemoveOrdersDonationId1761500262238 } from '../migrations/1761500262238-RemoveOrdersDonationId'; +import { AddVolunteerPantryUniqueConstraint1760033134668 } from '../migrations/1760033134668-AddVolunteerPantryUniqueConstraint'; import { AllergyFriendlyToBoolType1763963056712 } from '../migrations/1763963056712-AllergyFriendlyToBoolType'; import { UpdatePantryUserFieldsFixed1764350314832 } from '../migrations/1764350314832-UpdatePantryUserFieldsFixed'; @@ -55,6 +56,7 @@ const config = { UpdateColsToUseEnumType1760886499863, UpdatePantriesTable1742739750279, RemoveOrdersDonationId1761500262238, + AddVolunteerPantryUniqueConstraint1760033134668, AllergyFriendlyToBoolType1763963056712, UpdatePantryUserFieldsFixed1764350314832, ], diff --git a/apps/backend/src/donationItems/donationItems.controller.ts b/apps/backend/src/donationItems/donationItems.controller.ts index 96381fc0..96be5673 100644 --- a/apps/backend/src/donationItems/donationItems.controller.ts +++ b/apps/backend/src/donationItems/donationItems.controller.ts @@ -38,8 +38,8 @@ export class DonationItemsController { status: { type: 'string', example: 'available' }, ozPerItem: { type: 'integer', example: 5 }, estimatedValue: { type: 'integer', example: 100 }, - foodType: { - type: 'string', + foodType: { + type: 'string', enum: Object.values(FoodType), example: FoodType.DAIRY_FREE_ALTERNATIVES, }, @@ -59,7 +59,10 @@ export class DonationItemsController { foodType: FoodType; }, ): Promise { - if (body.foodType && !Object.values(FoodType).includes(body.foodType as FoodType)) { + if ( + body.foodType && + !Object.values(FoodType).includes(body.foodType as FoodType) + ) { throw new BadRequestException('Invalid foodtype'); } return this.donationItemsService.create( diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index d748df66..6bcd2a7e 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -46,8 +46,8 @@ export class DonationsController { type: 'string', format: 'date-time', }, - status: { - type: 'string', + status: { + type: 'string', enum: Object.values(DonationStatus), example: DonationStatus.AVAILABLE, }, diff --git a/apps/backend/src/donations/types.ts b/apps/backend/src/donations/types.ts index 549ee6c6..16387987 100644 --- a/apps/backend/src/donations/types.ts +++ b/apps/backend/src/donations/types.ts @@ -2,4 +2,4 @@ export enum DonationStatus { AVAILABLE = 'available', FULFILLED = 'fulfilled', MATCHING = 'matching', -} \ No newline at end of file +} diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index c01eda5b..e3a93727 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -58,8 +58,8 @@ export class FoodRequestsController { type: 'object', properties: { pantryId: { type: 'integer', example: 1 }, - requestedSize: { - type: 'string', + requestedSize: { + type: 'string', enum: Object.values(RequestSize), example: RequestSize.LARGE, }, @@ -166,7 +166,10 @@ export class FoodRequestsController { ); const request = await this.requestsService.findOne(requestId); - await this.ordersService.updateStatus(request.order.orderId, OrderStatus.DELIVERED); + await this.ordersService.updateStatus( + request.order.orderId, + OrderStatus.DELIVERED, + ); return this.requestsService.updateDeliveryDetails( requestId, diff --git a/apps/backend/src/migrations/1760033134668-AddVolunteerPantryUniqueConstraint.ts b/apps/backend/src/migrations/1760033134668-AddVolunteerPantryUniqueConstraint.ts new file mode 100644 index 00000000..bc45bd3e --- /dev/null +++ b/apps/backend/src/migrations/1760033134668-AddVolunteerPantryUniqueConstraint.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddVolunteerPantryUniqueConstraint1760033134668 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE volunteer_assignments DROP COLUMN assignment_id; + + ALTER TABLE volunteer_assignments + ADD PRIMARY KEY (volunteer_id, pantry_id); + + ALTER TABLE volunteer_assignments DROP CONSTRAINT IF EXISTS fk_volunteer_id; + + ALTER TABLE volunteer_assignments DROP CONSTRAINT IF EXISTS fk_pantry_id; + + ALTER TABLE volunteer_assignments + ADD CONSTRAINT fk_volunteer_id FOREIGN KEY (volunteer_id) REFERENCES users(user_id) ON DELETE CASCADE, + ADD CONSTRAINT fk_pantry_id FOREIGN KEY (pantry_id) REFERENCES pantries(pantry_id) ON DELETE CASCADE; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE volunteer_assignments DROP CONSTRAINT fk_volunteer_id; + + ALTER TABLE volunteer_assignments DROP CONSTRAINT fk_pantry_id; + + ALTER TABLE volunteer_assignments DROP CONSTRAINT volunteer_assignments_pkey; + + ALTER TABLE volunteer_assignments ADD COLUMN assignment_id SERIAL PRIMARY KEY; + + ALTER TABLE volunteer_assignments + ADD CONSTRAINT fk_volunteer_id FOREIGN KEY(volunteer_id) REFERENCES users(user_id), + ADD CONSTRAINT fk_pantry_id FOREIGN KEY(pantry_id) REFERENCES pantries(pantry_id); + `); + } +} diff --git a/apps/backend/src/migrations/1763963056712-AllergyFriendlyToBoolType.ts b/apps/backend/src/migrations/1763963056712-AllergyFriendlyToBoolType.ts index 079362d6..3a14c4d4 100644 --- a/apps/backend/src/migrations/1763963056712-AllergyFriendlyToBoolType.ts +++ b/apps/backend/src/migrations/1763963056712-AllergyFriendlyToBoolType.ts @@ -1,21 +1,21 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; -export class AllergyFriendlyToBoolType1763963056712 implements MigrationInterface { - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` +export class AllergyFriendlyToBoolType1763963056712 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` ALTER TABLE pantries ALTER COLUMN dedicated_allergy_friendly TYPE BOOLEAN USING (FALSE), ALTER COLUMN dedicated_allergy_friendly SET NOT NULL; `); - } + } - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(` + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` ALTER TABLE pantries ALTER COLUMN dedicated_allergy_friendly TYPE VARCHAR(255), ALTER COLUMN dedicated_allergy_friendly DROP NOT NULL; `); - } - + } } diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 4c64ac10..69ef277b 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -6,7 +6,13 @@ import { OrdersService } from './order.service'; import { mock } from 'jest-mock-extended'; import { Pantry } from '../pantries/pantries.entity'; import { User } from '../users/user.entity'; -import { AllergensConfidence, ClientVisitFrequency, PantryStatus, RefrigeratedDonation, ServeAllergicChildren } from '../pantries/types'; +import { + AllergensConfidence, + ClientVisitFrequency, + PantryStatus, + RefrigeratedDonation, + ServeAllergicChildren, +} from '../pantries/types'; import { OrderStatus } from './types'; const mockOrdersRepository = mock>(); @@ -37,6 +43,7 @@ const mockPantry: Pantry = { activitiesComments: '', itemsInStock: '', needMoreOptions: '', + volunteers: [], }; describe('OrdersService', () => { diff --git a/apps/backend/src/pantries/pantries.entity.ts b/apps/backend/src/pantries/pantries.entity.ts index 20ddacff..d714e587 100644 --- a/apps/backend/src/pantries/pantries.entity.ts +++ b/apps/backend/src/pantries/pantries.entity.ts @@ -4,6 +4,7 @@ import { PrimaryGeneratedColumn, OneToOne, JoinColumn, + ManyToMany, } from 'typeorm'; import { User } from '../users/user.entity'; import { @@ -158,4 +159,7 @@ export class Pantry { @Column({ name: 'need_more_options', type: 'text' }) needMoreOptions: string; + + @ManyToMany(() => User, (user) => user.pantries) + volunteers?: User[]; } diff --git a/apps/backend/src/pantries/pantries.module.ts b/apps/backend/src/pantries/pantries.module.ts index 5653396d..3de2a4c5 100644 --- a/apps/backend/src/pantries/pantries.module.ts +++ b/apps/backend/src/pantries/pantries.module.ts @@ -1,16 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { User } from '../users/user.entity'; import { PantriesService } from './pantries.service'; import { PantriesController } from './pantries.controller'; -import { JwtStrategy } from '../auth/jwt.strategy'; -import { AuthService } from '../auth/auth.service'; import { Pantry } from './pantries.entity'; import { OrdersModule } from '../orders/order.module'; @Module({ - imports: [TypeOrmModule.forFeature([Pantry, User]), OrdersModule], + imports: [TypeOrmModule.forFeature([Pantry]), OrdersModule], controllers: [PantriesController], - providers: [PantriesService, AuthService, JwtStrategy], + providers: [PantriesService], + exports: [PantriesService], }) export class PantriesModule {} diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 2e10535b..788341bc 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { Pantry } from './pantries.entity'; import { User } from '../users/user.entity'; import { validateId } from '../utils/validation.utils'; @@ -90,4 +90,20 @@ export class PantriesService { await this.repo.update(id, { status: PantryStatus.DENIED }); } + + async findByIds(pantryIds: number[]): Promise { + pantryIds.forEach((id) => validateId(id, 'Pantry')); + + const pantries = await this.repo.findBy({ pantryId: In(pantryIds) }); + + if (pantries.length !== pantryIds.length) { + const foundIds = pantries.map((p) => p.pantryId); + const missingIds = pantryIds.filter((id) => !foundIds.includes(id)); + throw new NotFoundException( + `Pantries not found: ${missingIds.join(', ')}`, + ); + } + + return pantries; + } } diff --git a/apps/backend/src/pantries/types.ts b/apps/backend/src/pantries/types.ts index f776991b..cdf8b671 100644 --- a/apps/backend/src/pantries/types.ts +++ b/apps/backend/src/pantries/types.ts @@ -33,7 +33,7 @@ export enum PantryStatus { export enum Activity { CREATE_LABELED_SHELF = 'Create labeled shelf', PROVIDE_EDUCATIONAL_PAMPHLETS = 'Provide educational pamphlets', - TRACK_DIETARY_NEEDS ='Spreadsheet to track dietary needs', + TRACK_DIETARY_NEEDS = 'Spreadsheet to track dietary needs', POST_RESOURCE_FLYERS = 'Post allergen-free resource flyers', SURVEY_CLIENTS = 'Survey clients to determine medical dietary needs', COLLECT_FEEDBACK = 'Collect feedback from allergen-avoidant clients', diff --git a/apps/backend/src/users/dtos/userSchema.dto.ts b/apps/backend/src/users/dtos/userSchema.dto.ts index b6905ea2..25cbc299 100644 --- a/apps/backend/src/users/dtos/userSchema.dto.ts +++ b/apps/backend/src/users/dtos/userSchema.dto.ts @@ -3,7 +3,6 @@ import { IsEnum, IsNotEmpty, IsString, - IsOptional, IsPhoneNumber, } from 'class-validator'; import { Role } from '../types'; diff --git a/apps/backend/src/users/user.entity.ts b/apps/backend/src/users/user.entity.ts index b0d95316..e2b9a958 100644 --- a/apps/backend/src/users/user.entity.ts +++ b/apps/backend/src/users/user.entity.ts @@ -1,6 +1,13 @@ -import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToMany, + JoinTable, +} from 'typeorm'; import { Role } from './types'; +import { Pantry } from '../pantries/pantries.entity'; @Entity() export class User { @@ -30,4 +37,18 @@ export class User { length: 20, }) phone: string; + + @ManyToMany(() => Pantry, (pantry) => pantry.volunteers) + @JoinTable({ + name: 'volunteer_assignments', + joinColumn: { + name: 'volunteer_id', + referencedColumnName: 'id', + }, + inverseJoinColumn: { + name: 'pantry_id', + referencedColumnName: 'pantryId', + }, + }) + pantries?: Pantry[]; } diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index 78d116a3..8a22cf3a 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -7,27 +7,40 @@ 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(); -const mockUser1: User = { +const mockUser1: Partial = { id: 1, - email: 'john@example.com', - firstName: 'John', - lastName: 'Doe', - phone: '1234567890', role: Role.STANDARD_VOLUNTEER, }; -const mockUser2: User = { +const mockUser2: Partial = { id: 2543210, - email: 'bobsmith@example.com', - firstName: 'Bob', - lastName: 'Smith', - phone: '9876', role: Role.LEAD_VOLUNTEER, }; +const mockUser3: Partial = { + id: 3, + role: Role.STANDARD_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; @@ -55,27 +68,9 @@ describe('UsersController', () => { expect(controller).toBeDefined(); }); - describe('GET /volunteers', () => { - it('should return all volunteers', async () => { - const volunteers = [mockUser1, mockUser2]; - mockUserService.findUsersByRoles.mockResolvedValue(volunteers); - - const result = await controller.getAllVolunteers(); - - const hasAdmin = result.some((user) => user.role === Role.ADMIN); - expect(hasAdmin).toBe(false); - - expect(result).toEqual(volunteers); - expect(mockUserService.findUsersByRoles).toHaveBeenCalledWith([ - Role.LEAD_VOLUNTEER, - Role.STANDARD_VOLUNTEER, - ]); - }); - }); - describe('GET /:id', () => { it('should return a user by id', async () => { - mockUserService.findOne.mockResolvedValue(mockUser1); + mockUserService.findOne.mockResolvedValue(mockUser1 as User); const result = await controller.getUser(1); @@ -86,7 +81,7 @@ describe('UsersController', () => { describe('DELETE /:id', () => { it('should remove a user by id', async () => { - mockUserService.remove.mockResolvedValue(mockUser1); + mockUserService.remove.mockResolvedValue(mockUser1 as User); const result = await controller.removeUser(1); @@ -95,10 +90,10 @@ describe('UsersController', () => { }); }); - describe('PUT :id/role', () => { + describe('PUT /:id/role', () => { it('should update user role with valid role', async () => { const updatedUser = { ...mockUser1, role: Role.ADMIN }; - mockUserService.update.mockResolvedValue(updatedUser); + mockUserService.update.mockResolvedValue(updatedUser as User); const result = await controller.updateRole(1, Role.ADMIN); @@ -126,7 +121,7 @@ describe('UsersController', () => { role: Role.ADMIN, }; - const createdUser = { ...createUserSchema, id: 2 }; + const createdUser = { ...createUserSchema, id: 2 } as User; mockUserService.create.mockResolvedValue(createdUser); const result = await controller.createUser(createUserSchema); @@ -158,4 +153,69 @@ 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 93a4dc01..6f11265d 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -15,8 +15,8 @@ import { UsersService } from './users.service'; //import { AuthGuard } from '@nestjs/passport'; import { User } from './user.entity'; import { Role } from './types'; -import { VOLUNTEER_ROLES } from './types'; import { userSchemaDto } from './dtos/userSchema.dto'; +import { Pantry } from '../pantries/pantries.entity'; //import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; @Controller('users') @@ -25,8 +25,10 @@ export class UsersController { constructor(private usersService: UsersService) {} @Get('/volunteers') - async getAllVolunteers(): Promise { - return this.usersService.findUsersByRoles(VOLUNTEER_ROLES); + async getAllVolunteers(): Promise< + (Omit & { pantryIds: number[] })[] + > { + return this.usersService.getVolunteersAndPantryAssignments(); } // @UseGuards(AuthGuard('jwt')) @@ -35,16 +37,23 @@ export class UsersController { 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) { + removeUser(@Param('id', ParseIntPipe) userId: number): Promise { return this.usersService.remove(userId); } - @Put(':id/role') + @Put('/:id/role') async updateRole( @Param('id', ParseIntPipe) id: number, @Body('role') role: string, - ) { + ): Promise { if (!Object.values(Role).includes(role as Role)) { throw new BadRequestException('Invalid role'); } @@ -56,4 +65,12 @@ 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.module.ts b/apps/backend/src/users/users.module.ts index 2f78bb05..6a780a8d 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -6,9 +6,10 @@ import { User } from './user.entity'; import { JwtStrategy } from '../auth/jwt.strategy'; import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; import { AuthService } from '../auth/auth.service'; +import { PantriesModule } from '../pantries/pantries.module'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [TypeOrmModule.forFeature([User]), PantriesModule], exports: [UsersService], controllers: [UsersController], providers: [UsersService, AuthService, JwtStrategy, CurrentUserInterceptor], diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index 8388a79a..db28dc0e 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -8,22 +8,31 @@ import { Role } from './types'; import { mock } from 'jest-mock-extended'; import { In } from 'typeorm'; import { BadRequestException } from '@nestjs/common'; +import { PantriesService } from '../pantries/pantries.service'; const mockUserRepository = mock>(); +const mockPantriesService = mock(); -const mockUser: User = { +const mockUser = { id: 1, email: 'test@example.com', firstName: 'John', lastName: 'Doe', phone: '1234567890', role: Role.STANDARD_VOLUNTEER, -}; +} as User; describe('UsersService', () => { let service: UsersService; beforeAll(async () => { + mockUserRepository.create.mockReset(); + mockUserRepository.save.mockReset(); + mockUserRepository.findOneBy.mockReset(); + mockUserRepository.find.mockReset(); + mockUserRepository.remove.mockReset(); + mockPantriesService.findByIds.mockReset(); + const module = await Test.createTestingModule({ providers: [ UsersService, @@ -31,6 +40,10 @@ describe('UsersService', () => { provide: getRepositoryToken(User), useValue: mockUserRepository, }, + { + provide: PantriesService, + useValue: mockPantriesService, + }, ], }).compile(); @@ -43,6 +56,7 @@ describe('UsersService', () => { mockUserRepository.findOneBy.mockReset(); mockUserRepository.find.mockReset(); mockUserRepository.remove.mockReset(); + mockPantriesService.findByIds.mockReset(); }); afterEach(() => { @@ -61,7 +75,7 @@ describe('UsersService', () => { lastName: 'Smith', phone: '9876543210', role: Role.ADMIN, - }; + } as User; const createdUser = { ...userData, id: 1 }; mockUserRepository.create.mockReturnValue(createdUser); @@ -198,6 +212,7 @@ describe('UsersService', () => { expect(result).toEqual(users); expect(mockUserRepository.find).toHaveBeenCalledWith({ where: { role: In(roles) }, + relations: ['pantries'], }); }); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index f10a0237..1f0984e3 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -1,14 +1,25 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + 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 { Role, VOLUNTEER_ROLES } 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) {} + constructor( + @InjectRepository(User) + private repo: Repository, + + private pantriesService: PantriesService, + ) {} async create( email: string, @@ -39,6 +50,22 @@ 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_ROLES.includes(volunteer.role)) { + throw new BadRequestException(`User ${volunteerId} is not a volunteer`); + } + return volunteer; + } + find(email: string) { return this.repo.find({ where: { email } }); } @@ -70,6 +97,46 @@ export class UsersService { } async findUsersByRoles(roles: Role[]): Promise { - return this.repo.find({ where: { role: In(roles) } }); + return this.repo.find({ + where: { role: In(roles) }, + relations: ['pantries'], + }); + } + + async getVolunteersAndPantryAssignments(): Promise< + (Omit & { pantryIds: number[] })[] + > { + const volunteers = await this.findUsersByRoles(VOLUNTEER_ROLES); + + 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); } } diff --git a/apps/backend/src/volunteerAssignments/volunteerAssignments.controller.ts b/apps/backend/src/volunteerAssignments/volunteerAssignments.controller.ts deleted file mode 100644 index 84912f9f..00000000 --- a/apps/backend/src/volunteerAssignments/volunteerAssignments.controller.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { AssignmentsService } from './volunteerAssignments.service'; -import { Assignments } from './volunteerAssignments.entity'; - -@Controller('assignments') -export class AssignmentsController { - constructor(private assignmentsService: AssignmentsService) {} - - @Get('') - async getAssignments(): Promise { - return this.assignmentsService.getAssignments(); - } -} diff --git a/apps/backend/src/volunteerAssignments/volunteerAssignments.entity.ts b/apps/backend/src/volunteerAssignments/volunteerAssignments.entity.ts deleted file mode 100644 index d9929fe5..00000000 --- a/apps/backend/src/volunteerAssignments/volunteerAssignments.entity.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - OneToOne, - JoinColumn, - ManyToOne, - Column, -} from 'typeorm'; -import { User } from '../users/user.entity'; -import { Pantry } from '../pantries/pantries.entity'; - -@Entity('volunteer_assignments') -export class Assignments { - @PrimaryGeneratedColumn({ name: 'assignment_id' }) - assignmentId: number; - - @ManyToOne(() => User, { nullable: false }) - @JoinColumn({ - name: 'volunteer_id', - referencedColumnName: 'id', - }) - volunteer: User; - - @OneToOne(() => Pantry, { nullable: true }) - @JoinColumn({ - name: 'pantry_id', - referencedColumnName: 'pantryId', - }) - pantry: Pantry; -} diff --git a/apps/backend/src/volunteerAssignments/volunteerAssignments.module.ts b/apps/backend/src/volunteerAssignments/volunteerAssignments.module.ts deleted file mode 100644 index dc0382e8..00000000 --- a/apps/backend/src/volunteerAssignments/volunteerAssignments.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { JwtStrategy } from '../auth/jwt.strategy'; -import { AuthService } from '../auth/auth.service'; -import { Assignments } from './volunteerAssignments.entity'; -import { AssignmentsController } from './volunteerAssignments.controller'; -import { AssignmentsService } from './volunteerAssignments.service'; -import { UsersModule } from '../users/users.module'; - -@Module({ - imports: [TypeOrmModule.forFeature([Assignments]), UsersModule], - controllers: [AssignmentsController], - providers: [AssignmentsService, AuthService, JwtStrategy], -}) -export class AssignmentsModule {} diff --git a/apps/backend/src/volunteerAssignments/volunteerAssignments.service.ts b/apps/backend/src/volunteerAssignments/volunteerAssignments.service.ts deleted file mode 100644 index d6ef19a1..00000000 --- a/apps/backend/src/volunteerAssignments/volunteerAssignments.service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Assignments } from './volunteerAssignments.entity'; -import { UsersService } from '../users/users.service'; - -@Injectable() -export class AssignmentsService { - constructor( - @InjectRepository(Assignments) private repo: Repository, - private usersService: UsersService, - ) {} - - // Gets the assignment id, volunteer details and the corresponding pantry - async getAssignments() { - const results = await this.repo.find({ - relations: ['volunteer', 'pantry'], - select: { - assignmentId: true, - volunteer: { - id: true, - firstName: true, - lastName: true, - email: true, - phone: true, - role: true, - }, - pantry: { - pantryId: true, - pantryName: true, - }, - }, - }); - return results; - } -} diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 74d2c812..a548bfa5 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -7,7 +7,6 @@ import { DonationItem, Donation, Allocation, - VolunteerPantryAssignment, CreateFoodRequestBody, Pantry, PantryApplicationDto, @@ -130,10 +129,6 @@ export class ApiClient { .then((response) => response.data); } - public async getAllAssignments(): Promise { - return this.get('/api/assignments') as Promise; - } - public async getVolunteers(): Promise { return this.get('/api/users/volunteers') as Promise; } diff --git a/apps/frontend/src/types/pantryEnums.ts b/apps/frontend/src/types/pantryEnums.ts index e5f13a6f..cdf8b671 100644 --- a/apps/frontend/src/types/pantryEnums.ts +++ b/apps/frontend/src/types/pantryEnums.ts @@ -33,7 +33,7 @@ export enum PantryStatus { export enum Activity { CREATE_LABELED_SHELF = 'Create labeled shelf', PROVIDE_EDUCATIONAL_PAMPHLETS = 'Provide educational pamphlets', - TRACK_DIETARY_NEEDS ='Spreadsheet to track dietary needs', + TRACK_DIETARY_NEEDS = 'Spreadsheet to track dietary needs', POST_RESOURCE_FLYERS = 'Post allergen-free resource flyers', SURVEY_CLIENTS = 'Survey clients to determine medical dietary needs', COLLECT_FEEDBACK = 'Collect feedback from allergen-avoidant clients', @@ -44,4 +44,4 @@ export enum ReserveFoodForAllergic { YES = 'Yes', SOME = 'Some', NO = 'No', -} \ No newline at end of file +} diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index cb901272..6fe1ad33 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -1,12 +1,12 @@ -import { - RefrigeratedDonation, - ReserveFoodForAllergic, - ClientVisitFrequency, +import { + RefrigeratedDonation, + ReserveFoodForAllergic, + ClientVisitFrequency, ServeAllergicChildren, AllergensConfidence, PantryStatus, Activity, -} from "./pantryEnums"; +} from './pantryEnums'; // Note: The API calls as currently written do not // return a pantry's SSF representative or pantry @@ -37,6 +37,7 @@ export interface Pantry { activitiesComments?: string; itemsInStock: string; needMoreOptions: string; + volunteers?: User[]; } export interface PantryApplicationDto { @@ -113,6 +114,7 @@ export interface User { lastName: string; email: string; phone: string; + pantries?: Pantry[]; } export interface FoodRequest { @@ -173,22 +175,6 @@ export enum VolunteerType { STANDARD_VOLUNTEER = 'standard_volunteer', } -export interface VolunteerPantryAssignment { - assignmentId: number; - volunteer: { - id: number; - firstName: string; - lastName: string; - email: string; - phone: string; - role: string; - }; - pantry: { - pantryId: number; - pantryName: string; - }; -} - export enum Role { ADMIN = 'admin', LEAD_VOLUNTEER = 'lead_volunteer', @@ -223,4 +209,3 @@ export enum DonationStatus { FULFILLED = 'fulfilled', MATCHING = 'matching', } -