From 061f2e6021ed22748174334647fe1e5a50252c1e Mon Sep 17 00:00:00 2001 From: Steph375 Date: Wed, 15 Jul 2026 15:31:51 -0400 Subject: [PATCH 01/11] 4245 change request process and factory --- src/backend/src/prisma/dev-seed.ts | 4 +- .../factories/change-request.factory.ts | 223 ++++++++++++++++++ .../src/prisma/seed/change-request.process.ts | 145 ++++++++++++ 3 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 src/backend/src/prisma/factories/change-request.factory.ts create mode 100644 src/backend/src/prisma/seed/change-request.process.ts diff --git a/src/backend/src/prisma/dev-seed.ts b/src/backend/src/prisma/dev-seed.ts index 8a65b86dd1..114e5960f8 100644 --- a/src/backend/src/prisma/dev-seed.ts +++ b/src/backend/src/prisma/dev-seed.ts @@ -17,6 +17,7 @@ import { ProjectProcess } from './seed/project.process.js'; import { SchedulingProcess } from './seed/scheduling.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(); @@ -33,7 +34,8 @@ await new SeedRunner() new ShopProcess(), new ProjectProcess(), new WorkPackageProcess(), - new TaskProcess() + new TaskProcess(), + new ChangeRequestProcess() ) .run(); diff --git a/src/backend/src/prisma/factories/change-request.factory.ts b/src/backend/src/prisma/factories/change-request.factory.ts new file mode 100644 index 0000000000..f8c77f2d87 --- /dev/null +++ b/src/backend/src/prisma/factories/change-request.factory.ts @@ -0,0 +1,223 @@ +import { Faker } from '@faker-js/faker'; +import { 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 SeedCrOverrides = Partial; +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.BUDGET, 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 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: 94, value: CR_Type.STANDARD }, + { weight: 3, value: CR_Type.BUDGET }, + { weight: 3, value: CR_Type.LEADERSHIP } + ]); +}; + +const reviewOutcome = (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 subtypeCreateInput = ( + faker: Faker, + type: CR_Type, + parent: SeedCrParent, + submittedDate: Date +): Pick< + Prisma.Change_RequestCreateInput, + 'budgetChangeRequest' | 'stageGateChangeRequest' | 'activationChangeRequest' | 'leadershipChangeRequest' +> => { + const { leadId, managerId } = parent; + + switch (type) { + case CR_Type.BUDGET: + return { + budgetChangeRequest: { + create: { proposedBudget: faker.helpers.arrayElement([500, 1000, 1500, 2000, 2500, 5000]) } + } + }; + case CR_Type.STAGE_GATE: + return { + stageGateChangeRequest: { + create: { leftoverBudget: 0, confirmDone: true } + } + }; + case CR_Type.ACTIVATION: + 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: { + ...(leadId ? { lead: { connect: { userId: leadId } } } : {}), + ...(managerId ? { manager: { connect: { userId: managerId } } } : {}) + } + } + }; + case CR_Type.STANDARD: + default: + return {}; + } +}; + +const changesCreateInput = ( + faker: Faker, + implementerId: string, + link: { wbsElementId: string } | { categoryId: string } +): 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 } }, + ...('wbsElementId' in link + ? { wbsElement: { connect: { wbsElementId: link.wbsElementId } } } + : { category: { connect: { otherReimbursementProductReasonId: link.categoryId } } }) + })); +}; + +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 createSeedChangeRequest = ( + faker: Faker, + parent: SeedCrParent, + isWorkPackage: boolean, + identifier: number, + organizationId: string, + submitterId: string, + reviewerId: string, + budgetReasonId: string | undefined, + overrides: SeedCrOverrides = {} +): Prisma.Change_RequestCreateInput => { + const { timeline, wbsElementId } = parent; + const type = crTypeForParent(faker, isWorkPackage); + + const useCategory = type === CR_Type.BUDGET && budgetReasonId !== undefined; + const baseLink = useCategory + ? { category: { connect: { otherReimbursementProductReasonId: budgetReasonId as string } } } + : { wbsElement: { connect: { wbsElementId } } }; + const changeLink = useCategory ? { categoryId: budgetReasonId as string } : { wbsElementId }; + + const dateSubmitted = clampDate( + addDaysToDate(new Date(timeline.start), faker.number.int({ min: 0, max: daysBetween(timeline) })), + { start: timeline.start, end: timeline.end } + ); + + const outcome = reviewOutcome(faker); + const reviewed = outcome !== 'PENDING'; + const accepted = outcome === 'APPROVED'; + + const dateReviewed = reviewed + ? clampDate(addDaysToDate(dateSubmitted, faker.number.int({ min: 1, max: 10 })), { + start: dateSubmitted, + end: timeline.end + }) + : undefined; + + 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, changeLink) } } : {}) + } + : {}), + ...overrides + }; +}; diff --git a/src/backend/src/prisma/seed/change-request.process.ts b/src/backend/src/prisma/seed/change-request.process.ts new file mode 100644 index 0000000000..37b3ae54d7 --- /dev/null +++ b/src/backend/src/prisma/seed/change-request.process.ts @@ -0,0 +1,145 @@ +import { Change_Request } from '@prisma/client'; +import { SeedProcess } from '../processes/seed-process.js'; +import { OrganizationOutput, OrganizationProcess } from './organization.process.js'; +import { ProjectOutput, ProjectProcess } from './project.process.js'; +import { UsersOutput, UsersProcess } from './user.process.js'; +import { WorkPackageOutput, WorkPackageProcess } from './work-package.process.js'; +import { ConfigDataOutput, ConfigDataProcess } from './config-data.process.js'; +import { + crCountForProject, + crCountForWorkPackage, + createSeedChangeRequest, + SeedCrParent +} from '../factories/change-request.factory.js'; + +type ChangeRequestInput = OrganizationOutput & ProjectOutput & UsersOutput & WorkPackageOutput & ConfigDataOutput; + +export type ChangeRequestOutput = { + changeRequests: Change_Request[]; + changeRequestsByWbsElementId: Record; +}; + +export class ChangeRequestProcess extends SeedProcess { + private identifierCounter = 1; + + dependencies() { + return [OrganizationProcess, ProjectProcess, UsersProcess, WorkPackageProcess, ConfigDataProcess]; + } + + private allocateIdentifiers(count: number): number[] { + const identifiers = Array.from({ length: count }, (_, offset) => this.identifierCounter + offset); + this.identifierCounter += count; + return identifiers; + } + + async run({ + organization, + projects, + workPackagesByProjectId, + members, + leadership, + heads, + admins, + appAdmins, + reimbursementProductOtherReasons + }: ChangeRequestInput): Promise { + const { organizationId } = organization; + + if (projects.length === 0) { + throw new Error('ChangeRequestProcess requires at least one project.'); + } + + this.identifierCounter = 1; + + const submitterPool = [...members, ...leadership, ...heads, ...admins, ...appAdmins]; + const reviewerPool = [...leadership, ...heads, ...admins, ...appAdmins]; + + if (submitterPool.length === 0 || reviewerPool.length === 0) { + throw new Error('ChangeRequestProcess requires submitters and reviewers.'); + } + + const budgetReasonId = (): string | undefined => + reimbursementProductOtherReasons.length > 0 + ? this.faker.helpers.arrayElement(reimbursementProductOtherReasons).otherReimbursementProductReasonId + : undefined; + + const changeRequests: Change_Request[] = []; + + for (const { project, timeline } of projects) { + const projectWorkPackages = workPackagesByProjectId[project.projectId] ?? []; + + // project-scoped CRs (standard / budget / leadership) + const { leadId: projectLeadId, managerId: projectManagerId } = project.wbsElement; + const projectParent: SeedCrParent = { + wbsElementId: project.wbsElementId, + timeline, + leadId: projectLeadId ?? undefined, + managerId: projectManagerId ?? undefined + }; + + const projectCrIdentifiers = this.allocateIdentifiers(crCountForProject(this.faker)); + + const projectCrs = await Promise.all( + projectCrIdentifiers.map((identifier) => + this.prisma.change_Request.create({ + data: createSeedChangeRequest( + this.faker, + projectParent, + false, + identifier, + organizationId, + this.faker.helpers.arrayElement(submitterPool).userId, + this.faker.helpers.arrayElement(reviewerPool).userId, + budgetReasonId() + ) + }) + ) + ); + changeRequests.push(...projectCrs); + + const wpCrs = await Promise.all( + projectWorkPackages.flatMap(({ workPackage, timeline: wpTimeline }) => { + const { leadId: wpLeadId, managerId: wpManagerId } = workPackage.wbsElement; + const wpParent: SeedCrParent = { + wbsElementId: workPackage.wbsElement.wbsElementId, + timeline: wpTimeline, + leadId: wpLeadId ?? undefined, + managerId: wpManagerId ?? undefined + }; + + const wpCrIdentifiers = this.allocateIdentifiers(crCountForWorkPackage(this.faker)); + + return wpCrIdentifiers.map((identifier) => + this.prisma.change_Request.create({ + data: createSeedChangeRequest( + this.faker, + wpParent, + true, + identifier, + organizationId, + this.faker.helpers.arrayElement(submitterPool).userId, + this.faker.helpers.arrayElement(reviewerPool).userId, + undefined + ) + }) + ); + }) + ); + changeRequests.push(...wpCrs); + } + + const changeRequestsByWbsElementId = changeRequests.reduce>((acc, cr) => { + // budget CRs are category-scoped and legitimately have no wbsElement + if (cr.wbsElementId) { + acc[cr.wbsElementId] ??= []; + acc[cr.wbsElementId].push(cr); + } + return acc; + }, {}); + + return { + changeRequests, + changeRequestsByWbsElementId + }; + } +} From e1dba52730061352c41e270c3e973d5c875b4553 Mon Sep 17 00:00:00 2001 From: Steph375 Date: Thu, 16 Jul 2026 16:34:27 -0400 Subject: [PATCH 02/11] 4245 comment fix --- src/backend/src/prisma/seed/change-request.process.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/backend/src/prisma/seed/change-request.process.ts b/src/backend/src/prisma/seed/change-request.process.ts index 37b3ae54d7..a2bc078063 100644 --- a/src/backend/src/prisma/seed/change-request.process.ts +++ b/src/backend/src/prisma/seed/change-request.process.ts @@ -58,7 +58,7 @@ export class ChangeRequestProcess extends SeedProcess + const generateBudgetReasonId = (): string | undefined => reimbursementProductOtherReasons.length > 0 ? this.faker.helpers.arrayElement(reimbursementProductOtherReasons).otherReimbursementProductReasonId : undefined; @@ -90,7 +90,7 @@ export class ChangeRequestProcess extends SeedProcess>((acc, cr) => { - // budget CRs are category-scoped and legitimately have no wbsElement if (cr.wbsElementId) { acc[cr.wbsElementId] ??= []; acc[cr.wbsElementId].push(cr); From ca39b01aceabf920af7456d1fbabe30f857644c9 Mon Sep 17 00:00:00 2001 From: Steph375 Date: Mon, 20 Jul 2026 14:16:07 -0400 Subject: [PATCH 03/11] #4245 comment fixes --- .../factories/change-request.factory.ts | 186 ++++++++++---- .../src/prisma/seed/change-request.process.ts | 229 +++++++++++++----- 2 files changed, 313 insertions(+), 102 deletions(-) diff --git a/src/backend/src/prisma/factories/change-request.factory.ts b/src/backend/src/prisma/factories/change-request.factory.ts index f8c77f2d87..5c06620982 100644 --- a/src/backend/src/prisma/factories/change-request.factory.ts +++ b/src/backend/src/prisma/factories/change-request.factory.ts @@ -1,5 +1,5 @@ import { Faker } from '@faker-js/faker'; -import { CR_Type, Prisma } from '@prisma/client'; +import { Account_Code, CR_Type, Prisma } from '@prisma/client'; import { DateRange } from '../context.js'; import { clampDate, daysBetween } from '../dates.js'; import { addDaysToDate } from 'shared'; @@ -11,9 +11,11 @@ export type SeedCrParent = { managerId?: string; }; +export type SeedCrActor = { userId: string }; + export type SeedCrOverrides = Partial; 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.BUDGET, CR_Type.LEADERSHIP]; +export const PROJECT_CR_TYPES: CR_Type[] = [CR_Type.STANDARD, CR_Type.LEADERSHIP]; type ReviewOutcome = 'APPROVED' | 'DENIED' | 'PENDING'; @@ -56,6 +58,10 @@ const CHANGE_DETAILS = [ '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([ @@ -65,35 +71,38 @@ const crTypeForParent = (faker: Faker, isWorkPackage: boolean): CR_Type => { } return faker.helpers.weightedArrayElement([ - { weight: 94, value: CR_Type.STANDARD }, - { weight: 3, value: CR_Type.BUDGET }, + { weight: 97, value: CR_Type.STANDARD }, { weight: 3, value: CR_Type.LEADERSHIP } ]); }; -const reviewOutcome = (faker: Faker): ReviewOutcome => +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, + parent: SeedCrParent | undefined, submittedDate: Date ): Pick< Prisma.Change_RequestCreateInput, 'budgetChangeRequest' | 'stageGateChangeRequest' | 'activationChangeRequest' | 'leadershipChangeRequest' > => { - const { leadId, managerId } = parent; - switch (type) { case CR_Type.BUDGET: return { budgetChangeRequest: { - create: { proposedBudget: faker.helpers.arrayElement([500, 1000, 1500, 2000, 2500, 5000]) } + create: { proposedBudget: faker.helpers.arrayElement(BUDGET_AMOUNTS) } } }; case CR_Type.STAGE_GATE: @@ -102,7 +111,9 @@ const subtypeCreateInput = ( create: { leftoverBudget: 0, confirmDone: true } } }; - case CR_Type.ACTIVATION: + 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.'); } @@ -116,12 +127,13 @@ const subtypeCreateInput = ( } } }; + } case CR_Type.LEADERSHIP: return { leadershipChangeRequest: { create: { - ...(leadId ? { lead: { connect: { userId: leadId } } } : {}), - ...(managerId ? { manager: { connect: { userId: managerId } } } : {}) + ...(parent?.leadId ? { lead: { connect: { userId: parent.leadId } } } : {}), + ...(parent?.managerId ? { manager: { connect: { userId: parent.managerId } } } : {}) } } }; @@ -134,16 +146,16 @@ const subtypeCreateInput = ( const changesCreateInput = ( faker: Faker, implementerId: string, - link: { wbsElementId: string } | { categoryId: 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 } }, - ...('wbsElementId' in link + ...(link.kind === 'wbs' ? { wbsElement: { connect: { wbsElementId: link.wbsElementId } } } - : { category: { connect: { otherReimbursementProductReasonId: link.categoryId } } }) + : { accountCode: { connect: { accountCodeId: link.accountCodeId } } }) })); }; @@ -163,42 +175,57 @@ export const crCountForWorkPackage = (faker: Faker): number => { weight: 5, value: 3 } ]); -export const createSeedChangeRequest = ( - faker: Faker, - parent: SeedCrParent, - isWorkPackage: boolean, - identifier: number, - organizationId: string, - submitterId: string, - reviewerId: string, - budgetReasonId: string | undefined, - overrides: SeedCrOverrides = {} -): Prisma.Change_RequestCreateInput => { - const { timeline, wbsElementId } = parent; - const type = crTypeForParent(faker, isWorkPackage); - - const useCategory = type === CR_Type.BUDGET && budgetReasonId !== undefined; - const baseLink = useCategory - ? { category: { connect: { otherReimbursementProductReasonId: budgetReasonId as string } } } - : { wbsElement: { connect: { wbsElementId } } }; - const changeLink = useCategory ? { categoryId: budgetReasonId as string } : { wbsElementId }; - - const dateSubmitted = clampDate( - addDaysToDate(new Date(timeline.start), faker.number.int({ min: 0, max: daysBetween(timeline) })), - { start: timeline.start, end: timeline.end } - ); +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 }) } + ]); - const outcome = reviewOutcome(faker); +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: timeline.end + end: reviewWindowEnd }) : undefined; + const baseLink = + link.kind === 'wbs' + ? { wbsElement: { connect: { wbsElementId: link.wbsElementId } } } + : { accountCode: { connect: { accountCodeId: link.accountCodeId } } }; + return { identifier, type, @@ -214,10 +241,83 @@ export const createSeedChangeRequest = ( dateReviewed, accepted, reviewNotes: accepted ? faker.helpers.arrayElement(APPROVED_NOTES) : faker.helpers.arrayElement(DENIED_NOTES), - - ...(accepted ? { changes: { create: changesCreateInput(faker, reviewerId, changeLink) } } : {}) + ...(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[] => + 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 + }) + ); +}; diff --git a/src/backend/src/prisma/seed/change-request.process.ts b/src/backend/src/prisma/seed/change-request.process.ts index a2bc078063..9df7f75063 100644 --- a/src/backend/src/prisma/seed/change-request.process.ts +++ b/src/backend/src/prisma/seed/change-request.process.ts @@ -1,29 +1,47 @@ -import { Change_Request } from '@prisma/client'; +import { Change_Request, CR_Type, Prisma, WBS_Element_Status } from '@prisma/client'; import { SeedProcess } from '../processes/seed-process.js'; import { OrganizationOutput, OrganizationProcess } from './organization.process.js'; import { ProjectOutput, ProjectProcess } from './project.process.js'; import { UsersOutput, UsersProcess } from './user.process.js'; import { WorkPackageOutput, WorkPackageProcess } from './work-package.process.js'; import { ConfigDataOutput, ConfigDataProcess } from './config-data.process.js'; +import { TeamOutput, TeamProcess } from './team.process.js'; +import { DateRange } from '../context.js'; import { + buildAccountCodeChangeRequests, + buildWbsChangeRequests, + crCountForAccountCode, crCountForProject, crCountForWorkPackage, - createSeedChangeRequest, + SeedCrActor, SeedCrParent } from '../factories/change-request.factory.js'; -type ChangeRequestInput = OrganizationOutput & ProjectOutput & UsersOutput & WorkPackageOutput & ConfigDataOutput; +type ChangeRequestInput = OrganizationOutput & + ProjectOutput & + UsersOutput & + WorkPackageOutput & + ConfigDataOutput & + TeamOutput; export type ChangeRequestOutput = { changeRequests: Change_Request[]; changeRequestsByWbsElementId: Record; }; +const changeRequestInclude = { + activationChangeRequest: true, + stageGateChangeRequest: true, + budgetChangeRequest: true +} satisfies Prisma.Change_RequestInclude; + +type SeededChangeRequest = Prisma.Change_RequestGetPayload<{ include: typeof changeRequestInclude }>; + export class ChangeRequestProcess extends SeedProcess { private identifierCounter = 1; dependencies() { - return [OrganizationProcess, ProjectProcess, UsersProcess, WorkPackageProcess, ConfigDataProcess]; + return [OrganizationProcess, ProjectProcess, UsersProcess, WorkPackageProcess, ConfigDataProcess, TeamProcess]; } private allocateIdentifiers(count: number): number[] { @@ -32,6 +50,83 @@ export class ChangeRequestProcess extends SeedProcess { + return Promise.all(inputs.map((data) => this.prisma.change_Request.create({ data, include: changeRequestInclude }))); + } + + private async applyAcceptedWbsStatuses(changeRequests: SeededChangeRequest[]): Promise { + const latestStatusByWbs = new Map(); + + for (const cr of changeRequests) { + if (!cr.accepted || !cr.wbsElementId) continue; + + const status = + cr.type === CR_Type.ACTIVATION + ? WBS_Element_Status.ACTIVE + : cr.type === CR_Type.STAGE_GATE + ? WBS_Element_Status.COMPLETE + : undefined; + + if (!status) continue; + + const current = latestStatusByWbs.get(cr.wbsElementId); + if (!current || cr.dateSubmitted >= current.dateSubmitted) { + latestStatusByWbs.set(cr.wbsElementId, { dateSubmitted: cr.dateSubmitted, status }); + } + } + + await Promise.all( + Array.from(latestStatusByWbs.entries()).map(([wbsElementId, { status }]) => + this.prisma.wBS_Element.update({ where: { wbsElementId }, data: { status } }) + ) + ); + } + + private async applyAcceptedAccountCodeBudgets(changeRequests: SeededChangeRequest[]): Promise { + const latestBudgetByAccountCode = new Map(); + + for (const cr of changeRequests) { + if (!cr.accepted || !cr.accountCodeId || !cr.budgetChangeRequest) continue; + + const current = latestBudgetByAccountCode.get(cr.accountCodeId); + if (!current || cr.dateSubmitted >= current.dateSubmitted) { + latestBudgetByAccountCode.set(cr.accountCodeId, { + dateSubmitted: cr.dateSubmitted, + proposedBudget: cr.budgetChangeRequest.proposedBudget + }); + } + } + + await Promise.all( + Array.from(latestBudgetByAccountCode.entries()).map(([accountCodeId, { proposedBudget }]) => + this.prisma.account_Code.update({ where: { accountCodeId }, data: { amount: proposedBudget } }) + ) + ); + } + + private async resolveFinanceActors( + financeTeamId: string, + fallbackSubmitters: SeedCrActor[], + fallbackReviewers: SeedCrActor[] + ): Promise<{ submitters: SeedCrActor[]; reviewers: SeedCrActor[] }> { + const financeTeam = await this.prisma.team.findUnique({ + where: { teamId: financeTeamId }, + include: { members: true, leads: true, head: true } + }); + + if (!financeTeam) { + return { submitters: fallbackSubmitters, reviewers: fallbackReviewers }; + } + + const reviewers = [...financeTeam.leads, financeTeam.head]; + const submitters = [...financeTeam.members, ...reviewers]; + + return { + submitters: submitters.length > 0 ? submitters : fallbackSubmitters, + reviewers: reviewers.length > 0 ? reviewers : fallbackReviewers + }; + } + async run({ organization, projects, @@ -41,7 +136,8 @@ export class ChangeRequestProcess extends SeedProcess { const { organizationId } = organization; @@ -58,17 +154,11 @@ export class ChangeRequestProcess extends SeedProcess - reimbursementProductOtherReasons.length > 0 - ? this.faker.helpers.arrayElement(reimbursementProductOtherReasons).otherReimbursementProductReasonId - : undefined; - - const changeRequests: Change_Request[] = []; + const wbsChangeRequestInputs: Prisma.Change_RequestCreateInput[] = []; for (const { project, timeline } of projects) { const projectWorkPackages = workPackagesByProjectId[project.projectId] ?? []; - // project-scoped CRs (standard / budget / leadership) const { leadId: projectLeadId, managerId: projectManagerId } = project.wbsElement; const projectParent: SeedCrParent = { wbsElementId: project.wbsElementId, @@ -77,57 +167,68 @@ export class ChangeRequestProcess extends SeedProcess - this.prisma.change_Request.create({ - data: createSeedChangeRequest( - this.faker, - projectParent, - false, - identifier, - organizationId, - this.faker.helpers.arrayElement(submitterPool).userId, - this.faker.helpers.arrayElement(reviewerPool).userId, - generateBudgetReasonId() - ) - }) + wbsChangeRequestInputs.push( + ...buildWbsChangeRequests( + this.faker, + projectParent, + false, + this.allocateIdentifiers(crCountForProject(this.faker)), + organizationId, + submitterPool, + reviewerPool ) ); - changeRequests.push(...projectCrs); - - const wpCrs = await Promise.all( - projectWorkPackages.flatMap(({ workPackage, timeline: wpTimeline }) => { - const { leadId: wpLeadId, managerId: wpManagerId } = workPackage.wbsElement; - const wpParent: SeedCrParent = { - wbsElementId: workPackage.wbsElement.wbsElementId, - timeline: wpTimeline, - leadId: wpLeadId ?? undefined, - managerId: wpManagerId ?? undefined - }; - - const wpCrIdentifiers = this.allocateIdentifiers(crCountForWorkPackage(this.faker)); - - return wpCrIdentifiers.map((identifier) => - this.prisma.change_Request.create({ - data: createSeedChangeRequest( - this.faker, - wpParent, - true, - identifier, - organizationId, - this.faker.helpers.arrayElement(submitterPool).userId, - this.faker.helpers.arrayElement(reviewerPool).userId, - undefined - ) - }) - ); - }) - ); - changeRequests.push(...wpCrs); + + for (const { workPackage, timeline: wpTimeline } of projectWorkPackages) { + const { leadId: wpLeadId, managerId: wpManagerId } = workPackage.wbsElement; + const wpParent: SeedCrParent = { + wbsElementId: workPackage.wbsElement.wbsElementId, + timeline: wpTimeline, + leadId: wpLeadId ?? undefined, + managerId: wpManagerId ?? undefined + }; + + wbsChangeRequestInputs.push( + ...buildWbsChangeRequests( + this.faker, + wpParent, + true, + this.allocateIdentifiers(crCountForWorkPackage(this.faker)), + organizationId, + submitterPool, + reviewerPool + ) + ); + } } + const financeWindow = this.financeWindow(projects.map(({ timeline }) => timeline)); + const { submitters: financeSubmitters, reviewers: financeReviewers } = await this.resolveFinanceActors( + financeTeam.teamId, + submitterPool, + reviewerPool + ); + + const budgetChangeRequestInputs = accountCodes.flatMap((accountCode) => + buildAccountCodeChangeRequests( + this.faker, + accountCode, + financeWindow, + this.allocateIdentifiers(crCountForAccountCode(this.faker)), + organizationId, + financeSubmitters, + financeReviewers + ) + ); + + const createdWbsChangeRequests = await this.createChangeRequests(wbsChangeRequestInputs); + const createdBudgetChangeRequests = await this.createChangeRequests(budgetChangeRequestInputs); + + await this.applyAcceptedWbsStatuses(createdWbsChangeRequests); + await this.applyAcceptedAccountCodeBudgets(createdBudgetChangeRequests); + + const changeRequests = [...createdWbsChangeRequests, ...createdBudgetChangeRequests]; + const changeRequestsByWbsElementId = changeRequests.reduce>((acc, cr) => { if (cr.wbsElementId) { acc[cr.wbsElementId] ??= []; @@ -141,4 +242,14 @@ export class ChangeRequestProcess extends SeedProcess start.getTime()); + const ends = timelines.map(({ end }) => end.getTime()); + + return { + start: new Date(Math.min(...starts)), + end: new Date(Math.max(...ends)) + }; + } } From 184787c1d15ddcf3c8276c51ad1ba72d52eab941 Mon Sep 17 00:00:00 2001 From: Steph375 Date: Tue, 21 Jul 2026 11:25:33 -0400 Subject: [PATCH 04/11] #4245 fixes --- .../factories/change-request.factory.ts | 44 +++++++++----- .../src/prisma/seed/change-request.process.ts | 59 ++++++++++++------- 2 files changed, 69 insertions(+), 34 deletions(-) diff --git a/src/backend/src/prisma/factories/change-request.factory.ts b/src/backend/src/prisma/factories/change-request.factory.ts index 5c06620982..7428254c18 100644 --- a/src/backend/src/prisma/factories/change-request.factory.ts +++ b/src/backend/src/prisma/factories/change-request.factory.ts @@ -62,6 +62,10 @@ const BUDGET_AMOUNTS = [500, 1000, 1500, 2000, 2500, 5000]; type CrLink = { kind: 'wbs'; wbsElementId: string } | { kind: 'accountCode'; accountCodeId: string }; +const AUTO_ACCEPTED_TYPES: CR_Type[] = [CR_Type.ACTIVATION, CR_Type.STAGE_GATE, CR_Type.LEADERSHIP]; + +const isAutoAccepted = (type: CR_Type): boolean => AUTO_ACCEPTED_TYPES.includes(type); + const crTypeForParent = (faker: Faker, isWorkPackage: boolean): CR_Type => { if (isWorkPackage) { return faker.helpers.weightedArrayElement([ @@ -248,15 +252,23 @@ const buildChangeRequest = ({ }; }; -const outcomesForOrderedCrs = (faker: Faker, count: number): ReviewOutcome[] => - Array.from({ length: count }, (_, index) => (index === count - 1 ? latestOutcome(faker) : resolvedOutcome(faker))); +const outcomesForOrderedCrs = (faker: Faker, types: CR_Type[]): ReviewOutcome[] => { + const lastReviewableIndex = types.reduce((last, type, index) => (isAutoAccepted(type) ? last : index), -1); + + return types.map((type, index) => { + if (isAutoAccepted(type)) return 'APPROVED'; + return index === lastReviewableIndex ? latestOutcome(faker) : resolvedOutcome(faker); + }); +}; + +const cappedWindow = (timeline: DateRange): DateRange => ({ + start: timeline.start, + end: new Date(Math.min(timeline.end.getTime(), Date.now())) +}); -const orderedSubmissionDates = (faker: Faker, timeline: DateRange, count: number): Date[] => +const orderedSubmissionDates = (faker: Faker, window: DateRange, count: number): Date[] => Array.from({ length: count }, () => - clampDate(addDaysToDate(new Date(timeline.start), faker.number.int({ min: 0, max: daysBetween(timeline) })), { - start: timeline.start, - end: timeline.end - }) + clampDate(addDaysToDate(new Date(window.start), faker.number.int({ min: 0, max: daysBetween(window) })), window) ).sort((a, b) => a.getTime() - b.getTime()); const pickActor = (faker: Faker, actors: SeedCrActor[]): string => faker.helpers.arrayElement(actors).userId; @@ -272,22 +284,24 @@ export const buildWbsChangeRequests = ( ): Prisma.Change_RequestCreateInput[] => { if (identifiers.length === 0) return []; - const dates = orderedSubmissionDates(faker, parent.timeline, identifiers.length); - const outcomes = outcomesForOrderedCrs(faker, identifiers.length); + const window = cappedWindow(parent.timeline); + const dates = orderedSubmissionDates(faker, window, identifiers.length); + const types = identifiers.map(() => crTypeForParent(faker, isWorkPackage)); + const outcomes = outcomesForOrderedCrs(faker, types); return identifiers.map((identifier, index) => buildChangeRequest({ faker, identifier, organizationId, - type: crTypeForParent(faker, isWorkPackage), + type: types[index], 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 + reviewWindowEnd: window.end }) ); }; @@ -303,8 +317,10 @@ export const buildAccountCodeChangeRequests = ( ): Prisma.Change_RequestCreateInput[] => { if (identifiers.length === 0) return []; - const dates = orderedSubmissionDates(faker, timeline, identifiers.length); - const outcomes = outcomesForOrderedCrs(faker, identifiers.length); + const window = cappedWindow(timeline); + const dates = orderedSubmissionDates(faker, window, identifiers.length); + const types = identifiers.map(() => CR_Type.BUDGET); + const outcomes = outcomesForOrderedCrs(faker, types); return identifiers.map((identifier, index) => buildChangeRequest({ @@ -317,7 +333,7 @@ export const buildAccountCodeChangeRequests = ( reviewerId: pickActor(faker, reviewers), dateSubmitted: dates[index], outcome: outcomes[index], - reviewWindowEnd: timeline.end + reviewWindowEnd: window.end }) ); }; diff --git a/src/backend/src/prisma/seed/change-request.process.ts b/src/backend/src/prisma/seed/change-request.process.ts index 9df7f75063..05c2e8a45d 100644 --- a/src/backend/src/prisma/seed/change-request.process.ts +++ b/src/backend/src/prisma/seed/change-request.process.ts @@ -7,6 +7,7 @@ import { WorkPackageOutput, WorkPackageProcess } from './work-package.process.js import { ConfigDataOutput, ConfigDataProcess } from './config-data.process.js'; import { TeamOutput, TeamProcess } from './team.process.js'; import { DateRange } from '../context.js'; +import { WEEK_MS } from '../dates.js'; import { buildAccountCodeChangeRequests, buildWbsChangeRequests, @@ -32,7 +33,8 @@ export type ChangeRequestOutput = { const changeRequestInclude = { activationChangeRequest: true, stageGateChangeRequest: true, - budgetChangeRequest: true + budgetChangeRequest: true, + wbsElement: { include: { workPackage: true } } } satisfies Prisma.Change_RequestInclude; type SeededChangeRequest = Prisma.Change_RequestGetPayload<{ include: typeof changeRequestInclude }>; @@ -54,32 +56,49 @@ export class ChangeRequestProcess extends SeedProcess this.prisma.change_Request.create({ data, include: changeRequestInclude }))); } - private async applyAcceptedWbsStatuses(changeRequests: SeededChangeRequest[]): Promise { - const latestStatusByWbs = new Map(); + private async applyAcceptedWbsChanges(changeRequests: SeededChangeRequest[]): Promise { + const workPackageCrs = new Map(); for (const cr of changeRequests) { - if (!cr.accepted || !cr.wbsElementId) continue; + if (!cr.accepted || !cr.wbsElement?.workPackage) continue; + if (cr.type !== CR_Type.ACTIVATION && cr.type !== CR_Type.STAGE_GATE) continue; - const status = - cr.type === CR_Type.ACTIVATION - ? WBS_Element_Status.ACTIVE - : cr.type === CR_Type.STAGE_GATE - ? WBS_Element_Status.COMPLETE - : undefined; + const list = workPackageCrs.get(cr.wbsElement.wbsElementId) ?? []; + list.push(cr); + workPackageCrs.set(cr.wbsElement.wbsElementId, list); + } - if (!status) continue; + await Promise.all(Array.from(workPackageCrs.values()).map((crs) => this.applyWorkPackageLifecycle(crs))); + } - const current = latestStatusByWbs.get(cr.wbsElementId); - if (!current || cr.dateSubmitted >= current.dateSubmitted) { - latestStatusByWbs.set(cr.wbsElementId, { dateSubmitted: cr.dateSubmitted, status }); + private async applyWorkPackageLifecycle(crs: SeededChangeRequest[]): Promise { + const workPackage = crs[0].wbsElement?.workPackage; + if (!workPackage) return; + + const ordered = [...crs].sort((a, b) => a.dateSubmitted.getTime() - b.dateSubmitted.getTime()); + + let { startDate, duration } = workPackage; + let status: WBS_Element_Status = WBS_Element_Status.INACTIVE; + + for (const cr of ordered) { + if (cr.type === CR_Type.ACTIVATION && cr.activationChangeRequest) { + ({ startDate } = cr.activationChangeRequest); + status = WBS_Element_Status.ACTIVE; + } else if (cr.type === CR_Type.STAGE_GATE) { + const completedDate = cr.dateReviewed ?? cr.dateSubmitted; + duration = Math.max(1, Math.round((completedDate.getTime() - startDate.getTime()) / WEEK_MS)); + status = WBS_Element_Status.COMPLETE; } } - await Promise.all( - Array.from(latestStatusByWbs.entries()).map(([wbsElementId, { status }]) => - this.prisma.wBS_Element.update({ where: { wbsElementId }, data: { status } }) - ) - ); + await this.prisma.work_Package.update({ + where: { workPackageId: workPackage.workPackageId }, + data: { + startDate, + duration, + wbsElement: { update: { status } } + } + }); } private async applyAcceptedAccountCodeBudgets(changeRequests: SeededChangeRequest[]): Promise { @@ -224,7 +243,7 @@ export class ChangeRequestProcess extends SeedProcess Date: Wed, 22 Jul 2026 17:15:00 -0400 Subject: [PATCH 05/11] #4245 comment fixes --- .../src/prisma/seed/change-request.process.ts | 59 ++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/src/backend/src/prisma/seed/change-request.process.ts b/src/backend/src/prisma/seed/change-request.process.ts index 05c2e8a45d..7e50e15d67 100644 --- a/src/backend/src/prisma/seed/change-request.process.ts +++ b/src/backend/src/prisma/seed/change-request.process.ts @@ -37,7 +37,9 @@ const changeRequestInclude = { wbsElement: { include: { workPackage: true } } } satisfies Prisma.Change_RequestInclude; -type SeededChangeRequest = Prisma.Change_RequestGetPayload<{ include: typeof changeRequestInclude }>; +type SeededChangeRequest = Prisma.Change_RequestGetPayload<{ + include: typeof changeRequestInclude; +}>; export class ChangeRequestProcess extends SeedProcess { private identifierCounter = 1; @@ -53,7 +55,14 @@ export class ChangeRequestProcess extends SeedProcess { - return Promise.all(inputs.map((data) => this.prisma.change_Request.create({ data, include: changeRequestInclude }))); + return Promise.all( + inputs.map((data) => + this.prisma.change_Request.create({ + data, + include: changeRequestInclude + }) + ) + ); } private async applyAcceptedWbsChanges(changeRequests: SeededChangeRequest[]): Promise { @@ -86,17 +95,25 @@ export class ChangeRequestProcess extends SeedProcess= current.dateSubmitted) { latestBudgetByAccountCode.set(cr.accountCodeId, { dateSubmitted: cr.dateSubmitted, @@ -118,7 +136,14 @@ export class ChangeRequestProcess extends SeedProcess - this.prisma.account_Code.update({ where: { accountCodeId }, data: { amount: proposedBudget } }) + this.prisma.account_Code.update({ + where: { + accountCodeId + }, + data: { + amount: proposedBudget + } + }) ) ); } @@ -129,12 +154,21 @@ export class ChangeRequestProcess extends SeedProcess { const financeTeam = await this.prisma.team.findUnique({ - where: { teamId: financeTeamId }, - include: { members: true, leads: true, head: true } + where: { + teamId: financeTeamId + }, + include: { + members: true, + leads: true, + head: true + } }); if (!financeTeam) { - return { submitters: fallbackSubmitters, reviewers: fallbackReviewers }; + return { + submitters: fallbackSubmitters, + reviewers: fallbackReviewers + }; } const reviewers = [...financeTeam.leads, financeTeam.head]; @@ -174,11 +208,15 @@ export class ChangeRequestProcess extends SeedProcess now.getTime()) continue; + const projectWorkPackages = workPackagesByProjectId[project.projectId] ?? []; const { leadId: projectLeadId, managerId: projectManagerId } = project.wbsElement; + const projectParent: SeedCrParent = { wbsElementId: project.wbsElementId, timeline, @@ -199,7 +237,10 @@ export class ChangeRequestProcess extends SeedProcess now.getTime()) continue; + const { leadId: wpLeadId, managerId: wpManagerId } = workPackage.wbsElement; + const wpParent: SeedCrParent = { wbsElementId: workPackage.wbsElement.wbsElementId, timeline: wpTimeline, @@ -222,6 +263,7 @@ export class ChangeRequestProcess extends SeedProcess timeline)); + const { submitters: financeSubmitters, reviewers: financeReviewers } = await this.resolveFinanceActors( financeTeam.teamId, submitterPool, @@ -253,6 +295,7 @@ export class ChangeRequestProcess extends SeedProcess Date: Fri, 24 Jul 2026 05:42:27 -0400 Subject: [PATCH 06/11] #4245 addressing comments --- .../factories/change-request.factory.ts | 55 +++++- .../src/prisma/seed/change-request.process.ts | 184 +++++++++++++++--- 2 files changed, 200 insertions(+), 39 deletions(-) diff --git a/src/backend/src/prisma/factories/change-request.factory.ts b/src/backend/src/prisma/factories/change-request.factory.ts index 7428254c18..790567212a 100644 --- a/src/backend/src/prisma/factories/change-request.factory.ts +++ b/src/backend/src/prisma/factories/change-request.factory.ts @@ -93,11 +93,22 @@ const resolvedOutcome = (faker: Faker): ReviewOutcome => { weight: 7, value: 'DENIED' as const } ]); +const pickDifferentActor = (faker: Faker, actors: SeedCrActor[], excludedIds: string[]): string => { + const candidates = actors.filter(({ userId }) => !excludedIds.includes(userId)); + + if (candidates.length === 0) { + throw new Error('Change request requires an available replacement actor.'); + } + + return faker.helpers.arrayElement(candidates).userId; +}; + const subtypeCreateInput = ( faker: Faker, type: CR_Type, parent: SeedCrParent | undefined, - submittedDate: Date + submittedDate: Date, + ownerCandidates: SeedCrActor[] ): Pick< Prisma.Change_RequestCreateInput, 'budgetChangeRequest' | 'stageGateChangeRequest' | 'activationChangeRequest' | 'leadershipChangeRequest' @@ -116,11 +127,16 @@ const subtypeCreateInput = ( } }; case CR_Type.ACTIVATION: { - const leadId = parent?.leadId; - const managerId = parent?.managerId; - if (!leadId || !managerId) { + const currentLeadId = parent?.leadId; + const currentManagerId = parent?.managerId; + + if (!currentLeadId || !currentManagerId) { throw new Error('Activation change request requires a lead and manager on the parent work package.'); } + + const leadId = pickDifferentActor(faker, ownerCandidates, [currentLeadId]); + const managerId = pickDifferentActor(faker, ownerCandidates, [currentManagerId, leadId]); + return { activationChangeRequest: { create: { @@ -132,15 +148,23 @@ const subtypeCreateInput = ( } }; } - case CR_Type.LEADERSHIP: + case CR_Type.LEADERSHIP: { + const currentLeadId = parent?.leadId; + const currentManagerId = parent?.managerId; + + const leadId = pickDifferentActor(faker, ownerCandidates, currentLeadId ? [currentLeadId] : []); + + const managerId = pickDifferentActor(faker, ownerCandidates, currentManagerId ? [currentManagerId, leadId] : [leadId]); + return { leadershipChangeRequest: { create: { - ...(parent?.leadId ? { lead: { connect: { userId: parent.leadId } } } : {}), - ...(parent?.managerId ? { manager: { connect: { userId: parent.managerId } } } : {}) + lead: { connect: { userId: leadId } }, + manager: { connect: { userId: managerId } } } } }; + } case CR_Type.STANDARD: default: return {}; @@ -150,12 +174,14 @@ const subtypeCreateInput = ( const changesCreateInput = ( faker: Faker, implementerId: string, - link: CrLink + link: CrLink, + dateImplemented: Date ): Prisma.ChangeCreateWithoutChangeRequestInput[] => { const count = faker.number.int({ min: 1, max: 4 }); return Array.from({ length: count }, () => ({ detail: faker.helpers.arrayElement(CHANGE_DETAILS), + dateImplemented, implementer: { connect: { userId: implementerId } }, ...(link.kind === 'wbs' ? { wbsElement: { connect: { wbsElementId: link.wbsElementId } } } @@ -195,6 +221,7 @@ type BuildChangeRequestArgs = { link: CrLink; submitterId: string; reviewerId: string; + ownerCandidates?: SeedCrActor[]; dateSubmitted: Date; outcome: ReviewOutcome; reviewWindowEnd: Date; @@ -210,6 +237,7 @@ const buildChangeRequest = ({ link, submitterId, reviewerId, + ownerCandidates = [], dateSubmitted, outcome, reviewWindowEnd, @@ -238,14 +266,20 @@ const buildChangeRequest = ({ organization: { connect: { organizationId } }, submitter: { connect: { userId: submitterId } }, ...baseLink, - ...subtypeCreateInput(faker, type, parent, dateSubmitted), + ...subtypeCreateInput(faker, type, parent, dateSubmitted, ownerCandidates), ...(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) } } : {}) + ...(accepted + ? { + changes: { + create: changesCreateInput(faker, reviewerId, link, dateReviewed ?? dateSubmitted) + } + } + : {}) } : {}), ...overrides @@ -299,6 +333,7 @@ export const buildWbsChangeRequests = ( link: { kind: 'wbs', wbsElementId: parent.wbsElementId }, submitterId: pickActor(faker, submitters), reviewerId: pickActor(faker, reviewers), + ownerCandidates: reviewers, dateSubmitted: dates[index], outcome: outcomes[index], reviewWindowEnd: window.end diff --git a/src/backend/src/prisma/seed/change-request.process.ts b/src/backend/src/prisma/seed/change-request.process.ts index 7e50e15d67..9cc2abc7db 100644 --- a/src/backend/src/prisma/seed/change-request.process.ts +++ b/src/backend/src/prisma/seed/change-request.process.ts @@ -4,6 +4,7 @@ import { OrganizationOutput, OrganizationProcess } from './organization.process. import { ProjectOutput, ProjectProcess } from './project.process.js'; import { UsersOutput, UsersProcess } from './user.process.js'; import { WorkPackageOutput, WorkPackageProcess } from './work-package.process.js'; +import { DescriptionBulletProcess } from './description-bullet.process.js'; import { ConfigDataOutput, ConfigDataProcess } from './config-data.process.js'; import { TeamOutput, TeamProcess } from './team.process.js'; import { DateRange } from '../context.js'; @@ -34,7 +35,12 @@ const changeRequestInclude = { activationChangeRequest: true, stageGateChangeRequest: true, budgetChangeRequest: true, - wbsElement: { include: { workPackage: true } } + leadershipChangeRequest: true, + wbsElement: { + include: { + workPackage: true + } + } } satisfies Prisma.Change_RequestInclude; type SeededChangeRequest = Prisma.Change_RequestGetPayload<{ @@ -45,12 +51,22 @@ export class ChangeRequestProcess extends SeedProcess this.identifierCounter + offset); + this.identifierCounter += count; + return identifiers; } @@ -65,64 +81,160 @@ export class ChangeRequestProcess extends SeedProcess { + const earliestStageGateByWbsElementId = new Map(); + + for (const cr of changeRequests) { + if (!cr.accepted) continue; + if (cr.type !== CR_Type.STAGE_GATE) continue; + if (!cr.wbsElementId || !cr.wbsElement?.workPackage) continue; + + const current = earliestStageGateByWbsElementId.get(cr.wbsElementId); + + if (!current || cr.dateSubmitted < current.dateSubmitted) { + earliestStageGateByWbsElementId.set(cr.wbsElementId, cr); + } + } + + await Promise.all( + Array.from(earliestStageGateByWbsElementId.values()).map((cr) => { + const checkerId = cr.reviewerId ?? cr.submitterId; + + return this.prisma.description_Bullet.updateMany({ + where: { + wbsElementId: cr.wbsElementId! + }, + data: { + userCheckedId: checkerId, + dateTimeChecked: cr.dateSubmitted + } + }); + }) + ); + } + + /** + * Apply accepted activation, stage-gate, and leadership CRs to their WBS + * elements in chronological order. + * + * Handling them together ensures that the final lead and manager come from + * the latest accepted CR rather than one CR type always overwriting another. + */ private async applyAcceptedWbsChanges(changeRequests: SeededChangeRequest[]): Promise { - const workPackageCrs = new Map(); + const changeRequestsByWbsElementId = new Map(); for (const cr of changeRequests) { - if (!cr.accepted || !cr.wbsElement?.workPackage) continue; - if (cr.type !== CR_Type.ACTIVATION && cr.type !== CR_Type.STAGE_GATE) continue; + if (!cr.accepted || !cr.wbsElementId || !cr.wbsElement) continue; + + const affectsWbsElement = + cr.type === CR_Type.ACTIVATION || cr.type === CR_Type.STAGE_GATE || cr.type === CR_Type.LEADERSHIP; + + if (!affectsWbsElement) continue; - const list = workPackageCrs.get(cr.wbsElement.wbsElementId) ?? []; + const list = changeRequestsByWbsElementId.get(cr.wbsElementId) ?? []; list.push(cr); - workPackageCrs.set(cr.wbsElement.wbsElementId, list); + changeRequestsByWbsElementId.set(cr.wbsElementId, list); } - await Promise.all(Array.from(workPackageCrs.values()).map((crs) => this.applyWorkPackageLifecycle(crs))); + await Promise.all(Array.from(changeRequestsByWbsElementId.values()).map((crs) => this.applyWbsElementChanges(crs))); } - private async applyWorkPackageLifecycle(crs: SeededChangeRequest[]): Promise { - const workPackage = crs[0].wbsElement?.workPackage; - if (!workPackage) return; + private async applyWbsElementChanges(changeRequests: SeededChangeRequest[]): Promise { + const wbsElement = changeRequests[0].wbsElement; + + if (!wbsElement) return; + + const workPackage = wbsElement.workPackage; - const ordered = [...crs].sort((a, b) => a.dateSubmitted.getTime() - b.dateSubmitted.getTime()); + const ordered = [...changeRequests].sort((a, b) => a.dateSubmitted.getTime() - b.dateSubmitted.getTime()); - let { startDate, duration } = workPackage; - let status: WBS_Element_Status = WBS_Element_Status.INACTIVE; + let leadId = wbsElement.leadId; + let managerId = wbsElement.managerId; + let status: WBS_Element_Status = wbsElement.status; + + let startDate = workPackage?.startDate; + let duration = workPackage?.duration; for (const cr of ordered) { - if (cr.type === CR_Type.ACTIVATION && cr.activationChangeRequest) { - ({ startDate } = cr.activationChangeRequest); + if (cr.type === CR_Type.ACTIVATION && cr.activationChangeRequest && workPackage) { + startDate = cr.activationChangeRequest.startDate; + + leadId = cr.activationChangeRequest.leadId ?? leadId; + managerId = cr.activationChangeRequest.managerId ?? managerId; + status = WBS_Element_Status.ACTIVE; - } else if (cr.type === CR_Type.STAGE_GATE) { + } else if (cr.type === CR_Type.STAGE_GATE && cr.stageGateChangeRequest && workPackage && startDate) { const completedDate = cr.dateReviewed ?? cr.dateSubmitted; duration = Math.max(1, Math.round((completedDate.getTime() - startDate.getTime()) / WEEK_MS)); status = WBS_Element_Status.COMPLETE; + } else if (cr.type === CR_Type.LEADERSHIP && cr.leadershipChangeRequest) { + leadId = cr.leadershipChangeRequest.leadId ?? leadId; + managerId = cr.leadershipChangeRequest.managerId ?? managerId; } } - await this.prisma.work_Package.update({ + await this.prisma.wBS_Element.update({ where: { - workPackageId: workPackage.workPackageId + wbsElementId: wbsElement.wbsElementId }, data: { - startDate, - duration, - wbsElement: { - update: { - status - } - } + status, + ...(leadId + ? { + lead: { + connect: { + userId: leadId + } + } + } + : {}), + ...(managerId + ? { + manager: { + connect: { + userId: managerId + } + } + } + : {}) } }); + + if (workPackage && startDate && duration !== undefined) { + await this.prisma.work_Package.update({ + where: { + workPackageId: workPackage.workPackageId + }, + data: { + startDate, + duration + } + }); + } } private async applyAcceptedAccountCodeBudgets(changeRequests: SeededChangeRequest[]): Promise { - const latestBudgetByAccountCode = new Map(); + const latestBudgetByAccountCode = new Map< + string, + { + dateSubmitted: Date; + proposedBudget: number; + } + >(); for (const cr of changeRequests) { - if (!cr.accepted || !cr.accountCodeId || !cr.budgetChangeRequest) continue; + if (!cr.accepted || !cr.accountCodeId || !cr.budgetChangeRequest) { + continue; + } const current = latestBudgetByAccountCode.get(cr.accountCodeId); @@ -152,7 +264,10 @@ export class ChangeRequestProcess extends SeedProcess { + ): Promise<{ + submitters: SeedCrActor[]; + reviewers: SeedCrActor[]; + }> { const financeTeam = await this.prisma.team.findUnique({ where: { teamId: financeTeamId @@ -201,6 +316,7 @@ export class ChangeRequestProcess extends SeedProcess now.getTime()) continue; const projectWorkPackages = workPackagesByProjectId[project.projectId] ?? []; @@ -237,6 +354,7 @@ export class ChangeRequestProcess extends SeedProcess now.getTime()) continue; const { leadId: wpLeadId, managerId: wpManagerId } = workPackage.wbsElement; @@ -283,9 +401,17 @@ export class ChangeRequestProcess extends SeedProcess Date: Fri, 24 Jul 2026 05:52:25 -0400 Subject: [PATCH 07/11] #4245 linting fix --- .../src/prisma/seed/change-request.process.ts | 249 +++++++----------- 1 file changed, 97 insertions(+), 152 deletions(-) diff --git a/src/backend/src/prisma/seed/change-request.process.ts b/src/backend/src/prisma/seed/change-request.process.ts index 9cc2abc7db..51107b7de0 100644 --- a/src/backend/src/prisma/seed/change-request.process.ts +++ b/src/backend/src/prisma/seed/change-request.process.ts @@ -36,11 +36,7 @@ const changeRequestInclude = { stageGateChangeRequest: true, budgetChangeRequest: true, leadershipChangeRequest: true, - wbsElement: { - include: { - workPackage: true - } - } + wbsElement: { include: { workPackage: true } } } satisfies Prisma.Change_RequestInclude; type SeededChangeRequest = Prisma.Change_RequestGetPayload<{ @@ -64,9 +60,7 @@ export class ChangeRequestProcess extends SeedProcess this.identifierCounter + offset); - this.identifierCounter += count; - return identifiers; } @@ -81,160 +75,101 @@ export class ChangeRequestProcess extends SeedProcess { - const earliestStageGateByWbsElementId = new Map(); - - for (const cr of changeRequests) { - if (!cr.accepted) continue; - if (cr.type !== CR_Type.STAGE_GATE) continue; - if (!cr.wbsElementId || !cr.wbsElement?.workPackage) continue; - - const current = earliestStageGateByWbsElementId.get(cr.wbsElementId); - - if (!current || cr.dateSubmitted < current.dateSubmitted) { - earliestStageGateByWbsElementId.set(cr.wbsElementId, cr); - } - } - - await Promise.all( - Array.from(earliestStageGateByWbsElementId.values()).map((cr) => { - const checkerId = cr.reviewerId ?? cr.submitterId; - - return this.prisma.description_Bullet.updateMany({ - where: { - wbsElementId: cr.wbsElementId! - }, - data: { - userCheckedId: checkerId, - dateTimeChecked: cr.dateSubmitted - } - }); - }) - ); - } - - /** - * Apply accepted activation, stage-gate, and leadership CRs to their WBS - * elements in chronological order. - * - * Handling them together ensures that the final lead and manager come from - * the latest accepted CR rather than one CR type always overwriting another. - */ private async applyAcceptedWbsChanges(changeRequests: SeededChangeRequest[]): Promise { - const changeRequestsByWbsElementId = new Map(); + const workPackageCrs = new Map(); for (const cr of changeRequests) { - if (!cr.accepted || !cr.wbsElementId || !cr.wbsElement) continue; - - const affectsWbsElement = - cr.type === CR_Type.ACTIVATION || cr.type === CR_Type.STAGE_GATE || cr.type === CR_Type.LEADERSHIP; - - if (!affectsWbsElement) continue; + if (!cr.accepted || !cr.wbsElement?.workPackage) continue; + if (cr.type !== CR_Type.ACTIVATION && cr.type !== CR_Type.STAGE_GATE) continue; - const list = changeRequestsByWbsElementId.get(cr.wbsElementId) ?? []; + const list = workPackageCrs.get(cr.wbsElement.wbsElementId) ?? []; list.push(cr); - changeRequestsByWbsElementId.set(cr.wbsElementId, list); + workPackageCrs.set(cr.wbsElement.wbsElementId, list); } - await Promise.all(Array.from(changeRequestsByWbsElementId.values()).map((crs) => this.applyWbsElementChanges(crs))); + await Promise.all(Array.from(workPackageCrs.values()).map((crs) => this.applyWorkPackageLifecycle(crs))); } + private async applyWorkPackageLifecycle(crs: SeededChangeRequest[]): Promise { + const [firstCr] = crs; + const { wbsElement } = firstCr; + const { workPackage } = wbsElement ?? {}; - private async applyWbsElementChanges(changeRequests: SeededChangeRequest[]): Promise { - const wbsElement = changeRequests[0].wbsElement; + if (!workPackage || !wbsElement) return; - if (!wbsElement) return; + const ordered = [...crs].sort((a, b) => a.dateSubmitted.getTime() - b.dateSubmitted.getTime()); - const workPackage = wbsElement.workPackage; - - const ordered = [...changeRequests].sort((a, b) => a.dateSubmitted.getTime() - b.dateSubmitted.getTime()); - - let leadId = wbsElement.leadId; - let managerId = wbsElement.managerId; - let status: WBS_Element_Status = wbsElement.status; - - let startDate = workPackage?.startDate; - let duration = workPackage?.duration; + let { startDate, duration } = workPackage; + let { leadId, managerId } = wbsElement; + let status: WBS_Element_Status = WBS_Element_Status.INACTIVE; for (const cr of ordered) { - if (cr.type === CR_Type.ACTIVATION && cr.activationChangeRequest && workPackage) { - startDate = cr.activationChangeRequest.startDate; - - leadId = cr.activationChangeRequest.leadId ?? leadId; - managerId = cr.activationChangeRequest.managerId ?? managerId; + const { type, activationChangeRequest, dateReviewed, dateSubmitted } = cr; + if (type === CR_Type.ACTIVATION && activationChangeRequest) { + ({ startDate, leadId, managerId } = activationChangeRequest); status = WBS_Element_Status.ACTIVE; - } else if (cr.type === CR_Type.STAGE_GATE && cr.stageGateChangeRequest && workPackage && startDate) { - const completedDate = cr.dateReviewed ?? cr.dateSubmitted; + } else if (type === CR_Type.STAGE_GATE) { + const completedDate = dateReviewed ?? dateSubmitted; duration = Math.max(1, Math.round((completedDate.getTime() - startDate.getTime()) / WEEK_MS)); status = WBS_Element_Status.COMPLETE; - } else if (cr.type === CR_Type.LEADERSHIP && cr.leadershipChangeRequest) { - leadId = cr.leadershipChangeRequest.leadId ?? leadId; - managerId = cr.leadershipChangeRequest.managerId ?? managerId; } } - await this.prisma.wBS_Element.update({ + await this.prisma.work_Package.update({ where: { - wbsElementId: wbsElement.wbsElementId + workPackageId: workPackage.workPackageId }, data: { - status, - ...(leadId - ? { - lead: { - connect: { - userId: leadId - } - } - } - : {}), - ...(managerId - ? { - manager: { - connect: { - userId: managerId - } - } - } - : {}) + startDate, + duration, + wbsElement: { + update: { + leadId, + managerId, + status + } + } } }); + } - if (workPackage && startDate && duration !== undefined) { - await this.prisma.work_Package.update({ - where: { - workPackageId: workPackage.workPackageId - }, - data: { - startDate, - duration - } - }); + private async applyAcceptedLeadershipChanges(changeRequests: SeededChangeRequest[]): Promise { + const latestLeadershipByWbsElementId = new Map(); + + for (const cr of changeRequests) { + if (!cr.accepted || cr.type !== CR_Type.LEADERSHIP || !cr.wbsElementId || !cr.leadershipChangeRequest) { + continue; + } + + const current = latestLeadershipByWbsElementId.get(cr.wbsElementId); + + if (!current || cr.dateSubmitted >= current.dateSubmitted) { + latestLeadershipByWbsElementId.set(cr.wbsElementId, cr); + } } + + await Promise.all( + Array.from(latestLeadershipByWbsElementId.entries()).map(([wbsElementId, cr]) => + this.prisma.wBS_Element.update({ + where: { + wbsElementId + }, + data: { + leadId: cr.leadershipChangeRequest!.leadId, + managerId: cr.leadershipChangeRequest!.managerId + } + }) + ) + ); } private async applyAcceptedAccountCodeBudgets(changeRequests: SeededChangeRequest[]): Promise { - const latestBudgetByAccountCode = new Map< - string, - { - dateSubmitted: Date; - proposedBudget: number; - } - >(); + const latestBudgetByAccountCode = new Map(); for (const cr of changeRequests) { - if (!cr.accepted || !cr.accountCodeId || !cr.budgetChangeRequest) { - continue; - } + if (!cr.accepted || !cr.accountCodeId || !cr.budgetChangeRequest) continue; const current = latestBudgetByAccountCode.get(cr.accountCodeId); @@ -264,10 +199,7 @@ export class ChangeRequestProcess extends SeedProcess { + ): Promise<{ submitters: SeedCrActor[]; reviewers: SeedCrActor[] }> { const financeTeam = await this.prisma.team.findUnique({ where: { teamId: financeTeamId @@ -316,7 +248,6 @@ export class ChangeRequestProcess extends SeedProcess now.getTime()) continue; const projectWorkPackages = workPackagesByProjectId[project.projectId] ?? []; @@ -354,7 +284,6 @@ export class ChangeRequestProcess extends SeedProcess now.getTime()) continue; const { leadId: wpLeadId, managerId: wpManagerId } = workPackage.wbsElement; @@ -366,17 +295,40 @@ export class ChangeRequestProcess extends SeedProcess input.type === CR_Type.STAGE_GATE && input.accepted === true + ); + + if (acceptedStageGate) { + if (!acceptedStageGate.dateSubmitted) { + throw new Error('Accepted stage-gate change request requires a submission date.'); + } + + const dateTimeChecked = new Date(acceptedStageGate.dateSubmitted); + dateTimeChecked.setMilliseconds(dateTimeChecked.getMilliseconds() - 1); + + await this.prisma.description_Bullet.updateMany({ + where: { + wbsElementId: workPackage.wbsElement.wbsElementId + }, + data: { + userCheckedId: wpLeadId ?? wpManagerId ?? this.faker.helpers.arrayElement(reviewerPool).userId, + dateTimeChecked + } + }); + } + + wbsChangeRequestInputs.push(...workPackageChangeRequestInputs); } } @@ -401,17 +353,10 @@ export class ChangeRequestProcess extends SeedProcess Date: Fri, 24 Jul 2026 05:52:45 -0400 Subject: [PATCH 08/11] #4245 linting fix --- .../src/prisma/seed/change-request.process.ts | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/src/backend/src/prisma/seed/change-request.process.ts b/src/backend/src/prisma/seed/change-request.process.ts index 51107b7de0..369af61a92 100644 --- a/src/backend/src/prisma/seed/change-request.process.ts +++ b/src/backend/src/prisma/seed/change-request.process.ts @@ -139,29 +139,39 @@ export class ChangeRequestProcess extends SeedProcess(); for (const cr of changeRequests) { - if (!cr.accepted || cr.type !== CR_Type.LEADERSHIP || !cr.wbsElementId || !cr.leadershipChangeRequest) { + const { accepted, type, wbsElementId, leadershipChangeRequest, dateSubmitted } = cr; + + if (!accepted || type !== CR_Type.LEADERSHIP || !wbsElementId || !leadershipChangeRequest) { continue; } - const current = latestLeadershipByWbsElementId.get(cr.wbsElementId); + const current = latestLeadershipByWbsElementId.get(wbsElementId); - if (!current || cr.dateSubmitted >= current.dateSubmitted) { - latestLeadershipByWbsElementId.set(cr.wbsElementId, cr); + if (!current || dateSubmitted >= current.dateSubmitted) { + latestLeadershipByWbsElementId.set(wbsElementId, cr); } } await Promise.all( - Array.from(latestLeadershipByWbsElementId.entries()).map(([wbsElementId, cr]) => - this.prisma.wBS_Element.update({ + Array.from(latestLeadershipByWbsElementId.entries()).map(([wbsElementId, cr]) => { + const { leadershipChangeRequest } = cr; + + if (!leadershipChangeRequest) { + return Promise.resolve(); + } + + const { leadId, managerId } = leadershipChangeRequest; + + return this.prisma.wBS_Element.update({ where: { wbsElementId }, data: { - leadId: cr.leadershipChangeRequest!.leadId, - managerId: cr.leadershipChangeRequest!.managerId + leadId, + managerId } - }) - ) + }); + }) ); } @@ -169,14 +179,17 @@ export class ChangeRequestProcess extends SeedProcess(); for (const cr of changeRequests) { - if (!cr.accepted || !cr.accountCodeId || !cr.budgetChangeRequest) continue; + const { accepted, accountCodeId, budgetChangeRequest, dateSubmitted } = cr; + + if (!accepted || !accountCodeId || !budgetChangeRequest) continue; - const current = latestBudgetByAccountCode.get(cr.accountCodeId); + const current = latestBudgetByAccountCode.get(accountCodeId); + const { proposedBudget } = budgetChangeRequest; - if (!current || cr.dateSubmitted >= current.dateSubmitted) { - latestBudgetByAccountCode.set(cr.accountCodeId, { - dateSubmitted: cr.dateSubmitted, - proposedBudget: cr.budgetChangeRequest.proposedBudget + if (!current || dateSubmitted >= current.dateSubmitted) { + latestBudgetByAccountCode.set(accountCodeId, { + dateSubmitted, + proposedBudget }); } } From af6ac649f905dada8f3011b423001cc8e1ea731a Mon Sep 17 00:00:00 2001 From: Steph375 Date: Fri, 24 Jul 2026 06:01:48 -0400 Subject: [PATCH 09/11] #4245 fix --- .../factories/change-request.factory.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/backend/src/prisma/factories/change-request.factory.ts b/src/backend/src/prisma/factories/change-request.factory.ts index 790567212a..dce714c44a 100644 --- a/src/backend/src/prisma/factories/change-request.factory.ts +++ b/src/backend/src/prisma/factories/change-request.factory.ts @@ -134,8 +134,8 @@ const subtypeCreateInput = ( throw new Error('Activation change request requires a lead and manager on the parent work package.'); } - const leadId = pickDifferentActor(faker, ownerCandidates, [currentLeadId]); - const managerId = pickDifferentActor(faker, ownerCandidates, [currentManagerId, leadId]); + const leadId = pickDifferentActor(faker, ownerCandidates, [currentLeadId, currentManagerId]); + const managerId = pickDifferentActor(faker, ownerCandidates, [currentLeadId, currentManagerId, leadId]); return { activationChangeRequest: { @@ -152,9 +152,17 @@ const subtypeCreateInput = ( const currentLeadId = parent?.leadId; const currentManagerId = parent?.managerId; - const leadId = pickDifferentActor(faker, ownerCandidates, currentLeadId ? [currentLeadId] : []); + const leadId = pickDifferentActor( + faker, + ownerCandidates, + [currentLeadId, currentManagerId].filter((id): id is string => id !== undefined) + ); - const managerId = pickDifferentActor(faker, ownerCandidates, currentManagerId ? [currentManagerId, leadId] : [leadId]); + const managerId = pickDifferentActor( + faker, + ownerCandidates, + [currentLeadId, currentManagerId, leadId].filter((id): id is string => id !== undefined) + ); return { leadershipChangeRequest: { @@ -291,7 +299,14 @@ const outcomesForOrderedCrs = (faker: Faker, types: CR_Type[]): ReviewOutcome[] return types.map((type, index) => { if (isAutoAccepted(type)) return 'APPROVED'; - return index === lastReviewableIndex ? latestOutcome(faker) : resolvedOutcome(faker); + + const outcome = index === lastReviewableIndex ? latestOutcome(faker) : resolvedOutcome(faker); + + if (type === CR_Type.STANDARD && outcome === 'APPROVED') { + return 'DENIED'; + } + + return outcome; }); }; From 3e15c90d2f9970d6f2821ad1cae27d463e55d712 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sat, 25 Jul 2026 12:31:53 -0400 Subject: [PATCH 10/11] #4245 fix reviewer --- .../factories/change-request.factory.ts | 48 +++++++++++++------ .../factories/description-bullet.factory.ts | 17 ++++++- .../src/prisma/seed/change-request.process.ts | 10 ++-- .../prisma/seed/description-bullet.process.ts | 29 ++++++++--- 4 files changed, 80 insertions(+), 24 deletions(-) diff --git a/src/backend/src/prisma/factories/change-request.factory.ts b/src/backend/src/prisma/factories/change-request.factory.ts index dce714c44a..a59ee3c452 100644 --- a/src/backend/src/prisma/factories/change-request.factory.ts +++ b/src/backend/src/prisma/factories/change-request.factory.ts @@ -229,6 +229,7 @@ type BuildChangeRequestArgs = { link: CrLink; submitterId: string; reviewerId: string; + reviewerIsHeadOrAdmin: boolean; ownerCandidates?: SeedCrActor[]; dateSubmitted: Date; outcome: ReviewOutcome; @@ -245,6 +246,7 @@ const buildChangeRequest = ({ link, submitterId, reviewerId, + reviewerIsHeadOrAdmin, ownerCandidates = [], dateSubmitted, outcome, @@ -266,6 +268,11 @@ const buildChangeRequest = ({ ? { wbsElement: { connect: { wbsElementId: link.wbsElementId } } } : { accountCode: { connect: { accountCodeId: link.accountCodeId } } }; + // reviewChangeRequest only allows a non-head/admin reviewer if they're a requested reviewer, + // so it's mandatory whenever the reviewer doesn't satisfy isHead; otherwise it's an optional + // field a submitter may or may not have set. + const hasRequestedReviewer = !reviewerIsHeadOrAdmin || faker.datatype.boolean({ probability: 0.25 }); + return { identifier, type, @@ -273,6 +280,7 @@ const buildChangeRequest = ({ why: type === CR_Type.BUDGET ? faker.helpers.arrayElement(BUDGET_WHY) : faker.helpers.arrayElement(STANDARD_WHY), organization: { connect: { organizationId } }, submitter: { connect: { userId: submitterId } }, + ...(hasRequestedReviewer ? { requestedReviewers: { connect: { userId: reviewerId } } } : {}), ...baseLink, ...subtypeCreateInput(faker, type, parent, dateSubmitted, ownerCandidates), ...(reviewed @@ -329,7 +337,8 @@ export const buildWbsChangeRequests = ( identifiers: number[], organizationId: string, submitters: SeedCrActor[], - reviewers: SeedCrActor[] + reviewers: SeedCrActor[], + headOrAdminUserIds: Set ): Prisma.Change_RequestCreateInput[] => { if (identifiers.length === 0) return []; @@ -338,22 +347,28 @@ export const buildWbsChangeRequests = ( const types = identifiers.map(() => crTypeForParent(faker, isWorkPackage)); const outcomes = outcomesForOrderedCrs(faker, types); - return identifiers.map((identifier, index) => - buildChangeRequest({ + return identifiers.map((identifier, index) => { + const submitterId = pickActor(faker, submitters); + // A reviewer who isn't head/admin rank can't review their own change request, so the + // reviewer must differ from the submitter whenever the pools overlap. + const reviewerId = pickDifferentActor(faker, reviewers, [submitterId]); + + return buildChangeRequest({ faker, identifier, organizationId, type: types[index], parent, link: { kind: 'wbs', wbsElementId: parent.wbsElementId }, - submitterId: pickActor(faker, submitters), - reviewerId: pickActor(faker, reviewers), + submitterId, + reviewerId, + reviewerIsHeadOrAdmin: headOrAdminUserIds.has(reviewerId), ownerCandidates: reviewers, dateSubmitted: dates[index], outcome: outcomes[index], reviewWindowEnd: window.end - }) - ); + }); + }); }; export const buildAccountCodeChangeRequests = ( @@ -363,7 +378,8 @@ export const buildAccountCodeChangeRequests = ( identifiers: number[], organizationId: string, submitters: SeedCrActor[], - reviewers: SeedCrActor[] + reviewers: SeedCrActor[], + headOrAdminUserIds: Set ): Prisma.Change_RequestCreateInput[] => { if (identifiers.length === 0) return []; @@ -372,18 +388,22 @@ export const buildAccountCodeChangeRequests = ( const types = identifiers.map(() => CR_Type.BUDGET); const outcomes = outcomesForOrderedCrs(faker, types); - return identifiers.map((identifier, index) => - buildChangeRequest({ + return identifiers.map((identifier, index) => { + const submitterId = pickActor(faker, submitters); + const reviewerId = pickDifferentActor(faker, reviewers, [submitterId]); + + return buildChangeRequest({ faker, identifier, organizationId, type: CR_Type.BUDGET, link: { kind: 'accountCode', accountCodeId: accountCode.accountCodeId }, - submitterId: pickActor(faker, submitters), - reviewerId: pickActor(faker, reviewers), + submitterId, + reviewerId, + reviewerIsHeadOrAdmin: headOrAdminUserIds.has(reviewerId), dateSubmitted: dates[index], outcome: outcomes[index], reviewWindowEnd: window.end - }) - ); + }); + }); }; diff --git a/src/backend/src/prisma/factories/description-bullet.factory.ts b/src/backend/src/prisma/factories/description-bullet.factory.ts index be79d927fa..7c986ae158 100644 --- a/src/backend/src/prisma/factories/description-bullet.factory.ts +++ b/src/backend/src/prisma/factories/description-bullet.factory.ts @@ -1,5 +1,8 @@ import { Faker } from '@faker-js/faker'; import { Prisma } from '@prisma/client'; +import { addDaysToDate } from 'shared'; +import { DateRange } from '../context.js'; +import { clampDate } from '../dates.js'; const BULLET_VERBS = [ 'Design', @@ -49,12 +52,24 @@ export const generateDescriptionBulletText = (faker: Faker, wbsElementName: stri return suffix ? `${verb} ${wbsElementName} ${suffix}` : `${verb} ${wbsElementName}`; }; +/** + * Expected activities are defined when a WBS element is set up, so this lands near the start of + * its timeline rather than spread uniformly across it - never later than `now`, and never later + * than the WBS element's own timeline end. + */ +export const generateDescriptionBulletDateAdded = (faker: Faker, timeline: DateRange, now: Date): Date => { + const end = clampDate(addDaysToDate(timeline.start, faker.number.int({ min: 0, max: 14 })), timeline); + return end > now ? now : end; +}; + export const descriptionBulletCreateInput = ( detail: string, descriptionBulletTypeId: string, - wbsElementId: string + wbsElementId: string, + dateAdded: Date ): Prisma.Description_BulletCreateInput => ({ detail, + dateAdded, descriptionBulletType: { connect: { id: descriptionBulletTypeId } }, wbsElement: { connect: { wbsElementId } } }); diff --git a/src/backend/src/prisma/seed/change-request.process.ts b/src/backend/src/prisma/seed/change-request.process.ts index 369af61a92..b266042ac1 100644 --- a/src/backend/src/prisma/seed/change-request.process.ts +++ b/src/backend/src/prisma/seed/change-request.process.ts @@ -262,6 +262,7 @@ export class ChangeRequestProcess extends SeedProcess userId)); if (submitterPool.length === 0 || reviewerPool.length === 0) { throw new Error('ChangeRequestProcess requires submitters and reviewers.'); @@ -292,7 +293,8 @@ export class ChangeRequestProcess extends SeedProcess> { const [bulletType] = descriptionBulletTypes; + const now = new Date(); await Promise.all([ - ...projects.map(({ project }) => - this.createBulletsForWbsElement(project.wbsElement.wbsElementId, project.wbsElement.name, bulletType.id) + ...projects.map(({ project, timeline }) => + this.createBulletsForWbsElement(project.wbsElement.wbsElementId, project.wbsElement.name, bulletType.id, timeline, now) ), - ...workPackages.map(({ workPackage }) => - this.createBulletsForWbsElement(workPackage.wbsElement.wbsElementId, workPackage.wbsElement.name, bulletType.id) + ...workPackages.map(({ workPackage, timeline }) => + this.createBulletsForWbsElement( + workPackage.wbsElement.wbsElementId, + workPackage.wbsElement.name, + bulletType.id, + timeline, + now + ) ) ]); return {}; } - private async createBulletsForWbsElement(wbsElementId: string, name: string, bulletTypeId: string) { + private async createBulletsForWbsElement( + wbsElementId: string, + name: string, + bulletTypeId: string, + timeline: DateRange, + now: Date + ) { const count = generateDescriptionBulletCount(this.faker); const usedDetails = new Set(); @@ -42,8 +57,10 @@ export class DescriptionBulletProcess extends SeedProcess Date: Sat, 25 Jul 2026 12:32:58 -0400 Subject: [PATCH 11/11] prettier --- src/backend/src/prisma/seed/description-bullet.process.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/backend/src/prisma/seed/description-bullet.process.ts b/src/backend/src/prisma/seed/description-bullet.process.ts index 109aa8aa7e..f474552244 100644 --- a/src/backend/src/prisma/seed/description-bullet.process.ts +++ b/src/backend/src/prisma/seed/description-bullet.process.ts @@ -24,7 +24,13 @@ export class DescriptionBulletProcess extends SeedProcess - this.createBulletsForWbsElement(project.wbsElement.wbsElementId, project.wbsElement.name, bulletType.id, timeline, now) + this.createBulletsForWbsElement( + project.wbsElement.wbsElementId, + project.wbsElement.name, + bulletType.id, + timeline, + now + ) ), ...workPackages.map(({ workPackage, timeline }) => this.createBulletsForWbsElement(