diff --git a/src/backend/src/prisma/context.ts b/src/backend/src/prisma/context.ts index 52aa2c6827..7d9d81db5f 100644 --- a/src/backend/src/prisma/context.ts +++ b/src/backend/src/prisma/context.ts @@ -1,5 +1,6 @@ import { Account_Code, + Car, Description_Bullet_Type, Index_Code, Link_Type, @@ -7,11 +8,14 @@ import { Material_Type, Organization, Prisma, + Project, Reimbursement_Product_Other_Reason, Role, Team_Type, + Team, Unit, - Vendor + Vendor, + WBS_Element } from '@prisma/client'; import { RoleEnum } from 'shared'; import { getUserQueryArgs } from '../prisma-query-args/user.query-args.js'; @@ -74,3 +78,12 @@ export type CarOutput = { cars: CarContext[]; currentYearCar: CarContext; }; + +export type ProjectContext = { + project: Project & { + wbsElement: WBS_Element; + teams: Team[]; + car: Car; + }; + timeline: DateRange; +}; diff --git a/src/backend/src/prisma/dev-seed.ts b/src/backend/src/prisma/dev-seed.ts index cae77f1a07..266a2f816d 100644 --- a/src/backend/src/prisma/dev-seed.ts +++ b/src/backend/src/prisma/dev-seed.ts @@ -12,6 +12,7 @@ import { OrganizationProcess } from './seed/organization.process.js'; import { CarProcess } from './seed/car.process.js'; import { ConfigDataProcess } from './seed/config-data.process.js'; import { TeamProcess } from './seed/team.process.js'; +import { ProjectProcess } from './seed/project.process.js'; import { SchedulingProcess } from './seed/scheduling.process.js'; const prisma = new PrismaClient(); @@ -25,7 +26,8 @@ await new SeedRunner() new UsersProcess(), new ConfigDataProcess(), new SchedulingProcess(), - new TeamProcess() + new TeamProcess(), + new ProjectProcess() ) .run(); diff --git a/src/backend/src/prisma/factories/project.factory.ts b/src/backend/src/prisma/factories/project.factory.ts new file mode 100644 index 0000000000..327111ecea --- /dev/null +++ b/src/backend/src/prisma/factories/project.factory.ts @@ -0,0 +1,276 @@ +import { Faker } from '@faker-js/faker'; +import { Link_Type, Prisma, WBS_Element_Status } from '@prisma/client'; +import { DateRange } from '../context.js'; + +export const PROJECTS_PER_CAR = 30; + +const MIN_PROJECT_MONTHS = 3; +const MAX_PROJECT_MONTHS = 12; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const DAYS_PER_MONTH = 30; + +const PROJECT_NAME_PARTS = [ + 'Accumulator', + 'Battery Box', + 'Brake Pedal', + 'Brake System', + 'Cooling Loop', + 'DAQ Harness', + 'Dashboard', + 'Drivetrain', + 'Firewall', + 'Front Wing', + 'HV Disconnect', + 'Impact Attenuator', + 'Inverter Mount', + 'LV Battery', + 'Main Hoop', + 'Motor Controller', + 'Nose Cone', + 'Pedal Box', + 'Power Distribution', + 'Rear Wing', + 'Seat Mount', + 'Shutdown Circuit', + 'Steering Rack', + 'Suspension Geometry', + 'Telemetry System', + 'Tractive System', + 'Vehicle Controls', + 'Wheel Assembly', + 'Wiring Harness', + 'Yaw Sensor' +]; + +const PROJECT_QUALIFIERS = [ + 'Design', + 'Integration', + 'Manufacturing', + 'Validation', + 'Testing', + 'Redesign', + 'Optimization', + 'Packaging', + 'Prototype', + 'Assembly' +]; + +const PROJECT_SUMMARY_BY_PART: Record = { + Accumulator: 'Design and validate the accumulator subsystem for the car.', + 'Battery Box': 'Design, manufacture, and validate the battery box assembly.', + 'Brake Pedal': 'Develop the brake pedal and pedal box interface.', + 'Brake System': 'Design and validate hydraulic braking components.', + 'Cooling Loop': 'Build and test the cooling loop for thermal management.', + 'DAQ Harness': 'Create the data acquisition harness and sensor wiring.', + Dashboard: 'Develop the driver-facing dashboard and controls.', + Drivetrain: 'Integrate drivetrain components and validate performance.', + Firewall: 'Design and manufacture rules-compliant firewall components.', + 'Front Wing': 'Design, manufacture, and test front aerodynamic elements.', + 'HV Disconnect': 'Develop high-voltage disconnect and safety interfaces.', + 'Impact Attenuator': 'Develop a rules-compliant impact attenuator.', + 'Inverter Mount': 'Package and mount the inverter assembly.', + 'LV Battery': 'Develop the low-voltage battery and supporting circuits.', + 'Main Hoop': 'Design and validate the main hoop structure.', + 'Motor Controller': 'Integrate motor controller hardware and software.', + 'Nose Cone': 'Design and manufacture the nose cone assembly.', + 'Pedal Box': 'Package brake and throttle pedal systems.', + 'Power Distribution': 'Build and validate vehicle power distribution.', + 'Rear Wing': 'Design, manufacture, and test rear aerodynamic elements.', + 'Seat Mount': 'Design and manufacture driver seat mounting hardware.', + 'Shutdown Circuit': 'Implement and validate the shutdown safety circuit.', + 'Steering Rack': 'Design and validate the steering rack and linkage.', + 'Suspension Geometry': 'Develop suspension geometry and mounting points.', + 'Telemetry System': 'Build telemetry collection and wireless data systems.', + 'Tractive System': 'Design and validate tractive system wiring and safety.', + 'Vehicle Controls': 'Develop vehicle control logic and driver interfaces.', + 'Wheel Assembly': 'Design and validate wheel-end components.', + 'Wiring Harness': 'Design, manufacture, and validate vehicle wiring harnesses.', + 'Yaw Sensor': 'Integrate yaw sensing and vehicle dynamics data collection.' +}; + +const PROJECT_LINK_URL_BY_TYPE: Record string> = { + Confluence: (projectSlug) => `https://nerdocs.atlassian.net/wiki/spaces/NER/pages/${projectSlug}`, + 'Bill of Materials': (projectSlug) => `https://docs.google.com/spreadsheets/d/${projectSlug}` +}; + +const clampDate = (date: Date, min: Date, max: Date): Date => { + if (date < min) return new Date(min); + if (date > max) return new Date(max); + return date; +}; + +const addDays = (date: Date, days: number): Date => { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +}; + +const daysBetween = (start: Date, end: Date): number => + Math.max(0, Math.floor((end.getTime() - start.getTime()) / MS_PER_DAY)); + +export const generateProjectTimeline = (faker: Faker, carDateRange: DateRange): DateRange => { + const carStart = new Date(carDateRange.start); + const carEnd = new Date(carDateRange.end); + + const durationMonths = faker.number.int({ + min: MIN_PROJECT_MONTHS, + max: MAX_PROJECT_MONTHS + }); + + const durationDays = durationMonths * DAYS_PER_MONTH; + const availableDays = daysBetween(carStart, carEnd); + const latestStartOffset = Math.max(0, availableDays - durationDays); + + const start = addDays( + carStart, + faker.number.int({ + min: 0, + max: latestStartOffset + }) + ); + + const end = clampDate(addDays(start, durationDays), carStart, carEnd); + + return { start, end }; +}; + +export const projectNameForIndex = (faker: Faker, index: number): string => { + const part = PROJECT_NAME_PARTS[index % PROJECT_NAME_PARTS.length]; + const qualifier = faker.helpers.arrayElement(PROJECT_QUALIFIERS); + + return `${part} ${qualifier}`; +}; + +export const projectSummaryForName = (name: string): string => { + const matchingPart = PROJECT_NAME_PARTS.find((part) => name.startsWith(part)); + + if (!matchingPart) { + return `Plan, design, manufacture, and validate ${name.toLowerCase()}.`; + } + + return PROJECT_SUMMARY_BY_PART[matchingPart] ?? `Develop ${matchingPart.toLowerCase()} for the car.`; +}; + +export const projectAbbreviationForName = (name: string, projectNumber: number): string => { + const abbreviation = name + .split(' ') + .map((word) => word[0]) + .join('') + .toUpperCase() + .slice(0, 6); + + return `${abbreviation}${projectNumber}`; +}; + +const projectSlugForName = (projectName: string): string => + projectName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + +const projectLinksCreateInput = ( + projectName: string, + linkTypes: Link_Type[], + creatorId: string +): Prisma.LinkCreateWithoutWbsElmentInput[] => { + const projectSlug = projectSlugForName(projectName); + + return linkTypes + .filter((linkType) => linkType.name in PROJECT_LINK_URL_BY_TYPE) + .map((linkType) => ({ + url: PROJECT_LINK_URL_BY_TYPE[linkType.name](projectSlug), + creator: { + connect: { userId: creatorId } + }, + linkType: { + connect: { id: linkType.id } + } + })); +}; + +export const projectCreateInput = ( + faker: Faker, + organizationId: string, + carId: string, + carNumber: number, + projectNumber: number, + projectName: string, + teamIds: string[], + leadId?: string, + managerId?: string, + linkTypes: Link_Type[] = [], + linkCreatorId?: string, + overrides: Partial = {} +): Prisma.ProjectCreateInput => ({ + summary: projectSummaryForName(projectName), + budget: faker.number.int({ min: 500, max: 12_000 }), + abbreviation: projectAbbreviationForName(projectName, projectNumber), + car: { + connect: { carId } + }, + teams: { + connect: teamIds.map((teamId) => ({ teamId })) + }, + wbsElement: { + create: { + name: projectName, + carNumber, + projectNumber, + workPackageNumber: 0, + status: WBS_Element_Status.ACTIVE, + organization: { + connect: { organizationId } + }, + ...(leadId + ? { + lead: { + connect: { userId: leadId } + } + } + : {}), + ...(managerId + ? { + manager: { + connect: { userId: managerId } + } + } + : {}), + ...(linkCreatorId && linkTypes.length > 0 + ? { + links: { + create: projectLinksCreateInput(projectName, linkTypes, linkCreatorId) + } + } + : {}) + } + }, + ...overrides +}); + +export const projectTemplateCreateInput = ( + organizationId: string, + userCreatedId: string, + templateName: string, + projectName: string, + teamIds: string[] +): Prisma.WBS_Element_TemplateCreateInput => ({ + templateName, + templateNotes: `Seed template for ${projectName}.`, + wbsElementName: projectName, + userCreated: { + connect: { userId: userCreatedId } + }, + organization: { + connect: { organizationId } + }, + projectTemplate: { + create: { + summary: projectSummaryForName(projectName), + budget: 0, + teams: { + connect: teamIds.map((teamId) => ({ teamId })) + } + } + } +}); diff --git a/src/backend/src/prisma/seed/project.process.ts b/src/backend/src/prisma/seed/project.process.ts new file mode 100644 index 0000000000..a4b4a494cc --- /dev/null +++ b/src/backend/src/prisma/seed/project.process.ts @@ -0,0 +1,171 @@ +import { Team } from '@prisma/client'; +import { SeedProcess } from '../processes/seed-process.js'; +import { OrganizationOutput, OrganizationProcess } from './organization.process.js'; +import { CarProcess } from './car.process.js'; +import { UsersProcess, UsersOutput } from './user.process.js'; +import { TeamOutput, TeamProcess } from './team.process.js'; +import { ConfigDataOutput, ConfigDataProcess } from './config-data.process.js'; +import { CarOutput } from '../context.js'; +import { + generateProjectTimeline, + PROJECTS_PER_CAR, + projectCreateInput, + projectNameForIndex, + projectTemplateCreateInput +} from '../factories/project.factory.js'; + +import type { ProjectContext } from '../context.js'; + +type ProjectInput = OrganizationOutput & CarOutput & UsersOutput & TeamOutput & ConfigDataOutput; + +const PROJECT_TEMPLATES = [ + { + templateName: 'Mechanical Project Template', + projectName: 'Mechanical Assembly Project' + }, + { + templateName: 'Electrical Project Template', + projectName: 'Electrical Integration Project' + }, + { + templateName: 'Software Project Template', + projectName: 'Software Controls Project' + } +]; + +export type ProjectOutput = { + projects: ProjectContext[]; + projectsByCarId: Record; + projectsById: Record; +}; + +export class ProjectProcess extends SeedProcess { + dependencies() { + return [OrganizationProcess, CarProcess, UsersProcess, TeamProcess, ConfigDataProcess]; + } + + async run({ + organization, + cars, + teams, + leadership, + heads, + admins, + appAdmins, + linkTypes + }: ProjectInput): Promise { + const { organizationId } = organization; + + if (cars.length === 0) { + throw new Error('ProjectProcess requires at least one car.'); + } + + if (teams.length === 0) { + throw new Error('ProjectProcess requires at least one team.'); + } + + const projectOwners = [...leadership, ...heads, ...admins, ...appAdmins]; + + if (projectOwners.length === 0) { + throw new Error('ProjectProcess requires users who can be project leads and managers.'); + } + + const projectContextsByCar = await Promise.all( + cars.map(async ({ car, dateRange }) => { + const { carNumber } = car.wbsElement; + const usedProjectNames = new Set(); + + return Promise.all( + Array.from({ length: PROJECTS_PER_CAR }, async (_, index) => { + const projectNumber = index + 1; + + let projectName = projectNameForIndex(this.faker, index); + + while (usedProjectNames.has(projectName)) { + projectName = projectNameForIndex(this.faker, index + usedProjectNames.size); + } + + usedProjectNames.add(projectName); + + const assignedTeams = this.faker.helpers.arrayElements( + teams, + this.faker.number.int({ min: 1, max: Math.min(3, teams.length) }) + ); + + const assignedTeamIds = assignedTeams.map((team) => team.teamId); + + const lead = this.faker.helpers.arrayElement(projectOwners); + const managerPool = projectOwners.filter((user) => user.userId !== lead.userId); + const manager = this.faker.helpers.arrayElement(managerPool.length > 0 ? managerPool : projectOwners); + + const timeline = generateProjectTimeline(this.faker, dateRange); + + const project = await this.prisma.project.create({ + data: projectCreateInput( + this.faker, + organizationId, + car.carId, + carNumber, + projectNumber, + projectName, + assignedTeamIds, + lead.userId, + manager.userId, + linkTypes, + lead.userId + ), + include: { + wbsElement: true, + teams: true, + car: true + } + }); + + return { project, timeline }; + }) + ); + }) + ); + + const projects = projectContextsByCar.flat(); + + const templateCreatorId = appAdmins[0]?.userId; + await this.createProjectTemplates(organizationId, templateCreatorId, teams); + + const projectsByCarId = projects.reduce>((acc, projectContext) => { + const { carId } = projectContext.project; + + acc[carId] ??= []; + acc[carId].push(projectContext); + + return acc; + }, {}); + + const projectsById = projects.reduce>((acc, projectContext) => { + acc[projectContext.project.projectId] = projectContext; + return acc; + }, {}); + + return { + projects, + projectsByCarId, + projectsById + }; + } + + private async createProjectTemplates(organizationId: string, userCreatedId: string | undefined, teams: Team[]) { + if (!userCreatedId) { + throw new Error('ProjectProcess requires an app admin to create project templates.'); + } + + const templateTeamIds = teams.slice(0, 2).map((team) => team.teamId); + + await Promise.all( + PROJECT_TEMPLATES.map(({ templateName, projectName }) => + this.prisma.wBS_Element_Template.create({ + data: projectTemplateCreateInput(organizationId, userCreatedId, templateName, projectName, templateTeamIds) + }) + ) + ); + } +}