diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index e943c5d28..c77d452a6 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -23,6 +23,7 @@ import { User } from '../users/users.entity'; import { AuthenticatedRequest } from '../auth/authenticated-request'; import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto'; import { RequestsService } from '../foodRequests/request.service'; +import { Role } from '../users/types'; import { FoodRequest } from '../foodRequests/request.entity'; const mockPantriesService = mock(); @@ -296,7 +297,7 @@ describe('PantriesController', () => { }); describe('PATCH /:pantryId/application', () => { - const req = { user: { id: 1 } }; + const req = { user: { id: 1, role: Role.PANTRY } }; it('should update a pantry application', async () => { const pantryId = 1; @@ -323,6 +324,7 @@ describe('PantriesController', () => { pantryId, mockUpdateData, 1, + Role.PANTRY, ); }); @@ -346,6 +348,32 @@ describe('PantriesController', () => { 999, mockUpdateData, 1, + Role.PANTRY, + ); + }); + + it('should allow admin to update any pantry application', async () => { + const adminReq = { user: { id: 2, role: Role.ADMIN } }; + const pantryId = 1; + + const mockUpdateData: UpdatePantryApplicationDto = { + secondaryContactFirstName: 'Admin Updated', + }; + + mockPantriesService.updatePantryApplication.mockResolvedValue(mockPantry); + + const result = await controller.updatePantryApplication( + adminReq as AuthenticatedRequest, + pantryId, + mockUpdateData, + ); + + expect(result).toEqual(mockPantry); + expect(mockPantriesService.updatePantryApplication).toHaveBeenCalledWith( + pantryId, + mockUpdateData, + 2, + Role.ADMIN, ); }); }); diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index 0d3fadb74..63503e96a 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -392,7 +392,7 @@ export class PantriesController { idParam: 'pantryId', resolver: resolvePantryAuthorizedUserIds, }) - @Roles(Role.PANTRY) + @Roles(Role.PANTRY, Role.ADMIN) @Patch('/:pantryId/application') async updatePantryApplication( @Req() req: AuthenticatedRequest, @@ -404,6 +404,7 @@ export class PantriesController { pantryId, pantryData, req.user.id, + req.user.role, ); } diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 9548b6cf4..0a0e85d35 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -34,6 +34,7 @@ import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto import { EmailsService } from '../emails/email.service'; import { mock } from 'jest-mock-extended'; import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates'; +import { Role } from '../users/types'; jest.setTimeout(60000); @@ -483,6 +484,40 @@ describe('PantriesService', () => { ), ); }); + + it('allows admin to update any approved pantry (bypassing ownership check)', async () => { + const dto: UpdatePantryApplicationDto = { + secondaryContactFirstName: 'AdminUpdated', + itemsInStock: 'Admin updated items', + }; + + // User 1 is not the owner of pantry 1 (owner is user 10), but as admin should be able to edit + const adminUserId = 1; + const updatedPantry = await service.updatePantryApplication( + 1, + dto, + adminUserId, + Role.ADMIN, + ); + + expect(updatedPantry.secondaryContactFirstName).toBe('AdminUpdated'); + expect(updatedPantry.itemsInStock).toBe('Admin updated items'); + }); + + it('still throws ForbiddenException for non-admin non-owner users', async () => { + const dto: UpdatePantryApplicationDto = { + itemsInStock: 'Should not work', + }; + + // User 999 is not the owner and not an admin + await expect( + service.updatePantryApplication(1, dto, 999, Role.PANTRY), + ).rejects.toThrow( + new ForbiddenException( + 'User 999 is not allowed to edit application for Pantry 1', + ), + ); + }); }); describe('getPantryStats (single pantry)', () => { diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index e8b42e2ea..21d70e1a0 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -422,6 +422,7 @@ export class PantriesService { pantryId: number, pantryData: UpdatePantryApplicationDto, currentUserId: number, + currentUserRole?: Role, ): Promise { validateId(pantryId, 'Pantry'); validateId(currentUserId, 'User'); @@ -435,7 +436,10 @@ export class PantriesService { throw new NotFoundException(`Pantry ${pantryId} not found`); } - if (pantry.pantryUser.id !== currentUserId) { + if ( + currentUserRole !== Role.ADMIN && + pantry.pantryUser.id !== currentUserId + ) { throw new ForbiddenException( `User ${currentUserId} is not allowed to edit application for Pantry ${pantryId}`, ); diff --git a/apps/frontend/src/components/forms/editablePantryApplication.tsx b/apps/frontend/src/components/forms/editablePantryApplication.tsx index 6d1a4ee2b..2e8149940 100644 --- a/apps/frontend/src/components/forms/editablePantryApplication.tsx +++ b/apps/frontend/src/components/forms/editablePantryApplication.tsx @@ -254,20 +254,31 @@ function validateRequired(form: FormState): boolean { interface EditablePantryApplicationProps { isEditing: boolean; onEditingChange: (v: boolean) => void; + pantryId?: number; + initialApplication?: PantryWithUser; } const EditablePantryApplication: React.FC = ({ isEditing, onEditingChange, + pantryId: propPantryId, + initialApplication, }) => { - const [application, setApplication] = useState(null); + const [application, setApplication] = useState( + initialApplication ?? null, + ); const [alertState, setAlertMessage] = useAlert(); const [isSaving, setIsSaving] = useState(false); - const [form, setForm] = useState(null); + const [isLoading, setIsLoading] = useState(!initialApplication); + const [form, setForm] = useState( + initialApplication ? buildFormState(initialApplication) : null, + ); const fetchApplication = useCallback(async () => { try { - const pantryId = await ApiClient.getCurrentUserPantryId(); + setIsLoading(true); + const pantryId = + propPantryId ?? (await ApiClient.getCurrentUserPantryId()); if (pantryId) { const data = await ApiClient.getPantry(pantryId); setApplication(data); @@ -278,12 +289,16 @@ const EditablePantryApplication: React.FC = ({ 'Could not load application details. Please try again later.', AlertStatus.ERROR, ); + } finally { + setIsLoading(false); } - }, []); + }, [propPantryId]); useEffect(() => { - fetchApplication(); - }, [fetchApplication]); + if (!initialApplication) { + fetchApplication(); + } + }, [fetchApplication, initialApplication]); useEffect(() => { if (!isEditing && application) setForm(buildFormState(application)); @@ -429,6 +444,10 @@ const EditablePantryApplication: React.FC = ({ } }; + if (isLoading) { + return null; + } + if (!application || !form) { return (
diff --git a/apps/frontend/src/containers/pantryApplicationDetails.tsx b/apps/frontend/src/containers/pantryApplicationDetails.tsx index 147babbaf..817a3ce97 100644 --- a/apps/frontend/src/containers/pantryApplicationDetails.tsx +++ b/apps/frontend/src/containers/pantryApplicationDetails.tsx @@ -15,10 +15,11 @@ import ApiClient from '@api/apiClient'; import { AlertStatus, ApplicationStatus, PantryWithUser } from '../types/types'; import { formatDate, formatPhone } from '@utils/utils'; import { TagGroup } from '@components/forms/tagGroup'; -import { TriangleAlert } from 'lucide-react'; +import { Pencil, TriangleAlert } from 'lucide-react'; import { AxiosError } from 'axios'; import { FloatingAlert } from '@components/floatingAlert'; import ConfirmPantryDecisionModal from '@components/forms/confirmPantryDecisionModal'; +import EditablePantryApplication from '@components/forms/editablePantryApplication'; import { useAlert } from '../hooks/alert'; import { ROUTES } from '../routes'; @@ -99,6 +100,7 @@ const PantryApplicationDetails: React.FC = () => { const [alertState, setAlertMessage] = useAlert(); const [showApproveModal, setShowApproveModal] = useState(false); const [showDenyModal, setShowDenyModal] = useState(false); + const [isEditing, setIsEditing] = useState(false); const fieldContentStyles = { textStyle: 'p2', @@ -210,354 +212,387 @@ const PantryApplicationDetails: React.FC = () => { const pantryUser = application.pantryUser; return ( - - - - {isApplicationMode ? 'Application Details' : 'Pantry Details'} - + + + {isApplicationMode ? 'Application Details' : 'Pantry Details'} + + + {alertState && ( + + )} + + + + + {isApplicationMode ? ( + <> + + Application #{application.pantryId} + + + {application.pantryName} + + + Applied {formatDate(application.dateApplied)} + + + ) : ( + + + {application.pantryName} + + setIsEditing(true)} + > + + + {isEditing ? 'Editing' : 'Edit'} + + + + )} + + + {!isApplicationMode && isEditing ? ( + { + setIsEditing(editing); + if (!editing) { + fetchApplicationDetails(); + } + }} + pantryId={application.pantryId} + initialApplication={application} + /> + ) : ( + <> + + + + Point of Contact Information + + + {pantryUser.firstName} {pantryUser.lastName} + + + {formatPhone(pantryUser.phone) || '-'} + + {pantryUser.email} + + + + Secondary Point of Contact + + + {application.secondaryContactFirstName || + application.secondaryContactLastName + ? `${application.secondaryContactFirstName ?? ''} ${ + application.secondaryContactLastName ?? '' + }`.trim() + : '-'} + + + {formatPhone(application.secondaryContactPhone) || '-'} + + + {application.secondaryContactEmail || '-'} + + + - {alertState && ( - - )} + + + + Has a contact who can regularly respond to SSF emails? + + + {application.hasEmailContact ? 'Yes' : 'No'} + + + + + Other email contact details + + + {application.emailContactOther || '-'} + + + - - - - {isApplicationMode ? ( - <> - - Application #{application.pantryId} + + + + Shipping Address + + + {application.shipmentAddressLine1} + {application.shipmentAddressLine2 && + `, ${application.shipmentAddressLine2}`} + + + {application.shipmentAddressCity},{' '} + {application.shipmentAddressState}{' '} + {application.shipmentAddressZip} + + + {application.shipmentAddressCountry === 'US' + ? 'United States of America' + : application.shipmentAddressCountry ?? '-'} + + + + + Mailing Address - - {application.pantryName} + + {application.mailingAddressLine1} + {application.mailingAddressLine2 && + `, ${application.mailingAddressLine2}`} - - Applied {formatDate(application.dateApplied)} + + {application.mailingAddressCity},{' '} + {application.mailingAddressState}{' '} + {application.mailingAddressZip} - - ) : ( - - {application.pantryName} - - )} - + + {application.mailingAddressCountry === 'US' + ? 'United States of America' + : application.mailingAddressCountry ?? '-'} + + + - - - - Point of Contact Information - - - {pantryUser.firstName} {pantryUser.lastName} - - - {formatPhone(pantryUser.phone) || '-'} - - {pantryUser.email} - - - - Secondary Point of Contact + + + Delivery Preferences - - {application.secondaryContactFirstName || - application.secondaryContactLastName - ? `${application.secondaryContactFirstName ?? ''} ${ - application.secondaryContactLastName ?? '' - }`.trim() - : '-'} - - - {formatPhone(application.secondaryContactPhone) || '-'} - - - {application.secondaryContactEmail || '-'} - - - - - - - - Has a contact who can regularly respond to SSF emails? - - - {application.hasEmailContact ? 'Yes' : 'No'} - - - - Other email contact details - - {application.emailContactOther || '-'} - - - - - - - - Shipping Address + + + + Accepts food deliveries during standard business hours + (Mon–Fri)? + + + {application.acceptFoodDeliveries ? 'Yes' : 'No'} + + + + + Delivery window restrictions + + + {application.deliveryWindowInstructions || '-'} + + + + + + + + Pantry Details - - {application.shipmentAddressLine1} - {application.shipmentAddressLine2 && - `, ${application.shipmentAddressLine2}`} - - - {application.shipmentAddressCity},{' '} - {application.shipmentAddressState}{' '} - {application.shipmentAddressZip} - - - {application.shipmentAddressCountry === 'US' - ? 'United States of America' - : application.shipmentAddressCountry ?? '-'} - - - - - Mailing Address + + + Pantry Name + + {application.pantryName} + + + + + Clients with food allergies or adverse reactions served + + + {application.allergenClients || '-'} + + + + + + + + Food allergies / dietary restrictions clients report - - {application.mailingAddressLine1} - {application.mailingAddressLine2 && - `, ${application.mailingAddressLine2}`} - - - {application.mailingAddressCity},{' '} - {application.mailingAddressState}{' '} - {application.mailingAddressZip} - - - {application.mailingAddressCountry === 'US' - ? 'United States of America' - : application.mailingAddressCountry ?? '-'} - - - + {application.restrictions && + application.restrictions.length > 0 ? ( + + ) : ( + - + )} + - - - Delivery Preferences - - Accepts food deliveries during standard business hours - (Mon–Fri)? + Able to accept frozen/refrigerated donations? - {application.acceptFoodDeliveries ? 'Yes' : 'No'} + {application.refrigeratedDonation || '-'} - Delivery window restrictions + Dedicated shelf/section for allergen-friendly items? - {application.deliveryWindowInstructions || '-'} + {application.dedicatedAllergyFriendly || '-'} - - - - Pantry Details - - Pantry Name - {application.pantryName} + + Willing to reserve food shipments for allergen-avoidant + individuals? + + + {application.reserveFoodForAllergic || '-'} + - Clients with food allergies or adverse reactions served + How allergen-friendly foods will reach allergic clients - {application.allergenClients || '-'} + {application.reservationExplanation || '-'} - - - - - Food allergies / dietary restrictions clients report - - {application.restrictions && - application.restrictions.length > 0 ? ( - - ) : ( - - - )} - - - - - Able to accept frozen/refrigerated donations? - - - {application.refrigeratedDonation || '-'} - - - - - Dedicated shelf/section for allergen-friendly items? - - - {application.dedicatedAllergyFriendly || '-'} - - - - - - - - Willing to reserve food shipments for allergen-avoidant - individuals? - - - {application.reserveFoodForAllergic || '-'} - - - - - How allergen-friendly foods will reach allergic clients - + + + + How often allergen-avoidant clients visit + + + {application.clientVisitFrequency || '-'} + + + + + Serves allergen-avoidant children? + + + {application.serveAllergicChildren || '-'} + + + + + + + Languages allergen-avoidant clients speak + + {application.languages && application.languages.length > 0 ? ( + + ) : ( + - + )} + + + + + Activities open to doing with SSF + + {application.activities && application.activities.length > 0 ? ( + + ) : ( + - + )} + + + + + Comments about activities + - {application.reservationExplanation || '-'} + {application.activitiesComments || '-'} - - + - - - - How often allergen-avoidant clients visit - + + + Allergen-free items currently in stock + - {application.clientVisitFrequency || '-'} - - - - - Serves allergen-avoidant children? + {application.itemsInStock || '-'} + + + + + Have clients requested more food options? + - {application.serveAllergicChildren || '-'} + {application.needMoreOptions || '-'} - - - - - - Languages allergen-avoidant clients speak - - {application.languages && application.languages.length > 0 ? ( - - ) : ( - - + + + {isApplicationMode && ( + + + + + setShowApproveModal(false)} + onConfirm={handleApprove} + decision="approve" + pantryName={application.pantryName} + dateApplied={formatDate(application.dateApplied)} + /> + + setShowDenyModal(false)} + onConfirm={handleDeny} + decision="deny" + pantryName={application.pantryName} + dateApplied={formatDate(application.dateApplied)} + /> + )} - - - - - Activities open to doing with SSF - - {application.activities && application.activities.length > 0 ? ( - - ) : ( - - - )} - - - - - Comments about activities - - - {application.activitiesComments || '-'} - - - - - - Allergen-free items currently in stock - - - {application.itemsInStock || '-'} - - - - - - Have clients requested more food options? - - - {application.needMoreOptions || '-'} - - - - {isApplicationMode && ( - - - - - setShowApproveModal(false)} - onConfirm={handleApprove} - decision="approve" - pantryName={application.pantryName} - dateApplied={formatDate(application.dateApplied)} - /> - - setShowDenyModal(false)} - onConfirm={handleDeny} - decision="deny" - pantryName={application.pantryName} - dateApplied={formatDate(application.dateApplied)} - /> - - )} - - + + )} + );