Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Donation } from '../donations/donations.entity';
import { UpdateFoodManufacturerApplicationDto } from './dtos/update-manufacturer-application.dto';
import { NotFoundException } from '@nestjs/common';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { Role } from '../users/types';
import {
DonationDetailsDto,
DonationReminderDto,
Expand Down Expand Up @@ -78,6 +79,37 @@ describe('FoodManufacturersController', () => {
});
});

describe('GET /approved', () => {
it('should return approved food manufacturers', async () => {
const mockManufacturers: Partial<FoodManufacturer>[] = [
{
foodManufacturerId: 1,
foodManufacturerName: 'Good Foods Inc',
status: ApplicationStatus.APPROVED,
},
{
foodManufacturerId: 2,
foodManufacturerName: 'Healthy Eats LLC',
status: ApplicationStatus.APPROVED,
},
];

mockManufacturersService.getApprovedManufacturers.mockResolvedValue(
mockManufacturers as FoodManufacturer[],
);

const result = await controller.getApprovedManufacturers();

expect(result).toEqual(mockManufacturers);
expect(result).toHaveLength(2);
expect(result[0].foodManufacturerId).toBe(1);
expect(result[1].foodManufacturerId).toBe(2);
expect(
mockManufacturersService.getApprovedManufacturers,
).toHaveBeenCalled();
});
});

describe('GET /:id', () => {
it('should return a food manufacturer by id', async () => {
mockManufacturersService.findOne.mockResolvedValue(
Expand Down Expand Up @@ -224,7 +256,7 @@ describe('FoodManufacturersController', () => {
});

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

it('should update a food manufacturer application', async () => {
const manufacturerId = 1;
Expand All @@ -251,7 +283,12 @@ describe('FoodManufacturersController', () => {
expect(result).toEqual(mockManufacturer1);
expect(
mockManufacturersService.updateFoodManufacturerApplication,
).toHaveBeenCalledWith(manufacturerId, mockUpdateData, 1);
).toHaveBeenCalledWith(
manufacturerId,
mockUpdateData,
1,
Role.FOODMANUFACTURER,
);
});

it('should throw error if manufacturer does not exist', async () => {
Expand All @@ -272,7 +309,31 @@ describe('FoodManufacturersController', () => {
).rejects.toThrow();
expect(
mockManufacturersService.updateFoodManufacturerApplication,
).toHaveBeenCalledWith(999, mockUpdateData, 1);
).toHaveBeenCalledWith(999, mockUpdateData, 1, Role.FOODMANUFACTURER);
});

it('should allow admin to update any food manufacturer application', async () => {
const adminReq = { user: { id: 2, role: Role.ADMIN } };
const manufacturerId = 1;

const mockUpdateData: UpdateFoodManufacturerApplicationDto = {
secondaryContactFirstName: 'Admin Updated',
};

mockManufacturersService.updateFoodManufacturerApplication.mockResolvedValue(
mockManufacturer1 as FoodManufacturer,
);

const result = await controller.updateFoodManufacturerApplication(
adminReq as AuthenticatedRequest,
manufacturerId,
mockUpdateData,
);

expect(result).toEqual(mockManufacturer1);
expect(
mockManufacturersService.updateFoodManufacturerApplication,
).toHaveBeenCalledWith(manufacturerId, mockUpdateData, 2, Role.ADMIN);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export class FoodManufacturersController {
return this.foodManufacturersService.getPendingManufacturers();
}

@Roles(Role.ADMIN)
@Get('/approved')
async getApprovedManufacturers(): Promise<FoodManufacturer[]> {
return this.foodManufacturersService.getApprovedManufacturers();
}

@Roles(Role.FOODMANUFACTURER)
@Get('/my-id')
async getCurrentUserFoodManufacturerId(
Expand Down Expand Up @@ -216,7 +222,7 @@ export class FoodManufacturersController {
);
}

@Roles(Role.FOODMANUFACTURER)
@Roles(Role.FOODMANUFACTURER, Role.ADMIN)
@CheckOwnership({
idParam: 'foodManufacturerId',
resolver: resolveFoodManufacturerAuthorizedUserIds,
Expand All @@ -232,6 +238,7 @@ export class FoodManufacturersController {
foodManufacturerId,
foodManufacturerData,
req.user.id,
req.user.role,
);
}

Expand Down
59 changes: 59 additions & 0 deletions apps/backend/src/foodManufacturers/manufacturers.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { FoodType } from '../donationItems/types';
import { DonationService } from '../donations/donations.service';
import { PantriesService } from '../pantries/pantries.service';
import { Pantry } from '../pantries/pantries.entity';
import { Role } from '../users/types';
import { Allocation } from '../allocations/allocations.entity';
import { RecurrenceEnum } from '../donations/types';
import { UpdateFoodManufacturerApplicationDto } from './dtos/update-manufacturer-application.dto';
Expand Down Expand Up @@ -174,6 +175,24 @@ describe('FoodManufacturersService', () => {
});
});

describe('getApprovedManufacturers', () => {
it('returns manufacturers with approved status', async () => {
const approved = await service.getApprovedManufacturers();
expect(approved.length).toBeGreaterThan(0);
expect(
approved.every((m) => m.status === ApplicationStatus.APPROVED),
).toBe(true);
});

it('returns empty array when no approved manufacturers exist', async () => {
await testDataSource.query(
`UPDATE food_manufacturers SET status = 'pending' WHERE status = 'approved'`,
);
const approved = await service.getApprovedManufacturers();
expect(approved).toEqual([]);
});
});

describe('approve', () => {
it('approves a pending manufacturer', async () => {
const pending = await service.getPendingManufacturers();
Expand Down Expand Up @@ -962,5 +981,45 @@ describe('FoodManufacturersService', () => {
),
);
});

it('allows admin to update any approved manufacturer (bypassing ownership check)', async () => {
const dto: UpdateFoodManufacturerApplicationDto = {
secondaryContactFirstName: 'AdminUpdated',
foodManufacturerName: 'Admin Edited Foods',
};

// User 999 is not the representative of manufacturer 1, but as admin should be able to edit
const adminUserId = 999;
const updated = await service.updateFoodManufacturerApplication(
1,
dto,
adminUserId,
Role.ADMIN,
);

expect(updated.secondaryContactFirstName).toBe('AdminUpdated');
expect(updated.foodManufacturerName).toBe('Admin Edited Foods');
});

it('still throws ForbiddenException for non-admin non-owner users', async () => {
const dto: UpdateFoodManufacturerApplicationDto = {
foodManufacturerName: 'Should not work',
};

const invalidUserId = 999;

await expect(
service.updateFoodManufacturerApplication(
1,
dto,
invalidUserId,
Role.FOODMANUFACTURER,
),
).rejects.toThrow(
new ForbiddenException(
`User ${invalidUserId} is not allowed to edit application for Food Manufacturer 1`,
),
);
});
});
});
13 changes: 12 additions & 1 deletion apps/backend/src/foodManufacturers/manufacturers.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,13 @@ export class FoodManufacturersService {
});
}

async getApprovedManufacturers(): Promise<FoodManufacturer[]> {
return this.repo.find({
where: { status: ApplicationStatus.APPROVED },
relations: ['foodManufacturerRepresentative'],
});
}

async addFoodManufacturer(
foodManufacturerData: FoodManufacturerApplicationDto,
) {
Expand Down Expand Up @@ -320,6 +327,7 @@ export class FoodManufacturersService {
manufacturerId: number,
foodManufacturerData: UpdateFoodManufacturerApplicationDto,
currentUserId: number,
currentUserRole?: Role,
): Promise<FoodManufacturer> {
validateId(manufacturerId, 'Food Manufacturer');
validateId(currentUserId, 'User');
Expand All @@ -335,7 +343,10 @@ export class FoodManufacturersService {
);
}

if (manufacturer.foodManufacturerRepresentative.id !== currentUserId) {
if (
currentUserRole !== Role.ADMIN &&
manufacturer.foodManufacturerRepresentative.id !== currentUserId
) {
throw new ForbiddenException(
`User ${currentUserId} is not allowed to edit application for Food Manufacturer ${manufacturerId}`,
);
Expand Down
6 changes: 6 additions & 0 deletions apps/frontend/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ export class ApiClient {
.then((response) => response.data);
}

public async getApprovedFoodManufacturers(): Promise<FoodManufacturer[]> {
return this.axiosInstance
.get('/api/manufacturers/approved')
.then((response) => response.data);
}

public async getFoodManufacturer(
manufacturerId: number,
): Promise<FoodManufacturer> {
Expand Down
17 changes: 17 additions & 0 deletions apps/frontend/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import AdminDonationStats from '@containers/adminDonationStats';
import ProfilePage from '@containers/profilePage';
import VolunteerOrderManagement from '@containers/volunteerOrderManagement';
import AdminPantryManagement from '@containers/adminPantryManagement';
import AdminFoodManufacturerManagement from '@containers/adminFoodManufacturerManagement';
import AdminRequestManagement from '@containers/adminRequestManagement';
import PantryDashboard from '@containers/pantryDashboard';
import VolunteerDashboard from '@containers/volunteerDashboard';
Expand Down Expand Up @@ -161,6 +162,14 @@ const router = createBrowserRouter([
</ProtectedRoute>
),
},
{
path: ROUTES.FOOD_MANUFACTURER_MANAGEMENT_DETAILS,
element: (
<ProtectedRoute>
<FoodManufacturerApplicationDetails />
</ProtectedRoute>
),
},
{
path: ROUTES.ADMIN_DONATION,
element: (
Expand Down Expand Up @@ -257,6 +266,14 @@ const router = createBrowserRouter([
</ProtectedRoute>
),
},
{
path: ROUTES.FOOD_MANUFACTURER_MANAGEMENT,
element: (
<ProtectedRoute>
<AdminFoodManufacturerManagement />
</ProtectedRoute>
),
},
],
},
]);
Expand Down
4 changes: 4 additions & 0 deletions apps/frontend/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ const ROLE_NAV_SECTIONS: Record<Role, NavSection[]> = {
type: 'group',
label: 'Manufacturers',
children: [
{
label: 'Food Manufacturer Management',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit can we shorten this to Manufacturer Management

to: ROUTES.FOOD_MANUFACTURER_MANAGEMENT,
},
{ label: 'Donation Management', to: ROUTES.ADMIN_DONATION },
{ label: 'Application Review', to: ROUTES.APPROVE_FOOD_MANUFACTURERS },
{ label: 'Donation Statistics', to: ROUTES.ADMIN_DONATION_STATS },
Expand Down
Loading
Loading