Skip to content

Commit 7db5646

Browse files
authored
[SSF 241] - Admin Food Manufacturer Management Page (#203)
1 parent a0ce90a commit 7db5646

13 files changed

Lines changed: 807 additions & 190 deletions

‎apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts‎

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Donation } from '../donations/donations.entity';
1010
import { UpdateFoodManufacturerApplicationDto } from './dtos/update-manufacturer-application.dto';
1111
import { NotFoundException } from '@nestjs/common';
1212
import { AuthenticatedRequest } from '../auth/authenticated-request';
13+
import { Role } from '../users/types';
1314
import {
1415
DonationDetailsDto,
1516
DonationReminderDto,
@@ -78,6 +79,37 @@ describe('FoodManufacturersController', () => {
7879
});
7980
});
8081

82+
describe('GET /approved', () => {
83+
it('should return approved food manufacturers', async () => {
84+
const mockManufacturers: Partial<FoodManufacturer>[] = [
85+
{
86+
foodManufacturerId: 1,
87+
foodManufacturerName: 'Good Foods Inc',
88+
status: ApplicationStatus.APPROVED,
89+
},
90+
{
91+
foodManufacturerId: 2,
92+
foodManufacturerName: 'Healthy Eats LLC',
93+
status: ApplicationStatus.APPROVED,
94+
},
95+
];
96+
97+
mockManufacturersService.getApprovedManufacturers.mockResolvedValue(
98+
mockManufacturers as FoodManufacturer[],
99+
);
100+
101+
const result = await controller.getApprovedManufacturers();
102+
103+
expect(result).toEqual(mockManufacturers);
104+
expect(result).toHaveLength(2);
105+
expect(result[0].foodManufacturerId).toBe(1);
106+
expect(result[1].foodManufacturerId).toBe(2);
107+
expect(
108+
mockManufacturersService.getApprovedManufacturers,
109+
).toHaveBeenCalled();
110+
});
111+
});
112+
81113
describe('GET /:id', () => {
82114
it('should return a food manufacturer by id', async () => {
83115
mockManufacturersService.findOne.mockResolvedValue(
@@ -224,7 +256,7 @@ describe('FoodManufacturersController', () => {
224256
});
225257

226258
describe('PATCH /:manufacturerId/application', () => {
227-
const req = { user: { id: 1 } };
259+
const req = { user: { id: 1, role: Role.FOODMANUFACTURER } };
228260

229261
it('should update a food manufacturer application', async () => {
230262
const manufacturerId = 1;
@@ -251,7 +283,12 @@ describe('FoodManufacturersController', () => {
251283
expect(result).toEqual(mockManufacturer1);
252284
expect(
253285
mockManufacturersService.updateFoodManufacturerApplication,
254-
).toHaveBeenCalledWith(manufacturerId, mockUpdateData, 1);
286+
).toHaveBeenCalledWith(
287+
manufacturerId,
288+
mockUpdateData,
289+
1,
290+
Role.FOODMANUFACTURER,
291+
);
255292
});
256293

257294
it('should throw error if manufacturer does not exist', async () => {
@@ -272,7 +309,31 @@ describe('FoodManufacturersController', () => {
272309
).rejects.toThrow();
273310
expect(
274311
mockManufacturersService.updateFoodManufacturerApplication,
275-
).toHaveBeenCalledWith(999, mockUpdateData, 1);
312+
).toHaveBeenCalledWith(999, mockUpdateData, 1, Role.FOODMANUFACTURER);
313+
});
314+
315+
it('should allow admin to update any food manufacturer application', async () => {
316+
const adminReq = { user: { id: 2, role: Role.ADMIN } };
317+
const manufacturerId = 1;
318+
319+
const mockUpdateData: UpdateFoodManufacturerApplicationDto = {
320+
secondaryContactFirstName: 'Admin Updated',
321+
};
322+
323+
mockManufacturersService.updateFoodManufacturerApplication.mockResolvedValue(
324+
mockManufacturer1 as FoodManufacturer,
325+
);
326+
327+
const result = await controller.updateFoodManufacturerApplication(
328+
adminReq as AuthenticatedRequest,
329+
manufacturerId,
330+
mockUpdateData,
331+
);
332+
333+
expect(result).toEqual(mockManufacturer1);
334+
expect(
335+
mockManufacturersService.updateFoodManufacturerApplication,
336+
).toHaveBeenCalledWith(manufacturerId, mockUpdateData, 2, Role.ADMIN);
276337
});
277338
});
278339

‎apps/backend/src/foodManufacturers/manufacturers.controller.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ export class FoodManufacturersController {
5050
return this.foodManufacturersService.getPendingManufacturers();
5151
}
5252

53+
@Roles(Role.ADMIN)
54+
@Get('/approved')
55+
async getApprovedManufacturers(): Promise<FoodManufacturer[]> {
56+
return this.foodManufacturersService.getApprovedManufacturers();
57+
}
58+
5359
@Roles(Role.FOODMANUFACTURER)
5460
@Get('/my-id')
5561
async getCurrentUserFoodManufacturerId(
@@ -216,7 +222,7 @@ export class FoodManufacturersController {
216222
);
217223
}
218224

219-
@Roles(Role.FOODMANUFACTURER)
225+
@Roles(Role.FOODMANUFACTURER, Role.ADMIN)
220226
@CheckOwnership({
221227
idParam: 'foodManufacturerId',
222228
resolver: resolveFoodManufacturerAuthorizedUserIds,
@@ -232,6 +238,7 @@ export class FoodManufacturersController {
232238
foodManufacturerId,
233239
foodManufacturerData,
234240
req.user.id,
241+
req.user.role,
235242
);
236243
}
237244

‎apps/backend/src/foodManufacturers/manufacturers.service.spec.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { FoodType } from '../donationItems/types';
2828
import { DonationService } from '../donations/donations.service';
2929
import { PantriesService } from '../pantries/pantries.service';
3030
import { Pantry } from '../pantries/pantries.entity';
31+
import { Role } from '../users/types';
3132
import { Allocation } from '../allocations/allocations.entity';
3233
import { RecurrenceEnum } from '../donations/types';
3334
import { UpdateFoodManufacturerApplicationDto } from './dtos/update-manufacturer-application.dto';
@@ -174,6 +175,24 @@ describe('FoodManufacturersService', () => {
174175
});
175176
});
176177

178+
describe('getApprovedManufacturers', () => {
179+
it('returns manufacturers with approved status', async () => {
180+
const approved = await service.getApprovedManufacturers();
181+
expect(approved.length).toBeGreaterThan(0);
182+
expect(
183+
approved.every((m) => m.status === ApplicationStatus.APPROVED),
184+
).toBe(true);
185+
});
186+
187+
it('returns empty array when no approved manufacturers exist', async () => {
188+
await testDataSource.query(
189+
`UPDATE food_manufacturers SET status = 'pending' WHERE status = 'approved'`,
190+
);
191+
const approved = await service.getApprovedManufacturers();
192+
expect(approved).toEqual([]);
193+
});
194+
});
195+
177196
describe('approve', () => {
178197
it('approves a pending manufacturer', async () => {
179198
const pending = await service.getPendingManufacturers();
@@ -962,5 +981,45 @@ describe('FoodManufacturersService', () => {
962981
),
963982
);
964983
});
984+
985+
it('allows admin to update any approved manufacturer (bypassing ownership check)', async () => {
986+
const dto: UpdateFoodManufacturerApplicationDto = {
987+
secondaryContactFirstName: 'AdminUpdated',
988+
foodManufacturerName: 'Admin Edited Foods',
989+
};
990+
991+
// User 999 is not the representative of manufacturer 1, but as admin should be able to edit
992+
const adminUserId = 999;
993+
const updated = await service.updateFoodManufacturerApplication(
994+
1,
995+
dto,
996+
adminUserId,
997+
Role.ADMIN,
998+
);
999+
1000+
expect(updated.secondaryContactFirstName).toBe('AdminUpdated');
1001+
expect(updated.foodManufacturerName).toBe('Admin Edited Foods');
1002+
});
1003+
1004+
it('still throws ForbiddenException for non-admin non-owner users', async () => {
1005+
const dto: UpdateFoodManufacturerApplicationDto = {
1006+
foodManufacturerName: 'Should not work',
1007+
};
1008+
1009+
const invalidUserId = 999;
1010+
1011+
await expect(
1012+
service.updateFoodManufacturerApplication(
1013+
1,
1014+
dto,
1015+
invalidUserId,
1016+
Role.FOODMANUFACTURER,
1017+
),
1018+
).rejects.toThrow(
1019+
new ForbiddenException(
1020+
`User ${invalidUserId} is not allowed to edit application for Food Manufacturer 1`,
1021+
),
1022+
);
1023+
});
9651024
});
9661025
});

‎apps/backend/src/foodManufacturers/manufacturers.service.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,13 @@ export class FoodManufacturersService {
240240
});
241241
}
242242

243+
async getApprovedManufacturers(): Promise<FoodManufacturer[]> {
244+
return this.repo.find({
245+
where: { status: ApplicationStatus.APPROVED },
246+
relations: ['foodManufacturerRepresentative'],
247+
});
248+
}
249+
243250
async addFoodManufacturer(
244251
foodManufacturerData: FoodManufacturerApplicationDto,
245252
) {
@@ -320,6 +327,7 @@ export class FoodManufacturersService {
320327
manufacturerId: number,
321328
foodManufacturerData: UpdateFoodManufacturerApplicationDto,
322329
currentUserId: number,
330+
currentUserRole?: Role,
323331
): Promise<FoodManufacturer> {
324332
validateId(manufacturerId, 'Food Manufacturer');
325333
validateId(currentUserId, 'User');
@@ -335,7 +343,10 @@ export class FoodManufacturersService {
335343
);
336344
}
337345

338-
if (manufacturer.foodManufacturerRepresentative.id !== currentUserId) {
346+
if (
347+
currentUserRole !== Role.ADMIN &&
348+
manufacturer.foodManufacturerRepresentative.id !== currentUserId
349+
) {
339350
throw new ForbiddenException(
340351
`User ${currentUserId} is not allowed to edit application for Food Manufacturer ${manufacturerId}`,
341352
);

‎apps/frontend/src/api/apiClient.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,12 @@ export class ApiClient {
157157
.then((response) => response.data);
158158
}
159159

160+
public async getApprovedFoodManufacturers(): Promise<FoodManufacturer[]> {
161+
return this.axiosInstance
162+
.get('/api/manufacturers/approved')
163+
.then((response) => response.data);
164+
}
165+
160166
public async getFoodManufacturer(
161167
manufacturerId: number,
162168
): Promise<FoodManufacturer> {

‎apps/frontend/src/app.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import AdminDonationStats from '@containers/adminDonationStats';
3232
import ProfilePage from '@containers/profilePage';
3333
import VolunteerOrderManagement from '@containers/volunteerOrderManagement';
3434
import AdminPantryManagement from '@containers/adminPantryManagement';
35+
import AdminFoodManufacturerManagement from '@containers/adminFoodManufacturerManagement';
3536
import AdminRequestManagement from '@containers/adminRequestManagement';
3637
import PantryDashboard from '@containers/pantryDashboard';
3738
import VolunteerDashboard from '@containers/volunteerDashboard';
@@ -161,6 +162,14 @@ const router = createBrowserRouter([
161162
</ProtectedRoute>
162163
),
163164
},
165+
{
166+
path: ROUTES.FOOD_MANUFACTURER_MANAGEMENT_DETAILS,
167+
element: (
168+
<ProtectedRoute>
169+
<FoodManufacturerApplicationDetails />
170+
</ProtectedRoute>
171+
),
172+
},
164173
{
165174
path: ROUTES.ADMIN_DONATION,
166175
element: (
@@ -257,6 +266,14 @@ const router = createBrowserRouter([
257266
</ProtectedRoute>
258267
),
259268
},
269+
{
270+
path: ROUTES.FOOD_MANUFACTURER_MANAGEMENT,
271+
element: (
272+
<ProtectedRoute>
273+
<AdminFoodManufacturerManagement />
274+
</ProtectedRoute>
275+
),
276+
},
260277
],
261278
},
262279
]);

‎apps/frontend/src/components/Navbar.tsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ const ROLE_NAV_SECTIONS: Record<Role, NavSection[]> = {
5454
type: 'group',
5555
label: 'Manufacturers',
5656
children: [
57+
{
58+
label: 'Manufacturer Management',
59+
to: ROUTES.FOOD_MANUFACTURER_MANAGEMENT,
60+
},
5761
{ label: 'Donation Management', to: ROUTES.ADMIN_DONATION },
5862
{ label: 'Application Review', to: ROUTES.APPROVE_FOOD_MANUFACTURERS },
5963
{ label: 'Donation Statistics', to: ROUTES.ADMIN_DONATION_STATS },
@@ -177,17 +181,17 @@ const NavGroup: React.FC<NavGroupProps> = ({
177181
key={child.to}
178182
display="flex"
179183
alignItems="center"
180-
h="32px"
184+
minH="32px"
181185
pl="8px"
182186
pr="8px"
183187
w="full"
184188
>
185-
<Box position="relative" w="15px" h="20px">
189+
<Box position="relative" w="15px" alignSelf="stretch">
186190
<Box
187191
position="absolute"
188192
left="calc(50% + 3px)"
189193
top="-6px"
190-
h="32px"
194+
bottom="-6px"
191195
w="1px"
192196
bg="neutral.300"
193197
transform="translateX(-50%)"

‎apps/frontend/src/components/forms/editableFMApplication.tsx‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,23 @@ interface EditableFMApplicationProps {
8787
isEditing: boolean;
8888
onEditingChange: (v: boolean) => void;
8989
foodManufacturerId: number;
90+
initialApplication?: FoodManufacturer;
9091
}
9192

9293
const EditableFMApplication: React.FC<EditableFMApplicationProps> = ({
9394
isEditing,
9495
onEditingChange,
9596
foodManufacturerId,
97+
initialApplication,
9698
}) => {
97-
const [application, setApplication] = useState<FoodManufacturer | null>(null);
99+
const [application, setApplication] = useState<FoodManufacturer | null>(
100+
initialApplication ?? null,
101+
);
98102
const [alertState, setAlertMessage] = useAlert();
99103
const [isSaving, setIsSaving] = useState(false);
100-
const [form, setForm] = useState<FormState | null>(null);
104+
const [form, setForm] = useState<FormState | null>(
105+
initialApplication ? buildFormState(initialApplication) : null,
106+
);
101107

102108
const fetchApplication = useCallback(async () => {
103109
try {
@@ -113,8 +119,9 @@ const EditableFMApplication: React.FC<EditableFMApplicationProps> = ({
113119
}, [foodManufacturerId]);
114120

115121
useEffect(() => {
116-
fetchApplication();
117-
}, [fetchApplication]);
122+
// Fetch the application when we don't have one loaded already
123+
if (!initialApplication) fetchApplication();
124+
}, [fetchApplication, initialApplication]);
118125

119126
useEffect(() => {
120127
if (!isEditing && application) setForm(buildFormState(application));
@@ -142,10 +149,13 @@ const EditableFMApplication: React.FC<EditableFMApplicationProps> = ({
142149
setIsSaving(true);
143150
try {
144151
const formData: UpdateFoodManufacturerApplicationDto = {
145-
secondaryContactFirstName: form.secondaryContactFirstName || undefined,
146-
secondaryContactLastName: form.secondaryContactLastName || undefined,
147-
secondaryContactEmail: form.secondaryContactEmail || undefined,
148-
secondaryContactPhone: form.secondaryContactPhone || undefined,
152+
// Optional fields send null (not undefined) when cleared so the empty
153+
// value is persisted; undefined would be dropped from the request body
154+
// and leave the previous value unchanged.
155+
secondaryContactFirstName: form.secondaryContactFirstName || null,
156+
secondaryContactLastName: form.secondaryContactLastName || null,
157+
secondaryContactEmail: form.secondaryContactEmail || null,
158+
secondaryContactPhone: form.secondaryContactPhone || null,
149159
foodManufacturerName: form.foodManufacturerName || undefined,
150160
foodManufacturerWebsite: form.foodManufacturerWebsite || undefined,
151161
unlistedProductAllergens: form.unlistedProductAllergens as Allergen[],
@@ -156,7 +166,7 @@ const EditableFMApplication: React.FC<EditableFMApplicationProps> = ({
156166
inKindDonations: form.inKindDonations === 'Yes',
157167
donateWastedFood:
158168
(form.donateWastedFood as DonateWastedFood) || undefined,
159-
additionalComments: form.additionalComments || undefined,
169+
additionalComments: form.additionalComments || null,
160170
};
161171
const updated = await ApiClient.updateFoodManufacturerApplicationData(
162172
application.foodManufacturerId,

0 commit comments

Comments
 (0)