Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"@types/multer": "^1.4.12",
"canvas-confetti": "^1.9.3",
"mitt": "^3.0.1",
"ora": "^9.4.1",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since ora is imported from backend seed code, should it be declared in the backend workspace package rather than only in the root package.json not sure just a thought

"react-hook-form-persist": "^3.0.0",
"recharts": "^2.15.3",
"typescript": "^5.7.3"
Expand Down
3 changes: 2 additions & 1 deletion src/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"build": "NODE_OPTIONS='--max-old-space-size=8192' tsc --noEmit false",
"start": "node -r dotenv/config dist/backend/index.js",
"prisma:manual": "tsx --import dotenv/config ./src/prisma/manual.ts",
"prisma:dev-seed": "tsx --import dotenv/config ./src/prisma/dev-seed.ts"
"prisma:dev-seed": "tsx --import dotenv/config ./src/prisma/dev-seed.ts",
"prisma:dev-setup": "docker compose up -d database && yarn prisma:reset:no-seed --force && yarn workspace backend prisma:dev-seed"
},
"dependencies": {
"@prisma/client": "^6.2.1",
Expand Down
6 changes: 6 additions & 0 deletions src/backend/src/prisma/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Team_Type,
Team,
Unit,
Work_Package,
Vendor,
WBS_Element
} from '@prisma/client';
Expand Down Expand Up @@ -87,3 +88,8 @@ export type ProjectContext = {
};
timeline: DateRange;
};

export type WorkPackageContext = {
workPackage: Work_Package & { wbsElement: WBS_Element };
timeline: DateRange;
};
15 changes: 15 additions & 0 deletions src/backend/src/prisma/dates.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Faker } from '@faker-js/faker';
import { DateRange } from './context.js';
import dayjs from 'dayjs';
/*
https://fakerjs.dev/api/date.html
*/
Expand All @@ -7,10 +9,23 @@ interface WithFaker {
faker: Faker;
}

export const SECOND_MS = 1000;
export const MINUTE_MS = SECOND_MS * 60;
export const HOUR_MS = MINUTE_MS * 60;
export const DAY_MS = HOUR_MS * 24;
export const WEEK_MS = DAY_MS * 7;

export const DAYS_PER_WEEK = 7;

export function generateRandomDate({ faker }: WithFaker, from?: Date, to?: Date) {
return faker.date.between({ from: from ?? '2000-01-01', to: to ?? Date.now() });
}

export function generateRandomDateAround({ faker }: WithFaker, date: Date) {
return faker.date.recent({ days: 5, refDate: date });
}

export const daysBetween = ({ start, end }: DateRange): number => Math.max(0, dayjs(end).diff(dayjs(start), 'day'));

export const clampDate = (date: Date, { start, end }: DateRange): Date =>
new Date(Math.min(Math.max(date.getTime(), start.getTime()), end.getTime()));
4 changes: 3 additions & 1 deletion src/backend/src/prisma/dev-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ShopProcess } from './seed/shop.process.js';
import { TeamProcess } from './seed/team.process.js';
import { ProjectProcess } from './seed/project.process.js';
import { SchedulingProcess } from './seed/scheduling.process.js';
import { WorkPackageProcess } from './seed/work-package.process.js';

const prisma = new PrismaClient();

Expand All @@ -29,7 +30,8 @@ await new SeedRunner()
new SchedulingProcess(),
new TeamProcess(),
new ShopProcess(),
new ProjectProcess()
new ProjectProcess(),
new WorkPackageProcess()
)
.run();

Expand Down
43 changes: 42 additions & 1 deletion src/backend/src/prisma/factories/config-data.factory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import { Prisma } from '@prisma/client';
import { Prisma, Work_Package_Stage } from '@prisma/client';
import { connectOrganization, connectUser } from '../utils/common.factory.js';

type WorkPackageTemplateConfig = {
templateName: string;
wbsElementName: string;
stage: Work_Package_Stage | null;
duration: number | null;
};

type ProjectTemplateConfig = {
templateName: string;
projectName: string;
summary: string;
budget: number;
workPackageTemplates: WorkPackageTemplateConfig[];
};

const SEED_CREATED_AT = new Date('2024-01-01T00:00:00.000Z');

export const teamTypeCreateInputs = (organizationId: string): Prisma.Team_TypeCreateInput[] => [
Expand Down Expand Up @@ -572,3 +587,29 @@ export const eventTypeCreateInput = (
})
}
});

export const projectTemplateConfigs: ProjectTemplateConfig[] = [
{
templateName: 'Standard Hardware Project',
projectName: 'Hardware Project',
summary: 'Standard template for hardware projects.',
budget: 0,
workPackageTemplates: [
{ templateName: 'Research Phase', wbsElementName: 'Research', stage: Work_Package_Stage.RESEARCH, duration: null },
{ templateName: 'Design Phase', wbsElementName: 'Design', stage: Work_Package_Stage.DESIGN, duration: null },
{
templateName: 'Manufacturing Phase',
wbsElementName: 'Manufacturing',
stage: Work_Package_Stage.MANUFACTURING,
duration: null
},
{ templateName: 'Install Phase', wbsElementName: 'Install', stage: Work_Package_Stage.INSTALL, duration: null },
{ templateName: 'Testing Phase', wbsElementName: 'Testing', stage: Work_Package_Stage.TESTING, duration: null },
{ templateName: 'Final Testing', wbsElementName: 'Final Testing', stage: Work_Package_Stage.TESTING, duration: null }
]
}
];

export const standaloneWorkPackageTemplateConfigs: WorkPackageTemplateConfig[] = [
{ templateName: 'Quick Install', wbsElementName: 'Install', stage: null, duration: 1 }
];
12 changes: 2 additions & 10 deletions src/backend/src/prisma/factories/project.factory.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Faker } from '@faker-js/faker';
import { Link_Type, Prisma, WBS_Element_Status } from '@prisma/client';
import dayjs from 'dayjs';
import { addDaysToDate } from 'shared';
import { DateRange } from '../context.js';
import { clampDate, daysBetween } from '../dates.js';

export const PROJECTS_PER_CAR = 30;

Expand Down Expand Up @@ -190,14 +190,6 @@ const PROJECT_LINK_URL_BY_TYPE: Record<string, (projectSlug: string) => string>
'Google Drive': (projectSlug) => `https://drive.google.com/drive/folders/${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 daysBetween = ({ start, end }: DateRange): number => Math.max(0, dayjs(end).diff(dayjs(start), 'day'));

const TARGET_BUDGET_PER_CAR = 80_000;

export const generateProjectBudgets = (
Expand Down Expand Up @@ -256,7 +248,7 @@ export const generateProjectTimeline = (faker: Faker, carDateRange: DateRange):
})
);

const end = clampDate(addDaysToDate(start, durationDays), carStart, carEnd);
const end = clampDate(addDaysToDate(start, durationDays), { start: carStart, end: carEnd });

return { start, end };
};
Expand Down
84 changes: 84 additions & 0 deletions src/backend/src/prisma/factories/work-package.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Faker } from '@faker-js/faker';
import { Prisma, WBS_Element_Status, Work_Package_Stage } from '@prisma/client';
import { DateRange } from '../context.js';
import { clampDate, daysBetween } from '../dates.js';
import { addDaysToDate } from 'shared';

const DAYS_PER_WEEK = 7;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cant we just use the one from dates


export const generateWorkPackageCount = (faker: Faker): number =>
// Each project gets 0–8 work packages. Average work package count is around 5.
faker.helpers.weightedArrayElement([
{ weight: 3, value: 0 },
{ weight: 5, value: 1 },
{ weight: 8, value: 2 },
{ weight: 14, value: 3 },
{ weight: 18, value: 4 },
{ weight: 22, value: 5 },
{ weight: 16, value: 6 },
{ weight: 9, value: 7 },
{ weight: 5, value: 8 }
]);

export const generateWorkPackageStage = (faker: Faker): Work_Package_Stage =>
faker.helpers.arrayElement(Object.values(Work_Package_Stage));

export const generateWorkPackageTimeline = (faker: Faker, projectTimeline: DateRange, blockerEndDate?: Date): DateRange => {
const start =
blockerEndDate && blockerEndDate < projectTimeline.end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could there be an edge case here when blockerEndDate is at or after projectTimeline.end? Since that would fall back to a random start date, it seems possible to create a blocked work package that starts before the blocker ends. Should we avoid creating a blocked work package in that case, or clamp/start it differently?

? addDaysToDate(blockerEndDate, 1)
: addDaysToDate(
projectTimeline.start,
faker.number.int({
min: 0,
max: Math.max(0, daysBetween(projectTimeline) - DAYS_PER_WEEK)
})
);

// duration saved in WEEKS instead of days
const maxDuration = Math.max(1, daysBetween({ start, end: projectTimeline.end }) / DAYS_PER_WEEK);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maxDuration can be fractional because it divides days by DAYS_PER_WEEK, but then it is passed to faker.number.int. Could we floor it first so the max is definitely an integer?

const duration = faker.number.int({ min: 1, max: Math.min(12, maxDuration) });

return { start, end: clampDate(addDaysToDate(start, duration * DAYS_PER_WEEK), { start, end: projectTimeline.end }) };
};

export const workPackageCreateInput = (
organizationId: string,
carNumber: number,
projectNumber: number,
workPackageNumber: number,
projectId: string,
orderInProject: number,
name: string,
startDate: Date,
duration: number,
stage: Work_Package_Stage,
leadId?: string,
managerId?: string,
blockedByWbsElementIds: string[] = []
): Prisma.Work_PackageCreateInput => ({
orderInProject,
startDate,
duration,
stage,
project: { connect: { projectId } },
...(blockedByWbsElementIds.length > 0
? {
blockedBy: {
connect: blockedByWbsElementIds.map((wbsElementId) => ({ wbsElementId }))
}
}
: {}),
wbsElement: {
create: {
name,
carNumber,
projectNumber,
workPackageNumber,
status: WBS_Element_Status.ACTIVE,
organization: { connect: { organizationId } },
...(leadId ? { lead: { connect: { userId: leadId } } } : {}),
...(managerId ? { manager: { connect: { userId: managerId } } } : {})
}
}
});
42 changes: 28 additions & 14 deletions src/backend/src/prisma/processes/seed-runner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PrismaClient } from '@prisma/client';
import { SeedProcess, GLOBAL_SEED } from './seed-process.js';
import { SeedProcess } from './seed-process.js';
import ora from 'ora';

export class SeedRunner {
private instances: SeedProcess<any, any>[] = [];
Expand All @@ -20,34 +21,47 @@ export class SeedRunner {

const outputs = new Map<string, any>();
const context: Record<string, any> = {};
const total = this.instances.length;

const mergeOutputs = (target: Record<string, any>, source: Record<string, any>, sourceName: string) => {
const duplicateKeys = Object.keys(source).filter((key) => key in target);

if (duplicateKeys.length > 0) {
throw new Error(`Duplicate seed output keys from ${sourceName}: ${duplicateKeys.join(', ')}`);
}

return Object.assign(target, source);
};

for (const instance of this.instances) {
for (let i = 0; i < this.instances.length; i++) {
const instance = this.instances[i];
instance.prisma = this.prisma;
const start = Date.now();

const depOutputs = instance.dependencies().reduce<Record<string, any>>((acc, depClass) => {
const output = outputs.get(depClass.name);
if (!output) throw new Error(`Missing output for dependency: ${depClass.name}`);
const spinner = ora({
text: `[${i + 1}/${total}] ${instance.constructor.name}...`,
color: 'cyan'
}).start();

return mergeOutputs(acc, output, depClass.name);
}, {});
try {
const depOutputs = instance.dependencies().reduce<Record<string, any>>((acc, depClass) => {
const output = outputs.get(depClass.name);
if (!output) throw new Error(`Missing output for dependency: ${depClass.name}`);
return mergeOutputs(acc, output, depClass.name);
}, {});

console.log(`Running ${instance.constructor.name} (seed ${GLOBAL_SEED})...`);
const output = await instance.run(depOutputs);
const output = await instance.run(depOutputs);

outputs.set(instance.constructor.name, output);
mergeOutputs(context, output, instance.constructor.name);
outputs.set(instance.constructor.name, output);
mergeOutputs(context, output, instance.constructor.name);

console.log(`${instance.constructor.name} complete`);
spinner.succeed(
`[${i + 1}/${total}] ${instance.constructor.name} complete (${((Date.now() - start) / 1000).toFixed(2)}s)`
);
} catch (e) {
spinner.fail(
`[${i + 1}/${total}] ${instance.constructor.name} failed (${((Date.now() - start) / 1000).toFixed(2)}s)`
);
throw e;
}
}

return context;
Expand Down
Loading
Loading