Skip to content
Merged
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
9 changes: 9 additions & 0 deletions apps/backend/src/donationItems/donationItems.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ export class DonationItemsService {
return this.repo.find({ where: { donation: { donationId } } });
}

async getAllForManufacturer(
foodManufacturerId: number,
): Promise<DonationItem[]> {
validateId(foodManufacturerId, 'Manufacturer');
return this.repo.find({
where: { donation: { foodManufacturer: { foodManufacturerId } } },
});
}

async getByIds(donationItemIds: number[]): Promise<DonationItem[]> {
donationItemIds.forEach((id) => validateId(id, 'Donation Item'));

Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/donations/donations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,7 @@ export class DonationService {
: this.allocationRepo;

for (const donationId of donationIds) {
validateId(donationId, 'Donation');
const donation = await donationRepo.findOne({
where: { donationId },
});
Expand Down
9 changes: 9 additions & 0 deletions apps/backend/src/orders/dtos/order-donation-item.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { FoodType } from '../../donationItems/types';

export class OrderDonationItemDto {
itemId!: number;
itemName!: string;
foodType!: FoodType;
quantity!: number;
reservedQuantity!: number;
}
26 changes: 26 additions & 0 deletions apps/backend/src/orders/order.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,32 @@ describe('OrdersController', () => {
});
});

describe('getOrderDonationItems', () => {
it('should call ordersService.getManufacturerDonationItems and return its result', async () => {
const items = [
{
itemId: 1,
itemName: 'Peanut Butter (16oz)',
foodType: FoodType.DAIRY_FREE_ALTERNATIVES,
quantity: 100,
reservedQuantity: 10,
},
];
mockOrdersService.getManufacturerDonationItems.mockResolvedValueOnce(
items,
);

const orderId = 1;

const result = await controller.getOrderDonationItems(orderId);

expect(result).toEqual(items);
expect(
mockOrdersService.getManufacturerDonationItems,
).toHaveBeenCalledWith(orderId);
});
});

describe('getAllOrders', () => {
it('should call ordersService.getAll and return orders', async () => {
const status = 'pending';
Expand Down
13 changes: 13 additions & 0 deletions apps/backend/src/orders/order.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { DonationService } from '../donations/donations.service';
import { Donation } from '../donations/donations.entity';
import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto';
import { OrderDetailsDto } from './dtos/order-details.dto';
import { OrderDonationItemDto } from './dtos/order-donation-item.dto';
import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto';
import { AWSS3Service } from '../aws/aws-s3.service';
import { FilesInterceptor } from '@nestjs/platform-express';
Expand Down Expand Up @@ -133,6 +134,18 @@ export class OrdersController {
return this.ordersService.findOrderDetails(orderId);
}

@CheckOwnership({
idParam: 'orderId',
resolver: resolveOrderAuthorizedUserIds,
})
@Roles(Role.VOLUNTEER)
@Get('/:orderId/donation-items')
async getOrderDonationItems(
@Param('orderId', ParseIntPipe) orderId: number,
): Promise<OrderDonationItemDto[]> {
return this.ordersService.getManufacturerDonationItems(orderId);
}

@Roles(Role.ADMIN, Role.VOLUNTEER)
@CheckOwnership({
idParam: 'foodRequestId',
Expand Down
94 changes: 83 additions & 11 deletions apps/backend/src/orders/order.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe('OrdersService', () => {
let service: OrdersService;
let donationService: DonationService;
let allocationsService: AllocationsService;
let donationItemsService: DonationItemsService;

beforeAll(async () => {
mockEmailsService.sendEmails.mockResolvedValue(undefined);
Expand Down Expand Up @@ -130,6 +131,8 @@ describe('OrdersService', () => {
service = module.get<OrdersService>(OrdersService);
donationService = module.get<DonationService>(DonationService);
allocationsService = module.get<AllocationsService>(AllocationsService);
donationItemsService =
module.get<DonationItemsService>(DonationItemsService);
});

beforeEach(async () => {
Expand Down Expand Up @@ -313,6 +316,72 @@ describe('OrdersService', () => {
});
});

describe('getManufacturerDonationItems', () => {
it("returns the order manufacturer's donation items with quantity and reservedQuantity", async () => {
const orderId = 1;
const order = await service.findOne(orderId);

const result = await service.getManufacturerDonationItems(orderId);
const expectedItems = await donationItemsService.getAllForManufacturer(
order.foodManufacturerId,
);

expect(result).toHaveLength(expectedItems.length);
expect(result).toEqual(
expect.arrayContaining(
expectedItems.map((item) => ({
itemId: item.itemId,
itemName: item.itemName,
foodType: item.foodType,
quantity: item.quantity,
reservedQuantity: item.reservedQuantity,
})),
),
);
});

it('includes fully-reserved items (does not filter by availability)', async () => {
const orderId = 1;
const order = await service.findOne(orderId);

const fullyReserved = {
itemId: 999,
itemName: 'Fully Reserved Item',
foodType: FoodType.SPREADS_SEED_BUTTERS,
quantity: 10,
reservedQuantity: 10,
donationId: 1,
} as DonationItem;

const spy = jest
.spyOn(donationItemsService, 'getAllForManufacturer')
.mockResolvedValue([fullyReserved]);

const result = await service.getManufacturerDonationItems(orderId);

expect(spy).toHaveBeenCalledWith(order.foodManufacturerId);
expect(result).toEqual([
{
itemId: 999,
itemName: 'Fully Reserved Item',
foodType: FoodType.SPREADS_SEED_BUTTERS,
quantity: 10,
reservedQuantity: 10,
},
]);

spy.mockRestore();
});

it('throws NotFoundException when order does not exist', async () => {
const missingOrderId = 99999999;

await expect(
service.getManufacturerDonationItems(missingOrderId),
).rejects.toThrow(NotFoundException);
});
});

describe('findOne', () => {
it('returns order by ID', async () => {
const orderId = 1;
Expand Down Expand Up @@ -1246,7 +1315,7 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${
};
};

it('sets the order status to CLOSED on success', async () => {
it('sets the order status to CLOSED when everything succeeds', async () => {
const { orderId } = await createPendingOrder();

await service.closeOrder(orderId);
Expand Down Expand Up @@ -1540,26 +1609,29 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${
});

describe('getAllOrdersForVolunteer', () => {
it('should return all orders across all pantries and assignees, with required actions for assigned orders', async () => {
it('should return only orders assigned to the volunteer, each with actionCompletion', async () => {
const volunteerId = 6;
const result = await service.getAllOrdersForVolunteer(volunteerId);
// assign all seed orders away from volunteer 6, then a single order back to them
await testDataSource.query(
`UPDATE orders SET assignee_id = (SELECT user_id FROM users WHERE role = 'volunteer' AND user_id != 6 LIMIT 1)`,
);
await testDataSource.query(
`UPDATE orders SET assignee_id = 6 WHERE order_id = 4`,
);

expect(result).toHaveLength(4);
const result = await service.getAllOrdersForVolunteer(volunteerId);

const assignedOrder = result.find((o) => o.assignee.id === volunteerId);
expect(assignedOrder?.actionCompletion).toEqual({
expect(result).toHaveLength(1);
expect(result.every((o) => o.assignee.id === volunteerId)).toBe(true);
expect(result[0].actionCompletion).toEqual({
confirmDonationReceipt: false,
notifyPantry: false,
});

const notAssignedOrder = result.find(
(o) => o.assignee.id !== volunteerId,
);
expect(notAssignedOrder?.actionCompletion).toBeUndefined();
});

it('should map the rest of the data correctly', async () => {
const volunteerId = 6;
await testDataSource.query(`UPDATE orders SET assignee_id = 6`);
const result = await service.getAllOrdersForVolunteer(volunteerId);
const order = result.find((o) => o.orderId === 4);

Expand Down
61 changes: 40 additions & 21 deletions apps/backend/src/orders/order.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { DonationService } from '../donations/donations.service';
import { OrderStatus, VolunteerAction } from './types';
import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto';
import { OrderDetailsDto } from './dtos/order-details.dto';
import { OrderDonationItemDto } from './dtos/order-donation-item.dto';
import { UpdateAllocationsDto } from './dtos/update-allocations.dto';
import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto';
import { ConfirmDeliveryDto } from './dtos/confirm-delivery.dto';
Expand Down Expand Up @@ -86,8 +87,7 @@ export class OrdersService {
return qb.getMany();
}

// returns ALL orders (not scoped to volunteer)
// for orders assigned to the given volunteer, includes actionCompletion (otherwise undefined)
// returns all orders assigned to the given volunteer, each with actionCompletion
async getAllOrdersForVolunteer(
volunteerId: number,
): Promise<VolunteerOrder[]> {
Expand All @@ -110,27 +110,23 @@ export class OrdersService {
'assignee.firstName',
'assignee.lastName',
])
.where('order.assigneeId = :volunteerId', { volunteerId })
.getMany();

return orders.map((o) => {
const { assignee, confirmDonationReceipt, notifyPantry } = o;
const actionCompletion =
assignee.id === volunteerId
? { confirmDonationReceipt, notifyPantry }
: undefined;

return {
orderId: o.orderId,
status: o.status,
createdAt: o.createdAt,
shippedAt: o.shippedAt,
deliveredAt: o.deliveredAt,
pantryId: o.request.pantryId,
pantryName: o.request.pantry.pantryName,
assignee: o.assignee,
actionCompletion,
};
});
return orders.map((o) => ({
orderId: o.orderId,
status: o.status,
createdAt: o.createdAt,
shippedAt: o.shippedAt,
deliveredAt: o.deliveredAt,
pantryId: o.request.pantryId,
pantryName: o.request.pantry.pantryName,
assignee: o.assignee,
actionCompletion: {
confirmDonationReceipt: o.confirmDonationReceipt,
notifyPantry: o.notifyPantry,
},
}));
}

async getRecentOrdersByAssignee(
Expand Down Expand Up @@ -393,6 +389,29 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${
return order;
}

async getManufacturerDonationItems(
orderId: number,
): Promise<OrderDonationItemDto[]> {
validateId(orderId, 'Order');

const order = await this.repo.findOneBy({ orderId });
if (!order) {
throw new NotFoundException(`Order ${orderId} not found`);
}

const items = await this.donationItemsService.getAllForManufacturer(
order.foodManufacturerId,
);

return items.map((item) => ({
itemId: item.itemId,
itemName: item.itemName,
foodType: item.foodType,
quantity: item.quantity,
reservedQuantity: item.reservedQuantity,
}));
}

async findOrderDetails(orderId: number): Promise<OrderDetailsDto> {
validateId(orderId, 'Order');

Expand Down
21 changes: 21 additions & 0 deletions apps/frontend/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import {
UpdateFoodRequestBody,
DonationReminderDto,
ReplaceDonationItemDto,
OrderDonationItemDto,
UpdateAllocationsDto,
} from 'types/types';

const defaultBaseUrl =
Expand Down Expand Up @@ -347,6 +349,25 @@ export class ApiClient {
.then((response) => response.data);
}

public async getOrderDonationItems(
orderId: number,
): Promise<OrderDonationItemDto[]> {
return this.axiosInstance
.get(`/api/orders/${orderId}/donation-items`)
.then((response) => response.data);
}

public async editAllocations(
orderId: number,
dto: UpdateAllocationsDto,
): Promise<void> {
await this.axiosInstance.patch(`/api/orders/${orderId}/allocations`, dto);
}

public async closeOrder(orderId: number): Promise<void> {
await this.axiosInstance.patch(`/api/orders/${orderId}/close`, {});
}

public async updatePantryApplicationData(
pantryId: number,
data: UpdatePantryApplicationDto,
Expand Down
Loading
Loading