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
3 changes: 3 additions & 0 deletions apps/backend/src/donations/donations.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
Expand Down Expand Up @@ -33,6 +35,7 @@ export class DonationService {
@InjectRepository(DonationItem)
private donationItemsRepo: Repository<DonationItem>,
private donationItemsService: DonationItemsService,
@Inject(forwardRef(() => FoodManufacturersService))
private foodManufacturersService: FoodManufacturersService,
@InjectDataSource() private dataSource: DataSource,
private emailsService: EmailsService,
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/foodRequests/request.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
InternalServerErrorException,
NotFoundException,
Expand Down Expand Up @@ -40,6 +42,7 @@ export class RequestsService {
@InjectRepository(DonationItem)
private donationItemRepo: Repository<DonationItem>,
private emailsService: EmailsService,
@Inject(forwardRef(() => UsersService))
private usersService: UsersService,
) {}

Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/orders/order.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
InternalServerErrorException,
Logger,
Expand Down Expand Up @@ -47,7 +49,9 @@ export class OrdersService {
@InjectRepository(Pantry) private pantryRepo: Repository<Pantry>,
@InjectRepository(Donation) private donationRepo: Repository<Donation>,
@InjectRepository(FoodRequest) private requestRepo: Repository<FoodRequest>,
@Inject(forwardRef(() => RequestsService))
private requestsService: RequestsService,
@Inject(forwardRef(() => UsersService))
private usersService: UsersService,
private manufacturerService: FoodManufacturersService,
private donationItemsService: DonationItemsService,
Expand Down
10 changes: 8 additions & 2 deletions apps/backend/src/pantries/pantries.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,16 @@ import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary
const resolvePantryAuthorizedUserIds: OwnerIdResolver = ({
entityId,
services,
user,
}) =>
pipeNullable(
() => services.get(PantriesService).findOne(entityId),
(pantry: Pantry) => [pantry.pantryUser.id],
(pantry: Pantry) => {
if (user?.role === Role.VOLUNTEER) {
return (pantry.volunteers ?? []).map((volunteer) => volunteer.id);
}
return [pantry.pantryUser.id];
},
);

@Controller('pantries')
Expand Down Expand Up @@ -119,7 +125,7 @@ export class PantriesController {
idParam: 'pantryId',
resolver: resolvePantryAuthorizedUserIds,
})
@Roles(Role.PANTRY, Role.ADMIN)
@Roles(Role.PANTRY, Role.ADMIN, Role.VOLUNTEER)
@Get('/:pantryId')
async getPantry(
@Param('pantryId', ParseIntPipe) pantryId: number,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type AdminVolunteerStats = {
export type AdminStatsDto = {
'Food Requests': string;
Orders: string;
Donations: string;
Expand Down
7 changes: 5 additions & 2 deletions apps/backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ import { userSchemaDto } from './dtos/userSchema.dto';
import { UpdateUserInfoDto } from './dtos/update-user-info.dto';
import { PendingApplication, Role } from './types';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { AdminVolunteerStats } from './dtos/admin-volunteer-stats.dto';
import { AdminStatsDto } from './dtos/admin-stats.dto';
import { PantryStatsDto } from '../pantries/dtos/pantry-stats.dto';
import { ManufacturerStatsDto } from '../foodManufacturers/dtos/manufacturer-stats.dto';
import { Roles } from '../auth/roles.decorator';
import { CheckOwnership, OwnerIdResolver } from '../auth/ownership.decorator';
import { VolunteerStatsDto } from '../volunteers/dtos/volunteer-stats.dto';

const resolveUserAuthorizedUserIds: OwnerIdResolver = async ({ entityId }) => [
entityId,
Expand All @@ -40,7 +41,9 @@ export class UsersController {
@Get('/:id/stats')
async getUserDashboardStats(
@Param('id', ParseIntPipe) userId: number,
): Promise<AdminVolunteerStats | PantryStatsDto | ManufacturerStatsDto> {
): Promise<
AdminStatsDto | PantryStatsDto | ManufacturerStatsDto | VolunteerStatsDto
> {
return this.usersService.getUserDashboardStats(userId);
}

Expand Down
193 changes: 50 additions & 143 deletions apps/backend/src/users/users.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { FoodManufacturersService } from '../foodManufacturers/manufacturers.ser
import { DonationItemsService } from '../donationItems/donationItems.service';
import { AllocationsService } from '../allocations/allocations.service';
import { PantriesService } from '../pantries/pantries.service';
import { VolunteersService } from '../volunteers/volunteers.service';

jest.setTimeout(60000);

Expand All @@ -50,6 +51,7 @@ describe('UsersService', () => {
let donationService: DonationService;
let pantriesService: PantriesService;
let foodManufacturersService: FoodManufacturersService;
let volunteersService: VolunteersService;

beforeAll(async () => {
process.env.SEND_AUTOMATED_EMAILS = 'true';
Expand All @@ -64,6 +66,7 @@ describe('UsersService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
VolunteersService,
RequestsService,
OrdersService,
DonationService,
Expand Down Expand Up @@ -125,6 +128,7 @@ describe('UsersService', () => {
foodManufacturersService = module.get<FoodManufacturersService>(
FoodManufacturersService,
);
volunteersService = module.get<VolunteersService>(VolunteersService);
});

beforeEach(async () => {
Expand Down Expand Up @@ -509,41 +513,9 @@ describe('UsersService', () => {
});
});

describe('getAdminVolunteerMonthlyAggregatedStats', () => {
it('should return correct aggregated counts for the current month', async () => {
const foodRequestRepo = testDataSource.getRepository(FoodRequest);

const now = new Date();

const createDonationBody: Partial<CreateDonationDto> = {
foodManufacturerId: 1,
recurrence: RecurrenceEnum.MONTHLY,
recurrenceFreq: 3,
occurrencesRemaining: 2,
items: [
{
itemName: 'Test Item',
quantity: 10,
foodType: FoodType.GRANOLA,
ozPerItem: 3.4,
estimatedValue: 3.4,
foodRescue: false,
},
],
};

await donationService.create(createDonationBody as CreateDonationDto);

// updating existing request to have a current month requested at date
const existingRequest = await foodRequestService.findOne(1);
existingRequest.requestedAt = new Date(
now.getFullYear(),
now.getMonth(),
5,
);
await foodRequestRepo.save(existingRequest);

const stats = await service.getAdminVolunteerMonthlyAggregatedStats();
describe('getAdminAggregatedStats', () => {
it('should return all-time aggregated counts across all records', async () => {
const stats = await service.getAdminAggregatedStats();

const expectedKeys = [
'Food Requests',
Expand All @@ -558,120 +530,62 @@ describe('UsersService', () => {
expect(typeof value).toBe('string');
});

// Seed data contains 4 food requests, 4 orders, 4 donations, and 4
// volunteers, all dated in 2024. All-time stats count them regardless
// of when they were created.
expect(stats).toEqual({
'Food Requests': '1',
Orders: '0',
Donations: '1',
'Food Requests': '4',
Orders: '4',
Donations: '4',
Volunteers: '4',
});
});

it('should return correct aggregated counts for the current month with edge cases of start and end of month', async () => {
it('should count records regardless of how old their dates are', async () => {
const foodRequestRepo = testDataSource.getRepository(FoodRequest);

const now = new Date();

const year = now.getFullYear();
const month = now.getMonth();

const startOfMonth = new Date(year, month, 1, 0, 0, 0, 0);

const endOfMonth = new Date(year, month + 1, 0, 23, 59, 59, 999);

const existingRequest1 = await foodRequestService.findOne(1);
existingRequest1.requestedAt = endOfMonth;
await foodRequestRepo.save(existingRequest1);

const existingRequest2 = await foodRequestService.findOne(2);
existingRequest2.requestedAt = startOfMonth;
await foodRequestRepo.save(existingRequest2);

const stats = await service.getAdminVolunteerMonthlyAggregatedStats();

const expectedKeys = [
'Food Requests',
'Orders',
'Donations',
'Volunteers',
];

expect(Object.keys(stats)).toEqual(expectedKeys);

Object.values(stats).forEach((value) => {
expect(typeof value).toBe('string');
});

expect(stats).toEqual({
'Food Requests': '2',
Orders: '0',
Donations: '0',
Volunteers: '4',
});
});

it('should return just volunteer count if no other fields are relative to current month', async () => {
const stats = await service.getAdminVolunteerMonthlyAggregatedStats();

const expectedKeys = [
'Food Requests',
'Orders',
'Donations',
'Volunteers',
];

expect(Object.keys(stats)).toEqual(expectedKeys);
// Move a request far into the past to confirm dates no longer filter
// the results.
const existingRequest = await foodRequestService.findOne(1);
existingRequest.requestedAt = new Date('2000-01-01T00:00:00Z');
await foodRequestRepo.save(existingRequest);

Object.values(stats).forEach((value) => {
expect(typeof value).toBe('string');
});
const stats = await service.getAdminAggregatedStats();

expect(stats).toEqual({
'Food Requests': '0',
Orders: '0',
Donations: '0',
'Food Requests': '4',
Orders: '4',
Donations: '4',
Volunteers: '4',
});
});

it('should return correct aggregated counts for mixed month dataset', async () => {
const foodRequestRepo = testDataSource.getRepository(FoodRequest);

const now = new Date();

const year = now.getFullYear();
const month = now.getMonth();

const startOfCurrentMonth = new Date(year, month, 1, 0, 0, 0, 0);

const endOfNextMonth = new Date(year, month + 2, 0, 23, 59, 59, 999);

const existingRequest1 = await foodRequestService.findOne(1);
existingRequest1.requestedAt = endOfNextMonth;
await foodRequestRepo.save(existingRequest1);

const existingRequest2 = await foodRequestService.findOne(2);
existingRequest2.requestedAt = startOfCurrentMonth;
await foodRequestRepo.save(existingRequest2);

const stats = await service.getAdminVolunteerMonthlyAggregatedStats();

const expectedKeys = [
'Food Requests',
'Orders',
'Donations',
'Volunteers',
];
it('should reflect newly created records in the counts', async () => {
const createDonationBody: Partial<CreateDonationDto> = {
foodManufacturerId: 1,
recurrence: RecurrenceEnum.MONTHLY,
recurrenceFreq: 3,
occurrencesRemaining: 2,
items: [
{
itemName: 'Test Item',
quantity: 10,
foodType: FoodType.GRANOLA,
ozPerItem: 3.4,
estimatedValue: 3.4,
foodRescue: false,
},
],
};

expect(Object.keys(stats)).toEqual(expectedKeys);
await donationService.create(createDonationBody as CreateDonationDto);

Object.values(stats).forEach((value) => {
expect(typeof value).toBe('string');
});
const stats = await service.getAdminAggregatedStats();

expect(stats).toEqual({
'Food Requests': '1',
Orders: '0',
Donations: '0',
'Food Requests': '4',
Orders: '4',
Donations: '5',
Volunteers: '4',
});
});
Expand All @@ -691,11 +605,8 @@ describe('UsersService', () => {
});

describe('getUserDashboardStats', () => {
it('should call getAdminVolunteerMonthlyAggregatedStats for admin user', async () => {
const spy = jest.spyOn(
service,
'getAdminVolunteerMonthlyAggregatedStats',
);
it('should call getAdminAggregatedStats for admin user', async () => {
const spy = jest.spyOn(service, 'getAdminAggregatedStats');

const result = await service.getUserDashboardStats(1);

Expand All @@ -708,21 +619,17 @@ describe('UsersService', () => {
]);
});

it('should call getAdminVolunteerMonthlyAggregatedStats for volunteer user', async () => {
it('should call getVolunteerDashboardStats for volunteer user', async () => {
// Maria Garcia (id=7) is a volunteer
const spy = jest.spyOn(
service,
'getAdminVolunteerMonthlyAggregatedStats',
);
const spy = jest.spyOn(volunteersService, 'getVolunteerDashboardStats');

const result = await service.getUserDashboardStats(7);

expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(7);
expect(Object.keys(result)).toEqual([
'Food Requests',
'Orders',
'Donations',
'Volunteers',
]);
});

Expand Down
Loading
Loading