From 64b76f53f8f3765cc911b9456c22a8c3c84c0e34 Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Sat, 24 Jan 2026 22:43:28 -0500 Subject: [PATCH 1/9] #3886 added functionality --- .../src/controllers/projects.controllers.ts | 16 +++ src/backend/src/routes/projects.routes.ts | 9 +- src/backend/src/services/boms.services.ts | 125 ++++++++++++++++++ src/backend/src/utils/validation.utils.ts | 5 + src/frontend/src/apis/bom.api.ts | 11 ++ src/frontend/src/hooks/bom.hooks.ts | 28 ++++ src/frontend/src/utils/urls.ts | 2 + 7 files changed, 195 insertions(+), 1 deletion(-) diff --git a/src/backend/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index c874848b1e..35813161d5 100644 --- a/src/backend/src/controllers/projects.controllers.ts +++ b/src/backend/src/controllers/projects.controllers.ts @@ -236,6 +236,22 @@ export default class ProjectsController { } } + static async copyMaterialsToProject(req: Request, res: Response, next: NextFunction) { + try { + const { materialIds, destinationWbsNum } = req.body; + const destinationWBS = validateWBS(destinationWbsNum as string); + const newMaterialIds = await BillOfMaterialsService.copyMaterialsToProject( + req.currentUser, + materialIds, + destinationWBS, + req.organization + ); + res.status(200).json(newMaterialIds); + } catch (error: unknown) { + next(error); + } + } + static async createManufacturer(req: Request, res: Response, next: NextFunction) { try { const { name } = req.body; diff --git a/src/backend/src/routes/projects.routes.ts b/src/backend/src/routes/projects.routes.ts index 5b7552ebe3..e628044b71 100644 --- a/src/backend/src/routes/projects.routes.ts +++ b/src/backend/src/routes/projects.routes.ts @@ -5,7 +5,8 @@ import { nonEmptyString, projectValidators, validateInputs, - materialValidators + materialValidators, + copyMaterialsValidators } from '../utils/validation.utils.js'; import ProjectsController from '../controllers/projects.controllers.js'; @@ -93,6 +94,12 @@ projectRouter.post( ); projectRouter.post('/bom/material/:wbsNum/create', ...materialValidators, validateInputs, ProjectsController.createMaterial); projectRouter.post('/bom/material/:materialId/edit', ...materialValidators, validateInputs, ProjectsController.editMaterial); +projectRouter.post( + '/bom/material/copy', + ...copyMaterialsValidators, + validateInputs, + ProjectsController.copyMaterialsToProject +); projectRouter.post( '/bom/assembly/:assemblyId/edit', diff --git a/src/backend/src/services/boms.services.ts b/src/backend/src/services/boms.services.ts index 54c6c030d2..c946bcc292 100644 --- a/src/backend/src/services/boms.services.ts +++ b/src/backend/src/services/boms.services.ts @@ -157,6 +157,131 @@ export default class BillOfMaterialsService { return materialTransformer(createdMaterial); } + /** + * Copy materials to project + * @param user The user making the copy + * @param materialIds The ids of the materials to be copied + * @param destinationProjectId The id of the project to copy the materials to + * @param organization The organization the user is currently in + * @returns an array of the newly created material ids + * @throws errors that will be added here later + */ + static async copyMaterialsToProject( + user: User, + materialIds: string[], + destinationProjectId: WbsNumber, + organization: Organization + ): Promise { + // Fetch materials to be copied + const materials = await prisma.material.findMany({ + where: { + materialId: { in: materialIds }, + dateDeleted: null + }, + ...getMaterialQueryArgs(organization.organizationId) + }); + + // Validate all materials were found + if (materials.length !== materialIds.length) throw new NotFoundException('Material', 'Not all materials found'); + + // Validate all materials are from the current organization + const invalidMaterials = materials.filter((material) => material.unit?.organizationId !== organization.organizationId); + if (invalidMaterials.length > 0) throw new HttpException(400, 'All materials must be from the current organization'); + + // Fetch destination project (validates project is in the user's organization) + const destinationProject = await ProjectsService.getSingleProjectWithQueryArgs(destinationProjectId, organization); + + // Check user has correct permissions + const perms = + (await userHasPermission(user.userId, organization.organizationId, isLeadership)) || + isUserPartOfTeams(destinationProject.teams, user); + + if (!perms) throw new AccessDeniedException('Permission to copy materials denied'); + + // Create copied materials and assemblies (all or none) + return await prisma.$transaction(async (tx) => { + const assemblyMap = new Map(); + const newMaterialIds: string[] = []; + + for (const material of materials) { + let newAssemblyId = null; + + // Get or create assembly if needed + if (material.assemblyId) { + if (assemblyMap.has(material.assemblyId)) { + newAssemblyId = assemblyMap.get(material.assemblyId); + } else { + const oldAssembly = await tx.assembly.findUnique({ + where: { assemblyId: material.assemblyId } + }); + if (!oldAssembly) throw new NotFoundException('Assembly', material.assemblyId); + if (oldAssembly.dateDeleted) throw new DeletedException('Assembly', material.assemblyId); + + const newAssembly = await tx.assembly.create({ + data: { + name: oldAssembly.name, + dateCreated: new Date(), + userCreatedId: user.userId, + wbsElementId: destinationProject.wbsElementId, + pdmFileName: oldAssembly.pdmFileName + } + }); + assemblyMap.set(material.assemblyId, newAssembly.assemblyId); + newAssemblyId = newAssembly.assemblyId; + } + } + + const materialType = await tx.material_Type.findUnique({ + where: { id: material.materialTypeId } + }); + if (!materialType) throw new NotFoundException('Material Type', material.materialTypeId); + if (materialType.dateDeleted) throw new DeletedException('Material Type', materialType.name); + + if (material.manufacturerId) { + const manufacturer = await tx.manufacturer.findUnique({ + where: { id: material.manufacturerId } + }); + if (!manufacturer) throw new NotFoundException('Manufacturer', material.manufacturerId); + if (manufacturer.dateDeleted) throw new DeletedException('Manufacturer', manufacturer.name); + } + + if (material.unitId) { + const unit = await tx.unit.findUnique({ + where: { id: material.unitId } + }); + if (!unit) throw new NotFoundException('Unit', material.unitId); + } + + // Create the new material + const newMaterial = await tx.material.create({ + data: { + name: material.name, + status: 'NOT_READY_TO_ORDER', + materialTypeId: material.materialTypeId, + manufacturerId: material.manufacturerId, + manufacturerPartNumber: material.manufacturerPartNumber, + pdmFileName: material.pdmFileName, + quantity: material.quantity, + unitId: material.unitId, + price: material.price, + subtotal: material.subtotal, + linkUrl: material.linkUrl, + notes: material.notes, + dateCreated: new Date(), + userCreatedId: user.userId, + wbsElementId: destinationProject.wbsElementId, + assemblyId: newAssemblyId + }, + ...getMaterialQueryArgs(organization.organizationId) + }); + + newMaterialIds.push(newMaterial.materialId); + } + + return newMaterialIds; + }); + } + /** * Create an assembly * @param name The name of the assembly to be created diff --git a/src/backend/src/utils/validation.utils.ts b/src/backend/src/utils/validation.utils.ts index 08a14d653b..6d73e4236f 100644 --- a/src/backend/src/utils/validation.utils.ts +++ b/src/backend/src/utils/validation.utils.ts @@ -209,6 +209,11 @@ export const materialValidators = [ nonEmptyString(body('reimbursementRequestId')).optional(), body('notes').isString().optional() ]; +export const copyMaterialsValidators = [ + body('materialIds').isArray({ min: 1 }), + body('materialIds.*').isString().notEmpty(), + nonEmptyString(body('destinationWbsNum')) +]; export const validateInputs = (req: Request, res: Response, next: Function): void => { const errors = validationResult(req); if (!errors.isEmpty()) { diff --git a/src/frontend/src/apis/bom.api.ts b/src/frontend/src/apis/bom.api.ts index 215861dfb1..918c843770 100644 --- a/src/frontend/src/apis/bom.api.ts +++ b/src/frontend/src/apis/bom.api.ts @@ -105,6 +105,17 @@ export const editMaterial = async (materialId: string, material: MaterialDataSub return data; }; +/** + * Requests to copy materials to a project. + * @param materialIds The IDs of materials to copy + * @param destinationWbsNum The destination project WBS number + * @returns Array of newly created material IDs + */ +export const copyMaterialsToProject = async (materialIds: string[], destinationWbsNum: string) => { + const { data } = await axios.post(apiUrls.bomCopyMaterials(), { materialIds, destinationWbsNum }); + return data; +}; + /** * Soft deletes a material. * @param materialId diff --git a/src/frontend/src/hooks/bom.hooks.ts b/src/frontend/src/hooks/bom.hooks.ts index 674f358448..85539f970e 100644 --- a/src/frontend/src/hooks/bom.hooks.ts +++ b/src/frontend/src/hooks/bom.hooks.ts @@ -1,11 +1,13 @@ import { useMutation, useQuery, useQueryClient } from 'react-query'; import { Assembly, Manufacturer, Material, MaterialType, Unit, WbsNumber, wbsPipe } from 'shared'; +import { useToast } from '../hooks/toasts.hooks'; import { assignMaterialToAssembly, createAssembly, createManufacturer, deleteManufacturer, createMaterial, + copyMaterialsToProject, createMaterialType, createUnit, deleteSingleAssembly, @@ -139,6 +141,32 @@ export const useDeleteMaterial = (wbsNum: WbsNumber) => { ); }; +/** + * Custom React hook to copy materials to a project. + * @returns the mutation function to copy materials + */ +export const useCopyMaterialsToProject = () => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation( + ['materials', 'copy'], + async ({ materialIds, destinationWbsNum }) => { + const data = await copyMaterialsToProject(materialIds, destinationWbsNum); + return data; + }, + { + onSuccess: (newMaterialIds, variables) => { + queryClient.invalidateQueries(['materials', variables.destinationWbsNum]); + toast.success(`Successfully copied ${newMaterialIds.length} material${newMaterialIds.length !== 1 ? 's' : ''}!`); + }, + onError: () => { + toast.error('Failed to copy materials'); + } + } + ); +}; + /** * Custom React hook to delete a assembly. * @param wbsNum The wbs element you are deleting the assembly from diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index e207dd37b1..26bc4c63d2 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -325,6 +325,7 @@ const bomGetAssembliesByWbsNum = (wbsNum: WbsNumber) => `${bomEndpoints()}/${wbs const bomCreateMaterial = (wbsNum: WbsNumber) => `${materialEndpoints()}/${wbsPipe(wbsNum)}/create`; const bomEditMaterial = (materialId: string) => `${materialEndpoints()}/${materialId}/edit`; const bomDeleteMaterial = (materialId: string) => `${materialEndpoints()}/${materialId}/delete`; +const bomCopyMaterials = () => `${materialEndpoints()}/copy`; const bomCreateAssembly = (wbsNum: WbsNumber) => `${assemblyEndpoints()}/${wbsPipe(wbsNum)}/create`; const bomDeleteAssembly = (assemblyId: string) => `${assemblyEndpoints()}/${assemblyId}/delete`; const bomAssignAssembly = (materialId: string) => `${materialEndpoints()}/${materialId}/assign-assembly`; @@ -650,6 +651,7 @@ export const apiUrls = { bomCreateMaterial, bomEditMaterial, bomDeleteMaterial, + bomCopyMaterials, bomCreateAssembly, bomDeleteAssembly, bomAssignAssembly, From db45c9482b6c85da5f3c1d636aa59b16b9848039 Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Wed, 28 Jan 2026 22:08:10 -0500 Subject: [PATCH 2/9] fixed validation and enum issues --- src/backend/src/controllers/projects.controllers.ts | 4 ++-- src/backend/src/services/boms.services.ts | 2 +- src/backend/src/utils/validation.utils.ts | 7 +++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/backend/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index 35813161d5..eb66d4182b 100644 --- a/src/backend/src/controllers/projects.controllers.ts +++ b/src/backend/src/controllers/projects.controllers.ts @@ -239,11 +239,11 @@ export default class ProjectsController { static async copyMaterialsToProject(req: Request, res: Response, next: NextFunction) { try { const { materialIds, destinationWbsNum } = req.body; - const destinationWBS = validateWBS(destinationWbsNum as string); + const newMaterialIds = await BillOfMaterialsService.copyMaterialsToProject( req.currentUser, materialIds, - destinationWBS, + destinationWbsNum, req.organization ); res.status(200).json(newMaterialIds); diff --git a/src/backend/src/services/boms.services.ts b/src/backend/src/services/boms.services.ts index c946bcc292..87ed73a9e3 100644 --- a/src/backend/src/services/boms.services.ts +++ b/src/backend/src/services/boms.services.ts @@ -256,7 +256,7 @@ export default class BillOfMaterialsService { const newMaterial = await tx.material.create({ data: { name: material.name, - status: 'NOT_READY_TO_ORDER', + status: Material_Status.NOT_READY_TO_ORDER, materialTypeId: material.materialTypeId, manufacturerId: material.manufacturerId, manufacturerPartNumber: material.manufacturerPartNumber, diff --git a/src/backend/src/utils/validation.utils.ts b/src/backend/src/utils/validation.utils.ts index 6d73e4236f..5318b0221f 100644 --- a/src/backend/src/utils/validation.utils.ts +++ b/src/backend/src/utils/validation.utils.ts @@ -1,7 +1,7 @@ import { Design_Review_Status, Graph_Display_Type, Graph_Type, Measure, Special_Permission } from '@prisma/client'; import { Request, Response } from 'express'; import { body, query, ValidationChain, validationResult } from 'express-validator'; -import { MaterialStatus, TaskPriority, TaskStatus, WorkPackageStage, RoleEnum, WbsElementStatus } from 'shared'; +import { MaterialStatus, TaskPriority, TaskStatus, WorkPackageStage, RoleEnum, WbsElementStatus, validateWBS } from 'shared'; export const intMinZero = (validationObject: ValidationChain): ValidationChain => { return validationObject.isInt({ min: 0 }).not().isString(); @@ -212,7 +212,10 @@ export const materialValidators = [ export const copyMaterialsValidators = [ body('materialIds').isArray({ min: 1 }), body('materialIds.*').isString().notEmpty(), - nonEmptyString(body('destinationWbsNum')) + body('destinationWbsNum').custom((value) => { + validateWBS(value); + return true; + }) ]; export const validateInputs = (req: Request, res: Response, next: Function): void => { const errors = validationResult(req); From 061a3ccb088ea55c9ed2097b998bc9c12d20f458 Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Thu, 29 Jan 2026 19:03:12 -0500 Subject: [PATCH 3/9] removed assembly copying --- src/backend/src/services/boms.services.ts | 31 ++--------------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/src/backend/src/services/boms.services.ts b/src/backend/src/services/boms.services.ts index 87ed73a9e3..a005cc27fe 100644 --- a/src/backend/src/services/boms.services.ts +++ b/src/backend/src/services/boms.services.ts @@ -198,39 +198,12 @@ export default class BillOfMaterialsService { if (!perms) throw new AccessDeniedException('Permission to copy materials denied'); - // Create copied materials and assemblies (all or none) + // Create copied materials (all or none) return await prisma.$transaction(async (tx) => { const assemblyMap = new Map(); const newMaterialIds: string[] = []; for (const material of materials) { - let newAssemblyId = null; - - // Get or create assembly if needed - if (material.assemblyId) { - if (assemblyMap.has(material.assemblyId)) { - newAssemblyId = assemblyMap.get(material.assemblyId); - } else { - const oldAssembly = await tx.assembly.findUnique({ - where: { assemblyId: material.assemblyId } - }); - if (!oldAssembly) throw new NotFoundException('Assembly', material.assemblyId); - if (oldAssembly.dateDeleted) throw new DeletedException('Assembly', material.assemblyId); - - const newAssembly = await tx.assembly.create({ - data: { - name: oldAssembly.name, - dateCreated: new Date(), - userCreatedId: user.userId, - wbsElementId: destinationProject.wbsElementId, - pdmFileName: oldAssembly.pdmFileName - } - }); - assemblyMap.set(material.assemblyId, newAssembly.assemblyId); - newAssemblyId = newAssembly.assemblyId; - } - } - const materialType = await tx.material_Type.findUnique({ where: { id: material.materialTypeId } }); @@ -270,7 +243,7 @@ export default class BillOfMaterialsService { dateCreated: new Date(), userCreatedId: user.userId, wbsElementId: destinationProject.wbsElementId, - assemblyId: newAssemblyId + assemblyId: null }, ...getMaterialQueryArgs(organization.organizationId) }); From 4d389c7a6a469a1e53810538da887820eca936df Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Thu, 29 Jan 2026 19:07:24 -0500 Subject: [PATCH 4/9] fixed linting error by removing assembly map --- src/backend/src/services/boms.services.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/backend/src/services/boms.services.ts b/src/backend/src/services/boms.services.ts index a005cc27fe..89a4d74130 100644 --- a/src/backend/src/services/boms.services.ts +++ b/src/backend/src/services/boms.services.ts @@ -200,7 +200,6 @@ export default class BillOfMaterialsService { // Create copied materials (all or none) return await prisma.$transaction(async (tx) => { - const assemblyMap = new Map(); const newMaterialIds: string[] = []; for (const material of materials) { From ca129168e8dac49f5aa7421461ba609a7cf10349 Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Wed, 4 Feb 2026 14:26:48 -0500 Subject: [PATCH 5/9] updated validation, id handling and added tests --- src/backend/src/services/boms.services.ts | 10 +- src/backend/src/utils/validation.utils.ts | 12 ++- src/backend/tests/unmocked/project.test.ts | 114 +++++++++++++++++++++ 3 files changed, 125 insertions(+), 11 deletions(-) diff --git a/src/backend/src/services/boms.services.ts b/src/backend/src/services/boms.services.ts index 89a4d74130..026ef132c2 100644 --- a/src/backend/src/services/boms.services.ts +++ b/src/backend/src/services/boms.services.ts @@ -181,24 +181,21 @@ export default class BillOfMaterialsService { ...getMaterialQueryArgs(organization.organizationId) }); - // Validate all materials were found if (materials.length !== materialIds.length) throw new NotFoundException('Material', 'Not all materials found'); - // Validate all materials are from the current organization - const invalidMaterials = materials.filter((material) => material.unit?.organizationId !== organization.organizationId); + const invalidMaterials = materials.filter( + (material) => material.materialType.organizationId !== organization.organizationId + ); if (invalidMaterials.length > 0) throw new HttpException(400, 'All materials must be from the current organization'); - // Fetch destination project (validates project is in the user's organization) const destinationProject = await ProjectsService.getSingleProjectWithQueryArgs(destinationProjectId, organization); - // Check user has correct permissions const perms = (await userHasPermission(user.userId, organization.organizationId, isLeadership)) || isUserPartOfTeams(destinationProject.teams, user); if (!perms) throw new AccessDeniedException('Permission to copy materials denied'); - // Create copied materials (all or none) return await prisma.$transaction(async (tx) => { const newMaterialIds: string[] = []; @@ -224,7 +221,6 @@ export default class BillOfMaterialsService { if (!unit) throw new NotFoundException('Unit', material.unitId); } - // Create the new material const newMaterial = await tx.material.create({ data: { name: material.name, diff --git a/src/backend/src/utils/validation.utils.ts b/src/backend/src/utils/validation.utils.ts index 5318b0221f..1edbc5fcdb 100644 --- a/src/backend/src/utils/validation.utils.ts +++ b/src/backend/src/utils/validation.utils.ts @@ -212,10 +212,14 @@ export const materialValidators = [ export const copyMaterialsValidators = [ body('materialIds').isArray({ min: 1 }), body('materialIds.*').isString().notEmpty(), - body('destinationWbsNum').custom((value) => { - validateWBS(value); - return true; - }) + body('destinationWbsNum') + .custom((value) => { + validateWBS(value); + return true; + }) + .customSanitizer((value) => { + return validateWBS(value); + }) ]; export const validateInputs = (req: Request, res: Response, next: Function): void => { const errors = validationResult(req); diff --git a/src/backend/tests/unmocked/project.test.ts b/src/backend/tests/unmocked/project.test.ts index c7788eb8b7..4c8ad6f5a7 100644 --- a/src/backend/tests/unmocked/project.test.ts +++ b/src/backend/tests/unmocked/project.test.ts @@ -1,3 +1,4 @@ +import prisma from '../../src/prisma/prisma.js'; import { createTestReimbursementRequest, resetUsers } from '../test-utils.js'; import { Organization, User } from '@prisma/client'; import BillOfMaterials from '../../src/services/boms.services.js'; @@ -93,6 +94,119 @@ describe('Material Tests', () => { }); }); + describe('Copy materials to project', () => { + test('Successfully copies materials and resets key fields', async () => { + const materialType = await BillOfMaterials.createMaterialType('Capacitor', createdUser, org); + const manufacturer = await BillOfMaterials.createManufacturer(createdUser, 'Mouser', org); + + const car = await prisma.car.findFirst({ + where: { + wbsElement: { + organizationId: org.organizationId + } + } + }); + + const project2 = await prisma.project.create({ + data: { + summary: 'Test destination project', + car: { + connect: { carId: car!.carId } + }, + wbsElement: { + create: { + carNumber: 0, + projectNumber: 2, + workPackageNumber: 0, + name: 'Destination Project', + organizationId: org.organizationId + } + } + } + }); + + const material1 = await BillOfMaterials.createMaterial( + createdUser, + '100uF Capacitor', + MaterialStatus.Ordered, + materialType.name, + 'https://example.com/mat1', + { carNumber: 0, projectNumber: 1, workPackageNumber: 0 }, + org, + manufacturer.name, + 'CAP-100UF', + new Decimal(10), + 50, + 500, + 'Test notes', + undefined, + undefined, + undefined, + reimbursementRequest.reimbursementRequestId + ); + + const material2 = await BillOfMaterials.createMaterial( + createdUser, + '220uF Capacitor', + MaterialStatus.ReadyToOrder, + materialType.name, + 'https://example.com/mat2', + { carNumber: 0, projectNumber: 1, workPackageNumber: 0 }, + org, + manufacturer.name, + 'CAP-220UF', + new Decimal(5), + 75, + 375 + ); + + const newMaterialIds = await BillOfMaterials.copyMaterialsToProject( + createdUser, + [material1.materialId, material2.materialId], + { carNumber: 0, projectNumber: 2, workPackageNumber: 0 }, + org + ); + + expect(newMaterialIds.length).toBe(2); + + const copiedMaterials = await prisma.material.findMany({ + where: { materialId: { in: newMaterialIds } }, + include: { wbsElement: true } + }); + + const copiedMat1 = copiedMaterials.find((m) => m.name === '100uF Capacitor')!; + const copiedMat2 = copiedMaterials.find((m) => m.name === '220uF Capacitor')!; + + expect(copiedMat1.wbsElement.projectNumber).toBe(2); + expect(copiedMat2.wbsElement.projectNumber).toBe(2); + + expect(copiedMat1.status).toBe('NOT_READY_TO_ORDER'); + expect(copiedMat1.userCreatedId).toBe(createdUser.userId); + expect(copiedMat1.reimbursementRequestId).toBeNull(); + expect(copiedMat1.assemblyId).toBeNull(); + + expect(copiedMat1.name).toBe('100uF Capacitor'); + expect(copiedMat1.price).toBe(50); + expect(copiedMat1.quantity?.toString()).toBe('10'); + expect(copiedMat1.manufacturerPartNumber).toBe('CAP-100UF'); + expect(copiedMat1.notes).toBe('Test notes'); + + expect(copiedMat2.status).toBe('NOT_READY_TO_ORDER'); + expect(copiedMat2.reimbursementRequestId).toBeNull(); + }); + + test('Fails when material does not exist', async () => { + await expect( + BillOfMaterials.copyMaterialsToProject( + createdUser, + ['non-existent-id'], + { carNumber: 0, projectNumber: 1, workPackageNumber: 0 }, + org + ) + ).rejects.toThrow(NotFoundException); + }); + }); + describe('Edit a material', () => { test('Updates the reimbursement request when originally undefined', async () => { const materialType = await BillOfMaterials.createMaterialType('Resistor', createdUser, org); From b3d69566c1701ec1ab92ef15dba052e8792921a1 Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Wed, 4 Feb 2026 14:38:48 -0500 Subject: [PATCH 6/9] fixed linting --- src/backend/tests/unmocked/project.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/tests/unmocked/project.test.ts b/src/backend/tests/unmocked/project.test.ts index 4c8ad6f5a7..8982bca901 100644 --- a/src/backend/tests/unmocked/project.test.ts +++ b/src/backend/tests/unmocked/project.test.ts @@ -107,7 +107,7 @@ describe('Material Tests', () => { } }); - const project2 = await prisma.project.create({ + await prisma.project.create({ data: { summary: 'Test destination project', car: { From a1cbdd06d78cd14c10e42573b03b9317f8d76ae4 Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Thu, 5 Feb 2026 19:01:37 -0500 Subject: [PATCH 7/9] made optimizations --- src/backend/src/utils/validation.utils.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/backend/src/utils/validation.utils.ts b/src/backend/src/utils/validation.utils.ts index 1edbc5fcdb..64f725b4c0 100644 --- a/src/backend/src/utils/validation.utils.ts +++ b/src/backend/src/utils/validation.utils.ts @@ -211,15 +211,10 @@ export const materialValidators = [ ]; export const copyMaterialsValidators = [ body('materialIds').isArray({ min: 1 }), - body('materialIds.*').isString().notEmpty(), - body('destinationWbsNum') - .custom((value) => { - validateWBS(value); - return true; - }) - .customSanitizer((value) => { - return validateWBS(value); - }) + nonEmptyString(body('materialIds.*')), + body('destinationWbsNum').customSanitizer((value) => { + return validateWBS(value); + }) ]; export const validateInputs = (req: Request, res: Response, next: Function): void => { const errors = validationResult(req); From d213c6a8b6a01a65bfbfb48a3dbb7fa553f739d4 Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Fri, 6 Feb 2026 10:13:55 -0500 Subject: [PATCH 8/9] moved validation + removed redundant id checks --- src/backend/src/routes/projects.routes.ts | 10 +++++++--- src/backend/src/services/boms.services.ts | 21 --------------------- src/backend/src/utils/validation.utils.ts | 7 ------- 3 files changed, 7 insertions(+), 31 deletions(-) diff --git a/src/backend/src/routes/projects.routes.ts b/src/backend/src/routes/projects.routes.ts index e628044b71..f387399ee5 100644 --- a/src/backend/src/routes/projects.routes.ts +++ b/src/backend/src/routes/projects.routes.ts @@ -5,9 +5,9 @@ import { nonEmptyString, projectValidators, validateInputs, - materialValidators, - copyMaterialsValidators + materialValidators } from '../utils/validation.utils.js'; +import { validateWBS } from 'shared'; import ProjectsController from '../controllers/projects.controllers.js'; const projectRouter = express.Router(); @@ -96,7 +96,11 @@ projectRouter.post('/bom/material/:wbsNum/create', ...materialValidators, valida projectRouter.post('/bom/material/:materialId/edit', ...materialValidators, validateInputs, ProjectsController.editMaterial); projectRouter.post( '/bom/material/copy', - ...copyMaterialsValidators, + body('materialIds').isArray({ min: 1 }), + nonEmptyString(body('materialIds.*')), + body('destinationWbsNum').customSanitizer((value) => { + return validateWBS(value); + }), validateInputs, ProjectsController.copyMaterialsToProject ); diff --git a/src/backend/src/services/boms.services.ts b/src/backend/src/services/boms.services.ts index 026ef132c2..0f7b2d46d2 100644 --- a/src/backend/src/services/boms.services.ts +++ b/src/backend/src/services/boms.services.ts @@ -200,27 +200,6 @@ export default class BillOfMaterialsService { const newMaterialIds: string[] = []; for (const material of materials) { - const materialType = await tx.material_Type.findUnique({ - where: { id: material.materialTypeId } - }); - if (!materialType) throw new NotFoundException('Material Type', material.materialTypeId); - if (materialType.dateDeleted) throw new DeletedException('Material Type', materialType.name); - - if (material.manufacturerId) { - const manufacturer = await tx.manufacturer.findUnique({ - where: { id: material.manufacturerId } - }); - if (!manufacturer) throw new NotFoundException('Manufacturer', material.manufacturerId); - if (manufacturer.dateDeleted) throw new DeletedException('Manufacturer', manufacturer.name); - } - - if (material.unitId) { - const unit = await tx.unit.findUnique({ - where: { id: material.unitId } - }); - if (!unit) throw new NotFoundException('Unit', material.unitId); - } - const newMaterial = await tx.material.create({ data: { name: material.name, diff --git a/src/backend/src/utils/validation.utils.ts b/src/backend/src/utils/validation.utils.ts index 64f725b4c0..0124064c2b 100644 --- a/src/backend/src/utils/validation.utils.ts +++ b/src/backend/src/utils/validation.utils.ts @@ -209,13 +209,6 @@ export const materialValidators = [ nonEmptyString(body('reimbursementRequestId')).optional(), body('notes').isString().optional() ]; -export const copyMaterialsValidators = [ - body('materialIds').isArray({ min: 1 }), - nonEmptyString(body('materialIds.*')), - body('destinationWbsNum').customSanitizer((value) => { - return validateWBS(value); - }) -]; export const validateInputs = (req: Request, res: Response, next: Function): void => { const errors = validationResult(req); if (!errors.isEmpty()) { From 2354b31fe19ddf73248c5d79d8a62df0b68c0faf Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Fri, 6 Feb 2026 10:26:35 -0500 Subject: [PATCH 9/9] removed unnecessary import --- src/backend/src/utils/validation.utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/src/utils/validation.utils.ts b/src/backend/src/utils/validation.utils.ts index 0124064c2b..08a14d653b 100644 --- a/src/backend/src/utils/validation.utils.ts +++ b/src/backend/src/utils/validation.utils.ts @@ -1,7 +1,7 @@ import { Design_Review_Status, Graph_Display_Type, Graph_Type, Measure, Special_Permission } from '@prisma/client'; import { Request, Response } from 'express'; import { body, query, ValidationChain, validationResult } from 'express-validator'; -import { MaterialStatus, TaskPriority, TaskStatus, WorkPackageStage, RoleEnum, WbsElementStatus, validateWBS } from 'shared'; +import { MaterialStatus, TaskPriority, TaskStatus, WorkPackageStage, RoleEnum, WbsElementStatus } from 'shared'; export const intMinZero = (validationObject: ValidationChain): ValidationChain => { return validationObject.isInt({ min: 0 }).not().isString();