diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index 554afe7d6..19cdd053d 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -7,6 +7,7 @@ import { CreateDonationDto } from './dtos/create-donation.dto'; import { CreateDonationItemDto } from '../donationItems/dtos/create-donation-items.dto'; import { DonationStatus, RecurrenceEnum } from './types'; import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; const mockDonationService = mock(); @@ -58,7 +59,6 @@ describe('DonationsController', () => { describe('POST /', () => { it('should call donationService.create and return the created donation', async () => { const createBody: Partial = { - foodManufacturerId: 1, recurrence: RecurrenceEnum.MONTHLY, recurrenceFreq: 3, occurrencesRemaining: 2, @@ -72,6 +72,8 @@ describe('DonationsController', () => { ] as CreateDonationItemDto[], }; + const mockReq = { user: { id: 1 } }; + const createdDonation: Partial = { donationId: 1, ...createBody, @@ -84,11 +86,12 @@ describe('DonationsController', () => { ); const result = await controller.createDonation( + mockReq as AuthenticatedRequest, createBody as CreateDonationDto, ); expect(result).toEqual(createdDonation); - expect(mockDonationService.create).toHaveBeenCalledWith(createBody); + expect(mockDonationService.create).toHaveBeenCalledWith(createBody, 1); }); }); diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index ab493edd5..e9005271b 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -8,6 +8,7 @@ import { ParseArrayPipe, Get, Delete, + Req, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { Donation } from './donations.entity'; @@ -21,6 +22,7 @@ import { Role } from '../users/types'; import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; @Controller('donations') export class DonationsController { @@ -37,7 +39,6 @@ export class DonationsController { schema: { type: 'object', properties: { - foodManufacturerId: { type: 'integer', example: 1 }, recurrence: { type: 'string', enum: Object.values(RecurrenceEnum), @@ -79,11 +80,12 @@ export class DonationsController { }, }, }) + @Roles(Role.FOODMANUFACTURER) async createDonation( - @Body() - body: CreateDonationDto, + @Req() req: AuthenticatedRequest, + @Body() body: CreateDonationDto, ): Promise { - return this.donationService.create(body); + return this.donationService.create(body, req.user.id); } @Patch('/:donationId/fulfill') diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 6fa0bcda3..245b4e877 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -13,12 +13,19 @@ import { DonationItemsService } from '../donationItems/donationItems.service'; import { Allocation } from '../allocations/allocations.entity'; import { DataSource, In } from 'typeorm'; import { FoodType } from '../donationItems/types'; -import { mock } from 'jest-mock-extended'; +import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; +import { UsersService } from '../users/users.service'; import { EmailsService } from '../emails/email.service'; +import { mock } from 'jest-mock-extended'; import { emailTemplates } from '../emails/emailTemplates'; jest.setTimeout(60000); +// findByUserId only touches the FoodManufacturer repo, so UsersService and +// EmailsService are mocked to satisfy FoodManufacturersService's DI. +const mockUsersService = mock(); +const mockEmailsService = mock(); + const TODAY = new Date(); TODAY.setHours(0, 0, 0, 0); const MOCK_MONDAY = new Date(2025, 0, 6); @@ -131,8 +138,6 @@ const TODAYOfWeek = (iso: string): DayOfWeek => { return days[new Date(iso).getDay()]; }; -const mockEmailsService = mock(); - describe('DonationService', () => { let service: DonationService; let donationItemService: DonationItemsService; @@ -151,6 +156,7 @@ describe('DonationService', () => { providers: [ DonationService, DonationItemsService, + FoodManufacturersService, { provide: getRepositoryToken(Allocation), useValue: testDataSource.getRepository(Allocation), @@ -163,6 +169,14 @@ describe('DonationService', () => { provide: getRepositoryToken(FoodManufacturer), useValue: testDataSource.getRepository(FoodManufacturer), }, + { + provide: UsersService, + useValue: mockUsersService, + }, + { + provide: EmailsService, + useValue: mockEmailsService, + }, { provide: getRepositoryToken(DonationItem), useValue: testDataSource.getRepository(DonationItem), @@ -1103,11 +1117,13 @@ describe('DonationService', () => { ]; it('successfully creates a donation with items', async () => { - const donation = await service.create({ - foodManufacturerId: 1, - recurrence: RecurrenceEnum.NONE, - items: validItems, - }); + const donation = await service.create( + { + recurrence: RecurrenceEnum.NONE, + items: validItems, + }, + 3, + ); expect(donation).toBeDefined(); expect(donation.donationId).toBeDefined(); @@ -1139,13 +1155,15 @@ describe('DonationService', () => { const before = new Date(); before.setHours(0, 0, 0, 0); - const donation = await service.create({ - foodManufacturerId: 1, - recurrence: RecurrenceEnum.MONTHLY, - recurrenceFreq: 1, - occurrencesRemaining: 3, - items: validItems, - }); + const donation = await service.create( + { + recurrence: RecurrenceEnum.MONTHLY, + recurrenceFreq: 1, + occurrencesRemaining: 3, + items: validItems, + }, + 3, + ); const rows = await testDataSource.query( `SELECT next_donation_dates, occurrences_remaining, recurrence, recurrence_freq @@ -1174,15 +1192,17 @@ describe('DonationService', () => { expect(actualDate.getDate()).toEqual(expectedDate.getDate()); }); - it('throws when foodManufacturerId does not exist', async () => { + it('throws when user ID is not a food manufacturer', async () => { await expect( - service.create({ - foodManufacturerId: 99999, - recurrence: RecurrenceEnum.NONE, - items: validItems, - }), + service.create( + { + recurrence: RecurrenceEnum.NONE, + items: validItems, + }, + 1, + ), ).rejects.toThrow( - new NotFoundException('Food Manufacturer 99999 not found'), + new NotFoundException('Food Manufacturer for User 1 not found'), ); }); @@ -1190,20 +1210,22 @@ describe('DonationService', () => { let donations = await testDataSource.query(`SELECT * FROM donations`); expect(donations).toHaveLength(4); await expect( - service.create({ - foodManufacturerId: 1, - recurrence: RecurrenceEnum.WEEKLY, - repeatOnDays: { - Sunday: false, - Monday: true, - Tuesday: false, - Wednesday: false, - Thursday: false, - Friday: false, - Saturday: false, + service.create( + { + recurrence: RecurrenceEnum.WEEKLY, + repeatOnDays: { + Sunday: false, + Monday: true, + Tuesday: false, + Wednesday: false, + Thursday: false, + Friday: false, + Saturday: false, + }, + items: validItems, }, - items: validItems, - }), + 3, + ), ).rejects.toThrow( new BadRequestException( 'recurrenceFreq is required for recurring donations', @@ -1219,19 +1241,21 @@ describe('DonationService', () => { expect(donations).toHaveLength(4); await expect( - service.create({ - foodManufacturerId: 1, - recurrence: RecurrenceEnum.NONE, - items: [ - ...validItems, - { - itemName: 'a'.repeat(1000), - quantity: 5, - foodType: FoodType.DAIRY_FREE_ALTERNATIVES, - foodRescue: false, - }, - ], - }), + service.create( + { + recurrence: RecurrenceEnum.NONE, + items: [ + ...validItems, + { + itemName: 'a'.repeat(1000), + quantity: 5, + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + foodRescue: false, + }, + ], + }, + 3, + ), ).rejects.toThrow(); donations = await testDataSource.query(`SELECT * FROM donations`); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 7fe8664b3..bd54f0bd2 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Injectable, - InternalServerErrorException, Logger, NotFoundException, } from '@nestjs/common'; @@ -13,11 +12,11 @@ import { DayOfWeek, DonationStatus, RecurrenceEnum } from './types'; import { OrderStatus } from '../orders/types'; import { calculateNextDonationDate } from './recurrence.utils'; import { CreateDonationDto, RepeatOnDaysDto } from './dtos/create-donation.dto'; -import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; import { DonationItemsService } from '../donationItems/donationItems.service'; import { DonationItem } from '../donationItems/donationItems.entity'; import { Allocation } from '../allocations/allocations.entity'; +import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; import { EmailsService } from '../emails/email.service'; import { emailTemplates } from '../emails/emailTemplates'; @@ -30,9 +29,8 @@ export class DonationService { private allocationRepo: Repository, @InjectRepository(DonationItem) private donationItemsRepo: Repository, - @InjectRepository(FoodManufacturer) - private manufacturerRepo: Repository, private donationItemsService: DonationItemsService, + private foodManufacturersService: FoodManufacturersService, @InjectDataSource() private dataSource: DataSource, private emailsService: EmailsService, ) {} @@ -59,17 +57,13 @@ export class DonationService { }); } - async create(donationData: CreateDonationDto): Promise { - validateId(donationData.foodManufacturerId, 'Food Manufacturer'); - const manufacturer = await this.manufacturerRepo.findOne({ - where: { foodManufacturerId: donationData.foodManufacturerId }, - }); - - if (!manufacturer) { - throw new NotFoundException( - `Food Manufacturer ${donationData.foodManufacturerId} not found`, - ); - } + async create( + donationData: CreateDonationDto, + userId: number, + ): Promise { + const manufacturer = await this.foodManufacturersService.findByUserId( + userId, + ); let nextDonationDates = null; diff --git a/apps/backend/src/donations/dtos/create-donation.dto.ts b/apps/backend/src/donations/dtos/create-donation.dto.ts index 523e6c085..04e7b2b25 100644 --- a/apps/backend/src/donations/dtos/create-donation.dto.ts +++ b/apps/backend/src/donations/dtos/create-donation.dto.ts @@ -65,10 +65,6 @@ export class RepeatOnDaysDto { } export class CreateDonationDto { - @IsInt() - @Min(1) - foodManufacturerId!: number; - @IsNotEmpty() @IsEnum(RecurrenceEnum) recurrence!: RecurrenceEnum; diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 5a2f5e7ad..5d708779d 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -616,6 +616,7 @@ describe('FoodManufacturersService', () => { futureDate1.setMilliseconds(0); futureDate1.setDate(futureDate1.getDate() + 30); clampDay(futureDate1); + const futureDate2 = new Date(); futureDate2.setMilliseconds(0); futureDate2.setDate(futureDate2.getDate() + 60); diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index c81a6664b..f504fe40d 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -349,7 +349,6 @@ describe('UsersService', () => { const now = new Date(); const createDonationBody: Partial = { - foodManufacturerId: 1, recurrence: RecurrenceEnum.MONTHLY, recurrenceFreq: 3, occurrencesRemaining: 2, @@ -363,7 +362,7 @@ describe('UsersService', () => { ], }; - await donationService.create(createDonationBody as CreateDonationDto); + await donationService.create(createDonationBody as CreateDonationDto, 3); // updating existing request to have a current month requested at date const existingRequest = await foodRequestService.findOne(1); diff --git a/apps/frontend/src/components/floatingAlert.tsx b/apps/frontend/src/components/floatingAlert.tsx index 8e45294da..52e0c22bb 100644 --- a/apps/frontend/src/components/floatingAlert.tsx +++ b/apps/frontend/src/components/floatingAlert.tsx @@ -1,9 +1,10 @@ import { Alert } from '@chakra-ui/react'; import { useEffect, useState } from 'react'; +import { AlertStatus } from '../types/types'; type FloatingAlertProps = { message?: string | null; - status?: 'info' | 'error'; + status?: AlertStatus; timeout?: number; }; @@ -35,7 +36,7 @@ export function FloatingAlert({ return ( Promise; @@ -50,8 +51,7 @@ const RequestManagement: React.FC = ({ const [selectedCreateOrderRequest, setSelectedCreateOrderRequest] = useState(null); - const [errorAlertState, setErrorMessage] = useAlert(); - const [successAlertState, setSuccessMessage] = useAlert(); + const [alertState, setAlertMessage] = useAlert(); const navigate = useNavigate(); const location = useLocation(); @@ -61,9 +61,9 @@ const RequestManagement: React.FC = ({ const data = await fetchData(); setRequests(data); } catch { - setErrorMessage('Error fetching requests'); + setAlertMessage('Error fetching requests', AlertStatus.ERROR); } - }, [fetchData, setErrorMessage]); + }, [fetchData, setAlertMessage]); useEffect(() => { loadRequests(); @@ -160,19 +160,11 @@ const RequestManagement: React.FC = ({ Food Request Management - {errorAlertState && ( + {alertState && ( - )} - {successAlertState && ( - )} @@ -424,7 +416,7 @@ const RequestManagement: React.FC = ({ isOpen={true} onClose={clearCloseRequest} onSuccess={() => { - setSuccessMessage('Request Closed'); + setAlertMessage('Request Closed', AlertStatus.INFO); loadRequests(); }} /> @@ -436,7 +428,7 @@ const RequestManagement: React.FC = ({ isOpen={true} onClose={clearCreateOrder} onSuccess={() => { - setSuccessMessage('Order Created'); + setAlertMessage('Order Created', AlertStatus.INFO); loadRequests(); }} /> diff --git a/apps/frontend/src/components/forms/addNewVolunteerModal.tsx b/apps/frontend/src/components/forms/addNewVolunteerModal.tsx index 5a85fa3be..e0dcafd34 100644 --- a/apps/frontend/src/components/forms/addNewVolunteerModal.tsx +++ b/apps/frontend/src/components/forms/addNewVolunteerModal.tsx @@ -9,11 +9,13 @@ import { Box, } from '@chakra-ui/react'; import { useState } from 'react'; -import { Role, UserDto } from '../../types/types'; +import { AlertStatus, Role, UserDto } from '../../types/types'; import ApiClient from '@api/apiClient'; import { USPhoneInput } from './usPhoneInput'; import { PlusIcon } from 'lucide-react'; import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup'; +import { useAlert } from '../../hooks/alert'; +import { FloatingAlert } from '@components/floatingAlert'; interface NewVolunteerModalProps { onSubmitSuccess?: () => void; @@ -32,16 +34,14 @@ const NewVolunteerModal: React.FC = ({ const [isOpen, setIsOpen] = useState(false); - const [error, setError] = useState(''); + const [alertState, setAlertMessage] = useAlert(); const handleSubmit = async () => { if (!firstName || !lastName || !email || !phone || phone === '+1') { - setError('Please fill in all fields. *'); + setAlertMessage('Please fill in all fields. *', AlertStatus.ERROR); return; } - setError(''); - const newVolunteer: UserDto = { firstName, lastName, @@ -80,9 +80,12 @@ const NewVolunteerModal: React.FC = ({ } if (hasEmailError) { - setError('Please specify a valid email. *'); + setAlertMessage('Please specify a valid email. *', AlertStatus.ERROR); } else if (hasPhoneError) { - setError('Please specify a valid phone number. *'); + setAlertMessage( + 'Please specify a valid phone number. *', + AlertStatus.ERROR, + ); } else { if (onSubmitFail) onSubmitFail(); handleClear(); @@ -95,7 +98,6 @@ const NewVolunteerModal: React.FC = ({ setLastName(''); setEmail(''); setPhone(''); - setError(''); setIsOpen(false); }; @@ -200,16 +202,13 @@ const NewVolunteerModal: React.FC = ({ }} /> - {error && ( - - {error} - + {alertState && ( + )} {manufacturerId !== null && ( { const navigate = useNavigate(); const [application, setApplication] = useState(null); const [loading, setLoading] = useState(true); - const [error, setError] = useState<{ - type: 'network' | 'not_found' | 'invalid' | null; - message: string; - }>({ - type: null, - message: '', - }); const [alertState, setAlertMessage] = useAlert(); const [showApproveModal, setShowApproveModal] = useState(false); const [showDenyModal, setShowDenyModal] = useState(false); @@ -127,31 +124,25 @@ const FoodManufacturerApplicationDetails: React.FC = () => { try { setLoading(true); if (!applicationId) { - setError({ type: 'invalid', message: 'Application ID not provided.' }); + setAlertMessage('Application ID not provided.', AlertStatus.ERROR); return; } else if (isNaN(parseInt(applicationId, 10))) { - setError({ - type: 'invalid', - message: 'Application ID is not a number.', - }); + setAlertMessage('Application ID is not a number.', AlertStatus.ERROR); } const data = await ApiClient.getFoodManufacturer( parseInt(applicationId, 10), ); if (!data) { - setError({ - type: 'not_found', - message: 'Application not found.', - }); + setAlertMessage('Application not found.', AlertStatus.ERROR); } setApplication(data); } catch (err: unknown) { if (err instanceof AxiosError) { if (err.response?.status !== 404 && err.response?.status !== 400) { - setError({ - type: 'network', - message: 'Could not load application details.', - }); + setAlertMessage( + 'Could not load application details.', + AlertStatus.ERROR, + ); } } } finally { @@ -178,7 +169,7 @@ const FoodManufacturerApplicationDetails: React.FC = () => { application.foodManufacturerName, ); } catch { - setAlertMessage('Error approving application'); + setAlertMessage('Error approving application', AlertStatus.ERROR); } } }; @@ -198,7 +189,7 @@ const FoodManufacturerApplicationDetails: React.FC = () => { application.foodManufacturerName, ); } catch { - setAlertMessage('Error denying application'); + setAlertMessage('Error denying application', AlertStatus.ERROR); } } }; @@ -213,25 +204,11 @@ const FoodManufacturerApplicationDetails: React.FC = () => { ); } - if (error.message || !application) { - const getIcon = () => { - switch (error.type) { - case 'network': - return ; - case 'not_found': - return ; - default: - return ; - } - }; - + if (alertState?.message || !application) { return ( } + title={alertState?.message ?? 'Application not found.'} /> ); } @@ -254,7 +231,7 @@ const FoodManufacturerApplicationDetails: React.FC = () => { )} diff --git a/apps/frontend/src/containers/foodManufacturerDashboard.tsx b/apps/frontend/src/containers/foodManufacturerDashboard.tsx index 93dd02e0d..4a885dbfc 100644 --- a/apps/frontend/src/containers/foodManufacturerDashboard.tsx +++ b/apps/frontend/src/containers/foodManufacturerDashboard.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { Box, Heading, Text } from '@chakra-ui/react'; import DashboardCard, { DashboardCardType } from '@components/dashboardCard'; import { + AlertStatus, Donation, DonationDetails, DonationReminderDto, @@ -16,7 +17,7 @@ import { ROUTES } from '../routes'; const FoodManufacturerDashboard: React.FC = () => { const navigate = useNavigate(); - const [errorAlertState, setErrorMessage] = useAlert(); + const [alertState, setAlertMessage] = useAlert(); const [foodManufacturer, setFoodManufacturer] = useState(null); const [upcomingReminders, setUpcomingReminders] = useState< @@ -32,7 +33,10 @@ const FoodManufacturerDashboard: React.FC = () => { const fm = await ApiClient.getFoodManufacturer(fmId); setFoodManufacturer(fm); } catch { - setErrorMessage('Error fetching your manufacturer profile.'); + setAlertMessage( + 'Error fetching your manufacturer profile.', + AlertStatus.ERROR, + ); return; } @@ -45,7 +49,10 @@ const FoodManufacturerDashboard: React.FC = () => { if (reminders.status === 'fulfilled') { setUpcomingReminders(reminders.value); } else { - setErrorMessage('Error fetching upcoming donations.'); + setAlertMessage( + 'Error fetching upcoming donations.', + AlertStatus.ERROR, + ); } // If donations is successfully retrieved from API with the Promise.allSettled @@ -60,19 +67,19 @@ const FoodManufacturerDashboard: React.FC = () => { .slice(0, 2); setRecentDonations(sorted); } else { - setErrorMessage('Error fetching recent donations.'); + setAlertMessage('Error fetching recent donations.', AlertStatus.ERROR); } }; fetchFmData(); - }, [setErrorMessage]); + }, [setAlertMessage]); return ( - {errorAlertState && ( + {alertState?.status === AlertStatus.ERROR && ( )} diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index 8005e28eb..cf1b596f0 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -13,26 +13,25 @@ import { import { ChevronRight, ChevronLeft, Mail, CircleCheck } from 'lucide-react'; import { capitalize, formatDate, DONATION_STATUS_COLORS } from '@utils/utils'; import ApiClient from '@api/apiClient'; -import { DonationDetails, DonationStatus } from '../types/types'; +import { AlertStatus, DonationDetails, DonationStatus } from '../types/types'; +import DonationDetailsModal from '@components/forms/donationDetailsModal'; import NewDonationFormModal from '@components/forms/newDonationFormModal'; import ResubmitDonationModal from '@components/forms/resubmitDonationModal'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { ROUTES } from '../routes'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; -import DonationDetailsModal from '@components/forms/donationDetailsModal'; import FmCompleteRequiredActionsModal from '@components/forms/fmCompleteRequiredActionsModal'; const MAX_PER_STATUS = 5; const FoodManufacturerDonationManagement: React.FC = () => { + const [alertState, setAlertMessage] = useAlert(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const resubmitDonationId: string | null = searchParams.get('resubmitDonationId'); const [isResubmitOpen, setIsResubmitOpen] = useState(false); - const [errorAlertState, setErrorMessage] = useAlert(); - const [successAlertState, setSuccessMessage] = useAlert(); const [isLogDonationOpen, setIsLogDonationOpen] = useState(false); const [manufacturerId, setManufacturerId] = useState(null); const [selectedActionDonation, setSelectedActionDonation] = @@ -107,13 +106,18 @@ const FoodManufacturerDonationManagement: React.FC = () => { setCurrentPages(initialPages); + // On page load, get the food manufacturer id and all appropriate donations return grouped; - } catch (error) { - setErrorMessage('Error fetching donations'); - return; + } catch { + setAlertMessage('Error fetching donations', AlertStatus.ERROR); } }; + const handleLogNewDonationSuccess = () => { + setAlertMessage('Successfully logged new donation', AlertStatus.INFO); + if (manufacturerId !== null) fetchDonations(manufacturerId); + }; + const openResubmitFromQueryParam = ( grouped: Record, ) => { @@ -138,7 +142,10 @@ const FoodManufacturerDonationManagement: React.FC = () => { const grouped = await fetchDonations(fmId); if (grouped) openResubmitFromQueryParam(grouped); } catch { - setErrorMessage('Error initializing donation management'); + setAlertMessage( + 'Error initializing donation management', + AlertStatus.ERROR, + ); } }; init(); @@ -150,7 +157,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { const id = Number(donationIdParam); setSelectedDonationId(id); - }, [searchParams, setErrorMessage]); + }, [searchParams, setAlertMessage]); const handleResubmitClose = () => { setIsResubmitOpen(false); @@ -168,19 +175,11 @@ const FoodManufacturerDonationManagement: React.FC = () => { return ( - {errorAlertState && ( + {alertState && ( - )} - {successAlertState && ( - )} @@ -223,8 +222,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { {manufacturerId !== null && ( fetchDonations(manufacturerId)} + onDonationSuccess={handleLogNewDonationSuccess} isOpen={isLogDonationOpen} onClose={() => setIsLogDonationOpen(false)} /> @@ -254,8 +252,9 @@ const FoodManufacturerDonationManagement: React.FC = () => { onSuccess={() => { setSelectedActionDonation(null); if (manufacturerId !== null) fetchDonations(manufacturerId); - setSuccessMessage( + setAlertMessage( 'Your details have been saved. Actions are complete once all shipment and item details are confirmed.', + AlertStatus.INFO, ); }} /> diff --git a/apps/frontend/src/containers/formRequests.tsx b/apps/frontend/src/containers/formRequests.tsx index 40a7cfb48..e5f05b47b 100644 --- a/apps/frontend/src/containers/formRequests.tsx +++ b/apps/frontend/src/containers/formRequests.tsx @@ -23,6 +23,7 @@ import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; import { useSearchParams, useNavigate } from 'react-router-dom'; import { ROUTES } from '../routes'; +import { AlertStatus } from '../types/types'; const FormRequests: React.FC = () => { const [currentPage, setCurrentPage] = useState(1); @@ -58,10 +59,13 @@ const FormRequests: React.FC = () => { setPreviousRequest(sortedData[0]); } } catch { - setAlertMessage('Error fetching requests'); + setAlertMessage('Error fetching requests', AlertStatus.ERROR); } } else { - setAlertMessage('No pantry associated with this account.'); + setAlertMessage( + 'No pantry associated with this account.', + AlertStatus.ERROR, + ); } }, [setAlertMessage]); @@ -101,7 +105,7 @@ const FormRequests: React.FC = () => { )} diff --git a/apps/frontend/src/containers/loginPage.tsx b/apps/frontend/src/containers/loginPage.tsx index d0933c365..18262c54f 100644 --- a/apps/frontend/src/containers/loginPage.tsx +++ b/apps/frontend/src/containers/loginPage.tsx @@ -19,6 +19,7 @@ import { Eye, EyeOff } from 'lucide-react'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; import AuthHeader from '@components/AuthHeader'; +import { AlertStatus } from '../types/types'; type Step = 'login' | 'new-password'; @@ -78,7 +79,10 @@ const LoginPage: React.FC = () => { error.name === 'NotAuthorizedException' || error.name === 'UserNotFoundException' ) { - setAlertMessage('Incorrect email or password. Please try again.'); + setAlertMessage( + 'Incorrect email or password. Please try again.', + AlertStatus.ERROR, + ); return; } } @@ -86,6 +90,7 @@ const LoginPage: React.FC = () => { navigator.onLine ? 'Login failed. The server may be unavailable. Please try again later.' : 'No internet connection. Please check your network and try again.', + AlertStatus.ERROR, ); } }; @@ -93,11 +98,14 @@ const LoginPage: React.FC = () => { // Sets the new password for the first time const handleSetNewPassword = async () => { if (newPassword !== confirmNewPassword) { - setAlertMessage('Passwords need to match'); + setAlertMessage('Passwords need to match', AlertStatus.ERROR); return; } if (newPassword.length < 8) { - setAlertMessage('Password needs to be at least 8 characters'); + setAlertMessage( + 'Password needs to be at least 8 characters', + AlertStatus.ERROR, + ); return; } @@ -107,7 +115,7 @@ const LoginPage: React.FC = () => { await fetchAuthSession({ forceRefresh: true }); navigate(from, { replace: true }); } catch { - setAlertMessage('Failed to set new password'); + setAlertMessage('Failed to set new password', AlertStatus.ERROR); } }; @@ -141,7 +149,7 @@ const LoginPage: React.FC = () => { )} diff --git a/apps/frontend/src/containers/pantryApplicationDetails.tsx b/apps/frontend/src/containers/pantryApplicationDetails.tsx index 5838b7336..7cbff9d58 100644 --- a/apps/frontend/src/containers/pantryApplicationDetails.tsx +++ b/apps/frontend/src/containers/pantryApplicationDetails.tsx @@ -12,10 +12,10 @@ import { Spinner, } from '@chakra-ui/react'; import ApiClient from '@api/apiClient'; -import { ApplicationStatus, PantryWithUser } from '../types/types'; +import { AlertStatus, ApplicationStatus, PantryWithUser } from '../types/types'; import { formatDate, formatPhone } from '@utils/utils'; import { TagGroup } from '@components/forms/tagGroup'; -import { FileX, TriangleAlert, WifiOff } from 'lucide-react'; +import { TriangleAlert } from 'lucide-react'; import { AxiosError } from 'axios'; import { FloatingAlert } from '@components/floatingAlert'; import ConfirmPantryDecisionModal from '@components/forms/confirmPantryDecisionModal'; @@ -96,13 +96,6 @@ const PantryApplicationDetails: React.FC = () => { const navigate = useNavigate(); const [application, setApplication] = useState(null); const [loading, setLoading] = useState(true); - const [error, setError] = useState<{ - type: 'network' | 'not_found' | 'invalid' | null; - message: string; - }>({ - type: null, - message: '', - }); const [alertState, setAlertMessage] = useAlert(); const [showApproveModal, setShowApproveModal] = useState(false); const [showDenyModal, setShowDenyModal] = useState(false); @@ -133,29 +126,23 @@ const PantryApplicationDetails: React.FC = () => { try { setLoading(true); if (!id) { - setError({ type: 'invalid', message: 'Application ID not provided.' }); + setAlertMessage('Application ID not provided.', AlertStatus.ERROR); return; } else if (isNaN(parseInt(id, 10))) { - setError({ - type: 'invalid', - message: 'Application ID is not a number.', - }); + setAlertMessage('Application ID is not a number.', AlertStatus.ERROR); } const data = await ApiClient.getPantry(parseInt(id, 10)); if (!data) { - setError({ - type: 'not_found', - message: 'Application not found.', - }); + setAlertMessage('Application not found.', AlertStatus.ERROR); } setApplication(data); } catch (err: unknown) { if (err instanceof AxiosError) { if (err.response?.status !== 404 && err.response?.status !== 400) { - setError({ - type: 'network', - message: 'Could not load application details.', - }); + setAlertMessage( + 'Could not load application details.', + AlertStatus.ERROR, + ); } } } finally { @@ -179,7 +166,7 @@ const PantryApplicationDetails: React.FC = () => { application.pantryName, ); } catch { - setAlertMessage('Error approving application'); + setAlertMessage('Error approving application', AlertStatus.ERROR); } } }; @@ -196,7 +183,7 @@ const PantryApplicationDetails: React.FC = () => { application.pantryName, ); } catch { - setAlertMessage('Error denying application'); + setAlertMessage('Error denying application', AlertStatus.ERROR); } } }; @@ -211,25 +198,11 @@ const PantryApplicationDetails: React.FC = () => { ); } - if (error.message || !application) { - const getIcon = () => { - switch (error.type) { - case 'network': - return ; - case 'not_found': - return ; - default: - return ; - } - }; - + if (alertState?.message || !application) { return ( } + title={alertState?.message ?? 'Application not found.'} /> ); } @@ -247,7 +220,7 @@ const PantryApplicationDetails: React.FC = () => { )} diff --git a/apps/frontend/src/containers/pantryDashboard.tsx b/apps/frontend/src/containers/pantryDashboard.tsx index 182c9a9ef..e366725fa 100644 --- a/apps/frontend/src/containers/pantryDashboard.tsx +++ b/apps/frontend/src/containers/pantryDashboard.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { Box, Heading, Text } from '@chakra-ui/react'; import DashboardCard, { ORDER_STATUS_BADGE } from '@components/dashboardCard'; import { + AlertStatus, FoodRequestSummaryDto, OrderSummary, PantryWithUser, @@ -31,7 +32,7 @@ const PantryDashboard: React.FC = () => { const pantryData = await ApiClient.getPantry(pantryId); setPantry(pantryData); } catch { - setAlertMessage('Error fetching pantry information'); + setAlertMessage('Error fetching pantry information', AlertStatus.ERROR); return; } @@ -44,7 +45,10 @@ const PantryDashboard: React.FC = () => { ); setRecentFoodRequests(sortedFoodRequests.slice(0, 2)); } catch { - setAlertMessage('Error fetching pantry food requests'); + setAlertMessage( + 'Error fetching pantry food requests', + AlertStatus.ERROR, + ); } try { @@ -55,7 +59,7 @@ const PantryDashboard: React.FC = () => { ); setRecentOrders(sortedOrders.slice(0, 4)); } catch { - setAlertMessage('Error fetching orders'); + setAlertMessage('Error fetching orders', AlertStatus.ERROR); } }; fetchDashboardData(); @@ -69,7 +73,7 @@ const PantryDashboard: React.FC = () => { )} diff --git a/apps/frontend/src/containers/pantryOrderManagement.tsx b/apps/frontend/src/containers/pantryOrderManagement.tsx index 5ee5339c2..1d1cf4e02 100644 --- a/apps/frontend/src/containers/pantryOrderManagement.tsx +++ b/apps/frontend/src/containers/pantryOrderManagement.tsx @@ -24,7 +24,7 @@ import { USER_ICON_COLORS, } from '@utils/utils'; import ApiClient from '@api/apiClient'; -import { OrderStatus, OrderSummary } from '../types/types'; +import { AlertStatus, OrderStatus, OrderSummary } from '../types/types'; import OrderReceivedActionModal from '@components/forms/orderReceivedActionModal'; import OrderDetailsModal from '@components/forms/orderDetailsModal'; import { FloatingAlert } from '@components/floatingAlert'; @@ -62,8 +62,7 @@ const PantryOrderManagement: React.FC = () => { const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const [errorAlertState, setErrorMessage] = useAlert(); - const [successAlertState, setSuccessMessage] = useAlert(); + const [alertState, setAlertMessage] = useAlert(); // State to hold filter state per status type FilterState = { @@ -116,9 +115,9 @@ const PantryOrderManagement: React.FC = () => { }; setCurrentPages(initialPages); } catch { - setErrorMessage('Failed to fetch orders'); + setAlertMessage('Failed to fetch orders', AlertStatus.ERROR); } - }, [setErrorMessage]); + }, [setAlertMessage]); useEffect(() => { fetchOrders(); @@ -170,19 +169,11 @@ const PantryOrderManagement: React.FC = () => { Order Management - {errorAlertState && ( + {alertState && ( - )} - {successAlertState && ( - )} @@ -249,10 +240,13 @@ const PantryOrderManagement: React.FC = () => { onClose={() => setSelectedActionOrder(null)} onSuccess={() => { fetchOrders(); - setSuccessMessage('Delivery Confirmed'); + setAlertMessage('Delivery Confirmed', AlertStatus.INFO); }} onError={() => { - setErrorMessage('Delivery could not be confirmed.'); + setAlertMessage( + 'Delivery could not be confirmed.', + AlertStatus.ERROR, + ); }} /> )} diff --git a/apps/frontend/src/containers/profilePage.tsx b/apps/frontend/src/containers/profilePage.tsx index d8d5b6fad..72b221e51 100644 --- a/apps/frontend/src/containers/profilePage.tsx +++ b/apps/frontend/src/containers/profilePage.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { Box, Center, Heading, Spinner, Text } from '@chakra-ui/react'; import ApiClient from '../api/apiClient'; -import { Role, UpdateProfileFields, User } from '../types/types'; +import { AlertStatus, Role, UpdateProfileFields, User } from '../types/types'; import ProfileLeftPanel from '@components/forms/profileLeftPanel'; import ProfileAccountInfo from '@components/forms/profileAccountInfo'; import { getInitials } from '@utils/utils'; @@ -36,7 +36,7 @@ const ProfilePage: React.FC = () => { const pantry = await ApiClient.getPantry(pantryId); setOrgName(pantry.pantryName); } catch { - setAlertMessage('Failed to fetch pantry data.'); + setAlertMessage('Failed to fetch pantry data.', AlertStatus.ERROR); } } else if (user.role === Role.FOODMANUFACTURER) { try { @@ -45,11 +45,17 @@ const ProfilePage: React.FC = () => { const fm = await ApiClient.getFoodManufacturer(fmId); setOrgName(fm.foodManufacturerName); } catch { - setAlertMessage('Failed to fetch food manufacturer data.'); + setAlertMessage( + 'Failed to fetch food manufacturer data.', + AlertStatus.ERROR, + ); } } } catch { - setAlertMessage('Authentication error. Please log in and try again.'); + setAlertMessage( + 'Authentication error. Please log in and try again.', + AlertStatus.ERROR, + ); } finally { setIsLoading(false); } @@ -59,7 +65,7 @@ const ProfilePage: React.FC = () => { const handleSave = async (fields: UpdateProfileFields): Promise => { if (!profile) { - setAlertMessage('Profile not found.'); + setAlertMessage('Profile not found.', AlertStatus.ERROR); return false; } @@ -71,14 +77,18 @@ const ProfilePage: React.FC = () => { if (axios.isAxiosError(error)) { const status = error.response?.status; if (status === 400 || status === 404) { - setAlertMessage(error.response?.data?.message); + setAlertMessage(error.response?.data?.message, AlertStatus.ERROR); } else { setAlertMessage( 'Profile unable to be edited. Please try again later.', + AlertStatus.ERROR, ); } } else { - setAlertMessage('An unexpected error occurred. Please try again.'); + setAlertMessage( + 'An unexpected error occurred. Please try again.', + AlertStatus.ERROR, + ); } return false; } @@ -110,7 +120,7 @@ const ProfilePage: React.FC = () => { )} diff --git a/apps/frontend/src/containers/volunteerAssignedPantries.tsx b/apps/frontend/src/containers/volunteerAssignedPantries.tsx index 27ec4da93..6cb9673bd 100644 --- a/apps/frontend/src/containers/volunteerAssignedPantries.tsx +++ b/apps/frontend/src/containers/volunteerAssignedPantries.tsx @@ -12,7 +12,7 @@ import { Input, } from '@chakra-ui/react'; import ApiClient from '@api/apiClient'; -import { Pantry, User } from 'types/types'; +import { AlertStatus, Pantry, User } from '../types/types'; import { RefrigeratedDonation } from '../types/pantryEnums'; import { FloatingAlert } from '@components/floatingAlert'; import { useNavigate } from 'react-router-dom'; @@ -38,7 +38,10 @@ const AssignedPantries: React.FC = () => { user = await ApiClient.getMe(); userId = user.id; } catch { - setAlertMessage('Authentication error. Please log in and try again.'); + setAlertMessage( + 'Authentication error. Please log in and try again.', + AlertStatus.ERROR, + ); setIsLoading(false); return; } @@ -47,7 +50,7 @@ const AssignedPantries: React.FC = () => { const data = await ApiClient.getVolunteerPantries(userId); setPantries(data); } catch { - setAlertMessage('Error fetching assigned pantries'); + setAlertMessage('Error fetching assigned pantries', AlertStatus.ERROR); } finally { setIsLoading(false); } @@ -119,7 +122,7 @@ const AssignedPantries: React.FC = () => { )} diff --git a/apps/frontend/src/containers/volunteerDashboard.tsx b/apps/frontend/src/containers/volunteerDashboard.tsx index c33843670..5e210c6f3 100644 --- a/apps/frontend/src/containers/volunteerDashboard.tsx +++ b/apps/frontend/src/containers/volunteerDashboard.tsx @@ -1,7 +1,12 @@ import React, { useEffect, useState } from 'react'; import { Box, Heading, Text } from '@chakra-ui/react'; import DashboardCard, { ORDER_STATUS_BADGE } from '@components/dashboardCard'; -import { FoodRequestSummaryDto, User, VolunteerOrder } from '../types/types'; +import { + AlertStatus, + FoodRequestSummaryDto, + User, + VolunteerOrder, +} from '../types/types'; import { DashboardCardType } from '@components/dashboardCard'; import ApiClient from '@api/apiClient'; import { useAlert } from '../hooks/alert'; @@ -25,7 +30,7 @@ const VolunteerDashboard: React.FC = () => { const currentUser = await ApiClient.getMe(); setUser(currentUser); } catch { - setAlertMessage('Error fetching user information'); + setAlertMessage('Error fetching user information', AlertStatus.ERROR); return; } @@ -38,14 +43,14 @@ const VolunteerDashboard: React.FC = () => { ); setRecentFoodRequests(sorted.slice(0, 2)); } catch { - setAlertMessage('Error fetching food requests'); + setAlertMessage('Error fetching food requests', AlertStatus.ERROR); } try { const orders = await ApiClient.getVolunteerRecentOrders(); setRecentOrders(orders); } catch { - setAlertMessage('Error fetching orders'); + setAlertMessage('Error fetching orders', AlertStatus.ERROR); } }; fetchDashboardData(); @@ -59,7 +64,7 @@ const VolunteerDashboard: React.FC = () => { )} diff --git a/apps/frontend/src/containers/volunteerManagement.tsx b/apps/frontend/src/containers/volunteerManagement.tsx index efe5caafa..197a50f1e 100644 --- a/apps/frontend/src/containers/volunteerManagement.tsx +++ b/apps/frontend/src/containers/volunteerManagement.tsx @@ -15,7 +15,7 @@ import { Link, } from '@chakra-ui/react'; import { SearchIcon, ChevronRight, ChevronLeft } from 'lucide-react'; -import { User } from '../types/types'; +import { AlertStatus, User } from '../types/types'; import ApiClient from '@api/apiClient'; import NewVolunteerModal from '@components/forms/addNewVolunteerModal'; import { FloatingAlert } from '@components/floatingAlert'; @@ -28,8 +28,7 @@ const VolunteerManagement: React.FC = () => { const [volunteers, setVolunteers] = useState([]); const [searchName, setSearchName] = useState(''); - const [errorAlertState, setErrorMessage] = useAlert(); - const [successAlertState, setSuccessMessage] = useAlert(); + const [alertState, setAlertMessage] = useAlert(); const pageSize = 8; @@ -39,12 +38,12 @@ const VolunteerManagement: React.FC = () => { const allVolunteers = await ApiClient.getVolunteers(); setVolunteers(allVolunteers); } catch { - setErrorMessage('Error fetching volunteers'); + setAlertMessage('Error fetching volunteers', AlertStatus.ERROR); } }; fetchVolunteers(); - }, [setErrorMessage]); + }, [setAlertMessage]); useEffect(() => { setCurrentPage(1); @@ -71,19 +70,11 @@ const VolunteerManagement: React.FC = () => { Volunteer Management - {errorAlertState && ( + {alertState && ( - )} - {successAlertState && ( - )} @@ -122,10 +113,13 @@ const VolunteerManagement: React.FC = () => { { - setSuccessMessage('Volunteer added.'); + setAlertMessage('Volunteer added.', AlertStatus.INFO); }} onSubmitFail={() => { - setErrorMessage('Volunteer could not be added.'); + setAlertMessage( + 'Volunteer could not be added.', + AlertStatus.ERROR, + ); }} /> diff --git a/apps/frontend/src/containers/volunteerOrderManagement.tsx b/apps/frontend/src/containers/volunteerOrderManagement.tsx index 39b0db4c9..bc4787d63 100644 --- a/apps/frontend/src/containers/volunteerOrderManagement.tsx +++ b/apps/frontend/src/containers/volunteerOrderManagement.tsx @@ -35,6 +35,7 @@ import { VolunteerOrder, VolunteerAction, User, + AlertStatus, } from '../types/types'; import OrderDetailsModal from '@components/forms/orderDetailsModal'; import CompleteRequiredActionsModal from '@components/forms/completeRequiredActionsModal'; @@ -119,7 +120,10 @@ const VolunteerOrderManagement: React.FC = () => { userId = user.id; setCurrentUser(user); } catch { - setAlertMessage('Authentication error. Please log in and try again.'); + setAlertMessage( + 'Authentication error. Please log in and try again.', + AlertStatus.ERROR, + ); setIsLoading(false); return; } @@ -156,7 +160,7 @@ const VolunteerOrderManagement: React.FC = () => { }; setCurrentPages(initialPages); } catch { - setAlertMessage('Error fetching assigned orders'); + setAlertMessage('Error fetching assigned orders', AlertStatus.ERROR); } finally { setIsLoading(false); } @@ -224,7 +228,7 @@ const VolunteerOrderManagement: React.FC = () => { }, })); } else { - setAlertMessage('Selected pantry has no orders'); + setAlertMessage('Selected pantry has no orders', AlertStatus.ERROR); navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); } }, [searchParams, statusOrders, navigate, setAlertMessage]); @@ -286,7 +290,7 @@ const VolunteerOrderManagement: React.FC = () => { )} diff --git a/apps/frontend/src/hooks/alert.ts b/apps/frontend/src/hooks/alert.ts index 0a2c609b3..a89990ce4 100644 --- a/apps/frontend/src/hooks/alert.ts +++ b/apps/frontend/src/hooks/alert.ts @@ -1,17 +1,25 @@ import { useCallback, useRef, useState } from 'react'; +import { AlertStatus } from '../types/types'; export interface AlertState { message: string; + status: AlertStatus; id: number; } -export function useAlert(): [AlertState | null, (message: string) => void] { +export function useAlert(): [ + AlertState | null, + (message: string, status: AlertStatus) => void, +] { const [alertState, setAlertState] = useState(null); const idRef = useRef(0); - const setAlertMessage = useCallback((message: string) => { - setAlertState({ message, id: idRef.current++ }); - }, []); + const setAlertMessage = useCallback( + (message: string, status: AlertStatus) => { + setAlertState({ message, status, id: idRef.current++ }); + }, + [], + ); return [alertState, setAlertMessage]; } diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index d42b0b539..fe4f111db 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -471,7 +471,6 @@ export interface CreateFoodRequestBody { } export interface CreateDonationDto { - foodManufacturerId: number; recurrenceFreq?: number; recurrence: RecurrenceEnum; repeatOnDays?: RepeatOnState; @@ -611,3 +610,8 @@ export interface UpdateDonationItemDetailsDto { estimatedValue?: number; foodRescue?: boolean; } + +export enum AlertStatus { + INFO = 'info', + ERROR = 'error', +}