diff --git a/apps/backend/src/emails/awsSes.wrapper.ts b/apps/backend/src/emails/awsSes.wrapper.ts index 4d2849bcb..a32180427 100644 --- a/apps/backend/src/emails/awsSes.wrapper.ts +++ b/apps/backend/src/emails/awsSes.wrapper.ts @@ -67,6 +67,6 @@ export class AmazonSESWrapper { }, }); - return await this.client.send(command); + return this.client.send(command); } } diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 12997849e..4cd5c221a 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -531,6 +531,21 @@ describe('FoodManufacturersService', () => { }); }); + describe('findByUserId', () => { + it('findByUserId success', async () => { + const manufacturer = await service.findOne(1); + const userId = manufacturer.foodManufacturerRepresentative.id; + const result = await service.findByUserId(userId); + expect(result.foodManufacturerId).toBe(1); + }); + + it('findByUserId with non-existent user throws NotFoundException', async () => { + await expect(service.findByUserId(9999)).rejects.toThrow( + new NotFoundException('Food Manufacturer for User 9999 not found'), + ); + }); + }); + describe('getStats', () => { it('returns proper stats for manufacturer', async () => { const manufacturerId = 1; diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index f3fae07d7..cc4153a17 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -1,5 +1,7 @@ import { BadRequestException, + forwardRef, + Inject, Injectable, NotFoundException, ConflictException, @@ -34,6 +36,7 @@ export class FoodManufacturersService { @InjectRepository(FoodManufacturer) private repo: Repository, + @Inject(forwardRef(() => UsersService)) private usersService: UsersService, private emailsService: EmailsService, @@ -140,7 +143,7 @@ export class FoodManufacturersService { } async getPendingManufacturers(): Promise { - return await this.repo.find({ + return this.repo.find({ where: { status: ApplicationStatus.PENDING }, relations: ['foodManufacturerRepresentative'], }); @@ -255,7 +258,7 @@ export class FoodManufacturersService { Object.assign(manufacturer, foodManufacturerData); - return await this.repo.save(manufacturer); + return this.repo.save(manufacturer); } async approve(id: number) { @@ -325,6 +328,22 @@ export class FoodManufacturersService { await this.repo.update(id, { status: ApplicationStatus.DENIED }); } + async findByUserId(userId: number): Promise { + validateId(userId, 'User'); + + const foodManufacturer = await this.repo.findOne({ + where: { foodManufacturerRepresentative: { id: userId } }, + relations: ['foodManufacturerRepresentative'], + }); + + if (!foodManufacturer) { + throw new NotFoundException( + `Food Manufacturer for User ${userId} not found`, + ); + } + return foodManufacturer; + } + async getStats(id: number): Promise { validateId(id, 'Food Manufacturer'); diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index d5d4e416d..2ddcc88c6 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -267,7 +267,7 @@ export class RequestsService { async find(pantryId: number) { validateId(pantryId, 'Pantry'); - return await this.repo.find({ + return this.repo.find({ where: { pantryId }, relations: ['orders', 'pantry'], }); diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 8ee19a4cc..d1a1ab75a 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -24,6 +24,7 @@ import { DonationStatus } from '../donations/types'; import { User } from '../users/users.entity'; import { AuthService } from '../auth/auth.service'; import { DonationService } from '../donations/donations.service'; +import { PantriesService } from '../pantries/pantries.service'; import { CreateOrderDto } from './dtos/create-order.dto'; import { DataSource } from 'typeorm'; import { EmailsService } from '../emails/email.service'; @@ -53,13 +54,8 @@ describe('OrdersService', () => { DonationItemsService, AllocationsService, UsersService, + PantriesService, DonationService, - EmailsService, - DonationService, - { - provide: DataSource, - useValue: testDataSource, - }, { provide: DataSource, useValue: testDataSource, diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 10f5480e9..19aa0d8bd 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -26,6 +26,8 @@ import { UsersService } from '../users/users.service'; import { AuthService } from '../auth/auth.service'; import { User } from '../users/users.entity'; import { AllocationsService } from '../allocations/allocations.service'; +import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto'; import { EmailsService } from '../emails/email.service'; import { mock } from 'jest-mock-extended'; @@ -108,6 +110,7 @@ describe('PantriesService', () => { providers: [ PantriesService, UsersService, + FoodManufacturersService, { provide: AuthService, useValue: { @@ -138,6 +141,10 @@ describe('PantriesService', () => { provide: getRepositoryToken(Donation), useValue: testDataSource.getRepository(Donation), }, + { + provide: getRepositoryToken(FoodManufacturer), + useValue: testDataSource.getRepository(FoodManufacturer), + }, ], }).compile(); diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 779b46b4a..395b879d5 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -256,7 +256,7 @@ export class PantriesService { } async getPendingPantries(): Promise { - return await this.repo.find({ + return this.repo.find({ where: { status: ApplicationStatus.PENDING }, relations: ['pantryUser'], }); @@ -396,7 +396,7 @@ export class PantriesService { Object.assign(pantry, pantryData); - return await this.repo.save(pantry); + return this.repo.save(pantry); } async approve(id: number) { @@ -578,6 +578,7 @@ export class PantriesService { const pantry = await this.repo.findOne({ where: { pantryUser: { id: userId } }, + relations: ['pantryUser'], }); if (!pantry) { diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index e54045d70..1a6bea651 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -29,6 +29,7 @@ describe('UsersController', () => { mockUserService.remove.mockReset(); mockUserService.update.mockReset(); mockUserService.create.mockReset(); + mockUserService.getUserDashboardStats.mockReset(); const module: TestingModule = await Test.createTestingModule({ controllers: [UsersController], @@ -123,6 +124,23 @@ describe('UsersController', () => { }); }); + describe('GET /:id/stats', () => { + it('should call getUserDashboardStats and return the result', async () => { + const mockStats = { + 'Food Requests': '0', + Orders: '0', + Donations: '0', + Volunteers: '4', + }; + mockUserService.getUserDashboardStats.mockResolvedValue(mockStats); + + const result = await controller.getUserDashboardStats(1); + + expect(result).toEqual(mockStats); + expect(mockUserService.getUserDashboardStats).toHaveBeenCalledWith(1); + }); + }); + describe('POST /api/users', () => { it('should create a new user with all required fields', async () => { const createUserSchema: userSchemaDto = { diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index c1f50cfa3..5bb3bdad1 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -16,6 +16,9 @@ import { UpdateUserInfoDto } from './dtos/update-user-info.dto'; import { AuthenticatedRequest } from '../auth/authenticated-request'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { UseGuards } from '@nestjs/common'; +import { UserStatsDto } from './dtos/user-stats.dto'; +import { PantryStatsDto } from '../pantries/dtos/pantry-stats.dto'; +import { ManufacturerStatsDto } from '../foodManufacturers/dtos/manufacturer-stats.dto'; @Controller('users') export class UsersController { @@ -32,6 +35,13 @@ export class UsersController { return this.usersService.findOne(userId); } + @Get('/:id/stats') + async getUserDashboardStats( + @Param('id', ParseIntPipe) userId: number, + ): Promise { + return this.usersService.getUserDashboardStats(userId); + } + @Delete('/:id') removeUser(@Param('id', ParseIntPipe) userId: number): Promise { return this.usersService.remove(userId); diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index ce5211d56..e271c6298 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -9,12 +9,14 @@ import { EmailsModule } from '../emails/email.module'; import { FoodRequest } from '../foodRequests/request.entity'; import { Order } from '../orders/order.entity'; import { Donation } from '../donations/donations.entity'; +import { ManufacturerModule } from '../foodManufacturers/manufacturers.module'; @Module({ imports: [ TypeOrmModule.forFeature([User, FoodRequest, Order, Donation]), forwardRef(() => PantriesModule), forwardRef(() => AuthModule), + forwardRef(() => ManufacturerModule), EmailsModule, ], controllers: [UsersController], diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index 569e0b184..31ac57725 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -30,6 +30,7 @@ import { DataSource } from 'typeorm'; import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; import { DonationItemsService } from '../donationItems/donationItems.service'; import { AllocationsService } from '../allocations/allocations.service'; +import { PantriesService } from '../pantries/pantries.service'; jest.setTimeout(60000); @@ -42,6 +43,8 @@ describe('UsersService', () => { let service: UsersService; let foodRequestService: RequestsService; let donationService: DonationService; + let pantriesService: PantriesService; + let foodManufacturersService: FoodManufacturersService; beforeAll(async () => { process.env.SEND_AUTOMATED_EMAILS = 'true'; @@ -62,6 +65,7 @@ describe('UsersService', () => { FoodManufacturersService, DonationItemsService, AllocationsService, + PantriesService, { provide: AuthService, useValue: mockAuthService, @@ -112,6 +116,10 @@ describe('UsersService', () => { service = module.get(UsersService); foodRequestService = module.get(RequestsService); donationService = module.get(DonationService); + pantriesService = module.get(PantriesService); + foodManufacturersService = module.get( + FoodManufacturersService, + ); }); beforeEach(async () => { @@ -516,4 +524,163 @@ describe('UsersService', () => { ); }); }); + + describe('getUserDashboardStats', () => { + it('should call getMonthlyAggregatedStats and return admin stats for admin user', async () => { + // Populate with dummy data + const now = new Date(); + const foodRequestRepo = testDataSource.getRepository(FoodRequest); + const orderRepo = testDataSource.getRepository(Order); + + const request1 = await foodRequestService.findOne(1); + request1.requestedAt = new Date(now.getFullYear(), now.getMonth(), 5); + await foodRequestRepo.save(request1); + + const request2 = await foodRequestService.findOne(2); + request2.requestedAt = new Date(now.getFullYear(), now.getMonth(), 10); + await foodRequestRepo.save(request2); + + const order1 = await orderRepo.findOneBy({ orderId: 1 }); + order1!.createdAt = new Date(now.getFullYear(), now.getMonth(), 5); + await orderRepo.save(order1!); + + await donationService.create({ + foodManufacturerId: 1, + recurrence: RecurrenceEnum.MONTHLY, + recurrenceFreq: 3, + occurrencesRemaining: 2, + items: [ + { + itemName: 'Test Item', + quantity: 10, + foodType: FoodType.GRANOLA, + foodRescue: false, + }, + ], + } as CreateDonationDto); + + const spy = jest.spyOn(service, 'getMonthlyAggregatedStats'); + const result = await service.getUserDashboardStats(1); + + expect(spy).toHaveBeenCalled(); + expect(result).toEqual({ + 'Food Requests': '2', + Orders: '1', + Donations: '1', + Volunteers: '4', + }); + }); + + it('should call getMonthlyAggregatedStats and return volunteer stats for volunteer user', async () => { + // Populate with dummy data + const now = new Date(); + const foodRequestRepo = testDataSource.getRepository(FoodRequest); + const orderRepo = testDataSource.getRepository(Order); + + const request1 = await foodRequestService.findOne(3); + request1.requestedAt = new Date(now.getFullYear(), now.getMonth(), 8); + await foodRequestRepo.save(request1); + + const order1 = await orderRepo.findOneBy({ orderId: 2 }); + order1!.createdAt = new Date(now.getFullYear(), now.getMonth(), 8); + await orderRepo.save(order1!); + + const order2 = await orderRepo.findOneBy({ orderId: 3 }); + order2!.createdAt = new Date(now.getFullYear(), now.getMonth(), 15); + await orderRepo.save(order2!); + + await donationService.create({ + foodManufacturerId: 1, + recurrence: RecurrenceEnum.MONTHLY, + recurrenceFreq: 3, + occurrencesRemaining: 2, + items: [ + { + itemName: 'Test Item A', + quantity: 5, + foodType: FoodType.GRANOLA, + foodRescue: false, + }, + ], + } as CreateDonationDto); + + await donationService.create({ + foodManufacturerId: 1, + recurrence: RecurrenceEnum.MONTHLY, + recurrenceFreq: 3, + occurrencesRemaining: 2, + items: [ + { + itemName: 'Test Item B', + quantity: 8, + foodType: FoodType.GRANOLA, + foodRescue: false, + }, + ], + } as CreateDonationDto); + + // Maria Garcia (id=7) is a volunteer + const spy = jest.spyOn(service, 'getMonthlyAggregatedStats'); + const result = await service.getUserDashboardStats(7); + + expect(spy).toHaveBeenCalled(); + expect(result).toEqual({ + 'Food Requests': '1', + Orders: '2', + Donations: '2', + Volunteers: '4', + }); + }); + + it('should call pantriesService.getStats and return pantry stats for pantry user', async () => { + const findByUserIdSpy = jest.spyOn(pantriesService, 'findByUserId'); + const getStatsSpy = jest.spyOn(pantriesService, 'getStats'); + + const result = await service.getUserDashboardStats(10); + + expect(findByUserIdSpy).toHaveBeenCalledWith(10); + expect(getStatsSpy).toHaveBeenCalledWith(1); + expect(result).toEqual({ + 'Food Requests': '2', + Orders: '2', + 'Items Received': '125', + 'Value Received': '$625', + }); + }); + + it('should call foodManufacturersService.getStats and return manufacturer stats for food manufacturer user', async () => { + const findByUserIdSpy = jest.spyOn( + foodManufacturersService, + 'findByUserId', + ); + const getStatsSpy = jest.spyOn(foodManufacturersService, 'getStats'); + + const result = await service.getUserDashboardStats(3); + + expect(findByUserIdSpy).toHaveBeenCalledWith(3); + expect(getStatsSpy).toHaveBeenCalledWith(1); + expect(result).toEqual({ + Donations: '2', + 'Value Donated': '$925', + 'Items Donated': '225', + 'lbs Donated': '225.03125', + }); + }); + + it('should throw NotFoundException for non-existent user', async () => { + await expect(service.getUserDashboardStats(9999)).rejects.toThrow( + new NotFoundException('User 9999 not found'), + ); + }); + + it('should throw BadRequestException for unsupported role', async () => { + jest + .spyOn(service, 'findOne') + .mockResolvedValueOnce({ role: 'unknown' } as unknown as User); + + await expect(service.getUserDashboardStats(1)).rejects.toThrow( + new BadRequestException('Unsupported role: unknown'), + ); + }); + }); }); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index c52031e92..0a05ec0a9 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -1,5 +1,7 @@ import { BadRequestException, + forwardRef, + Inject, Injectable, InternalServerErrorException, NotFoundException, @@ -18,6 +20,10 @@ import { FoodRequest } from '../foodRequests/request.entity'; import { Order } from '../orders/order.entity'; import { Donation } from '../donations/donations.entity'; import { UserStatsDto } from './dtos/user-stats.dto'; +import { PantryStatsDto } from '../pantries/dtos/pantry-stats.dto'; +import { ManufacturerStatsDto } from '../foodManufacturers/dtos/manufacturer-stats.dto'; +import { PantriesService } from '../pantries/pantries.service'; +import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; @Injectable() export class UsersService { @@ -32,6 +38,10 @@ export class UsersService { private donationRepo: Repository, private authService: AuthService, private emailsService: EmailsService, + @Inject(forwardRef(() => PantriesService)) + private pantriesService: PantriesService, + @Inject(forwardRef(() => FoodManufacturersService)) + private foodManufacturersService: FoodManufacturersService, ) {} async create(createUserDto: userSchemaDto): Promise { @@ -212,4 +222,26 @@ export class UsersService { Volunteers: volunteersCount.toString(), }; } + + async getUserDashboardStats( + userId: number, + ): Promise { + const user = await this.findOne(userId); + + if (user.role === Role.ADMIN || user.role === Role.VOLUNTEER) { + return this.getMonthlyAggregatedStats(); + } else if (user.role === Role.PANTRY) { + const pantry = await this.pantriesService.findByUserId(userId); + return this.pantriesService.getStats(pantry.pantryId); + } else if (user.role === Role.FOODMANUFACTURER) { + const foodManufacturer = await this.foodManufacturersService.findByUserId( + userId, + ); + return this.foodManufacturersService.getStats( + foodManufacturer.foodManufacturerId, + ); + } else { + throw new BadRequestException(`Unsupported role: ${user.role}`); + } + } } diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 3ce6ec12f..3827da984 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -16,6 +16,7 @@ import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { DonationItem } from '../donationItems/donationItems.entity'; import { Donation } from '../donations/donations.entity'; import { DataSource } from 'typeorm'; +import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; jest.setTimeout(60000); @@ -34,6 +35,7 @@ describe('VolunteersService', () => { UsersService, PantriesService, RequestsService, + FoodManufacturersService, { provide: DataSource, useValue: testDataSource,