From bbf7c42b42d193fd4d6e5f944be7bab724d180ee Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sun, 26 Apr 2026 17:13:16 -0400 Subject: [PATCH 01/12] Final commit --- .../dtos/bulk-update-tracking-cost.dto.ts | 20 + .../src/orders/order.controller.spec.ts | 22 + apps/backend/src/orders/order.controller.ts | 8 + apps/backend/src/orders/order.service.spec.ts | 264 ++++++++ apps/backend/src/orders/order.service.ts | 77 +++ apps/frontend/src/api/apiClient.ts | 32 + .../forms/fmCompleteRequiredActionsModal.tsx | 621 ++++++++++++++++++ .../components/forms/newDonationFormModal.tsx | 4 +- .../frontend/src/components/signOutButton.tsx | 1 - .../src/containers/donationManagement.tsx | 20 +- .../foodManufacturerDonationManagement.tsx | 51 +- apps/frontend/src/types/types.ts | 21 + 12 files changed, 1128 insertions(+), 13 deletions(-) create mode 100644 apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts create mode 100644 apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx diff --git a/apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts b/apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts new file mode 100644 index 000000000..856d3e434 --- /dev/null +++ b/apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts @@ -0,0 +1,20 @@ +import { IsArray, IsInt, Min, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { TrackingCostDto } from './tracking-cost.dto'; + +export class OrderTrackingCostEntryDto extends TrackingCostDto { + @IsInt() + @Min(1) + orderId!: number; +} + +export class BulkUpdateTrackingCostDto { + @IsInt() + @Min(1) + donationId!: number; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => OrderTrackingCostEntryDto) + orders!: OrderTrackingCostEntryDto[]; +} diff --git a/apps/backend/src/orders/order.controller.spec.ts b/apps/backend/src/orders/order.controller.spec.ts index d3ebf17d9..c71ccb557 100644 --- a/apps/backend/src/orders/order.controller.spec.ts +++ b/apps/backend/src/orders/order.controller.spec.ts @@ -10,6 +10,7 @@ import { FoodRequest } from '../foodRequests/request.entity'; import { Pantry } from '../pantries/pantries.entity'; import { AWSS3Service } from '../aws/aws-s3.service'; import { TrackingCostDto } from './dtos/tracking-cost.dto'; +import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { OrderDetailsDto } from './dtos/order-details.dto'; import { FoodType } from '../donationItems/types'; import { BadRequestException } from '@nestjs/common'; @@ -375,6 +376,27 @@ describe('OrdersController', () => { }); }); + describe('bulkUpdateTrackingCostInfo', () => { + it('should call ordersService.bulkUpdateTrackingCostInfo with correct parameters', async () => { + const dto: BulkUpdateTrackingCostDto = { + donationId: 1, + orders: [ + { + orderId: 4, + trackingLink: 'https://tracking.example.com', + shippingCost: 15.99, + }, + ], + }; + + await controller.bulkUpdateTrackingCostInfo(dto); + + expect(mockOrdersService.bulkUpdateTrackingCostInfo).toHaveBeenCalledWith( + dto, + ); + }); + }); + describe('createOrder', () => { const req = { user: { id: 3 } }; diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index a43649640..40b2e6ddf 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -24,6 +24,7 @@ import { OrderStatus } from './types'; import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; import { PantriesService } from '../pantries/pantries.service'; import { TrackingCostDto } from './dtos/tracking-cost.dto'; +import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { OrderDetailsDto } from './dtos/order-details.dto'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; import { AWSS3Service } from '../aws/aws-s3.service'; @@ -203,6 +204,13 @@ export class OrdersController { return this.ordersService.updateStatus(orderId, newStatus as OrderStatus); } + @Patch('/bulk-update-tracking-cost-info') + async bulkUpdateTrackingCostInfo( + @Body(new ValidationPipe()) dto: BulkUpdateTrackingCostDto, + ): Promise { + return this.ordersService.bulkUpdateTrackingCostInfo(dto); + } + @Patch('/:orderId/update-tracking-cost-info') async updateTrackingCostInfo( @Param('orderId', ParseIntPipe) orderId: number, diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index d32a328ce..e0ead36a0 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -8,6 +8,7 @@ import { Pantry } from '../pantries/pantries.entity'; import { OrderDetailsDto } from './dtos/order-details.dto'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { TrackingCostDto } from './dtos/tracking-cost.dto'; +import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { FoodType } from '../donationItems/types'; import { FoodRequest } from '../foodRequests/request.entity'; import 'multer'; @@ -34,6 +35,7 @@ jest.setTimeout(60000); describe('OrdersService', () => { let service: OrdersService; + let donationService: DonationService; beforeAll(async () => { // Initialize DataSource once @@ -110,6 +112,7 @@ describe('OrdersService', () => { }).compile(); service = module.get(OrdersService); + donationService = module.get(DonationService); }); beforeEach(async () => { @@ -1106,4 +1109,265 @@ describe('OrdersService', () => { ); }); }); + + describe('bulkUpdateTrackingCostInfo', () => { + async function insertMatchedDonation(): Promise { + const [{ donation_id }] = await testDataSource.query(` + INSERT INTO donations (food_manufacturer_id, status, recurrence, recurrence_freq, next_donation_dates, occurrences_remaining) + VALUES ( + (SELECT food_manufacturer_id FROM food_manufacturers LIMIT 1), + 'matched', 'none', NULL, NULL, NULL + ) + RETURNING donation_id + `); + return donation_id; + } + + async function insertDonationItem(donationId: number): Promise { + const [{ item_id }] = await testDataSource.query( + `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, food_type, food_rescue, details_confirmed) + VALUES ($1, 'Test Item', 10, 10, 'Granola', false, false) + RETURNING item_id`, + [donationId], + ); + return item_id; + } + + async function insertAllocation( + orderId: number, + itemId: number, + ): Promise { + await testDataSource.query( + `INSERT INTO allocations (order_id, item_id, allocated_quantity) VALUES ($1, $2, 1)`, + [orderId, itemId], + ); + } + + async function createPendingOrder(): Promise { + const [{ order_id }] = await testDataSource.query(` + INSERT INTO orders (request_id, food_manufacturer_id, status, assignee_id) + VALUES ( + (SELECT request_id FROM food_requests LIMIT 1), + (SELECT food_manufacturer_id FROM food_manufacturers LIMIT 1), + 'pending', + (SELECT user_id FROM users LIMIT 1) + ) + RETURNING order_id + `); + return order_id; + } + + it('throws BadRequestException when tracking link fails sanitization', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + await expect( + service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [ + { + orderId: 4, + trackingLink: `javascript:alert("you've been hacked!")`, + shippingCost: 5.0, + }, + ], + }), + ).rejects.toThrow( + new BadRequestException( + 'Invalid tracking link for order 4. Only valid HTTP/HTTPS URLs are accepted.', + ), + ); + }); + + it('throws BadRequestException when one order has an invalid tracking URL', async () => { + const donationId = await insertMatchedDonation(); + const itemId1 = await insertDonationItem(donationId); + const itemId2 = await insertDonationItem(donationId); + const orderId2 = await createPendingOrder(); + await insertAllocation(4, itemId1); + await insertAllocation(orderId2, itemId2); + + await expect( + service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [ + { + orderId: 4, + trackingLink: 'https://valid.com', + shippingCost: 5.0, + }, + { + orderId: orderId2, + trackingLink: `javascript:alert('xss')`, + shippingCost: 5.0, + }, + ], + }), + ).rejects.toThrow( + new BadRequestException( + `Invalid tracking link for order ${orderId2}. Only valid HTTP/HTTPS URLs are accepted.`, + ), + ); + }); + + it('throws NotFoundException when donation does not exist', async () => { + const dto: BulkUpdateTrackingCostDto = { + donationId: 9999, + orders: [ + { + orderId: 4, + trackingLink: 'https://tracking.com', + shippingCost: 5.0, + }, + ], + }; + + await expect(service.bulkUpdateTrackingCostInfo(dto)).rejects.toThrow( + new NotFoundException('Donation 9999 not found'), + ); + }); + + it('throws NotFoundException when one order does not exist', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + const dto: BulkUpdateTrackingCostDto = { + donationId, + orders: [ + { + orderId: 4, + trackingLink: 'https://tracking.com', + shippingCost: 5.0, + }, + { + orderId: 9999, + trackingLink: 'https://tracking2.com', + shippingCost: 6.0, + }, + ], + }; + + await expect(service.bulkUpdateTrackingCostInfo(dto)).rejects.toThrow( + new NotFoundException('Order 9999 not found'), + ); + }); + + it('throws BadRequestException when one order is not pending', async () => { + const donationId = await insertMatchedDonation(); + const itemId1 = await insertDonationItem(donationId); + const itemId2 = await insertDonationItem(donationId); + await insertAllocation(4, itemId1); + await insertAllocation(2, itemId2); + + const dto: BulkUpdateTrackingCostDto = { + donationId, + orders: [ + { + orderId: 4, + trackingLink: 'https://tracking.com', + shippingCost: 5.0, + }, + { + orderId: 2, + trackingLink: 'https://tracking2.com', + shippingCost: 6.0, + }, + ], + }; + + await expect(service.bulkUpdateTrackingCostInfo(dto)).rejects.toThrow( + new BadRequestException( + `Can only update tracking info for pending orders. Order 2 is ${OrderStatus.DELIVERED}`, + ), + ); + }); + + it('throws BadRequestException when one order does not belong to the donation', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + const orderId2 = await createPendingOrder(); + // orderId2 has no allocation to donationId + + const dto: BulkUpdateTrackingCostDto = { + donationId, + orders: [ + { + orderId: 4, + trackingLink: 'https://tracking.com', + shippingCost: 5.0, + }, + { + orderId: orderId2, + trackingLink: 'https://tracking2.com', + shippingCost: 6.0, + }, + ], + }; + + await expect(service.bulkUpdateTrackingCostInfo(dto)).rejects.toThrow( + new BadRequestException( + `Order ${orderId2} does not belong to donation ${donationId}`, + ), + ); + }); + + it('updates tracking link (sanitized), shipping cost, status, and shippedAt for all orders', async () => { + const donationId = await insertMatchedDonation(); + const itemId1 = await insertDonationItem(donationId); + const itemId2 = await insertDonationItem(donationId); + const orderId2 = await createPendingOrder(); + await insertAllocation(4, itemId1); + await insertAllocation(orderId2, itemId2); + + const before1 = await service.findOne(4); + const before2 = await service.findOne(orderId2); + expect(before1.status).toEqual(OrderStatus.PENDING); + expect(before1.shippedAt).toBeNull(); + expect(before2.status).toEqual(OrderStatus.PENDING); + expect(before2.shippedAt).toBeNull(); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [ + { orderId: 4, trackingLink: 'tracking1.com', shippingCost: 5.0 }, + { + orderId: orderId2, + trackingLink: 'tracking2.com', + shippingCost: 7.5, + }, + ], + }); + + const after1 = await service.findOne(4); + const after2 = await service.findOne(orderId2); + expect(after1.trackingLink).toEqual('https://tracking1.com/'); + expect(after1.shippingCost).toEqual(5.0); + expect(after1.status).toEqual(OrderStatus.SHIPPED); + expect(after1.shippedAt).toBeDefined(); + expect(after2.trackingLink).toEqual('https://tracking2.com/'); + expect(after2.shippingCost).toEqual(7.5); + expect(after2.status).toEqual(OrderStatus.SHIPPED); + expect(after2.shippedAt).toBeDefined(); + }); + + it('calls donationService.checkAndFulfillDonation after updating orders', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + const spy = jest.spyOn(donationService, 'checkAndFulfillDonation'); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [ + { orderId: 4, trackingLink: 'tracking.com', shippingCost: 5.0 }, + ], + }); + + expect(spy).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index ab73160a1..b0d6ff7ad 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -12,6 +12,7 @@ import { sanitizeUrl, validateId } from '../utils/validation.utils'; import { DonationService } from '../donations/donations.service'; import { OrderStatus, VolunteerAction } from './types'; import { TrackingCostDto } from './dtos/tracking-cost.dto'; +import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { OrderDetailsDto } from './dtos/order-details.dto'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; import { ConfirmDeliveryDto } from './dtos/confirm-delivery.dto'; @@ -490,6 +491,82 @@ export class OrdersService { } } + async bulkUpdateTrackingCostInfo( + dto: BulkUpdateTrackingCostDto, + ): Promise { + // Sanitize all URLs before entering transaction + for (const entry of dto.orders) { + validateId(entry.orderId, 'Order'); + const sanitized = sanitizeUrl(entry.trackingLink); + if (!sanitized) { + throw new BadRequestException( + `Invalid tracking link for order ${entry.orderId}. Only valid HTTP/HTTPS URLs are accepted.`, + ); + } + entry.trackingLink = sanitized; + } + + await this.dataSource.transaction(async (transactionManager) => { + const orderTransactionRepo = transactionManager.getRepository(Order); + const donationTransactionRepo = + transactionManager.getRepository(Donation); + + const donation = await donationTransactionRepo.findOneBy({ + donationId: dto.donationId, + }); + if (!donation) { + throw new NotFoundException(`Donation ${dto.donationId} not found`); + } + + const ordersToUpdate: Order[] = []; + + for (const entry of dto.orders) { + const order = await orderTransactionRepo.findOneBy({ + orderId: entry.orderId, + }); + if (!order) { + throw new NotFoundException(`Order ${entry.orderId} not found`); + } + + if (order.status !== OrderStatus.PENDING) { + throw new BadRequestException( + `Can only update tracking info for pending orders. Order ${entry.orderId} is ${order.status}`, + ); + } + + const relatedCount = await transactionManager + .createQueryBuilder(DonationItem, 'item') + .innerJoin('item.allocations', 'allocation') + .where('allocation.orderId = :orderId', { orderId: entry.orderId }) + .andWhere('item.donationId = :donationId', { + donationId: dto.donationId, + }) + .getCount(); + + if (relatedCount === 0) { + throw new BadRequestException( + `Order ${entry.orderId} does not belong to donation ${dto.donationId}`, + ); + } + + order.trackingLink = entry.trackingLink; + order.shippingCost = entry.shippingCost; + order.status = OrderStatus.SHIPPED; + order.shippedAt = new Date(); + ordersToUpdate.push(order); + } + + await orderTransactionRepo.save(ordersToUpdate); + }); + + const donation = await this.donationRepo.findOneBy({ + donationId: dto.donationId, + }); + if (donation) { + await this.donationService.checkAndFulfillDonation(donation); + } + } + async completeVolunteerAction(orderId: number, action: VolunteerAction) { validateId(orderId, 'Order'); diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index ffed2ae0d..e54031fdc 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -40,6 +40,9 @@ import { VolunteerOrder, VolunteerAction, FoodRequestWithoutRelations, + TrackingCostDto, + BulkUpdateTrackingCostDto, + ConfirmDonationItemDetailsDto, } from 'types/types'; const defaultBaseUrl = @@ -453,6 +456,35 @@ export class ApiClient { .then((response) => response.data); } + public async updateTrackingCostInfo( + orderId: number, + data: TrackingCostDto, + ): Promise { + await this.axiosInstance.patch( + `/api/orders/${orderId}/update-tracking-cost-info`, + data, + ); + } + + public async bulkUpdateTrackingCostInfo( + data: BulkUpdateTrackingCostDto, + ): Promise { + await this.axiosInstance.patch( + '/api/orders/bulk-update-tracking-cost-info', + data, + ); + } + + public async confirmDonationItemDetails( + donationId: number, + items: ConfirmDonationItemDetailsDto[], + ): Promise { + await this.axiosInstance.patch( + `/api/donations/${donationId}/item-details`, + items, + ); + } + public async updateFoodManufacturerApplicationData( manufacturerId: number, data: UpdateFoodManufacturerApplicationDto, diff --git a/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx new file mode 100644 index 000000000..5469308b5 --- /dev/null +++ b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx @@ -0,0 +1,621 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Button, + Text, + Flex, + Input, + Dialog, + CloseButton, + Pagination, + ButtonGroup, + IconButton, + Table, + Checkbox, +} from '@chakra-ui/react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import axios from 'axios'; +import ApiClient from '@api/apiClient'; +import { + DonationDetails, + OrderDetails, + ConfirmDonationItemDetailsDto, +} from '../../types/types'; +import { useGroupedItemsByFoodType } from '../../hooks/groupedItemsByFoodType'; +import { FloatingAlert } from '@components/floatingAlert'; +import { useAlert } from '../../hooks/alert'; + +type Stage = 'shipping' | 'itemDetails'; + +interface FmCompleteRequiredActionsModalProps { + donation: DonationDetails; + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +interface OrderFormData { + shippingCost: string; + trackingLink: string; +} + +interface ItemFormData { + ozPerItem: string; + estimatedValue: string; + foodRescue: boolean; +} + +// Order items section +const OrderItemsSection: React.FC<{ + orderDetails: OrderDetails | undefined; +}> = ({ orderDetails }) => { + const groupedItems = useGroupedItemsByFoodType(orderDetails?.items); + + if (!orderDetails) { + return ( + + Loading order details... + + ); + } + + return ( + + {Object.entries(groupedItems).map(([foodType, items]) => ( + + + {foodType} + + {items.map((item) => ( + + + {item.name} + + + + {item.quantity} + + + ))} + + ))} + + ); +}; + +const FmCompleteRequiredActionsModal: React.FC< + FmCompleteRequiredActionsModalProps +> = ({ donation, isOpen, onClose, onSuccess }) => { + const orders = donation.associatedPendingOrders; + const items = donation.relevantDonationItems; + const hasItemsToConfirm = items.length > 0; + + const [stage, setStage] = useState('shipping'); + const [currentPage, setCurrentPage] = useState(1); + const [orderFormData, setOrderFormData] = useState< + Record + >(() => + Object.fromEntries( + orders.map((o) => [o.orderId, { shippingCost: '', trackingLink: '' }]), + ), + ); + const [itemFormData, setItemFormData] = useState< + Record + >(() => + Object.fromEntries( + items.map((item) => [ + item.itemId, + { ozPerItem: '', estimatedValue: '', foodRescue: false }, + ]), + ), + ); + const [orderDetailsMap, setOrderDetailsMap] = useState< + Record + >({}); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [alertState, setAlertMessage] = useAlert(); + + const currentOrder = orders[currentPage - 1]; + + useEffect(() => { + const fetchAllOrderDetails = async () => { + try { + const fetchedDetails = await Promise.all( + orders.map((order) => ApiClient.getOrder(order.orderId)), + ); + const detailsMap: Record = {}; + orders.forEach((order, i) => { + detailsMap[order.orderId] = fetchedDetails[i]; + }); + setOrderDetailsMap(detailsMap); + } catch { + setAlertMessage('Error fetching order details. Please try again.'); + } + }; + + fetchAllOrderDetails(); + }, []); + + const updateOrderField = ( + orderId: number, + field: keyof OrderFormData, + value: string, + ) => { + setOrderFormData((prev) => ({ + ...prev, + [orderId]: { ...prev[orderId], [field]: value }, + })); + }; + + const updateItemField = ( + itemId: number, + field: keyof ItemFormData, + value: string | boolean, + ) => { + setItemFormData((prev) => ({ + ...prev, + [itemId]: { ...prev[itemId], [field]: value }, + })); + }; + + const areOrderFieldsFilled = orders.every( + (order) => + orderFormData[order.orderId].trackingLink.trim() !== '' && + orderFormData[order.orderId].shippingCost !== '', + ); + + const areItemFieldsFilled = items.every( + (item) => + itemFormData[item.itemId].ozPerItem !== '' && + itemFormData[item.itemId].estimatedValue !== '', + ); + + const handleSubmit = async () => { + setIsSubmitting(true); + try { + if (hasItemsToConfirm) { + const confirmItems: ConfirmDonationItemDetailsDto[] = items.map( + (item) => ({ + itemId: item.itemId, + ozPerItem: parseFloat(itemFormData[item.itemId].ozPerItem), + estimatedValue: parseFloat( + itemFormData[item.itemId].estimatedValue, + ), + foodRescue: itemFormData[item.itemId].foodRescue, + }), + ); + await ApiClient.confirmDonationItemDetails( + donation.donation.donationId, + confirmItems, + ); + } + + await ApiClient.bulkUpdateTrackingCostInfo({ + donationId: donation.donation.donationId, + orders: orders.map((order) => ({ + orderId: order.orderId, + trackingLink: orderFormData[order.orderId].trackingLink, + shippingCost: parseFloat(orderFormData[order.orderId].shippingCost), + })), + }); + + onSuccess(); + } catch (error) { + const rawMsg = axios.isAxiosError(error) && error.response?.data?.message; + const msg = Array.isArray(rawMsg) ? rawMsg[0] : rawMsg; + // Strip out nested validation for the orders for cleaner message + setAlertMessage( + msg + ? msg.replace(/^orders\.\d+\./, '') + : 'Error completing required actions. Please try again.', + ); + } finally { + setIsSubmitting(false); + } + }; + + if (!currentOrder) return null; + + const tableHeaderStyles = { + borderBottom: '1px solid', + borderColor: 'neutral.100', + color: 'neutral.800', + fontFamily: 'ibm', + fontWeight: '600', + fontSize: 'sm', + py: 2, + }; + + return ( + { + if (!e.open) onClose(); + }} + closeOnInteractOutside + > + {alertState && ( + + )} + + + + + + + + + + Complete Required Actions + + + + + {stage === 'shipping' && ( + <> + + Your donation has been partially matched to a pantry's food + request. Please check your inbox for details on where to ship + the following quantities of products, then provide the + shipping cost and tracking links for the following delivery. + + + + + Shipping Cost + + * + + + + updateOrderField( + currentOrder.orderId, + 'shippingCost', + e.target.value, + ) + } + /> + + + + + Delivery Tracking Link + + * + + + + updateOrderField( + currentOrder.orderId, + 'trackingLink', + e.target.value, + ) + } + /> + + + + + Order {currentOrder.orderId} -{' '} + + Requested by {currentOrder.pantryName} + + + + + + {orders.length > 1 && ( + + + setCurrentPage(e.page) + } + > + + + + + + ( + + {page.value} + + )} + /> + + + + + + + + )} + + + + {hasItemsToConfirm ? ( + + ) : ( + + )} + + + )} + + {stage === 'itemDetails' && ( + <> + + Please fill out the missing fields information to record + donation details. + + + + + + + + Food Item + + + Oz. per item + + * + + + + Donation Value + + * + + + + Food Rescue + + * + + + + + + {items.map((item) => ( + + + {item.itemName} + + + + updateItemField( + item.itemId, + 'ozPerItem', + e.target.value, + ) + } + /> + + + + updateItemField( + item.itemId, + 'estimatedValue', + e.target.value, + ) + } + /> + + + + updateItemField( + item.itemId, + 'foodRescue', + !!e.checked, + ) + } + > + + + + + + + + ))} + + + + + + + + + + )} + + + + + ); +}; + +export default FmCompleteRequiredActionsModal; diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx index 8386b850f..8fb4d4a43 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -31,6 +31,7 @@ import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../../hooks/alert'; interface NewDonationFormModalProps { + foodManufacturerId: number; onDonationSuccess: () => void; isOpen: boolean; onClose: () => void; @@ -102,6 +103,7 @@ const getFirstValidationError = ( }; const NewDonationFormModal: React.FC = ({ + foodManufacturerId, onDonationSuccess, isOpen, onClose, @@ -205,7 +207,7 @@ const NewDonationFormModal: React.FC = ({ } const donationBody: CreateDonationDto = { - foodManufacturerId: 1, + foodManufacturerId, recurrenceFreq: isRecurring ? parseInt(repeatEvery) : undefined, recurrence: isRecurring ? repeatInterval : RecurrenceEnum.NONE, repeatOnDays: diff --git a/apps/frontend/src/components/signOutButton.tsx b/apps/frontend/src/components/signOutButton.tsx index 2de170fe2..cff94f15c 100644 --- a/apps/frontend/src/components/signOutButton.tsx +++ b/apps/frontend/src/components/signOutButton.tsx @@ -1,4 +1,3 @@ -import apiClient from '@api/apiClient'; import { Button, ButtonProps } from '@chakra-ui/react'; import { signOut } from 'aws-amplify/auth'; import { useNavigate } from 'react-router-dom'; diff --git a/apps/frontend/src/containers/donationManagement.tsx b/apps/frontend/src/containers/donationManagement.tsx index 4a717ee10..c0c1b39f9 100644 --- a/apps/frontend/src/containers/donationManagement.tsx +++ b/apps/frontend/src/containers/donationManagement.tsx @@ -22,6 +22,13 @@ const DonationManagement: React.FC = () => { const [donationItemStock, setDonationItemStock] = useState<{ [key: number]: number; }>({}); + const [manufacturerId, setManufacturerId] = useState(null); + + useEffect(() => { + ApiClient.getCurrentUserFoodManufacturerId() + .then(setManufacturerId) + .catch(() => setManufacturerId(null)); + }, []); const fetchDonations = async () => { try { @@ -86,11 +93,14 @@ const DonationManagement: React.FC = () => { return (
- + {manufacturerId !== null && ( + + )} diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index f60109c27..18b42c543 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -15,9 +15,13 @@ import ApiClient from '@api/apiClient'; import { DonationDetails, DonationStatus } from '../types/types'; import DonationDetailsModal from '@components/forms/donationDetailsModal'; import NewDonationFormModal from '@components/forms/newDonationFormModal'; +import FmCompleteRequiredActionsModal from '@components/forms/fmCompleteRequiredActionsModal'; const FoodManufacturerDonationManagement: React.FC = () => { const [isLogDonationOpen, setIsLogDonationOpen] = useState(false); + const [manufacturerId, setManufacturerId] = useState(null); + const [selectedActionDonation, setSelectedActionDonation] = + useState(null); // State to hold donations grouped by status const [statusDonations, setStatusDonations] = useState<{ [key in DonationStatus]: DonationDetails[]; @@ -44,9 +48,9 @@ const FoodManufacturerDonationManagement: React.FC = () => { const MAX_PER_STATUS = 5; // Fetch all donations on component mount and sorts them into their appropriate status lists - const fetchDonations = async () => { + const fetchDonations = async (fmId: number) => { try { - const data = await ApiClient.getAllDonationsByFoodManufacturer(1); // Replace with actual food manufacturer ID + const data = await ApiClient.getAllDonationsByFoodManufacturer(fmId); const grouped: Record = { [DonationStatus.AVAILABLE]: [], @@ -81,7 +85,16 @@ const FoodManufacturerDonationManagement: React.FC = () => { }; useEffect(() => { - fetchDonations(); + const init = async () => { + try { + const fmId = await ApiClient.getCurrentUserFoodManufacturerId(); + setManufacturerId(fmId); + await fetchDonations(fmId); + } catch (error) { + alert('Error initializing donation management: ' + error); + } + }; + init(); }, []); const handlePageChange = (status: DonationStatus, page: number) => { @@ -114,14 +127,27 @@ const FoodManufacturerDonationManagement: React.FC = () => { Log New Donation - {isLogDonationOpen && ( + {isLogDonationOpen && manufacturerId !== null && ( fetchDonations(manufacturerId)} isOpen={isLogDonationOpen} onClose={() => setIsLogDonationOpen(false)} /> )} + {selectedActionDonation && ( + setSelectedActionDonation(null)} + onSuccess={() => { + setSelectedActionDonation(null); + if (manufacturerId !== null) fetchDonations(manufacturerId); + }} + /> + )} + {Object.values(DonationStatus).map((status) => { const allDonationsByStatus = statusDonations[status] || []; @@ -142,6 +168,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { totalDonations={allDonationsByStatus.length} currentPage={currentPage} onPageChange={(page) => handlePageChange(status, page)} + onActionSelect={setSelectedActionDonation} /> ); @@ -159,6 +186,7 @@ interface DonationStatusSectionProps { totalDonations: number; currentPage: number; onPageChange: (page: number) => void; + onActionSelect: (donation: DonationDetails | null) => void; } const DonationStatusSection: React.FC = ({ @@ -170,6 +198,7 @@ const DonationStatusSection: React.FC = ({ totalDonations, currentPage, onPageChange, + onActionSelect, }) => { const MAX_PER_STATUS = 5; const totalPages = Math.ceil(totalDonations / MAX_PER_STATUS); @@ -336,7 +365,17 @@ const DonationStatusSection: React.FC = ({ textAlign="right" color="neutral.700" > - No Action Required + {donationDetail.associatedPendingOrders.length > 0 ? ( + onActionSelect(donationDetail)} + > + Complete Required Actions + + ) : ( + 'No Action Required' + )} ); diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 20318879c..34bb35920 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -552,3 +552,24 @@ export type OrderItemDetailsGroupedByFoodType = Partial< export type DonationItemsGroupedByFoodType = Partial< Record >; + +export interface TrackingCostDto { + trackingLink: string; + shippingCost: number; +} + +export interface BulkUpdateTrackingCostDto { + donationId: number; + orders: { + orderId: number; + trackingLink: string; + shippingCost: number; + }[]; +} + +export interface ConfirmDonationItemDetailsDto { + itemId: number; + ozPerItem: number; + estimatedValue: number; + foodRescue: boolean; +} From 4f7dcd92ebee3d3e74295357f03404f2f22cf981 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sun, 26 Apr 2026 18:41:40 -0400 Subject: [PATCH 02/12] revisions --- .../components/forms/fmCompleteRequiredActionsModal.tsx | 6 +++++- .../src/containers/foodManufacturerDonationManagement.tsx | 1 + apps/frontend/src/types/types.ts | 7 ++----- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx index 5469308b5..902ffe793 100644 --- a/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx +++ b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx @@ -108,11 +108,15 @@ const FmCompleteRequiredActionsModal: React.FC< FmCompleteRequiredActionsModalProps > = ({ donation, isOpen, onClose, onSuccess }) => { const orders = donation.associatedPendingOrders; - const items = donation.relevantDonationItems; + const items = donation.relevantDonationItems.filter( + (item) => !item.detailsConfirmed, + ); const hasItemsToConfirm = items.length > 0; + // Track which action user is on const [stage, setStage] = useState('shipping'); const [currentPage, setCurrentPage] = useState(1); + // Form data for each id to persis between pagination const [orderFormData, setOrderFormData] = useState< Record >(() => diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index 18b42c543..0f2b93cd4 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -84,6 +84,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { } }; + // On page load, get the food manufacturer id and all appropriate donations useEffect(() => { const init = async () => { try { diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 34bb35920..a0708b017 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -198,6 +198,7 @@ export interface DonationItemWithAllocatedQuantity { itemName: string; foodType: FoodType; allocatedQuantity: number; + detailsConfirmed: boolean; } export interface DonationOrderDetails { @@ -560,11 +561,7 @@ export interface TrackingCostDto { export interface BulkUpdateTrackingCostDto { donationId: number; - orders: { - orderId: number; - trackingLink: string; - shippingCost: number; - }[]; + orders: ({ orderId: number } & TrackingCostDto)[]; } export interface ConfirmDonationItemDetailsDto { From 1f9a3bd96ba37873d96da4c6739104399391e140 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Mon, 27 Apr 2026 01:14:50 -0400 Subject: [PATCH 03/12] more revisions --- .../donationItems.service.spec.ts | 103 +++++-- .../donationItems/donationItems.service.ts | 51 +++- ...ts => update-donation-item-details.dto.ts} | 13 +- .../donations/donations.controller.spec.ts | 12 +- .../src/donations/donations.controller.ts | 10 +- .../src/donations/donations.service.spec.ts | 28 +- .../src/donations/donations.service.ts | 19 +- .../src/foodRequests/request.service.ts | 1 + .../dtos/bulk-update-tracking-cost.dto.ts | 30 +- .../src/orders/dtos/order-details.dto.ts | 1 + .../src/orders/dtos/tracking-cost.dto.ts | 18 -- apps/backend/src/orders/order.controller.ts | 10 - apps/backend/src/orders/order.service.spec.ts | 289 ++++++------------ apps/backend/src/orders/order.service.ts | 83 ++--- apps/frontend/src/api/apiClient.ts | 17 +- .../forms/fmCompleteRequiredActionsModal.tsx | 206 ++++++------- apps/frontend/src/types/types.ts | 20 +- 17 files changed, 419 insertions(+), 492 deletions(-) rename apps/backend/src/donationItems/dtos/{confirm-donation-item-details.dto.ts => update-donation-item-details.dto.ts} (62%) delete mode 100644 apps/backend/src/orders/dtos/tracking-cost.dto.ts diff --git a/apps/backend/src/donationItems/donationItems.service.spec.ts b/apps/backend/src/donationItems/donationItems.service.spec.ts index e054d81f7..b77a40c36 100644 --- a/apps/backend/src/donationItems/donationItems.service.spec.ts +++ b/apps/backend/src/donationItems/donationItems.service.spec.ts @@ -8,7 +8,7 @@ import { FoodType } from './types'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { testDataSource } from '../config/typeormTestDataSource'; import { CreateDonationItemDto } from './dtos/create-donation-items.dto'; -import { ConfirmDonationItemDetailsDto } from './dtos/confirm-donation-item-details.dto'; +import { UpdateDonationItemDetailsDto } from './dtos/update-donation-item-details.dto'; jest.setTimeout(60000); @@ -297,8 +297,8 @@ describe('DonationItemsService', () => { }); }); - describe('confirmItemDetails', () => { - const makeDto = (itemId: number): ConfirmDonationItemDetailsDto => ({ + describe('updateItemDetails', () => { + const makeDto = (itemId: number): UpdateDonationItemDetailsDto => ({ itemId, ozPerItem: 5.0, estimatedValue: 10.0, @@ -339,7 +339,7 @@ describe('DonationItemsService', () => { const donationId = await insertMatchedDonation(); await expect( testDataSource.transaction((tm) => - service.confirmItemDetails(donationId, [makeDto(99999)], tm), + service.updateItemDetails(donationId, [makeDto(99999)], tm), ), ).rejects.toThrow(new NotFoundException('Donation item 99999 not found')); }); @@ -349,7 +349,7 @@ describe('DonationItemsService', () => { // Item 1 belongs to donation 1, not the new donation await expect( testDataSource.transaction((tm) => - service.confirmItemDetails(donationId, [makeDto(1)], tm), + service.updateItemDetails(donationId, [makeDto(1)], tm), ), ).rejects.toThrow( new BadRequestException( @@ -358,30 +358,11 @@ describe('DonationItemsService', () => { ); }); - it('throws BadRequestException when an item in the body is already confirmed', async () => { - const donationId = await insertMatchedDonation(); - const itemId = await insertDonationItem(donationId, 10, 10); - await testDataSource.query( - `UPDATE donation_items SET details_confirmed = true WHERE item_id = $1`, - [itemId], - ); - - await expect( - testDataSource.transaction((tm) => - service.confirmItemDetails(donationId, [makeDto(itemId)], tm), - ), - ).rejects.toThrow( - new BadRequestException( - `Donation item ${itemId} has already been confirmed`, - ), - ); - }); - it('updates fields and sets detailsConfirmed to true for a single item', async () => { const donationId = await insertMatchedDonation(); const itemId = await insertDonationItem(donationId, 10, 5); - const dto: ConfirmDonationItemDetailsDto = { + const dto: UpdateDonationItemDetailsDto = { itemId, ozPerItem: 8.5, estimatedValue: 12.0, @@ -389,7 +370,7 @@ describe('DonationItemsService', () => { }; await testDataSource.transaction((tm) => - service.confirmItemDetails(donationId, [dto], tm), + service.updateItemDetails(donationId, [dto], tm), ); const item = await testDataSource @@ -407,7 +388,7 @@ describe('DonationItemsService', () => { const itemId2 = await insertDonationItem(donationId, 20, 10); await testDataSource.transaction((tm) => - service.confirmItemDetails( + service.updateItemDetails( donationId, [ { @@ -452,7 +433,7 @@ describe('DonationItemsService', () => { // Second dto references item 1 which belongs to donation 1, not ours await expect( testDataSource.transaction((tm) => - service.confirmItemDetails( + service.updateItemDetails( donationId, [makeDto(itemId), makeDto(1)], tm, @@ -470,5 +451,71 @@ describe('DonationItemsService', () => { expect(item?.detailsConfirmed).toBe(false); expect(item?.ozPerItem).toBeNull(); }); + + it('returns false and does not confirm when only some fields are provided', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId, 10, 5); + + const result = await testDataSource.transaction((tm) => + service.updateItemDetails(donationId, [{ itemId, ozPerItem: 8.5 }], tm), + ); + + expect(result).toBe(false); + const item = await testDataSource + .getRepository(DonationItem) + .findOneBy({ itemId }); + expect(Number(item?.ozPerItem)).toBe(8.5); + expect(item?.estimatedValue).toBeNull(); + expect(item?.detailsConfirmed).toBe(false); + }); + + it('confirms item on a second call that supplies the remaining fields', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId, 10, 5); + + const firstResult = await testDataSource.transaction((tm) => + service.updateItemDetails(donationId, [{ itemId, ozPerItem: 8.5 }], tm), + ); + expect(firstResult).toBe(false); + + const secondResult = await testDataSource.transaction((tm) => + service.updateItemDetails( + donationId, + [{ itemId, estimatedValue: 12.0, foodRescue: true }], + tm, + ), + ); + expect(secondResult).toBe(true); + + const item = await testDataSource + .getRepository(DonationItem) + .findOneBy({ itemId }); + expect(Number(item?.ozPerItem)).toBe(8.5); + expect(Number(item?.estimatedValue)).toBe(12.0); + expect(item?.foodRescue).toBe(true); + expect(item?.detailsConfirmed).toBe(true); + }); + + it('allows updating an already-confirmed item without throwing', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId, 10, 5); + await testDataSource.query( + `UPDATE donation_items + SET details_confirmed = true, oz_per_item = 5.0, estimated_value = 10.0 + WHERE item_id = $1`, + [itemId], + ); + + const result = await testDataSource.transaction((tm) => + service.updateItemDetails(donationId, [{ itemId, ozPerItem: 9.0 }], tm), + ); + + expect(result).toBe(true); + const item = await testDataSource + .getRepository(DonationItem) + .findOneBy({ itemId }); + expect(Number(item?.ozPerItem)).toBe(9.0); + expect(item?.detailsConfirmed).toBe(true); + }); }); }); diff --git a/apps/backend/src/donationItems/donationItems.service.ts b/apps/backend/src/donationItems/donationItems.service.ts index 30ac3d522..13619e95d 100644 --- a/apps/backend/src/donationItems/donationItems.service.ts +++ b/apps/backend/src/donationItems/donationItems.service.ts @@ -9,9 +9,8 @@ import { DonationItem } from './donationItems.entity'; import { validateId } from '../utils/validation.utils'; import { FoodType } from './types'; import { Donation } from '../donations/donations.entity'; -import { DonationStatus } from '../donations/types'; import { CreateDonationItemDto } from './dtos/create-donation-items.dto'; -import { ConfirmDonationItemDetailsDto } from './dtos/confirm-donation-item-details.dto'; +import { UpdateDonationItemDetailsDto } from './dtos/update-donation-item-details.dto'; @Injectable() export class DonationItemsService { @@ -103,14 +102,16 @@ export class DonationItemsService { return this.repo.save(donationItem); } - async confirmItemDetails( + async updateItemDetails( donationId: number, - body: ConfirmDonationItemDetailsDto[], + body: UpdateDonationItemDetailsDto[], transactionManager: EntityManager, - ): Promise { + ): Promise { const donationItemTransactionRepo = transactionManager.getRepository(DonationItem); + let confirmedDetailsForAnItem = false; + for (const dto of body) { const item = await donationItemTransactionRepo.findOneBy({ itemId: dto.itemId, @@ -126,19 +127,39 @@ export class DonationItemsService { ); } - if (item.detailsConfirmed) { - throw new BadRequestException( - `Donation item ${dto.itemId} has already been confirmed`, - ); + const updateData: Partial = {}; + if (dto.ozPerItem !== undefined) updateData.ozPerItem = dto.ozPerItem; + if (dto.estimatedValue !== undefined) + updateData.estimatedValue = dto.estimatedValue; + if (dto.foodRescue !== undefined) updateData.foodRescue = dto.foodRescue; + + // If included in DTO, keep it, otherwise use whatever is in the DB (could be null) + const resultingOzPerItem = + updateData.ozPerItem !== undefined + ? updateData.ozPerItem + : item.ozPerItem; + const resultingEstimatedValue = + updateData.estimatedValue !== undefined + ? updateData.estimatedValue + : item.estimatedValue; + const resultingFoodRescue = + updateData.foodRescue !== undefined + ? updateData.foodRescue + : item.foodRescue; + + if ( + resultingOzPerItem != null && + resultingEstimatedValue != null && + resultingFoodRescue != null + ) { + updateData.detailsConfirmed = true; + confirmedDetailsForAnItem = true; } - await donationItemTransactionRepo.update(dto.itemId, { - ozPerItem: dto.ozPerItem, - estimatedValue: dto.estimatedValue, - foodRescue: dto.foodRescue, - detailsConfirmed: true, - }); + await donationItemTransactionRepo.update(dto.itemId, updateData); } + + return confirmedDetailsForAnItem; } async createMultiple( diff --git a/apps/backend/src/donationItems/dtos/confirm-donation-item-details.dto.ts b/apps/backend/src/donationItems/dtos/update-donation-item-details.dto.ts similarity index 62% rename from apps/backend/src/donationItems/dtos/confirm-donation-item-details.dto.ts rename to apps/backend/src/donationItems/dtos/update-donation-item-details.dto.ts index 68bf556a4..182ace64d 100644 --- a/apps/backend/src/donationItems/dtos/confirm-donation-item-details.dto.ts +++ b/apps/backend/src/donationItems/dtos/update-donation-item-details.dto.ts @@ -1,23 +1,26 @@ -import { IsNumber, Min, IsBoolean, IsInt } from 'class-validator'; +import { IsNumber, Min, IsBoolean, IsInt, IsOptional } from 'class-validator'; -export class ConfirmDonationItemDetailsDto { +export class UpdateDonationItemDetailsDto { @IsInt() itemId!: number; + @IsOptional() @IsNumber( { maxDecimalPlaces: 2 }, { message: 'Oz per item must have at most 2 decimal places' }, ) @Min(0.01, { message: 'Oz per item must be at least 0.01' }) - ozPerItem!: number; + ozPerItem?: number; + @IsOptional() @IsNumber( { maxDecimalPlaces: 2 }, { message: 'Estimated value must have at most 2 decimal places' }, ) @Min(0.01, { message: 'Estimated value must be at least 0.01' }) - estimatedValue!: number; + estimatedValue?: number; + @IsOptional() @IsBoolean() - foodRescue!: boolean; + foodRescue?: boolean; } diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index d14b7f6ad..31c1fba1a 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -6,7 +6,7 @@ import { Donation } from './donations.entity'; import { CreateDonationDto } from './dtos/create-donation.dto'; import { CreateDonationItemDto } from '../donationItems/dtos/create-donation-items.dto'; import { DonationStatus, RecurrenceEnum } from './types'; -import { ConfirmDonationItemDetailsDto } from '../donationItems/dtos/confirm-donation-item-details.dto'; +import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; import { DonationItem } from '../donationItems/donationItems.entity'; import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; import { FoodType } from '../donationItems/types'; @@ -135,9 +135,9 @@ describe('DonationsController', () => { }); describe('PATCH /:donationId/item-details', () => { - it('calls confirmDonationItemDetails with the correct donationId and body, returns result', async () => { + it('calls updateDonationItemDetails with the correct donationId and body, returns result', async () => { const donationId = 1; - const body: ConfirmDonationItemDetailsDto[] = [ + const body: UpdateDonationItemDetailsDto[] = [ { itemId: 1, ozPerItem: 5.0, @@ -152,18 +152,18 @@ describe('DonationsController', () => { }, ]; - mockDonationService.confirmDonationItemDetails.mockResolvedValueOnce( + mockDonationService.updateDonationItemDetails.mockResolvedValueOnce( donation1 as Donation, ); - const result = await controller.confirmDonationItemDetails( + const result = await controller.updateDonationItemDetails( donationId, body, ); expect(result).toEqual(donation1); expect( - mockDonationService.confirmDonationItemDetails, + mockDonationService.updateDonationItemDetails, ).toHaveBeenCalledWith(donationId, body); }); }); diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 1376139de..8452ea33b 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -15,7 +15,7 @@ import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; import { RecurrenceEnum } from './types'; import { CreateDonationDto } from './dtos/create-donation.dto'; -import { ConfirmDonationItemDetailsDto } from '../donationItems/dtos/confirm-donation-item-details.dto'; +import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; import { FoodType } from '../donationItems/types'; import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; @@ -103,12 +103,12 @@ export class DonationsController { } @Patch('/:donationId/item-details') - async confirmDonationItemDetails( + async updateDonationItemDetails( @Param('donationId', ParseIntPipe) donationId: number, - @Body(new ParseArrayPipe({ items: ConfirmDonationItemDetailsDto })) - body: ConfirmDonationItemDetailsDto[], + @Body(new ParseArrayPipe({ items: UpdateDonationItemDetailsDto })) + body: UpdateDonationItemDetailsDto[], ): Promise { - return this.donationService.confirmDonationItemDetails(donationId, body); + return this.donationService.updateDonationItemDetails(donationId, body); } @Put('/:donationId/items') diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 619fd7d34..79a47cef8 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -8,7 +8,7 @@ import { RepeatOnDaysDto } from './dtos/create-donation.dto'; import { testDataSource } from '../config/typeormTestDataSource'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { DonationItem } from '../donationItems/donationItems.entity'; -import { ConfirmDonationItemDetailsDto } from '../donationItems/dtos/confirm-donation-item-details.dto'; +import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; import { DonationItemsService } from '../donationItems/donationItems.service'; import { Allocation } from '../allocations/allocations.entity'; import { DataSource, In } from 'typeorm'; @@ -1254,8 +1254,8 @@ describe('DonationService', () => { }); }); - describe('confirmDonationItemDetails', () => { - const makeDto = (itemId: number): ConfirmDonationItemDetailsDto => ({ + describe('updateDonationItemDetails', () => { + const makeDto = (itemId: number): UpdateDonationItemDetailsDto => ({ itemId, ozPerItem: 5.0, estimatedValue: 10.0, @@ -1264,14 +1264,14 @@ describe('DonationService', () => { it('throws NotFoundException when donation does not exist', async () => { await expect( - service.confirmDonationItemDetails(9999, [makeDto(1)]), + service.updateDonationItemDetails(9999, [makeDto(1)]), ).rejects.toThrow(new NotFoundException('Donation 9999 not found')); }); it('throws BadRequestException when donation status is not MATCHED', async () => { // seed donation 1 has status 'available' — status check fires before item lookup await expect( - service.confirmDonationItemDetails(1, [makeDto(1)]), + service.updateDonationItemDetails(1, [makeDto(1)]), ).rejects.toThrow( new BadRequestException( `Donation status must be ${DonationStatus.MATCHED}`, @@ -1285,7 +1285,7 @@ describe('DonationService', () => { const spy = jest.spyOn(service, 'checkAndFulfillDonation'); - const result = await service.confirmDonationItemDetails(donationId, [ + const result = await service.updateDonationItemDetails(donationId, [ makeDto(itemId), ]); @@ -1299,6 +1299,22 @@ describe('DonationService', () => { expect(dbDonation.status).toBe(DonationStatus.FULFILLED); expect(spy).toHaveBeenCalled(); }); + + it('does not call checkAndFulfillDonation when no items are fully confirmed', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId, 10, 5); + + const spy = jest.spyOn(service, 'checkAndFulfillDonation'); + + const result = await service.updateDonationItemDetails(donationId, [ + { itemId, ozPerItem: 5.0 }, + ]); + + expect(result).toBeDefined(); + expect(result.donationId).toBe(donationId); + expect(result.status).toBe(DonationStatus.MATCHED); + expect(spy).not.toHaveBeenCalled(); + }); }); describe('checkAndFulfillDonation', () => { diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 68a15abb7..4e5b33e8b 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -12,7 +12,7 @@ import { DayOfWeek, DonationStatus, RecurrenceEnum } from './types'; import { OrderStatus } from '../orders/types'; import { CreateDonationDto, RepeatOnDaysDto } from './dtos/create-donation.dto'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; -import { ConfirmDonationItemDetailsDto } from '../donationItems/dtos/confirm-donation-item-details.dto'; +import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; import { DonationItemsService } from '../donationItems/donationItems.service'; import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; import { DonationItem } from '../donationItems/donationItems.entity'; @@ -373,9 +373,9 @@ export class DonationService { return dates; } - async confirmDonationItemDetails( + async updateDonationItemDetails( donationId: number, - body: ConfirmDonationItemDetailsDto[], + body: UpdateDonationItemDetailsDto[], ): Promise { validateId(donationId, 'Donation'); @@ -395,11 +395,14 @@ export class DonationService { ); } - await this.donationItemsService.confirmItemDetails( - donationId, - body, - transactionManager, - ); + const confirmedDetailsForAnItem = + await this.donationItemsService.updateItemDetails( + donationId, + body, + transactionManager, + ); + + if (!confirmedDetailsForAnItem) return donation; const updated = await donationTransactionRepo.findOne({ where: { donationId }, diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 615a18f55..11ba4d880 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -95,6 +95,7 @@ export class RequestsService { status: order.status, foodManufacturerName: order.foodManufacturer.foodManufacturerName, trackingLink: order.trackingLink, + shippingCost: order.shippingCost, items: order.allocations.map((allocation) => ({ id: allocation.item.itemId, name: allocation.item.itemName, diff --git a/apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts b/apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts index 856d3e434..73a79de61 100644 --- a/apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts +++ b/apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts @@ -1,11 +1,35 @@ -import { IsArray, IsInt, Min, ValidateNested } from 'class-validator'; +import { + IsArray, + IsInt, + IsNumber, + IsOptional, + IsUrl, + Min, + ValidateNested, +} from 'class-validator'; import { Type } from 'class-transformer'; -import { TrackingCostDto } from './tracking-cost.dto'; -export class OrderTrackingCostEntryDto extends TrackingCostDto { +export class OrderTrackingCostEntryDto { @IsInt() @Min(1) orderId!: number; + + @IsOptional() + @IsUrl( + { + protocols: ['http', 'https'], + }, + { message: 'Tracking link must be a valid HTTP/HTTPS URL' }, + ) + trackingLink?: string; + + @IsOptional() + @IsNumber( + { maxDecimalPlaces: 2 }, + { message: 'Shipping cost must have at most 2 decimal places' }, + ) + @Min(0, { message: 'Shipping cost cannot be negative' }) + shippingCost?: number; } export class BulkUpdateTrackingCostDto { diff --git a/apps/backend/src/orders/dtos/order-details.dto.ts b/apps/backend/src/orders/dtos/order-details.dto.ts index f18ef3f83..fc5333677 100644 --- a/apps/backend/src/orders/dtos/order-details.dto.ts +++ b/apps/backend/src/orders/dtos/order-details.dto.ts @@ -13,5 +13,6 @@ export class OrderDetailsDto { status!: OrderStatus; foodManufacturerName!: string; trackingLink!: string | null; + shippingCost!: number | null; items!: OrderItemDetailsDto[]; } diff --git a/apps/backend/src/orders/dtos/tracking-cost.dto.ts b/apps/backend/src/orders/dtos/tracking-cost.dto.ts deleted file mode 100644 index 94548e838..000000000 --- a/apps/backend/src/orders/dtos/tracking-cost.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { IsUrl, IsNumber, Min } from 'class-validator'; - -export class TrackingCostDto { - @IsUrl( - { - protocols: ['http', 'https'], - }, - { message: 'Tracking link must be a valid HTTP/HTTPS URL' }, - ) - trackingLink!: string; - - @IsNumber( - { maxDecimalPlaces: 2 }, - { message: 'Shipping cost must have at most 2 decimal places' }, - ) - @Min(0, { message: 'Shipping cost cannot be negative' }) - shippingCost!: number; -} diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index 40b2e6ddf..fafde4b01 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -23,7 +23,6 @@ import { AllocationsService } from '../allocations/allocations.service'; import { OrderStatus } from './types'; import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; import { PantriesService } from '../pantries/pantries.service'; -import { TrackingCostDto } from './dtos/tracking-cost.dto'; import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { OrderDetailsDto } from './dtos/order-details.dto'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; @@ -211,15 +210,6 @@ export class OrdersController { return this.ordersService.bulkUpdateTrackingCostInfo(dto); } - @Patch('/:orderId/update-tracking-cost-info') - async updateTrackingCostInfo( - @Param('orderId', ParseIntPipe) orderId: number, - @Body(new ValidationPipe()) - dto: TrackingCostDto, - ): Promise { - return this.ordersService.updateTrackingCostInfo(orderId, dto); - } - @Patch('/:orderId/confirm-delivery') @ApiBody({ description: 'Details for a confirmation of order delivery form', diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index e0ead36a0..8a4316e71 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -7,7 +7,6 @@ import { OrderStatus, VolunteerAction } from './types'; import { Pantry } from '../pantries/pantries.entity'; import { OrderDetailsDto } from './dtos/order-details.dto'; import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { TrackingCostDto } from './dtos/tracking-cost.dto'; import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { FoodType } from '../donationItems/types'; import { FoodRequest } from '../foodRequests/request.entity'; @@ -209,6 +208,7 @@ describe('OrdersService', () => { orderId: 1, status: OrderStatus.DELIVERED, foodManufacturerName: 'FoodCorp Industries', + shippingCost: 8.0, trackingLink: 'https://www.samplelink.com/samplelink', items: [ { @@ -417,154 +417,6 @@ describe('OrdersService', () => { }); }); - describe('updateTrackingCostInfo', () => { - it('throws when order is non-existent', async () => { - const trackingCostDto: TrackingCostDto = { - trackingLink: 'www.test.com', - shippingCost: 5.99, - }; - - await expect( - service.updateTrackingCostInfo(9999, trackingCostDto), - ).rejects.toThrow(new NotFoundException('Order 9999 not found')); - }); - - it('updates both shipping cost and tracking link (sanitized)', async () => { - const trackingCostDto: TrackingCostDto = { - trackingLink: 'testtracking.com', - shippingCost: 7.5, - }; - - await service.updateTrackingCostInfo(4, trackingCostDto); - - const order = await service.findOne(4); - expect(order.trackingLink).toEqual('https://testtracking.com/'); - expect(order.shippingCost).toEqual(7.5); - }); - - it('throws BadRequestException for delivered order', async () => { - const trackingCostDto: TrackingCostDto = { - trackingLink: 'testtracking.com', - shippingCost: 7.5, - }; - const orderId = 2; - - const order = await service.findOne(orderId); - - expect(order.status).toEqual(OrderStatus.DELIVERED); - - await expect( - service.updateTrackingCostInfo(orderId, trackingCostDto), - ).rejects.toThrow( - new BadRequestException( - 'Can only update tracking info for pending orders', - ), - ); - }); - - it('throws when tracking link is invalid', async () => { - const trackingCostDto: TrackingCostDto = { - trackingLink: `javascript:alert("you've been hacked!")`, - shippingCost: 7.5, - }; - - await expect( - service.updateTrackingCostInfo(3, trackingCostDto), - ).rejects.toThrow( - new BadRequestException( - 'Invalid tracking link. Only valid HTTP/HTTPS URLs are accepted.', - ), - ); - }); - - it('sets status to shipped when both fields provided and previous status pending', async () => { - const trackingCostDto: TrackingCostDto = { - trackingLink: 'testtracking.com', - shippingCost: 5.75, - }; - const orderId = 4; - - const order = await service.findOne(orderId); - - expect(order.status).toEqual(OrderStatus.PENDING); - expect(order.shippedAt).toBeNull(); - - await service.updateTrackingCostInfo(orderId, trackingCostDto); - - const updatedOrder = await service.findOne(orderId); - - expect(updatedOrder.status).toEqual(OrderStatus.SHIPPED); - expect(updatedOrder.shippedAt).toBeDefined(); - }); - }); - - describe('checkAndFulfillDonations', () => { - it('does not fulfill associated donation when items are not fully reserved or confirmed', async () => { - // Create a matched donation with an item that is not fully reserved - const [{ donation_id }] = await testDataSource.query(` - INSERT INTO donations (food_manufacturer_id, status, recurrence, recurrence_freq, next_donation_dates, occurrences_remaining) - VALUES ( - (SELECT food_manufacturer_id FROM food_manufacturers LIMIT 1), - 'matched', 'none', NULL, NULL, NULL - ) - RETURNING donation_id - `); - const [{ item_id }] = await testDataSource.query( - `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, food_type, details_confirmed) - VALUES ($1, 'Test Item', 10, 5, 'Granola', false) - RETURNING item_id`, - [donation_id], - ); - await testDataSource.query( - `INSERT INTO allocations (order_id, item_id, allocated_quantity) VALUES (4, $1, 1)`, - [item_id], - ); - - await service.updateTrackingCostInfo(4, { - trackingLink: 'testtracking.com', - shippingCost: 5.0, - }); - - const donation = await testDataSource - .getRepository(Donation) - .findOneBy({ donationId: donation_id }); - expect(donation?.status).toBe(DonationStatus.MATCHED); - }); - - it('fulfills associated donation when all items are confirmed, fully reserved, and no pending orders remain', async () => { - // Create a matched donation with a fully-reserved confirmed item allocated to order 4 - const [{ donation_id }] = await testDataSource.query(` - INSERT INTO donations (food_manufacturer_id, status, recurrence, recurrence_freq, next_donation_dates, occurrences_remaining) - VALUES ( - (SELECT food_manufacturer_id FROM food_manufacturers LIMIT 1), - 'matched', 'none', NULL, NULL, NULL - ) - RETURNING donation_id - `); - const [{ item_id }] = await testDataSource.query( - `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, food_type, details_confirmed) - VALUES ($1, 'Test Item', 10, 10, 'Granola', true) - RETURNING item_id`, - [donation_id], - ); - // Allocate to order 4 (pending); after updateTrackingCostInfo it becomes shipped → no more pending orders - await testDataSource.query( - `INSERT INTO allocations (order_id, item_id, allocated_quantity) VALUES (4, $1, 1)`, - [item_id], - ); - - await service.updateTrackingCostInfo(4, { - trackingLink: 'testtracking.com', - shippingCost: 5.0, - }); - - const donation = await testDataSource - .getRepository(Donation) - .findOneBy({ donationId: donation_id }); - expect(donation?.status).toBe(DonationStatus.FULFILLED); - }); - }); - describe('confirmDelivery', () => { it('should throw BadRequestException for invalid date format', async () => { await expect( @@ -1157,6 +1009,23 @@ describe('OrdersService', () => { return order_id; } + it('throws BadRequestException when neither tracking link nor shipping cost is provided', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + await expect( + service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4 }], + }), + ).rejects.toThrow( + new BadRequestException( + 'Order 4 must include at least a tracking link or shipping cost.', + ), + ); + }); + it('throws BadRequestException when tracking link fails sanitization', async () => { const donationId = await insertMatchedDonation(); const itemId = await insertDonationItem(donationId); @@ -1169,7 +1038,6 @@ describe('OrdersService', () => { { orderId: 4, trackingLink: `javascript:alert("you've been hacked!")`, - shippingCost: 5.0, }, ], }), @@ -1192,15 +1060,10 @@ describe('OrdersService', () => { service.bulkUpdateTrackingCostInfo({ donationId, orders: [ - { - orderId: 4, - trackingLink: 'https://valid.com', - shippingCost: 5.0, - }, + { orderId: 4, trackingLink: 'https://valid.com' }, { orderId: orderId2, trackingLink: `javascript:alert('xss')`, - shippingCost: 5.0, }, ], }), @@ -1214,13 +1077,7 @@ describe('OrdersService', () => { it('throws NotFoundException when donation does not exist', async () => { const dto: BulkUpdateTrackingCostDto = { donationId: 9999, - orders: [ - { - orderId: 4, - trackingLink: 'https://tracking.com', - shippingCost: 5.0, - }, - ], + orders: [{ orderId: 4, shippingCost: 5.0 }], }; await expect(service.bulkUpdateTrackingCostInfo(dto)).rejects.toThrow( @@ -1236,16 +1093,8 @@ describe('OrdersService', () => { const dto: BulkUpdateTrackingCostDto = { donationId, orders: [ - { - orderId: 4, - trackingLink: 'https://tracking.com', - shippingCost: 5.0, - }, - { - orderId: 9999, - trackingLink: 'https://tracking2.com', - shippingCost: 6.0, - }, + { orderId: 4, shippingCost: 5.0 }, + { orderId: 9999, trackingLink: 'https://tracking2.com' }, ], }; @@ -1264,16 +1113,8 @@ describe('OrdersService', () => { const dto: BulkUpdateTrackingCostDto = { donationId, orders: [ - { - orderId: 4, - trackingLink: 'https://tracking.com', - shippingCost: 5.0, - }, - { - orderId: 2, - trackingLink: 'https://tracking2.com', - shippingCost: 6.0, - }, + { orderId: 4, shippingCost: 5.0 }, + { orderId: 2, trackingLink: 'https://tracking2.com' }, ], }; @@ -1294,16 +1135,8 @@ describe('OrdersService', () => { const dto: BulkUpdateTrackingCostDto = { donationId, orders: [ - { - orderId: 4, - trackingLink: 'https://tracking.com', - shippingCost: 5.0, - }, - { - orderId: orderId2, - trackingLink: 'https://tracking2.com', - shippingCost: 6.0, - }, + { orderId: 4, shippingCost: 5.0 }, + { orderId: orderId2, trackingLink: 'https://tracking2.com' }, ], }; @@ -1314,7 +1147,7 @@ describe('OrdersService', () => { ); }); - it('updates tracking link (sanitized), shipping cost, status, and shippedAt for all orders', async () => { + it('updates both fields when tracking link and shipping cost are provided', async () => { const donationId = await insertMatchedDonation(); const itemId1 = await insertDonationItem(donationId); const itemId2 = await insertDonationItem(donationId); @@ -1322,13 +1155,6 @@ describe('OrdersService', () => { await insertAllocation(4, itemId1); await insertAllocation(orderId2, itemId2); - const before1 = await service.findOne(4); - const before2 = await service.findOne(orderId2); - expect(before1.status).toEqual(OrderStatus.PENDING); - expect(before1.shippedAt).toBeNull(); - expect(before2.status).toEqual(OrderStatus.PENDING); - expect(before2.shippedAt).toBeNull(); - await service.bulkUpdateTrackingCostInfo({ donationId, orders: [ @@ -1353,6 +1179,63 @@ describe('OrdersService', () => { expect(after2.shippedAt).toBeDefined(); }); + it('updates only tracking link when no shipping cost is provided, order stays PENDING', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4, trackingLink: 'tracking.com' }], + }); + + const after = await service.findOne(4); + expect(after.trackingLink).toEqual('https://tracking.com/'); + expect(after.shippingCost).toBeNull(); + expect(after.status).toEqual(OrderStatus.PENDING); + expect(after.shippedAt).toBeNull(); + }); + + it('updates only shipping cost when no tracking link is provided, order stays PENDING', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4, shippingCost: 12.5 }], + }); + + const after = await service.findOne(4); + expect(after.trackingLink).toBeNull(); + expect(after.shippingCost).toEqual(12.5); + expect(after.status).toEqual(OrderStatus.PENDING); + expect(after.shippedAt).toBeNull(); + }); + + it('sets order to SHIPPED when a second partial call completes both fields', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4, trackingLink: 'tracking.com' }], + }); + expect((await service.findOne(4)).status).toEqual(OrderStatus.PENDING); + + await service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [{ orderId: 4, shippingCost: 10.0 }], + }); + + const after = await service.findOne(4); + expect(after.trackingLink).toEqual('https://tracking.com/'); + expect(after.shippingCost).toEqual(10.0); + expect(after.status).toEqual(OrderStatus.SHIPPED); + expect(after.shippedAt).toBeDefined(); + }); + it('calls donationService.checkAndFulfillDonation after updating orders', async () => { const donationId = await insertMatchedDonation(); const itemId = await insertDonationItem(donationId); @@ -1362,9 +1245,7 @@ describe('OrdersService', () => { await service.bulkUpdateTrackingCostInfo({ donationId, - orders: [ - { orderId: 4, trackingLink: 'tracking.com', shippingCost: 5.0 }, - ], + orders: [{ orderId: 4, shippingCost: 5.0 }], }); expect(spy).toHaveBeenCalled(); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index b0d6ff7ad..4550646ec 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -11,7 +11,6 @@ import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { sanitizeUrl, validateId } from '../utils/validation.utils'; import { DonationService } from '../donations/donations.service'; import { OrderStatus, VolunteerAction } from './types'; -import { TrackingCostDto } from './dtos/tracking-cost.dto'; import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { OrderDetailsDto } from './dtos/order-details.dto'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; @@ -277,6 +276,7 @@ export class OrdersService { status: order.status, foodManufacturerName: order.foodManufacturer.foodManufacturerName, trackingLink: order.trackingLink, + shippingCost: order.shippingCost, items: order.allocations.map((allocation) => ({ id: allocation.item.itemId, name: allocation.item.itemName, @@ -442,68 +442,29 @@ export class OrdersService { return qb.getMany(); } - async updateTrackingCostInfo(orderId: number, dto: TrackingCostDto) { - validateId(orderId, 'Order'); - - const sanitized = sanitizeUrl(dto.trackingLink); - if (!sanitized) { - throw new BadRequestException( - 'Invalid tracking link. Only valid HTTP/HTTPS URLs are accepted.', - ); - } - dto.trackingLink = sanitized; - - const order = await this.repo.findOneBy({ orderId }); - if (!order) { - throw new NotFoundException(`Order ${orderId} not found`); - } - - if (order.status !== OrderStatus.PENDING) { - throw new BadRequestException( - 'Can only update tracking info for pending orders', - ); - } - - order.trackingLink = dto.trackingLink; - order.shippingCost = dto.shippingCost; - - order.status = OrderStatus.SHIPPED; - order.shippedAt = new Date(); - - await this.repo.save(order); - - await this.checkAndFulfillDonations(orderId); - } - - async checkAndFulfillDonations(orderId: number): Promise { - const affectedDonations = await this.donationItemRepo - .createQueryBuilder('item') - .innerJoin('item.allocations', 'allocation') - .where('allocation.orderId = :orderId', { orderId }) - .select('DISTINCT item.donationId', 'donationId') - .getRawMany<{ donationId: number }>(); - - for (const { donationId } of affectedDonations) { - const donation = await this.donationRepo.findOneBy({ donationId }); - if (donation) { - await this.donationService.checkAndFulfillDonation(donation); - } - } - } - async bulkUpdateTrackingCostInfo( dto: BulkUpdateTrackingCostDto, ): Promise { // Sanitize all URLs before entering transaction for (const entry of dto.orders) { validateId(entry.orderId, 'Order'); - const sanitized = sanitizeUrl(entry.trackingLink); - if (!sanitized) { + if ( + entry.trackingLink === undefined && + entry.shippingCost === undefined + ) { throw new BadRequestException( - `Invalid tracking link for order ${entry.orderId}. Only valid HTTP/HTTPS URLs are accepted.`, + `Order ${entry.orderId} must include at least a tracking link or shipping cost.`, ); } - entry.trackingLink = sanitized; + if (entry.trackingLink !== undefined) { + const sanitized = sanitizeUrl(entry.trackingLink); + if (!sanitized) { + throw new BadRequestException( + `Invalid tracking link for order ${entry.orderId}. Only valid HTTP/HTTPS URLs are accepted.`, + ); + } + entry.trackingLink = sanitized; + } } await this.dataSource.transaction(async (transactionManager) => { @@ -549,10 +510,16 @@ export class OrdersService { ); } - order.trackingLink = entry.trackingLink; - order.shippingCost = entry.shippingCost; - order.status = OrderStatus.SHIPPED; - order.shippedAt = new Date(); + if (entry.trackingLink !== undefined) { + order.trackingLink = entry.trackingLink; + } + if (entry.shippingCost !== undefined) { + order.shippingCost = entry.shippingCost; + } + if (order.trackingLink != null && order.shippingCost != null) { + order.status = OrderStatus.SHIPPED; + order.shippedAt = new Date(); + } ordersToUpdate.push(order); } diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index e54031fdc..d69207bfb 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -40,9 +40,8 @@ import { VolunteerOrder, VolunteerAction, FoodRequestWithoutRelations, - TrackingCostDto, BulkUpdateTrackingCostDto, - ConfirmDonationItemDetailsDto, + UpdateDonationItemDetailsDto, } from 'types/types'; const defaultBaseUrl = @@ -456,16 +455,6 @@ export class ApiClient { .then((response) => response.data); } - public async updateTrackingCostInfo( - orderId: number, - data: TrackingCostDto, - ): Promise { - await this.axiosInstance.patch( - `/api/orders/${orderId}/update-tracking-cost-info`, - data, - ); - } - public async bulkUpdateTrackingCostInfo( data: BulkUpdateTrackingCostDto, ): Promise { @@ -475,9 +464,9 @@ export class ApiClient { ); } - public async confirmDonationItemDetails( + public async updateDonationItemDetails( donationId: number, - items: ConfirmDonationItemDetailsDto[], + items: UpdateDonationItemDetailsDto[], ): Promise { await this.axiosInstance.patch( `/api/donations/${donationId}/item-details`, diff --git a/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx index 902ffe793..71c5a0c67 100644 --- a/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx +++ b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx @@ -18,8 +18,9 @@ import axios from 'axios'; import ApiClient from '@api/apiClient'; import { DonationDetails, + DonationItem, OrderDetails, - ConfirmDonationItemDetailsDto, + UpdateDonationItemDetailsDto, } from '../../types/types'; import { useGroupedItemsByFoodType } from '../../hooks/groupedItemsByFoodType'; import { FloatingAlert } from '@components/floatingAlert'; @@ -108,15 +109,11 @@ const FmCompleteRequiredActionsModal: React.FC< FmCompleteRequiredActionsModalProps > = ({ donation, isOpen, onClose, onSuccess }) => { const orders = donation.associatedPendingOrders; - const items = donation.relevantDonationItems.filter( - (item) => !item.detailsConfirmed, - ); - const hasItemsToConfirm = items.length > 0; // Track which action user is on const [stage, setStage] = useState('shipping'); const [currentPage, setCurrentPage] = useState(1); - // Form data for each id to persis between pagination + // Form data for each id to persist between pagination const [orderFormData, setOrderFormData] = useState< Record >(() => @@ -124,16 +121,10 @@ const FmCompleteRequiredActionsModal: React.FC< orders.map((o) => [o.orderId, { shippingCost: '', trackingLink: '' }]), ), ); + const [donationItems, setDonationItems] = useState([]); const [itemFormData, setItemFormData] = useState< Record - >(() => - Object.fromEntries( - items.map((item) => [ - item.itemId, - { ozPerItem: '', estimatedValue: '', foodRescue: false }, - ]), - ), - ); + >({}); const [orderDetailsMap, setOrderDetailsMap] = useState< Record >({}); @@ -144,22 +135,50 @@ const FmCompleteRequiredActionsModal: React.FC< const currentOrder = orders[currentPage - 1]; useEffect(() => { - const fetchAllOrderDetails = async () => { + const fetchData = async () => { try { - const fetchedDetails = await Promise.all( - orders.map((order) => ApiClient.getOrder(order.orderId)), + const [fetchedItems, ...fetchedOrderDetails] = await Promise.all([ + ApiClient.getDonationItemsByDonationId(donation.donation.donationId), + ...orders.map((order) => ApiClient.getOrder(order.orderId)), + ]); + + setDonationItems(fetchedItems as DonationItem[]); + setItemFormData( + Object.fromEntries( + (fetchedItems as DonationItem[]).map((item) => [ + item.itemId, + { + ozPerItem: item.ozPerItem?.toString() ?? '', + estimatedValue: item.estimatedValue?.toString() ?? '', + foodRescue: item.foodRescue, + }, + ]), + ), ); + const detailsMap: Record = {}; orders.forEach((order, i) => { - detailsMap[order.orderId] = fetchedDetails[i]; + detailsMap[order.orderId] = fetchedOrderDetails[i] as OrderDetails; }); setOrderDetailsMap(detailsMap); + + setOrderFormData((prev) => { + const updated = { ...prev }; + orders.forEach((order) => { + const details = detailsMap[order.orderId]; + updated[order.orderId] = { + trackingLink: details?.trackingLink ?? '', + shippingCost: details?.shippingCost?.toString() ?? '', + }; + }); + return updated; + }); } catch { - setAlertMessage('Error fetching order details. Please try again.'); + setAlertMessage('Error fetching donation details. Please try again.'); } }; - fetchAllOrderDetails(); + fetchData(); }, []); const updateOrderField = ( @@ -184,52 +203,63 @@ const FmCompleteRequiredActionsModal: React.FC< })); }; - const areOrderFieldsFilled = orders.every( - (order) => - orderFormData[order.orderId].trackingLink.trim() !== '' && - orderFormData[order.orderId].shippingCost !== '', - ); - - const areItemFieldsFilled = items.every( - (item) => - itemFormData[item.itemId].ozPerItem !== '' && - itemFormData[item.itemId].estimatedValue !== '', - ); - const handleSubmit = async () => { setIsSubmitting(true); try { - if (hasItemsToConfirm) { - const confirmItems: ConfirmDonationItemDetailsDto[] = items.map( - (item) => ({ + const confirmItems: UpdateDonationItemDetailsDto[] = donationItems.map( + (item) => { + const formData = itemFormData[item.itemId]; + const dto: UpdateDonationItemDetailsDto = { itemId: item.itemId, - ozPerItem: parseFloat(itemFormData[item.itemId].ozPerItem), - estimatedValue: parseFloat( - itemFormData[item.itemId].estimatedValue, - ), - foodRescue: itemFormData[item.itemId].foodRescue, - }), - ); - await ApiClient.confirmDonationItemDetails( - donation.donation.donationId, - confirmItems, + foodRescue: formData.foodRescue, + }; + if (formData.ozPerItem !== '') + dto.ozPerItem = parseFloat(formData.ozPerItem); + if (formData.estimatedValue !== '') + dto.estimatedValue = parseFloat(formData.estimatedValue); + return dto; + }, + ); + await ApiClient.updateDonationItemDetails( + donation.donation.donationId, + confirmItems, + ); + + const ordersToUpdate = orders + .filter((order) => { + const { trackingLink, shippingCost } = orderFormData[order.orderId]; + return trackingLink.trim() !== '' || shippingCost.trim() !== ''; + }) + .map( + ( + order, + ): { + orderId: number; + trackingLink?: string; + shippingCost?: number; + } => { + const { trackingLink, shippingCost } = orderFormData[order.orderId]; + return { + orderId: order.orderId, + ...(trackingLink.trim() !== '' && { trackingLink }), + ...(shippingCost !== '' && { + shippingCost: parseFloat(shippingCost), + }), + }; + }, ); - } - await ApiClient.bulkUpdateTrackingCostInfo({ - donationId: donation.donation.donationId, - orders: orders.map((order) => ({ - orderId: order.orderId, - trackingLink: orderFormData[order.orderId].trackingLink, - shippingCost: parseFloat(orderFormData[order.orderId].shippingCost), - })), - }); + if (ordersToUpdate.length > 0) { + await ApiClient.bulkUpdateTrackingCostInfo({ + donationId: donation.donation.donationId, + orders: ordersToUpdate, + }); + } onSuccess(); } catch (error) { const rawMsg = axios.isAxiosError(error) && error.response?.data?.message; const msg = Array.isArray(rawMsg) ? rawMsg[0] : rawMsg; - // Strip out nested validation for the orders for cleaner message setAlertMessage( msg ? msg.replace(/^orders\.\d+\./, '') @@ -295,9 +325,6 @@ const FmCompleteRequiredActionsModal: React.FC< Shipping Cost - - * - Delivery Tracking Link - - * - Cancel - {hasItemsToConfirm ? ( - - ) : ( - - )} + )} @@ -479,15 +486,9 @@ const FmCompleteRequiredActionsModal: React.FC< Oz. per item - - * - Donation Value - - * - Food Rescue - - * - - {items.map((item) => ( + {donationItems.map((item) => ( updateItemField( item.itemId, @@ -547,7 +545,9 @@ const FmCompleteRequiredActionsModal: React.FC< min={0.01} step={0.01} placeholder="0.00" - value={itemFormData[item.itemId].estimatedValue} + value={ + itemFormData[item.itemId]?.estimatedValue ?? '' + } onChange={(e) => updateItemField( item.itemId, @@ -564,7 +564,9 @@ const FmCompleteRequiredActionsModal: React.FC< textAlign="center" > @@ -605,12 +607,12 @@ const FmCompleteRequiredActionsModal: React.FC< color="neutral.50" fontWeight={600} size="md" - disabled={!areItemFieldsFilled || isSubmitting} + disabled={isSubmitting} _disabled={{ opacity: 0.4, cursor: 'not-allowed' }} loading={isSubmitting} onClick={handleSubmit} > - Complete Actions + Submit diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index a0708b017..33a6da0ce 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -324,6 +324,7 @@ export interface OrderDetails { status: OrderStatus; foodManufacturerName: string; trackingLink: string | null; + shippingCost: number | null; items: OrderItemDetails[]; } @@ -554,19 +555,18 @@ export type DonationItemsGroupedByFoodType = Partial< Record >; -export interface TrackingCostDto { - trackingLink: string; - shippingCost: number; -} - export interface BulkUpdateTrackingCostDto { donationId: number; - orders: ({ orderId: number } & TrackingCostDto)[]; + orders: { + orderId: number; + trackingLink?: string; + shippingCost?: number; + }[]; } -export interface ConfirmDonationItemDetailsDto { +export interface UpdateDonationItemDetailsDto { itemId: number; - ozPerItem: number; - estimatedValue: number; - foodRescue: boolean; + ozPerItem?: number; + estimatedValue?: number; + foodRescue?: boolean; } From b06337a74ad249ebdc7960523a20e85d236aea68 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Mon, 27 Apr 2026 01:30:08 -0400 Subject: [PATCH 04/12] fixed tests --- .../src/donations/donations.service.spec.ts | 1 + .../src/foodRequests/request.controller.spec.ts | 2 ++ .../backend/src/orders/order.controller.spec.ts | 17 ----------------- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 79a47cef8..1111bd6c3 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -183,6 +183,7 @@ describe('DonationService', () => { }); afterEach(async () => { + jest.restoreAllMocks(); await testDataSource.query(`DROP SCHEMA public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); }); diff --git a/apps/backend/src/foodRequests/request.controller.spec.ts b/apps/backend/src/foodRequests/request.controller.spec.ts index 135609d50..27dc4281b 100644 --- a/apps/backend/src/foodRequests/request.controller.spec.ts +++ b/apps/backend/src/foodRequests/request.controller.spec.ts @@ -124,6 +124,7 @@ describe('RequestsController', () => { status: OrderStatus.DELIVERED, foodManufacturerName: 'Test Manufacturer', trackingLink: 'examplelink.com', + shippingCost: 8.0, items: [ { id: 1, @@ -144,6 +145,7 @@ describe('RequestsController', () => { status: OrderStatus.PENDING, foodManufacturerName: 'Another Manufacturer', trackingLink: 'examplelink.com', + shippingCost: 8.0, items: [ { id: 1, diff --git a/apps/backend/src/orders/order.controller.spec.ts b/apps/backend/src/orders/order.controller.spec.ts index c71ccb557..18179ff46 100644 --- a/apps/backend/src/orders/order.controller.spec.ts +++ b/apps/backend/src/orders/order.controller.spec.ts @@ -9,7 +9,6 @@ import { OrderStatus, VolunteerAction } from './types'; import { FoodRequest } from '../foodRequests/request.entity'; import { Pantry } from '../pantries/pantries.entity'; import { AWSS3Service } from '../aws/aws-s3.service'; -import { TrackingCostDto } from './dtos/tracking-cost.dto'; import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { OrderDetailsDto } from './dtos/order-details.dto'; import { FoodType } from '../donationItems/types'; @@ -360,22 +359,6 @@ describe('OrdersController', () => { }); }); - describe('updateTrackingCostInfo', () => { - it('should call ordersService.updateTrackingCostInfo with correct parameters', async () => { - const orderId = 1; - const trackingLink = 'www.samplelink/samplelink'; - const shippingCost = 15.99; - const dto: TrackingCostDto = { trackingLink, shippingCost }; - - await controller.updateTrackingCostInfo(orderId, dto); - - expect(mockOrdersService.updateTrackingCostInfo).toHaveBeenCalledWith( - orderId, - dto, - ); - }); - }); - describe('bulkUpdateTrackingCostInfo', () => { it('should call ordersService.bulkUpdateTrackingCostInfo with correct parameters', async () => { const dto: BulkUpdateTrackingCostDto = { From 49cb87353d99678eb81a0775fd9936a2ad7085e8 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Mon, 27 Apr 2026 14:34:20 -0400 Subject: [PATCH 05/12] final revisions --- .../dtos/donation-details-dto.ts | 13 ++ .../manufacturers.controller.spec.ts | 4 + .../manufacturers.service.spec.ts | 16 -- .../manufacturers.service.ts | 32 ++-- .../forms/fmCompleteRequiredActionsModal.tsx | 171 +++++++++--------- apps/frontend/src/types/types.ts | 6 + 6 files changed, 129 insertions(+), 113 deletions(-) diff --git a/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts b/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts index c1f220e87..e38d29346 100644 --- a/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts +++ b/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts @@ -6,12 +6,25 @@ export class DonationItemWithAllocatedQuantityDto { itemName!: string; foodType!: FoodType; allocatedQuantity!: number; + ozPerItem?: number; + estimatedValue?: number; + foodRescue!: boolean; +} + +export class PendingOrderItemDto { + id!: number; + name!: string; + quantity!: number; + foodType!: FoodType; } export class DonationOrderDetailsDto { orderId!: number; pantryId!: number; pantryName!: string; + trackingLink!: string | null; + shippingCost!: number | null; + items!: PendingOrderItemDto[]; } export class DonationDetailsDto { diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts index bc86024a5..3932cdda6 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts @@ -108,6 +108,9 @@ describe('FoodManufacturersController', () => { orderId: 1, pantryId: 2, pantryName: 'Community Food Pantry', + trackingLink: null, + shippingCost: null, + items: [], }, ], relevantDonationItems: [ @@ -116,6 +119,7 @@ describe('FoodManufacturersController', () => { itemName: 'Almond Breeze Almond Milk', foodType: FoodType.DAIRY_FREE_ALTERNATIVES, allocatedQuantity: 10, + foodRescue: false, }, ], }, diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 4cd5c221a..08e8fef97 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -464,22 +464,6 @@ describe('FoodManufacturersService', () => { expect(item.foodType).toBe(FoodType.GLUTEN_FREE_BREAD); }); - it('excludes donation items where detailsConfirmed is true', async () => { - await testDataSource.query( - `UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`, - [fulfilledDonationId], - ); - - await testDataSource.query( - `UPDATE public.donation_items SET details_confirmed = true - WHERE item_name = 'Cereal Boxes'`, - ); - - const result = await service.getFMDonations(fmId1, fmRepId1); - - expect(result[0].relevantDonationItems).toEqual([]); - }); - it('excludes donation items not used in any pending order', async () => { await testDataSource.query( `UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`, diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index cd04726a0..d0661b7be 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -121,17 +121,18 @@ export class FoodManufacturersService { if (pendingAllocations.length === 0) return; - if (!item.detailsConfirmed) { - relevantDonationItems.push({ - itemId: item.itemId, - itemName: item.itemName, - foodType: item.foodType, - allocatedQuantity: pendingAllocations.reduce( - (sum, a) => sum + a.allocatedQuantity, - 0, - ), - }); - } + relevantDonationItems.push({ + itemId: item.itemId, + itemName: item.itemName, + foodType: item.foodType, + allocatedQuantity: pendingAllocations.reduce( + (sum, a) => sum + a.allocatedQuantity, + 0, + ), + ozPerItem: item.ozPerItem ?? undefined, + estimatedValue: item.estimatedValue ?? undefined, + foodRescue: item.foodRescue, + }); pendingAllocations.forEach((a) => { const order = a.order; @@ -140,8 +141,17 @@ export class FoodManufacturersService { orderId: order.orderId, pantryId: order.request.pantry.pantryId, pantryName: order.request.pantry.pantryName, + trackingLink: order.trackingLink, + shippingCost: order.shippingCost, + items: [], }); } + orderMap.get(order.orderId)!.items.push({ + id: item.itemId, + name: item.itemName, + quantity: a.allocatedQuantity, + foodType: item.foodType, + }); }); }); } diff --git a/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx index 71c5a0c67..70fefac7e 100644 --- a/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx +++ b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useMemo } from 'react'; import { Box, Button, @@ -18,8 +18,7 @@ import axios from 'axios'; import ApiClient from '@api/apiClient'; import { DonationDetails, - DonationItem, - OrderDetails, + OrderItemDetails, UpdateDonationItemDetailsDto, } from '../../types/types'; import { useGroupedItemsByFoodType } from '../../hooks/groupedItemsByFoodType'; @@ -48,11 +47,11 @@ interface ItemFormData { // Order items section const OrderItemsSection: React.FC<{ - orderDetails: OrderDetails | undefined; -}> = ({ orderDetails }) => { - const groupedItems = useGroupedItemsByFoodType(orderDetails?.items); + items: OrderItemDetails[] | undefined; +}> = ({ items }) => { + const groupedItems = useGroupedItemsByFoodType(items); - if (!orderDetails) { + if (!items) { return ( Loading order details... @@ -110,76 +109,58 @@ const FmCompleteRequiredActionsModal: React.FC< > = ({ donation, isOpen, onClose, onSuccess }) => { const orders = donation.associatedPendingOrders; - // Track which action user is on + // Which stage of the two-step modal the user is currently on const [stage, setStage] = useState('shipping'); const [currentPage, setCurrentPage] = useState(1); - // Form data for each id to persist between pagination + + // Shipping cost and tracking link inputs keyed by orderId, pre-filled from prop and persisted across pagination const [orderFormData, setOrderFormData] = useState< Record >(() => Object.fromEntries( - orders.map((o) => [o.orderId, { shippingCost: '', trackingLink: '' }]), + orders.map((o) => [ + o.orderId, + { + shippingCost: o.shippingCost?.toString() ?? '', + trackingLink: o.trackingLink ?? '', + }, + ]), ), ); - const [donationItems, setDonationItems] = useState([]); + + // ozPerItem, estimatedValue, and foodRescue inputs keyed by itemId, pre-filled from prop const [itemFormData, setItemFormData] = useState< Record - >({}); - const [orderDetailsMap, setOrderDetailsMap] = useState< - Record - >({}); + >(() => + Object.fromEntries( + donation.relevantDonationItems.map((item) => [ + item.itemId, + { + ozPerItem: item.ozPerItem?.toString() ?? '', + estimatedValue: item.estimatedValue?.toString() ?? '', + foodRescue: item.foodRescue, + }, + ]), + ), + ); const [isSubmitting, setIsSubmitting] = useState(false); const [alertState, setAlertMessage] = useAlert(); - const currentOrder = orders[currentPage - 1]; - - useEffect(() => { - const fetchData = async () => { - try { - const [fetchedItems, ...fetchedOrderDetails] = await Promise.all([ - ApiClient.getDonationItemsByDonationId(donation.donation.donationId), - ...orders.map((order) => ApiClient.getOrder(order.orderId)), - ]); - - setDonationItems(fetchedItems as DonationItem[]); - setItemFormData( - Object.fromEntries( - (fetchedItems as DonationItem[]).map((item) => [ - item.itemId, - { - ozPerItem: item.ozPerItem?.toString() ?? '', - estimatedValue: item.estimatedValue?.toString() ?? '', - foodRescue: item.foodRescue, - }, - ]), - ), - ); - - const detailsMap: Record = {}; - orders.forEach((order, i) => { - detailsMap[order.orderId] = fetchedOrderDetails[i] as OrderDetails; - }); - setOrderDetailsMap(detailsMap); - - setOrderFormData((prev) => { - const updated = { ...prev }; - orders.forEach((order) => { - const details = detailsMap[order.orderId]; - updated[order.orderId] = { - trackingLink: details?.trackingLink ?? '', - shippingCost: details?.shippingCost?.toString() ?? '', - }; - }); - return updated; - }); - } catch { - setAlertMessage('Error fetching donation details. Please try again.'); - } - }; + // True once every relevant item has both ozPerItem and estimatedValue filled in + const isSubmitEnabled = useMemo( + () => + donation.relevantDonationItems.length > 0 && + donation.relevantDonationItems.every( + (item) => + (itemFormData[item.itemId]?.ozPerItem ?? '') !== '' && + (itemFormData[item.itemId]?.estimatedValue ?? '') !== '', + ), + [itemFormData], + ); - fetchData(); - }, []); + // The order currently shown in the shipping stage based on the current page + const currentOrder = orders[currentPage - 1]; const updateOrderField = ( orderId: number, @@ -206,29 +187,48 @@ const FmCompleteRequiredActionsModal: React.FC< const handleSubmit = async () => { setIsSubmitting(true); try { - const confirmItems: UpdateDonationItemDetailsDto[] = donationItems.map( - (item) => { - const formData = itemFormData[item.itemId]; - const dto: UpdateDonationItemDetailsDto = { - itemId: item.itemId, - foodRescue: formData.foodRescue, - }; - if (formData.ozPerItem !== '') - dto.ozPerItem = parseFloat(formData.ozPerItem); - if (formData.estimatedValue !== '') - dto.estimatedValue = parseFloat(formData.estimatedValue); - return dto; - }, - ); - await ApiClient.updateDonationItemDetails( - donation.donation.donationId, - confirmItems, - ); + // Only include items where the user actually changed a value from the original prop values + const confirmItems: UpdateDonationItemDetailsDto[] = + donation.relevantDonationItems + .filter((item) => { + const formData = itemFormData[item.itemId]; + return ( + formData.ozPerItem !== (item.ozPerItem?.toString() ?? '') || + formData.estimatedValue !== + (item.estimatedValue?.toString() ?? '') || + formData.foodRescue !== item.foodRescue + ); + }) + .map((item) => { + const formData = itemFormData[item.itemId]; + const dto: UpdateDonationItemDetailsDto = { + itemId: item.itemId, + foodRescue: formData.foodRescue, + }; + if (formData.ozPerItem !== '') + dto.ozPerItem = parseFloat(formData.ozPerItem); + if (formData.estimatedValue !== '') + dto.estimatedValue = parseFloat(formData.estimatedValue); + return dto; + }); + // Donation items must be updated before tracking/shipping so detailsConfirmed is set first + if (confirmItems.length > 0) { + await ApiClient.updateDonationItemDetails( + donation.donation.donationId, + confirmItems, + ); + } + + // Only include orders where the user actually changed a value from the original prop values const ordersToUpdate = orders .filter((order) => { const { trackingLink, shippingCost } = orderFormData[order.orderId]; - return trackingLink.trim() !== '' || shippingCost.trim() !== ''; + const originalTracking = order.trackingLink ?? ''; + const originalCost = order.shippingCost?.toString() ?? ''; + return ( + trackingLink !== originalTracking || shippingCost !== originalCost + ); }) .map( ( @@ -272,6 +272,7 @@ const FmCompleteRequiredActionsModal: React.FC< if (!currentOrder) return null; + // Shared style props applied to every column header in the item details table const tableHeaderStyles = { borderBottom: '1px solid', borderColor: 'neutral.100', @@ -374,9 +375,7 @@ const FmCompleteRequiredActionsModal: React.FC< Requested by {currentOrder.pantryName} - + {orders.length > 1 && ( @@ -500,7 +499,7 @@ const FmCompleteRequiredActionsModal: React.FC< - {donationItems.map((item) => ( + {donation.relevantDonationItems.map((item) => ( Date: Mon, 27 Apr 2026 14:52:48 -0400 Subject: [PATCH 06/12] final commit --- .../manufacturers.service.ts | 1 + .../dtos/bulk-update-tracking-cost.dto.ts | 1 + apps/backend/src/orders/order.service.spec.ts | 68 ++++--------------- apps/backend/src/orders/order.service.ts | 24 +++---- .../src/utils/validation.utils.spec.ts | 42 +----------- apps/backend/src/utils/validation.utils.ts | 21 ------ 6 files changed, 23 insertions(+), 134 deletions(-) diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index d0661b7be..de49daf91 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -146,6 +146,7 @@ export class FoodManufacturersService { items: [], }); } + // Populate the items afterwards orderMap.get(order.orderId)!.items.push({ id: item.itemId, name: item.itemName, diff --git a/apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts b/apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts index 73a79de61..8f9cea09f 100644 --- a/apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts +++ b/apps/backend/src/orders/dtos/bulk-update-tracking-cost.dto.ts @@ -18,6 +18,7 @@ export class OrderTrackingCostEntryDto { @IsUrl( { protocols: ['http', 'https'], + require_protocol: true, }, { message: 'Tracking link must be a valid HTTP/HTTPS URL' }, ) diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 8a4316e71..83527fda5 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -1026,54 +1026,6 @@ describe('OrdersService', () => { ); }); - it('throws BadRequestException when tracking link fails sanitization', async () => { - const donationId = await insertMatchedDonation(); - const itemId = await insertDonationItem(donationId); - await insertAllocation(4, itemId); - - await expect( - service.bulkUpdateTrackingCostInfo({ - donationId, - orders: [ - { - orderId: 4, - trackingLink: `javascript:alert("you've been hacked!")`, - }, - ], - }), - ).rejects.toThrow( - new BadRequestException( - 'Invalid tracking link for order 4. Only valid HTTP/HTTPS URLs are accepted.', - ), - ); - }); - - it('throws BadRequestException when one order has an invalid tracking URL', async () => { - const donationId = await insertMatchedDonation(); - const itemId1 = await insertDonationItem(donationId); - const itemId2 = await insertDonationItem(donationId); - const orderId2 = await createPendingOrder(); - await insertAllocation(4, itemId1); - await insertAllocation(orderId2, itemId2); - - await expect( - service.bulkUpdateTrackingCostInfo({ - donationId, - orders: [ - { orderId: 4, trackingLink: 'https://valid.com' }, - { - orderId: orderId2, - trackingLink: `javascript:alert('xss')`, - }, - ], - }), - ).rejects.toThrow( - new BadRequestException( - `Invalid tracking link for order ${orderId2}. Only valid HTTP/HTTPS URLs are accepted.`, - ), - ); - }); - it('throws NotFoundException when donation does not exist', async () => { const dto: BulkUpdateTrackingCostDto = { donationId: 9999, @@ -1158,10 +1110,14 @@ describe('OrdersService', () => { await service.bulkUpdateTrackingCostInfo({ donationId, orders: [ - { orderId: 4, trackingLink: 'tracking1.com', shippingCost: 5.0 }, + { + orderId: 4, + trackingLink: 'https://tracking1.com', + shippingCost: 5.0, + }, { orderId: orderId2, - trackingLink: 'tracking2.com', + trackingLink: 'https://tracking2.com', shippingCost: 7.5, }, ], @@ -1169,11 +1125,11 @@ describe('OrdersService', () => { const after1 = await service.findOne(4); const after2 = await service.findOne(orderId2); - expect(after1.trackingLink).toEqual('https://tracking1.com/'); + expect(after1.trackingLink).toEqual('https://tracking1.com'); expect(after1.shippingCost).toEqual(5.0); expect(after1.status).toEqual(OrderStatus.SHIPPED); expect(after1.shippedAt).toBeDefined(); - expect(after2.trackingLink).toEqual('https://tracking2.com/'); + expect(after2.trackingLink).toEqual('https://tracking2.com'); expect(after2.shippingCost).toEqual(7.5); expect(after2.status).toEqual(OrderStatus.SHIPPED); expect(after2.shippedAt).toBeDefined(); @@ -1186,11 +1142,11 @@ describe('OrdersService', () => { await service.bulkUpdateTrackingCostInfo({ donationId, - orders: [{ orderId: 4, trackingLink: 'tracking.com' }], + orders: [{ orderId: 4, trackingLink: 'https://tracking.com' }], }); const after = await service.findOne(4); - expect(after.trackingLink).toEqual('https://tracking.com/'); + expect(after.trackingLink).toEqual('https://tracking.com'); expect(after.shippingCost).toBeNull(); expect(after.status).toEqual(OrderStatus.PENDING); expect(after.shippedAt).toBeNull(); @@ -1220,7 +1176,7 @@ describe('OrdersService', () => { await service.bulkUpdateTrackingCostInfo({ donationId, - orders: [{ orderId: 4, trackingLink: 'tracking.com' }], + orders: [{ orderId: 4, trackingLink: 'https://tracking.com' }], }); expect((await service.findOne(4)).status).toEqual(OrderStatus.PENDING); @@ -1230,7 +1186,7 @@ describe('OrdersService', () => { }); const after = await service.findOne(4); - expect(after.trackingLink).toEqual('https://tracking.com/'); + expect(after.trackingLink).toEqual('https://tracking.com'); expect(after.shippingCost).toEqual(10.0); expect(after.status).toEqual(OrderStatus.SHIPPED); expect(after.shippedAt).toBeDefined(); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 4550646ec..88ba3f787 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -8,7 +8,7 @@ import { Repository, In, DataSource } from 'typeorm'; import { Order } from './order.entity'; import { Pantry } from '../pantries/pantries.entity'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; -import { sanitizeUrl, validateId } from '../utils/validation.utils'; +import { validateId } from '../utils/validation.utils'; import { DonationService } from '../donations/donations.service'; import { OrderStatus, VolunteerAction } from './types'; import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; @@ -445,26 +445,17 @@ export class OrdersService { async bulkUpdateTrackingCostInfo( dto: BulkUpdateTrackingCostDto, ): Promise { - // Sanitize all URLs before entering transaction - for (const entry of dto.orders) { - validateId(entry.orderId, 'Order'); + for (const order of dto.orders) { + validateId(order.orderId, 'Order'); + if ( - entry.trackingLink === undefined && - entry.shippingCost === undefined + order.trackingLink === undefined && + order.shippingCost === undefined ) { throw new BadRequestException( - `Order ${entry.orderId} must include at least a tracking link or shipping cost.`, + `Order ${order.orderId} must include at least a tracking link or shipping cost.`, ); } - if (entry.trackingLink !== undefined) { - const sanitized = sanitizeUrl(entry.trackingLink); - if (!sanitized) { - throw new BadRequestException( - `Invalid tracking link for order ${entry.orderId}. Only valid HTTP/HTTPS URLs are accepted.`, - ); - } - entry.trackingLink = sanitized; - } } await this.dataSource.transaction(async (transactionManager) => { @@ -495,6 +486,7 @@ export class OrdersService { ); } + // Can only update orders belonging to the provided donation const relatedCount = await transactionManager .createQueryBuilder(DonationItem, 'item') .innerJoin('item.allocations', 'allocation') diff --git a/apps/backend/src/utils/validation.utils.spec.ts b/apps/backend/src/utils/validation.utils.spec.ts index 3815ec8ae..422b2a7fc 100644 --- a/apps/backend/src/utils/validation.utils.spec.ts +++ b/apps/backend/src/utils/validation.utils.spec.ts @@ -2,12 +2,7 @@ import { BadRequestException, InternalServerErrorException, } from '@nestjs/common'; -import { - hasDuplicates, - sanitizeUrl, - validateEnv, - validateId, -} from './validation.utils'; +import { hasDuplicates, validateEnv, validateId } from './validation.utils'; describe('validateId', () => { it('should not throw an error for a valid ID', () => { @@ -45,41 +40,6 @@ describe('validateEnv', () => { }); }); -describe('sanitizeUrl', () => { - it('should return null for malicious protocols', () => { - const maliciousProtocols = ['javascript:', 'data:', 'file:', 'vbscript:']; - - for (const protocol of maliciousProtocols) { - expect(sanitizeUrl(protocol + 'test')).toBeNull(); - } - }); - - it('should return null for empty or invalid URLs', () => { - expect(sanitizeUrl('')).toBeNull(); - expect(sanitizeUrl('https://')).toBeNull(); - expect(sanitizeUrl('https://foo')).toBeNull(); - }); - - it('should accept valid http/https URLs', () => { - const validHttpUrl = 'http://www.tracking.com/test'; - const validHttpsUrl = 'https://www.tracking.com/test'; - expect(sanitizeUrl(validHttpUrl)).toBe(validHttpUrl); - expect(sanitizeUrl(validHttpsUrl)).toBe(validHttpsUrl); - }); - - it('adds https:// to URL without protocol', () => { - expect(sanitizeUrl('www.tracking.com/test')).toBe( - 'https://www.tracking.com/test', - ); - }); - - it('trims whitespace from URL', () => { - expect(sanitizeUrl(' https://www.tracking.com/test ')).toBe( - 'https://www.tracking.com/test', - ); - }); -}); - describe('hasDuplicates', () => { it('returns true for int array with duplicates', () => { expect(hasDuplicates([1, 1, 2])).toBeTruthy(); diff --git a/apps/backend/src/utils/validation.utils.ts b/apps/backend/src/utils/validation.utils.ts index 9852173b4..d55ecd97f 100644 --- a/apps/backend/src/utils/validation.utils.ts +++ b/apps/backend/src/utils/validation.utils.ts @@ -19,27 +19,6 @@ export function validateEnv(name: string): string { return v; } -export function sanitizeUrl(url: string): string | null { - try { - const trimmed = url.trim(); - if (!trimmed) return null; - - let fullUrl = trimmed; - if (!/^https?:\/\//i.test(trimmed)) { - fullUrl = 'https://' + trimmed; - } - - const urlObj = new URL(fullUrl); - - if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') - return null; - if (!urlObj.hostname || !urlObj.hostname.includes('.')) return null; - return urlObj.href; - } catch { - return null; - } -} - export function hasDuplicates(arr: T[]): boolean { return new Set(arr).size !== arr.length; } From 6895c9131c46de669846f71fccb8894bda6ee713 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sat, 2 May 2026 13:58:33 -0400 Subject: [PATCH 07/12] Comments --- .../donationItems/donationItems.service.ts | 12 +--- .../dtos/update-donation-item-details.dto.ts | 1 + .../src/donations/donations.controller.ts | 25 ++++++- .../backend/src/donations/donations.module.ts | 2 + .../src/donations/donations.service.ts | 13 ++-- .../manufacturers.service.spec.ts | 3 +- .../manufacturers.service.ts | 3 +- apps/backend/src/orders/order.controller.ts | 1 + apps/backend/src/orders/order.service.spec.ts | 3 + apps/backend/src/orders/order.service.ts | 12 ++-- .../src/pantries/pantries.service.spec.ts | 3 +- apps/backend/src/pantries/pantries.service.ts | 3 +- .../foodManufacturerDonationManagement.tsx | 14 ++++ apps/frontend/src/types/types.ts | 72 +++++++++---------- 14 files changed, 99 insertions(+), 68 deletions(-) diff --git a/apps/backend/src/donationItems/donationItems.service.ts b/apps/backend/src/donationItems/donationItems.service.ts index 13619e95d..f43ed6a43 100644 --- a/apps/backend/src/donationItems/donationItems.service.ts +++ b/apps/backend/src/donationItems/donationItems.service.ts @@ -142,16 +142,8 @@ export class DonationItemsService { updateData.estimatedValue !== undefined ? updateData.estimatedValue : item.estimatedValue; - const resultingFoodRescue = - updateData.foodRescue !== undefined - ? updateData.foodRescue - : item.foodRescue; - - if ( - resultingOzPerItem != null && - resultingEstimatedValue != null && - resultingFoodRescue != null - ) { + + if (resultingOzPerItem != null && resultingEstimatedValue != null) { updateData.detailsConfirmed = true; confirmedDetailsForAnItem = true; } diff --git a/apps/backend/src/donationItems/dtos/update-donation-item-details.dto.ts b/apps/backend/src/donationItems/dtos/update-donation-item-details.dto.ts index 182ace64d..7c2366761 100644 --- a/apps/backend/src/donationItems/dtos/update-donation-item-details.dto.ts +++ b/apps/backend/src/donationItems/dtos/update-donation-item-details.dto.ts @@ -2,6 +2,7 @@ import { IsNumber, Min, IsBoolean, IsInt, IsOptional } from 'class-validator'; export class UpdateDonationItemDetailsDto { @IsInt() + @Min(1) itemId!: number; @IsOptional() diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 8452ea33b..ce7248e70 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -18,6 +18,11 @@ import { CreateDonationDto } from './dtos/create-donation.dto'; import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; import { FoodType } from '../donationItems/types'; import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; +import { Roles } from '../auth/roles.decorator'; +import { Role } from '../users/types'; +import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; +import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; @Controller('donations') export class DonationsController { @@ -102,13 +107,29 @@ export class DonationsController { return this.donationService.fulfill(donationId); } + @Roles(Role.ADMIN, Role.FOODMANUFACTURER) + @CheckOwnership({ + idParam: 'donationId', + resolver: async ({ entityId, services }) => { + return pipeNullable( + () => services.get(DonationService).findOne(entityId), + (donation: Donation) => + services + .get(FoodManufacturersService) + .findOne(donation.foodManufacturer.foodManufacturerId), + (manufacturer: FoodManufacturer) => [ + manufacturer.foodManufacturerRepresentative.id, + ], + ); + }, + }) @Patch('/:donationId/item-details') async updateDonationItemDetails( @Param('donationId', ParseIntPipe) donationId: number, @Body(new ParseArrayPipe({ items: UpdateDonationItemDetailsDto })) body: UpdateDonationItemDetailsDto[], - ): Promise { - return this.donationService.updateDonationItemDetails(donationId, body); + ): Promise { + await this.donationService.updateDonationItemDetails(donationId, body); } @Put('/:donationId/items') diff --git a/apps/backend/src/donations/donations.module.ts b/apps/backend/src/donations/donations.module.ts index c3a4de760..00302ee6c 100644 --- a/apps/backend/src/donations/donations.module.ts +++ b/apps/backend/src/donations/donations.module.ts @@ -10,6 +10,7 @@ import { DonationItem } from '../donationItems/donationItems.entity'; import { DonationItemsModule } from '../donationItems/donationItems.module'; import { Allocation } from '../allocations/allocations.entity'; import { AllocationModule } from '../allocations/allocations.module'; +import { ManufacturerModule } from '../foodManufacturers/manufacturers.module'; @Module({ imports: [ @@ -22,6 +23,7 @@ import { AllocationModule } from '../allocations/allocations.module'; forwardRef(() => AuthModule), DonationItemsModule, AllocationModule, + ManufacturerModule, ], controllers: [DonationsController], providers: [DonationService, DonationsSchedulerService], diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 4e5b33e8b..90966f153 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -376,10 +376,10 @@ export class DonationService { async updateDonationItemDetails( donationId: number, body: UpdateDonationItemDetailsDto[], - ): Promise { + ): Promise { validateId(donationId, 'Donation'); - return this.dataSource.transaction(async (transactionManager) => { + await this.dataSource.transaction(async (transactionManager) => { const donationTransactionRepo = transactionManager.getRepository(Donation); @@ -402,14 +402,9 @@ export class DonationService { transactionManager, ); - if (!confirmedDetailsForAnItem) return donation; - - const updated = await donationTransactionRepo.findOne({ - where: { donationId }, - relations: ['donationItems'], - }); + if (!confirmedDetailsForAnItem) return; - return this.checkAndFulfillDonation(updated!, transactionManager); + await this.checkAndFulfillDonation(donation, transactionManager); }); } diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 08e8fef97..8611c481d 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -5,6 +5,7 @@ import { FoodManufacturer } from './manufacturers.entity'; import { BadRequestException, ConflictException, + ForbiddenException, InternalServerErrorException, NotFoundException, } from '@nestjs/common'; @@ -393,7 +394,7 @@ describe('FoodManufacturersService', () => { it('throws BadRequestException when user is not the representative of the food manufacturer', async () => { await expect(service.getFMDonations(fmId1, fmRepId2)).rejects.toThrow( - new BadRequestException( + new ForbiddenException( `User ${fmRepId2} is not allowed to access donations for Food Manufacturer ${fmId1}`, ), ); diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index de49daf91..e05c082b1 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -4,6 +4,7 @@ import { NotFoundException, ConflictException, InternalServerErrorException, + ForbiddenException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { FoodManufacturer } from './manufacturers.entity'; @@ -91,7 +92,7 @@ export class FoodManufacturersService { } if (manufacturer.foodManufacturerRepresentative.id !== currentUserId) { - throw new BadRequestException( + throw new ForbiddenException( `User ${currentUserId} is not allowed to access donations for Food Manufacturer ${foodManufacturerId}`, ); } diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index fafde4b01..49eb70566 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -203,6 +203,7 @@ export class OrdersController { return this.ordersService.updateStatus(orderId, newStatus as OrderStatus); } + @Roles(Role.FOODMANUFACTURER) @Patch('/bulk-update-tracking-cost-info') async bulkUpdateTrackingCostInfo( @Body(new ValidationPipe()) dto: BulkUpdateTrackingCostDto, diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 83527fda5..6d5bb857b 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -1053,6 +1053,9 @@ describe('OrdersService', () => { await expect(service.bulkUpdateTrackingCostInfo(dto)).rejects.toThrow( new NotFoundException('Order 9999 not found'), ); + + const order4 = await service.findOne(4); + expect(order4.shippingCost).toBeNull(); }); it('throws BadRequestException when one order is not pending', async () => { diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 88ba3f787..1d93da452 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -458,12 +458,14 @@ export class OrdersService { } } + let donation: Donation | null; + await this.dataSource.transaction(async (transactionManager) => { const orderTransactionRepo = transactionManager.getRepository(Order); const donationTransactionRepo = transactionManager.getRepository(Donation); - const donation = await donationTransactionRepo.findOneBy({ + donation = await donationTransactionRepo.findOneBy({ donationId: dto.donationId, }); if (!donation) { @@ -518,12 +520,8 @@ export class OrdersService { await orderTransactionRepo.save(ordersToUpdate); }); - const donation = await this.donationRepo.findOneBy({ - donationId: dto.donationId, - }); - if (donation) { - await this.donationService.checkAndFulfillDonation(donation); - } + // Cast here since we know if we make it out of the transaction that the donation exists + await this.donationService.checkAndFulfillDonation(donation!); } async completeVolunteerAction(orderId: number, action: VolunteerAction) { diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 10f5480e9..33eec89c4 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -5,6 +5,7 @@ import { Pantry } from './pantries.entity'; import { BadRequestException, ConflictException, + ForbiddenException, InternalServerErrorException, NotFoundException, } from '@nestjs/common'; @@ -443,7 +444,7 @@ describe('PantriesService', () => { await expect( service.updatePantryApplication(1, dto, invalidUserId), ).rejects.toThrow( - new BadRequestException( + new ForbiddenException( `User ${invalidUserId} is not allowed to edit application for Pantry 1`, ), ); diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 779b46b4a..f98302b49 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -6,6 +6,7 @@ import { NotFoundException, ConflictException, InternalServerErrorException, + ForbiddenException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; @@ -389,7 +390,7 @@ export class PantriesService { } if (pantry.pantryUser.id !== currentUserId) { - throw new BadRequestException( + throw new ForbiddenException( `User ${currentUserId} is not allowed to edit application for Pantry ${pantryId}`, ); } diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index 0f2b93cd4..9c00e6a7a 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -16,8 +16,11 @@ import { DonationDetails, DonationStatus } from '../types/types'; import DonationDetailsModal from '@components/forms/donationDetailsModal'; import NewDonationFormModal from '@components/forms/newDonationFormModal'; import FmCompleteRequiredActionsModal from '@components/forms/fmCompleteRequiredActionsModal'; +import { FloatingAlert } from '@components/floatingAlert'; +import { useAlert } from '../hooks/alert'; const FoodManufacturerDonationManagement: React.FC = () => { + const [alertState, setAlertMessage] = useAlert(); const [isLogDonationOpen, setIsLogDonationOpen] = useState(false); const [manufacturerId, setManufacturerId] = useState(null); const [selectedActionDonation, setSelectedActionDonation] = @@ -107,6 +110,14 @@ const FoodManufacturerDonationManagement: React.FC = () => { return ( + {alertState && ( + + )} Donation Management @@ -145,6 +156,9 @@ const FoodManufacturerDonationManagement: React.FC = () => { onSuccess={() => { setSelectedActionDonation(null); if (manufacturerId !== null) fetchDonations(manufacturerId); + setAlertMessage( + 'Your details have been saved. Actions are complete once all shipment and item details are confirmed', + ); }} /> )} diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index d5dd8a835..872ca46f8 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -16,39 +16,39 @@ export interface Pantry { pantryId: number; pantryName: string; shipmentAddressLine1: string; - shipmentAddressLine2?: string; + shipmentAddressLine2: string | null; shipmentAddressCity: string; shipmentAddressState: string; shipmentAddressZip: string; - shipmentAddressCountry?: string; + shipmentAddressCountry: string | null; mailingAddressLine1: string; - mailingAddressLine2?: string; + mailingAddressLine2: string | null; mailingAddressCity: string; mailingAddressState: string; mailingAddressZip: string; - mailingAddressCountry?: string; + mailingAddressCountry: string | null; allergenClients: string; refrigeratedDonation: RefrigeratedDonation; acceptFoodDeliveries: boolean; - deliveryWindowInstructions?: string; + deliveryWindowInstructions: string | null; reserveFoodForAllergic: ReserveFoodForAllergic; - reservationExplanation?: string; + reservationExplanation: string | null; dedicatedAllergyFriendly: boolean; - clientVisitFrequency?: ClientVisitFrequency; - identifyAllergensConfidence?: AllergensConfidence; - serveAllergicChildren?: ServeAllergicChildren; + clientVisitFrequency: ClientVisitFrequency | null; + identifyAllergensConfidence: AllergensConfidence | null; + serveAllergicChildren: ServeAllergicChildren | null; newsletterSubscription: boolean; restrictions: string[]; hasEmailContact: boolean; - emailContactOther?: string; - secondaryContactFirstName?: string; - secondaryContactLastName?: string; - secondaryContactEmail?: string; - secondaryContactPhone?: string; + emailContactOther: string | null; + secondaryContactFirstName: string | null; + secondaryContactLastName: string | null; + secondaryContactEmail: string | null; + secondaryContactPhone: string | null; status: ApplicationStatus; dateApplied: string; activities: Activity[]; - activitiesComments?: string; + activitiesComments: string | null; itemsInStock: string; needMoreOptions: string; volunteers?: User[]; @@ -102,7 +102,7 @@ export interface PantryApplicationDto { activitiesComments?: string; itemsInStock: string; needMoreOptions: string; - newsletterSubscription?: string; + newsletterSubscription?: boolean; } export interface UpdatePantryApplicationDto { @@ -182,9 +182,9 @@ export interface Donation { status: DonationStatus; foodManufacturer?: FoodManufacturer; recurrence: RecurrenceEnum; - recurrenceFreq?: number; - nextDonationDates?: string[]; - occurrencesRemaining?: number; + recurrenceFreq: number | null; + nextDonationDates: string[] | null; + occurrencesRemaining: number | null; } export interface DonationDetails { @@ -274,7 +274,7 @@ export interface FoodRequestWithoutRelations { pantryId: number; requestedSize: RequestSize; requestedFoodTypes: FoodType[]; - additionalInformation?: string; + additionalInformation: string | null; requestedAt: string; status: FoodRequestStatus; } @@ -309,13 +309,13 @@ export interface OrderWithoutRelations { foodManufacturerId: number; status: OrderStatus; createdAt: string; - shippedAt?: string; - deliveredAt?: string; - trackingLink?: string; - shippingCost?: number; - dateReceived?: string; - feedback?: string; - photos?: string[]; + shippedAt: string | null; + deliveredAt: string | null; + trackingLink: string | null; + shippingCost: number | null; + dateReceived: string | null; + feedback: string | null; + photos: string[] | null; } export interface OrderItemDetails { @@ -370,10 +370,10 @@ export interface FoodManufacturerWithoutRelations { foodManufacturerId: number; foodManufacturerName: string; foodManufacturerWebsite: string; - secondaryContactFirstName?: string; - secondaryContactLastName?: string; - secondaryContactEmail?: string; - secondaryContactPhone?: string; + secondaryContactFirstName: string | null; + secondaryContactLastName: string | null; + secondaryContactEmail: string | null; + secondaryContactPhone: string | null; unlistedProductAllergens: Allergen[]; facilityFreeAllergens: Allergen[]; productsGlutenFree: boolean; @@ -381,9 +381,9 @@ export interface FoodManufacturerWithoutRelations { productsSustainableExplanation: string; inKindDonations: boolean; donateWastedFood: DonateWastedFood; - manufacturerAttribute?: ManufacturerAttribute; - additionalComments?: string; - newsletterSubscription?: boolean; + manufacturerAttribute: ManufacturerAttribute | null; + additionalComments: string | null; + newsletterSubscription: boolean | null; status: ApplicationStatus; dateApplied: string; } @@ -500,8 +500,8 @@ export interface OrderSummary { orderId: number; status: OrderStatus; createdAt: string; - shippedAt?: string; - deliveredAt?: string; + shippedAt: string | null; + deliveredAt: string | null; request: { pantryId: number; pantry: { From 239503e0258094c8dea79bd58fa6a6ae0824e1eb Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sat, 2 May 2026 14:14:10 -0400 Subject: [PATCH 08/12] fixed tests --- .../donations/donations.controller.spec.ts | 10 +------ .../src/donations/donations.service.spec.ts | 26 +++++++++---------- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index 31c1fba1a..2057678b7 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -152,16 +152,8 @@ describe('DonationsController', () => { }, ]; - mockDonationService.updateDonationItemDetails.mockResolvedValueOnce( - donation1 as Donation, - ); + await controller.updateDonationItemDetails(donationId, body); - const result = await controller.updateDonationItemDetails( - donationId, - body, - ); - - expect(result).toEqual(donation1); expect( mockDonationService.updateDonationItemDetails, ).toHaveBeenCalledWith(donationId, body); diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 1111bd6c3..3ea5f7ecd 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -1280,24 +1280,23 @@ describe('DonationService', () => { ); }); - it('returns the donation after confirming item details, as well as fulfills donation', async () => { + it('updates item details and fulfills donation when all items are confirmed', async () => { const donationId = await insertMatchedDonation(); const itemId = await insertDonationItem(donationId, 10, 10); const spy = jest.spyOn(service, 'checkAndFulfillDonation'); - const result = await service.updateDonationItemDetails(donationId, [ - makeDto(itemId), - ]); + await service.updateDonationItemDetails(donationId, [makeDto(itemId)]); - expect(result).toBeDefined(); - expect(result.donationId).toBe(donationId); - expect(result.status).toBe(DonationStatus.FULFILLED); - expect(result.donationItems.every((item) => item.detailsConfirmed)).toBe( - true, - ); const dbDonation = await service.findOne(donationId); expect(dbDonation.status).toBe(DonationStatus.FULFILLED); + + const dbItem = await donationItemService.findOne(itemId); + expect(dbItem.detailsConfirmed).toBe(true); + expect(Number(dbItem.ozPerItem)).toBe(5.0); + expect(Number(dbItem.estimatedValue)).toBe(10.0); + expect(dbItem.foodRescue).toBe(true); + expect(spy).toHaveBeenCalled(); }); @@ -1307,13 +1306,12 @@ describe('DonationService', () => { const spy = jest.spyOn(service, 'checkAndFulfillDonation'); - const result = await service.updateDonationItemDetails(donationId, [ + await service.updateDonationItemDetails(donationId, [ { itemId, ozPerItem: 5.0 }, ]); - expect(result).toBeDefined(); - expect(result.donationId).toBe(donationId); - expect(result.status).toBe(DonationStatus.MATCHED); + const dbDonation = await service.findOne(donationId); + expect(dbDonation.status).toBe(DonationStatus.MATCHED); expect(spy).not.toHaveBeenCalled(); }); }); From adfac9f34f5a2c002dd962f6843d2785a84b775a Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sun, 3 May 2026 13:20:22 -0400 Subject: [PATCH 09/12] comments --- .../containers/foodManufacturerDonationManagement.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index 9c00e6a7a..1d68a77a2 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -19,6 +19,8 @@ import FmCompleteRequiredActionsModal from '@components/forms/fmCompleteRequired import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; +const MAX_PER_STATUS = 5; + const FoodManufacturerDonationManagement: React.FC = () => { const [alertState, setAlertMessage] = useAlert(); const [isLogDonationOpen, setIsLogDonationOpen] = useState(false); @@ -48,8 +50,6 @@ const FoodManufacturerDonationManagement: React.FC = () => { [DonationStatus.FULFILLED]: 1, }); - const MAX_PER_STATUS = 5; - // Fetch all donations on component mount and sorts them into their appropriate status lists const fetchDonations = async (fmId: number) => { try { @@ -83,7 +83,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { }; setCurrentPages(initialPages); } catch (error) { - alert('Error fetching donations: ' + error); + setAlertMessage('Error fetching donations: ' + error); } }; @@ -95,7 +95,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { setManufacturerId(fmId); await fetchDonations(fmId); } catch (error) { - alert('Error initializing donation management: ' + error); + setAlertMessage('Error initializing donation management: ' + error); } }; init(); @@ -215,7 +215,6 @@ const DonationStatusSection: React.FC = ({ onPageChange, onActionSelect, }) => { - const MAX_PER_STATUS = 5; const totalPages = Math.ceil(totalDonations / MAX_PER_STATUS); const tableHeaderStyles = { From 1bca5215489c5c49e199e4228f71151770551afb Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sun, 3 May 2026 14:02:23 -0400 Subject: [PATCH 10/12] refactored all service and controllers to return Promise --- .../donations/donations.controller.spec.ts | 26 +++------- .../src/donations/donations.controller.ts | 8 +-- .../src/donations/donations.service.spec.ts | 22 ++++---- .../src/donations/donations.service.ts | 9 ++-- .../manufacturers.controller.spec.ts | 3 +- .../manufacturers.service.ts | 2 +- .../foodRequests/request.controller.spec.ts | 26 +++------- .../src/foodRequests/request.controller.ts | 8 +-- .../src/foodRequests/request.service.spec.ts | 32 ++++-------- .../src/foodRequests/request.service.ts | 8 +-- .../src/orders/order.controller.spec.ts | 51 +++---------------- apps/backend/src/orders/order.controller.ts | 8 +-- apps/backend/src/orders/order.service.spec.ts | 28 +++++----- apps/backend/src/orders/order.service.ts | 11 ++-- .../src/pantries/pantries.controller.spec.ts | 7 +-- .../src/pantries/pantries.service.spec.ts | 2 - apps/backend/src/pantries/pantries.service.ts | 2 +- .../src/users/users.controller.spec.ts | 10 ++-- apps/backend/src/users/users.service.spec.ts | 11 ++-- apps/frontend/src/api/apiClient.ts | 35 +++++-------- .../src/containers/donationManagement.tsx | 28 ++++++---- 21 files changed, 125 insertions(+), 212 deletions(-) diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index 2057678b7..20400d45a 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -7,7 +7,6 @@ import { CreateDonationDto } from './dtos/create-donation.dto'; import { CreateDonationItemDto } from '../donationItems/dtos/create-donation-items.dto'; import { DonationStatus, RecurrenceEnum } from './types'; import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; -import { DonationItem } from '../donationItems/donationItems.entity'; import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; import { FoodType } from '../donationItems/types'; @@ -122,14 +121,13 @@ describe('DonationsController', () => { }); describe('PATCH /:donationId/fulfill', () => { - it('should call donationService.fulfill and return updated donation', async () => { + it('should call donationService.fulfill', async () => { const donationId = 1; - mockDonationService.fulfill.mockResolvedValueOnce(donation1 as Donation); + mockDonationService.fulfill.mockResolvedValueOnce(undefined); - const result = await controller.fulfillDonation(donationId); + await controller.fulfillDonation(donationId); - expect(result).toEqual(donation1); expect(mockDonationService.fulfill).toHaveBeenCalledWith(donationId); }); }); @@ -161,7 +159,7 @@ describe('DonationsController', () => { }); describe('PUT /:donationId/items', () => { - it('should call donationService.replaceDonationItems and return updated donation', async () => { + it('should call donationService.replaceDonationItems', async () => { const donationId = 1; const replaceBody = { @@ -180,25 +178,13 @@ describe('DonationsController', () => { ], }; - const updatedDonation: Partial = { - donationId, - donationItems: [ - { itemId: 1, itemName: 'Apples', quantity: 10 } as DonationItem, - { itemId: 2, itemName: 'Oranges', quantity: 5 } as DonationItem, - ], - status: DonationStatus.AVAILABLE, - }; - - mockDonationService.replaceDonationItems.mockResolvedValueOnce( - updatedDonation as Donation, - ); + mockDonationService.replaceDonationItems.mockResolvedValueOnce(undefined); - const result = await controller.replaceDonationItems( + await controller.replaceDonationItems( donationId, replaceBody as ReplaceDonationItemsDto, ); - expect(result).toEqual(updatedDonation); expect(mockDonationService.replaceDonationItems).toHaveBeenCalledWith( donationId, replaceBody, diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index ce7248e70..19657b089 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -103,8 +103,8 @@ export class DonationsController { @Patch('/:donationId/fulfill') async fulfillDonation( @Param('donationId', ParseIntPipe) donationId: number, - ): Promise { - return this.donationService.fulfill(donationId); + ): Promise { + await this.donationService.fulfill(donationId); } @Roles(Role.ADMIN, Role.FOODMANUFACTURER) @@ -136,8 +136,8 @@ export class DonationsController { async replaceDonationItems( @Param('donationId', ParseIntPipe) donationId: number, @Body() body: ReplaceDonationItemsDto, - ): Promise { - return this.donationService.replaceDonationItems(donationId, body); + ): Promise { + await this.donationService.replaceDonationItems(donationId, body); } @Delete('/:donationId') diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 3ea5f7ecd..7fc1b0b67 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -1083,17 +1083,14 @@ describe('DonationService', () => { // manually removing allocations for deleted item ids await service['allocationRepo'].delete({ itemId: In([2, 3]) }); - const updatedDonation = await service.replaceDonationItems( + await service.replaceDonationItems(donationId, body); + + const updatedItems = await donationItemService.getAllDonationItems( donationId, - body, ); + expect(updatedItems).toHaveLength(2); - expect(updatedDonation).toBeDefined(); - expect(updatedDonation.donationItems).toHaveLength(2); - - const updatedItemNames = updatedDonation.donationItems.map( - (i) => i.itemName, - ); + const updatedItemNames = updatedItems.map((i) => i.itemName); expect(updatedItemNames).toContain('Green Apples'); // updated expect(updatedItemNames).toContain('Bananas'); // new expect(updatedItemNames).not.toContain('Canned Green Beans'); // deleted @@ -1136,13 +1133,12 @@ describe('DonationService', () => { // manually removing allocations for deleted item ids await service['allocationRepo'].delete({ itemId: In([1, 2, 3]) }); - const updatedDonation = await service.replaceDonationItems( + await service.replaceDonationItems(donationId, body); + + const updatedItems = await donationItemService.getAllDonationItems( donationId, - body, ); - - expect(updatedDonation).toBeDefined(); - expect(updatedDonation.donationItems).toHaveLength(0); + expect(updatedItems).toHaveLength(0); }); it('should throw NotFoundException if donation does not exist', async () => { diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 90966f153..c4d8fdf25 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -111,7 +111,7 @@ export class DonationService { }); } - async fulfill(donationId: number): Promise { + async fulfill(donationId: number): Promise { validateId(donationId, 'Donation'); const donation = await this.repo.findOneBy({ donationId }); @@ -119,7 +119,8 @@ export class DonationService { throw new NotFoundException(`Donation ${donationId} not found`); } donation.status = DonationStatus.FULFILLED; - return this.repo.save(donation); + + await this.repo.save(donation); } async matchAll( @@ -449,7 +450,7 @@ export class DonationService { async replaceDonationItems( donationId: number, body: ReplaceDonationItemsDto, - ): Promise { + ): Promise { validateId(donationId, 'Donation'); const donation = await this.repo.findOne({ @@ -529,8 +530,6 @@ export class DonationService { await transactionRepo.save(donation.donationItems); } }); - - return donation; } async delete(donationId: number): Promise { diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts index 3932cdda6..f5964ec69 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts @@ -202,11 +202,10 @@ describe('FoodManufacturersController', () => { mockUpdateData, ); + expect(result).toEqual(mockManufacturer1); expect( mockManufacturersService.updateFoodManufacturerApplication, ).toHaveBeenCalledWith(manufacturerId, mockUpdateData, 1); - - expect(result).toEqual(mockManufacturer1); }); it('should throw error if manufacturer does not exist', async () => { diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index e05c082b1..97eb8e69d 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -259,7 +259,7 @@ export class FoodManufacturersService { manufacturerId: number, foodManufacturerData: UpdateFoodManufacturerApplicationDto, currentUserId: number, - ) { + ): Promise { validateId(manufacturerId, 'Food Manufacturer'); validateId(currentUserId, 'User'); diff --git a/apps/backend/src/foodRequests/request.controller.spec.ts b/apps/backend/src/foodRequests/request.controller.spec.ts index 27dc4281b..61c491302 100644 --- a/apps/backend/src/foodRequests/request.controller.spec.ts +++ b/apps/backend/src/foodRequests/request.controller.spec.ts @@ -3,7 +3,7 @@ import { RequestsController } from './request.controller'; import { Test, TestingModule } from '@nestjs/testing'; import { mock } from 'jest-mock-extended'; import { FoodRequest } from './request.entity'; -import { FoodRequestStatus, RequestSize } from './types'; +import { RequestSize } from './types'; import { OrderStatus } from '../orders/types'; import { FoodType } from '../donationItems/types'; import { OrderDetailsDto } from '../orders/dtos/order-details.dto'; @@ -246,20 +246,13 @@ describe('RequestsController', () => { describe('PATCH /:requestId', () => { it('should update request with valid information', async () => { - const updatedRequest = { - ...foodRequest1, - requestedSize: RequestSize.MEDIUM, - }; - mockRequestsService.update.mockResolvedValue( - updatedRequest as FoodRequest, - ); + mockRequestsService.update.mockResolvedValue(undefined); const updateRequestDto: UpdateRequestDto = { requestedSize: RequestSize.MEDIUM, }; - const result = await controller.updateRequest(1, updateRequestDto); + await controller.updateRequest(1, updateRequestDto); - expect(result).toEqual(updatedRequest); expect(mockRequestsService.update).toHaveBeenCalledWith( 1, updateRequestDto, @@ -323,20 +316,13 @@ describe('RequestsController', () => { }); describe('PATCH /:requestId/close', () => { - it('should call requestsService.closeRequest and return the closed food request', async () => { + it('should call requestsService.closeRequest', async () => { const requestId = 1; - const closedRequest: Partial = { - ...foodRequest1, - status: FoodRequestStatus.CLOSED, - }; - mockRequestsService.closeRequest.mockResolvedValueOnce( - closedRequest as FoodRequest, - ); + mockRequestsService.closeRequest.mockResolvedValueOnce(undefined); - const result = await controller.closeRequest(requestId); + await controller.closeRequest(requestId); - expect(result).toEqual(closedRequest); expect(mockRequestsService.closeRequest).toHaveBeenCalledWith(requestId); }); }); diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index 5939c3edd..30dbf64ad 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -117,8 +117,8 @@ export class RequestsController { async updateRequest( @Param('requestId', ParseIntPipe) requestId: number, @Body(new ValidationPipe()) body: UpdateRequestDto, - ): Promise { - return this.requestsService.update(requestId, body); + ): Promise { + await this.requestsService.update(requestId, body); } @Delete('/:requestId') @@ -132,7 +132,7 @@ export class RequestsController { @Patch('/:requestId/close') async closeRequest( @Param('requestId', ParseIntPipe) requestId: number, - ): Promise { - return this.requestsService.closeRequest(requestId); + ): Promise { + await this.requestsService.closeRequest(requestId); } } diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index b0be5b454..5c945fb2c 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -620,22 +620,18 @@ describe('RequestsService', () => { ); await testDataSource.query(`DELETE FROM orders WHERE request_id = 1`); - const result = await service.update(1, { + await service.update(1, { requestedSize: RequestSize.MEDIUM, }); - expect(result.requestedSize).toBe(RequestSize.MEDIUM); - expect(result.requestedFoodTypes).toEqual([ + const fromDb = await service.findOne(1); + expect(fromDb.requestedSize).toBe(RequestSize.MEDIUM); + expect(fromDb.requestedFoodTypes).toEqual([ FoodType.SEED_BUTTERS, FoodType.GLUTEN_FREE_BREAD, FoodType.DRIED_BEANS, FoodType.DAIRY_FREE_ALTERNATIVES, ]); - - const fromDb = await testDataSource - .getRepository(FoodRequest) - .findOneBy({ requestId: 1 }); - expect(fromDb?.requestedSize).toBe(RequestSize.MEDIUM); }); it('should throw NotFoundException when request is not found', async () => { @@ -650,22 +646,16 @@ describe('RequestsService', () => { ); await testDataSource.query(`DELETE FROM orders WHERE request_id = 1`); - const result = await service.update(1, { + await service.update(1, { requestedSize: RequestSize.SMALL, requestedFoodTypes: [FoodType.GRANOLA], additionalInformation: 'Updated information', }); - expect(result.requestedSize).toBe(RequestSize.SMALL); - expect(result.requestedFoodTypes).toEqual([FoodType.GRANOLA]); - expect(result.additionalInformation).toBe('Updated information'); - - const fromDb = await testDataSource - .getRepository(FoodRequest) - .findOneBy({ requestId: 1 }); - expect(fromDb?.requestedSize).toBe(RequestSize.SMALL); - expect(fromDb?.requestedFoodTypes).toEqual([FoodType.GRANOLA]); - expect(fromDb?.additionalInformation).toBe('Updated information'); + const fromDb = await service.findOne(1); + expect(fromDb.requestedSize).toBe(RequestSize.SMALL); + expect(fromDb.requestedFoodTypes).toEqual([FoodType.GRANOLA]); + expect(fromDb.additionalInformation).toBe('Updated information'); }); it('should throw BadRequestException when request is not active', async () => { @@ -745,9 +735,7 @@ describe('RequestsService', () => { describe('closeRequest', () => { it('should close an active request', async () => { - const result = await service.closeRequest(3); - - expect(result.status).toBe(FoodRequestStatus.CLOSED); + await service.closeRequest(3); const fromDb = await service.findOne(3); expect(fromDb.status).toBe(FoodRequestStatus.CLOSED); diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 11ba4d880..c5930ba5d 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -308,7 +308,7 @@ export class RequestsService { await this.repo.save(request); } - async update(requestId: number, dto: UpdateRequestDto): Promise { + async update(requestId: number, dto: UpdateRequestDto): Promise { validateId(requestId, 'Request'); if ( @@ -344,7 +344,7 @@ export class RequestsService { Object.assign(request, dto); - return this.repo.save(request); + await this.repo.save(request); } async delete(requestId: number) { @@ -374,7 +374,7 @@ export class RequestsService { await this.repo.remove(request); } - async closeRequest(requestId: number): Promise { + async closeRequest(requestId: number): Promise { validateId(requestId, 'Request'); const request = await this.repo.findOne({ @@ -392,6 +392,6 @@ export class RequestsService { } request.status = FoodRequestStatus.CLOSED; - return this.repo.save(request); + await this.repo.save(request); } } diff --git a/apps/backend/src/orders/order.controller.spec.ts b/apps/backend/src/orders/order.controller.spec.ts index 18179ff46..e645fdd72 100644 --- a/apps/backend/src/orders/order.controller.spec.ts +++ b/apps/backend/src/orders/order.controller.spec.ts @@ -255,18 +255,9 @@ describe('OrdersController', () => { const uploadedUrls = ['https://s3.example.com/photo1.jpg']; mockAWSS3Service.upload.mockResolvedValueOnce(uploadedUrls); - const confirmedOrder: Partial = { - orderId, - status: OrderStatus.DELIVERED, - dateReceived: new Date(body.dateReceived), - feedback: body.feedback, - photos: uploadedUrls, - }; - mockOrdersService.confirmDelivery.mockResolvedValueOnce( - confirmedOrder as Order, - ); + mockOrdersService.confirmDelivery.mockResolvedValueOnce(undefined); - const result = await controller.confirmDelivery(orderId, body, mockFiles); + await controller.confirmDelivery(orderId, body, mockFiles); expect(mockAWSS3Service.upload).toHaveBeenCalledWith(mockFiles); expect(mockOrdersService.confirmDelivery).toHaveBeenCalledWith( @@ -274,7 +265,6 @@ describe('OrdersController', () => { body, uploadedUrls, ); - expect(result).toEqual(confirmedOrder); }); it('should handle no photos being uploaded', async () => { @@ -284,18 +274,9 @@ describe('OrdersController', () => { feedback: 'Delivery without photos', }; - const confirmedOrder: Partial = { - orderId, - status: OrderStatus.DELIVERED, - dateReceived: new Date(body.dateReceived), - feedback: body.feedback, - photos: [], - }; - mockOrdersService.confirmDelivery.mockResolvedValueOnce( - confirmedOrder as Order, - ); + mockOrdersService.confirmDelivery.mockResolvedValueOnce(undefined); - const result = await controller.confirmDelivery(orderId, body); + await controller.confirmDelivery(orderId, body); expect(mockAWSS3Service.upload).not.toHaveBeenCalled(); expect(mockOrdersService.confirmDelivery).toHaveBeenCalledWith( @@ -303,7 +284,6 @@ describe('OrdersController', () => { body, [], ); - expect(result).toEqual(confirmedOrder); }); it('should handle empty photos array', async () => { @@ -313,18 +293,9 @@ describe('OrdersController', () => { feedback: 'Empty photos', }; - const confirmedOrder: Partial = { - orderId, - status: OrderStatus.DELIVERED, - dateReceived: new Date(body.dateReceived), - feedback: body.feedback, - photos: [], - }; - mockOrdersService.confirmDelivery.mockResolvedValueOnce( - confirmedOrder as Order, - ); + mockOrdersService.confirmDelivery.mockResolvedValueOnce(undefined); - const result = await controller.confirmDelivery(orderId, body, []); + await controller.confirmDelivery(orderId, body, []); expect(mockAWSS3Service.upload).not.toHaveBeenCalled(); expect(mockOrdersService.confirmDelivery).toHaveBeenCalledWith( @@ -332,7 +303,6 @@ describe('OrdersController', () => { body, [], ); - expect(result).toEqual(confirmedOrder); }); }); @@ -592,21 +562,16 @@ describe('OrdersController', () => { action: VolunteerAction.CONFIRM_DONATION_RECEIPT, }; - const updatedOrder = { - ...mockOrders[0], - confirmDonationReceipt: true, - }; mockOrdersService.completeVolunteerAction.mockResolvedValueOnce( - updatedOrder as Order, + undefined, ); - const result = await controller.completeVolunteerAction(orderId, dto); + await controller.completeVolunteerAction(orderId, dto); expect(mockOrdersService.completeVolunteerAction).toHaveBeenCalledWith( orderId, VolunteerAction.CONFIRM_DONATION_RECEIPT, ); - expect(result).toEqual(updatedOrder); }); }); }); diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index 49eb70566..6a72b2b57 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -251,13 +251,13 @@ export class OrdersController { @Param('orderId', ParseIntPipe) orderId: number, @Body() body: ConfirmDeliveryDto, @UploadedFiles() photos?: Express.Multer.File[], - ): Promise { + ): Promise { try { const uploadedPhotoUrls = photos && photos.length > 0 ? await this.awsS3Service.upload(photos) : []; - return this.ordersService.confirmDelivery( + await this.ordersService.confirmDelivery( orderId, body, uploadedPhotoUrls, @@ -287,7 +287,7 @@ export class OrdersController { async completeVolunteerAction( @Param('orderId', ParseIntPipe) orderId: number, @Body(new ValidationPipe()) dto: CompleteVolunteerActionDto, - ) { - return this.ordersService.completeVolunteerAction(orderId, dto.action); + ): Promise { + await this.ordersService.completeVolunteerAction(orderId, dto.action); } } diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 6d5bb857b..60f8daca6 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -447,18 +447,19 @@ describe('OrdersService', () => { const feedback = 'Perfect delivery!'; const photos = ['photo1.jpg', 'photo2.jpg']; - const result = await service.confirmDelivery( + await service.confirmDelivery( shippedOrder.orderId, { dateReceived, feedback }, photos, ); - expect(result.orderId).toBe(shippedOrder.orderId); - expect(result.status).toBe(OrderStatus.DELIVERED); - expect(result.dateReceived).toEqual(new Date(dateReceived)); - expect(result.feedback).toBe(feedback); - expect(result.photos).toEqual(photos); - expect(result.deliveredAt).toBeNull(); + const updatedOrder = await service.findOne(shippedOrder.orderId); + expect(updatedOrder.orderId).toBe(shippedOrder.orderId); + expect(updatedOrder.status).toBe(OrderStatus.DELIVERED); + expect(updatedOrder.dateReceived).toEqual(new Date(dateReceived)); + expect(updatedOrder.feedback).toBe(feedback); + expect(updatedOrder.photos).toEqual(photos); + expect(updatedOrder.deliveredAt).toBeNull(); const updatedRequest = await requestRepo.findOne({ where: { requestId: shippedOrder.requestId }, @@ -499,17 +500,18 @@ describe('OrdersService', () => { const feedback = 'Perfect delivery!'; const photos = ['photo1.jpg', 'photo2.jpg']; - const result = await service.confirmDelivery( + await service.confirmDelivery( existingShippedOrder.orderId, { dateReceived, feedback }, photos, ); - expect(result.orderId).toBe(existingShippedOrder.orderId); - expect(result.status).toBe(OrderStatus.DELIVERED); - expect(result.dateReceived).toEqual(new Date(dateReceived)); - expect(result.feedback).toBe(feedback); - expect(result.photos).toEqual(photos); + const updatedOrder = await service.findOne(existingShippedOrder.orderId); + expect(updatedOrder.orderId).toBe(existingShippedOrder.orderId); + expect(updatedOrder.status).toBe(OrderStatus.DELIVERED); + expect(updatedOrder.dateReceived).toEqual(new Date(dateReceived)); + expect(updatedOrder.feedback).toBe(feedback); + expect(updatedOrder.photos).toEqual(photos); const updatedRequest = await requestRepo.findOne({ where: { requestId: existingShippedOrder.requestId }, diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 1d93da452..d7a617954 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -383,7 +383,7 @@ export class OrdersService { orderId: number, dto: ConfirmDeliveryDto, photos: string[], - ): Promise { + ): Promise { validateId(orderId, 'Order'); const formattedDate = new Date(dto.dateReceived); @@ -411,8 +411,6 @@ export class OrdersService { const updatedOrder = await this.repo.save(order); await this.requestsService.updateRequestStatus(order.requestId); - - return updatedOrder; } async getOrdersByPantry( @@ -524,7 +522,10 @@ export class OrdersService { await this.donationService.checkAndFulfillDonation(donation!); } - async completeVolunteerAction(orderId: number, action: VolunteerAction) { + async completeVolunteerAction( + orderId: number, + action: VolunteerAction, + ): Promise { validateId(orderId, 'Order'); const order = await this.repo.findOneBy({ orderId }); @@ -547,6 +548,6 @@ export class OrdersService { order[action] = true; - return this.repo.save(order); + await this.repo.save(order); } } diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index ce8ae094d..4dacb907a 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -295,9 +295,7 @@ describe('PantriesController', () => { itemsInStock: 'Canned beans, rice', }; - mockPantriesService.updatePantryApplication.mockResolvedValue( - mockPantry as Pantry, - ); + mockPantriesService.updatePantryApplication.mockResolvedValue(mockPantry); const result = await controller.updatePantryApplication( req as AuthenticatedRequest, @@ -305,13 +303,12 @@ describe('PantriesController', () => { mockUpdateData, ); + expect(result).toEqual(mockPantry); expect(mockPantriesService.updatePantryApplication).toHaveBeenCalledWith( pantryId, mockUpdateData, 1, ); - - expect(result).toEqual(mockPantry); }); it('should throw error if pantry does not exist', async () => { diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 33eec89c4..0a41687c1 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -397,7 +397,6 @@ describe('PantriesService', () => { }; const updatedPantry = await service.updatePantryApplication(1, dto, 10); - expect(updatedPantry.secondaryContactFirstName).toBe('John'); expect(updatedPantry.secondaryContactLastName).toBe('Doe'); expect(updatedPantry.refrigeratedDonation).toBe(RefrigeratedDonation.YES); @@ -426,7 +425,6 @@ describe('PantriesService', () => { }; const updated = await service.updatePantryApplication(2, dto, 11); - expect(updated.itemsInStock).toBe('Rice and beans'); expect(updated.pantryName).toBe(original.pantryName); expect(updated.secondaryContactEmail).toBe( diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index f98302b49..bba400160 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -376,7 +376,7 @@ export class PantriesService { pantryId: number, pantryData: UpdatePantryApplicationDto, currentUserId: number, - ) { + ): Promise { validateId(pantryId, 'Pantry'); validateId(currentUserId, 'User'); diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index e54045d70..a0b59f368 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -86,19 +86,21 @@ describe('UsersController', () => { describe('PATCH /:id', () => { it('should update user info with valid information', async () => { - const updatedUser = { - ...mockUser1, + const updateUserSchema: UpdateUserInfoDto = { firstName: 'UpdatedFirstName', lastName: 'UpdatedLastName', phone: '777-777-7777', }; - mockUserService.update.mockResolvedValue(updatedUser as User); - const updateUserSchema: UpdateUserInfoDto = { + const updatedUser: Partial = { + ...mockUser1, firstName: 'UpdatedFirstName', lastName: 'UpdatedLastName', phone: '777-777-7777', }; + + mockUserService.update.mockResolvedValue(updatedUser as User); + const result = await controller.updateInfo(1, updateUserSchema); expect(result).toEqual(updatedUser); diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index 569e0b184..8e2c50073 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -265,15 +265,10 @@ describe('UsersService', () => { describe('update', () => { it('should update user attributes', async () => { - const result = await service.update(1, { firstName: 'Updated' }); + const updatedUser = await service.update(1, { firstName: 'Updated' }); - expect(result.firstName).toBe('Updated'); - expect(result.lastName).toBe('Smith'); - - const fromDb = await testDataSource - .getRepository(User) - .findOneBy({ id: 1 }); - expect(fromDb?.firstName).toBe('Updated'); + expect(updatedUser.firstName).toBe('Updated'); + expect(updatedUser.lastName).toBe('Smith'); }); it('should throw NotFoundException when user is not found', async () => { diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index d69207bfb..fa9ae8b84 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -1,7 +1,6 @@ import { fetchAuthSession } from 'aws-amplify/auth'; import axios, { AxiosError, - AxiosResponse, type AxiosInstance, type InternalAxiosRequestConfig, } from 'axios'; @@ -39,7 +38,6 @@ import { DonationDetails, VolunteerOrder, VolunteerAction, - FoodRequestWithoutRelations, BulkUpdateTrackingCostDto, UpdateDonationItemDetailsDto, } from 'types/types'; @@ -102,12 +100,8 @@ export class ApiClient { .then((response) => response.data); } - public async closeFoodRequest( - requestId: number, - ): Promise { - return this.axiosInstance - .patch(`/api/requests/${requestId}/close`, {}) - .then((response) => response.data); + public async closeFoodRequest(requestId: number): Promise { + await this.axiosInstance.patch(`/api/requests/${requestId}/close`, {}); } public async getAllDonations(): Promise { @@ -127,10 +121,11 @@ export class ApiClient { public async fulfillDonation( donationId: number, body?: unknown, - ): Promise { - return this.axiosInstance - .patch(`/api/donations/${donationId}/fulfill`, body ?? {}) - .then((response) => response.data); + ): Promise { + await this.axiosInstance.patch( + `/api/donations/${donationId}/fulfill`, + body ?? {}, + ); } public async getRepresentativeUser(userId: number): Promise { @@ -185,10 +180,8 @@ export class ApiClient { .then((response) => response.data); } - public async postPantry( - data: PantryApplicationDto, - ): Promise> { - return this.axiosInstance.post(`/api/pantries`, data); + public async postPantry(data: PantryApplicationDto): Promise { + await this.axiosInstance.post(`/api/pantries`, data); } public async getPantryStats(params?: { @@ -307,7 +300,7 @@ export class ApiClient { orderId: number, dto: ConfirmDeliveryDto, photos: File[], - ): Promise { + ): Promise { const formData = new FormData(); formData.append('dateReceived', dto.dateReceived); @@ -319,18 +312,16 @@ export class ApiClient { formData.append('photos', file); } - const { data } = await this.axiosInstance.patch( + await this.axiosInstance.patch( `/api/orders/${orderId}/confirm-delivery`, formData, ); - - return data; } public async postManufacturer( data: ManufacturerApplicationDto, - ): Promise> { - return this.axiosInstance.post(`/api/manufacturers/application`, data); + ): Promise { + await this.axiosInstance.post(`/api/manufacturers/application`, data); } public async getAllOrders(): Promise { diff --git a/apps/frontend/src/containers/donationManagement.tsx b/apps/frontend/src/containers/donationManagement.tsx index c0c1b39f9..e7d038f4c 100644 --- a/apps/frontend/src/containers/donationManagement.tsx +++ b/apps/frontend/src/containers/donationManagement.tsx @@ -11,9 +11,12 @@ import ApiClient from '@api/apiClient'; import NewDonationFormModal from '@components/forms/newDonationFormModal'; import { formatDate } from '@utils/utils'; import { Donation, DonationItem } from 'types/types'; +import { FloatingAlert } from '@components/floatingAlert'; +import { useAlert } from '../hooks/alert'; const DonationManagement: React.FC = () => { const { open, onOpen, onClose } = useDisclosure(); + const [alertState, setAlertMessage] = useAlert(); const [donations, setDonations] = useState([]); const [expandedDonationIds, setExpandedDonationIds] = useState([]); const [donationItems, setDonationItems] = useState<{ @@ -39,8 +42,8 @@ const DonationManagement: React.FC = () => { return 0; }); setDonations(sortedDonations); - } catch (error) { - alert('Error fetching donations: ' + error); + } catch { + setAlertMessage('Error fetching donations'); } }; @@ -58,8 +61,8 @@ const DonationManagement: React.FC = () => { [item.itemId]: item.quantity - item.reservedQuantity, })); }); - } catch (error) { - alert('Error fetching donation items: ' + error); + } catch { + setAlertMessage('Error fetching donation items'); } }; @@ -76,13 +79,10 @@ const DonationManagement: React.FC = () => { const fulfillDonation = async (donationId: number) => { try { - const response = await ApiClient.fulfillDonation(donationId); - if (!response) { - alert('Error fulfilling donation'); - } + await ApiClient.fulfillDonation(donationId); fetchDonations(); - } catch (error) { - alert('Failed to fulfill donation: ' + error); + } catch { + setAlertMessage('Failed to fulfill donation'); } }; @@ -92,6 +92,14 @@ const DonationManagement: React.FC = () => { return (
+ {alertState && ( + + )} {manufacturerId !== null && ( Date: Tue, 5 May 2026 22:16:34 -0400 Subject: [PATCH 11/12] comments --- apps/backend/src/orders/order.service.ts | 7 +++-- .../src/components/foodRequestManagement.tsx | 29 ++++++++++-------- .../containers/approveFoodManufacturers.tsx | 25 +++++++++++----- .../src/containers/approvePantries.tsx | 25 +++++++++++----- .../foodManufacturerDonationManagement.tsx | 25 +++++++++++----- .../src/containers/pantryOrderManagement.tsx | 30 +++++++++++-------- .../src/containers/volunteerManagement.tsx | 30 +++++++++++-------- 7 files changed, 107 insertions(+), 64 deletions(-) diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index d7a617954..ac2d12897 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -516,10 +516,11 @@ export class OrdersService { } await orderTransactionRepo.save(ordersToUpdate); + await this.donationService.checkAndFulfillDonation( + donation, + transactionManager, + ); }); - - // Cast here since we know if we make it out of the transaction that the donation exists - await this.donationService.checkAndFulfillDonation(donation!); } async completeVolunteerAction( diff --git a/apps/frontend/src/components/foodRequestManagement.tsx b/apps/frontend/src/components/foodRequestManagement.tsx index 805df96f3..77f15228a 100644 --- a/apps/frontend/src/components/foodRequestManagement.tsx +++ b/apps/frontend/src/components/foodRequestManagement.tsx @@ -47,16 +47,15 @@ const RequestManagement: React.FC = ({ const [selectedCreateOrderRequest, setSelectedCreateOrderRequest] = useState(null); - const [alertState, setAlertMessage] = useAlert(); - const [isAlertError, setIsAlertError] = useState(true); + const [errorAlertState, setErrorMessage] = useAlert(); + const [successAlertState, setSuccessMessage] = useAlert(); const loadRequests = async () => { try { const data = await fetchData(); setRequests(data); } catch { - setIsAlertError(true); - setAlertMessage('Error fetching requests'); + setErrorMessage('Error fetching requests'); } }; @@ -133,11 +132,19 @@ const RequestManagement: React.FC = ({ Food Request Management - {alertState && ( + {errorAlertState && ( + )} + {successAlertState && ( + )} @@ -384,8 +391,7 @@ const RequestManagement: React.FC = ({ isOpen={true} onClose={clearCloseRequest} onSuccess={() => { - setIsAlertError(false); - setAlertMessage('Request Closed'); + setSuccessMessage('Request Closed'); loadRequests(); }} /> @@ -397,8 +403,7 @@ const RequestManagement: React.FC = ({ isOpen={true} onClose={clearCreateOrder} onSuccess={() => { - setIsAlertError(false); - setAlertMessage('Order Created'); + setSuccessMessage('Order Created'); loadRequests(); }} /> diff --git a/apps/frontend/src/containers/approveFoodManufacturers.tsx b/apps/frontend/src/containers/approveFoodManufacturers.tsx index 17627f8a3..069195750 100644 --- a/apps/frontend/src/containers/approveFoodManufacturers.tsx +++ b/apps/frontend/src/containers/approveFoodManufacturers.tsx @@ -35,7 +35,8 @@ const ApproveFoodManufacturers: React.FC = () => { const [currentPage, setCurrentPage] = useState(1); const [isFilterOpen, setIsFilterOpen] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); - const [alertState, setAlertMessage] = useAlert(); + const [errorAlertState, setErrorMessage] = useAlert(); + const [successAlertState, setSuccessMessage] = useAlert(); useEffect(() => { const fetchFoodManufacturers = async () => { @@ -43,12 +44,12 @@ const ApproveFoodManufacturers: React.FC = () => { const data = await ApiClient.getAllPendingFoodManufacturers(); setFoodManufacturers(data); } catch { - setAlertMessage('Error fetching food manufacturers'); + setErrorMessage('Error fetching food manufacturers'); } }; fetchFoodManufacturers(); - }, [setAlertMessage]); + }, [setErrorMessage]); useEffect(() => { setCurrentPage(1); @@ -114,20 +115,28 @@ const ApproveFoodManufacturers: React.FC = () => { ? `${name} - Application Accepted` : `${name} - Application Rejected`; - setAlertMessage(message); + setSuccessMessage(message); setSearchParams({}); } - }, [searchParams, setSearchParams, setAlertMessage]); + }, [searchParams, setSearchParams, setErrorMessage, setSuccessMessage]); return ( Application Review - {alertState && ( + {errorAlertState && ( + )} + {successAlertState && ( + diff --git a/apps/frontend/src/containers/approvePantries.tsx b/apps/frontend/src/containers/approvePantries.tsx index 3d1bdf80e..20d44cd80 100644 --- a/apps/frontend/src/containers/approvePantries.tsx +++ b/apps/frontend/src/containers/approvePantries.tsx @@ -31,7 +31,8 @@ const ApprovePantries: React.FC = () => { const [currentPage, setCurrentPage] = useState(1); const [isFilterOpen, setIsFilterOpen] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); - const [alertState, setAlertMessage] = useAlert(); + const [errorAlertState, setErrorMessage] = useAlert(); + const [successAlertState, setSuccessMessage] = useAlert(); useEffect(() => { const fetchPantries = async () => { @@ -39,12 +40,12 @@ const ApprovePantries: React.FC = () => { const data = await ApiClient.getAllPendingPantries(); setPantries(data); } catch { - setAlertMessage('Error fetching pantries'); + setErrorMessage('Error fetching pantries'); } }; fetchPantries(); - }, [setAlertMessage]); + }, [setErrorMessage]); useEffect(() => { setCurrentPage(1); @@ -105,20 +106,28 @@ const ApprovePantries: React.FC = () => { ? `${name} - Application Accepted` : `${name} - Application Rejected`; - setAlertMessage(message); + setSuccessMessage(message); setSearchParams({}); } - }, [searchParams, setSearchParams, setAlertMessage]); + }, [searchParams, setSearchParams, setErrorMessage, setSuccessMessage]); return ( Application Review - {alertState && ( + {errorAlertState && ( + )} + {successAlertState && ( + diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index 1d68a77a2..41b9d129d 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -22,7 +22,8 @@ import { useAlert } from '../hooks/alert'; const MAX_PER_STATUS = 5; const FoodManufacturerDonationManagement: React.FC = () => { - const [alertState, setAlertMessage] = useAlert(); + const [errorAlertState, setErrorMessage] = useAlert(); + const [successAlertState, setSuccessMessage] = useAlert(); const [isLogDonationOpen, setIsLogDonationOpen] = useState(false); const [manufacturerId, setManufacturerId] = useState(null); const [selectedActionDonation, setSelectedActionDonation] = @@ -83,7 +84,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { }; setCurrentPages(initialPages); } catch (error) { - setAlertMessage('Error fetching donations: ' + error); + setErrorMessage('Error fetching donations: ' + error); } }; @@ -95,7 +96,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { setManufacturerId(fmId); await fetchDonations(fmId); } catch (error) { - setAlertMessage('Error initializing donation management: ' + error); + setErrorMessage('Error initializing donation management: ' + error); } }; init(); @@ -110,10 +111,18 @@ const FoodManufacturerDonationManagement: React.FC = () => { return ( - {alertState && ( + {errorAlertState && ( + )} + {successAlertState && ( + @@ -156,8 +165,8 @@ const FoodManufacturerDonationManagement: React.FC = () => { onSuccess={() => { setSelectedActionDonation(null); if (manufacturerId !== null) fetchDonations(manufacturerId); - setAlertMessage( - 'Your details have been saved. Actions are complete once all shipment and item details are confirmed', + setSuccessMessage( + 'Your details have been saved. Actions are complete once all shipment and item details are confirmed.', ); }} /> diff --git a/apps/frontend/src/containers/pantryOrderManagement.tsx b/apps/frontend/src/containers/pantryOrderManagement.tsx index 547325c1b..283a8586d 100644 --- a/apps/frontend/src/containers/pantryOrderManagement.tsx +++ b/apps/frontend/src/containers/pantryOrderManagement.tsx @@ -52,9 +52,8 @@ const PantryOrderManagement: React.FC = () => { }, ); - const [isAlertError, setIsAlertError] = useState(false); - - const [alertState, setAlertMessage] = useAlert(); + const [errorAlertState, setErrorMessage] = useAlert(); + const [successAlertState, setSuccessMessage] = useAlert(); // State to hold filter state per status type FilterState = { @@ -107,8 +106,7 @@ const PantryOrderManagement: React.FC = () => { }; setCurrentPages(initialPages); } catch (error) { - setIsAlertError(true); - setAlertMessage('Failed to fetch orders'); + setErrorMessage('Failed to fetch orders'); } }; @@ -134,11 +132,19 @@ const PantryOrderManagement: React.FC = () => { Order Management - {alertState && ( + {errorAlertState && ( + + )} + {successAlertState && ( )} @@ -202,12 +208,10 @@ const PantryOrderManagement: React.FC = () => { onClose={() => setSelectedActionOrder(null)} onSuccess={() => { fetchOrders(); - setIsAlertError(false); - setAlertMessage('Delivery Confirmed'); + setSuccessMessage('Delivery Confirmed'); }} onError={() => { - setIsAlertError(true); - setAlertMessage('Delivery could not be confirmed.'); + setErrorMessage('Delivery could not be confirmed.'); }} /> )} diff --git a/apps/frontend/src/containers/volunteerManagement.tsx b/apps/frontend/src/containers/volunteerManagement.tsx index c48c92fd9..20ee2137f 100644 --- a/apps/frontend/src/containers/volunteerManagement.tsx +++ b/apps/frontend/src/containers/volunteerManagement.tsx @@ -25,8 +25,8 @@ const VolunteerManagement: React.FC = () => { const [volunteers, setVolunteers] = useState([]); const [searchName, setSearchName] = useState(''); - const [alertState, setAlertMessage] = useAlert(); - const [submitSuccess, setSubmitSuccess] = useState(false); + const [errorAlertState, setErrorMessage] = useAlert(); + const [successAlertState, setSuccessMessage] = useAlert(); const pageSize = 8; @@ -38,12 +38,12 @@ const VolunteerManagement: React.FC = () => { const allVolunteers = await ApiClient.getVolunteers(); setVolunteers(allVolunteers); } catch { - setAlertMessage('Error fetching volunteers'); + setErrorMessage('Error fetching volunteers'); } }; fetchVolunteers(); - }, [setAlertMessage]); + }, [setErrorMessage]); useEffect(() => { setCurrentPage(1); @@ -70,11 +70,19 @@ const VolunteerManagement: React.FC = () => { Volunteer Management - {alertState && ( + {errorAlertState && ( + )} + {successAlertState && ( + )} @@ -108,12 +116,10 @@ const VolunteerManagement: React.FC = () => { { - setAlertMessage('Volunteer added.'); - setSubmitSuccess(true); + setSuccessMessage('Volunteer added.'); }} onSubmitFail={() => { - setAlertMessage('Volunteer could not be added.'); - setSubmitSuccess(false); + setErrorMessage('Volunteer could not be added.'); }} /> From cc0e6cb882c7f74bd578f335ef7aa22540bd190e Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Fri, 8 May 2026 03:18:36 -0400 Subject: [PATCH 12/12] comments --- .../donationItems.service.spec.ts | 44 +++++++++++++++ .../donationItems/donationItems.service.ts | 1 + apps/backend/src/orders/order.service.spec.ts | 47 ++++++++++++++++ apps/backend/src/orders/order.service.ts | 11 ++++ .../forms/fmCompleteRequiredActionsModal.tsx | 56 ++++++++++++++----- apps/frontend/src/utils/utils.ts | 9 +++ 6 files changed, 154 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/donationItems/donationItems.service.spec.ts b/apps/backend/src/donationItems/donationItems.service.spec.ts index b77a40c36..847041741 100644 --- a/apps/backend/src/donationItems/donationItems.service.spec.ts +++ b/apps/backend/src/donationItems/donationItems.service.spec.ts @@ -214,6 +214,7 @@ describe('DonationItemsService', () => { expect(Number(beans.estimatedValue)).toEqual(2.99); expect(beans.foodType).toEqual(FoodType.DRIED_BEANS); expect(beans.foodRescue).toEqual(false); + expect(beans.detailsConfirmed).toEqual(true); expect(rice.itemId).toBeDefined(); expect(rice.donationId).toEqual(donation.donationId); @@ -224,6 +225,7 @@ describe('DonationItemsService', () => { expect(Number(rice.estimatedValue)).toEqual(4.99); expect(rice.foodType).toEqual(FoodType.GRANOLA); expect(rice.foodRescue).toEqual(true); + expect(rice.detailsConfirmed).toEqual(true); }); it('creates items with optional fields omitted', async () => { @@ -249,6 +251,48 @@ describe('DonationItemsService', () => { expect(result[0].itemId).toBeDefined(); expect(result[0].ozPerItem).toBeNull(); expect(result[0].estimatedValue).toBeNull(); + expect(result[0].detailsConfirmed).toEqual(false); + }); + + it('sets detailsConfirmed to true only when both ozPerItem and estimatedValue are provided', async () => { + const donation = await getSeedDonation(); + const transactionManager = testDataSource.createEntityManager(); + + const mixedItems: CreateDonationItemDto[] = [ + { + itemName: 'Both Fields', + quantity: 4, + ozPerItem: 12, + estimatedValue: 3.5, + foodType: FoodType.DRIED_BEANS, + foodRescue: false, + }, + { + itemName: 'Missing Estimated Value', + quantity: 2, + ozPerItem: 8, + foodType: FoodType.DRIED_BEANS, + foodRescue: false, + }, + { + itemName: 'Missing Oz Per Item', + quantity: 6, + estimatedValue: 1.99, + foodType: FoodType.DRIED_BEANS, + foodRescue: false, + }, + ]; + + const result = await service.createMultiple( + donation, + mixedItems, + transactionManager, + ); + + const byName = Object.fromEntries(result.map((i) => [i.itemName, i])); + expect(byName['Both Fields'].detailsConfirmed).toEqual(true); + expect(byName['Missing Estimated Value'].detailsConfirmed).toEqual(false); + expect(byName['Missing Oz Per Item'].detailsConfirmed).toEqual(false); }); it('rolls back all items when one fails within a transaction', async () => { diff --git a/apps/backend/src/donationItems/donationItems.service.ts b/apps/backend/src/donationItems/donationItems.service.ts index f43ed6a43..168f46048 100644 --- a/apps/backend/src/donationItems/donationItems.service.ts +++ b/apps/backend/src/donationItems/donationItems.service.ts @@ -171,6 +171,7 @@ export class DonationItemsService { estimatedValue: item.estimatedValue, foodType: item.foodType, foodRescue: item.foodRescue, + detailsConfirmed: item.ozPerItem != null && item.estimatedValue != null, }), ); return transactionRepo.save(donationItems); diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 60f8daca6..ea4cd275a 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -1028,6 +1028,53 @@ describe('OrdersService', () => { ); }); + it('does not update orders or call dependent services when given an empty orders array', async () => { + const donationId = await insertMatchedDonation(); + const checkAndFulfillSpy = jest.spyOn( + donationService, + 'checkAndFulfillDonation', + ); + + const beforeOrder = await service.findOne(4); + + await service.bulkUpdateTrackingCostInfo({ donationId, orders: [] }); + + expect(checkAndFulfillSpy).not.toHaveBeenCalled(); + + const afterOrder = await service.findOne(4); + expect(afterOrder.trackingLink).toEqual(beforeOrder.trackingLink); + expect(afterOrder.shippingCost).toEqual(beforeOrder.shippingCost); + expect(afterOrder.status).toEqual(beforeOrder.status); + }); + + it('throws BadRequestException and does not update or call dependent services when duplicate order ids are provided', async () => { + const donationId = await insertMatchedDonation(); + const itemId = await insertDonationItem(donationId); + await insertAllocation(4, itemId); + + const beforeOrder = await service.findOne(4); + + const duplicateEntry1 = { orderId: 4, shippingCost: 100.0 }; + const duplicateEntry2 = { + orderId: 4, + shippingCost: 99.0, + trackingLink: 'https://example.com', + }; + + await expect( + service.bulkUpdateTrackingCostInfo({ + donationId, + orders: [duplicateEntry1, duplicateEntry2], + }), + ).rejects.toThrow( + new BadRequestException('Cannot update duplicate entries for orders'), + ); + + const afterOrder = await service.findOne(4); + expect(afterOrder.trackingLink).toEqual(beforeOrder.trackingLink); + expect(afterOrder.shippingCost).toEqual(beforeOrder.shippingCost); + }); + it('throws NotFoundException when donation does not exist', async () => { const dto: BulkUpdateTrackingCostDto = { donationId: 9999, diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index ac2d12897..6af1eceed 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -443,6 +443,17 @@ export class OrdersService { async bulkUpdateTrackingCostInfo( dto: BulkUpdateTrackingCostDto, ): Promise { + if (dto.orders.length == 0) { + return; + } + + const orders = new Set(dto.orders.map((o) => o.orderId)); + if (orders.size != dto.orders.length) { + throw new BadRequestException( + 'Cannot update duplicate entries for orders', + ); + } + for (const order of dto.orders) { validateId(order.orderId, 'Order'); diff --git a/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx index 70fefac7e..14b45dad8 100644 --- a/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx +++ b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx @@ -24,6 +24,13 @@ import { import { useGroupedItemsByFoodType } from '../../hooks/groupedItemsByFoodType'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../../hooks/alert'; +import { isValidUrl } from '../../utils/utils'; + +// Up to two decimal places, e.g. "0.5", "1", "12.34" — but not "1.234" or "-1" +const POSITIVE_TWO_DECIMAL_REGEX = /^\d+(\.\d{1,2})?$/; + +const isValidShippingCost = (value: string): boolean => + POSITIVE_TWO_DECIMAL_REGEX.test(value) && parseFloat(value) > 0; type Stage = 'shipping' | 'itemDetails'; @@ -109,8 +116,12 @@ const FmCompleteRequiredActionsModal: React.FC< > = ({ donation, isOpen, onClose, onSuccess }) => { const orders = donation.associatedPendingOrders; - // Which stage of the two-step modal the user is currently on - const [stage, setStage] = useState('shipping'); + // Which stage of the two-step modal the user is currently on. If there are no pending + // orders left to update, skip straight to itemDetails so the modal still opens for + // any items that still need their details confirmed. + const [stage, setStage] = useState( + orders.length > 0 ? 'shipping' : 'itemDetails', + ); const [currentPage, setCurrentPage] = useState(1); // Shipping cost and tracking link inputs keyed by orderId, pre-filled from prop and persisted across pagination @@ -173,6 +184,23 @@ const FmCompleteRequiredActionsModal: React.FC< })); }; + const handleContinue = () => { + for (const order of orders) { + const { shippingCost, trackingLink } = orderFormData[order.orderId]; + if (shippingCost !== '' && !isValidShippingCost(shippingCost)) { + setAlertMessage( + 'Shipping cost must be a positive number with up to 2 decimal places.', + ); + return; + } + if (trackingLink.trim() !== '' && !isValidUrl(trackingLink)) { + setAlertMessage('Tracking link must be a valid http or https URL.'); + return; + } + } + setStage('itemDetails'); + }; + const updateItemField = ( itemId: number, field: keyof ItemFormData, @@ -270,8 +298,6 @@ const FmCompleteRequiredActionsModal: React.FC< } }; - if (!currentOrder) return null; - // Shared style props applied to every column header in the item details table const tableHeaderStyles = { borderBottom: '1px solid', @@ -457,7 +483,7 @@ const FmCompleteRequiredActionsModal: React.FC< color="neutral.50" fontWeight={600} size="md" - onClick={() => setStage('itemDetails')} + onClick={handleContinue} > Continue @@ -592,15 +618,17 @@ const FmCompleteRequiredActionsModal: React.FC< - + {orders.length > 0 && ( + + )}