Skip to content

Commit 88bde9b

Browse files
authored
Merge pull request #4271 from Northeastern-Electric-Racing/#4212-generate-teams
#4212 generate teams
2 parents 69129e4 + ccaa2e6 commit 88bde9b

7 files changed

Lines changed: 320 additions & 5192 deletions

File tree

src/backend/src/prisma/dev-seed.ts

Lines changed: 3 additions & 5190 deletions
Large diffs are not rendered by default.

src/backend/src/prisma/distributions.ts

Whitespace-only changes.
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { Faker } from '@faker-js/faker';
2+
import { Prisma, Team_Type } from '@prisma/client';
3+
import type { FullUser } from '../context.js';
4+
5+
const SLACK_ID_RANDOM_LENGTH = 4;
6+
7+
export type SeedTeamConfig = {
8+
teamName: string;
9+
description: string;
10+
teamTypeName: string;
11+
financeTeam?: boolean;
12+
};
13+
14+
export const seedTeamConfigs: SeedTeamConfig[] = [
15+
{
16+
teamName: 'Mechanical',
17+
description: 'Designs and manufactures mechanical systems for the car.',
18+
teamTypeName: 'Mechanical'
19+
},
20+
{
21+
teamName: 'Powertrain',
22+
description: 'Develops drivetrain and high-voltage powertrain systems.',
23+
teamTypeName: 'Electrical'
24+
},
25+
{
26+
teamName: 'Electrical',
27+
description: 'Builds and maintains the car electrical architecture.',
28+
teamTypeName: 'Electrical'
29+
},
30+
{
31+
teamName: 'Software',
32+
description: 'Develops FinishLine, telemetry, and vehicle software.',
33+
teamTypeName: 'Software'
34+
},
35+
{
36+
teamName: 'Embedded Systems',
37+
description: 'Works on firmware and embedded controls.',
38+
teamTypeName: 'Software'
39+
},
40+
{
41+
teamName: 'Controls',
42+
description: 'Develops vehicle control systems and performance tools.',
43+
teamTypeName: 'Software'
44+
},
45+
{
46+
teamName: 'Battery',
47+
description: 'Designs and validates the battery pack and accumulator systems.',
48+
teamTypeName: 'Electrical'
49+
},
50+
{
51+
teamName: 'Aerodynamics',
52+
description: 'Designs aero components and validates vehicle airflow.',
53+
teamTypeName: 'Mechanical'
54+
},
55+
{
56+
teamName: 'Chassis',
57+
description: 'Develops the frame and structural systems.',
58+
teamTypeName: 'Mechanical'
59+
},
60+
{
61+
teamName: 'Suspension',
62+
description: 'Designs suspension geometry and handling systems.',
63+
teamTypeName: 'Mechanical'
64+
},
65+
{
66+
teamName: 'Brakes',
67+
description: 'Develops braking systems and pedal box components.',
68+
teamTypeName: 'Mechanical'
69+
},
70+
{
71+
teamName: 'Composites',
72+
description: 'Manufactures composite bodywork and structural parts.',
73+
teamTypeName: 'Mechanical'
74+
},
75+
{
76+
teamName: 'Manufacturing',
77+
description: 'Coordinates machining, fabrication, and shop work.',
78+
teamTypeName: 'Mechanical'
79+
},
80+
{
81+
teamName: 'Operations',
82+
description: 'Coordinates logistics, planning, and internal processes.',
83+
teamTypeName: 'Business'
84+
},
85+
{
86+
teamName: 'Finance',
87+
description: 'Manages purchasing, reimbursements, budgeting, and sponsor funds.',
88+
teamTypeName: 'Business',
89+
financeTeam: true
90+
},
91+
{
92+
teamName: 'Sponsorship',
93+
description: 'Manages sponsor outreach and sponsor relationships.',
94+
teamTypeName: 'Business'
95+
},
96+
{
97+
teamName: 'Marketing',
98+
description: 'Creates team media, branding, and public-facing content.',
99+
teamTypeName: 'Business'
100+
},
101+
{
102+
teamName: 'Recruitment',
103+
description: 'Supports recruiting, onboarding, and member engagement.',
104+
teamTypeName: 'Business'
105+
},
106+
{
107+
teamName: 'Data Acquisition',
108+
description: 'Builds telemetry, sensors, and data analysis tools.',
109+
teamTypeName: 'Electrical'
110+
},
111+
{
112+
teamName: 'Driver Interface',
113+
description: 'Develops cockpit, dashboard, and driver-facing controls.',
114+
teamTypeName: 'Electrical'
115+
}
116+
];
117+
118+
const connectUsers = (users: FullUser[]) => users.map((user) => ({ userId: user.userId }));
119+
120+
const slackIdForTeam = (faker: Faker, teamName: string): string => {
121+
const slug = teamName.toLowerCase().replaceAll(' ', '-').replaceAll('/', '-');
122+
123+
return `seed-${slug}-${faker.string.alphanumeric(SLACK_ID_RANDOM_LENGTH).toLowerCase()}`;
124+
};
125+
126+
const findTeamType = (teamTypesByName: Record<string, Team_Type>, teamTypeName: string): Team_Type => {
127+
const teamType = teamTypesByName[teamTypeName];
128+
129+
if (!teamType) {
130+
throw new Error(`Missing team type: ${teamTypeName}`);
131+
}
132+
133+
return teamType;
134+
};
135+
136+
export const teamCreateInput = (
137+
faker: Faker,
138+
organizationId: string,
139+
head: FullUser,
140+
leads: FullUser[],
141+
members: FullUser[],
142+
teamTypesByName: Record<string, Team_Type>,
143+
config: SeedTeamConfig,
144+
overrides: Partial<Prisma.TeamCreateInput> = {}
145+
): Prisma.TeamCreateInput => {
146+
const teamType = findTeamType(teamTypesByName, config.teamTypeName);
147+
148+
return {
149+
teamName: config.teamName,
150+
slackId: slackIdForTeam(faker, config.teamName),
151+
description: config.description,
152+
financeTeam: config.financeTeam ?? false,
153+
head: {
154+
connect: { userId: head.userId }
155+
},
156+
leads: {
157+
connect: connectUsers(leads)
158+
},
159+
members: {
160+
connect: connectUsers(members)
161+
},
162+
teamType: {
163+
connect: { teamTypeId: teamType.teamTypeId }
164+
},
165+
organization: {
166+
connect: { organizationId }
167+
},
168+
...overrides
169+
};
170+
};

src/backend/src/prisma/index.ts

Whitespace-only changes.

src/backend/src/prisma/processes/seed-runner.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,37 @@ export class SeedRunner {
1919
if (!this.prisma) throw new Error('SeedRunner requires a PrismaClient. Call withPrisma() before run().');
2020

2121
const outputs = new Map<string, any>();
22+
const context: Record<string, any> = {};
23+
24+
const mergeOutputs = (target: Record<string, any>, source: Record<string, any>, sourceName: string) => {
25+
const duplicateKeys = Object.keys(source).filter((key) => key in target);
26+
27+
if (duplicateKeys.length > 0) {
28+
throw new Error(`Duplicate seed output keys from ${sourceName}: ${duplicateKeys.join(', ')}`);
29+
}
30+
31+
return Object.assign(target, source);
32+
};
2233

2334
for (const instance of this.instances) {
2435
instance.prisma = this.prisma;
2536

26-
const depOutputs = instance.dependencies().reduce((acc, depClass) => {
37+
const depOutputs = instance.dependencies().reduce<Record<string, any>>((acc, depClass) => {
2738
const output = outputs.get(depClass.name);
2839
if (!output) throw new Error(`Missing output for dependency: ${depClass.name}`);
29-
return { ...acc, ...output };
40+
41+
return mergeOutputs(acc, output, depClass.name);
3042
}, {});
3143

3244
console.log(`Running ${instance.constructor.name} (seed ${GLOBAL_SEED})...`);
3345
const output = await instance.run(depOutputs);
46+
3447
outputs.set(instance.constructor.name, output);
48+
mergeOutputs(context, output, instance.constructor.name);
49+
3550
console.log(`${instance.constructor.name} complete`);
3651
}
52+
53+
return context;
3754
}
3855
}

src/backend/src/prisma/random.ts

Whitespace-only changes.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { Team, Team_Type } from '@prisma/client';
2+
import { ConfigDataOutput, ConfigDataProcess } from './config-data.process.js';
3+
import { OrganizationOutput, OrganizationProcess } from './organization.process.js';
4+
import { UsersOutput, UsersProcess } from './user.process.js';
5+
import { seedTeamConfigs, teamCreateInput } from '../factories/teams.factory.js';
6+
import { SeedProcess } from '../processes/seed-process.js';
7+
import type { FullUser } from '../context.js';
8+
9+
const MIN_LEADS_PER_TEAM = 1;
10+
const MAX_LEADS_PER_TEAM = 3;
11+
const MIN_MEMBERS_PER_TEAM = 8;
12+
const MAX_MEMBERS_PER_TEAM = 20;
13+
14+
type TeamInput = OrganizationOutput & UsersOutput & ConfigDataOutput;
15+
16+
export type TeamOutput = {
17+
teams: Team[];
18+
financeTeam: Team;
19+
teamsByName: Record<string, Team>;
20+
};
21+
22+
const uniqueUsersById = (users: FullUser[]): FullUser[] => {
23+
return Array.from(new Map(users.map((user) => [user.userId, user])).values());
24+
};
25+
26+
export class TeamProcess extends SeedProcess<TeamInput, TeamOutput> {
27+
dependencies() {
28+
return [OrganizationProcess, UsersProcess, ConfigDataProcess];
29+
}
30+
31+
async run({ organization, admins, heads, leadership, members, teamTypes }: TeamInput): Promise<TeamOutput> {
32+
if (admins.length === 0 || heads.length === 0 || leadership.length === 0 || members.length === 0) {
33+
throw new Error('TeamProcess requires admins, heads, leadership, and members to create teams.');
34+
}
35+
36+
if (members.length < MIN_MEMBERS_PER_TEAM) {
37+
throw new Error(
38+
`TeamProcess requires at least ${MIN_MEMBERS_PER_TEAM} member candidates, but only found ${members.length}.`
39+
);
40+
}
41+
42+
const teamNames = seedTeamConfigs.map((team) => team.teamName);
43+
44+
if (new Set(teamNames).size !== teamNames.length) {
45+
throw new Error('TeamProcess cannot generate duplicate team names.');
46+
}
47+
48+
const teamTypesByName = teamTypes.reduce<Record<string, Team_Type>>((acc, teamType) => {
49+
acc[teamType.name] = teamType;
50+
return acc;
51+
}, {});
52+
53+
const teamLeadershipCandidates = uniqueUsersById([...heads, ...admins, ...leadership]);
54+
55+
if (teamLeadershipCandidates.length < seedTeamConfigs.length) {
56+
throw new Error(
57+
`Not enough head candidates (${teamLeadershipCandidates.length}) for ${seedTeamConfigs.length} teams.`
58+
);
59+
}
60+
61+
const headCandidates = teamLeadershipCandidates.slice(0, seedTeamConfigs.length);
62+
const headIds = new Set(headCandidates.map((head) => head.userId));
63+
64+
const leadCandidates = teamLeadershipCandidates.filter((candidate) => !headIds.has(candidate.userId));
65+
66+
if (leadCandidates.length < seedTeamConfigs.length * MIN_LEADS_PER_TEAM) {
67+
throw new Error(`Not enough unique lead candidates (${leadCandidates.length}) for ${seedTeamConfigs.length} teams.`);
68+
}
69+
70+
const usedLeadIds = new Set<string>();
71+
72+
const getLeadsForTeam = (teamIndex: number): FullUser[] => {
73+
const remainingTeams = seedTeamConfigs.length - teamIndex;
74+
const availableLeads = leadCandidates.filter((candidate) => !usedLeadIds.has(candidate.userId));
75+
76+
const maxLeadsForThisTeam = Math.min(
77+
MAX_LEADS_PER_TEAM,
78+
availableLeads.length - (remainingTeams - 1) * MIN_LEADS_PER_TEAM
79+
);
80+
81+
if (maxLeadsForThisTeam < MIN_LEADS_PER_TEAM) {
82+
throw new Error('TeamProcess could not assign unique leads to every team.');
83+
}
84+
85+
const leads = this.faker.helpers.arrayElements(
86+
availableLeads,
87+
this.faker.number.int({
88+
min: MIN_LEADS_PER_TEAM,
89+
max: maxLeadsForThisTeam
90+
})
91+
);
92+
93+
leads.forEach((lead) => usedLeadIds.add(lead.userId));
94+
95+
return leads;
96+
};
97+
98+
const teamCreateInputs = seedTeamConfigs.map((config, index) => {
99+
const head = headCandidates[index];
100+
const leads = getLeadsForTeam(index);
101+
102+
const teamMembers = this.faker.helpers.arrayElements(
103+
members,
104+
this.faker.number.int({
105+
min: MIN_MEMBERS_PER_TEAM,
106+
max: Math.min(MAX_MEMBERS_PER_TEAM, members.length)
107+
})
108+
);
109+
110+
return teamCreateInput(this.faker, organization.organizationId, head, leads, teamMembers, teamTypesByName, config);
111+
});
112+
113+
const teams = await Promise.all(teamCreateInputs.map((data) => this.prisma.team.create({ data })));
114+
115+
const teamsByName = teams.reduce<Record<string, Team>>((acc, team) => {
116+
acc[team.teamName] = team;
117+
return acc;
118+
}, {});
119+
120+
const financeTeam = teams.find((_, index) => seedTeamConfigs[index]?.financeTeam);
121+
122+
if (!financeTeam) {
123+
throw new Error('TeamProcess expected one team config to be marked as the finance team.');
124+
}
125+
126+
return { teams, financeTeam, teamsByName };
127+
}
128+
}

0 commit comments

Comments
 (0)