diff --git a/src/backend/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index c874848b1e..eb66d4182b 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 newMaterialIds = await BillOfMaterialsService.copyMaterialsToProject( + req.currentUser, + materialIds, + destinationWbsNum, + 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..f387399ee5 100644 --- a/src/backend/src/routes/projects.routes.ts +++ b/src/backend/src/routes/projects.routes.ts @@ -7,6 +7,7 @@ import { validateInputs, materialValidators } from '../utils/validation.utils.js'; +import { validateWBS } from 'shared'; import ProjectsController from '../controllers/projects.controllers.js'; const projectRouter = express.Router(); @@ -93,6 +94,16 @@ 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', + body('materialIds').isArray({ min: 1 }), + nonEmptyString(body('materialIds.*')), + body('destinationWbsNum').customSanitizer((value) => { + return validateWBS(value); + }), + 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..0f7b2d46d2 100644 --- a/src/backend/src/services/boms.services.ts +++ b/src/backend/src/services/boms.services.ts @@ -157,6 +157,78 @@ 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) + }); + + if (materials.length !== materialIds.length) throw new NotFoundException('Material', 'Not all materials found'); + + 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'); + + const destinationProject = await ProjectsService.getSingleProjectWithQueryArgs(destinationProjectId, organization); + + const perms = + (await userHasPermission(user.userId, organization.organizationId, isLeadership)) || + isUserPartOfTeams(destinationProject.teams, user); + + if (!perms) throw new AccessDeniedException('Permission to copy materials denied'); + + return await prisma.$transaction(async (tx) => { + const newMaterialIds: string[] = []; + + for (const material of materials) { + const newMaterial = await tx.material.create({ + data: { + name: material.name, + status: Material_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: null + }, + ...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/tests/unmocked/project.test.ts b/src/backend/tests/unmocked/project.test.ts index c7788eb8b7..8982bca901 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 + } + } + }); + + 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); 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,