diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index 20400d45a..1b8a13f15 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -9,6 +9,7 @@ import { DonationStatus, RecurrenceEnum } from './types'; import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; import { FoodType } from '../donationItems/types'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; const mockDonationService = mock(); @@ -86,7 +87,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, @@ -100,6 +100,8 @@ describe('DonationsController', () => { ] as CreateDonationItemDto[], }; + const mockReq = { user: { id: 1 } }; + const createdDonation: Partial = { donationId: 1, ...createBody, @@ -112,11 +114,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 19657b089..f08daf702 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -9,6 +9,7 @@ import { ParseArrayPipe, Put, Delete, + Req, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { Donation } from './donations.entity'; @@ -23,6 +24,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 { @@ -51,7 +53,6 @@ export class DonationsController { schema: { type: 'object', properties: { - foodManufacturerId: { type: 'integer', example: 1 }, recurrence: { type: 'string', enum: Object.values(RecurrenceEnum), @@ -93,11 +94,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 7fc1b0b67..00fb4f5a2 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -17,9 +17,18 @@ import { ReplaceDonationItemsDto, } from '../donationItems/dtos/create-donation-items.dto'; import { FoodType } from '../donationItems/types'; +import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; +import { UsersService } from '../users/users.service'; +import { EmailsService } from '../emails/email.service'; +import { mock } from 'jest-mock-extended'; 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); @@ -148,6 +157,7 @@ describe('DonationService', () => { providers: [ DonationService, DonationItemsService, + FoodManufacturersService, { provide: getRepositoryToken(Allocation), useValue: testDataSource.getRepository(Allocation), @@ -160,6 +170,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), @@ -924,11 +942,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(); @@ -960,13 +980,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 @@ -995,15 +1017,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 () => { 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'), ); }); @@ -1011,20 +1035,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', @@ -1040,19 +1066,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 88263b43c..915d58682 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -12,12 +12,12 @@ 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 { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; import { DonationItem } from '../donationItems/donationItems.entity'; import { Allocation } from '../allocations/allocations.entity'; +import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; @Injectable() export class DonationService { @@ -29,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, ) {} @@ -58,17 +57,13 @@ export class DonationService { return this.repo.count(); } - 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 8074c2981..ea69d27eb 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -611,9 +611,12 @@ describe('FoodManufacturersService', () => { it('returns next two upcoming donation reminders from same donation', async () => { const futureDate1 = new Date(); + futureDate1.setMilliseconds(0); futureDate1.setDate(futureDate1.getDate() + 30); clampDay(futureDate1); + const futureDate2 = new Date(); + futureDate2.setMilliseconds(0); futureDate2.setDate(futureDate2.getDate() + 60); clampDay(futureDate2); diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index a39c7faae..359887d6b 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; @@ -51,8 +52,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(); @@ -62,9 +62,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(); @@ -150,19 +150,11 @@ const RequestManagement: React.FC = ({ Food Request Management - {errorAlertState && ( + {alertState && ( - )} - {successAlertState && ( - )} @@ -414,7 +406,7 @@ const RequestManagement: React.FC = ({ isOpen={true} onClose={clearCloseRequest} onSuccess={() => { - setSuccessMessage('Request Closed'); + setAlertMessage('Request Closed', AlertStatus.INFO); loadRequests(); }} /> @@ -426,7 +418,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 +125,38 @@ const FoodManufacturerApplicationDetails: React.FC = () => { try { setLoading(true); if (!applicationId) { - setError({ type: 'invalid', message: 'Application ID not provided.' }); + setAlertMessage( + 'Application ID not provided.', + AlertStatus.ERROR, + AlertType.INVALID, + ); 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, + AlertType.INVALID, + ); } const data = await ApiClient.getFoodManufacturer( parseInt(applicationId, 10), ); if (!data) { - setError({ - type: 'not_found', - message: 'Application not found.', - }); + setAlertMessage( + 'Application not found.', + AlertStatus.ERROR, + AlertType.NOT_FOUND, + ); } 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, + AlertType.NETWORK, + ); } } } finally { @@ -178,7 +183,7 @@ const FoodManufacturerApplicationDetails: React.FC = () => { application.foodManufacturerName, ); } catch { - setAlertMessage('Error approving application'); + setAlertMessage('Error approving application', AlertStatus.ERROR); } } }; @@ -198,7 +203,7 @@ const FoodManufacturerApplicationDetails: React.FC = () => { application.foodManufacturerName, ); } catch { - setAlertMessage('Error denying application'); + setAlertMessage('Error denying application', AlertStatus.ERROR); } } }; @@ -213,9 +218,9 @@ const FoodManufacturerApplicationDetails: React.FC = () => { ); } - if (error.message || !application) { + if (alertState?.message || !application) { const getIcon = () => { - switch (error.type) { + switch (alertState?.type) { case 'network': return ; case 'not_found': @@ -228,9 +233,9 @@ const FoodManufacturerApplicationDetails: React.FC = () => { return ( ); @@ -254,7 +259,7 @@ const FoodManufacturerApplicationDetails: React.FC = () => { )} diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index a5b1b2a85..84b1648f2 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -12,7 +12,7 @@ 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 FmCompleteRequiredActionsModal from '@components/forms/fmCompleteRequiredActionsModal'; @@ -22,8 +22,7 @@ import { useAlert } from '../hooks/alert'; const MAX_PER_STATUS = 5; const FoodManufacturerDonationManagement: React.FC = () => { - const [errorAlertState, setErrorMessage] = useAlert(); - const [successAlertState, setSuccessMessage] = useAlert(); + const [alertState, setAlertMessage] = useAlert(); const [isLogDonationOpen, setIsLogDonationOpen] = useState(false); const [manufacturerId, setManufacturerId] = useState(null); const [selectedActionDonation, setSelectedActionDonation] = @@ -84,10 +83,15 @@ const FoodManufacturerDonationManagement: React.FC = () => { }; setCurrentPages(initialPages); } catch { - setErrorMessage('Error fetching donations'); + setAlertMessage('Error fetching donations', AlertStatus.ERROR); } }; + const handleLogNewDonationSuccess = () => { + setAlertMessage('Successfully logged new donation', AlertStatus.INFO); + if (manufacturerId !== null) fetchDonations(manufacturerId); + }; + // On page load, get the food manufacturer id and all appropriate donations useEffect(() => { const init = async () => { @@ -96,7 +100,10 @@ const FoodManufacturerDonationManagement: React.FC = () => { setManufacturerId(fmId); await fetchDonations(fmId); } catch { - setErrorMessage('Error initializing donation management'); + setAlertMessage( + 'Error initializing donation management', + AlertStatus.ERROR, + ); } }; init(); @@ -111,19 +118,11 @@ const FoodManufacturerDonationManagement: React.FC = () => { return ( - {errorAlertState && ( - - )} - {successAlertState && ( + {alertState && ( )} @@ -150,8 +149,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { {isLogDonationOpen && manufacturerId !== null && ( fetchDonations(manufacturerId)} + onDonationSuccess={handleLogNewDonationSuccess} isOpen={isLogDonationOpen} onClose={() => setIsLogDonationOpen(false)} /> @@ -165,8 +163,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 5dc980bc7..d86f03710 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]); @@ -97,7 +101,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..65f8d7ae6 100644 --- a/apps/frontend/src/containers/pantryApplicationDetails.tsx +++ b/apps/frontend/src/containers/pantryApplicationDetails.tsx @@ -12,7 +12,12 @@ import { Spinner, } from '@chakra-ui/react'; import ApiClient from '@api/apiClient'; -import { ApplicationStatus, PantryWithUser } from '../types/types'; +import { + AlertStatus, + AlertType, + ApplicationStatus, + PantryWithUser, +} from '../types/types'; import { formatDate, formatPhone } from '@utils/utils'; import { TagGroup } from '@components/forms/tagGroup'; import { FileX, TriangleAlert, WifiOff } from 'lucide-react'; @@ -96,13 +101,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 +131,36 @@ const PantryApplicationDetails: React.FC = () => { try { setLoading(true); if (!id) { - setError({ type: 'invalid', message: 'Application ID not provided.' }); + setAlertMessage( + 'Application ID not provided.', + AlertStatus.ERROR, + AlertType.INVALID, + ); 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, + AlertType.INVALID, + ); } const data = await ApiClient.getPantry(parseInt(id, 10)); if (!data) { - setError({ - type: 'not_found', - message: 'Application not found.', - }); + setAlertMessage( + 'Application not found.', + AlertStatus.ERROR, + AlertType.NOT_FOUND, + ); } 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, + AlertType.NETWORK, + ); } } } finally { @@ -179,7 +184,7 @@ const PantryApplicationDetails: React.FC = () => { application.pantryName, ); } catch { - setAlertMessage('Error approving application'); + setAlertMessage('Error approving application', AlertStatus.ERROR); } } }; @@ -196,7 +201,7 @@ const PantryApplicationDetails: React.FC = () => { application.pantryName, ); } catch { - setAlertMessage('Error denying application'); + setAlertMessage('Error denying application', AlertStatus.ERROR); } } }; @@ -211,9 +216,9 @@ const PantryApplicationDetails: React.FC = () => { ); } - if (error.message || !application) { + if (alertState?.message || !application) { const getIcon = () => { - switch (error.type) { + switch (alertState?.type) { case 'network': return ; case 'not_found': @@ -226,9 +231,9 @@ const PantryApplicationDetails: React.FC = () => { return ( ); @@ -247,7 +252,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 c231b87a4..23b292c4f 100644 --- a/apps/frontend/src/containers/pantryOrderManagement.tsx +++ b/apps/frontend/src/containers/pantryOrderManagement.tsx @@ -18,7 +18,7 @@ import { } from 'lucide-react'; import { capitalize, formatDate, ORDER_STATUS_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'; @@ -56,8 +56,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 = { @@ -110,9 +109,9 @@ const PantryOrderManagement: React.FC = () => { }; setCurrentPages(initialPages); } catch { - setErrorMessage('Failed to fetch orders'); + setAlertMessage('Failed to fetch orders', AlertStatus.ERROR); } - }, [setErrorMessage]); + }, [setAlertMessage]); useEffect(() => { fetchOrders(); @@ -149,19 +148,11 @@ const PantryOrderManagement: React.FC = () => { Order Management - {errorAlertState && ( + {alertState && ( - )} - {successAlertState && ( - )} @@ -228,10 +219,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 2079f7167..c598a4fdc 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'; @@ -33,7 +33,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 { @@ -42,11 +42,17 @@ const ProfilePage: React.FC = () => { const fm = await ApiClient.getFoodManufacturer(foodManufacturerId); 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); } @@ -56,7 +62,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; } @@ -68,14 +74,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; } @@ -107,7 +117,7 @@ const ProfilePage: React.FC = () => { )} diff --git a/apps/frontend/src/containers/volunteerAssignedPantries.tsx b/apps/frontend/src/containers/volunteerAssignedPantries.tsx index 53e37450a..ca7caceac 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 54707fe94..ffcefb3dd 100644 --- a/apps/frontend/src/containers/volunteerManagement.tsx +++ b/apps/frontend/src/containers/volunteerManagement.tsx @@ -14,7 +14,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'; @@ -26,8 +26,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; @@ -37,12 +36,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); @@ -69,19 +68,11 @@ const VolunteerManagement: React.FC = () => { Volunteer Management - {errorAlertState && ( + {alertState && ( - )} - {successAlertState && ( - )} @@ -115,10 +106,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 591981416..79cd3890f 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'; @@ -125,7 +126,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; } @@ -162,7 +166,7 @@ const VolunteerOrderManagement: React.FC = () => { }; setCurrentPages(initialPages); } catch { - setAlertMessage('Error fetching assigned orders'); + setAlertMessage('Error fetching assigned orders', AlertStatus.ERROR); } finally { setIsLoading(false); } @@ -242,7 +246,7 @@ const VolunteerOrderManagement: React.FC = () => { )} diff --git a/apps/frontend/src/hooks/alert.ts b/apps/frontend/src/hooks/alert.ts index 0a2c609b3..b4d54627e 100644 --- a/apps/frontend/src/hooks/alert.ts +++ b/apps/frontend/src/hooks/alert.ts @@ -1,17 +1,26 @@ import { useCallback, useRef, useState } from 'react'; +import { AlertStatus, AlertType } from '../types/types'; export interface AlertState { message: string; + status: AlertStatus; id: number; + type: AlertType | null; } -export function useAlert(): [AlertState | null, (message: string) => void] { +export function useAlert(): [ + AlertState | null, + (message: string, status: AlertStatus, type?: AlertType | null) => 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, type: AlertType | null = null) => { + setAlertState({ message, status, type, id: idRef.current++ }); + }, + [], + ); return [alertState, setAlertMessage]; } diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 2a1592b91..83a9dc183 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -465,7 +465,6 @@ export interface CreateFoodRequestBody { } export interface CreateDonationDto { - foodManufacturerId: number; recurrenceFreq?: number; recurrence: RecurrenceEnum; repeatOnDays?: RepeatOnState; @@ -605,3 +604,14 @@ export interface UpdateDonationItemDetailsDto { estimatedValue?: number; foodRescue?: boolean; } + +export enum AlertStatus { + INFO = 'info', + ERROR = 'error', +} + +export enum AlertType { + NETWORK = 'network', + NOT_FOUND = 'not_found', + INVALID = 'invalid', +}