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
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 @@ -19,6 +19,7 @@ import { DescriptionBulletProcess } from './seed/description-bullet.process.js';
import { BOMProcess } from './seed/bom.process.js';
import { TaskProcess } from './seed/tasks.process.js';
import { WorkPackageProcess } from './seed/work-package.process.js';
import { ChangeRequestProcess } from './seed/change-request.process.js';

const prisma = new PrismaClient();

Expand All @@ -37,7 +38,8 @@ await new SeedRunner()
new WorkPackageProcess(),
new DescriptionBulletProcess(),
new BOMProcess(),
new TaskProcess()
new TaskProcess(),
new ChangeRequestProcess()
)
.run();

Expand Down
323 changes: 323 additions & 0 deletions src/backend/src/prisma/factories/change-request.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
import { Faker } from '@faker-js/faker';
import { Account_Code, CR_Type, Prisma } from '@prisma/client';
import { DateRange } from '../context.js';
import { clampDate, daysBetween } from '../dates.js';
import { addDaysToDate } from 'shared';

export type SeedCrParent = {
wbsElementId: string;
timeline: DateRange;
leadId?: string;
managerId?: string;
};

export type SeedCrActor = { userId: string };

export type SeedCrOverrides = Partial<Prisma.Change_RequestCreateInput>;
export const WORK_PACKAGE_CR_TYPES: CR_Type[] = [CR_Type.ACTIVATION, CR_Type.STAGE_GATE];
export const PROJECT_CR_TYPES: CR_Type[] = [CR_Type.STANDARD, CR_Type.LEADERSHIP];

type ReviewOutcome = 'APPROVED' | 'DENIED' | 'PENDING';

const STANDARD_WHY = [
'Initial change request',
'Scope adjustment after design review',
'Updating the timeline to reflect current progress',
'Reassigning ownership after a lead change',
'Correcting budget to match the latest quote',
'Adding deliverables identified during research',
'Pulling in schedule after blocker was resolved',
'Documentation cleanup and status update'
];

const BUDGET_WHY = [
'Requesting additional budget for consumables',
'Budget increase to cover competition costs',
'Reallocating funds after a quote came in higher',
'Additional budget needed for tools and equipment'
];

const APPROVED_NOTES = ['LGTM', 'Approved!', 'Looks good to me!', 'Approved, nice work', 'LGTM!', 'Good to go'];
const DENIED_NOTES = [
'Needs more detail before this can be approved',
'Please revise the scope and resubmit',
'Budget is too high, tighten this up',
'Talk to the project lead before resubmitting',
'Not ready yet'
];

const CHANGE_DETAILS = [
'New Project Created',
'New Work Package Created',
'Changed budget from "0" to "3500"',
'Added manager',
'Changed Project Lead',
'Added new Deliverables',
'Changed start date',
'Changed duration',
'Updated status'
];

const BUDGET_AMOUNTS = [500, 1000, 1500, 2000, 2500, 5000];

type CrLink = { kind: 'wbs'; wbsElementId: string } | { kind: 'accountCode'; accountCodeId: string };

const crTypeForParent = (faker: Faker, isWorkPackage: boolean): CR_Type => {
if (isWorkPackage) {
return faker.helpers.weightedArrayElement([
{ weight: 51, value: CR_Type.ACTIVATION },
{ weight: 49, value: CR_Type.STAGE_GATE }
]);
}

return faker.helpers.weightedArrayElement([
{ weight: 97, value: CR_Type.STANDARD },
{ weight: 3, value: CR_Type.LEADERSHIP }
]);
};

const latestOutcome = (faker: Faker): ReviewOutcome =>
faker.helpers.weightedArrayElement([
{ weight: 57, value: 'APPROVED' as const },
{ weight: 39, value: 'PENDING' as const },
{ weight: 4, value: 'DENIED' as const }
]);

const resolvedOutcome = (faker: Faker): ReviewOutcome =>
faker.helpers.weightedArrayElement([
{ weight: 93, value: 'APPROVED' as const },
{ weight: 7, value: 'DENIED' as const }
]);

const subtypeCreateInput = (
faker: Faker,
type: CR_Type,
parent: SeedCrParent | undefined,
submittedDate: Date
): Pick<
Prisma.Change_RequestCreateInput,
'budgetChangeRequest' | 'stageGateChangeRequest' | 'activationChangeRequest' | 'leadershipChangeRequest'
> => {
switch (type) {
case CR_Type.BUDGET:
return {
budgetChangeRequest: {
create: { proposedBudget: faker.helpers.arrayElement(BUDGET_AMOUNTS) }
}
};
case CR_Type.STAGE_GATE:
return {
stageGateChangeRequest: {
create: { leftoverBudget: 0, confirmDone: true }
}
};
case CR_Type.ACTIVATION: {
const leadId = parent?.leadId;
const managerId = parent?.managerId;
if (!leadId || !managerId) {
throw new Error('Activation change request requires a lead and manager on the parent work package.');
}
return {
activationChangeRequest: {
create: {
startDate: submittedDate,
confirmDetails: true,
lead: { connect: { userId: leadId } },
manager: { connect: { userId: managerId } }
}
}
};
}
case CR_Type.LEADERSHIP:
return {
leadershipChangeRequest: {
create: {
...(parent?.leadId ? { lead: { connect: { userId: parent.leadId } } } : {}),
...(parent?.managerId ? { manager: { connect: { userId: parent.managerId } } } : {})
}
}
};
case CR_Type.STANDARD:
default:
return {};
}
};

const changesCreateInput = (
faker: Faker,
implementerId: string,
link: CrLink
): Prisma.ChangeCreateWithoutChangeRequestInput[] => {
const count = faker.number.int({ min: 1, max: 4 });

return Array.from({ length: count }, () => ({
detail: faker.helpers.arrayElement(CHANGE_DETAILS),
implementer: { connect: { userId: implementerId } },
...(link.kind === 'wbs'
? { wbsElement: { connect: { wbsElementId: link.wbsElementId } } }
: { accountCode: { connect: { accountCodeId: link.accountCodeId } } })
}));
};

export const crCountForProject = (faker: Faker): number =>
faker.helpers.weightedArrayElement([
{ weight: 15, value: 0 },
{ weight: 45, value: faker.number.int({ min: 1, max: 3 }) },
{ weight: 30, value: faker.number.int({ min: 4, max: 8 }) },
{ weight: 10, value: faker.number.int({ min: 9, max: 15 }) }
]);

export const crCountForWorkPackage = (faker: Faker): number =>
faker.helpers.weightedArrayElement([
{ weight: 20, value: 0 },
{ weight: 55, value: 1 },
{ weight: 20, value: 2 },
{ weight: 5, value: 3 }
]);

export const crCountForAccountCode = (faker: Faker): number =>
faker.helpers.weightedArrayElement([
{ weight: 40, value: 0 },
{ weight: 40, value: faker.number.int({ min: 1, max: 3 }) },
{ weight: 20, value: faker.number.int({ min: 4, max: 6 }) }
]);

type BuildChangeRequestArgs = {
faker: Faker;
identifier: number;
organizationId: string;
type: CR_Type;
parent?: SeedCrParent;
link: CrLink;
submitterId: string;
reviewerId: string;
dateSubmitted: Date;
outcome: ReviewOutcome;
reviewWindowEnd: Date;
overrides?: SeedCrOverrides;
};

const buildChangeRequest = ({
faker,
identifier,
organizationId,
type,
parent,
link,
submitterId,
reviewerId,
dateSubmitted,
outcome,
reviewWindowEnd,
overrides = {}
}: BuildChangeRequestArgs): Prisma.Change_RequestCreateInput => {
const reviewed = outcome !== 'PENDING';
const accepted = outcome === 'APPROVED';

const dateReviewed = reviewed
? clampDate(addDaysToDate(dateSubmitted, faker.number.int({ min: 1, max: 10 })), {
start: dateSubmitted,
end: reviewWindowEnd
})
: undefined;

const baseLink =
link.kind === 'wbs'
? { wbsElement: { connect: { wbsElementId: link.wbsElementId } } }
: { accountCode: { connect: { accountCodeId: link.accountCodeId } } };

return {
identifier,
type,
dateSubmitted,
why: type === CR_Type.BUDGET ? faker.helpers.arrayElement(BUDGET_WHY) : faker.helpers.arrayElement(STANDARD_WHY),
organization: { connect: { organizationId } },
submitter: { connect: { userId: submitterId } },
...baseLink,
...subtypeCreateInput(faker, type, parent, dateSubmitted),
...(reviewed
? {
reviewer: { connect: { userId: reviewerId } },
dateReviewed,
accepted,
reviewNotes: accepted ? faker.helpers.arrayElement(APPROVED_NOTES) : faker.helpers.arrayElement(DENIED_NOTES),
...(accepted ? { changes: { create: changesCreateInput(faker, reviewerId, link) } } : {})
}
: {}),
...overrides
};
};

const outcomesForOrderedCrs = (faker: Faker, count: number): ReviewOutcome[] =>
Array.from({ length: count }, (_, index) => (index === count - 1 ? latestOutcome(faker) : resolvedOutcome(faker)));

const orderedSubmissionDates = (faker: Faker, timeline: DateRange, count: number): Date[] =>

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.

Dates here are bounded only by timeline, never capped at now
Since project/work-package timelines can extend into the future, this can produce a CR dateSubmitted/dateReviewed that's ahead of now

Array.from({ length: count }, () =>
clampDate(addDaysToDate(new Date(timeline.start), faker.number.int({ min: 0, max: daysBetween(timeline) })), {
start: timeline.start,
end: timeline.end
})
).sort((a, b) => a.getTime() - b.getTime());

const pickActor = (faker: Faker, actors: SeedCrActor[]): string => faker.helpers.arrayElement(actors).userId;

export const buildWbsChangeRequests = (
faker: Faker,
parent: SeedCrParent,
isWorkPackage: boolean,
identifiers: number[],
organizationId: string,
submitters: SeedCrActor[],
reviewers: SeedCrActor[]
): Prisma.Change_RequestCreateInput[] => {
if (identifiers.length === 0) return [];

const dates = orderedSubmissionDates(faker, parent.timeline, identifiers.length);
const outcomes = outcomesForOrderedCrs(faker, identifiers.length);

return identifiers.map((identifier, index) =>
buildChangeRequest({
faker,
identifier,
organizationId,
type: crTypeForParent(faker, isWorkPackage),
parent,
link: { kind: 'wbs', wbsElementId: parent.wbsElementId },
submitterId: pickActor(faker, submitters),
reviewerId: pickActor(faker, reviewers),
dateSubmitted: dates[index],
outcome: outcomes[index],
reviewWindowEnd: parent.timeline.end
})
);
};

export const buildAccountCodeChangeRequests = (
faker: Faker,
accountCode: Account_Code,
timeline: DateRange,
identifiers: number[],
organizationId: string,
submitters: SeedCrActor[],
reviewers: SeedCrActor[]
): Prisma.Change_RequestCreateInput[] => {
if (identifiers.length === 0) return [];

const dates = orderedSubmissionDates(faker, timeline, identifiers.length);
const outcomes = outcomesForOrderedCrs(faker, identifiers.length);

return identifiers.map((identifier, index) =>
buildChangeRequest({
faker,
identifier,
organizationId,
type: CR_Type.BUDGET,
link: { kind: 'accountCode', accountCodeId: accountCode.accountCodeId },
submitterId: pickActor(faker, submitters),
reviewerId: pickActor(faker, reviewers),
dateSubmitted: dates[index],
outcome: outcomes[index],
reviewWindowEnd: timeline.end
})
);
};
Loading
Loading