Skip to content

Commit 1df2238

Browse files
authored
Merge pull request #1677 from Northeastern-Electric-Racing/#1528-material-to-assembly
#1528 Create assign material assembly route and controller
2 parents bc3c305 + b45d14f commit 1df2238

6 files changed

Lines changed: 180 additions & 5 deletions

File tree

src/backend/src/controllers/projects.controllers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,18 @@ export default class ProjectsController {
225225
}
226226
}
227227

228+
static async assignMaterialAssembly(req: Request, res: Response, next: NextFunction) {
229+
try {
230+
const { materialId } = req.params;
231+
const { assemblyId } = req.body;
232+
const user = await getCurrentUser(res);
233+
const updatedMaterial = await ProjectsService.assignMaterialAssembly(user, materialId, assemblyId);
234+
res.status(200).json(updatedMaterial);
235+
} catch (error: unknown) {
236+
next(error);
237+
}
238+
}
239+
228240
static async deleteAssemblyType(req: Request, res: Response, next: NextFunction) {
229241
try {
230242
const { assemblyId } = req.params;

src/backend/src/routes/projects.routes.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,15 @@ projectRouter.post('/bom/material-type/create', nonEmptyString(body('name')), Pr
6262
projectRouter.post(
6363
'/bom/assembly/:wbsNum/create',
6464
nonEmptyString(body('name')),
65-
nonEmptyString(body('pdmFileName')).optional(),
65+
nonEmptyString(body('pdmFileName').optional()),
6666
ProjectsController.createAssembly
6767
);
68+
projectRouter.post(
69+
'/bom/material/:materialId/assign-assembly',
70+
nonEmptyString(body('assemblyId').optional()),
71+
validateInputs,
72+
ProjectsController.assignMaterialAssembly
73+
);
6874
projectRouter.post(
6975
'/material/:wbsNum/create',
7076
nonEmptyString(body('name')),

src/backend/src/services/projects.services.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,79 @@ export default class ProjectsService {
879879
return newMaterialType;
880880
}
881881

882+
/**
883+
* Assign a material on a project to a different assembly
884+
* @param submitter the submitter
885+
* @param materialId the material that will be moved
886+
* @param assemblyId the assembly to change the material to, or undefined to unassign the material
887+
* @throws if the submitter does not have the relevant positions
888+
* @returns the updated material
889+
*/
890+
static async assignMaterialAssembly(submitter: User, materialId: string, assemblyId?: string) {
891+
const material = await prisma.material.findUnique({
892+
where: { materialId },
893+
include: { wbsElement: true, assembly: true }
894+
});
895+
896+
if (!material) throw new NotFoundException('Material', materialId);
897+
898+
const project = await prisma.project.findFirst({
899+
where: {
900+
wbsElementId: material.wbsElementId
901+
},
902+
...projectQueryArgs
903+
});
904+
905+
if (!project) throw new NotFoundException('Project', material.wbsElementId);
906+
if (project.wbsElement.dateDeleted) throw new DeletedException('Project', project.projectId);
907+
908+
// Permission: leadership and up, anyone on project team
909+
if (!(isLeadership(submitter.role) || isUserPartOfTeams(project.teams, submitter)))
910+
throw new AccessDeniedException(
911+
`Only leadership or above, or someone on the project's team can assign materials to assemblies`
912+
);
913+
914+
//assigning the material to a new assembly
915+
if (assemblyId) {
916+
const assembly = await prisma.assembly.findUnique({
917+
where: { assemblyId },
918+
include: { wbsElement: true }
919+
});
920+
if (!assembly) throw new NotFoundException('Assembly', assemblyId);
921+
922+
// Confirm that the assembly's wbsElement is the same as the material's wbsElement
923+
if (material.wbsElementId !== assembly.wbsElementId)
924+
throw new HttpException(
925+
400,
926+
`The WBS element of the material (${wbsPipe(material.wbsElement)}) and assembly (${wbsPipe(
927+
assembly.wbsElement
928+
)}) do not match`
929+
);
930+
const updatedMaterial = await prisma.material.update({
931+
where: { materialId },
932+
data: { assemblyId }
933+
});
934+
935+
return updatedMaterial;
936+
}
937+
//unassigning material from an existing assembly
938+
if (material.assemblyId) {
939+
await prisma.assembly.update({
940+
where: { assemblyId: material.assemblyId },
941+
data: { materials: { disconnect: { materialId } } },
942+
include: { materials: true }
943+
});
944+
945+
const updatedMaterial = await prisma.material.findUnique({
946+
where: { materialId },
947+
include: { wbsElement: true, assembly: true }
948+
});
949+
950+
return updatedMaterial;
951+
}
952+
return material;
953+
}
954+
882955
/**
883956
* Deletes an assembly type
884957
* @param assemblyId the name of the assembly

src/backend/src/utils/errors.utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ type ExceptionObjectNames =
112112
| 'Reimbursement Request'
113113
| 'User Secure Settings'
114114
| 'Image File'
115+
| 'Material'
115116
| 'Assembly'
116117
| 'Material Type'
117118
| 'Manufacturer'

src/backend/tests/projects.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
prismaAssembly1,
99
toolMaterial,
1010
prismaManufacturer1,
11+
prismaMaterial1,
1112
prismaManufacturer2,
1213
prismaMaterial,
1314
prismaMaterialType,
@@ -29,7 +30,7 @@ import {
2930
import { prismaWbsElement1 } from './test-data/wbs-element.test-data';
3031
import WorkPackagesService from '../src/services/work-packages.services';
3132
import { validateWBS, WbsNumber } from 'shared';
32-
import { Material_Status, User } from '@prisma/client';
33+
import { Material, Material_Status, User } from '@prisma/client';
3334

3435
vi.mock('../src/utils/projects.utils');
3536
const mockGetHighestProjectNumber = getHighestProjectNumber as jest.Mock<Promise<number>>;
@@ -725,6 +726,64 @@ describe('Projects', () => {
725726
});
726727
});
727728

729+
describe('assigning material assemblies', () => {
730+
test('assignment fails because of permissions', async () => {
731+
vi.spyOn(prisma.material, 'findUnique').mockResolvedValue(prismaMaterial1);
732+
vi.spyOn(prisma.assembly, 'findUnique').mockResolvedValue(prismaAssembly1);
733+
const project = { ...prismaProject1, teams: [{ members: [aquaman], leads: [superman] }] };
734+
vi.spyOn(prisma.project, 'findFirst').mockResolvedValue(project);
735+
736+
await expect(ProjectsService.assignMaterialAssembly(theVisitor, 'mid', 'aid')).rejects.toThrow(
737+
new AccessDeniedException(
738+
`Only leadership or above, or someone on the project's team can assign materials to assemblies`
739+
)
740+
);
741+
});
742+
743+
test('assignment fails because of invalid material id', async () => {
744+
vi.spyOn(prisma.material, 'findUnique').mockResolvedValue(null);
745+
await expect(ProjectsService.assignMaterialAssembly(superman, 'invalid-mid', 'aid')).rejects.toThrow(
746+
new NotFoundException('Material', 'invalid-mid')
747+
);
748+
});
749+
750+
test('assignment fails because of invalid assembly id', async () => {
751+
vi.spyOn(prisma.material, 'findUnique').mockResolvedValue(prismaMaterial1);
752+
vi.spyOn(prisma.assembly, 'findUnique').mockResolvedValue(null);
753+
await expect(ProjectsService.assignMaterialAssembly(superman, 'mid', 'invalid-aid')).rejects.toThrow(
754+
new NotFoundException('Assembly', 'invalid-aid')
755+
);
756+
});
757+
758+
test('assignment fails because the wbsElements do not match', async () => {
759+
const material = { ...prismaMaterial1, wbsElementId: 1, wbsElement: prismaWbsElement1 };
760+
vi.spyOn(prisma.material, 'findUnique').mockResolvedValue(material);
761+
const assembly = {
762+
...prismaAssembly1,
763+
wbsElementId: 2,
764+
wbsElement: { ...prismaWbsElement1, wbsElementId: 2, projectNumber: 1 }
765+
};
766+
vi.spyOn(prisma.assembly, 'findUnique').mockResolvedValue(assembly);
767+
await expect(ProjectsService.assignMaterialAssembly(superman, 'mid', 'aid')).rejects.toThrow(
768+
new HttpException(400, `The WBS element of the material (1.2.0) and assembly (1.1.0) do not match`)
769+
);
770+
});
771+
772+
test('assignment successful', async () => {
773+
vi.spyOn(prisma.material, 'findUnique').mockResolvedValue(prismaMaterial1);
774+
vi.spyOn(prisma.assembly, 'findUnique').mockResolvedValue(prismaAssembly1);
775+
vi.spyOn(prisma.project, 'findFirst').mockResolvedValue(prismaProject1);
776+
777+
const expectedUpdatedToolMaterial: Material = {
778+
...prismaMaterial1,
779+
assemblyId: 'updated-aid'
780+
};
781+
vi.spyOn(prisma.material, 'update').mockResolvedValue(expectedUpdatedToolMaterial);
782+
783+
const updatedMaterial = await ProjectsService.assignMaterialAssembly(aquaman, 'mid', 'updated-aid');
784+
expect(updatedMaterial).toBe(expectedUpdatedToolMaterial);
785+
});
786+
});
728787
describe('Delete Assembly', () => {
729788
test('Deleteing assembly fails because user is not an admin or head', async () => {
730789
await expect(ProjectsService.deleteAssembly('New Assembly', theVisitor)).rejects.toThrow(
@@ -733,6 +792,8 @@ describe('Projects', () => {
733792
});
734793

735794
test('Deleting assembly fails if assembly does not exist', async () => {
795+
vi.spyOn(prisma.assembly, 'findUnique').mockResolvedValue(null);
796+
736797
await expect(ProjectsService.deleteAssembly('New Assembly', batman)).rejects.toThrow(
737798
new NotFoundException('Assembly', 'New Assembly')
738799
);

src/backend/tests/test-data/projects.test-data.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {
55
Prisma,
66
WBS_Element_Status as PrismaWBSElementStatus,
77
Project,
8-
Unit,
9-
Manufacturer
8+
Manufacturer,
9+
Unit
1010
} from '@prisma/client';
1111
import { Project as SharedProject, WbsElementStatus } from 'shared';
1212
import projectQueryArgs from '../../src/prisma-query-args/projects.query-args';
@@ -112,7 +112,7 @@ export const prismaAssembly1: Assembly = {
112112
pdmFileName: 'file.txt',
113113
dateCreated: new Date('10-19-2023'),
114114
userCreatedId: batman.userId,
115-
wbsElementId: 66,
115+
wbsElementId: sharedProject1.id,
116116
dateDeleted: null,
117117
userDeletedId: null,
118118
assemblyId: '1'
@@ -192,3 +192,25 @@ export const prismaMaterial2: Material = {
192192
unitName: 'FT',
193193
linkUrl: 'https://www.google.com'
194194
};
195+
196+
export const prismaMaterial1: Material = {
197+
materialId: '1',
198+
assemblyId: '1',
199+
dateCreated: new Date('2023-11-07'),
200+
linkUrl: 'https://example.com',
201+
manufacturerName: 'Manufacturer1',
202+
manufacturerPartNumber: '1',
203+
materialTypeName: 'NERSoftwareTools',
204+
name: prismaManufacturer1.name,
205+
notes: 'Sample notes',
206+
pdmFileName: 'pdmname',
207+
price: 10,
208+
quantity: 89,
209+
status: 'ORDERED',
210+
subtotal: 1,
211+
unitName: 'Unit',
212+
userCreatedId: batman.userId,
213+
wbsElementId: sharedProject1.id,
214+
dateDeleted: null,
215+
userDeletedId: null
216+
};

0 commit comments

Comments
 (0)