From bbf7c42b42d193fd4d6e5f944be7bab724d180ee Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sun, 26 Apr 2026 17:13:16 -0400 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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; }