Skip to content

Commit af5fdc6

Browse files
authored
Merge pull request #1663 from Northeastern-Electric-Racing/#1509-Create-Assembly-Endpoint
#1509 create assembly endpoint
2 parents 1c9e3bb + 16c0401 commit af5fdc6

8 files changed

Lines changed: 273 additions & 6 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,18 @@ export default class ProjectsController {
128128
}
129129
}
130130

131+
static async createAssembly(req: Request, res: Response, next: NextFunction) {
132+
try {
133+
const user: User = await getCurrentUser(res);
134+
const wbsNum: WbsNumber = validateWBS(req.params.wbsNum);
135+
const { name, pdmFileName } = req.body;
136+
const createAssembly = await ProjectsService.createAssembly(name, user, wbsNum, pdmFileName);
137+
res.status(200).json(createAssembly);
138+
} catch (error: unknown) {
139+
next(error);
140+
}
141+
}
142+
131143
static async createManufacturer(req: Request, res: Response, next: NextFunction) {
132144
try {
133145
const { name } = req.body;

src/backend/src/prisma/migrations/20231019013832_add_bom/migration.sql renamed to src/backend/src/prisma/migrations/20231024124220_add_bom/migration.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ CREATE TABLE "Unit" (
1212
CREATE TABLE "Assembly" (
1313
"assemblyId" TEXT NOT NULL,
1414
"name" TEXT NOT NULL,
15-
"pdmFileName" TEXT NOT NULL,
15+
"pdmFileName" TEXT,
1616
"dateDeleted" TIMESTAMP(3),
1717
"userDeletedId" INTEGER,
1818
"dateCreated" TIMESTAMP(3) NOT NULL,

src/backend/src/prisma/schema.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ model Unit {
474474
model Assembly {
475475
assemblyId String @id @default(uuid())
476476
name String @unique
477-
pdmFileName String
477+
pdmFileName String?
478478
dateDeleted DateTime?
479479
userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "assemblyDeleter")
480480
userDeletedId Int?

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,11 @@ projectRouter.post(
5656
ProjectsController.createManufacturer
5757
);
5858
projectRouter.post('/bom/material-type/create', nonEmptyString(body('name')), ProjectsController.createMaterialType);
59+
projectRouter.post(
60+
'/bom/assembly/:wbsNum/create',
61+
nonEmptyString(body('name')),
62+
nonEmptyString(body('pdmFileName')).optional(),
63+
ProjectsController.createAssembly
64+
);
5965

6066
export default projectRouter;

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

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Material_Type, User } from '@prisma/client';
1+
import { Material_Type, User, Assembly } from '@prisma/client';
22
import { isAdmin, isGuest, isLeadership, isProject, LinkCreateArgs, LinkType, Project, WbsNumber, wbsPipe } from 'shared';
33
import projectQueryArgs from '../prisma-query-args/projects.query-args';
44
import prisma from '../prisma/prisma';
@@ -30,6 +30,7 @@ import linkQueryArgs from '../prisma-query-args/links.query-args';
3030
import linkTypeQueryArgs from '../prisma-query-args/link-types.query-args';
3131
import { linkTypeTransformer } from '../transformers/links.transformer';
3232
import { updateLinks, linkToChangeListValue } from '../utils/links.utils';
33+
import { isUserPartOfTeams } from '../utils/teams.utils';
3334

3435
export default class ProjectsService {
3536
/**
@@ -605,6 +606,62 @@ export default class ProjectsService {
605606
}
606607

607608
/**
609+
* Create an assembly
610+
* @param name The name of the assembly to be created
611+
* @param userCreated The user creating the assembly
612+
* @param wbsElementId The
613+
* @param pdmFileName optional - The name of the file holding the assembly
614+
* @returns the project that the user has favorited/unfavorited
615+
* @throws if the project wbs doesn't exist or is not corresponding to a project
616+
*/
617+
static async createAssembly(
618+
name: string,
619+
userCreated: User,
620+
wbsNumber: WbsNumber,
621+
pdmFileName?: string
622+
): Promise<Assembly> {
623+
if (!isProject(wbsNumber)) throw new HttpException(400, `${wbsPipe(wbsNumber)} is not a valid project WBS #!`);
624+
const { carNumber, projectNumber, workPackageNumber } = wbsNumber;
625+
626+
const project = await prisma.project.findFirst({
627+
where: {
628+
wbsElement: {
629+
carNumber,
630+
projectNumber,
631+
workPackageNumber
632+
}
633+
},
634+
...projectQueryArgs
635+
});
636+
637+
if (!project) throw new NotFoundException('Project', wbsPipe(wbsNumber));
638+
if (project.wbsElement.dateDeleted) throw new DeletedException('Project', project.projectId);
639+
640+
const checkAssembly = await prisma.assembly.findUnique({ where: { name } });
641+
642+
if (checkAssembly) throw new HttpException(400, `${name} already exists as an assembly!`);
643+
644+
const { teams, wbsElementId } = project;
645+
646+
if (!isAdmin(userCreated.role) && !isUserPartOfTeams(teams, userCreated))
647+
throw new AccessDeniedException('Users must be admin, or assigned to the team to create assemblies');
648+
649+
const userCreatedId = userCreated.userId;
650+
651+
const assembly = await prisma.assembly.create({
652+
data: {
653+
name,
654+
dateCreated: new Date(),
655+
userCreatedId,
656+
wbsElementId,
657+
pdmFileName
658+
}
659+
});
660+
661+
return assembly;
662+
}
663+
664+
/*
608665
* Creates a new Manufacturer
609666
* @param submitter the user who's creating the manufacturer
610667
* @param name the name of the manufacturer

src/backend/src/utils/teams.utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,14 @@ export const isUserOnTeam = (team: Prisma.TeamGetPayload<typeof teamQueryArgsMem
3939
export const areUsersPartOfTeams = (teams: Prisma.TeamGetPayload<typeof teamQueryArgsMembersOnly>[], users: User[]) => {
4040
return users.every((user) => teams.some((team) => isUserOnTeam(team, user)));
4141
};
42+
43+
/**
44+
* Validates that the given user is part of at least one of the given teams
45+
*
46+
* @param teams the teams to check the users are on
47+
* @param user the user to check
48+
* @returns if all of the users are part of at least one of ther teams
49+
*/
50+
export const isUserPartOfTeams = (teams: Prisma.TeamGetPayload<typeof teamQueryArgsMembersOnly>[], user: User) => {
51+
return teams.some((team) => isUserOnTeam(team, user));
52+
};

src/backend/tests/projects.test.ts

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import prisma from '../src/prisma/prisma';
22
import { getHighestProjectNumber } from '../src/utils/projects.utils';
33
import * as changeRequestUtils from '../src/utils/change-requests.utils';
4-
import { aquaman, batman, wonderwoman } from './test-data/users.test-data';
5-
import { prismaManufacturer1, prismaProject1, sharedProject1, toolMaterial } from './test-data/projects.test-data';
4+
import { aquaman, batman, wonderwoman, superman } from './test-data/users.test-data';
5+
import {
6+
prismaProject1,
7+
sharedProject1,
8+
prismaAssembly1,
9+
toolMaterial,
10+
prismaManufacturer1
11+
} from './test-data/projects.test-data';
612
import { prismaChangeRequest1 } from './test-data/change-requests.test-data';
713
import { prismaTeam1 } from './test-data/teams.test-data';
814
import * as projectTransformer from '../src/transformers/projects.transformer';
@@ -257,6 +263,169 @@ describe('Projects', () => {
257263
});
258264
});
259265

266+
describe('createAssembly', () => {
267+
test('createAssembly fails given invalid project wbs number', async () => {
268+
await expect(
269+
async () =>
270+
await ProjectsService.createAssembly(
271+
'new assembly',
272+
batman,
273+
{
274+
carNumber: 1,
275+
projectNumber: 1,
276+
workPackageNumber: 1
277+
},
278+
'file.txt'
279+
)
280+
).rejects.toThrow(new HttpException(400, `1.1.1 is not a valid project WBS #!`));
281+
});
282+
283+
test('createAssembly fails when associated wbsElement doesnt exist', async () => {
284+
vi.spyOn(prisma.project, 'findFirst').mockResolvedValue(null);
285+
await expect(
286+
async () =>
287+
await ProjectsService.createAssembly(
288+
'new assembly',
289+
batman,
290+
{
291+
carNumber: 1,
292+
projectNumber: 1,
293+
workPackageNumber: 0
294+
},
295+
'file.txt'
296+
)
297+
).rejects.toThrow(new NotFoundException('Project', '1.1.0'));
298+
});
299+
300+
test('createAssembly fails when project has been deleted', async () => {
301+
vi.spyOn(prisma.project, 'findFirst').mockResolvedValue({
302+
wbsElement: { ...prismaProject1.wbsElement, dateDeleted: new Date() },
303+
projectId: prismaProject1.projectId
304+
} as any);
305+
await expect(
306+
async () =>
307+
await ProjectsService.createAssembly(
308+
'new assembly',
309+
batman,
310+
{
311+
carNumber: 1,
312+
projectNumber: 1,
313+
workPackageNumber: 0
314+
},
315+
'file.txt'
316+
)
317+
).rejects.toThrow(new DeletedException('Project', prismaProject1.projectId));
318+
});
319+
320+
test('createAssembly fails if name is not unique', async () => {
321+
vi.spyOn(prisma.project, 'findFirst').mockResolvedValue({
322+
wbsElement: { ...prismaProject1.wbsElement },
323+
projectId: prismaProject1.projectId
324+
} as any);
325+
vi.spyOn(prisma.assembly, 'findUnique').mockResolvedValue({ ...prismaAssembly1, name: 'a1' });
326+
327+
// no error, no return value
328+
await expect(
329+
async () =>
330+
await ProjectsService.createAssembly(
331+
'a1',
332+
batman,
333+
{
334+
carNumber: 1,
335+
projectNumber: 1,
336+
workPackageNumber: 0
337+
},
338+
'file.txt'
339+
)
340+
).rejects.toThrow(new HttpException(400, `a1 already exists as an assembly!`));
341+
});
342+
343+
test('createAssembly fails when no permissions', async () => {
344+
vi.spyOn(prisma.project, 'findFirst').mockResolvedValue({
345+
wbsElement: { ...prismaProject1.wbsElement, dateDeleted: '' },
346+
projectId: prismaProject1.projectId,
347+
teams: [{ prismaTeam1, leads: [], members: [] }]
348+
} as any);
349+
vi.spyOn(prisma.assembly, 'findUnique').mockResolvedValue(null);
350+
await expect(
351+
async () =>
352+
await ProjectsService.createAssembly(
353+
'new assembly',
354+
wonderwoman,
355+
{
356+
carNumber: 1,
357+
projectNumber: 1,
358+
workPackageNumber: 0
359+
},
360+
'file.txt'
361+
)
362+
).rejects.toThrow(new AccessDeniedException(`Users must be admin, or assigned to the team to create assemblies`));
363+
});
364+
365+
test('createAssembly fails when no permissions - leadership', async () => {
366+
vi.spyOn(prisma.project, 'findFirst').mockResolvedValue({
367+
wbsElement: { ...prismaProject1.wbsElement, dateDeleted: '' },
368+
projectId: prismaProject1.projectId,
369+
teams: [{ prismaTeam1, leads: [], members: [] }]
370+
} as any);
371+
vi.spyOn(prisma.assembly, 'findUnique').mockResolvedValue(null);
372+
await expect(
373+
async () =>
374+
await ProjectsService.createAssembly(
375+
'new assembly',
376+
aquaman,
377+
{
378+
carNumber: 1,
379+
projectNumber: 1,
380+
workPackageNumber: 0
381+
},
382+
'file.txt'
383+
)
384+
).rejects.toThrow(new AccessDeniedException(`Users must be admin, or assigned to the team to create assemblies`));
385+
});
386+
387+
test('createAssembly works if the submitter is admin', async () => {
388+
vi.spyOn(prisma.project, 'findFirst').mockResolvedValue({
389+
wbsElement: { ...prismaProject1.wbsElement, dateDeleted: '' },
390+
projectId: prismaProject1.projectId,
391+
teams: [{ prismaTeam1, leads: [], members: [] }]
392+
} as any);
393+
vi.spyOn(prisma.assembly, 'findUnique').mockResolvedValue(null);
394+
vi.spyOn(prisma.assembly, 'create').mockResolvedValue(prismaAssembly1);
395+
396+
// no error, no return value
397+
await ProjectsService.createAssembly(
398+
'new assembly',
399+
batman,
400+
{
401+
carNumber: 1,
402+
projectNumber: 1,
403+
workPackageNumber: 0
404+
},
405+
'file.txt'
406+
);
407+
});
408+
409+
test('createAssembly works if the submitter is on the team', async () => {
410+
mockGetHighestProjectNumber.mockResolvedValue(0);
411+
vi.spyOn(prisma.assembly, 'create').mockResolvedValue(prismaAssembly1);
412+
vi.spyOn(prisma.project, 'findFirst').mockResolvedValue(prismaProject1);
413+
vi.spyOn(prisma.assembly, 'findUnique').mockResolvedValue(null);
414+
415+
// no error, no return value
416+
await ProjectsService.createAssembly(
417+
'new assembly',
418+
superman,
419+
{
420+
carNumber: 1,
421+
projectNumber: 1,
422+
workPackageNumber: 0
423+
},
424+
'file.txt'
425+
);
426+
});
427+
});
428+
260429
describe('Manufacturer Tests', () => {
261430
test('createManufacturer throws an error if user is a guest', async () => {
262431
await expect(ProjectsService.createManufacturer(wonderwoman, 'NAME')).rejects.toThrow(

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import {
33
Prisma,
44
WBS_Element_Status as PrismaWBSElementStatus,
55
Project,
6-
Manufacturer
6+
Manufacturer,
7+
Assembly
78
} from '@prisma/client';
89
import { Project as SharedProject, WbsElementStatus } from 'shared';
910
import projectQueryArgs from '../../src/prisma-query-args/projects.query-args';
@@ -104,6 +105,17 @@ export const sharedProject1: SharedProject = {
104105
teams: []
105106
};
106107

108+
export const prismaAssembly1: Assembly = {
109+
name: 'New Assembly',
110+
pdmFileName: 'file.txt',
111+
dateCreated: new Date('10-19-2023'),
112+
userCreatedId: batman.userId,
113+
wbsElementId: 66,
114+
dateDeleted: null,
115+
userDeletedId: null,
116+
assemblyId: '1'
117+
};
118+
107119
export const prismaManufacturer1: Manufacturer = {
108120
name: 'Manufacturer1',
109121
dateCreated: new Date('10-1-2023'),

0 commit comments

Comments
 (0)