From 7102b74fe4a8054dcc3ed30812bf0fdd50bb1e6d Mon Sep 17 00:00:00 2001 From: wavehassman Date: Tue, 21 Apr 2026 17:03:04 -0400 Subject: [PATCH 01/29] #4168 2 migrations + changes everywhere to get rid of unused functionality --- .../change-requests.controllers.ts | 29 +- .../change-requests.query-args.ts | 92 ++- .../proposed-solutions.query-args.ts | 11 - .../scope-change-requests.query-args.ts | 64 -- src/backend/src/prisma/manual.ts | 100 +-- .../migration.sql | 2 + .../migration.sql | 97 +++ src/backend/src/prisma/schema.prisma | 71 +- src/backend/src/prisma/seed.ts | 595 ++-------------- .../src/routes/change-requests.routes.ts | 31 +- .../src/services/change-requests.services.ts | 635 ++++-------------- .../src/services/work-packages.services.ts | 10 +- .../change-requests.transformer.ts | 77 +-- .../proposed-solutions.transformer.ts | 21 - .../src/utils/change-requests.utils.ts | 217 +----- .../test-data/change-requests.test-data.ts | 34 +- src/backend/tests/test-utils.ts | 3 - .../tests/unit/change-requests.test.ts | 409 +---------- .../src/hooks/change-requests.hooks.ts | 8 +- .../ChangeRequestDetailsView.tsx | 11 +- .../ProposedSolutionForm.tsx | 207 ------ .../ProposedSolutionSelectItem.tsx | 63 -- .../ProposedSolutionView.tsx | 101 --- .../ProposedSolutionsList.tsx | 119 ---- .../ReviewChangeRequestView.tsx | 159 +---- .../StandardDetails.tsx | 22 +- .../CreateChangeRequest.tsx | 71 +- .../CreateChangeRequestModal.tsx | 2 - .../CreateChangeRequestView.tsx | 188 +----- .../CreateProposedSolutionsList.tsx | 92 --- .../EditProjectBudgetModal.tsx | 7 +- .../GanttTimeLineChangeModal.tsx | 53 +- .../ProjectForm/ProjectCreateContainer.tsx | 45 +- .../ProjectForm/ProjectEditContainer.tsx | 46 +- .../WorkPackageForm/WorkPackageFormView.tsx | 43 +- .../ProposedSolutionForm.test.tsx | 76 --- .../ProposedSolutionView.test.tsx | 67 -- .../ProposedSolutionsList.test.tsx | 93 --- .../ReviewChangeRequest.test.tsx | 2 +- .../StandardDetails.test.tsx | 8 +- .../CreateProposedSolutionsList.test.tsx | 57 -- .../test-data/change-requests.stub.ts | 69 +- src/frontend/src/utils/enum-pipes.ts | 8 +- src/shared/src/types/change-request-types.ts | 51 +- 44 files changed, 515 insertions(+), 3651 deletions(-) delete mode 100644 src/backend/src/prisma-query-args/proposed-solutions.query-args.ts delete mode 100644 src/backend/src/prisma-query-args/scope-change-requests.query-args.ts create mode 100644 src/backend/src/prisma/migrations/20260420022045_descoping_change_requests/migration.sql create mode 100644 src/backend/src/prisma/migrations/20260420023918_descoping_change_requests_2/migration.sql delete mode 100644 src/backend/src/transformers/proposed-solutions.transformer.ts delete mode 100644 src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionForm.tsx delete mode 100644 src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionSelectItem.tsx delete mode 100644 src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionView.tsx delete mode 100644 src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionsList.tsx delete mode 100644 src/frontend/src/pages/CreateChangeRequestPage/CreateProposedSolutionsList.tsx delete mode 100644 src/frontend/src/tests/pages/ChangeRequestDetailPage/ProposedSolutionForm.test.tsx delete mode 100644 src/frontend/src/tests/pages/ChangeRequestDetailPage/ProposedSolutionView.test.tsx delete mode 100644 src/frontend/src/tests/pages/ChangeRequestDetailPage/ProposedSolutionsList.test.tsx delete mode 100644 src/frontend/src/tests/pages/CreateChangeRequestPage/CreateProposedSolutionsList.test.tsx diff --git a/src/backend/src/controllers/change-requests.controllers.ts b/src/backend/src/controllers/change-requests.controllers.ts index 0dd94267ba..d1271b3a16 100644 --- a/src/backend/src/controllers/change-requests.controllers.ts +++ b/src/backend/src/controllers/change-requests.controllers.ts @@ -83,14 +83,13 @@ export default class ChangeRequestsController { static async reviewChangeRequest(req: Request, res: Response, next: NextFunction) { try { - const { crId, reviewNotes, accepted, psId } = req.body; + const { crId, reviewNotes, accepted } = req.body; const id = await ChangeRequestsService.reviewChangeRequest( req.currentUser, crId, reviewNotes, accepted, - req.organization, - psId + req.organization ); res.status(200).json({ message: `Change request #${id} successfully reviewed.` }); } catch (error: unknown) { @@ -176,7 +175,7 @@ export default class ChangeRequestsController { static async createStandardChangeRequest(req: Request, res: Response, next: NextFunction) { try { - const { wbsNum, type, what, why, proposedSolutions, projectProposedChanges, workPackageProposedChanges } = req.body; + const { wbsNum, why, requestedReviewerId, projectProposedChanges, workPackageProposedChanges } = req.body; if (workPackageProposedChanges && workPackageProposedChanges.stage === 'NONE') { workPackageProposedChanges.stage = null; } @@ -186,11 +185,9 @@ export default class ChangeRequestsController { wbsNum.carNumber, wbsNum.projectNumber, wbsNum.workPackageNumber, - type, - what, why, - proposedSolutions, req.organization, + requestedReviewerId, projectProposedChanges, workPackageProposedChanges ); @@ -200,24 +197,6 @@ export default class ChangeRequestsController { } } - static async addProposedSolution(req: Request, res: Response, next: NextFunction) { - try { - const { crId, budgetImpact, description, timelineImpact, scopeImpact } = req.body; - const id = await ChangeRequestsService.addProposedSolution( - req.currentUser, - crId, - budgetImpact, - description, - timelineImpact, - scopeImpact, - req.organization - ); - res.status(200).json({ message: `Successfully added proposed solution with id #${id}` }); - } catch (error: unknown) { - next(error); - } - } - static async deleteChangeRequest(req: Request, res: Response, next: NextFunction) { try { const { crId } = req.params as Record; diff --git a/src/backend/src/prisma-query-args/change-requests.query-args.ts b/src/backend/src/prisma-query-args/change-requests.query-args.ts index ebe4c8be15..9fdf6435f5 100644 --- a/src/backend/src/prisma-query-args/change-requests.query-args.ts +++ b/src/backend/src/prisma-query-args/change-requests.query-args.ts @@ -1,17 +1,73 @@ import { Prisma } from '@prisma/client'; -import { getScopeChangeRequestQueryArgs } from './scope-change-requests.query-args.js'; import { getUserQueryArgs } from './user.query-args.js'; import { getWorkPackageQueryArgs } from './work-packages.query-args.js'; import { getReimbursementProductOtherReasonQueryArgs } from './reimbursement-product-other-reason.query-args.js'; import { getAccountCodeQueryArgs } from './account-code.query-args.js'; +import { getTeamQueryArgs } from './teams.query-args.js'; export type ChangeRequestQueryArgs = ReturnType; - export type ChangeRequestWithProjectAndWorkPackageQueryArgs = ReturnType< typeof getChangeRequestWithProjectAndWorkPackageQueryArgs >; - +export type WbsProposedChangeQueryArgs = ReturnType; export type ChangeRequestManyQueryArgs = ReturnType; +export type WorkPackageProposedChangesQueryArgs = ReturnType; +export type ProjectProposedChangesQueryArgs = ReturnType; + +const getProjectProposedChangesQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + teams: getTeamQueryArgs(organizationId), + car: { include: { wbsElement: true } }, + workPackageProposedChanges: getWorkPackageProposedChangesQueryArgs(organizationId) + } + }); + +const getWorkPackageProposedChangesQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + wbsProposedChanges: { + include: { + links: { where: { dateDeleted: null }, include: { linkType: true } }, + proposedDescriptionBulletChanges: { + where: { dateDeleted: null }, + include: { + descriptionBulletType: true, + userChecked: getUserQueryArgs(organizationId) + } + }, + lead: getUserQueryArgs(organizationId), + manager: getUserQueryArgs(organizationId) + } + }, + blockedBy: true + } + }); + +const getWbsProposedChangesQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + links: { where: { dateDeleted: null }, include: { linkType: true } }, + proposedDescriptionBulletChanges: { + where: { dateDeleted: null }, + include: { descriptionBulletType: true, userChecked: getUserQueryArgs(organizationId) } + }, + lead: getUserQueryArgs(organizationId), + manager: getUserQueryArgs(organizationId), + projectProposedChanges: { + include: { + teams: getTeamQueryArgs(organizationId), + car: { + include: { + wbsElement: true + } + }, + workPackageProposedChanges: getWorkPackageProposedChangesQueryArgs(organizationId) + } + }, + workPackageProposedChanges: getWorkPackageProposedChangesQueryArgs(organizationId) + } + }); export const getChangeRequestQueryArgs = (organizationId: string) => Prisma.validator()({ @@ -22,17 +78,13 @@ export const getChangeRequestQueryArgs = (organizationId: string) => accountCode: getAccountCodeQueryArgs(organizationId), reviewer: getUserQueryArgs(organizationId), changes: { - where: { - wbsElement: { - dateDeleted: null - } - }, + where: { wbsElement: { dateDeleted: null } }, include: { implementer: getUserQueryArgs(organizationId), wbsElement: true } }, - scopeChangeRequest: getScopeChangeRequestQueryArgs(organizationId), + wbsProposedChanges: getWbsProposedChangesQueryArgs(organizationId), stageGateChangeRequest: true, activationChangeRequest: { include: { lead: getUserQueryArgs(organizationId), manager: getUserQueryArgs(organizationId) } @@ -91,9 +143,7 @@ export const getGuestChangeRequestQueryArgs = (organizationId: string) => project: { select: { wbsElement: { select: { name: true } }, - teams: { - select: { teamType: { select: { name: true } } } - } + teams: { select: { teamType: { select: { name: true } } } } } }, workPackage: { @@ -101,9 +151,7 @@ export const getGuestChangeRequestQueryArgs = (organizationId: string) => project: { select: { wbsElement: { select: { name: true } }, - teams: { - select: { teamType: { select: { name: true } } } - } + teams: { select: { teamType: { select: { name: true } } } } } } } @@ -120,11 +168,7 @@ export const getChangeRequestWithProjectAndWorkPackageQueryArgs = (organizationI wbsElement: { include: { workPackage: getWorkPackageQueryArgs(organizationId), - project: { - include: { - teams: true - } - }, + project: { include: { teams: true } }, descriptionBullets: { where: { dateDeleted: null } }, links: { where: { dateDeleted: null } } } @@ -133,11 +177,7 @@ export const getChangeRequestWithProjectAndWorkPackageQueryArgs = (organizationI accountCode: getAccountCodeQueryArgs(organizationId), reviewer: getUserQueryArgs(organizationId), changes: { - where: { - wbsElement: { - dateDeleted: null - } - }, + where: { wbsElement: { dateDeleted: null } }, include: { implementer: getUserQueryArgs(organizationId), wbsElement: true, @@ -145,7 +185,7 @@ export const getChangeRequestWithProjectAndWorkPackageQueryArgs = (organizationI accountCode: getAccountCodeQueryArgs(organizationId) } }, - scopeChangeRequest: getScopeChangeRequestQueryArgs(organizationId), + wbsProposedChanges: getWbsProposedChangesQueryArgs(organizationId), stageGateChangeRequest: true, activationChangeRequest: { include: { lead: getUserQueryArgs(organizationId), manager: getUserQueryArgs(organizationId) } diff --git a/src/backend/src/prisma-query-args/proposed-solutions.query-args.ts b/src/backend/src/prisma-query-args/proposed-solutions.query-args.ts deleted file mode 100644 index 3b3435e542..0000000000 --- a/src/backend/src/prisma-query-args/proposed-solutions.query-args.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Prisma } from '@prisma/client'; -import { getUserQueryArgs } from './user.query-args.js'; - -export type ProposedSolutionQueryArgs = ReturnType; - -export const getProposedSolutionQueryArgs = (organizationId: string) => - Prisma.validator()({ - include: { - createdBy: getUserQueryArgs(organizationId) - } - }); diff --git a/src/backend/src/prisma-query-args/scope-change-requests.query-args.ts b/src/backend/src/prisma-query-args/scope-change-requests.query-args.ts deleted file mode 100644 index b7766b83d0..0000000000 --- a/src/backend/src/prisma-query-args/scope-change-requests.query-args.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Prisma } from '@prisma/client'; -import { getUserQueryArgs } from './user.query-args.js'; -import { getLinkQueryArgs } from './links.query-args.js'; -import { getDescriptionBulletQueryArgs } from './description-bullets.query-args.js'; -import { getProposedSolutionQueryArgs } from './proposed-solutions.query-args.js'; -import { getTeamQueryArgs } from './teams.query-args.js'; - -export type ProjectProposedChangesQueryArgs = ReturnType; - -export type WorkPackageProposedChangesQueryArgs = ReturnType; - -export type WbsProposedChangeQueryArgs = ReturnType; - -export type ScopeChangeRequestQueryArgs = ReturnType; - -const getProjectProposedChangesQueryArgs = (organizationId: string) => - Prisma.validator()({ - include: { - teams: getTeamQueryArgs(organizationId), - workPackageProposedChanges: getWorkPackageProposedChangesQueryArgs(organizationId), - car: { - include: { - wbsElement: true - } - } - } - }); - -export const getWorkPackageProposedChangesQueryArgs = (organizationId: string) => - Prisma.validator()({ - include: { - blockedBy: true, - wbsProposedChanges: { - include: { - links: getLinkQueryArgs(), - lead: getUserQueryArgs(organizationId), - manager: getUserQueryArgs(organizationId), - proposedDescriptionBulletChanges: getDescriptionBulletQueryArgs(organizationId) - } - } - } - }); - -export const getWbsProposedChangeQueryArgs = (organizationId: string) => - Prisma.validator()({ - include: { - projectProposedChanges: getProjectProposedChangesQueryArgs(organizationId), - workPackageProposedChanges: getWorkPackageProposedChangesQueryArgs(organizationId), - links: getLinkQueryArgs(), - lead: getUserQueryArgs(organizationId), - manager: getUserQueryArgs(organizationId), - proposedDescriptionBulletChanges: getDescriptionBulletQueryArgs(organizationId) - } - }); - -export const getScopeChangeRequestQueryArgs = (organizationId: string) => - Prisma.validator()({ - include: { - why: true, - proposedSolutions: getProposedSolutionQueryArgs(organizationId), - wbsProposedChanges: getWbsProposedChangeQueryArgs(organizationId), - wbsOriginalData: getWbsProposedChangeQueryArgs(organizationId) - } - }); diff --git a/src/backend/src/prisma/manual.ts b/src/backend/src/prisma/manual.ts index 7f37579661..db6e6ca8f2 100644 --- a/src/backend/src/prisma/manual.ts +++ b/src/backend/src/prisma/manual.ts @@ -19,30 +19,6 @@ import { getUserFullName } from '../utils/users.utils.js'; /** Execute all given prisma database interaction scripts written in this function */ const executeScripts = async () => {}; -/** - * Print metrics on accepted Change Requests with timeline impact - */ -export const checkTimelineImpact = async () => { - const res = await prisma.change_Request.findMany({ - where: { - accepted: true, - scopeChangeRequest: { - timelineImpact: { - gt: 0 - } - } - }, - include: { - scopeChangeRequest: true - } - }); - const calc = res.reduce((acc, curr) => { - return acc + (curr.scopeChangeRequest?.timelineImpact || 0); - }, 0); - console.log('total accepted CRs w/ timeline impact:', res.length); - console.log('total accepted delays', calc, 'weeks'); -}; - /** * Print count of total work packages */ @@ -60,9 +36,10 @@ export const activeUserMetrics = async () => { }; /** - * Calculate, pull, and print various metrics per request from Anushka. + * Print basic change request metrics + * Note: timeline/budget impact metrics were removed in the CR descoping migration (see 20260420_descoping_change_requests) */ -export const pullNumbersForPM = async () => { +export const pullCRMetrics = async () => { const nums = await Promise.all([ '# of CRs', prisma.change_Request.count(), @@ -72,70 +49,22 @@ export const pullNumbersForPM = async () => { prisma.change_Request.count({ where: { accepted: false } }), '# of CRs open', prisma.change_Request.count({ where: { accepted: null } }), - '# w/ timeline impact > 0', - prisma.change_Request.count({ where: { scopeChangeRequest: { timelineImpact: { gt: 0 } } } }), - '# w/ timeline impact > 0 ISSUE', - prisma.change_Request.count({ - where: { scopeChangeRequest: { timelineImpact: { gt: 0 } }, type: 'ISSUE' } - }), - '# w/ timeline impact > 0 DEFINITION_CHANGE', - prisma.change_Request.count({ - where: { scopeChangeRequest: { timelineImpact: { gt: 0 } }, type: 'DEFINITION_CHANGE' } - }), - '# w/ timeline impact > 0 OTHER', - prisma.change_Request.count({ - where: { scopeChangeRequest: { timelineImpact: { gt: 0 } }, type: 'OTHER' } - }), - 'w/ timeline impact > 0 AVG TIMELINE IMPACT', - prisma.scope_CR.aggregate({ - _avg: { timelineImpact: true }, - where: { timelineImpact: { gt: 0 } } - }), - 'timeline impact = 0', - prisma.scope_CR.count({ where: { timelineImpact: { equals: 0 } } }), - 'timeline impact >0 and <=2', - prisma.scope_CR.count({ where: { timelineImpact: { gt: 0, lte: 2 } } }), - 'timeline impact >2 and <=4', - prisma.scope_CR.count({ where: { timelineImpact: { gt: 2, lte: 4 } } }), - 'timeline impact >4 and <=8', - prisma.scope_CR.count({ where: { timelineImpact: { gt: 4, lte: 8 } } }), - 'timeline impact >8 and <=16', - prisma.scope_CR.count({ where: { timelineImpact: { gt: 8, lte: 16 } } }), - 'timeline impact >8', - prisma.scope_CR.count({ where: { timelineImpact: { gt: 16 } } }) + '# of STANDARD CRs', + prisma.change_Request.count({ where: { type: 'STANDARD' } }), + '# of ACTIVATION CRs', + prisma.change_Request.count({ where: { type: 'ACTIVATION' } }), + '# of STAGE_GATE CRs', + prisma.change_Request.count({ where: { type: 'STAGE_GATE' } }), + '# of BUDGET CRs', + prisma.change_Request.count({ where: { type: 'BUDGET' } }), + '# of LEADERSHIP CRs', + prisma.change_Request.count({ where: { type: 'LEADERSHIP' } }) ]); for (let idx = 0; idx < nums.length; idx += 2) { console.log(nums[idx], nums[idx + 1]); } }; -/** - * migrate all Change Requests to use Proposed Solutions - */ -export const migrateToProposedSolutions = async () => { - const crs = await prisma.scope_CR.findMany({ include: { changeRequest: true } }); - crs.forEach(async (cr) => { - const alreadyHasSolution = await prisma.proposed_Solution.findFirst({ - where: { scopeChangeRequestId: cr.scopeCrId } - }); - - if (!alreadyHasSolution) { - await prisma.proposed_Solution.create({ - data: { - description: '', - timelineImpact: cr.timelineImpact, - scopeImpact: cr.scopeImpact, - budgetImpact: cr.budgetImpact, - scopeChangeRequestId: cr.scopeCrId, - createdByUserId: cr.changeRequest.submitterId, - dateCreated: cr.changeRequest.dateSubmitted, - approved: cr.changeRequest.accepted ?? false - } - }); - } - }); -}; - /** * Migrate all complete wps to have checked description bullets */ @@ -178,7 +107,6 @@ const downloadReimbursementRequests = async () => { const csv = await Promise.all(promises); - // if file doesnt exist create it writeFileSync('./reimbursements.csv', csv.join('\n'), 'utf-8'); }; @@ -207,6 +135,7 @@ const getTotalAmountOwedForCashAndBudgetForSubmittedToSaboAndPendingFinanceTeam } return 0; }, 0); + const totalAmountOwedForBudgetSabo = submittedToSabo.reduce((acc, curr) => { if (curr.indexCode.name === 'Budget') { return acc + curr.totalCost / 100; @@ -231,6 +160,7 @@ const getTotalAmountOwedForCashAndBudgetForSubmittedToSaboAndPendingFinanceTeam console.log('Total amount owed for cash submitted to SABO:', totalAmountOwedForCashSabo); console.log('Total amount owed for budget submitted to SABO:', totalAmountOwedForBudgetSabo); console.log('Total amount owed for cash pending finance team:', totalAmountOwedForCashFinance); + console.log('Total amount owed for budget pending finance team:', totalAmountOwedForBudgetFinance); }; executeScripts() diff --git a/src/backend/src/prisma/migrations/20260420022045_descoping_change_requests/migration.sql b/src/backend/src/prisma/migrations/20260420022045_descoping_change_requests/migration.sql new file mode 100644 index 0000000000..bfabb2ca6d --- /dev/null +++ b/src/backend/src/prisma/migrations/20260420022045_descoping_change_requests/migration.sql @@ -0,0 +1,2 @@ + +ALTER TYPE "CR_Type" ADD VALUE 'STANDARD'; \ No newline at end of file diff --git a/src/backend/src/prisma/migrations/20260420023918_descoping_change_requests_2/migration.sql b/src/backend/src/prisma/migrations/20260420023918_descoping_change_requests_2/migration.sql new file mode 100644 index 0000000000..7ab41f7d77 --- /dev/null +++ b/src/backend/src/prisma/migrations/20260420023918_descoping_change_requests_2/migration.sql @@ -0,0 +1,97 @@ +/* + Warnings: + + - The values [ISSUE,DEFINITION_CHANGE,OTHER] on the enum `CR_Type` will be removed. If these variants are still used in the database, this will fail. + - You are about to drop the column `scopeChangeRequestAsOriginalDataId` on the `Wbs_Proposed_Changes` table. All the data in the column will be lost. + - You are about to drop the column `scopeChangeRequestId` on the `Wbs_Proposed_Changes` table. All the data in the column will be lost. + - You are about to drop the `Proposed_Solution` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `Scope_CR` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `Scope_CR_Why` table. If the table is not empty, all the data it contains will be lost. + - A unique constraint covering the columns `[wbsProposedChangesId]` on the table `Change_Request` will be added. If there are existing duplicate values, this will fail. + +*/ + +-- Step 1: Add why column so the backfill can write to it +ALTER TABLE "Change_Request" ADD COLUMN "why" TEXT; + +-- Step 2: Backfill +UPDATE "Change_Request" cr +SET + why = CONCAT( + 'Type: ', cr.type::text, + CASE WHEN sc.what IS NOT NULL THEN CONCAT(' | What: ', sc.what) ELSE '' END, + CASE WHEN why_agg.why_text IS NOT NULL THEN CONCAT(' | Why: ', why_agg.why_text) ELSE '' END + ), + type = 'STANDARD'::"CR_Type" +FROM "Scope_CR" sc +LEFT JOIN ( + SELECT + "scopeCrId", + STRING_AGG(CONCAT(type::text, ': ', explain), ', ') as why_text + FROM "Scope_CR_Why" + GROUP BY "scopeCrId" +) why_agg ON why_agg."scopeCrId" = sc."scopeCrId" +WHERE sc."changeRequestId" = cr."crId" +AND cr.type IN ('ISSUE', 'DEFINITION_CHANGE', 'OTHER'); + +-- Step 3: Replace enum +CREATE TYPE "CR_Type_new" AS ENUM ('LEADERSHIP', 'STAGE_GATE', 'ACTIVATION', 'BUDGET', 'STANDARD'); +ALTER TABLE "Change_Request" ALTER COLUMN "type" TYPE "CR_Type_new" USING ("type"::text::"CR_Type_new"); +ALTER TYPE "CR_Type" RENAME TO "CR_Type_old"; +ALTER TYPE "CR_Type_new" RENAME TO "CR_Type"; +DROP TYPE "CR_Type_old"; + +-- Step 4: Add wbsProposedChangesId (why already added above, remove it from here) +ALTER TABLE "Change_Request" ADD COLUMN "wbsProposedChangesId" TEXT; + +-- DropForeignKey +ALTER TABLE "Proposed_Solution" DROP CONSTRAINT "Proposed_Solution_createdByUserId_fkey"; + +-- DropForeignKey +ALTER TABLE "Proposed_Solution" DROP CONSTRAINT "Proposed_Solution_scopeChangeRequestId_fkey"; + +-- DropForeignKey +ALTER TABLE "Scope_CR" DROP CONSTRAINT "Scope_CR_changeRequestId_fkey"; + +-- DropForeignKey +ALTER TABLE "Scope_CR_Why" DROP CONSTRAINT "Scope_CR_Why_scopeCrId_fkey"; + +-- DropForeignKey +ALTER TABLE "Wbs_Proposed_Changes" DROP CONSTRAINT "Wbs_Proposed_Changes_scopeChangeRequestAsOriginalDataId_fkey"; + +-- DropForeignKey +ALTER TABLE "Wbs_Proposed_Changes" DROP CONSTRAINT "Wbs_Proposed_Changes_scopeChangeRequestId_fkey"; + +-- DropIndex +DROP INDEX "Wbs_Proposed_Changes_scopeChangeRequestAsOriginalDataId_idx"; + +-- DropIndex +DROP INDEX "Wbs_Proposed_Changes_scopeChangeRequestAsOriginalDataId_key"; + +-- DropIndex +DROP INDEX "Wbs_Proposed_Changes_scopeChangeRequestId_idx"; + +-- DropIndex +DROP INDEX "Wbs_Proposed_Changes_scopeChangeRequestId_key"; + +-- AlterTable +ALTER TABLE "Wbs_Proposed_Changes" DROP COLUMN "scopeChangeRequestAsOriginalDataId", +DROP COLUMN "scopeChangeRequestId"; + +-- DropTable +DROP TABLE "Proposed_Solution"; + +-- DropTable +DROP TABLE "Scope_CR"; + +-- DropTable +DROP TABLE "Scope_CR_Why"; + +-- DropEnum +DROP TYPE "Scope_CR_Why_Type"; + +-- CreateIndex +CREATE UNIQUE INDEX "Change_Request_wbsProposedChangesId_key" ON "Change_Request"("wbsProposedChangesId"); + +-- AddForeignKey +ALTER TABLE "Change_Request" ADD CONSTRAINT "Change_Request_wbsProposedChangesId_fkey" FOREIGN KEY ("wbsProposedChangesId") REFERENCES "Wbs_Proposed_Changes"("wbsProposedChangesId") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 9a5a1b569f..71a9c9a486 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -13,13 +13,11 @@ generator client { } enum CR_Type { - ISSUE - DEFINITION_CHANGE LEADERSHIP - OTHER STAGE_GATE ACTIVATION BUDGET + STANDARD } enum Task_Priority { @@ -54,19 +52,6 @@ enum Theme { DARK } -enum Scope_CR_Why_Type { - ESTIMATION - SCHOOL - DESIGN - MANUFACTURING - RULES - INITIALIZATION - COMPETITION - MAINTENANCE - OTHER_PROJECT - OTHER -} - enum Work_Package_Stage { RESEARCH DESIGN @@ -208,7 +193,6 @@ model User { teamsAsLead Team[] @relation(name: "teamsAsLead") deletedWBSElements WBS_Element[] @relation(name: "deletedWbsElements") checkedDescriptionBullets Description_Bullet[] @relation(name: "checkDescriptionBullets") - createdProposedSolutions Proposed_Solution[] createdTasks Task[] @relation(name: "createdBy") deletedTasks Task[] @relation(name: "deletedBy") assignedTasks Task[] @relation(name: "assignedTo") @@ -382,6 +366,7 @@ model Change_Request { accountCodeId String? accountCode Account_Code? @relation(fields: [accountCodeId], references: [accountCodeId]) type CR_Type + why String? reviewerId String? reviewer User? @relation(name: "reviewedChangeRequests", fields: [reviewerId], references: [userId]) requestedReviewers User[] @relation(name: "requestedChangeRequestReviewers") @@ -391,12 +376,13 @@ model Change_Request { accepted Boolean? reviewNotes String? changes Change[] - scopeChangeRequest Scope_CR? stageGateChangeRequest Stage_Gate_CR? activationChangeRequest Activation_CR? budgetChangeRequest Budget_CR? leadershipChangeRequest Leadership_CR? notificationSlackThreads Message_Info[] + wbsProposedChangesId String? @unique + wbsProposedChanges Wbs_Proposed_Changes? @relation("wbsProposedChanges", fields: [wbsProposedChangesId], references: [wbsProposedChangesId]) @@unique([identifier, organizationId], name: "uniqueChangeRequest") @@index([reviewerId]) @@ -420,46 +406,6 @@ model Message_Info { @@index([changeRequestId]) } -model Scope_CR { - scopeCrId String @id @default(uuid()) - changeRequestId String @unique - changeRequest Change_Request @relation(fields: [changeRequestId], references: [crId]) - what String - why Scope_CR_Why[] - scopeImpact String - timelineImpact Int - budgetImpact Int - proposedSolutions Proposed_Solution[] - wbsProposedChanges Wbs_Proposed_Changes? @relation(name: "wbsProposedChanges") - wbsOriginalData Wbs_Proposed_Changes? @relation(name: "wbsOriginalData") -} - -model Proposed_Solution { - proposedSolutionId String @id @default(uuid()) - description String - timelineImpact Int - budgetImpact Int - scopeImpact String - scopeChangeRequest Scope_CR @relation(fields: [scopeChangeRequestId], references: [scopeCrId]) - scopeChangeRequestId String - createdByUserId String - createdBy User @relation(fields: [createdByUserId], references: [userId]) - dateCreated DateTime @default(now()) - approved Boolean @default(false) - - @@index([scopeChangeRequestId]) -} - -model Scope_CR_Why { - scopeCrWhyId String @id @default(uuid()) - scopeCrId String - scopeCr Scope_CR @relation(fields: [scopeCrId], references: [scopeCrId]) - type Scope_CR_Why_Type - explain String - - @@index([scopeCrId]) -} - model Stage_Gate_CR { stageGateCrId String @id @default(uuid()) changeRequestId String @unique @@ -1286,14 +1232,7 @@ model Wbs_Proposed_Changes { proposedDescriptionBulletChanges Description_Bullet[] @relation(name: "proposedDescriptionBulletChanges") dateDeleted DateTime? - // A Wbs Proposed Change can either be original data or a proposed change, not a combination and not none - scopeChangeRequestAsOriginalData Scope_CR? @relation(name: "wbsOriginalData", fields: [scopeChangeRequestAsOriginalDataId], references: [scopeCrId]) - scopeChangeRequestAsOriginalDataId String? @unique - scopeChangeRequest Scope_CR? @relation(name: "wbsProposedChanges", fields: [scopeChangeRequestId], references: [scopeCrId]) - scopeChangeRequestId String? @unique - - @@index([scopeChangeRequestAsOriginalDataId]) - @@index([scopeChangeRequestId]) + changeRequest Change_Request? @relation("wbsProposedChanges") } model Project_Proposed_Changes { diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 4ddc2c0c1e..4901521a93 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -11,7 +11,6 @@ import { Graph_Type, Measure, PrismaClient, - Scope_CR_Why_Type, Task_Priority, Task_Status, Team, @@ -359,44 +358,16 @@ const performSeed: () => Promise = async () => { } }); - /** - * Make an initial change request for NER-25 using the wbs of the genesis project - */ const changeRequest1: StandardChangeRequest = await ChangeRequestsService.createStandardChangeRequest( cyborg, car25.wbsElement.carNumber, fergus.wbsElement.projectNumber, fergus.wbsElement.workPackageNumber, - CR_Type.OTHER, - 'Initial Change Request', - [ - { - type: Scope_CR_Why_Type.INITIALIZATION, - explain: 'need this to initialize all the seed data' - } - ], - [ - { - description: 'Initialize seed data', - scopeImpact: 'no scope impact', - timelineImpact: 0, - budgetImpact: 0 - } - ], - ner, - null, - null + 'need this to initialize all the seed data', + ner ); - // approve the change request - await ChangeRequestsService.reviewChangeRequest( - batman, - changeRequest1.crId, - 'LGTM', - true, - ner, - changeRequest1.proposedSolutions[0].id - ); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequest1.crId, 'LGTM', true, ner); /** Set the organization ID in the current process environment and update .env */ process.env.DEV_ORGANIZATION_ID = organizationId; @@ -1135,417 +1106,112 @@ const performSeed: () => Promise = async () => { projectHuskies1WbsNumber.carNumber, projectHuskies1WbsNumber.projectNumber, projectHuskies1WbsNumber.workPackageNumber, - CR_Type.OTHER, - 'Initial Change Request', - [ - { - type: Scope_CR_Why_Type.INITIALIZATION, - explain: 'need this to initialize work packages' - } - ], - [ - { - budgetImpact: 0, - description: 'Initializing seed data', - timelineImpact: 0, - scopeImpact: 'no scope impact' - } - ], - ner, - null, - null - ); - - const changeRequestProjectHuskies1Id = changeRequestHuskiesProject1.crId; - - // make a proposed solution for it - const proposedSolution2 = await ChangeRequestsService.addProposedSolution( - cyborg, - changeRequestProjectHuskies1Id, - 0, 'Initializing seed data', - 0, - 'no scope impact', ner ); - const proposedSolution2Id = proposedSolution2.id; + const changeRequestProjectHuskies1Id = changeRequestHuskiesProject1.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest( - batman, - changeRequestProjectHuskies1Id, - 'LGTM', - true, - ner, - proposedSolution2Id - ); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectHuskies1Id, 'LGTM', true, ner); const changeRequestProjectSlackbot1 = await ChangeRequestsService.createStandardChangeRequest( cyborg, projectSlackbot1WbsNumber.carNumber, projectSlackbot1WbsNumber.projectNumber, projectSlackbot1WbsNumber.workPackageNumber, - CR_Type.OTHER, 'Initial Change Request', - [ - { - type: Scope_CR_Why_Type.INITIALIZATION, - explain: 'need this to initialize work packages' - } - ], - [ - { - budgetImpact: 0, - description: 'Initializing seed data', - timelineImpact: 0, - scopeImpact: 'no scope impact' - } - ], - ner, - null, - null + ner ); const changeRequestProjectSlackbot1Id = changeRequestProjectSlackbot1.crId; - // make a proposed solution for it - const proposedSolution3 = await ChangeRequestsService.addProposedSolution( - cyborg, - changeRequestProjectSlackbot1Id, - 0, - 'Initializing seed data', - 0, - 'no scope impact', - ner - ); - - const proposedSolution3Id = proposedSolution3.id; // approve the change request - await ChangeRequestsService.reviewChangeRequest( - batman, - changeRequestProjectSlackbot1Id, - 'LGTM', - true, - ner, - proposedSolution3Id - ); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectSlackbot1Id, 'LGTM', true, ner); const changeRequestProjectAvatar1 = await ChangeRequestsService.createStandardChangeRequest( cyborg, projectAvatar1WbsNumber.carNumber, projectAvatar1WbsNumber.projectNumber, projectAvatar1WbsNumber.workPackageNumber, - CR_Type.OTHER, 'Initial Change Request', - [ - { - type: Scope_CR_Why_Type.INITIALIZATION, - explain: 'need this to initialize work packages' - } - ], - [ - { - budgetImpact: 0, - description: 'Initializing seed data', - timelineImpact: 0, - scopeImpact: 'no scope impact' - } - ], - ner, - null, - null - ); - - const changeRequestProjectAvatar1Id = changeRequestProjectAvatar1.crId; - - // make a proposed solution for it - const proposedSolutionAvatar1 = await ChangeRequestsService.addProposedSolution( - cyborg, - changeRequestProjectAvatar1Id, - 0, - 'Initializing seed data', - 0, - 'no scope impact', ner ); - const proposedSolutionAvatar1Id = proposedSolutionAvatar1.id; - - // approve the change request - // await ChangeRequestsService.reviewChangeRequest( - // batman, - // changeRequestProjectAvatar1Id, - // 'LGTM', - // true, - // ner, - // proposedSolutionAvatar1Id - // ); + const changeRequestProjectAvatar1Id = changeRequestProjectAvatar1.crId; const changeRequestProjectJustice1 = await ChangeRequestsService.createStandardChangeRequest( cyborg, projectJustice1WbsNumber.carNumber, projectJustice1WbsNumber.projectNumber, projectJustice1WbsNumber.workPackageNumber, - CR_Type.OTHER, 'Initial Change Request', - [ - { - type: Scope_CR_Why_Type.INITIALIZATION, - explain: 'need this to initialize work packages' - } - ], - [ - { - budgetImpact: 0, - description: 'Initializing seed data', - timelineImpact: 0, - scopeImpact: 'no scope impact' - } - ], - ner, - null, - null - ); - - const changeRequestProjectJustice1Id = changeRequestProjectJustice1.crId; - - // make a proposed solution for it - const proposedSolutionJustice1 = await ChangeRequestsService.addProposedSolution( - cyborg, - changeRequestProjectJustice1Id, - 0, - 'Initializing seed data', - 0, - 'no scope impact', ner ); - const proposedSolution7Id = proposedSolutionJustice1.id; + const changeRequestProjectJustice1Id = changeRequestProjectJustice1.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest( - batman, - changeRequestProjectJustice1Id, - 'LGTM', - true, - ner, - proposedSolution7Id - ); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectJustice1Id, 'LGTM', true, ner); // approve the change request - await ChangeRequestsService.reviewChangeRequest( - batman, - changeRequestProjectAvatar1Id, - 'LGTM', - true, - ner, - proposedSolutionAvatar1Id - ); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectAvatar1Id, 'LGTM', true, ner); const changeRequestProjectJustice2 = await ChangeRequestsService.createStandardChangeRequest( cyborg, projectJustice2WbsNumber.carNumber, projectJustice2WbsNumber.projectNumber, projectJustice2WbsNumber.workPackageNumber, - CR_Type.OTHER, 'Initial Change Request', - [ - { - type: Scope_CR_Why_Type.INITIALIZATION, - explain: 'need this to initialize work packages' - } - ], - [ - { - budgetImpact: 0, - description: 'Initializing seed data', - timelineImpact: 0, - scopeImpact: 'no scope impact' - } - ], - ner, - null, - null - ); - - const changeRequestProjectJustice2Id = changeRequestProjectJustice2.crId; - - // make a proposed solution for it - const proposedSolutionJustice2 = await ChangeRequestsService.addProposedSolution( - cyborg, - changeRequestProjectJustice2Id, - 0, - 'Initializing seed data', - 0, - 'no scope impact', ner ); - const proposedSolutionJustice2Id = proposedSolutionJustice2.id; + const changeRequestProjectJustice2Id = changeRequestProjectJustice2.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest( - batman, - changeRequestProjectJustice2Id, - 'LGTM', - true, - ner, - proposedSolutionJustice2Id - ); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectJustice2Id, 'LGTM', true, ner); const changeRequestProjectRavens1 = await ChangeRequestsService.createStandardChangeRequest( cyborg, projectRavens1WbsNumber.carNumber, projectRavens1WbsNumber.projectNumber, projectRavens1WbsNumber.workPackageNumber, - CR_Type.OTHER, 'Initial Change Request', - [ - { - type: Scope_CR_Why_Type.INITIALIZATION, - explain: 'need this to initialize work packages' - } - ], - [ - { - budgetImpact: 0, - description: 'Initializing seed data', - timelineImpact: 0, - scopeImpact: 'no scope impact' - } - ], - ner, - null, - null - ); - - const changeRequestProjectRavens1Id = changeRequestProjectRavens1.crId; - - // make a proposed solution for it - const proposedSolution8 = await ChangeRequestsService.addProposedSolution( - cyborg, - changeRequestProjectRavens1Id, - 0, - 'Initializing seed data', - 0, - 'no scope impact', ner ); - const proposedSolution8Id = proposedSolution8.id; + const changeRequestProjectRavens1Id = changeRequestProjectRavens1.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest( - batman, - changeRequestProjectRavens1Id, - 'LGTM', - true, - ner, - proposedSolution8Id - ); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectRavens1Id, 'LGTM', true, ner); const changeRequestProjectSlackbot2 = await ChangeRequestsService.createStandardChangeRequest( cyborg, projectSlackbot2WbsNumber.carNumber, projectSlackbot2WbsNumber.projectNumber, projectSlackbot2WbsNumber.workPackageNumber, - CR_Type.OTHER, 'Initial Change Request', - [ - { - type: Scope_CR_Why_Type.INITIALIZATION, - explain: 'need this to initialize work packages' - } - ], - [ - { - budgetImpact: 0, - description: 'Initializing seed data', - timelineImpact: 0, - scopeImpact: 'no scope impact' - } - ], - ner, - null, - null - ); - - const changeRequestProjectSlackbot2Id = changeRequestProjectSlackbot2.crId; - - // make a proposed solution for it - const proposedSolutionSlackbot2 = await ChangeRequestsService.addProposedSolution( - cyborg, - changeRequestProjectSlackbot2Id, - 0, - 'Initializing seed data', - 0, - 'no scope impact', ner ); - const proposedSolutionSlackbot2Id = proposedSolutionSlackbot2.id; + const changeRequestProjectSlackbot2Id = changeRequestProjectSlackbot2.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest( - batman, - changeRequestProjectSlackbot2Id, - 'LGTM', - true, - ner, - proposedSolutionSlackbot2Id - ); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectSlackbot2Id, 'LGTM', true, ner); const changeRequestProjectKrusty1 = await ChangeRequestsService.createStandardChangeRequest( squidward, projectKrusty1WbsNumber.carNumber, projectKrusty1WbsNumber.projectNumber, projectKrusty1WbsNumber.workPackageNumber, - CR_Type.OTHER, 'Initial Change Request', - [ - { - type: Scope_CR_Why_Type.INITIALIZATION, - explain: 'need this to initialize work packages' - } - ], - [ - { - budgetImpact: 0, - description: 'Initializing seed data', - timelineImpact: 0, - scopeImpact: 'no scope impact' - } - ], - ner, - null, - null - ); - - const changeRequestProjectKrusty1Id = changeRequestProjectKrusty1.crId; - - // make a proposed solution for it - const proposedSolution10 = await ChangeRequestsService.addProposedSolution( - mrKrabs, - changeRequestProjectKrusty1Id, - 0, - 'Initializing seed data', - 0, - 'no scope impact', ner ); - const proposedSolution10Id = proposedSolution10.id; + const changeRequestProjectKrusty1Id = changeRequestProjectKrusty1.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest( - batman, - changeRequestProjectKrusty1Id, - 'LGTM', - true, - ner, - proposedSolution10Id - ); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectKrusty1Id, 'LGTM', true, ner); // Project 2 @@ -1554,51 +1220,14 @@ const performSeed: () => Promise = async () => { projectKrusty2WbsNumber.carNumber, projectKrusty2WbsNumber.projectNumber, projectKrusty2WbsNumber.workPackageNumber, - CR_Type.OTHER, 'Initial Change Request', - [ - { - type: Scope_CR_Why_Type.INITIALIZATION, - explain: 'need this to initialize work packages' - } - ], - [ - { - budgetImpact: 0, - description: 'Initializing seed data', - timelineImpact: 0, - scopeImpact: 'no scope impact' - } - ], - ner, - null, - null - ); - - const changeRequestProjectKrusty2Id = changeRequestProjectKrusty2.crId; - - // make a proposed solution for it - const proposedSolutionKrusty2 = await ChangeRequestsService.addProposedSolution( - mrKrabs, - changeRequestProjectKrusty2Id, - 0, - 'Initializing seed data', - 0, - 'no scope impact', ner ); - const proposedSolutionKrusty2Id = proposedSolutionKrusty2.id; + const changeRequestProjectKrusty2Id = changeRequestProjectKrusty2.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest( - batman, - changeRequestProjectKrusty2Id, - 'LGTM', - true, - ner, - proposedSolutionKrusty2Id - ); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectKrusty2Id, 'LGTM', true, ner); // Penguins // For Project 1 @@ -1607,51 +1236,14 @@ const performSeed: () => Promise = async () => { projectPenguin1WbsNumber.carNumber, projectPenguin1WbsNumber.projectNumber, projectPenguin1WbsNumber.workPackageNumber, - CR_Type.OTHER, 'Initial Change Request', - [ - { - type: Scope_CR_Why_Type.INITIALIZATION, - explain: 'need this to initialize work packages' - } - ], - [ - { - budgetImpact: 0, - description: 'Initializing seed data', - timelineImpact: 0, - scopeImpact: 'no scope impact' - } - ], - ner, - null, - null - ); - - const changeRequestProjectPenguin1Id = changeRequestProjectPenguin1.crId; - - // make a proposed solution for it - const proposedSolutionPenguin1 = await ChangeRequestsService.addProposedSolution( - skipper, - changeRequestProjectPenguin1Id, - 0, - 'Initializing seed data', - 0, - 'no scope impact', ner ); - const proposedSolutionPenguin1Id = proposedSolutionPenguin1.id; + const changeRequestProjectPenguin1Id = changeRequestProjectPenguin1.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest( - batman, - changeRequestProjectPenguin1Id, - 'LGTM', - true, - ner, - proposedSolutionPenguin1Id - ); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectPenguin1Id, 'LGTM', true, ner); // For Project 2 @@ -1660,51 +1252,14 @@ const performSeed: () => Promise = async () => { projectPenguin2WbsNumber.carNumber, projectPenguin2WbsNumber.projectNumber, projectPenguin2WbsNumber.workPackageNumber, - CR_Type.OTHER, 'Initial Change Request', - [ - { - type: Scope_CR_Why_Type.INITIALIZATION, - explain: 'need this to initialize work packages' - } - ], - [ - { - budgetImpact: 0, - description: 'Initializing seed data', - timelineImpact: 0, - scopeImpact: 'no scope impact' - } - ], - ner, - null, - null - ); - - const changeRequestProjectPenguin2Id = changeRequestProjectPenguin2.crId; - - // make a proposed solution for it - const proposedSolutionPenguin2 = await ChangeRequestsService.addProposedSolution( - skipper, - changeRequestProjectPenguin2Id, - 0, - 'Initializing seed data', - 0, - 'no scope impact', ner ); - const proposedSolutionPenguin2Id = proposedSolutionPenguin2.id; + const changeRequestProjectPenguin2Id = changeRequestProjectPenguin2.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest( - batman, - changeRequestProjectPenguin2Id, - 'LGTM', - true, - ner, - proposedSolutionPenguin2Id - ); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectPenguin2Id, 'LGTM', true, ner); /** * Work Packages @@ -1740,14 +1295,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest( - joeShmoe, - workPackage1ActivationCrId, - 'Looks good to me!', - true, - ner, - null - ); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackage1ActivationCrId, 'Looks good to me!', true, ner); // await DescriptionBulletsService.checkDescriptionBullet(thomasEmrax, workPackage1.description[0].descriptionId); @@ -1804,7 +1352,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageSlackbot1ActivationCrId, 'LGTM!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageSlackbot1ActivationCrId, 'LGTM!', true, ner); /** Work Package Slackbot 2 */ const { workPackageWbsNumber: workPackageSlackbot2WbsNumber, workPackage: workPackage4 } = await seedWorkPackage( @@ -1837,7 +1385,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageSlackbot2ActivationCrId, 'LGTM!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageSlackbot2ActivationCrId, 'LGTM!', true, ner); /** AVATAR TEAM */ /** Work Packages for Project 1 */ @@ -1878,8 +1426,7 @@ const performSeed: () => Promise = async () => { workPackageAvatarProject1ActivationCrId, 'Very cute LGTM!', true, - ner, - null + ner ); /** Work Package 2 */ @@ -1914,14 +1461,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest( - joeShmoe, - workPackageAvatarProject2ActivationCrId, - 'LGTM!', - true, - ner, - null - ); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageAvatarProject2ActivationCrId, 'LGTM!', true, ner); /** Work Package 3 */ const { workPackageWbsNumber: workPackageAvatarProject3WbsNumber, workPackage: workPackageAvatarProject3 } = @@ -1955,7 +1495,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageAvatarProject3ActivationCrId, 'LFG', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageAvatarProject3ActivationCrId, 'LFG', true, ner); /** Work Packages for Justice League */ /** Project 1 */ @@ -1990,7 +1530,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectJustice1WP1ActivationCrId, 'Approved!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectJustice1WP1ActivationCrId, 'Approved!', true, ner); /** Work Package 2 */ await seedWorkPackage( @@ -2060,7 +1600,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectJustice2WP1ActivationCrId, 'Approved!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectJustice2WP1ActivationCrId, 'Approved!', true, ner); /** Work Package 2 */ await seedWorkPackage( @@ -2112,7 +1652,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, project4WP1ActivationCrId, 'Approved!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, project4WP1ActivationCrId, 'Approved!', true, ner); /** Work Package 2 */ await seedWorkPackage( @@ -2182,7 +1722,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectKrusty1WP1ActivationCrId, 'Approved!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectKrusty1WP1ActivationCrId, 'Approved!', true, ner); /** Work Package 2 */ await seedWorkPackage( @@ -2252,7 +1792,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectKrusty2WP1ActivationCrId, 'Approved!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectKrusty2WP1ActivationCrId, 'Approved!', true, ner); /** Work Package 2 */ await seedWorkPackage( @@ -2629,7 +2169,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectPenguin1WP1ActivationCrId, 'Approved!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectPenguin1WP1ActivationCrId, 'Approved!', true, ner); /** Work Packages for Penguin Project 2*/ /** Work Package 1 */ @@ -2715,31 +2255,10 @@ const performSeed: () => Promise = async () => { projectHuskies2WbsNumber.carNumber, projectHuskies2WbsNumber.projectNumber, projectHuskies2WbsNumber.workPackageNumber, - CR_Type.DEFINITION_CHANGE, 'Change the bodywork to be hot pink', - [ - { type: Scope_CR_Why_Type.DESIGN, explain: 'It would be really pretty' }, - { type: Scope_CR_Why_Type.ESTIMATION, explain: 'I estimate that it would be really pretty' } - ], - [ - { - description: 'Buy hot pink paint', - scopeImpact: 'n/a', - timelineImpact: 1, - budgetImpact: 50 - }, - { - description: 'Buy slightly cheaper but lower quality hot pink paint', - scopeImpact: 'n/a', - timelineImpact: 1, - budgetImpact: 40 - } - ], - ner, - null, - null + ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, changeRequest2.crId, 'What the hell Thomas', false, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, changeRequest2.crId, 'What the hell Thomas', false, ner); await ChangeRequestsService.createActivationChangeRequest( thomasEmrax, @@ -3630,25 +3149,10 @@ const performSeed: () => Promise = async () => { projectHuskies2WbsNumber.carNumber, projectHuskies2WbsNumber.projectNumber, projectHuskies2WbsNumber.workPackageNumber, - CR_Type.OTHER, 'This is a wpchange test', - [{ type: Scope_CR_Why_Type.OTHER, explain: 'Creating work package' }], - [], - ner, - null, - { - name: 'new workpackage test', - leadId: batman.userId, - managerId: cyborg.userId, - duration: 5, - startDate: toDateString(new Date()), - stage: WorkPackageStage.Design, - blockedBy: [], - descriptionBullets: [], - links: [] - } + ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, newWorkPackageChangeRequest.crId, 'create wp', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, newWorkPackageChangeRequest.crId, 'create wp', true, ner); const { workPackageWbsNumber: workPackage9WbsNumber } = await seedWorkPackage( thomasEmrax, @@ -3672,23 +3176,8 @@ const performSeed: () => Promise = async () => { workPackage9WbsNumber.carNumber, workPackage9WbsNumber.projectNumber, workPackage9WbsNumber.workPackageNumber, - CR_Type.OTHER, 'This is editing a wp through CR', - [{ type: Scope_CR_Why_Type.OTHER, explain: 'editing a workpackage' }], - [], - ner, - null, - { - name: 'editing a work package test', - leadId: batman.userId, - managerId: cyborg.userId, - duration: 5, - startDate: toDateString(new Date()), - stage: WorkPackageStage.Design, - blockedBy: [], - descriptionBullets: [], - links: [] - } + ner ); await WbsElementTemplatesService.createWorkPackageTemplate( diff --git a/src/backend/src/routes/change-requests.routes.ts b/src/backend/src/routes/change-requests.routes.ts index 744f4f615c..5e947ad124 100644 --- a/src/backend/src/routes/change-requests.routes.ts +++ b/src/backend/src/routes/change-requests.routes.ts @@ -1,6 +1,6 @@ import express from 'express'; import { body } from 'express-validator'; -import { ChangeRequestReason, ChangeRequestType } from 'shared'; +import { ChangeRequestType } from 'shared'; import ChangeRequestsController from '../controllers/change-requests.controllers.js'; import { intMinZero, @@ -73,22 +73,13 @@ changeRequestsRouter.post( changeRequestsRouter.post( '/new/standard', - nonEmptyString(body('what')), intMinZero(body('wbsNum.carNumber')), intMinZero(body('wbsNum.projectNumber')), intMinZero(body('wbsNum.workPackageNumber')), - body('type').custom( - (value) => - value === ChangeRequestType.Other || value === ChangeRequestType.Issue || value === ChangeRequestType.Redefinition - ), - body('why').isArray(), - nonEmptyString(body('why.*.explain')), - body('why.*.type').custom((value) => Object.values(ChangeRequestReason).includes(value)), - body('proposedSolutions').isArray({ min: 0 }), - nonEmptyString(body('proposedSolutions.*.description')), - nonEmptyString(body('proposedSolutions.*.scopeImpact')), - body('proposedSolutions.*.timelineImpact').isInt(), - body('proposedSolutions.*.budgetImpact').isInt(), + nonEmptyString(body('submitterId')), + nonEmptyString(body('why')), + body('type').custom((value) => value === ChangeRequestType.Standard), + nonEmptyString(body('requestedReviewerId')).optional(), ...projectProposedChangesValidators, ...workPackageProposedChangesValidators('workPackageProposedChanges'), validateInputs, @@ -97,18 +88,6 @@ changeRequestsRouter.post( changeRequestsRouter.delete('/:crId/delete', ChangeRequestsController.deleteChangeRequest); -changeRequestsRouter.post( - '/new/proposed-solution', - nonEmptyString(body('submitterId')), - nonEmptyString(body('crId')), - nonEmptyString(body('description')), - nonEmptyString(body('scopeImpact')), - body('timelineImpact').isInt(), - body('budgetImpact').isInt(), - validateInputs, - ChangeRequestsController.addProposedSolution -); - changeRequestsRouter.post( '/:crId/request-review', body('userIds').isArray(), diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 8afab03ff5..80f9e500ad 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -8,8 +8,6 @@ import { isNotLeadership, isProjectWbs, ProjectProposedChangesCreateArgs, - ProposedSolution, - ProposedSolutionCreateArgs, StageGateChangeRequest, StandardChangeRequest, WbsNumber, @@ -38,13 +36,12 @@ import { applyProjectProposedChanges, applyWorkPackageProposedChanges, validateNoUnreviewedOpenCRs, - reviewProposedSolution, sendCRSubmitterReviewedNotification, validateWbsElement, validateNoUnreviewedOpenOtherReasonCRs, validateNoUnreviewedOpenAccountCodeCRs } from '../utils/change-requests.utils.js'; -import { CR_Type, WBS_Element_Status, Scope_CR_Why_Type, Prisma, Organization } from '@prisma/client'; +import { CR_Type, WBS_Element_Status, Prisma, Organization } from '@prisma/client'; import { getUserFullName, getUsersWithSettings, userHasPermission } from '../utils/users.utils.js'; import { throwIfUncheckedDescriptionBullets } from '../utils/description-bullets.utils.js'; import { buildChangeDetail } from '../utils/changes.utils.js'; @@ -61,8 +58,6 @@ import { getGuestChangeRequestQueryArgs, getManyChangeRequestQueryArgs } from '../prisma-query-args/change-requests.query-args.js'; -import proposedSolutionTransformer from '../transformers/proposed-solutions.transformer.js'; -import { getProposedSolutionQueryArgs } from '../prisma-query-args/proposed-solutions.query-args.js'; import { sendCrRequestReviewPopUp, sendCrReviewedPopUp } from '../utils/pop-up.utils.js'; import { GuestChangeRequest } from '../../../shared/src/types/change-request-types.js'; @@ -89,7 +84,7 @@ export default class ChangeRequestsService { } /** - * gets all the change requests in the database for the given organization + * Gets all the change requests in the database for the given organization * @param organization The organization the user is currently in * @returns All of the change requests */ @@ -107,7 +102,7 @@ export default class ChangeRequestsService { } /** - * gets all the change requests in the database for the given organization, tailored to the guest cr page + * Gets all the change requests in the database for the given organization, tailored to the guest cr page * @param organization The organization the user is currently in * @returns All of the change requests */ @@ -121,8 +116,7 @@ export default class ChangeRequestsService { } /** - * Gets a users change requests that they have been requested reviewer for or, if they are leadership, their teams change requests as well - * + * Gets a user's change requests that they have been requested reviewer for or, if they are leadership, their teams change requests as well * @param user The user to get their to review change requests for * @param organization The organization the user is in * @returns The user's change requests for them to review @@ -147,30 +141,20 @@ export default class ChangeRequestsService { } const queryOr: Prisma.Change_RequestWhereInput[] = [ - { - requestedReviewers: { - some: { - userId: user.userId - } - } - }, - { - wbsElement: { - OR: wbsOr - } - } + { requestedReviewers: { some: { userId: user.userId } } }, + { wbsElement: { OR: wbsOr } } ]; const changeRequests = await prisma.change_Request.findMany({ where: { dateDeleted: null, AND: [ - // Check that its unreviewed and a scope change request, omit activation and stage gate - { - dateReviewed: null - }, + { dateReviewed: null }, { - NOT: [{ scopeChangeRequest: null }, { submitterId: user.userId }] + NOT: [ + { type: { in: [CR_Type.ACTIVATION, CR_Type.STAGE_GATE, CR_Type.LEADERSHIP] } }, + { submitterId: user.userId } + ] }, ...(carId ? [{ wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }] : []) ], @@ -185,7 +169,6 @@ export default class ChangeRequestsService { /** * Gets all the unreviewed change requests for the current user - * * @param user The user to get the change requests for * @param wbsnum Optional wbs number to filter the request for * @param organization The organization the user is currently in @@ -197,15 +180,7 @@ export default class ChangeRequestsService { organization: Organization, carId?: string ): Promise { - // Check that its unreviewed and a scope change request, omit activation and stage gate - const queryAnd: Prisma.Change_RequestWhereInput[] = [ - { - dateReviewed: null - }, - { - changes: { none: {} } - } - ]; + const queryAnd: Prisma.Change_RequestWhereInput[] = [{ dateReviewed: null }, { changes: { none: {} } }]; if (wbsnum) queryAnd.push({ wbsElementId: (await validateWbsElement(wbsnum, organization)).wbsElementId }); else { @@ -229,7 +204,6 @@ export default class ChangeRequestsService { /** * Gets the users approved change requests from the last five days - * * @param user The user to get their approved change requests for * @param wbsnum Optional wbs number to filter the request for * @param organization The organization the user is currently in @@ -242,7 +216,7 @@ export default class ChangeRequestsService { carId?: string ): Promise { const currentDate = new Date(); - const fiveDaysAgo = new Date(currentDate.getTime() - 1000 * 60 * 60 * 24 * 5); // Change requests that were reviewed less than five days ago + const fiveDaysAgo = new Date(currentDate.getTime() - 1000 * 60 * 60 * 24 * 5); const queryAnd = wbsnum ? [{ wbsElementId: (await validateWbsElement(wbsnum, organization)).wbsElementId }] : [ @@ -254,16 +228,10 @@ export default class ChangeRequestsService { where: { organizationId: organization.organizationId, OR: [ + { dateReviewed: { gte: fiveDaysAgo } }, { - dateReviewed: { - gte: fiveDaysAgo - } - }, - { - scopeChangeRequest: null, - dateSubmitted: { - gte: fiveDaysAgo - } + type: { in: [CR_Type.ACTIVATION, CR_Type.STAGE_GATE, CR_Type.LEADERSHIP, CR_Type.BUDGET] }, + dateSubmitted: { gte: fiveDaysAgo } } ], dateDeleted: null, @@ -276,29 +244,24 @@ export default class ChangeRequestsService { } /** - * reviews the change request for the given Id and automates any changes that are made + * Reviews the change request for the given Id and automates any changes that are made * @param reviewer The user reviewing the change request * @param crId the change request id * @param reviewNotes any notes passed in by the reviewer * @param accepted whether or not the change request is accepted * @param organization the organization the user is currently in - * @param psId an optional psId to be passed in if the change request is a scope change request * @returns the id of the reviewed change request - * @throws if the user does not have perms, the change request does not exist, the change request is already approved, */ static async reviewChangeRequest( reviewer: User, crId: string, reviewNotes: string, accepted: boolean, - organization: Organization, - psId: string | null + organization: Organization ): Promise { - // verify that the user is allowed review change requests if (await userHasPermission(reviewer.userId, organization.organizationId, isNotLeadership)) throw new AccessDeniedMemberException('review change requests'); - // ensure existence of change request const foundCR = await prisma.change_Request.findUnique({ where: { crId }, ...getChangeRequestWithProjectAndWorkPackageQueryArgs(organization.organizationId) @@ -310,11 +273,9 @@ export default class ChangeRequestsService { if (foundCR.wbsElement?.dateDeleted) throw new DeletedException('WBS Element', wbsPipe(foundCR.wbsElement)); if (foundCR.organizationId !== organization.organizationId) throw new InvalidOrganizationException('Change Request'); - // verify that the user is not reviewing their own change request if (reviewer.userId === foundCR.submitterId) throw new AccessDeniedException("You can't review your own change request!"); - // verify that if there are requested reviewers, the reviewer is one of them if (foundCR.requestedReviewers.length > 0) { const isRequestedReviewer = foundCR.requestedReviewers.some((user) => user.userId === reviewer.userId); if (!isRequestedReviewer) { @@ -322,19 +283,16 @@ export default class ChangeRequestsService { } } - // ScopeChange Request That Has Been Accepted Being Reviewed - if (foundCR.scopeChangeRequest && accepted) { - await this.reviewScopeChangeRequest(foundCR, reviewer, psId, organization); - // Stage Gate Change Request That Has Been Accepted Being Reviewed - } else if (accepted && foundCR.type === CR_Type.STAGE_GATE) { + if (accepted && foundCR.type === CR_Type.STAGE_GATE) { await this.reviewStageGateChangeRequest(foundCR, reviewer); - // Activation Change Requested That Has Been Accepted Being Reviewed } else if (foundCR.type === CR_Type.ACTIVATION && foundCR.activationChangeRequest && accepted) { await this.reviewActivationChangeRequest(foundCR, reviewer); } else if (foundCR.type === CR_Type.BUDGET && foundCR.budgetChangeRequest && accepted) { await this.reviewBudgetChangeRequest(foundCR, reviewer); + } else if (foundCR.type === CR_Type.STANDARD && accepted) { + await this.reviewStandardChangeRequest(foundCR, reviewer, organization); } - // finally we can update change request + const updated = await prisma.change_Request.update({ where: { crId }, data: { @@ -349,143 +307,63 @@ export default class ChangeRequestsService { } }); - // send a notification to the submitter that their change request has been reviewed await sendCRSubmitterReviewedNotification(updated); - await sendCrReviewedPopUp(foundCR, updated.submitter, accepted, organization.organizationId); - - // send a reply to a CR's notifications of its updated status await sendSlackCRStatusToThread(updated.notificationSlackThreads, foundCR.crId, foundCR.identifier, accepted); return updated.crId; } /** - * Reviews the scope change request considering either proposed solutions or proposed changes and initiating the respective changes + * Reviews a standard change request, applying wbs proposed changes if present * @param foundCR the change request to be reviewed * @param reviewer the user reviewing the change request - * @param psId an optional psId to be passed in if the change request is a scope change request * @param organization the organization the user is currently in */ - static async reviewScopeChangeRequest( + private static async reviewStandardChangeRequest( foundCR: Prisma.Change_RequestGetPayload, reviewer: User, - psId: string | null, organization: Organization ): Promise { - if (!foundCR.scopeChangeRequest) throw new HttpException(400, 'No scope change request found!'); - if (!foundCR.scopeChangeRequest.wbsProposedChanges && !psId) { - // if there isn't wbs changes or proposed solutions - throw new HttpException(400, 'No proposed solution or proposed changes for scope change request'); - } else if (psId && !foundCR.scopeChangeRequest.wbsProposedChanges) { - // if there is only a proposed solution and no wbs changes - // reviews a proposed solution applying certain changes based on the content of the proposed solution - await reviewProposedSolution(psId, foundCR, reviewer, organization.organizationId); - } else if (foundCR.scopeChangeRequest?.wbsProposedChanges && !psId) { - const associatedProject = foundCR.wbsElement?.project - ? { - ...foundCR.wbsElement?.project, - wbsNum: { - carNumber: foundCR.wbsElement.carNumber, - projectNumber: foundCR.wbsElement.projectNumber, - workPackageNumber: foundCR.wbsElement.workPackageNumber - } - } - : null; - const associatedWorkPackage = foundCR.wbsElement?.workPackage; - const { wbsProposedChanges } = foundCR.scopeChangeRequest; - const { workPackageProposedChanges } = wbsProposedChanges; - const { projectProposedChanges } = wbsProposedChanges; - - // must accept and review a change request before using the workpackage and project services - await prisma.scope_CR.update({ - where: { changeRequestId: foundCR.crId }, - data: { - changeRequest: { - update: { - accepted: true, - dateReviewed: new Date() - } - }, - wbsOriginalData: { - create: { - name: foundCR.wbsElement?.name ?? '', - status: foundCR.wbsElement?.status ?? WBS_Element_Status.INACTIVE, - leadId: foundCR.wbsElement?.leadId, - managerId: foundCR.wbsElement?.managerId, - links: { - connect: foundCR.wbsElement?.links.map((link) => ({ - linkId: link.linkId - })) - }, - proposedDescriptionBulletChanges: { - connect: foundCR.wbsElement?.descriptionBullets.map((descriptionBullet) => ({ - descriptionId: descriptionBullet.descriptionId - })) - }, - projectProposedChanges: - projectProposedChanges && associatedProject - ? { - create: { - budget: associatedProject.budget, - summary: associatedProject.summary, - teams: { - connect: associatedProject.teams.map((team) => ({ teamId: team.teamId })) - }, - car: { - connect: { - carId: associatedProject.carId - } - } - } - } - : undefined, - workPackageProposedChanges: - workPackageProposedChanges && associatedWorkPackage - ? { - create: { - startDate: associatedWorkPackage.startDate, - duration: associatedWorkPackage.duration, - blockedBy: { - connect: associatedWorkPackage.blockedBy.map((wbsElement) => ({ - wbsNumber: { - carNumber: wbsElement.carNumber, - projectNumber: wbsElement.projectNumber, - workPackageNumber: wbsElement.workPackageNumber, - organizationId: wbsElement.organizationId - } - })) - }, - stage: associatedWorkPackage.stage - } - } - : undefined - } - } + if (!foundCR.wbsProposedChanges) return; + + const { wbsProposedChanges } = foundCR; + const { workPackageProposedChanges, projectProposedChanges } = wbsProposedChanges; + + const associatedProject = foundCR.wbsElement?.project ?? null; + const associatedWorkPackage = foundCR.wbsElement?.workPackage ?? null; + + // wbsNum of the project — needed when creating new work packages under a project CR + const projectWbsNum: WbsNumber | null = associatedProject + ? { + carNumber: foundCR.wbsElement!.carNumber, + projectNumber: foundCR.wbsElement!.projectNumber, + workPackageNumber: foundCR.wbsElement!.workPackageNumber } - }); + : null; - if (workPackageProposedChanges) { - await applyWorkPackageProposedChanges( - wbsProposedChanges, - workPackageProposedChanges, - associatedProject?.wbsNum ?? null, - associatedWorkPackage ?? null, - reviewer, - foundCR.crId, - organization - ); - } else if (projectProposedChanges) { - await applyProjectProposedChanges( - wbsProposedChanges, - projectProposedChanges, - associatedProject, - reviewer, - foundCR.crId, - foundCR.wbsElement?.carNumber ?? 0, - organization - ); - } + if (workPackageProposedChanges) { + // CR is on a work package — either edit it or create it under the project + await applyWorkPackageProposedChanges( + wbsProposedChanges, + workPackageProposedChanges, + associatedWorkPackage ? null : projectWbsNum, // existingWbsNum only when creating new WP + associatedWorkPackage, + reviewer, + foundCR.crId, + organization + ); + } else if (projectProposedChanges) { + // CR is on a project — edit it or create it + await applyProjectProposedChanges( + wbsProposedChanges, + projectProposedChanges, + associatedProject, + reviewer, + foundCR.crId, + foundCR.wbsElement?.carNumber ?? 0, + organization + ); } } @@ -504,7 +382,6 @@ export default class ChangeRequestsService { throwIfUncheckedDescriptionBullets(foundCR.wbsElement.descriptionBullets); - // update the status of the associated wp to be complete if needed const shouldChangeStatus = foundCR.wbsElement.status !== WBS_Element_Status.COMPLETE; const changesList = []; if (shouldChangeStatus) { @@ -521,11 +398,7 @@ export default class ChangeRequestsService { wbsElement: { update: { status: WBS_Element_Status.COMPLETE, - changes: { - createMany: { - data: changesList - } - } + changes: { createMany: { data: changesList } } } } } @@ -637,9 +510,7 @@ export default class ChangeRequestsService { await prisma.reimbursement_Product_Other_Reason.update({ where: { otherReimbursementProductReasonId: foundCR.categoryId ?? '' }, - data: { - budget: budgetChangeRequest.proposedBudget - } + data: { budget: budgetChangeRequest.proposedBudget } }); } @@ -658,9 +529,7 @@ export default class ChangeRequestsService { await prisma.account_Code.update({ where: { accountCodeId: foundCR.accountCodeId ?? '' }, - data: { - amount: budgetChangeRequest.proposedBudget - } + data: { amount: budgetChangeRequest.proposedBudget } }); } @@ -677,7 +546,6 @@ export default class ChangeRequestsService { * @param confirmDetails whether or not to confirm * @param organization the organization the user is currently in * @returns the id of the created cr - * @throws if user is not allowed to create crs, if wbs element does not exist, or if the cr type is not activation */ static async createActivationChangeRequest( submitter: User, @@ -691,45 +559,29 @@ export default class ChangeRequestsService { confirmDetails: boolean, organization: Organization ): Promise { - // verify user is allowed to create activation change requests if (await userHasPermission(submitter.userId, organization.organizationId, isGuest)) throw new AccessDeniedGuestException('create activation change requests'); - // verify wbs element exists const wbsElement = await prisma.wBS_Element.findUnique({ where: { - wbsNumber: { - carNumber, - projectNumber, - workPackageNumber, - organizationId: organization.organizationId - } + wbsNumber: { carNumber, projectNumber, workPackageNumber, organizationId: organization.organizationId } }, include: { - changeRequests: { - where: { - dateDeleted: null - }, - include: { - changes: true - } - } + changeRequests: { where: { dateDeleted: null }, include: { changes: true } } } }); if (!wbsElement) throw new NotFoundException('WBS Element', wbsPipe({ carNumber, projectNumber, workPackageNumber })); if (wbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsPipe({ carNumber, projectNumber, workPackageNumber })); - // we don't want to have merge conflictS on the wbs element thus we check if there are unreviewed or open CRs on the wbs element + await validateNoUnreviewedOpenCRs(wbsElement.wbsElementId); const { changeRequests } = wbsElement; if (!allChangeRequestsReviewed(changeRequests)) { throw new HttpException( 400, - `Please resolve all change requests related to ${wbsPipe({ carNumber, projectNumber, workPackageNumber })} - ${ - wbsElement.name - } before proceeding` + `Please resolve all change requests related to ${wbsPipe({ carNumber, projectNumber, workPackageNumber })} - ${wbsElement.name} before proceeding` ); } @@ -765,12 +617,10 @@ export default class ChangeRequestsService { wbsElement, createdCR.wbsElement?.workPackage?.project.wbsElement.name || '' ); - - // save the slack references to the change request await addSlackThreadsToChangeRequest(createdCR.crId, notifications); } - await ChangeRequestsService.reviewActivationChangeRequest(createdCR, submitter); // automatically accept activation change requests for convenience + await ChangeRequestsService.reviewActivationChangeRequest(createdCR, submitter); return createdCR.crId; } @@ -778,14 +628,13 @@ export default class ChangeRequestsService { /** * Validates and creates a stage gate change request * @param submitter The user creating the cr - * @param carNumber the car number for the wbs element - * @param projectNumber the project number for the wbs element - * @param workPackageNumber the work package number for the wbs element - * @param type the type of cr - * @param confirmDone whether or not to confirm + * @param carNumber the car number for the wbs element + * @param projectNumber the project number for the wbs element + * @param workPackageNumber the work package number for the wbs element + * @param type the type of cr + * @param confirmDone whether or not to confirm * @param organization the organization the user is currently in * @returns the id of the created cr - * @throws if user is not allowed to create crs, if wbs element does not exist, or if the cr type is not stage gate */ static async createStageGateChangeRequest( submitter: User, @@ -796,36 +645,24 @@ export default class ChangeRequestsService { confirmDone: boolean, organization: Organization ): Promise { - // verify user is allowed to create stage gate change requests if (await userHasPermission(submitter.userId, organization.organizationId, isGuest)) throw new AccessDeniedGuestException('create stage gate change requests'); - // verify wbs element exists const wbsElement = await prisma.wBS_Element.findUnique({ where: { - wbsNumber: { - carNumber, - projectNumber, - workPackageNumber, - organizationId: organization.organizationId - } + wbsNumber: { carNumber, projectNumber, workPackageNumber, organizationId: organization.organizationId } }, include: { workPackage: true, descriptionBullets: true, - changeRequests: { - where: { - dateDeleted: null - }, - include: { changes: true } - } + changeRequests: { where: { dateDeleted: null }, include: { changes: true } } } }); if (!wbsElement) throw new NotFoundException('WBS Element', `${carNumber}.${projectNumber}.${workPackageNumber}`); if (wbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsPipe({ carNumber, projectNumber, workPackageNumber })); - // we don't want to have merge conflictS on the wbs element thus we check if there are unreviewed or open CRs on the wbs element + await validateNoUnreviewedOpenCRs(wbsElement.wbsElementId); if (wbsElement.workPackage) { @@ -836,9 +673,7 @@ export default class ChangeRequestsService { if (!allChangeRequestsReviewed(changeRequests)) { throw new HttpException( 400, - `Please resolve all change requests related to ${wbsPipe({ carNumber, projectNumber, workPackageNumber })} - ${ - wbsElement.name - } before proceeding` + `Please resolve all change requests related to ${wbsPipe({ carNumber, projectNumber, workPackageNumber })} - ${wbsElement.name} before proceeding` ); } @@ -852,10 +687,7 @@ export default class ChangeRequestsService { wbsElement: { connect: { wbsElementId: wbsElement.wbsElementId } }, type, stageGateChangeRequest: { - create: { - leftoverBudget: 0, - confirmDone - } + create: { leftoverBudget: 0, confirmDone } }, organization: { connect: { organizationId: organization.organizationId } }, identifier: numChangeRequests + 1 @@ -872,26 +704,23 @@ export default class ChangeRequestsService { wbsElement, createdChangeRequest.wbsElement?.workPackage?.project.wbsElement.name || '' ); - - // save the slack references to the change request await addSlackThreadsToChangeRequest(createdChangeRequest.crId, notifications); } - await ChangeRequestsService.reviewStageGateChangeRequest(createdChangeRequest, submitter); // automatically accept stage gate change requests for convenience + await ChangeRequestsService.reviewStageGateChangeRequest(createdChangeRequest, submitter); return createdChangeRequest.crId; } /** - * Validates and creates a budget change request for a category + * Validates and creates a budget change request * @param submitter The user creating the cr - * @param type the type of cr + * @param type the type of cr * @param proposedBudget the proposed budget * @param organization the organization the user is currently in * @param otherReasonId the id of the other reason/category to change budget of * @param accountCodeId the id of the account code to change budget of - * @returns the id of the created cr - * @throws if user is not allowed to create crs, if other reason does not exist, or if the cr type is not budget + * @returns the created change request */ static async createBudgetChangeRequest( submitter: User, @@ -900,28 +729,21 @@ export default class ChangeRequestsService { organization: Organization, otherReasonId?: string, accountCodeId?: string - ): Promise< - ChangeRequest | StandardChangeRequest | ActivationChangeRequest | StageGateChangeRequest | BudgetChangeRequest - > { - // verify user is allowed to create budget change requests + ): Promise { if (await userHasPermission(submitter.userId, organization.organizationId, isGuest)) throw new AccessDeniedGuestException('create budget change requests'); let createdChangeRequest; if (otherReasonId) { - // verify category exists const category = await prisma.reimbursement_Product_Other_Reason.findUnique({ - where: { - otherReimbursementProductReasonId: otherReasonId - }, + where: { otherReimbursementProductReasonId: otherReasonId }, include: { changeRequests: { where: { dateDeleted: null }, include: { changes: true } } } }); if (!category) throw new NotFoundException('Reimbursement Product Other Reason', otherReasonId); if (category.dateDeleted) throw new DeletedException('Reimbursement Product Other Reason', otherReasonId); - // we don't want to have merge conflictS on the category element thus we check if there are unreviewed or open CRs on the category await validateNoUnreviewedOpenOtherReasonCRs(category.otherReimbursementProductReasonId); const { changeRequests } = category; @@ -941,11 +763,7 @@ export default class ChangeRequestsService { submitter: { connect: { userId: submitter.userId } }, category: { connect: { otherReimbursementProductReasonId: otherReasonId } }, type, - budgetChangeRequest: { - create: { - proposedBudget - } - }, + budgetChangeRequest: { create: { proposedBudget } }, organization: { connect: { organizationId: organization.organizationId } }, identifier: numChangeRequests + 1 }, @@ -953,10 +771,7 @@ export default class ChangeRequestsService { }); const financeTeams = await prisma.team.findMany({ - where: { - financeTeam: true, - organizationId: organization.organizationId - } + where: { financeTeam: true, organizationId: organization.organizationId } }); if (financeTeams && financeTeams.length > 0) { @@ -968,23 +783,17 @@ export default class ChangeRequestsService { undefined, category ); - - // save the slack references to the change request await addSlackThreadsToChangeRequest(createdChangeRequest.crId, notifications); } } else if (accountCodeId) { - // verify account code exists const accountCode = await prisma.account_Code.findUnique({ - where: { - accountCodeId - }, + where: { accountCodeId }, include: { changeRequests: { where: { dateDeleted: null }, include: { changes: true } } } }); if (!accountCode) throw new NotFoundException('Account Code', accountCodeId); if (accountCode.dateDeleted) throw new DeletedException('Account Code', accountCodeId); - // we don't want to have merge conflicts on the account codes thus we check if there are unreviewed or open CRs on the category await validateNoUnreviewedOpenAccountCodeCRs(accountCode.accountCodeId); const { changeRequests } = accountCode; @@ -1001,11 +810,7 @@ export default class ChangeRequestsService { submitter: { connect: { userId: submitter.userId } }, accountCode: { connect: { accountCodeId } }, type, - budgetChangeRequest: { - create: { - proposedBudget - } - }, + budgetChangeRequest: { create: { proposedBudget } }, organization: { connect: { organizationId: organization.organizationId } }, identifier: numChangeRequests + 1 }, @@ -1013,10 +818,7 @@ export default class ChangeRequestsService { }); const financeTeams = await prisma.team.findMany({ - where: { - financeTeam: true, - organizationId: organization.organizationId - } + where: { financeTeam: true, organizationId: organization.organizationId } }); if (financeTeams && financeTeams.length > 0) { @@ -1029,8 +831,6 @@ export default class ChangeRequestsService { undefined, accountCode ); - - // save the slack references to the change request await addSlackThreadsToChangeRequest(createdChangeRequest.crId, notifications); } } @@ -1044,7 +844,6 @@ export default class ChangeRequestsService { /** * Validates and creates a leadership change request, auto-approved immediately. - * Updates the lead and/or manager of a project or work package without requiring review. * @param submitter the user creating the cr * @param carNumber the car number for the wbs element * @param projectNumber the project number for the wbs element @@ -1066,15 +865,9 @@ export default class ChangeRequestsService { if (await userHasPermission(submitter.userId, organization.organizationId, isGuest)) throw new AccessDeniedGuestException('create leadership change requests'); - // verify wbs element exists const wbsElement = await prisma.wBS_Element.findUnique({ where: { - wbsNumber: { - carNumber, - projectNumber, - workPackageNumber, - organizationId: organization.organizationId - } + wbsNumber: { carNumber, projectNumber, workPackageNumber, organizationId: organization.organizationId } }, select: { wbsElementId: true, @@ -1090,7 +883,6 @@ export default class ChangeRequestsService { throw new DeletedException('WBS Element', wbsPipe({ carNumber, projectNumber, workPackageNumber })); if (wbsElement.organizationId !== organization.organizationId) throw new InvalidOrganizationException('WBS Element'); - // avoid merge conflicts await validateNoUnreviewedOpenCRs(wbsElement.wbsElementId); const numChangeRequests = await prisma.change_Request.count({ @@ -1119,8 +911,7 @@ export default class ChangeRequestsService { } /** - * Applies a leadership change request by updating the wbs element's lead/manager - * and auto-approving the change request. + * Applies a leadership change request by updating the wbs element's lead/manager and auto-approving */ private static async applyLeadershipChangeRequest( crId: string, @@ -1150,11 +941,7 @@ export default class ChangeRequestsService { const changes: { changeRequestId: string; implementerId: string; wbsElementId: string; detail: string }[] = []; - const oldLeadId = wbsElement.leadId ?? undefined; - const oldManagerId = wbsElement.managerId ?? undefined; - - if (leadId !== oldLeadId) { - // only update if lead changed + if (leadId !== (wbsElement.leadId ?? undefined)) { const oldLead = await getUserFullName(wbsElement.leadId ?? null); const newLead = await getUserFullName(leadId ?? null); changes.push({ @@ -1165,8 +952,7 @@ export default class ChangeRequestsService { }); } - if (managerId !== oldManagerId) { - // only update if manager changed + if (managerId !== (wbsElement.managerId ?? undefined)) { const oldManager = await getUserFullName(wbsElement.managerId ?? null); const newManager = await getUserFullName(managerId ?? null); changes.push({ @@ -1185,56 +971,34 @@ export default class ChangeRequestsService { /** * Validates and creates a standard change request - * @param submitter The user creating the cr - * @param carNumber the car number for the wbs element - * @param projectNumber the project number for the wbs element - * @param workPackageNumber the work package number for the wbs element - * @param type the type of cr - * @param what the description of the change - * @param why the reason for the change - * @param budgetImpact the impact on the budget - * @param proposedSolutions the proposed solutions of the scope cr - * @param wbsProposedChanges the proposed changes of the wbs element + * @param submitter The user creating the cr + * @param carNumber the car number for the wbs element + * @param projectNumber the project number for the wbs element + * @param workPackageNumber the work package number for the wbs element + * @param why the reason for the change * @param organization the organization the user is currently in - * @param projectProposedChanges the project proposed changes - * @param workPackageProposedChanges the work package proposed changes - * @returns the id of the created cr - * @throws if user is not allowed to create crs, if wbs element does not exist, or if the cr type is not standard + * @param requestedReviewerId optional id of the requested reviewer + * @param projectProposedChanges optional project proposed changes + * @param workPackageProposedChanges optional work package proposed changes + * @returns the created standard change request */ static async createStandardChangeRequest( submitter: User, carNumber: number, projectNumber: number, workPackageNumber: number, - type: CR_Type, - what: string, - why: { type: Scope_CR_Why_Type; explain: string }[], - proposedSolutions: ProposedSolutionCreateArgs[], + why: string, organization: Organization, - projectProposedChanges: ProjectProposedChangesCreateArgs | null, - workPackageProposedChanges: WorkPackageProposedChangesCreateArgs | null + requestedReviewerId?: string, + projectProposedChanges?: ProjectProposedChangesCreateArgs, + workPackageProposedChanges?: WorkPackageProposedChangesCreateArgs ): Promise { - // verify user is allowed to create standard change requests if (await userHasPermission(submitter.userId, organization.organizationId, isGuest)) throw new AccessDeniedGuestException('create standard change requests'); - //verify proposed solutions length is greater than 0 - if (proposedSolutions.length === 0 && !projectProposedChanges && !workPackageProposedChanges) - throw new HttpException(400, 'No proposed solutions/changes provided'); - - if (proposedSolutions.length > 0 && (projectProposedChanges || workPackageProposedChanges)) { - throw new HttpException(400, `Can't have proposed solutions and proposed changes`); - } - - // verify wbs element exists const wbsElement = await prisma.wBS_Element.findUnique({ where: { - wbsNumber: { - carNumber, - projectNumber, - workPackageNumber, - organizationId: organization.organizationId - } + wbsNumber: { carNumber, projectNumber, workPackageNumber, organizationId: organization.organizationId } } }); @@ -1242,11 +1006,11 @@ export default class ChangeRequestsService { if (wbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsPipe({ carNumber, projectNumber, workPackageNumber })); if (wbsElement.organizationId !== organization.organizationId) throw new InvalidOrganizationException('WBS Element'); - // we don't want to have merge conflicts on the wbs element thus we check if there are unreviewed or open CRs on the wbs element + if ( - projectNumber !== 0 && // Excluding Cars - !(projectProposedChanges && projectProposedChanges.workPackageProposedChanges.length === 0) && // Excluding new projects with work packages - !(isProjectWbs(wbsElement) && workPackageProposedChanges) // Excluding Creating Work Package on Project + projectNumber !== 0 && + !(projectProposedChanges && projectProposedChanges.workPackageProposedChanges.length === 0) && + !(isProjectWbs(wbsElement) && workPackageProposedChanges) ) { await validateNoUnreviewedOpenCRs(wbsElement.wbsElementId); } @@ -1259,16 +1023,11 @@ export default class ChangeRequestsService { data: { submitter: { connect: { userId: submitter.userId } }, wbsElement: { connect: { wbsElementId: wbsElement.wbsElementId } }, - type, - scopeChangeRequest: { - create: { - what, - scopeImpact: '', - timelineImpact: 0, - budgetImpact: 0, - why: { createMany: { data: why } } - } - }, + type: CR_Type.STANDARD, + why, + ...(requestedReviewerId && { + requestedReviewers: { connect: { userId: requestedReviewerId } } + }), organization: { connect: { organizationId: organization.organizationId } }, identifier: numChangeRequests + 1 }, @@ -1276,14 +1035,9 @@ export default class ChangeRequestsService { wbsElement: { include: { project: { include: { teams: true, wbsElement: true } }, - workPackage: { - include: { - project: { include: { teams: true, wbsElement: true } } - } - } + workPackage: { include: { project: { include: { teams: true, wbsElement: true } } } } } - }, - scopeChangeRequest: true + } } }); @@ -1299,8 +1053,8 @@ export default class ChangeRequestsService { summary, teamIds, descriptionBullets, - workPackageProposedChanges, - carNumber + workPackageProposedChanges: wpChanges, + carNumber: proposedCarNumber } = projectProposedChanges; const validationResult = await validateProposedChangesFields( @@ -1308,9 +1062,9 @@ export default class ChangeRequestsService { links, descriptionBullets, [], - workPackageProposedChanges, + wpChanges, organization.organizationId, - carNumber, + proposedCarNumber, leadId, managerId ); @@ -1322,15 +1076,11 @@ export default class ChangeRequestsService { } } - const isCreatingNewProject = projectProposedChanges && projectNumber === 0; + const isCreatingNewProject = projectNumber === 0; const changes = await prisma.wbs_Proposed_Changes.create({ data: { - scopeChangeRequest: { - connect: { - scopeCrId: createdCR.scopeChangeRequest!.scopeCrId - } - }, + changeRequest: { connect: { crId: createdCR.crId } }, name, status: isCreatingNewProject ? WBS_Element_Status.INACTIVE : wbsElement.status, links: { @@ -1371,11 +1121,11 @@ export default class ChangeRequestsService { startDate: new Date(workPackage.originalElement.startDate), stage: workPackage.originalElement.stage, blockedBy: { - connect: workPackage.validatedBlockedBys.map((wbsElement) => ({ + connect: workPackage.validatedBlockedBys.map((wbsEl) => ({ wbsNumber: { - carNumber: wbsElement.carNumber, - projectNumber: wbsElement.projectNumber, - workPackageNumber: wbsElement.workPackageNumber, + carNumber: wbsEl.carNumber, + projectNumber: wbsEl.projectNumber, + workPackageNumber: wbsEl.workPackageNumber, organizationId: organization.organizationId } })) @@ -1392,11 +1142,7 @@ export default class ChangeRequestsService { data: { leadId, managerId, - projectProposedChanges: { - update: { - carId: validationResult.carId - } - } + projectProposedChanges: { update: { carId: validationResult.carId } } } }); } else if (workPackageProposedChanges) { @@ -1415,11 +1161,11 @@ export default class ChangeRequestsService { managerId ); - const isCreatingNewWorkPackage = workPackageProposedChanges && workPackageNumber === 0; + const isCreatingNewWorkPackage = workPackageNumber === 0; const changes = await prisma.wbs_Proposed_Changes.create({ data: { - scopeChangeRequest: { connect: { scopeCrId: createdCR.scopeChangeRequest!.scopeCrId } }, + changeRequest: { connect: { crId: createdCR.crId } }, name, status: isCreatingNewWorkPackage ? WBS_Element_Status.INACTIVE : wbsElement.status, proposedDescriptionBulletChanges: { @@ -1428,20 +1174,8 @@ export default class ChangeRequestsService { descriptionBulletType: { connect: { id: bullet.descriptionBulletType.id } } })) }, - ...(leadId && { - lead: { - connect: { - userId: leadId - } - } - }), - ...(managerId && { - manager: { - connect: { - userId: managerId - } - } - }), + ...(leadId && { lead: { connect: { userId: leadId } } }), + ...(managerId && { manager: { connect: { userId: managerId } } }), workPackageProposedChanges: { create: { duration, @@ -1464,27 +1198,10 @@ export default class ChangeRequestsService { await prisma.wbs_Proposed_Changes.update({ where: { wbsProposedChangesId: changes.wbsProposedChangesId }, - data: { - leadId, - managerId - } + data: { leadId, managerId } }); } - const proposedSolutionPromises = proposedSolutions.map(async (proposedSolution) => { - return await this.addProposedSolution( - submitter, - createdCR.crId, - proposedSolution.budgetImpact, - proposedSolution.description, - proposedSolution.timelineImpact, - proposedSolution.scopeImpact, - organization - ); - }); - - await Promise.all(proposedSolutionPromises); - const project = createdCR.wbsElement?.workPackage?.project || createdCR.wbsElement?.project; const teams = project?.teams; if (teams && teams.length > 0) { @@ -1495,8 +1212,6 @@ export default class ChangeRequestsService { wbsElement, project.wbsElement.name ); - - // save the slack references to the change request await addSlackThreadsToChangeRequest(createdCR.crId, notifications); } @@ -1510,61 +1225,6 @@ export default class ChangeRequestsService { return changeRequestTransformer(finishedCR) as StandardChangeRequest; } - /** - * valides and adds a proposed solution to a change request - * @param submitter The user creating the cr - * @param crId the id of the change request - * @param budgetImpact the impact on the budget - * @param description the description of the proposed solution - * @param timelineImpact the impact on the timeline - * @param scopeImpact the impact on the scope - * @param organization the organization the user is currently in - * @returns the id of the created cr - * @throws if user is not allowed to create crs, if the change request is not found, - * or if the change request has already been reviewed - */ - static async addProposedSolution( - submitter: User, - crId: string, - budgetImpact: number, - description: string, - timelineImpact: number, - scopeImpact: string, - organization: Organization - ): Promise { - // verify user is allowed to add proposed solutions - if (await userHasPermission(submitter.userId, organization.organizationId, isGuest)) - throw new AccessDeniedGuestException('add proposed solutions'); - - // ensure existence of change request - const foundCR = await prisma.change_Request.findUnique({ - where: { crId } - }); - - if (!foundCR) throw new NotFoundException('Change Request', crId); - if (foundCR.dateDeleted) throw new DeletedException('Change Request', crId); - if (foundCR.accepted !== null) - throw new HttpException(400, `Cannot create proposed solutions on a reviewed change request!`); - - // ensure existence of scope change request - const foundScopeCR = await prisma.scope_CR.findUnique({ where: { changeRequestId: crId } }); - if (!foundScopeCR) throw new NotFoundException('Change Request', crId); - - const createdProposedSolution = await prisma.proposed_Solution.create({ - data: { - description, - scopeImpact, - timelineImpact, - budgetImpact, - scopeChangeRequest: { connect: { scopeCrId: foundScopeCR.scopeCrId } }, - createdBy: { connect: { userId: submitter.userId } } - }, - ...getProposedSolutionQueryArgs(organization.organizationId) - }); - - return proposedSolutionTransformer(createdProposedSolution); - } - /** * Deletes the Change Request * @param submitter The user who deleted the change request @@ -1572,19 +1232,15 @@ export default class ChangeRequestsService { * @param organization the organization the user is currently in */ static async deleteChangeRequest(submitter: User, crId: string, organization: Organization): Promise { - // ensure existence of change request const foundCR = await prisma.change_Request.findUnique({ where: { crId }, - include: { - wbsElement: true - } + include: { wbsElement: true } }); if (!foundCR) throw new NotFoundException('Change Request', crId); if (foundCR.dateDeleted) throw new DeletedException('Change Request', crId); if (foundCR.organizationId !== organization.organizationId) throw new InvalidOrganizationException('Change Request'); - // verify user is allowed to delete change requests if ( !( (await userHasPermission(submitter.userId, organization.organizationId, isAdmin)) || @@ -1593,8 +1249,6 @@ export default class ChangeRequestsService { ) throw new AccessDeniedAdminOnlyException('delete change requests'); - if (foundCR.dateDeleted) throw new DeletedException('Change Request', crId); - if (foundCR.reviewerId) throw new HttpException(400, `Cannot delete a reviewed change request!`); await prisma.change_Request.update({ @@ -1613,7 +1267,6 @@ export default class ChangeRequestsService { static async requestCRReview(submitter: User, userIds: string[], crId: string, organization: Organization): Promise { const reviewers = await getUsersWithSettings(userIds); - // check if any reviewers' role is below leadership const underLeadsPromises = reviewers.map(async (user) => { return { ...user, underLead: !(await userHasPermission(user.userId, organization.organizationId, isLeadership)) }; }); @@ -1625,7 +1278,6 @@ export default class ChangeRequestsService { throw new AccessDeniedException(`The following user(s) are not leadership: ${underLeadsNames.join(', ')}`); } - // check if all reviewers have slackId const missingReviewersSettings = reviewers.filter((reviewer) => reviewer.userSettings == null); if (missingReviewersSettings.length > 0) { @@ -1649,26 +1301,15 @@ export default class ChangeRequestsService { const oldRequestedReviewersIds = foundCR.requestedReviewers.map((reviewer) => reviewer.userId); - const reviewerIds = reviewers.map((reviewer) => { - return { - userId: reviewer.userId - }; - }); - + const reviewerIds = reviewers.map((reviewer) => ({ userId: reviewer.userId })); const newReviewers = reviewers.filter((user) => !oldRequestedReviewersIds.includes(user.userId)); await prisma.change_Request.update({ where: { crId }, - data: { - requestedReviewers: { - set: reviewerIds - } - } + data: { requestedReviewers: { set: reviewerIds } } }); - // send slack message to CR reviewers await sendSlackRequestedReviewNotification(newReviewers, changeRequestTransformer(foundCR)); - await sendCrRequestReviewPopUp(foundCR, newReviewers, organization.organizationId); } } diff --git a/src/backend/src/services/work-packages.services.ts b/src/backend/src/services/work-packages.services.ts index 83feb938d8..61b1860089 100644 --- a/src/backend/src/services/work-packages.services.ts +++ b/src/backend/src/services/work-packages.services.ts @@ -26,7 +26,7 @@ import { } from '../utils/errors.utils.js'; import { getWorkPackageQueryArgs, getWorkPackagePreviewQueryArgs } from '../prisma-query-args/work-packages.query-args.js'; import workPackageTransformer, { workPackagePreviewTransformer } from '../transformers/work-packages.transformer.js'; -import { updateBlocking, validateChangeRequestAccepted } from '../utils/change-requests.utils.js'; +import { validateChangeRequestAccepted } from '../utils/change-requests.utils.js'; import { sendSlackUpcomingDeadlineNotification } from '../utils/slack.utils.js'; import { getWorkPackageChanges } from '../utils/changes.utils.js'; import { @@ -435,13 +435,7 @@ export default class WorkPackagesService { ...getWorkPackageQueryArgs(organization.organizationId) }); - // Transform Milliseconds to weeks - const timelineImpact = - (updatedWorkPackage.startDate.getTime() - originalWorkPackage.startDate.getTime()) / 1000 / 60 / 60 / 24 / 7 + - updatedWorkPackage.duration - - originalWorkPackage.duration; - - await updateBlocking(updatedWorkPackage, timelineImpact, crId, user); + // await updateBlocking(updatedWorkPackage, timelineImpact, crId, user); // Update any deleted description bullets to have their date deleted as right now if (changes.deletedDescriptionBullets.length > 0) { diff --git a/src/backend/src/transformers/change-requests.transformer.ts b/src/backend/src/transformers/change-requests.transformer.ts index 63f6723c88..20b8304d2c 100644 --- a/src/backend/src/transformers/change-requests.transformer.ts +++ b/src/backend/src/transformers/change-requests.transformer.ts @@ -14,21 +14,18 @@ import { ChangeRequestStatus } from 'shared'; import { wbsNumOf } from '../utils/utils.js'; -import { calculateChangeRequestStatus, convertCRScopeWhyType } from '../utils/change-requests.utils.js'; -import proposedSolutionTransformer from './proposed-solutions.transformer.js'; +import { calculateChangeRequestStatus } from '../utils/change-requests.utils.js'; import { getDateImplemented } from '../utils/change-requests.utils.js'; import { userTransformer } from './user.transformer.js'; import { descBulletConverter } from '../utils/description-bullets.utils.js'; import teamTransformer from './teams.transformer.js'; -import { - WbsProposedChangeQueryArgs, - WorkPackageProposedChangesQueryArgs -} from '../prisma-query-args/scope-change-requests.query-args.js'; import { HttpException } from '../utils/errors.utils.js'; import { ChangeRequestGuestQueryArgs, ChangeRequestManyQueryArgs, - ChangeRequestWithProjectAndWorkPackageQueryArgs + ChangeRequestWithProjectAndWorkPackageQueryArgs, + WbsProposedChangeQueryArgs, + WorkPackageProposedChangesQueryArgs } from '../prisma-query-args/change-requests.query-args.js'; import { accountCodeTransformer, otherProductReasonTransformer } from './reimbursement-requests.transformer.js'; import { GuestChangeRequest } from '../../../shared/src/types/change-request-types.js'; @@ -58,22 +55,19 @@ const projectProposedChangesTransformer = ( const workPackageProposedChangesTransformer = ( workPackageProposedChanges: Prisma.Work_Package_Proposed_ChangesGetPayload ): WorkPackageProposedChanges => { + const { wbsProposedChanges } = workPackageProposedChanges; + return { - id: workPackageProposedChanges.wbsProposedChangesId, - name: workPackageProposedChanges.wbsProposedChanges.name, - status: workPackageProposedChanges.wbsProposedChanges.status as WbsElementStatus, - links: workPackageProposedChanges.wbsProposedChanges.links, - lead: workPackageProposedChanges.wbsProposedChanges.lead - ? userTransformer(workPackageProposedChanges.wbsProposedChanges.lead) - : undefined, - manager: workPackageProposedChanges.wbsProposedChanges.manager - ? userTransformer(workPackageProposedChanges.wbsProposedChanges.manager) - : undefined, + id: workPackageProposedChanges.workPackageProposedChangesId, + name: wbsProposedChanges.name, + status: wbsProposedChanges.status as WbsElementStatus, + links: wbsProposedChanges.links, + lead: wbsProposedChanges.lead ? userTransformer(wbsProposedChanges.lead) : undefined, + manager: wbsProposedChanges.manager ? userTransformer(wbsProposedChanges.manager) : undefined, startDate: workPackageProposedChanges.startDate, duration: workPackageProposedChanges.duration, blockedBy: workPackageProposedChanges.blockedBy.map(wbsNumOf), - descriptionBullets: - workPackageProposedChanges.wbsProposedChanges.proposedDescriptionBulletChanges.map(descBulletConverter), + descriptionBullets: wbsProposedChanges.proposedDescriptionBulletChanges.map(descBulletConverter), stage: (workPackageProposedChanges.stage as WorkPackageStage) || undefined }; }; @@ -90,7 +84,6 @@ export const changeRequestManyTransformer = ( const status = calculateChangeRequestStatus(changeRequest); return { - // all cr fields crId: changeRequest.crId, identifier: changeRequest.identifier, wbsNum: changeRequest.wbsElement ? wbsNumOf(changeRequest.wbsElement) : undefined, @@ -107,15 +100,10 @@ export const changeRequestManyTransformer = ( dateImplemented: getDateImplemented(changeRequest), implementedChanges: [], status, - // scope cr fields + // standard cr fields — not included in many query + why: undefined, projectProposedChanges: undefined, workPackageProposedChanges: undefined, - what: undefined, - why: undefined, - scopeImpact: undefined, - budgetImpact: undefined, - timelineImpact: undefined, - proposedSolutions: undefined, originalProjectData: undefined, originalWorkPackageData: undefined, // activation + leadership cr fields @@ -135,7 +123,7 @@ export const changeRequestManyTransformer = ( leftoverBudget: changeRequest.stageGateChangeRequest?.leftoverBudget ?? undefined, confirmDone: changeRequest.stageGateChangeRequest?.confirmDone ?? undefined, requestedReviewers: changeRequest.requestedReviewers.map(userTransformer) ?? [], - //budget cr fields + // budget cr fields proposedBudget: changeRequest.budgetChangeRequest?.proposedBudget ?? undefined }; }; @@ -158,7 +146,6 @@ const changeRequestTransformer = ( : undefined; return { - // all cr fields crId: changeRequest.crId, identifier: changeRequest.identifier, wbsNum: changeRequest.wbsElement ? wbsNumOf(changeRequest.wbsElement) : undefined, @@ -185,30 +172,16 @@ const changeRequestTransformer = ( dateImplemented: change.dateImplemented })), status, - // scope cr fields - projectProposedChanges: changeRequest.scopeChangeRequest?.wbsProposedChanges?.projectProposedChanges - ? projectProposedChangesTransformer(changeRequest.scopeChangeRequest.wbsProposedChanges) + // standard cr fields + why: changeRequest.why ?? undefined, + projectProposedChanges: changeRequest.wbsProposedChanges?.projectProposedChanges + ? projectProposedChangesTransformer(changeRequest.wbsProposedChanges) : undefined, - workPackageProposedChanges: changeRequest.scopeChangeRequest?.wbsProposedChanges?.workPackageProposedChanges - ? workPackageProposedChangesTransformer(changeRequest.scopeChangeRequest.wbsProposedChanges.workPackageProposedChanges) - : undefined, - what: changeRequest.scopeChangeRequest?.what ?? undefined, - why: changeRequest.scopeChangeRequest?.why.map((why) => ({ - type: convertCRScopeWhyType(why.type), - explain: why.explain - })), - scopeImpact: changeRequest.scopeChangeRequest?.scopeImpact ?? undefined, - budgetImpact: changeRequest.scopeChangeRequest?.budgetImpact ?? undefined, - timelineImpact: changeRequest.scopeChangeRequest?.timelineImpact ?? undefined, - proposedSolutions: changeRequest.scopeChangeRequest - ? (changeRequest.scopeChangeRequest?.proposedSolutions.map(proposedSolutionTransformer) ?? []) - : undefined, - originalProjectData: changeRequest.scopeChangeRequest?.wbsOriginalData?.projectProposedChanges - ? projectProposedChangesTransformer(changeRequest.scopeChangeRequest.wbsOriginalData) - : undefined, - originalWorkPackageData: changeRequest.scopeChangeRequest?.wbsOriginalData?.workPackageProposedChanges - ? workPackageProposedChangesTransformer(changeRequest.scopeChangeRequest.wbsOriginalData.workPackageProposedChanges) + workPackageProposedChanges: changeRequest.wbsProposedChanges?.workPackageProposedChanges + ? workPackageProposedChangesTransformer(changeRequest.wbsProposedChanges.workPackageProposedChanges) : undefined, + originalProjectData: undefined, + originalWorkPackageData: undefined, // activation + leadership cr fields lead: changeRequest.leadershipChangeRequest?.lead ? userTransformer(changeRequest.leadershipChangeRequest.lead) @@ -226,7 +199,7 @@ const changeRequestTransformer = ( leftoverBudget: changeRequest.stageGateChangeRequest?.leftoverBudget ?? undefined, confirmDone: changeRequest.stageGateChangeRequest?.confirmDone ?? undefined, requestedReviewers: changeRequest.requestedReviewers.map(userTransformer) ?? [], - //budget cr fields + // budget cr fields proposedBudget: changeRequest.budgetChangeRequest?.proposedBudget ?? undefined }; }; diff --git a/src/backend/src/transformers/proposed-solutions.transformer.ts b/src/backend/src/transformers/proposed-solutions.transformer.ts deleted file mode 100644 index 314e0a7224..0000000000 --- a/src/backend/src/transformers/proposed-solutions.transformer.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Prisma } from '@prisma/client'; -import { ProposedSolution } from 'shared'; -import { userTransformer } from './user.transformer.js'; -import { ProposedSolutionQueryArgs } from '../prisma-query-args/proposed-solutions.query-args.js'; - -const proposedSolutionTransformer = ( - proposedSolution: Prisma.Proposed_SolutionGetPayload -): ProposedSolution => { - return { - id: proposedSolution.proposedSolutionId, - description: proposedSolution.description, - scopeImpact: proposedSolution.scopeImpact, - budgetImpact: proposedSolution.budgetImpact, - timelineImpact: proposedSolution.timelineImpact, - createdBy: userTransformer(proposedSolution.createdBy), - dateCreated: proposedSolution.dateCreated, - approved: proposedSolution.approved - }; -}; - -export default proposedSolutionTransformer; diff --git a/src/backend/src/utils/change-requests.utils.ts b/src/backend/src/utils/change-requests.utils.ts index 1f7ef4c27c..4fc4698157 100644 --- a/src/backend/src/utils/change-requests.utils.ts +++ b/src/backend/src/utils/change-requests.utils.ts @@ -1,6 +1,5 @@ import prisma from '../prisma/prisma.js'; import { - Scope_CR_Why_Type, Prisma, Change_Request, Change, @@ -12,121 +11,29 @@ import { Organization } from '@prisma/client'; import { - addWeeksToDate, - ChangeRequestReason, DescriptionBulletPreview, LinkCreateArgs, WbsNumber, wbsPipe, WorkPackageProposedChangesCreateArgs, WorkPackageStage, - User, - formatDateOnly + User } from 'shared'; import { DeletedException, HttpException, NotFoundException } from './errors.utils.js'; import { ChangeRequestStatus } from 'shared'; -import { buildChangeDetail, createChange } from './changes.utils.js'; -import { WorkPackageQueryArgs, getWorkPackageQueryArgs } from '../prisma-query-args/work-packages.query-args.js'; import { ChangeRequestManyQueryArgs, ChangeRequestQueryArgs, - ChangeRequestWithProjectAndWorkPackageQueryArgs -} from '../prisma-query-args/change-requests.query-args.js'; -import { ProjectProposedChangesQueryArgs, WbsProposedChangeQueryArgs, WorkPackageProposedChangesQueryArgs -} from '../prisma-query-args/scope-change-requests.query-args.js'; +} from '../prisma-query-args/change-requests.query-args.js'; import ProjectsService from '../services/projects.services.js'; import WorkPackagesService from '../services/work-packages.services.js'; import { descriptionBulletToDescriptionBulletPreview } from './description-bullets.utils.js'; import { sendSlackCRReviewedNotification } from './slack.utils.js'; import { validateBlockedBys } from './work-packages.utils.js'; -export const convertCRScopeWhyType = (whyType: Scope_CR_Why_Type): ChangeRequestReason => - ({ - ESTIMATION: ChangeRequestReason.Estimation, - SCHOOL: ChangeRequestReason.School, - DESIGN: ChangeRequestReason.Design, - MANUFACTURING: ChangeRequestReason.Manufacturing, - RULES: ChangeRequestReason.Rules, - INITIALIZATION: ChangeRequestReason.Initialization, - COMPETITION: ChangeRequestReason.Competition, - MAINTENANCE: ChangeRequestReason.Maintenance, - OTHER_PROJECT: ChangeRequestReason.OtherProject, - OTHER: ChangeRequestReason.Other - })[whyType]; - -/** - * This function updates the start date of all the blockings (and nested blockings) of the initial given work package. - * It uses a depth first search algorithm for efficiency and to avoid cycles. - * - * @param initialWorkPackage the initial work package - * @param timelineImpact the timeline impact of the proposed solution - * @param crId the change request id - * @param reviewer the reviewer of the change request - */ -export const updateBlocking = async ( - initialWorkPackage: Prisma.Work_PackageGetPayload, - timelineImpact: number, - crId: string, - reviewer: User -) => { - // track the wbs element ids we've seen so far so we don't update the same one multiple times - const seenWbsElementIds: Set = new Set([initialWorkPackage.wbsElement.wbsElementId]); - - // blocking ids that still need to be updated - const blockingUpdateQueue: string[] = initialWorkPackage.wbsElement.blocking.map((blocking) => blocking.wbsElementId); - - while (blockingUpdateQueue.length > 0) { - const currWbsId = blockingUpdateQueue.pop(); // get the next blocking and remove it from the queue - - if (!currWbsId) break; // this is more of a type check for pop becuase the while loop prevents this from not existing - if (seenWbsElementIds.has(currWbsId)) continue; // if we've already seen it we skip it - - seenWbsElementIds.add(currWbsId); - - // get the current wbs object from prisma - const currWbs = await prisma.wBS_Element.findUnique({ - where: { wbsElementId: currWbsId }, - include: { - blocking: true, - workPackage: true - } - }); - - if (!currWbs) throw new NotFoundException('WBS Element', currWbsId); - if (currWbs.dateDeleted) continue; // this wbs element has been deleted so skip it - if (!currWbs.workPackage) continue; // this wbs element is a project so skip it - - const newStartDate: Date = addWeeksToDate(currWbs.workPackage.startDate, timelineImpact); - - const change = { - changeRequestId: crId, - implementerId: reviewer.userId, - detail: buildChangeDetail('Start Date', formatDateOnly(currWbs.workPackage.startDate), formatDateOnly(newStartDate)) - }; - - await prisma.work_Package.update({ - where: { workPackageId: currWbs.workPackage.workPackageId }, - data: { - startDate: newStartDate, - wbsElement: { - update: { - changes: { - create: change - } - } - } - } - }); - - // get all the blockings of the current wbs and add them to the queue to update - const newBlocking: string[] = currWbs.blocking.map((blocking) => blocking.wbsElementId); - blockingUpdateQueue.push(...newBlocking); - } -}; - /** Makes sure that a change request has been accepted already (and not deleted) * @param crId - the id of the change request to check * @returns the change request @@ -475,126 +382,6 @@ export const applyWorkPackageProposedChanges = async ( } }; -/** - * Reviews a proposed solution and automates the changes - * @param psId the proposed solution id - * @param foundCR the change request being reviewed - * @param crId the change request id - * @param reviewer the user reviewing the change request - */ -export const reviewProposedSolution = async ( - psId: string, - foundCR: Prisma.Change_RequestGetPayload, - reviewer: User, - organizationId: string -) => { - const foundPs = await prisma.proposed_Solution.findUnique({ - where: { proposedSolutionId: psId } - }); - if (!foundPs || foundPs.scopeChangeRequestId !== foundCR.scopeChangeRequest?.scopeCrId) - throw new NotFoundException('Proposed Solution', psId); - - // automate the changes for the proposed solution - // if cr is for a project: update the budget based off of the proposed solution - // else if cr is for a wp: update the budget and duration based off of the proposed solution - if (!foundCR.wbsElement?.workPackage && foundCR.wbsElement?.project) { - const newBudget = foundCR.wbsElement.project.budget + foundPs.budgetImpact; - const change = createChange( - 'Budget', - foundCR.wbsElement.project.budget, - newBudget, - foundCR.crId, - reviewer.userId, - foundCR.wbsElementId, - foundCR.categoryId, - foundCR.accountCodeId - ); - await prisma.project.update({ - where: { projectId: foundCR.wbsElement.project.projectId }, - data: { - budget: newBudget - } - }); - - //Make the associated budget change if there was a change - if (change) await prisma.change.create({ data: change }); - } else if (foundCR.wbsElement?.workPackage) { - // get the project for the work package - const wpProj = await prisma.project.findUnique({ - where: { projectId: foundCR.wbsElement.workPackage.projectId }, - include: { workPackages: getWorkPackageQueryArgs(organizationId) } - }); - if (!wpProj) throw new NotFoundException('Project', foundCR.wbsElement.workPackage.projectId); - - // calculate the new budget and new duration - const newBudget = wpProj.budget + foundPs.budgetImpact; - const updatedDuration = foundCR.wbsElement.workPackage.duration + foundPs.timelineImpact; - - // create changes that reflect the new budget and duration - const changes = [ - createChange( - 'Budget', - wpProj.budget, - newBudget, - foundCR.crId, - reviewer.userId, - foundCR.wbsElementId, - foundCR.categoryId, - foundCR.accountCodeId - ), - createChange( - 'Duration', - foundCR.wbsElement.workPackage.duration, - updatedDuration, - foundCR.crId, - reviewer.userId, - foundCR.wbsElementId, - foundCR.categoryId, - foundCR.accountCodeId - ) - ]; - - // update all the wps this wp is blocking (and nested blockings) of this work package so that their start dates reflect the new duration - if (foundPs.timelineImpact > 0) { - await updateBlocking(foundCR.wbsElement.workPackage, foundPs.timelineImpact, foundCR.crId, reviewer); - } - - // update the project and work package - await prisma.project.update({ - where: { projectId: foundCR.wbsElement.workPackage.projectId }, - data: { - budget: newBudget, - workPackages: { - update: { - where: { workPackageId: foundCR.wbsElement.workPackage.workPackageId }, - data: { - duration: updatedDuration - } - } - } - } - }); - - //Making associated changes - const changePromises = changes.map(async (change) => { - //Checking if change is not zero so we dont make changes for zero budget or timeline impact - if (change) { - await prisma.change.create({ data: change }); - } - }); - - await Promise.all(changePromises); - } - - // finally update the proposed solution - await prisma.proposed_Solution.update({ - where: { proposedSolutionId: psId }, - data: { - approved: true - } - }); -}; - /** * Sends a slack notification to the submitter of the change request that their change request has been reviewed * @param foundCR the change request that was reviewed diff --git a/src/backend/tests/test-data/change-requests.test-data.ts b/src/backend/tests/test-data/change-requests.test-data.ts index a099b2a568..0dabe3a821 100644 --- a/src/backend/tests/test-data/change-requests.test-data.ts +++ b/src/backend/tests/test-data/change-requests.test-data.ts @@ -1,9 +1,4 @@ -import { - Change_Request as PrismaChangeRequest, - Proposed_Solution as PrismaProposedSolution, - Scope_CR as PrismaScopeCR, - CR_Type as PrismaCRType -} from '@prisma/client'; +import { Change_Request as PrismaChangeRequest } from '@prisma/client'; import { ChangeRequest as SharedChangeRequest, ChangeRequestStatus, ChangeRequestType } from 'shared'; import { sharedBatman } from './users.test-data.js'; @@ -15,7 +10,9 @@ export const prismaChangeRequest1: PrismaChangeRequest = { wbsElementId: '65', categoryId: null, accountCodeId: null, - type: PrismaCRType.DEFINITION_CHANGE, + type: ChangeRequestType.Budget, + why: null, + wbsProposedChangesId: null, dateSubmitted: new Date('11/24/2020'), dateReviewed: new Date('11/25/2020'), accepted: null, @@ -25,27 +22,6 @@ export const prismaChangeRequest1: PrismaChangeRequest = { deletedByUserId: null }; -export const prismaProposedSolution1: PrismaProposedSolution = { - proposedSolutionId: '1', - description: 'Change Color from Orange to Black', - timelineImpact: 10, - budgetImpact: 1000, - scopeImpact: 'huge', - scopeChangeRequestId: '1', - createdByUserId: '3', - dateCreated: new Date('10/16/2022'), - approved: false -}; - -export const prismaScopeChangeRequest1: PrismaScopeCR = { - scopeCrId: '1', - changeRequestId: '2', - what: 'redesign whip', - scopeImpact: 'huge', - timelineImpact: 10, - budgetImpact: 1000 -}; - export const sharedChangeRequest: SharedChangeRequest = { crId: '1', wbsNum: { @@ -56,7 +32,7 @@ export const sharedChangeRequest: SharedChangeRequest = { wbsName: 'whip', submitter: sharedBatman, dateSubmitted: new Date('12-25-2000'), - type: ChangeRequestType.Redefinition, + type: ChangeRequestType.Leadership, status: ChangeRequestStatus.Open, requestedReviewers: [], identifier: 1 diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index 430e4ee640..9c4d40ce39 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -137,9 +137,6 @@ export const resetUsers = async () => { await prisma.stage_Gate_CR.deleteMany(); await prisma.activation_CR.deleteMany(); await prisma.change.deleteMany(); - await prisma.proposed_Solution.deleteMany(); - await prisma.scope_CR_Why.deleteMany(); - await prisma.scope_CR.deleteMany(); await prisma.budget_CR.deleteMany(); await prisma.change_Request.deleteMany(); await prisma.link.deleteMany(); diff --git a/src/backend/tests/unit/change-requests.test.ts b/src/backend/tests/unit/change-requests.test.ts index cfdd23fecc..41b4a82b7f 100644 --- a/src/backend/tests/unit/change-requests.test.ts +++ b/src/backend/tests/unit/change-requests.test.ts @@ -1,4 +1,4 @@ -import { CR_Type, Organization, Scope_CR_Why_Type, User, WBS_Element_Status } from '@prisma/client'; +import { Organization, User, WBS_Element_Status } from '@prisma/client'; import { createTestCar, createTestOrganization, createTestProject, createTestUser, resetUsers } from '../test-utils.js'; import ChangeRequestsService from '../../src/services/change-requests.services.js'; import { @@ -8,7 +8,6 @@ import { flashAdmin, robinMember } from '../test-data/users.test-data.js'; -import { ProjectProposedChangesCreateArgs, WorkPackageProposedChangesCreateArgs } from 'shared'; import prisma from '../../src/prisma/prisma.js'; import { AccessDeniedException } from '../../src/utils/errors.utils.js'; @@ -39,43 +38,14 @@ describe('Change Request Tests', () => { describe('Create Change Request', () => { it('create change request on an inactive project - project changes', async () => { - const projPropChanges: ProjectProposedChangesCreateArgs = { - name: 'Project name changes', - descriptionBullets: [], - links: [], - budget: 10, - summary: 'Summary', - teamIds: [], - workPackageProposedChanges: [] - }; - - const cr = await ChangeRequestsService.createStandardChangeRequest( - user, - 12, - 13, - 14, - CR_Type.DEFINITION_CHANGE, - 'What', - [ - { - type: Scope_CR_Why_Type.COMPETITION, - explain: 'Explaining' - } - ], - [], - organization, - projPropChanges, - null - ); + const cr = await ChangeRequestsService.createStandardChangeRequest(user, 12, 13, 14, 'Explaining', organization); expect(cr.submitter.userId).toEqual(user.userId); expect(cr.wbsNum?.carNumber).toEqual(12); expect(cr.wbsNum?.projectNumber).toEqual(13); expect(cr.wbsNum?.workPackageNumber).toEqual(14); - expect(cr.type).toEqual(CR_Type.DEFINITION_CHANGE); - expect(cr.what).toEqual('What'); - expect(cr.proposedSolutions).toHaveLength(0); + expect(cr.why).toEqual('Explaining'); expect(cr.wbsNum).toBeDefined(); expect(cr.wbsNum).not.toBeNull(); @@ -111,35 +81,7 @@ describe('Change Request Tests', () => { } }); - const wpPropChanges: WorkPackageProposedChangesCreateArgs = { - name: 'wp', - descriptionBullets: [], - links: [], - duration: 3, - startDate: '2025-09-13', - blockedBy: [], - leadId: user.userId, - managerId: user.userId - }; - - await ChangeRequestsService.createStandardChangeRequest( - user, - 12, - 13, - 14, - CR_Type.DEFINITION_CHANGE, - 'What', - [ - { - type: Scope_CR_Why_Type.COMPETITION, - explain: 'Explaining' - } - ], - [], - organization, - null, - wpPropChanges - ); + await ChangeRequestsService.createStandardChangeRequest(user, 12, 13, 14, 'Explaining', organization); const wbsElement = await prisma.wBS_Element.findUnique({ where: { @@ -172,35 +114,7 @@ describe('Change Request Tests', () => { } }); - const wpPropChanges: WorkPackageProposedChangesCreateArgs = { - name: 'wp', - descriptionBullets: [], - links: [], - duration: 3, - startDate: '2025-09-13', - blockedBy: [], - leadId: user.userId, - managerId: user.userId - }; - - const cr = await ChangeRequestsService.createStandardChangeRequest( - user, - 12, - 13, - 14, - CR_Type.DEFINITION_CHANGE, - 'What', - [ - { - type: Scope_CR_Why_Type.COMPETITION, - explain: 'Explaining' - } - ], - [], - organization, - null, - wpPropChanges - ); + const cr = await ChangeRequestsService.createStandardChangeRequest(user, 12, 13, 14, 'Explaining', organization); const wbsElement = await prisma.wBS_Element.findUnique({ where: { @@ -217,9 +131,7 @@ describe('Change Request Tests', () => { expect(cr.wbsNum?.projectNumber).toEqual(13); expect(cr.wbsNum?.workPackageNumber).toEqual(14); - expect(cr.type).toEqual(CR_Type.DEFINITION_CHANGE); - expect(cr.what).toEqual('What'); - expect(cr.proposedSolutions).toHaveLength(0); + expect(cr.why).toEqual('Explaining'); expect(cr.wbsNum).toBeDefined(); expect(cr.wbsNum).not.toBeNull(); @@ -244,35 +156,7 @@ describe('Change Request Tests', () => { } }); - const wpPropChanges: WorkPackageProposedChangesCreateArgs = { - name: 'wp', - descriptionBullets: [], - links: [], - duration: 3, - startDate: '2025-09-13', - blockedBy: [], - leadId: user.userId, - managerId: user.userId - }; - - await ChangeRequestsService.createStandardChangeRequest( - user, - 12, - 13, - 14, - CR_Type.DEFINITION_CHANGE, - 'What', - [ - { - type: Scope_CR_Why_Type.COMPETITION, - explain: 'Explaining' - } - ], - [], - organization, - null, - wpPropChanges - ); + await ChangeRequestsService.createStandardChangeRequest(user, 12, 13, 14, 'Explaining', organization); const wbsElement = await prisma.wBS_Element.findUnique({ where: { @@ -334,20 +218,8 @@ describe('Change Request Tests', () => { 12, 13, 14, - CR_Type.ISSUE, - 'What is being changed', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'Why it is being changed' }], - [ - { - description: 'Proposed solution', - scopeImpact: 'Low impact', - timelineImpact: 0, - budgetImpact: 0 - } - ], - organization, - null, - null + 'Why it is being changed', + organization ); changeRequestId = cr.crId; @@ -359,8 +231,7 @@ describe('Change Request Tests', () => { changeRequestId, 'Looks good', false, - organization, - null + organization ); expect(reviewResult).toBe(changeRequestId); @@ -386,8 +257,7 @@ describe('Change Request Tests', () => { changeRequestId, 'Approved', false, - organization, - null + organization ); expect(reviewResult).toBe(changeRequestId); @@ -414,8 +284,7 @@ describe('Change Request Tests', () => { changeRequestId, 'I want to review this', true, - organization, - null + organization ) ).rejects.toThrow(AccessDeniedException); @@ -425,8 +294,7 @@ describe('Change Request Tests', () => { changeRequestId, 'I want to review this', true, - organization, - null + organization ) ).rejects.toThrow('Only requested reviewers can review this change request!'); }); @@ -444,8 +312,7 @@ describe('Change Request Tests', () => { changeRequestId, 'Approved by second reviewer', false, - organization, - null + organization ); expect(reviewResult).toBe(changeRequestId); @@ -488,8 +355,7 @@ describe('Change Request Tests', () => { changeRequestId, 'Rejecting this', false, - organization, - null + organization ) ).rejects.toThrow(AccessDeniedException); }); @@ -500,19 +366,6 @@ describe('Change Request Tests', () => { let carBId: string; let otherUser: User; - const solutionArgs = [{ description: 'Solution', scopeImpact: 'Low', timelineImpact: 0, budgetImpact: 0 }]; - - // projPropChanges makes a CR a scope CR - const projPropChanges = { - name: 'Updated project', - descriptionBullets: [], - links: [], - budget: 100, - summary: 'Summary', - teamIds: [], - workPackageProposedChanges: [] - }; - beforeEach(async () => { // The reviewing user (user) cannot be the submitter of scope CRs they review so otherUser is used . otherUser = await createTestUser(aquamanLeadership, orgId); @@ -530,32 +383,8 @@ describe('Change Request Tests', () => { describe('getAllChangeRequests', () => { it('respects the global car filter and returns only CRs for the selected car', async () => { - await ChangeRequestsService.createStandardChangeRequest( - user, - 0, - 1, - 0, - CR_Type.ISSUE, - 'CR on car A', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - solutionArgs, - organization, - null, - null - ); - await ChangeRequestsService.createStandardChangeRequest( - user, - 0, - 2, - 0, - CR_Type.ISSUE, - 'CR on car B', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - solutionArgs, - organization, - null, - null - ); + await ChangeRequestsService.createStandardChangeRequest(user, 0, 1, 0, 'reason', organization); + await ChangeRequestsService.createStandardChangeRequest(user, 0, 2, 0, 'reason', organization); const results = await ChangeRequestsService.getAllChangeRequests(organization, carAId); @@ -564,32 +393,8 @@ describe('Change Request Tests', () => { }); it('returns all CRs when no car is selected', async () => { - await ChangeRequestsService.createStandardChangeRequest( - user, - 0, - 1, - 0, - CR_Type.ISSUE, - 'CR on car A', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - solutionArgs, - organization, - null, - null - ); - await ChangeRequestsService.createStandardChangeRequest( - user, - 0, - 2, - 0, - CR_Type.ISSUE, - 'CR on car B', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - solutionArgs, - organization, - null, - null - ); + await ChangeRequestsService.createStandardChangeRequest(user, 0, 1, 0, 'reason', organization); + await ChangeRequestsService.createStandardChangeRequest(user, 0, 2, 0, 'reason', organization); const results = await ChangeRequestsService.getAllChangeRequests(organization); @@ -599,32 +404,8 @@ describe('Change Request Tests', () => { describe('getToReviewChangeRequests', () => { it('respects the global car filter and returns only to-review CRs for the selected car', async () => { - await ChangeRequestsService.createStandardChangeRequest( - otherUser, - 0, - 1, - 0, - CR_Type.DEFINITION_CHANGE, - 'Scope CR on car A', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - [], - organization, - projPropChanges, - null - ); - await ChangeRequestsService.createStandardChangeRequest( - otherUser, - 0, - 2, - 0, - CR_Type.DEFINITION_CHANGE, - 'Scope CR on car B', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - [], - organization, - projPropChanges, - null - ); + await ChangeRequestsService.createStandardChangeRequest(otherUser, 0, 1, 0, 'reason', organization); + await ChangeRequestsService.createStandardChangeRequest(otherUser, 0, 2, 0, 'reason', organization); const results = await ChangeRequestsService.getToReviewChangeRequests(user, organization, carAId); @@ -633,32 +414,8 @@ describe('Change Request Tests', () => { }); it('returns all to-review CRs when no car is selected', async () => { - await ChangeRequestsService.createStandardChangeRequest( - otherUser, - 0, - 1, - 0, - CR_Type.DEFINITION_CHANGE, - 'Scope CR on car A', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - [], - organization, - projPropChanges, - null - ); - await ChangeRequestsService.createStandardChangeRequest( - otherUser, - 0, - 2, - 0, - CR_Type.DEFINITION_CHANGE, - 'Scope CR on car B', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - [], - organization, - projPropChanges, - null - ); + await ChangeRequestsService.createStandardChangeRequest(otherUser, 0, 1, 0, 'reason', organization); + await ChangeRequestsService.createStandardChangeRequest(otherUser, 0, 2, 0, 'reason', organization); const results = await ChangeRequestsService.getToReviewChangeRequests(user, organization); @@ -668,32 +425,8 @@ describe('Change Request Tests', () => { describe('getUnreviewedChangeRequests', () => { it('respects the global car filter and returns only unreviewed CRs for the selected car', async () => { - await ChangeRequestsService.createStandardChangeRequest( - user, - 0, - 1, - 0, - CR_Type.ISSUE, - 'Unreviewed CR on car A', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - solutionArgs, - organization, - null, - null - ); - await ChangeRequestsService.createStandardChangeRequest( - user, - 0, - 2, - 0, - CR_Type.ISSUE, - 'Unreviewed CR on car B', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - solutionArgs, - organization, - null, - null - ); + await ChangeRequestsService.createStandardChangeRequest(user, 0, 1, 0, 'reason', organization); + await ChangeRequestsService.createStandardChangeRequest(user, 0, 2, 0, 'reason', organization); const results = await ChangeRequestsService.getUnreviewedChangeRequests(user, undefined, organization, carAId); @@ -702,32 +435,8 @@ describe('Change Request Tests', () => { }); it('ignores the global car filter when a wbsNum is provided and returns CRs matching the wbsNum', async () => { - await ChangeRequestsService.createStandardChangeRequest( - user, - 0, - 1, - 0, - CR_Type.ISSUE, - 'Unreviewed CR on car A', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - solutionArgs, - organization, - null, - null - ); - await ChangeRequestsService.createStandardChangeRequest( - user, - 0, - 2, - 0, - CR_Type.ISSUE, - 'Unreviewed CR on car B', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - solutionArgs, - organization, - null, - null - ); + await ChangeRequestsService.createStandardChangeRequest(user, 0, 1, 0, 'reason', organization); + await ChangeRequestsService.createStandardChangeRequest(user, 0, 2, 0, 'reason', organization); // wbsNum scopes to car A's project; carId points to car B - car filter should be ignored const wbsNum = { carNumber: 0, projectNumber: 1, workPackageNumber: 0 }; @@ -740,36 +449,12 @@ describe('Change Request Tests', () => { describe('getApprovedChangeRequests', () => { it('respects the global car filter and returns only recent CRs for the selected car', async () => { - const crA = await ChangeRequestsService.createStandardChangeRequest( - user, - 0, - 1, - 0, - CR_Type.ISSUE, - 'Recent CR on car A', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - solutionArgs, - organization, - null, - null - ); - const crB = await ChangeRequestsService.createStandardChangeRequest( - user, - 0, - 2, - 0, - CR_Type.ISSUE, - 'Recent CR on car B', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - solutionArgs, - organization, - null, - null - ); + const crA = await ChangeRequestsService.createStandardChangeRequest(user, 0, 1, 0, 'reason', organization); + const crB = await ChangeRequestsService.createStandardChangeRequest(user, 0, 2, 0, 'reason', organization); // getApprovedChangeRequests requires dateReviewed >= fiveDaysAgo - review both CRs to satisfy this - await ChangeRequestsService.reviewChangeRequest(otherUser, crA.crId, '', false, organization, null); - await ChangeRequestsService.reviewChangeRequest(otherUser, crB.crId, '', false, organization, null); + await ChangeRequestsService.reviewChangeRequest(otherUser, crA.crId, '', false, organization); + await ChangeRequestsService.reviewChangeRequest(otherUser, crB.crId, '', false, organization); const results = await ChangeRequestsService.getApprovedChangeRequests(user, undefined, organization, carAId); @@ -778,36 +463,12 @@ describe('Change Request Tests', () => { }, 15000); it('ignores the global car filter when a wbsNum is provided and returns CRs matching the wbsNum', async () => { - const crA = await ChangeRequestsService.createStandardChangeRequest( - user, - 0, - 1, - 0, - CR_Type.ISSUE, - 'Recent CR on car A', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - solutionArgs, - organization, - null, - null - ); - const crB = await ChangeRequestsService.createStandardChangeRequest( - user, - 0, - 2, - 0, - CR_Type.ISSUE, - 'Recent CR on car B', - [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], - solutionArgs, - organization, - null, - null - ); + const crA = await ChangeRequestsService.createStandardChangeRequest(user, 0, 1, 0, 'reason', organization); + const crB = await ChangeRequestsService.createStandardChangeRequest(user, 0, 2, 0, 'reason', organization); // getApprovedChangeRequests requires dateReviewed >= fiveDaysAgo - review both CRs to satisfy this - await ChangeRequestsService.reviewChangeRequest(otherUser, crA.crId, '', false, organization, null); - await ChangeRequestsService.reviewChangeRequest(otherUser, crB.crId, '', false, organization, null); + await ChangeRequestsService.reviewChangeRequest(otherUser, crA.crId, '', false, organization); + await ChangeRequestsService.reviewChangeRequest(otherUser, crB.crId, '', false, organization); // wbsNum scopes to car A's project; carId points to car B - car filter should be ignored const wbsNum = { carNumber: 0, projectNumber: 1, workPackageNumber: 0 }; diff --git a/src/frontend/src/hooks/change-requests.hooks.ts b/src/frontend/src/hooks/change-requests.hooks.ts index 8f45ad0c7d..479fa63c13 100644 --- a/src/frontend/src/hooks/change-requests.hooks.ts +++ b/src/frontend/src/hooks/change-requests.hooks.ts @@ -7,10 +7,7 @@ import { useMutation, useQuery, useQueryClient } from 'react-query'; import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; import { ChangeRequest, - ChangeRequestReason, - ChangeRequestType, ProjectProposedChangesCreateArgs, - ProposedSolutionCreateArgs, WbsNumber, WorkPackageProposedChangesCreateArgs, LeadershipChangeCreateArgs, @@ -154,10 +151,7 @@ export const useDeleteChangeRequest = () => { export type CreateStandardChangeRequestPayload = { wbsNum: WbsNumber; - type: Exclude; - what: string; - why: { explain: string; type: ChangeRequestReason }[]; - proposedSolutions: ProposedSolutionCreateArgs[]; + why: string; projectProposedChanges?: ProjectProposedChangesCreateArgs; workPackageProposedChanges?: WorkPackageProposedChangesCreateArgs; }; diff --git a/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.tsx index 0b4ba90a14..8909df89ab 100644 --- a/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.tsx +++ b/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.tsx @@ -19,7 +19,6 @@ import ImplementedChangesList from './ImplementedChangesList'; import StandardDetails from './StandardDetails'; import ReviewChangeRequest from './ReviewChangeRequest'; import ReviewNotes from './ReviewNotes'; -import ProposedSolutionsList from './ProposedSolutionsList'; import { Grid, Typography, Link, Box } from '@mui/material'; import DeleteChangeRequest from './DeleteChangeRequest'; import PageLayout from '../../components/PageLayout'; @@ -149,16 +148,8 @@ const ChangeRequestDetailsView: React.FC = ({ - {hasProposedChanges(changeRequest as StandardChangeRequest) ? ( + {hasProposedChanges(changeRequest as StandardChangeRequest) ?? ( - ) : ( - isStandard && ( - - ) )} diff --git a/src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionForm.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionForm.tsx deleted file mode 100644 index 0b5f7ce130..0000000000 --- a/src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionForm.tsx +++ /dev/null @@ -1,207 +0,0 @@ -/* - * This file is part of NER's FinishLine and licensed under GNU AGPLv3. - * See the LICENSE file in the repository root folder for details. - */ - -import { Box, Dialog, DialogContent, DialogTitle } from '@mui/material'; -import CloseIcon from '@mui/icons-material/Close'; -import NERSuccessButton from '../../components/NERSuccessButton'; -import * as yup from 'yup'; -import { Controller, useForm } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; -import { ProposedSolution, ProposedSolutionFormInput } from 'shared'; -import { TextField, Typography, IconButton } from '@mui/material'; -import { generateUUID } from '../../utils/form'; - -interface ProposedSolutionFormProps { - defaultValues?: ProposedSolution; - readOnly?: boolean; - onSubmit: (data: ProposedSolutionFormInput) => void; - open: boolean; - onClose: () => void; -} - -const schema = yup.object().shape({ - description: yup.string().required('Description is required'), - budgetImpact: yup - .number() - .typeError('Budget Impact must be a number') - .required('Budget Impact is required') - .integer('Budget Impact must be an integer'), - scopeImpact: yup.string().required('Scope Impact is required'), - timelineImpact: yup - .number() - .typeError('Timeline Impact must be a number') - .required('Timeline Impact is required') - .integer('Timeline Impact must be an integer'), - id: yup.string().required() -}); - -const ProposedSolutionForm: React.FC = ({ defaultValues, readOnly, onSubmit, open, onClose }) => { - const { formState, handleSubmit, control } = useForm({ - resolver: yupResolver(schema), - defaultValues: { - description: defaultValues?.description, - budgetImpact: defaultValues?.budgetImpact, - timelineImpact: defaultValues?.timelineImpact, - scopeImpact: defaultValues?.scopeImpact, - id: defaultValues ? defaultValues.id : generateUUID() - } - }); - - return ( - - {defaultValues ? 'Edit Proposed Solution' : 'Propose a Solution'} - theme.palette.grey[500] - }} - > - - - -
{ - e.preventDefault(); - e.stopPropagation(); - handleSubmit(onSubmit)(e); - }} - > - ( - <> - {'Description'} - - - )} - /> - ( - <> - {'Scope Impact'} - - - )} - /> - ( - <> - {'Budget Impact'} - - - )} - /> - ( - <> - {'Timeline Impact'} - - - )} - /> - {readOnly ? ( - '' - ) : ( - - - {defaultValues ? 'Save' : 'Add'} - - - )} - -
-
- ); -}; - -export default ProposedSolutionForm; diff --git a/src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionSelectItem.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionSelectItem.tsx deleted file mode 100644 index aeb5a52cd2..0000000000 --- a/src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionSelectItem.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/* - * This file is part of NER's FinishLine and licensed under GNU AGPLv3. - * See the LICENSE file in the repository root folder for details. - */ - -import { ProposedSolution } from 'shared'; -import { dollarsPipe, weeksPipe } from '../../utils/pipes'; -import { Box, Card, CardContent, Chip, Grid } from '@mui/material'; -import DetailDisplay from '../../components/DetailDisplay'; - -interface ProposedSolutionSelectItemProps { - proposedSolution: ProposedSolution; - selected: boolean; - onClick: () => void; -} - -const ProposedSolutionSelectItem: React.FC = ({ - proposedSolution, - selected, - onClick: setter -}) => { - const component = ( - - - - - - - - - - {selected ? ( - - ) : ( - - )} - - - - - - - - - - - - - - - - ); - - return selected ? component :
{component}
; -}; - -export default ProposedSolutionSelectItem; diff --git a/src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionView.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionView.tsx deleted file mode 100644 index 2a63f6fb1f..0000000000 --- a/src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionView.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/* - * This file is part of NER's FinishLine and licensed under GNU AGPLv3. - * See the LICENSE file in the repository root folder for details. - */ - -import { ProposedSolution } from 'shared'; -import Chip from '@mui/material/Chip'; -import Button from '@mui/material/Button'; -import Grid from '@mui/material/Grid'; -import { weeksPipe } from '../../utils/pipes'; -import DeleteIcon from '@mui/icons-material/Delete'; -import DetailDisplay from '../../components/DetailDisplay'; -import { Typography, useTheme } from '@mui/material'; -import AttachMoneyIcon from '@mui/icons-material/AttachMoney'; -import ScheduleIcon from '@mui/icons-material/Schedule'; -import { Edit } from '@mui/icons-material'; - -interface ProposedSolutionViewProps { - proposedSolution: ProposedSolution; - showDeleteButton?: boolean; - onDelete?: (proposedSolution: ProposedSolution) => void; - crReviewed?: boolean; - onEdit: (proposedSolution: ProposedSolution) => void; -} - -const ProposedSolutionView: React.FC = ({ - proposedSolution, - showDeleteButton, - onDelete, - crReviewed, - onEdit -}) => { - const faded = crReviewed != null && proposedSolution.approved === false; - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - - return ( - - - - - - - - - - - {proposedSolution.budgetImpact} - - - - - {weeksPipe(proposedSolution.timelineImpact)} - - {proposedSolution.approved && } - - - - - - {showDeleteButton && onDelete !== undefined && ( - - - - )} - - - - - - ); -}; - -export default ProposedSolutionView; diff --git a/src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionsList.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionsList.tsx deleted file mode 100644 index 95e7423867..0000000000 --- a/src/frontend/src/pages/ChangeRequestDetailPage/ProposedSolutionsList.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/* - * This file is part of NER's FinishLine and licensed under GNU AGPLv3. - * See the LICENSE file in the repository root folder for details. - */ - -import { ProposedSolution, ProposedSolutionFormInput, isGuest } from 'shared'; -import ProposedSolutionForm from './ProposedSolutionForm'; -import { useState } from 'react'; -import { useCreateProposeSolution } from '../../hooks/change-requests.hooks'; -import ErrorPage from '../ErrorPage'; -import LoadingIndicator from '../../components/LoadingIndicator'; -import { Box, Button, Chip, Grid, Typography, useTheme } from '@mui/material'; -import { useToast } from '../../hooks/toasts.hooks'; -import DetailDisplay from '../../components/DetailDisplay'; -import { weeksPipe } from '../../utils/pipes'; -import AttachMoneyIcon from '@mui/icons-material/AttachMoney'; -import ScheduleIcon from '@mui/icons-material/Schedule'; -import { useCurrentUser } from '../../hooks/users.hooks'; - -interface ProposedSolutionsListProps { - proposedSolutions: ProposedSolution[]; - crReviewed?: boolean; - crId: string; -} - -const ProposedSolutionsList: React.FC = ({ proposedSolutions, crReviewed, crId }) => { - const [showEditableForm, setShowEditableForm] = useState(false); - const user = useCurrentUser(); - const { isLoading, isError, error, mutateAsync } = useCreateProposeSolution(); - const toast = useToast(); - const theme = useTheme(); - const isDark = theme.palette.mode === 'dark'; - - if (isLoading) return ; - if (isError) return ; - - const { userId } = user; - - const addProposedSolution = async (data: ProposedSolutionFormInput) => { - setShowEditableForm(false); - const { description, timelineImpact, scopeImpact, budgetImpact } = data; - try { - // send the details of new proposed solution to the backend database - await mutateAsync({ - submitterId: userId, - crId, - description, - scopeImpact, - timelineImpact, - budgetImpact - }); - } catch (e) { - if (e instanceof Error) { - toast.error(e.message); - } - } - }; - - return ( - - {showEditableForm ? ( - setShowEditableForm(false)} - /> - ) : null} - - Proposed Solutions - {crReviewed === undefined && !isGuest(user.role) && ( - - )} - - {proposedSolutions.map((proposedSolution) => ( - - - - - - - - - - - {proposedSolution.budgetImpact} - - - - - {weeksPipe(proposedSolution.timelineImpact)} - - {proposedSolution.approved && } - - - - ))} - - ); -}; - -export default ProposedSolutionsList; diff --git a/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequestView.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequestView.tsx index 009bafbf0c..7edf058266 100644 --- a/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequestView.tsx +++ b/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequestView.tsx @@ -7,26 +7,22 @@ import { Controller, useForm } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import * as yup from 'yup'; import { FormInput } from './ReviewChangeRequest'; -import { ChangeRequest, ProposedSolution, StandardChangeRequest, WorkPackage } from 'shared'; +import { ChangeRequest, WorkPackage } from 'shared'; import { useState } from 'react'; -import ProposedSolutionSelectItem from './ProposedSolutionSelectItem'; import { Dialog, DialogActions, DialogContent, DialogTitle, - Box, TextField, Typography, Breakpoint, IconButton } from '@mui/material'; -import { useToast } from '../../hooks/toasts.hooks'; import NERSuccessButton from '../../components/NERSuccessButton'; import NERFailButton from '../../components/NERFailButton'; import CloseIcon from '@mui/icons-material/Close'; import ChangeRequestBlockerWarning from '../../components/ChangeRequestBlockerWarning'; -import { hasProposedChanges } from '../../utils/change-request.utils'; interface ReviewChangeRequestViewProps { cr: ChangeRequest; @@ -49,9 +45,7 @@ const ReviewChangeRequestsView: React.FC = ({ onSubmit, blockingWorkPackages }: ReviewChangeRequestViewProps) => { - const [selected, setSelected] = useState(-1); - const [selectedTimelineImpact, setSelectedTimelineImpact] = useState(-1); - const toast = useToast(); + const [selectedTimelineImpact] = useState(-1); const [showWarning, setShowWarning] = useState(false); const { register, setValue, getFieldState, reset, handleSubmit, control, getValues } = useForm({ resolver: yupResolver(schema) @@ -63,9 +57,6 @@ const ReviewChangeRequestsView: React.FC = ({ */ const handleAcceptDeny = (value: boolean) => { getFieldState('accepted') ? setValue('accepted', value) : register('accepted', { value }); - if (selected !== -1) { - setValue('psId', (cr as StandardChangeRequest).proposedSolutions[selected].id); - } }; /** @@ -76,149 +67,7 @@ const ReviewChangeRequestsView: React.FC = ({ reset({ reviewNotes: '' }); }; - const handleShowWarning = (data: FormInput) => { - if (selected === -1) { - onSubmitWrapper(data); - return; - } - const standardChangeRequest = cr as StandardChangeRequest; - const selectedProposedSolution = standardChangeRequest.proposedSolutions.find((ps) => ps.id === data.psId)!; - if ( - data.accepted && - selectedProposedSolution.timelineImpact > 0 && - blockingWorkPackages && - blockingWorkPackages.length > 0 - ) { - setSelectedTimelineImpact(selectedProposedSolution.timelineImpact); - setShowWarning(true); - } else { - onSubmitWrapper(data); - } - }; - - const overflowStyle: object = { - overflowY: 'scroll', - maxHeight: '300px' - }; - - const proposedSolutionStyle = { - cursor: 'pointer', - width: 'auto', - margin: 'auto', - display: 'block' - }; - const dialogWidth: Breakpoint = 'md'; - const dialogContentWidthRatio: number = 1; // dialog contents fit 100% width - - const renderProposedSolutionModal: (scr: StandardChangeRequest) => JSX.Element = ( - standardChangeRequest: StandardChangeRequest - ) => { - return ( - - theme.palette.grey[500] - }} - > - - - {`Review Change Request #${cr.identifier}`} - - {!hasProposedChanges(standardChangeRequest) && ( - {'Select Proposed Solution'} - )} - - {standardChangeRequest.proposedSolutions.map((solution: ProposedSolution, i: number) => { - return ( -
- (selected === i ? setSelected(-1) : setSelected(i))} - /> -
- ); - })} -
-
- ( - <> - - {'Additional Comments'} - - - - )} - /> - -
- - handleAcceptDeny(false)} - > - Deny - - { - selected > -1 || hasProposedChanges(standardChangeRequest) - ? handleAcceptDeny(true) - : toast.error('Please select a proposed solution!', 4500); - }} - > - Accept - - -
- ); - }; const renderModal: () => JSX.Element = () => { return ( @@ -286,9 +135,7 @@ const ReviewChangeRequestsView: React.FC = ({ return ( <> - {cr.type === 'ISSUE' || cr.type === 'DEFINITION_CHANGE' || cr.type === 'OTHER' - ? renderProposedSolutionModal(cr as StandardChangeRequest) - : renderModal()} + {renderModal()} { = ({ cr }: StandardDetailsProps) => { return ( - - {cr.what} - - - - - {cr.why.map((ele: ChangeRequestExplanation, idx: number) => ( - - {ele.type + ' '} - {' - '} - {ele.explain} - - ))} + + {cr.why} diff --git a/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequest.tsx b/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequest.tsx index 621dde59e8..6c995cdd08 100644 --- a/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequest.tsx +++ b/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequest.tsx @@ -2,9 +2,8 @@ * This file is part of NER's FinishLine and licensed under GNU AGPLv3. * See the LICENSE file in the repository root folder for details. */ -import { wbsTester } from '../../utils/form'; import { useHistory } from 'react-router-dom'; -import { ChangeRequestReason, ChangeRequestType, ProposedSolutionFormInput, validateWBS } from 'shared'; +import { validateWBS } from 'shared'; import { useCreateStandardChangeRequest } from '../../hooks/change-requests.hooks'; import { useQuery } from '../../hooks/utils.hooks'; import { routes } from '../../utils/routes'; @@ -16,7 +15,6 @@ import { useToast } from '../../hooks/toasts.hooks'; import { useForm } from 'react-hook-form'; import { FormInput } from './CreateChangeRequestView'; import * as yup from 'yup'; -import { StandardChangeRequestType } from './CreateChangeRequestView'; import { yupResolver } from '@hookform/resolvers/yup'; interface CreateChangeRequestProps {} @@ -25,76 +23,28 @@ const CreateChangeRequest: React.FC = () => { const query = useQuery(); const history = useHistory(); const { isLoading, isError, error, mutateAsync } = useCreateStandardChangeRequest(); - const defaultProposedSolution = query.get('budgetChange') - ? [ - { - id: '', - description: 'Increase Budget', - budgetImpact: Number(query.get('budgetChange')), - timelineImpact: 0, - scopeImpact: 'No Changes' - } - ] - : query.get('timelineDelay') - ? [ - { - id: '', - description: 'Timeline Delay', - budgetImpact: 0, - timelineImpact: Number(query.get('timelineDelay')), - scopeImpact: 'No Changes' - } - ] - : []; - const [proposedSolutions, setProposedSolutions] = useState(defaultProposedSolution); const [wbsNum, setWbsNum] = useState(query.get('wbsNum') || ''); const toast = useToast(); const changeRequestSchema = yup.object().shape({ - type: yup.mixed().required('Type is required'), - what: yup.string().required('What is required'), - why: yup - .array() - .min(1, 'At least one Why is required') - .required('Why is required') - .of( - yup.object().shape({ - type: yup.mixed().required('Why Type is required'), - explain: yup - .string() - .required('Why Explain is required') - .when('type', ([type], schema) => - type === ChangeRequestReason.OtherProject - ? schema.required().test('wbs-num-valid', 'WBS Number is not valid', wbsTester) - : yup.string() - ) - }) - ) + why: yup.string().required('Why Explain is required') }); const { reset: resetChangeRequestForm, ...changeRequestFormMethods } = useForm({ resolver: yupResolver(changeRequestSchema), defaultValues: query.get('budgetChange') ? { - what: 'Increase the budget to account for the cost of materials', - why: [{ type: ChangeRequestReason.Other, explain: 'The cost of materials ended up exceeding the initial budget' }], - type: ChangeRequestType.Issue + why: 'The cost of materials ended up exceeding the initial budget' } : query.get('timelineDelay') ? { - what: 'Timeline delay', - why: [{ type: ChangeRequestReason.Other, explain: 'Decided to extend timeline after design review' }], - type: ChangeRequestType.Redefinition + why: 'Decided to extend timeline after design review' } : query.get('createWP') ? { - what: '', - why: [{ type: ChangeRequestReason.Initialization, explain: 'Creating a Work Package on this Project' }], - type: ChangeRequestType.Redefinition + why: 'Creating a Work Package on this Project' } : { - what: '', - why: [{ type: ChangeRequestReason.Other, explain: '' }], - type: ChangeRequestType.Issue + why: '' } }); @@ -102,20 +52,17 @@ const CreateChangeRequest: React.FC = () => { if (isError) return ; const handleConfirm = async (data: FormInput) => { - const requestHasASolution: boolean = proposedSolutions.length !== 0; try { - if (!requestHasASolution) throw new Error('You must have at least one proposed solution added!'); await mutateAsync({ ...data, - wbsNum: validateWBS(wbsNum), - proposedSolutions + wbsNum: validateWBS(wbsNum) }); } catch (e) { if (e instanceof Error) { toast.error(e.message); } } finally { - if (requestHasASolution) history.push(`${routes.PROJECTS}/${wbsNum}/change-requests`); + history.push(`${routes.PROJECTS}/${wbsNum}/change-requests`); } }; @@ -128,8 +75,6 @@ const CreateChangeRequest: React.FC = () => { wbsNum={wbsNum} setWbsNum={setWbsNum} onSubmit={handleConfirm} - proposedSolutions={proposedSolutions} - setProposedSolutions={setProposedSolutions} handleCancel={handleCancel} changeRequestFormReturn={changeRequestFormMethods} /> diff --git a/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestModal.tsx b/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestModal.tsx index 8c88405c74..ff966bdff8 100644 --- a/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestModal.tsx +++ b/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestModal.tsx @@ -24,8 +24,6 @@ const CreateChangeRequestModal: React.FC = ({ wbsNum={wbsNum} setWbsNum={() => {}} onSubmit={onConfirm} - proposedSolutions={[]} - setProposedSolutions={() => {}} modalView handleCancel={onHide} changeRequestFormReturn={changeRequestFormReturn} diff --git a/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestView.tsx b/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestView.tsx index b478083b98..277126b55c 100644 --- a/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestView.tsx +++ b/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestView.tsx @@ -2,35 +2,12 @@ * This file is part of NER's FinishLine and licensed under GNU AGPLv3. * See the LICENSE file in the repository root folder for details. */ -import { Controller, useFieldArray } from 'react-hook-form'; -import { - ChangeRequestReason, - ChangeRequestType, - ProjectPreview, - ProposedSolutionFormInput, - WbsElementPreview, - wbsNamePipe, - wbsPipe -} from 'shared'; +import { ProjectPreview, WbsElementPreview, wbsNamePipe, wbsPipe } from 'shared'; import { routes } from '../../utils/routes'; -import TextField from '@mui/material/TextField'; -import FormHelperText from '@mui/material/FormHelperText'; import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import DeleteIcon from '@mui/icons-material/Delete'; import Grid from '@mui/material/Grid'; -import CreateProposedSolutionsList from './CreateProposedSolutionsList'; +import { FormControl, FormLabel } from '@mui/material'; import ReactHookTextField from '../../components/ReactHookTextField'; -import { - Autocomplete, - AutocompleteRenderInputParams, - FormControl, - FormLabel, - IconButton, - MenuItem, - RadioGroup, - Select -} from '@mui/material'; import NERAutocomplete from '../../components/NERAutocomplete'; import { useAllProjects } from '../../hooks/projects.hooks'; import ErrorPage from '../ErrorPage'; @@ -39,16 +16,11 @@ import NERFailButton from '../../components/NERFailButton'; import NERSuccessButton from '../../components/NERSuccessButton'; import PageLayout from '../../components/PageLayout'; import { wbsNumComparator } from 'shared'; -import { ChangeEvent } from 'react'; -import { NERButton } from '../../components/NERButton'; import { UseFormRegister, UseFormHandleSubmit, UseFormWatch, UseFormSetValue, FormState, Control } from 'react-hook-form'; -export type StandardChangeRequestType = Exclude; - export interface FormInput { - type: StandardChangeRequestType; - what: string; - why: { type: ChangeRequestReason; explain: string }[]; + why: string; + requestedReviewerId?: string; } export interface ChangeRequestFormReturn { @@ -64,8 +36,6 @@ interface CreateChangeRequestViewProps { wbsNum: string; setWbsNum: (val: string) => void; onSubmit: (data: FormInput) => Promise; - proposedSolutions: ProposedSolutionFormInput[]; - setProposedSolutions: (ps: ProposedSolutionFormInput[]) => void; handleCancel: () => void; modalView?: boolean; changeRequestFormReturn: ChangeRequestFormReturn; @@ -75,8 +45,6 @@ const CreateChangeRequestsView: React.FC = ({ wbsNum, setWbsNum, onSubmit, - proposedSolutions, - setProposedSolutions, handleCancel, modalView = false, changeRequestFormReturn @@ -84,28 +52,14 @@ const CreateChangeRequestsView: React.FC = ({ const { handleSubmit, control, - formState: { errors }, - register, - watch + formState: { errors } } = changeRequestFormReturn; - const { fields: whys, append: appendWhy, remove: removeWhy } = useFieldArray({ control, name: 'why' }); - const { isLoading, isError, error, data: projects } = useAllProjects(); - const permittedTypes = Object.values(ChangeRequestType).filter( - (t) => - t !== ChangeRequestType.Activation && - t !== ChangeRequestType.StageGate && - t !== ChangeRequestType.Budget && - t !== ChangeRequestType.Leadership - ); - if (isLoading || !projects) return ; if (isError) return ; - const projectOptions: { label: string; id: string }[] = []; - const wbsDropdownOptions: { label: string; id: string }[] = []; projects.forEach((project: ProjectPreview) => { @@ -113,10 +67,6 @@ const CreateChangeRequestsView: React.FC = ({ label: `${wbsNamePipe(project)}`, id: wbsPipe(project.wbsNum) }); - projectOptions.push({ - label: `${wbsNamePipe(project)}`, - id: wbsPipe(project.wbsNum) - }); project.workPackages.forEach((workPackage: WbsElementPreview) => { wbsDropdownOptions.push({ label: `${wbsNamePipe({ @@ -142,40 +92,6 @@ const CreateChangeRequestsView: React.FC = ({ } }; - const renderReasonInput = (index: number) => { - const typeValue = watch(`why.${index}.type`); - return typeValue === `${ChangeRequestReason.OtherProject}` ? ( - ( - option.id === value.id} - options={projectOptions} - size="small" - value={projectOptions.find((element) => element.id === value)} - sx={{ mx: 1, flex: 1, '.MuiInputBase-input': { height: '39px' } }} - renderInput={(params: AutocompleteRenderInputParams) => } - onChange={(_event, value) => (value ? onChange(value?.id) : null)} - /> - )} - /> - ) : ( - - ); - }; - return (
= ({ } > - + {!modalView && ( @@ -220,100 +136,32 @@ const CreateChangeRequestsView: React.FC = ({ /> )} - - - Type - ( - - - {permittedTypes.map((t) => ( - onChange(t as 'ISSUE' | ChangeEvent)} - > - {t} - - ))} - - - )} - /> - - - What needs to be changed? + Why are you making this change? - Why does this need to be changed? - - {whys.map((element, index) => ( - - - - removeWhy(index)}> - - - - - {renderReasonInput(index)} - - - ))} - - {errors.why?.message} + Requested Reviewer (optional) + - - - - {!modalView && ( - - - - )} diff --git a/src/frontend/src/pages/CreateChangeRequestPage/CreateProposedSolutionsList.tsx b/src/frontend/src/pages/CreateChangeRequestPage/CreateProposedSolutionsList.tsx deleted file mode 100644 index cce11b0a1d..0000000000 --- a/src/frontend/src/pages/CreateChangeRequestPage/CreateProposedSolutionsList.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* - * This file is part of NER's FinishLine and licensed under GNU AGPLv3. - * See the LICENSE file in the repository root folder for details. - */ - -import { isGuest, ProposedSolution, ProposedSolutionFormInput } from 'shared'; -import ProposedSolutionForm from '../ChangeRequestDetailPage/ProposedSolutionForm'; -import { useEffect, useState } from 'react'; -import ProposedSolutionView from '../ChangeRequestDetailPage/ProposedSolutionView'; -import { Button, Typography } from '@mui/material'; -import { useCurrentUser } from '../../hooks/users.hooks'; -import { Box } from '@mui/system'; - -interface CreateProposedSolutionsListProps { - proposedSolutions: ProposedSolutionFormInput[]; - setProposedSolutions: (ps: ProposedSolutionFormInput[]) => void; -} - -const CreateProposedSolutionsList: React.FC = ({ - proposedSolutions, - setProposedSolutions -}) => { - const user = useCurrentUser(); - const [showCreateForm, setShowCreateForm] = useState(false); - const [showEditForm, setShowEditForm] = useState(false); - const [editingProposedSolution, setEditingProposedSolution] = useState(); - - useEffect(() => { - setShowEditForm(!!editingProposedSolution); - }, [editingProposedSolution]); - - const addProposedSolution = (data: ProposedSolutionFormInput) => { - setProposedSolutions([...proposedSolutions, data]); - setShowCreateForm(false); - }; - - const editProposedSolution = (data: ProposedSolutionFormInput) => { - setProposedSolutions( - proposedSolutions.map((proposedSolution) => (proposedSolution.id === data.id ? data : proposedSolution)) - ); - setShowEditForm(false); - }; - - const removeProposedSolution = (data: ProposedSolution) => { - setProposedSolutions(proposedSolutions.filter((proposedSolution) => proposedSolution !== data)); - }; - - return ( - <> - {!isGuest(user.role) && ( - - - Proposed Solutions - - - - )} -
- {proposedSolutions.map((proposedSolution, i) => ( - { - setEditingProposedSolution(proposedSolution); - }} - /> - ))} -
- {showCreateForm && ( - setShowCreateForm(false)} - /> - )} - {showEditForm && ( - setEditingProposedSolution(undefined)} - defaultValues={editingProposedSolution} - /> - )} - - ); -}; - -export default CreateProposedSolutionsList; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/EditProjectBudgetModal.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/EditProjectBudgetModal.tsx index f4d271d27b..c0afeeb537 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/EditProjectBudgetModal.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/EditProjectBudgetModal.tsx @@ -9,7 +9,7 @@ import { useGetAllIndexCodes } from '../../../hooks/finance.hooks'; import LoadingIndicator from '../../../components/LoadingIndicator'; import ErrorPage from '../../ErrorPage'; import { CreateStandardChangeRequestPayload, useCreateStandardChangeRequest } from '../../../hooks/change-requests.hooks'; -import { ChangeRequestType, Project } from 'shared'; +import { Project } from 'shared'; import { useGetTeamsProjects } from '../../../hooks/projects.hooks'; const schema = yup.object().shape({ @@ -95,10 +95,7 @@ export const EditProjectBudgetModal: React.FC = ({ const payload: CreateStandardChangeRequestPayload = { wbsNum: currentProject.wbsNum, - type: ChangeRequestType.Other, - what: 'project', - why: [], - proposedSolutions: [], + why: '', projectProposedChanges: { leadId: currentProject.lead?.userId, managerId: currentProject.manager?.userId, diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx index 995e8d1f37..be9b6641d8 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx @@ -1,15 +1,5 @@ -import { Box, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent, TextField, Typography } from '@mui/material'; -import { - ChangeRequestReason, - ChangeRequestType, - dateToMidnightUTC, - Link, - LinkCreateArgs, - ProjectGantt, - Task, - WbsElementPreview, - WorkPackage -} from 'shared'; +import { Box, FormControl, InputLabel, TextField, Typography } from '@mui/material'; +import { dateToMidnightUTC, Link, LinkCreateArgs, ProjectGantt, Task, WbsElementPreview, WorkPackage } from 'shared'; import { useState } from 'react'; import dayjs from 'dayjs'; import { CreateStandardChangeRequestPayload, useCreateStandardChangeRequest } from '../../../../hooks/change-requests.hooks'; @@ -26,7 +16,6 @@ interface GanttTimeLineChangeModalProps extends GanttRequestChangeModalProps {} export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTimeLineChangeModalProps) => { const toast = useToast(); - const [reasonForChange, setReasonForChange] = useState(ChangeRequestReason.Estimation); const [explanationForChange, setExplanationForChange] = useState(''); const { data: originalProject, @@ -50,10 +39,6 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim return ; if (originalProjectIsError) return ; - const handleReasonChange = (event: SelectChangeEvent) => { - setReasonForChange(event.target.value as ChangeRequestReason); - }; - const handleExplanationChange = (event: React.ChangeEvent) => { setExplanationForChange(event.target.value); }; @@ -62,21 +47,6 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim return `${dayjs(startDate).format('MMMM D, YYYY')} - ${dayjs(endDate).format('MMMM D, YYYY')}`; }; - const createWhatMessage = (editedWorkPackages: WorkPackage[]): string => { - return ( - 'Adjusted Timelines for WorkPackages: \n' + - editedWorkPackages - .map( - (workPackage) => - `- ${workPackage.name}: ${changeInTimeline(workPackage.startDate, workPackage.endDate)} To: ${changeInTimeline( - change.newStart, - change.newEnd - )}` - ) - .join('\n') - ); - }; - const transformLinkToLinkCreateArgs = (link: Link): LinkCreateArgs => { return { linkId: link.linkId, @@ -128,7 +98,7 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim }); const handleSubmit = async () => { - if (editedWorkPackages.length > 0 && !reasonForChange) { + if (editedWorkPackages.length > 0) { return; } @@ -136,15 +106,7 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim if (editedWorkPackages.length > 0) { const payload: CreateStandardChangeRequestPayload = { wbsNum: change.element.wbsNum, - type: ChangeRequestType.Issue, - what: createWhatMessage(editedWorkPackages), - why: [ - { - explain: explanationForChange, - type: reasonForChange - } - ], - proposedSolutions: [], + why: explanationForChange, projectProposedChanges: { workPackageProposedChanges: editedWorkPackages.map((workPackage) => { const duration = dayjs(workPackage.endDate).diff(dayjs(workPackage.startDate), 'week'); @@ -246,7 +208,7 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim 0 && (!reasonForChange || !explanationForChange)} + disableSuccessButton={editedWorkPackages.length > 0 && !explanationForChange} handleSubmit={handleSubmit} onHide={() => handleClose(true)} > @@ -262,11 +224,6 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim Reason for Change - { const toast = useToast(); @@ -71,51 +67,25 @@ const ProjectCreateContainer: React.FC = () => { }); const changeRequestSchema = yup.object().shape({ - type: yup.mixed().required('Type is required'), - what: yup.string().required('What is required'), - why: yup - .array() - .min(1, 'At least one Why is required') - .required('Why is required') - .of( - yup.object().shape({ - type: yup.mixed().required('Why Type is required'), - explain: yup - .string() - .required('Why Explain is required') - .when('type', ([type], schema) => - type === ChangeRequestReason.OtherProject - ? schema.required().test('wbs-num-valid', 'WBS Number is not valid', wbsTester) - : yup.string() - ) - }) - ) + why: yup.string().required('Why Explain is required') }); const { reset: resetChangeRequestForm, ...changeRequestFormMethods } = useForm({ resolver: yupResolver(changeRequestSchema), defaultValues: query.get('budgetChange') ? { - what: 'Increase the budget to account for the cost of materials', - why: [{ type: ChangeRequestReason.Other, explain: 'The cost of materials ended up exceeding the initial budget' }], - type: ChangeRequestType.Issue + why: 'The cost of materials ended up exceeding the initial budget' } : query.get('timelineDelay') ? { - what: 'Timeline delay', - why: [{ type: ChangeRequestReason.Other, explain: 'Decided to extend timeline after design review' }], - type: ChangeRequestType.Redefinition + why: 'Decided to extend timeline after design review' } : query.get('createWP') ? { - what: '', - why: [{ type: ChangeRequestReason.Initialization, explain: 'Creating a Work Package on this Project' }], - type: ChangeRequestType.Redefinition + why: 'Creating a Work Package on this Project' } : { - what: '', - why: [{ type: ChangeRequestReason.Other, explain: '' }], - type: ChangeRequestType.Issue + why: '' } }); @@ -140,7 +110,7 @@ const ProjectCreateContainer: React.FC = () => { const requiredLinkTypeNames = getRequiredLinkTypeNames(allLinkTypes); const onSubmitChangeRequest = async (data: ProjectCreateChangeRequestFormInput) => { - const { name, budget, summary, links, teamIds, carNumber, descriptionBullets, type, what, why } = data; + const { name, budget, summary, links, teamIds, carNumber, descriptionBullets, why } = data; // Car number could be zero and a truthy check would fail if (carNumber === undefined) throw new Error('Car number is required!'); @@ -160,10 +130,7 @@ const ProjectCreateContainer: React.FC = () => { }; const changeRequestPayload: CreateStandardChangeRequestPayload = { wbsNum: { carNumber, projectNumber: 0, workPackageNumber: 0 }, - type, - what, why, - proposedSolutions: [], projectProposedChanges: projectPayload }; await mutateCRAsync(changeRequestPayload); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx index 65c1c43731..8f70d6dd33 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx @@ -2,9 +2,9 @@ * This file is part of NER's FinishLine and licensed under GNU AGPLv3. * See the LICENSE file in the repository root folder for details. */ -import { ChangeRequestReason, ChangeRequestType, Project, ProjectProposedChangesCreateArgs } from 'shared'; +import { Project, ProjectProposedChangesCreateArgs } from 'shared'; import { useAllLinkTypes, useEditSingleProject } from '../../../hooks/projects.hooks'; -import { bulletsToObject, wbsTester } from '../../../utils/form'; +import { bulletsToObject } from '../../../utils/form'; import { useToast } from '../../../hooks/toasts.hooks'; import { EditSingleProjectPayload } from '../../../utils/types'; import { useState } from 'react'; @@ -14,7 +14,6 @@ import ErrorPage from '../../ErrorPage'; import { getRequiredLinkTypeNames } from '../../../utils/link.utils'; import { useQuery } from '../../../hooks/utils.hooks'; import * as yup from 'yup'; -import { StandardChangeRequestType } from '../../CreateChangeRequestPage/CreateChangeRequestView'; import { FormInput, FormInput as ChangeRequestFormInput } from '../../CreateChangeRequestPage/CreateChangeRequestView'; import { CreateStandardChangeRequestPayload, @@ -68,51 +67,25 @@ const ProjectEditContainer: React.FC = ({ project, ex }); const changeRequestSchema = yup.object().shape({ - type: yup.mixed().required('Type is required'), - what: yup.string().required('What is required'), - why: yup - .array() - .min(1, 'At least one Why is required') - .required('Why is required') - .of( - yup.object().shape({ - type: yup.mixed().required('Why Type is required'), - explain: yup - .string() - .required('Why Explain is required') - .when('type', ([type], schema) => - type === ChangeRequestReason.OtherProject - ? schema.required().test('wbs-num-valid', 'WBS Number is not valid', wbsTester) - : yup.string() - ) - }) - ) + why: yup.string().required('Why Explain is required') }); const { reset: resetChangeRequestForm, ...changeRequestFormMethods } = useForm({ resolver: yupResolver(changeRequestSchema), defaultValues: query.get('budgetChange') ? { - what: 'Increase the budget to account for the cost of materials', - why: [{ type: ChangeRequestReason.Other, explain: 'The cost of materials ended up exceeding the initial budget' }], - type: ChangeRequestType.Issue + why: 'The cost of materials ended up exceeding the initial budget' } : query.get('timelineDelay') ? { - what: 'Timeline delay', - why: [{ type: ChangeRequestReason.Other, explain: 'Decided to extend timeline after design review' }], - type: ChangeRequestType.Redefinition + why: 'Decided to extend timeline after design review' } : query.get('createWP') ? { - what: '', - why: [{ type: ChangeRequestReason.Initialization, explain: 'Creating a Work Package on this Project' }], - type: ChangeRequestType.Redefinition + why: 'Creating a Work Package on this Project' } : { - what: '', - why: [{ type: ChangeRequestReason.Other, explain: '' }], - type: ChangeRequestType.Issue + why: '' } }); @@ -193,7 +166,7 @@ const ProjectEditContainer: React.FC = ({ project, ex }; const onSubmitChangeRequest = async (data: ProjectCreateChangeRequestFormInput) => { - const { name, budget, summary, links, type, what, why, descriptionBullets } = data; + const { name, budget, summary, links, why, descriptionBullets } = data; try { const projectPayload: ProjectProposedChangesCreateArgs = { @@ -209,10 +182,7 @@ const ProjectEditContainer: React.FC = ({ project, ex }; const changeRequestPayload: CreateStandardChangeRequestPayload = { wbsNum: project.wbsNum, - type, - what, why, - proposedSolutions: [], projectProposedChanges: projectPayload }; await mutateCRAsync(changeRequestPayload); diff --git a/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx b/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx index eb148e43d1..8282dcafac 100644 --- a/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx +++ b/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx @@ -4,8 +4,6 @@ */ import { - ChangeRequestReason, - ChangeRequestType, DescriptionBulletPreview, User, validateWBS, @@ -41,7 +39,6 @@ import { ObjectSchema } from 'yup'; import { getMonday } from '../../utils/datetime.utils'; import { toDateString } from 'shared'; import { CreateStandardChangeRequestPayload } from '../../hooks/change-requests.hooks'; -import { StandardChangeRequestType } from '../CreateChangeRequestPage/CreateChangeRequestView'; import { FormInput } from '../CreateChangeRequestPage/CreateChangeRequestView'; import { useHistory } from 'react-router-dom'; import { routes } from '../../utils/routes'; @@ -54,7 +51,6 @@ import LoadingIndicator from '../../components/LoadingIndicator'; import ErrorPage from '../ErrorPage'; import { WorkPackageTemplateSection } from './WorkPackageTemplateSection'; import { useQuery } from '../../hooks/utils.hooks'; -import { wbsTester } from '../../utils/form'; import * as yup from 'yup'; import CreateChangeRequestModal from '../CreateChangeRequestPage/CreateChangeRequestModal'; import { useQueryClient } from 'react-query'; @@ -166,51 +162,25 @@ const WorkPackageFormView: React.FC = ({ const watchedDescriptionBullets = watch('descriptionBullets'); const changeRequestSchema = yup.object().shape({ - type: yup.mixed().required('Type is required'), - what: yup.string().required('What is required'), - why: yup - .array() - .min(1, 'At least one Why is required') - .required('Why is required') - .of( - yup.object().shape({ - type: yup.mixed().required('Why Type is required'), - explain: yup - .string() - .required('Why Explain is required') - .when('type', ([type], schema) => - type === ChangeRequestReason.OtherProject - ? schema.required().test('wbs-num-valid', 'WBS Number is not valid', wbsTester) - : yup.string() - ) - }) - ) + why: yup.string().required('Why Explain is required') }); const { reset: resetChangeRequestForm, ...changeRequestFormMethods } = useForm({ resolver: yupResolver(changeRequestSchema), defaultValues: query.get('budgetChange') ? { - what: 'Increase the budget to account for the cost of materials', - why: [{ type: ChangeRequestReason.Other, explain: 'The cost of materials ended up exceeding the initial budget' }], - type: ChangeRequestType.Issue + why: 'The cost of materials ended up exceeding the initial budget' } : query.get('timelineDelay') ? { - what: 'Timeline delay', - why: [{ type: ChangeRequestReason.Other, explain: 'Decided to extend timeline after design review' }], - type: ChangeRequestType.Redefinition + why: 'Decided to extend timeline after design review' } : query.get('createWP') ? { - what: '', - why: [{ type: ChangeRequestReason.Initialization, explain: 'Creating a Work Package on this Project' }], - type: ChangeRequestType.Redefinition + why: 'Creating a Work Package on this Project' } : { - what: '', - why: [{ type: ChangeRequestReason.Other, explain: '' }], - type: ChangeRequestType.Issue + why: '' } }); @@ -301,8 +271,7 @@ const WorkPackageFormView: React.FC = ({ wbsNum: wbsElement.wbsNum, workPackageProposedChanges: { ...payload - }, - proposedSolutions: [] + } }); history.push(`${routes.PROJECTS}/${wbsPipe(wbsElement.wbsNum)}/change-requests`); diff --git a/src/frontend/src/tests/pages/ChangeRequestDetailPage/ProposedSolutionForm.test.tsx b/src/frontend/src/tests/pages/ChangeRequestDetailPage/ProposedSolutionForm.test.tsx deleted file mode 100644 index b6a054eabb..0000000000 --- a/src/frontend/src/tests/pages/ChangeRequestDetailPage/ProposedSolutionForm.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/* - * This file is part of NER's FinishLine and licensed under GNU AGPLv3. - * See the LICENSE file in the repository root folder for details. - */ - -import { render, routerWrapperBuilder, screen } from '../../test-support/test-utils'; -import ProposedSolutionForm from '../../../pages/ChangeRequestDetailPage/ProposedSolutionForm'; -import { exampleAdminUser } from '../../test-support/test-data/users.stub'; - -/** - * Mock function for submitting the form, use if there is additional functionality added while submitting - */ -const mockHandleSubmit = vi.fn(); - -/** - * Sets up the component under test with the desired values and renders it. - */ -const renderComponent = (readOnly: boolean, description = '', budgetImpact = 0, timelineImpact = 0, scopeImpact = '') => { - const RouterWrapper = routerWrapperBuilder({}); - return render( - - {}} - /> - - ); -}; - -describe('Individual Proposed Solution Form Test Suite', () => { - it('Renders everything correctly when readOnly', () => { - renderComponent(true); - expect(screen.getByText('Description')).toBeInTheDocument(); - expect(screen.getByText('Budget Impact')).toBeInTheDocument(); - expect(screen.getByText('Timeline Impact')).toBeInTheDocument(); - expect(screen.getByText('Scope Impact')).toBeInTheDocument(); - expect(screen.queryByText('Add')).not.toBeInTheDocument(); - }); - - it('Renders everything correctly when not readOnly', () => { - renderComponent(false); - expect(screen.getByText('Description')).toBeInTheDocument(); - expect(screen.getByText('Budget Impact')).toBeInTheDocument(); - expect(screen.getByText('Timeline Impact')).toBeInTheDocument(); - expect(screen.getByText('Scope Impact')).toBeInTheDocument(); - expect(screen.getByText('Save')).toBeInTheDocument(); - }); - - it('Renders prefill elements when readOnly', () => { - renderComponent(true, 'Test Description', 1, 2, 'Test Scope'); - expect(screen.getByDisplayValue('Test Description')).toBeInTheDocument(); - expect(screen.getByDisplayValue(1)).toBeInTheDocument(); - expect(screen.getByDisplayValue(2)).toBeInTheDocument(); - expect(screen.getByDisplayValue('Test Scope')).toBeInTheDocument(); - }); - - it('Renders prefill elements when not readOnly', () => { - renderComponent(false, 'Test Description', 1, 2, 'Test Scope'); - expect(screen.getByDisplayValue('Test Description')).toBeInTheDocument(); - expect(screen.getByDisplayValue(1)).toBeInTheDocument(); - expect(screen.getByDisplayValue(2)).toBeInTheDocument(); - expect(screen.getByDisplayValue('Test Scope')).toBeInTheDocument(); - }); -}); diff --git a/src/frontend/src/tests/pages/ChangeRequestDetailPage/ProposedSolutionView.test.tsx b/src/frontend/src/tests/pages/ChangeRequestDetailPage/ProposedSolutionView.test.tsx deleted file mode 100644 index fe8fbd7661..0000000000 --- a/src/frontend/src/tests/pages/ChangeRequestDetailPage/ProposedSolutionView.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* - * This file is part of NER's FinishLine and licensed under GNU AGPLv3. - * See the LICENSE file in the repository root folder for details. - */ - -import { render, routerWrapperBuilder, screen } from '../../test-support/test-utils'; -import { ProposedSolution } from 'shared'; -import { exampleAdminUser, exampleLeadershipUser } from '../../test-support/test-data/users.stub'; -import ProposedSolutionView from '../../../pages/ChangeRequestDetailPage/ProposedSolutionView'; - -const exampleProposedSolution: ProposedSolution = { - id: '1', - description: 'Desc 1', - scopeImpact: 'Scope Impact 1', - budgetImpact: 11, - timelineImpact: 111, - createdBy: exampleAdminUser, - dateCreated: new Date(), - approved: true -}; - -const exampleProposedSolution2: ProposedSolution = { - id: '2', - description: 'Desc 2', - scopeImpact: 'Scope Impact 2', - budgetImpact: 22, - timelineImpact: 222, - createdBy: exampleLeadershipUser, - dateCreated: new Date(), - approved: false -}; - -/** - * Sets up the component under test with the desired values and renders it. - */ -const renderComponent = (proposedSolution = exampleProposedSolution) => { - const RouterWrapper = routerWrapperBuilder({}); - return render( - - {}} proposedSolution={proposedSolution} /> - - ); -}; - -describe('Proposed Solutions View Test Suite', () => { - it('Renders correctly when approved', () => { - renderComponent(); - expect(screen.getByText('Approved')).toBeInTheDocument(); - expect(screen.getByText('Description:')).toBeInTheDocument(); - expect(screen.getByText('Scope Impact:')).toBeInTheDocument(); - expect(screen.getByText('Desc 1')).toBeInTheDocument(); - expect(screen.getByText('Scope Impact 1')).toBeInTheDocument(); - expect(screen.getByText('11')).toBeInTheDocument(); - expect(screen.getByText('111 weeks')).toBeInTheDocument(); - }); - - it('Renders correctly when not approved', () => { - renderComponent(exampleProposedSolution2); - expect(screen.queryByText('Approved')).not.toBeInTheDocument(); - expect(screen.getByText('Description:')).toBeInTheDocument(); - expect(screen.getByText('Scope Impact:')).toBeInTheDocument(); - expect(screen.getByText('Desc 2')).toBeInTheDocument(); - expect(screen.getByText('Scope Impact 2')).toBeInTheDocument(); - expect(screen.getByText('22')).toBeInTheDocument(); - expect(screen.getByText('222 weeks')).toBeInTheDocument(); - }); -}); diff --git a/src/frontend/src/tests/pages/ChangeRequestDetailPage/ProposedSolutionsList.test.tsx b/src/frontend/src/tests/pages/ChangeRequestDetailPage/ProposedSolutionsList.test.tsx deleted file mode 100644 index b268244ef6..0000000000 --- a/src/frontend/src/tests/pages/ChangeRequestDetailPage/ProposedSolutionsList.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/* - * This file is part of NER's FinishLine and licensed under GNU AGPLv3. - * See the LICENSE file in the repository root folder for details. - */ - -import { render, routerWrapperBuilder, screen } from '../../test-support/test-utils'; -import ProposedSolutionsList from '../../../pages/ChangeRequestDetailPage/ProposedSolutionsList'; -import { ProposedSolution } from 'shared'; -import { exampleAdminUser, exampleLeadershipUser } from '../../test-support/test-data/users.stub'; -import { ToastProvider } from '../../../components/Toast/ToastProvider'; -import AppContextUser from '../../../app/AppContextUser'; -import * as userHooks from '../../../hooks/users.hooks'; -import { exampleAuthenticatedAdminUser } from '../../test-support/test-data/authenticated-user.stub'; -import ClarityProvider from '../../../app/ClarityProvider'; - -const exampleProposedSolution1: ProposedSolution = { - id: '1', - description: 'Desc 1', - scopeImpact: 'Scope Impact 1', - budgetImpact: 11, - timelineImpact: 111, - createdBy: exampleAdminUser, - dateCreated: new Date(), - approved: true -}; - -const exampleProposedSolution2: ProposedSolution = { - id: '2', - description: 'Desc 2', - scopeImpact: 'Scope Impact 2', - budgetImpact: 22, - timelineImpact: 222, - createdBy: exampleLeadershipUser, - dateCreated: new Date(), - approved: false -}; - -const exampleProposedSolutions = [exampleProposedSolution1, exampleProposedSolution2]; - -/** - * Sets up the component under test with the desired values and renders it. - */ -const renderComponent = (proposedSolutions: ProposedSolution[] = [], crReviewed: boolean | undefined = undefined) => { - const RouterWrapper = routerWrapperBuilder({}); - return render( - - - - - {' '} - - - - - ); -}; - -describe('Proposed Solutions List Test Suite', () => { - beforeEach(() => { - vi.spyOn(userHooks, 'useCurrentUser').mockReturnValue(exampleAuthenticatedAdminUser); - }); - - it('Renders correctly when empty and CR is not reviewed', () => { - renderComponent(); - expect(screen.queryAllByText('Description').length).toBe(0); - expect(screen.queryAllByText('Scope Impact').length).toBe(0); - expect(screen.queryByText('Desc 1')).not.toBeInTheDocument(); - expect(screen.queryByText('Scope Impact 1')).not.toBeInTheDocument(); - expect(screen.queryByText('11')).not.toBeInTheDocument(); - expect(screen.queryByText('111 weeks')).not.toBeInTheDocument(); - expect(screen.queryByText('Desc 2')).not.toBeInTheDocument(); - expect(screen.queryByText('Scope Impact 2')).not.toBeInTheDocument(); - expect(screen.queryByText('22')).not.toBeInTheDocument(); - expect(screen.queryByText('222 weeks')).not.toBeInTheDocument(); - }); - - it('Fires Modal correctly', () => { - renderComponent(); - expect(screen.queryByText('Description')).not.toBeInTheDocument(); - expect(screen.queryByText('Scope Impact')).not.toBeInTheDocument(); - expect(screen.queryByText('Add')).not.toBeInTheDocument(); - }); - - it('Renders correctly when not empty and CR is reviewed', () => { - renderComponent(exampleProposedSolutions, true); - expect(screen.queryByText('+ Add Solution')).not.toBeInTheDocument(); - }); - - it('Renders correctly when empty and CR is reviewed', () => { - renderComponent([], false); - expect(screen.queryByText('+ Add Solution')).not.toBeInTheDocument(); - }); -}); diff --git a/src/frontend/src/tests/pages/ChangeRequestDetailPage/ReviewChangeRequest.test.tsx b/src/frontend/src/tests/pages/ChangeRequestDetailPage/ReviewChangeRequest.test.tsx index 5609e3d3bf..c313ddd8e3 100644 --- a/src/frontend/src/tests/pages/ChangeRequestDetailPage/ReviewChangeRequest.test.tsx +++ b/src/frontend/src/tests/pages/ChangeRequestDetailPage/ReviewChangeRequest.test.tsx @@ -32,7 +32,7 @@ const renderComponent = (modalShow: boolean, route: string) => { workPackageNumber: 0 }, dateSubmitted: new Date(), - type: 'ISSUE', + type: 'BUDGET', wbsName: 'a', status: ChangeRequestStatus.Open, requestedReviewers: [] diff --git a/src/frontend/src/tests/pages/ChangeRequestDetailPage/StandardDetails.test.tsx b/src/frontend/src/tests/pages/ChangeRequestDetailPage/StandardDetails.test.tsx index 9b2563017f..ff269f85ff 100644 --- a/src/frontend/src/tests/pages/ChangeRequestDetailPage/StandardDetails.test.tsx +++ b/src/frontend/src/tests/pages/ChangeRequestDetailPage/StandardDetails.test.tsx @@ -4,7 +4,7 @@ */ import { render, screen } from '../../test-support/test-utils'; -import { ChangeRequestExplanation, StandardChangeRequest } from 'shared'; +import { StandardChangeRequest } from 'shared'; import { exampleStandardChangeRequest as cr } from '../../test-support/test-data/change-requests.stub'; import StandardDetails from '../../../pages/ChangeRequestDetailPage/StandardDetails'; @@ -18,13 +18,7 @@ const renderComponent = (cr: StandardChangeRequest) => { describe('Change request details standard cr display element tests', () => { it('Renders what and why section', () => { renderComponent(cr); - expect(screen.getByText(`What`)).toBeInTheDocument(); - expect(screen.getByText(`${cr.what}`)).toBeInTheDocument(); expect(screen.getByText(`Why`)).toBeInTheDocument(); - cr.why.forEach((explanation: ChangeRequestExplanation) => { - expect(screen.getByText(`${explanation.type}`)).toBeInTheDocument(); - expect(screen.getByText(`${explanation.explain}`)).toBeInTheDocument(); - }); }); }); diff --git a/src/frontend/src/tests/pages/CreateChangeRequestPage/CreateProposedSolutionsList.test.tsx b/src/frontend/src/tests/pages/CreateChangeRequestPage/CreateProposedSolutionsList.test.tsx deleted file mode 100644 index 9d0db2e724..0000000000 --- a/src/frontend/src/tests/pages/CreateChangeRequestPage/CreateProposedSolutionsList.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/* - * This file is part of NER's FinishLine and licensed under GNU AGPLv3. - * See the LICENSE file in the repository root folder for details. - */ - -import { render, routerWrapperBuilder, screen, waitFor } from '../../test-support/test-utils'; -import CreateProposedSolutionsList from '../../../pages/CreateChangeRequestPage/CreateProposedSolutionsList'; -import * as authHooks from '../../../hooks/auth.hooks'; -import { mockAuth } from '../../test-support/test-data/test-utils.stub'; -import * as userHooks from '../../../hooks/users.hooks'; -import { exampleAuthenticatedAdminUser } from '../../test-support/test-data/authenticated-user.stub'; - -/** - * Sets up the component under test with the desired values and renders it. - */ -const renderComponent = () => { - const RouterWrapper = routerWrapperBuilder({}); - return render( - - {}} /> - - ); -}; - -describe('Proposed Solutions List Test Suite', () => { - beforeEach(() => { - vi.spyOn(userHooks, 'useCurrentUser').mockReturnValue(exampleAuthenticatedAdminUser); - vi.spyOn(authHooks, 'useAuth').mockReturnValue(mockAuth(false, exampleAuthenticatedAdminUser)); - }); - - it('Renders correctly when empty', () => { - renderComponent(); - expect(screen.getByText('+ Add Solution')).toBeInTheDocument(); - expect(screen.queryAllByText('Description').length).toBe(0); - expect(screen.queryAllByText('Scope Impact').length).toBe(0); - expect(screen.queryAllByText('Budget Impact').length).toBe(0); - expect(screen.queryAllByText('Timeline Impact').length).toBe(0); - }); - - it('Fires Modal correctly', async () => { - renderComponent(); - expect(screen.queryByText('Description')).not.toBeInTheDocument(); - expect(screen.queryByText('Scope Impact')).not.toBeInTheDocument(); - expect(screen.queryByText('Budget Impact')).not.toBeInTheDocument(); - expect(screen.queryByText('Timeline Impact')).not.toBeInTheDocument(); - expect(screen.queryByText('Add')).not.toBeInTheDocument(); - screen.getByText('+ Add Solution').click(); - await waitFor(() => { - return screen.getByText('Description'); - }); - expect(screen.getByText('Description')).toBeInTheDocument(); - expect(screen.getByText('Scope Impact')).toBeInTheDocument(); - expect(screen.getByText('Budget Impact')).toBeInTheDocument(); - expect(screen.getByText('Timeline Impact')).toBeInTheDocument(); - expect(screen.getByText('Add')).toBeInTheDocument(); - }); -}); diff --git a/src/frontend/src/tests/test-support/test-data/change-requests.stub.ts b/src/frontend/src/tests/test-support/test-data/change-requests.stub.ts index ee7342c33b..a517707058 100644 --- a/src/frontend/src/tests/test-support/test-data/change-requests.stub.ts +++ b/src/frontend/src/tests/test-support/test-data/change-requests.stub.ts @@ -6,7 +6,6 @@ import { ActivationChangeRequest, ChangeRequest, - ChangeRequestReason, ChangeRequestStatus, ChangeRequestType, RoleEnum, @@ -23,44 +22,14 @@ export const exampleStandardChangeRequest: StandardChangeRequest = { wbsName: 'Example Work Package 1', submitter: exampleAdminUser, dateSubmitted: new Date('02/25/21'), - type: ChangeRequestType.Issue, dateReviewed: new Date('03/01/21'), reviewer: exampleAppAdminUser, accepted: true, reviewNotes: 'Adjust description, increase budget to 200, and add 3 weeks', dateImplemented: new Date('03/04/21'), status: ChangeRequestStatus.Implemented, - what: 'Spacers are needed to prevent the jet fuel from melting the I beams', - why: [ - { - type: ChangeRequestReason.Estimation, - explain: 'Original estimate did not account for spacers' - }, - { - type: ChangeRequestReason.Manufacturing, - explain: 'No availibilitiy in Richards' - }, - { - type: ChangeRequestReason.Other, - explain: "Matt won't shut up" - }, - { - type: ChangeRequestReason.OtherProject, - explain: '2.2.0' - }, - { - type: ChangeRequestReason.Rules, - explain: 'Discovered rule EV 5.2.6' - }, - { - type: ChangeRequestReason.School, - explain: 'All team members had 5 midterms each' - } - ], - scopeImpact: 'Design and machine titanium spacers', - budgetImpact: 75, - timelineImpact: 2, - proposedSolutions: [], + type: ChangeRequestType.Standard, + why: 'Spacers are needed to prevent the jet fuel from melting the I beams', requestedReviewers: [] }; @@ -101,42 +70,13 @@ export const exampleStandardImplementedChangeRequest: StandardChangeRequest = { wbsName: 'Example Work Package 1', submitter: exampleAdminUser, dateSubmitted: new Date('02/25/21'), - type: ChangeRequestType.Issue, + type: ChangeRequestType.Standard, dateReviewed: new Date('03/01/21'), accepted: true, reviewNotes: 'Adjust description, increase budget to 200, and add 3 weeks', dateImplemented: new Date('03/04/21'), status: ChangeRequestStatus.Implemented, - what: 'Spacers are needed to prevent the jet fuel from melting the I beams', - why: [ - { - type: ChangeRequestReason.Estimation, - explain: 'Original estimate did not account for spacers' - }, - { - type: ChangeRequestReason.Manufacturing, - explain: 'No availibilitiy in Richards' - }, - { - type: ChangeRequestReason.Other, - explain: "Matt won't shut up" - }, - { - type: ChangeRequestReason.OtherProject, - explain: '2.2.0' - }, - { - type: ChangeRequestReason.Rules, - explain: 'Discovered rule EV 5.2.6' - }, - { - type: ChangeRequestReason.School, - explain: 'All team members had 5 midterms each' - } - ], - scopeImpact: 'Design and machine titanium spacers', - budgetImpact: 75, - timelineImpact: 2, + why: 'Spacers are needed to prevent the jet fuel from melting the I beams. Original estimate did not account for spacers. No availability in Richards.', implementedChanges: [ { changeRequestIdentifier: 1, @@ -196,7 +136,6 @@ export const exampleStandardImplementedChangeRequest: StandardChangeRequest = { dateImplemented: new Date('02/25/21') } ], - proposedSolutions: [], requestedReviewers: [] }; diff --git a/src/frontend/src/utils/enum-pipes.ts b/src/frontend/src/utils/enum-pipes.ts index 201e66fd01..8f6b0bf6bf 100644 --- a/src/frontend/src/utils/enum-pipes.ts +++ b/src/frontend/src/utils/enum-pipes.ts @@ -45,18 +45,14 @@ export const ChangeRequestTypeTextPipe: (type: ChangeRequestType) => string = (t switch (type) { case ChangeRequestType.Activation: return 'Activation'; - case ChangeRequestType.Redefinition: - return 'Redefinition'; case ChangeRequestType.StageGate: return 'Stage Gate'; - case ChangeRequestType.Issue: - return 'Issue'; - case ChangeRequestType.Other: - return 'Other'; case ChangeRequestType.Budget: return 'Budget'; case ChangeRequestType.Leadership: return 'Leadership'; + case ChangeRequestType.Standard: + return 'Standard'; } }; diff --git a/src/shared/src/types/change-request-types.ts b/src/shared/src/types/change-request-types.ts index 1b1f2b9060..5a4209b02f 100644 --- a/src/shared/src/types/change-request-types.ts +++ b/src/shared/src/types/change-request-types.ts @@ -29,9 +29,7 @@ export interface ChangeRequest { } export const ChangeRequestType = { - Issue: 'ISSUE', - Redefinition: 'DEFINITION_CHANGE', - Other: 'OTHER', + Standard: 'STANDARD', StageGate: 'STAGE_GATE', Activation: 'ACTIVATION', Budget: 'BUDGET', @@ -41,29 +39,13 @@ export const ChangeRequestType = { export type ChangeRequestType = (typeof ChangeRequestType)[keyof typeof ChangeRequestType]; export interface StandardChangeRequest extends ChangeRequest { - what: string; - why: ChangeRequestExplanation[]; - scopeImpact: string; - budgetImpact: number; - timelineImpact: number; - proposedSolutions: ProposedSolution[]; + why: string; projectProposedChanges?: ProjectProposedChanges; workPackageProposedChanges?: WorkPackageProposedChanges; originalProjectData?: ProjectProposedChanges; originalWorkPackageData?: WorkPackageProposedChanges; } -export interface ProposedSolution { - id: string; - description: string; - scopeImpact: string; - budgetImpact: number; - timelineImpact: number; - createdBy: User; - dateCreated: Date; - approved: boolean; -} - export interface GuestChangeRequest { crId: string; submitter: User; @@ -98,24 +80,6 @@ export interface LeadershipChangeRequest extends ChangeRequest { manager?: User; } -export interface ChangeRequestExplanation { - type: ChangeRequestReason; - explain: string; -} - -export enum ChangeRequestReason { - Estimation = 'ESTIMATION', - School = 'SCHOOL', - Design = 'DESIGN', - Manufacturing = 'MANUFACTURING', - Rules = 'RULES', - Initialization = 'INITIALIZATION', - Competition = 'COMPETITION', - Maintenance = 'MAINTENANCE', - OtherProject = 'OTHER_PROJECT', - Other = 'OTHER' -} - export enum ChangeRequestStatus { Implemented = 'Implemented', Accepted = 'Accepted', @@ -135,17 +99,6 @@ export interface ImplementedChange { dateImplemented: Date; } -export interface ProposedSolutionCreateArgs { - description: string; - scopeImpact: string; - budgetImpact: number; - timelineImpact: number; -} - -export interface ProposedSolutionFormInput extends ProposedSolutionCreateArgs { - id: string; -} - export interface DescriptionBulletPreview { id: string; detail: string; From 7b5107cd68a4bba8968fe9f85ebf3fb9d653ed63 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Tue, 21 Apr 2026 17:04:22 -0400 Subject: [PATCH 02/29] feature branch From 9a181dc737bc298f94a839a18f89a5857f70af7c Mon Sep 17 00:00:00 2001 From: wavehassman Date: Wed, 22 Apr 2026 17:00:51 -0400 Subject: [PATCH 03/29] #4168 combined migration files --- .../migration.sql | 105 +++++++++++++++++- .../migration.sql | 97 ---------------- 2 files changed, 104 insertions(+), 98 deletions(-) delete mode 100644 src/backend/src/prisma/migrations/20260420023918_descoping_change_requests_2/migration.sql diff --git a/src/backend/src/prisma/migrations/20260420022045_descoping_change_requests/migration.sql b/src/backend/src/prisma/migrations/20260420022045_descoping_change_requests/migration.sql index bfabb2ca6d..52f0691018 100644 --- a/src/backend/src/prisma/migrations/20260420022045_descoping_change_requests/migration.sql +++ b/src/backend/src/prisma/migrations/20260420022045_descoping_change_requests/migration.sql @@ -1,2 +1,105 @@ +/* + Warnings: -ALTER TYPE "CR_Type" ADD VALUE 'STANDARD'; \ No newline at end of file + - The values [ISSUE,DEFINITION_CHANGE,OTHER] on the enum `CR_Type` will be removed. If these variants are still used in the database, this will fail. + - You are about to drop the column `scopeChangeRequestAsOriginalDataId` on the `Wbs_Proposed_Changes` table. All the data in the column will be lost. + - You are about to drop the column `scopeChangeRequestId` on the `Wbs_Proposed_Changes` table. All the data in the column will be lost. + - You are about to drop the `Proposed_Solution` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `Scope_CR` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `Scope_CR_Why` table. If the table is not empty, all the data it contains will be lost. + - A unique constraint covering the columns `[wbsProposedChangesId]` on the table `Change_Request` will be added. If there are existing duplicate values, this will fail. + +*/ + +ALTER TYPE "CR_Type" ADD VALUE 'STANDARD'; + +COMMIT; + +BEGIN; + +-- Step 1: Add why column so the backfill can write to it +ALTER TABLE "Change_Request" ADD COLUMN "why" TEXT; + +-- Step 2: Backfill +UPDATE "Change_Request" cr +SET + why = CONCAT( + 'Type: ', cr.type::text, + CASE WHEN sc.what IS NOT NULL THEN CONCAT(' | What: ', sc.what) ELSE '' END, + CASE WHEN why_agg.why_text IS NOT NULL THEN CONCAT(' | Why: ', why_agg.why_text) ELSE '' END + ), + type = 'STANDARD'::"CR_Type" +FROM "Scope_CR" sc +LEFT JOIN ( + SELECT + "scopeCrId", + STRING_AGG(CONCAT(type::text, ': ', explain), ', ') as why_text + FROM "Scope_CR_Why" + GROUP BY "scopeCrId" +) why_agg ON why_agg."scopeCrId" = sc."scopeCrId" +WHERE sc."changeRequestId" = cr."crId" +AND cr.type IN ('ISSUE', 'DEFINITION_CHANGE', 'OTHER'); + +-- Step 3: Replace enum +CREATE TYPE "CR_Type_new" AS ENUM ('LEADERSHIP', 'STAGE_GATE', 'ACTIVATION', 'BUDGET', 'STANDARD'); +ALTER TABLE "Change_Request" ALTER COLUMN "type" TYPE "CR_Type_new" USING ("type"::text::"CR_Type_new"); +ALTER TYPE "CR_Type" RENAME TO "CR_Type_old"; +ALTER TYPE "CR_Type_new" RENAME TO "CR_Type"; +DROP TYPE "CR_Type_old"; + +-- Step 4: Add wbsProposedChangesId (why already added above, remove it from here) +ALTER TABLE "Change_Request" ADD COLUMN "wbsProposedChangesId" TEXT; + +-- DropForeignKey +ALTER TABLE "Proposed_Solution" DROP CONSTRAINT "Proposed_Solution_createdByUserId_fkey"; + +-- DropForeignKey +ALTER TABLE "Proposed_Solution" DROP CONSTRAINT "Proposed_Solution_scopeChangeRequestId_fkey"; + +-- DropForeignKey +ALTER TABLE "Scope_CR" DROP CONSTRAINT "Scope_CR_changeRequestId_fkey"; + +-- DropForeignKey +ALTER TABLE "Scope_CR_Why" DROP CONSTRAINT "Scope_CR_Why_scopeCrId_fkey"; + +-- DropForeignKey +ALTER TABLE "Wbs_Proposed_Changes" DROP CONSTRAINT "Wbs_Proposed_Changes_scopeChangeRequestAsOriginalDataId_fkey"; + +-- DropForeignKey +ALTER TABLE "Wbs_Proposed_Changes" DROP CONSTRAINT "Wbs_Proposed_Changes_scopeChangeRequestId_fkey"; + +-- DropIndex +DROP INDEX "Wbs_Proposed_Changes_scopeChangeRequestAsOriginalDataId_idx"; + +-- DropIndex +DROP INDEX "Wbs_Proposed_Changes_scopeChangeRequestAsOriginalDataId_key"; + +-- DropIndex +DROP INDEX "Wbs_Proposed_Changes_scopeChangeRequestId_idx"; + +-- DropIndex +DROP INDEX "Wbs_Proposed_Changes_scopeChangeRequestId_key"; + +-- AlterTable +ALTER TABLE "Wbs_Proposed_Changes" DROP COLUMN "scopeChangeRequestAsOriginalDataId", +DROP COLUMN "scopeChangeRequestId"; + +-- DropTable +DROP TABLE "Proposed_Solution"; + +-- DropTable +DROP TABLE "Scope_CR"; + +-- DropTable +DROP TABLE "Scope_CR_Why"; + +-- DropEnum +DROP TYPE "Scope_CR_Why_Type"; + +-- CreateIndex +CREATE UNIQUE INDEX "Change_Request_wbsProposedChangesId_key" ON "Change_Request"("wbsProposedChangesId"); + +-- AddForeignKey +ALTER TABLE "Change_Request" ADD CONSTRAINT "Change_Request_wbsProposedChangesId_fkey" FOREIGN KEY ("wbsProposedChangesId") REFERENCES "Wbs_Proposed_Changes"("wbsProposedChangesId") ON DELETE SET NULL ON UPDATE CASCADE; + +COMMIT; diff --git a/src/backend/src/prisma/migrations/20260420023918_descoping_change_requests_2/migration.sql b/src/backend/src/prisma/migrations/20260420023918_descoping_change_requests_2/migration.sql deleted file mode 100644 index 7ab41f7d77..0000000000 --- a/src/backend/src/prisma/migrations/20260420023918_descoping_change_requests_2/migration.sql +++ /dev/null @@ -1,97 +0,0 @@ -/* - Warnings: - - - The values [ISSUE,DEFINITION_CHANGE,OTHER] on the enum `CR_Type` will be removed. If these variants are still used in the database, this will fail. - - You are about to drop the column `scopeChangeRequestAsOriginalDataId` on the `Wbs_Proposed_Changes` table. All the data in the column will be lost. - - You are about to drop the column `scopeChangeRequestId` on the `Wbs_Proposed_Changes` table. All the data in the column will be lost. - - You are about to drop the `Proposed_Solution` table. If the table is not empty, all the data it contains will be lost. - - You are about to drop the `Scope_CR` table. If the table is not empty, all the data it contains will be lost. - - You are about to drop the `Scope_CR_Why` table. If the table is not empty, all the data it contains will be lost. - - A unique constraint covering the columns `[wbsProposedChangesId]` on the table `Change_Request` will be added. If there are existing duplicate values, this will fail. - -*/ - --- Step 1: Add why column so the backfill can write to it -ALTER TABLE "Change_Request" ADD COLUMN "why" TEXT; - --- Step 2: Backfill -UPDATE "Change_Request" cr -SET - why = CONCAT( - 'Type: ', cr.type::text, - CASE WHEN sc.what IS NOT NULL THEN CONCAT(' | What: ', sc.what) ELSE '' END, - CASE WHEN why_agg.why_text IS NOT NULL THEN CONCAT(' | Why: ', why_agg.why_text) ELSE '' END - ), - type = 'STANDARD'::"CR_Type" -FROM "Scope_CR" sc -LEFT JOIN ( - SELECT - "scopeCrId", - STRING_AGG(CONCAT(type::text, ': ', explain), ', ') as why_text - FROM "Scope_CR_Why" - GROUP BY "scopeCrId" -) why_agg ON why_agg."scopeCrId" = sc."scopeCrId" -WHERE sc."changeRequestId" = cr."crId" -AND cr.type IN ('ISSUE', 'DEFINITION_CHANGE', 'OTHER'); - --- Step 3: Replace enum -CREATE TYPE "CR_Type_new" AS ENUM ('LEADERSHIP', 'STAGE_GATE', 'ACTIVATION', 'BUDGET', 'STANDARD'); -ALTER TABLE "Change_Request" ALTER COLUMN "type" TYPE "CR_Type_new" USING ("type"::text::"CR_Type_new"); -ALTER TYPE "CR_Type" RENAME TO "CR_Type_old"; -ALTER TYPE "CR_Type_new" RENAME TO "CR_Type"; -DROP TYPE "CR_Type_old"; - --- Step 4: Add wbsProposedChangesId (why already added above, remove it from here) -ALTER TABLE "Change_Request" ADD COLUMN "wbsProposedChangesId" TEXT; - --- DropForeignKey -ALTER TABLE "Proposed_Solution" DROP CONSTRAINT "Proposed_Solution_createdByUserId_fkey"; - --- DropForeignKey -ALTER TABLE "Proposed_Solution" DROP CONSTRAINT "Proposed_Solution_scopeChangeRequestId_fkey"; - --- DropForeignKey -ALTER TABLE "Scope_CR" DROP CONSTRAINT "Scope_CR_changeRequestId_fkey"; - --- DropForeignKey -ALTER TABLE "Scope_CR_Why" DROP CONSTRAINT "Scope_CR_Why_scopeCrId_fkey"; - --- DropForeignKey -ALTER TABLE "Wbs_Proposed_Changes" DROP CONSTRAINT "Wbs_Proposed_Changes_scopeChangeRequestAsOriginalDataId_fkey"; - --- DropForeignKey -ALTER TABLE "Wbs_Proposed_Changes" DROP CONSTRAINT "Wbs_Proposed_Changes_scopeChangeRequestId_fkey"; - --- DropIndex -DROP INDEX "Wbs_Proposed_Changes_scopeChangeRequestAsOriginalDataId_idx"; - --- DropIndex -DROP INDEX "Wbs_Proposed_Changes_scopeChangeRequestAsOriginalDataId_key"; - --- DropIndex -DROP INDEX "Wbs_Proposed_Changes_scopeChangeRequestId_idx"; - --- DropIndex -DROP INDEX "Wbs_Proposed_Changes_scopeChangeRequestId_key"; - --- AlterTable -ALTER TABLE "Wbs_Proposed_Changes" DROP COLUMN "scopeChangeRequestAsOriginalDataId", -DROP COLUMN "scopeChangeRequestId"; - --- DropTable -DROP TABLE "Proposed_Solution"; - --- DropTable -DROP TABLE "Scope_CR"; - --- DropTable -DROP TABLE "Scope_CR_Why"; - --- DropEnum -DROP TYPE "Scope_CR_Why_Type"; - --- CreateIndex -CREATE UNIQUE INDEX "Change_Request_wbsProposedChangesId_key" ON "Change_Request"("wbsProposedChangesId"); - --- AddForeignKey -ALTER TABLE "Change_Request" ADD CONSTRAINT "Change_Request_wbsProposedChangesId_fkey" FOREIGN KEY ("wbsProposedChangesId") REFERENCES "Wbs_Proposed_Changes"("wbsProposedChangesId") ON DELETE SET NULL ON UPDATE CASCADE; From a153a5550da97aa2b6d3d141a4f79b34c55f7f08 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 24 Apr 2026 13:03:05 -0400 Subject: [PATCH 04/29] #4168 requested changes --- .../change-requests.controllers.ts | 9 ++--- src/backend/src/prisma/seed.ts | 16 --------- .../src/routes/change-requests.routes.ts | 6 ---- .../src/services/change-requests.services.ts | 14 +++----- src/frontend/src/apis/change-requests.api.ts | 28 --------------- .../src/hooks/change-requests.hooks.ts | 36 ------------------- .../ChangeRequestDetailsView.tsx | 2 +- 7 files changed, 8 insertions(+), 103 deletions(-) diff --git a/src/backend/src/controllers/change-requests.controllers.ts b/src/backend/src/controllers/change-requests.controllers.ts index d1271b3a16..48284b7843 100644 --- a/src/backend/src/controllers/change-requests.controllers.ts +++ b/src/backend/src/controllers/change-requests.controllers.ts @@ -99,14 +99,13 @@ export default class ChangeRequestsController { static async createActivationChangeRequest(req: Request, res: Response, next: NextFunction) { try { - const { wbsNum, type, leadId, managerId, startDate, confirmDetails } = req.body; + const { wbsNum, leadId, managerId, startDate, confirmDetails } = req.body; const id = await ChangeRequestsService.createActivationChangeRequest( req.currentUser, wbsNum.carNumber, wbsNum.projectNumber, wbsNum.workPackageNumber, - type, leadId, managerId, startDate, @@ -121,13 +120,12 @@ export default class ChangeRequestsController { static async createStageGateChangeRequest(req: Request, res: Response, next: NextFunction) { try { - const { wbsNum, type, confirmDone } = req.body; + const { wbsNum, confirmDone } = req.body; const id = await ChangeRequestsService.createStageGateChangeRequest( req.currentUser, wbsNum.carNumber, wbsNum.projectNumber, wbsNum.workPackageNumber, - type, confirmDone, req.organization ); @@ -139,10 +137,9 @@ export default class ChangeRequestsController { static async createBudgetChangeRequest(req: Request, res: Response, next: NextFunction) { try { - const { otherReasonId, accountCodeId, type, proposedBudget } = req.body; + const { otherReasonId, accountCodeId, proposedBudget } = req.body; const cr = await ChangeRequestsService.createBudgetChangeRequest( req.currentUser, - type, proposedBudget, req.organization, otherReasonId, diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 4901521a93..05f57d0973 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -1287,7 +1287,6 @@ const performSeed: () => Promise = async () => { workPackageHuskies1.wbsNum.carNumber, workPackageHuskies1.wbsNum.projectNumber, workPackageHuskies1.wbsNum.workPackageNumber, - 'ACTIVATION', thomasEmrax.userId, joeShmoe.userId, weeksAgo(12), @@ -1344,7 +1343,6 @@ const performSeed: () => Promise = async () => { workPackageSlackbot1WbsNumber.carNumber, workPackageSlackbot1WbsNumber.projectNumber, workPackageSlackbot1WbsNumber.workPackageNumber, - CR_Type.ACTIVATION, regina.userId, janis.userId, weeksAgo(9), @@ -1377,7 +1375,6 @@ const performSeed: () => Promise = async () => { workPackageSlackbot2WbsNumber.carNumber, workPackageSlackbot2WbsNumber.projectNumber, workPackageSlackbot2WbsNumber.workPackageNumber, - CR_Type.ACTIVATION, joeShmoe.userId, thomasEmrax.userId, weeksAgo(5), @@ -1413,7 +1410,6 @@ const performSeed: () => Promise = async () => { workPackageAvatarProject1WbsNumber.carNumber, workPackageAvatarProject1WbsNumber.projectNumber, workPackageAvatarProject1WbsNumber.workPackageNumber, - CR_Type.ACTIVATION, katara.userId, aang.userId, weeksAgo(16), @@ -1453,7 +1449,6 @@ const performSeed: () => Promise = async () => { workPackageAvatarProject2WbsNumber.carNumber, workPackageAvatarProject2WbsNumber.projectNumber, workPackageAvatarProject2WbsNumber.workPackageNumber, - CR_Type.ACTIVATION, katara.userId, aang.userId, weeksAgo(9), @@ -1487,7 +1482,6 @@ const performSeed: () => Promise = async () => { workPackageAvatarProject3WbsNumber.carNumber, workPackageAvatarProject3WbsNumber.projectNumber, workPackageAvatarProject3WbsNumber.workPackageNumber, - CR_Type.ACTIVATION, katara.userId, aang.userId, weeksAgo(4), @@ -1522,7 +1516,6 @@ const performSeed: () => Promise = async () => { projectJustice1WP1.wbsNum.carNumber, projectJustice1WP1.wbsNum.projectNumber, projectJustice1WP1.wbsNum.workPackageNumber, - CR_Type.ACTIVATION, zatanna.userId, lexLuther.userId, weeksAgo(8), @@ -1592,7 +1585,6 @@ const performSeed: () => Promise = async () => { projectJustice2WP1.wbsNum.carNumber, projectJustice2WP1.wbsNum.projectNumber, projectJustice2WP1.wbsNum.workPackageNumber, - CR_Type.ACTIVATION, zatanna.userId, lexLuther.userId, weeksAgo(8), @@ -1644,7 +1636,6 @@ const performSeed: () => Promise = async () => { project4WP1.wbsNum.carNumber, project4WP1.wbsNum.projectNumber, project4WP1.wbsNum.workPackageNumber, - CR_Type.ACTIVATION, mikeMacdonald.userId, ryanGiggs.userId, weeksAgo(14), @@ -1714,7 +1705,6 @@ const performSeed: () => Promise = async () => { projectKrusty1WP1.wbsNum.carNumber, projectKrusty1WP1.wbsNum.projectNumber, projectKrusty1WP1.wbsNum.workPackageNumber, - CR_Type.ACTIVATION, mrKrabs.userId, squidward.userId, weeksAgo(6), @@ -1784,7 +1774,6 @@ const performSeed: () => Promise = async () => { projectKrusty2WP1.wbsNum.carNumber, projectKrusty2WP1.wbsNum.projectNumber, projectKrusty2WP1.wbsNum.workPackageNumber, - CR_Type.ACTIVATION, mrKrabs.userId, squidward.userId, weeksAgo(6), @@ -2161,7 +2150,6 @@ const performSeed: () => Promise = async () => { projectPenguin1WP1.wbsNum.carNumber, projectPenguin1WP1.wbsNum.projectNumber, projectPenguin1WP1.wbsNum.workPackageNumber, - CR_Type.ACTIVATION, rico.userId, kowalski.userId, weeksAgo(6), @@ -2195,7 +2183,6 @@ const performSeed: () => Promise = async () => { projectPenguin2WP1.wbsNum.carNumber, projectPenguin2WP1.wbsNum.projectNumber, projectPenguin2WP1.wbsNum.workPackageNumber, - CR_Type.ACTIVATION, rico.userId, kowalski.userId, weeksAgo(6), @@ -2245,7 +2232,6 @@ const performSeed: () => Promise = async () => { workPackageHuskies1WbsNumber.carNumber, workPackageHuskies1WbsNumber.projectNumber, workPackageHuskies1WbsNumber.workPackageNumber, - CR_Type.STAGE_GATE, true, ner ); @@ -2265,7 +2251,6 @@ const performSeed: () => Promise = async () => { workPackageSlackbot1WbsNumber.carNumber, workPackageSlackbot1WbsNumber.projectNumber, workPackageSlackbot1WbsNumber.workPackageNumber, - CR_Type.ACTIVATION, thomasEmrax.userId, joeShmoe.userId, weeksAgo(9), @@ -3017,7 +3002,6 @@ const performSeed: () => Promise = async () => { const budgetCR = await ChangeRequestsService.createBudgetChangeRequest( thomasEmrax, - 'BUDGET', 50, ner, otherProductReasonConsumables.otherProductReasonId diff --git a/src/backend/src/routes/change-requests.routes.ts b/src/backend/src/routes/change-requests.routes.ts index 5e947ad124..6748f6cb90 100644 --- a/src/backend/src/routes/change-requests.routes.ts +++ b/src/backend/src/routes/change-requests.routes.ts @@ -1,6 +1,5 @@ import express from 'express'; import { body } from 'express-validator'; -import { ChangeRequestType } from 'shared'; import ChangeRequestsController from '../controllers/change-requests.controllers.js'; import { intMinZero, @@ -39,7 +38,6 @@ changeRequestsRouter.post( intMinZero(body('wbsNum.carNumber')), intMinZero(body('wbsNum.projectNumber')), intMinZero(body('wbsNum.workPackageNumber')), - body('type').custom((value) => value === ChangeRequestType.Activation), isDateOnly(body('startDate')), nonEmptyString(body('leadId')), nonEmptyString(body('managerId')), @@ -54,7 +52,6 @@ changeRequestsRouter.post( intMinZero(body('wbsNum.carNumber')), intMinZero(body('wbsNum.projectNumber')), intMinZero(body('wbsNum.workPackageNumber')), - body('type').custom((value) => value === ChangeRequestType.StageGate), body('confirmDone').isBoolean(), validateInputs, ChangeRequestsController.createStageGateChangeRequest @@ -65,7 +62,6 @@ changeRequestsRouter.post( nonEmptyString(body('submitterId')), nonEmptyString(body('otherReasonId')).optional(), nonEmptyString(body('accountCodeId')).optional(), - body('type').custom((value) => value === ChangeRequestType.Budget), intMinZero(body('proposedBudget')), validateInputs, ChangeRequestsController.createBudgetChangeRequest @@ -76,9 +72,7 @@ changeRequestsRouter.post( intMinZero(body('wbsNum.carNumber')), intMinZero(body('wbsNum.projectNumber')), intMinZero(body('wbsNum.workPackageNumber')), - nonEmptyString(body('submitterId')), nonEmptyString(body('why')), - body('type').custom((value) => value === ChangeRequestType.Standard), nonEmptyString(body('requestedReviewerId')).optional(), ...projectProposedChangesValidators, ...workPackageProposedChangesValidators('workPackageProposedChanges'), diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 80f9e500ad..b08ba10830 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -539,7 +539,6 @@ export default class ChangeRequestsService { * @param carNumber the car number for the wbs element * @param projectNumber the project number for the wbs element * @param workPackageNumber the work package number for the wbs element - * @param type the type of cr * @param leadId the id of the project lead * @param managerId the id of the project manager * @param startDate the start date of the work package/project @@ -552,7 +551,6 @@ export default class ChangeRequestsService { carNumber: number, projectNumber: number, workPackageNumber: number, - type: CR_Type, leadId: string, managerId: string, startDate: Date, @@ -593,7 +591,7 @@ export default class ChangeRequestsService { data: { submitter: { connect: { userId: submitter.userId } }, wbsElement: { connect: { wbsElementId: wbsElement.wbsElementId } }, - type, + type: CR_Type.ACTIVATION, activationChangeRequest: { create: { lead: { connect: { userId: leadId } }, @@ -631,7 +629,6 @@ export default class ChangeRequestsService { * @param carNumber the car number for the wbs element * @param projectNumber the project number for the wbs element * @param workPackageNumber the work package number for the wbs element - * @param type the type of cr * @param confirmDone whether or not to confirm * @param organization the organization the user is currently in * @returns the id of the created cr @@ -641,7 +638,6 @@ export default class ChangeRequestsService { carNumber: number, projectNumber: number, workPackageNumber: number, - type: CR_Type, confirmDone: boolean, organization: Organization ): Promise { @@ -685,7 +681,7 @@ export default class ChangeRequestsService { data: { submitter: { connect: { userId: submitter.userId } }, wbsElement: { connect: { wbsElementId: wbsElement.wbsElementId } }, - type, + type: CR_Type.STAGE_GATE, stageGateChangeRequest: { create: { leftoverBudget: 0, confirmDone } }, @@ -715,7 +711,6 @@ export default class ChangeRequestsService { /** * Validates and creates a budget change request * @param submitter The user creating the cr - * @param type the type of cr * @param proposedBudget the proposed budget * @param organization the organization the user is currently in * @param otherReasonId the id of the other reason/category to change budget of @@ -724,7 +719,6 @@ export default class ChangeRequestsService { */ static async createBudgetChangeRequest( submitter: User, - type: CR_Type, proposedBudget: number, organization: Organization, otherReasonId?: string, @@ -762,7 +756,7 @@ export default class ChangeRequestsService { data: { submitter: { connect: { userId: submitter.userId } }, category: { connect: { otherReimbursementProductReasonId: otherReasonId } }, - type, + type: CR_Type.BUDGET, budgetChangeRequest: { create: { proposedBudget } }, organization: { connect: { organizationId: organization.organizationId } }, identifier: numChangeRequests + 1 @@ -809,7 +803,7 @@ export default class ChangeRequestsService { data: { submitter: { connect: { userId: submitter.userId } }, accountCode: { connect: { accountCodeId } }, - type, + type: CR_Type.BUDGET, budgetChangeRequest: { create: { proposedBudget } }, organization: { connect: { organizationId: organization.organizationId } }, identifier: numChangeRequests + 1 diff --git a/src/frontend/src/apis/change-requests.api.ts b/src/frontend/src/apis/change-requests.api.ts index 9892caddeb..638cbe93dc 100644 --- a/src/frontend/src/apis/change-requests.api.ts +++ b/src/frontend/src/apis/change-requests.api.ts @@ -183,34 +183,6 @@ export const createLeadershipChangeRequest = ( }); }; -/** - * Create a propose solution - * @param submitterId The ID of the user creating the change request. - * @param crId The ID of the associated change request. - * @param description The description of the proposed solution. - * @param scopeImpact The scope of the change for the proposed solution. - * @param timelineImpact The number of week(s) impact for the proposed solution. - * @param budgetImpact The budget in dollars, for the proposed solution. - */ - -export const addProposedSolution = ( - submitterId: string, - crId: string, - description: string, - scopeImpact: string, - timelineImpact: number, - budgetImpact: number -) => { - return axios.post<{ message: string }>(apiUrls.changeRequestCreateProposeSolution(), { - submitterId, - crId, - description, - scopeImpact, - timelineImpact, - budgetImpact - }); -}; - /** * Request reviewers in change request * @param crId The ID of the associated change request. diff --git a/src/frontend/src/hooks/change-requests.hooks.ts b/src/frontend/src/hooks/change-requests.hooks.ts index 479fa63c13..0a007ef40a 100644 --- a/src/frontend/src/hooks/change-requests.hooks.ts +++ b/src/frontend/src/hooks/change-requests.hooks.ts @@ -20,7 +20,6 @@ import { getAllChangeRequests, getSingleChangeRequest, reviewChangeRequest, - addProposedSolution, deleteChangeRequest, requestCRReview, getToReviewChangeRequests, @@ -200,15 +199,6 @@ export interface CreateBudgetChangeRequestPayload { type: string; } -export interface CreateProposedSolutionPayload { - submitterId: string; - crId: string; - description: string; - scopeImpact: string; - timelineImpact: number; - budgetImpact: number; -} - /** * Custom React Hook to create an activation change request. */ @@ -287,32 +277,6 @@ export const useCreateLeadershipChangeRequest = () => { ); }; -/** - * Custom React Hook to create a proposed solution - */ -export const useCreateProposeSolution = () => { - const queryClient = useQueryClient(); - return useMutation<{ message: string }, Error, CreateProposedSolutionPayload>( - ['change requests', 'create', 'propose solution'], - async (payload: CreateProposedSolutionPayload) => { - const { data } = await addProposedSolution( - payload.submitterId, - payload.crId, - payload.description, - payload.scopeImpact, - payload.timelineImpact, - payload.budgetImpact - ); - return data; - }, - { - onSuccess: () => { - queryClient.invalidateQueries(['change requests']); - } - } - ); -}; - export interface CRReviewPayload { userIds: string[]; } diff --git a/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.tsx index 8909df89ab..242bb2403c 100644 --- a/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.tsx +++ b/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.tsx @@ -148,7 +148,7 @@ const ChangeRequestDetailsView: React.FC = ({
- {hasProposedChanges(changeRequest as StandardChangeRequest) ?? ( + {hasProposedChanges(changeRequest as StandardChangeRequest) && ( )} From 9f8298298a754b44ae51fa4255a64e9d76c417e8 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sat, 25 Apr 2026 21:52:48 -0400 Subject: [PATCH 05/29] #4170 updated service and added test --- .../src/services/change-requests.services.ts | 15 ++-- .../tests/unit/change-requests.test.ts | 68 +++++++++---------- 2 files changed, 42 insertions(+), 41 deletions(-) diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index b08ba10830..6d575abf5b 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -5,7 +5,6 @@ import { isAdmin, isGuest, isLeadership, - isNotLeadership, isProjectWbs, ProjectProposedChangesCreateArgs, StageGateChangeRequest, @@ -13,7 +12,8 @@ import { WbsNumber, wbsPipe, WorkPackageProposedChangesCreateArgs, - User + User, + isHead } from 'shared'; import prisma from '../prisma/prisma.js'; import { @@ -259,9 +259,6 @@ export default class ChangeRequestsService { accepted: boolean, organization: Organization ): Promise { - if (await userHasPermission(reviewer.userId, organization.organizationId, isNotLeadership)) - throw new AccessDeniedMemberException('review change requests'); - const foundCR = await prisma.change_Request.findUnique({ where: { crId }, ...getChangeRequestWithProjectAndWorkPackageQueryArgs(organization.organizationId) @@ -276,11 +273,15 @@ export default class ChangeRequestsService { if (reviewer.userId === foundCR.submitterId) throw new AccessDeniedException("You can't review your own change request!"); + const isHeadOrAdmin = await userHasPermission(reviewer.userId, organization.organizationId, isHead); + if (foundCR.requestedReviewers.length > 0) { const isRequestedReviewer = foundCR.requestedReviewers.some((user) => user.userId === reviewer.userId); - if (!isRequestedReviewer) { - throw new AccessDeniedException('Only requested reviewers can review this change request!'); + if (!isRequestedReviewer && !isHeadOrAdmin) { + throw new AccessDeniedException('Only the requested reviewer or a head/admin can review this change request!'); } + } else if (!isHeadOrAdmin) { + throw new AccessDeniedMemberException('review change requests'); } if (accepted && foundCR.type === CR_Type.STAGE_GATE) { diff --git a/src/backend/tests/unit/change-requests.test.ts b/src/backend/tests/unit/change-requests.test.ts index 41b4a82b7f..224ac950d7 100644 --- a/src/backend/tests/unit/change-requests.test.ts +++ b/src/backend/tests/unit/change-requests.test.ts @@ -6,10 +6,11 @@ import { aquamanLeadership, greenlanternHead, flashAdmin, - robinMember + robinMember, + batmanAppAdmin } from '../test-data/users.test-data.js'; import prisma from '../../src/prisma/prisma.js'; -import { AccessDeniedException } from '../../src/utils/errors.utils.js'; +import { AccessDeniedException, AccessDeniedMemberException } from '../../src/utils/errors.utils.js'; describe('Change Request Tests', () => { let orgId: string; @@ -270,7 +271,7 @@ describe('Change Request Tests', () => { expect(updatedCR?.accepted).toBe(false); }); - it('rejects non-requested leadership when reviewers are requested', async () => { + it('allows non-requested head/admin to review when reviewers are requested', async () => { await ChangeRequestsService.requestCRReview( submitterUser, [leadershipUser1.userId, leadershipUser2.userId], @@ -278,25 +279,15 @@ describe('Change Request Tests', () => { organization ); - await expect( - ChangeRequestsService.reviewChangeRequest( - nonRequestedLeadership, - changeRequestId, - 'I want to review this', - true, - organization - ) - ).rejects.toThrow(AccessDeniedException); + const reviewResult = await ChangeRequestsService.reviewChangeRequest( + nonRequestedLeadership, + changeRequestId, + 'I want to review this', + false, + organization + ); - await expect( - ChangeRequestsService.reviewChangeRequest( - nonRequestedLeadership, - changeRequestId, - 'I want to review this', - true, - organization - ) - ).rejects.toThrow('Only requested reviewers can review this change request!'); + expect(reviewResult).toBe(changeRequestId); }); it('allows second requested reviewer to review when reviewers are requested', async () => { @@ -346,18 +337,23 @@ describe('Change Request Tests', () => { ).rejects.toThrow('The following user(s) are not leadership: Dick Grayson'); }); - it('allows rejection by non-requested leadership when reviewers are requested', async () => { + it('allows non-requested head/admin to reject when reviewers are requested', async () => { await ChangeRequestsService.requestCRReview(submitterUser, [leadershipUser1.userId], changeRequestId, organization); + const reviewResult = await ChangeRequestsService.reviewChangeRequest( + nonRequestedLeadership, + changeRequestId, + 'Rejecting this', + false, + organization + ); + + expect(reviewResult).toBe(changeRequestId); + }); + it('rejects member user from reviewing even when no specific reviewer is requested', async () => { await expect( - ChangeRequestsService.reviewChangeRequest( - nonRequestedLeadership, - changeRequestId, - 'Rejecting this', - false, - organization - ) - ).rejects.toThrow(AccessDeniedException); + ChangeRequestsService.reviewChangeRequest(memberUser, changeRequestId, 'trying to review', false, organization) + ).rejects.toThrow(AccessDeniedMemberException); }); }); @@ -452,9 +448,11 @@ describe('Change Request Tests', () => { const crA = await ChangeRequestsService.createStandardChangeRequest(user, 0, 1, 0, 'reason', organization); const crB = await ChangeRequestsService.createStandardChangeRequest(user, 0, 2, 0, 'reason', organization); + const adminUser = await createTestUser(batmanAppAdmin, orgId); + // getApprovedChangeRequests requires dateReviewed >= fiveDaysAgo - review both CRs to satisfy this - await ChangeRequestsService.reviewChangeRequest(otherUser, crA.crId, '', false, organization); - await ChangeRequestsService.reviewChangeRequest(otherUser, crB.crId, '', false, organization); + await ChangeRequestsService.reviewChangeRequest(adminUser, crA.crId, '', false, organization); + await ChangeRequestsService.reviewChangeRequest(adminUser, crB.crId, '', false, organization); const results = await ChangeRequestsService.getApprovedChangeRequests(user, undefined, organization, carAId); @@ -466,9 +464,11 @@ describe('Change Request Tests', () => { const crA = await ChangeRequestsService.createStandardChangeRequest(user, 0, 1, 0, 'reason', organization); const crB = await ChangeRequestsService.createStandardChangeRequest(user, 0, 2, 0, 'reason', organization); + const adminUser = await createTestUser(batmanAppAdmin, orgId); + // getApprovedChangeRequests requires dateReviewed >= fiveDaysAgo - review both CRs to satisfy this - await ChangeRequestsService.reviewChangeRequest(otherUser, crA.crId, '', false, organization); - await ChangeRequestsService.reviewChangeRequest(otherUser, crB.crId, '', false, organization); + await ChangeRequestsService.reviewChangeRequest(adminUser, crA.crId, '', false, organization); + await ChangeRequestsService.reviewChangeRequest(adminUser, crB.crId, '', false, organization); // wbsNum scopes to car A's project; carId points to car B - car filter should be ignored const wbsNum = { carNumber: 0, projectNumber: 1, workPackageNumber: 0 }; From 567df82ad20ec4ef8ccd9220c6726fb23437d55d Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sun, 26 Apr 2026 15:57:00 -0400 Subject: [PATCH 06/29] #4170 allow heads and above to review their own crs --- src/backend/src/services/change-requests.services.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 6d575abf5b..305dbb1b79 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -270,11 +270,11 @@ export default class ChangeRequestsService { if (foundCR.wbsElement?.dateDeleted) throw new DeletedException('WBS Element', wbsPipe(foundCR.wbsElement)); if (foundCR.organizationId !== organization.organizationId) throw new InvalidOrganizationException('Change Request'); - if (reviewer.userId === foundCR.submitterId) - throw new AccessDeniedException("You can't review your own change request!"); - const isHeadOrAdmin = await userHasPermission(reviewer.userId, organization.organizationId, isHead); + if (reviewer.userId === foundCR.submitterId && !isHeadOrAdmin) + throw new AccessDeniedException("You can't review your own change request!"); + if (foundCR.requestedReviewers.length > 0) { const isRequestedReviewer = foundCR.requestedReviewers.some((user) => user.userId === reviewer.userId); if (!isRequestedReviewer && !isHeadOrAdmin) { From 44fc98bc7a2311ec5a567004f2df6d0af5015246 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sun, 26 Apr 2026 20:51:22 -0400 Subject: [PATCH 07/29] #4171 diff calc and added why + changes to notif --- .../src/services/change-requests.services.ts | 21 ++++-- .../src/utils/change-requests.utils.ts | 71 +++++++++++++++++++ src/backend/src/utils/slack.utils.ts | 60 ++++++++++++++++ 3 files changed, 146 insertions(+), 6 deletions(-) diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index b08ba10830..a54db69e04 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -32,6 +32,7 @@ import changeRequestTransformer, { } from '../transformers/change-requests.transformer.js'; import { allChangeRequestsReviewed, + buildCRDiff, validateProposedChangesFields, applyProjectProposedChanges, applyWorkPackageProposedChanges, @@ -49,7 +50,8 @@ import { addSlackThreadsToChangeRequest, sendAndGetSlackCRNotifications, sendSlackCRStatusToThread, - sendSlackRequestedReviewNotification + sendSlackRequestedReviewNotification, + sendStandardCRCreatedNotification } from '../utils/slack.utils.js'; import { ChangeRequestWithProjectAndWorkPackageQueryArgs, @@ -993,6 +995,10 @@ export default class ChangeRequestsService { const wbsElement = await prisma.wBS_Element.findUnique({ where: { wbsNumber: { carNumber, projectNumber, workPackageNumber, organizationId: organization.organizationId } + }, + include: { + project: { select: { budget: true, summary: true } }, + workPackage: { select: { startDate: true, duration: true, stage: true } } } }); @@ -1199,14 +1205,17 @@ export default class ChangeRequestsService { const project = createdCR.wbsElement?.workPackage?.project || createdCR.wbsElement?.project; const teams = project?.teams; if (teams && teams.length > 0) { - const notifications: { channelId: string; ts: string }[] = await sendAndGetSlackCRNotifications( - teams, + const diffText = buildCRDiff(wbsElement, projectProposedChanges ?? workPackageProposedChanges); + await sendStandardCRCreatedNotification( createdCR, + wbsElement.name, + project.wbsElement.name, submitter, - wbsElement, - project.wbsElement.name + teams, + why, + requestedReviewerId, + diffText ); - await addSlackThreadsToChangeRequest(createdCR.crId, notifications); } const finishedCR = await prisma.change_Request.findUnique({ diff --git a/src/backend/src/utils/change-requests.utils.ts b/src/backend/src/utils/change-requests.utils.ts index 4fc4698157..955e325c57 100644 --- a/src/backend/src/utils/change-requests.utils.ts +++ b/src/backend/src/utils/change-requests.utils.ts @@ -12,7 +12,9 @@ import { } from '@prisma/client'; import { DescriptionBulletPreview, + formatDateOnly, LinkCreateArgs, + ProjectProposedChangesCreateArgs, WbsNumber, wbsPipe, WorkPackageProposedChangesCreateArgs, @@ -382,6 +384,75 @@ export const applyWorkPackageProposedChanges = async ( } }; +/** + * Builds a human-readable diff string comparing current WBS state to proposed changes. + * Each changed field is shown as two lines: "− Field: old" and "+ Field: new". + */ +export const buildCRDiff = ( + currentWbs: { + name: string; + leadId: string | null; + managerId: string | null; + project?: { budget: number; summary: string } | null; + workPackage?: { startDate: Date; duration: number; stage: string | null } | null; + }, + proposed: ProjectProposedChangesCreateArgs | WorkPackageProposedChangesCreateArgs | undefined +): string => { + if (!proposed) return ''; + + const isWpChange = 'startDate' in proposed; + const lines: string[] = []; + + const addDiff = (label: string, before: string | number | null | undefined, after: string | number | null | undefined) => { + const b = String(before ?? '(none)'); + const a = String(after ?? '(none)'); + if (b !== a) { + lines.push(`− ${label}: ${b}`); + lines.push(`+ ${label}: ${a}`); + } + }; + + const addNew = (label: string, value: string | number | null | undefined) => { + if (value !== null && value !== undefined) lines.push(`+ ${label}: ${value}`); + }; + + if (isWpChange) { + const wpProposed = proposed as WorkPackageProposedChangesCreateArgs; + if (!currentWbs.workPackage) { + addNew('Name', wpProposed.name); + addNew('Lead', wpProposed.leadId); + addNew('Manager', wpProposed.managerId); + addNew('Start date', wpProposed.startDate); + addNew('Duration', wpProposed.duration); + addNew('Stage', wpProposed.stage); + } else { + addDiff('Name', currentWbs.name, wpProposed.name); + addDiff('Lead', currentWbs.leadId, wpProposed.leadId); + addDiff('Manager', currentWbs.managerId, wpProposed.managerId); + addDiff('Start date', formatDateOnly(currentWbs.workPackage.startDate, 'YYYY-MM-DD'), wpProposed.startDate); + addDiff('Duration', currentWbs.workPackage.duration, wpProposed.duration); + addDiff('Stage', currentWbs.workPackage.stage, wpProposed.stage); + } + } else { + const projProposed = proposed as ProjectProposedChangesCreateArgs; + if (!currentWbs.project) { + addNew('Name', projProposed.name); + addNew('Lead', projProposed.leadId); + addNew('Manager', projProposed.managerId); + addNew('Budget', projProposed.budget); + addNew('Summary', projProposed.summary); + } else { + addDiff('Name', currentWbs.name, projProposed.name); + addDiff('Lead', currentWbs.leadId, projProposed.leadId); + addDiff('Manager', currentWbs.managerId, projProposed.managerId); + addDiff('Budget', currentWbs.project.budget, projProposed.budget); + addDiff('Summary', currentWbs.project.summary, projProposed.summary); + } + } + + return lines.join('\n'); +}; + /** * Sends a slack notification to the submitter of the change request that their change request has been reviewed * @param foundCR the change request that was reviewed diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index b85a4ca721..7ff7ef757a 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -549,6 +549,66 @@ export const sendSlackCRStatusToThread = async ( } }; +/** + * Sends Slack notifications for a newly created standard (manual) CR: + * 1. Initial message to each project team channel + * 2. Thread reply tagging the project head(s) and requested reviewer(s) + * 3. Thread reply with the why text and field diff + * Also stores Message_Info records linking threads to the CR. + */ +export const sendStandardCRCreatedNotification = async ( + cr: Change_Request, + wbsElementName: string, + projectWbsName: string, + submitter: User, + teams: Team[], + why: string, + requestedReviewerId: string | undefined, + diffText: string +): Promise => { + if (process.env.NODE_ENV !== 'production' && !DEV_TESTING_OVERRIDE) return; + + const message = + wbsElementName !== projectWbsName + ? `${submitter.firstName} ${submitter.lastName} submitted a change request for ${wbsElementName} in ${projectWbsName}` + : `${submitter.firstName} ${submitter.lastName} submitted a change request for ${projectWbsName}`; + const notifications: { channelId: string; ts: string }[] = []; + + await Promise.all( + teams.map(async (team) => { + const sent = await sendSlackChangeRequestNotification(team, message, cr.crId, cr.identifier); + notifications.push(...sent); + }) + ); + + if (notifications.length === 0) return; + + await addSlackThreadsToChangeRequest(cr.crId, notifications); + + // Thread reply 1: why + diff + const whyAndDiff = diffText + ? `*Change Justification:*\n${why}\n\n*Proposed Changes:*\n${diffText}` + : `*Change Justification:*\n${why}`; + await Promise.all(notifications.map((n) => replyToMessageInThread(n.channelId, n.ts, whyAndDiff))); + + // Thread reply 2: tag project head(s) + requested reviewer + const headSlackIds = (await Promise.all(teams.filter((t) => t.headId).map((t) => getUserSlackId(t.headId!)))).filter( + (id): id is string => !!id + ); + + const reviewerSlackId = requestedReviewerId ? await getUserSlackId(requestedReviewerId) : undefined; + const allSlackIds = new Set([...headSlackIds, ...(reviewerSlackId ? [reviewerSlackId] : [])]); + const allMentions = [...allSlackIds].map((id) => `<@${id}>`).join(' '); + + if (allMentions) { + const reviewMsg = `${allMentions} Your review has been requested on CR #${cr.identifier}!`; + const crLink = `https://finishlinebyner.com/cr/${cr.crId}`; + await Promise.all( + notifications.map((n) => replyToMessageInThread(n.channelId, n.ts, reviewMsg, crLink, `View CR #${cr.identifier}`)) + ); + } +}; + /** * Adds the relevant slack notifications for a change request to the change request * From e42d41411428b879f75577c5947d80bc76b82eb7 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sun, 26 Apr 2026 21:15:51 -0400 Subject: [PATCH 08/29] #4171 added project link diffs --- .../src/services/change-requests.services.ts | 1 + .../src/utils/change-requests.utils.ts | 21 +++++++++++++++++++ src/backend/src/utils/slack.utils.ts | 9 ++++---- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index a54db69e04..0dda93d044 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -997,6 +997,7 @@ export default class ChangeRequestsService { wbsNumber: { carNumber, projectNumber, workPackageNumber, organizationId: organization.organizationId } }, include: { + links: { where: { dateDeleted: null }, include: { linkType: { select: { name: true } } } }, project: { select: { budget: true, summary: true } }, workPackage: { select: { startDate: true, duration: true, stage: true } } } diff --git a/src/backend/src/utils/change-requests.utils.ts b/src/backend/src/utils/change-requests.utils.ts index 955e325c57..1ea52d0af8 100644 --- a/src/backend/src/utils/change-requests.utils.ts +++ b/src/backend/src/utils/change-requests.utils.ts @@ -393,6 +393,7 @@ export const buildCRDiff = ( name: string; leadId: string | null; managerId: string | null; + links: { url: string; linkType: { name: string } }[]; project?: { budget: number; summary: string } | null; workPackage?: { startDate: Date; duration: number; stage: string | null } | null; }, @@ -416,6 +417,22 @@ export const buildCRDiff = ( if (value !== null && value !== undefined) lines.push(`+ ${label}: ${value}`); }; + const addLinkDiffs = (currentLinks: { url: string; linkType: { name: string } }[], proposedLinks: LinkCreateArgs[]) => { + const currentMap = new Map(currentLinks.map((l) => [l.linkType.name, l.url])); + const proposedMap = new Map(proposedLinks.map((l) => [l.linkTypeName, l.url])); + for (const [name, url] of currentMap) { + if (!proposedMap.has(name)) lines.push(`− ${name}: ${url}`); + } + for (const [name, url] of proposedMap) { + if (!currentMap.has(name)) { + lines.push(`+ ${name}: ${url}`); + } else if (currentMap.get(name) !== url) { + lines.push(`− ${name}: ${currentMap.get(name)}`); + lines.push(`+ ${name}: ${url}`); + } + } + }; + if (isWpChange) { const wpProposed = proposed as WorkPackageProposedChangesCreateArgs; if (!currentWbs.workPackage) { @@ -425,6 +442,7 @@ export const buildCRDiff = ( addNew('Start date', wpProposed.startDate); addNew('Duration', wpProposed.duration); addNew('Stage', wpProposed.stage); + wpProposed.links.forEach((l) => addNew(l.linkTypeName, l.url)); } else { addDiff('Name', currentWbs.name, wpProposed.name); addDiff('Lead', currentWbs.leadId, wpProposed.leadId); @@ -432,6 +450,7 @@ export const buildCRDiff = ( addDiff('Start date', formatDateOnly(currentWbs.workPackage.startDate, 'YYYY-MM-DD'), wpProposed.startDate); addDiff('Duration', currentWbs.workPackage.duration, wpProposed.duration); addDiff('Stage', currentWbs.workPackage.stage, wpProposed.stage); + addLinkDiffs(currentWbs.links, wpProposed.links); } } else { const projProposed = proposed as ProjectProposedChangesCreateArgs; @@ -441,12 +460,14 @@ export const buildCRDiff = ( addNew('Manager', projProposed.managerId); addNew('Budget', projProposed.budget); addNew('Summary', projProposed.summary); + projProposed.links.forEach((l) => addNew(l.linkTypeName, l.url)); } else { addDiff('Name', currentWbs.name, projProposed.name); addDiff('Lead', currentWbs.leadId, projProposed.leadId); addDiff('Manager', currentWbs.managerId, projProposed.managerId); addDiff('Budget', currentWbs.project.budget, projProposed.budget); addDiff('Summary', currentWbs.project.summary, projProposed.summary); + addLinkDiffs(currentWbs.links, projProposed.links); } } diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index 7ff7ef757a..c083200340 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -552,8 +552,8 @@ export const sendSlackCRStatusToThread = async ( /** * Sends Slack notifications for a newly created standard (manual) CR: * 1. Initial message to each project team channel - * 2. Thread reply tagging the project head(s) and requested reviewer(s) - * 3. Thread reply with the why text and field diff + * 2. Thread reply with the why text and field diff + * 3. Thread reply tagging the project head(s) and requested reviewer(s) * Also stores Message_Info records linking threads to the CR. */ export const sendStandardCRCreatedNotification = async ( @@ -583,15 +583,16 @@ export const sendStandardCRCreatedNotification = async ( if (notifications.length === 0) return; + // add message_Info records for the sent notifications so we can link the CR to the slack threads for future replies await addSlackThreadsToChangeRequest(cr.crId, notifications); - // Thread reply 1: why + diff + // Thread reply: why + diff const whyAndDiff = diffText ? `*Change Justification:*\n${why}\n\n*Proposed Changes:*\n${diffText}` : `*Change Justification:*\n${why}`; await Promise.all(notifications.map((n) => replyToMessageInThread(n.channelId, n.ts, whyAndDiff))); - // Thread reply 2: tag project head(s) + requested reviewer + // Thread reply: tag project head(s) + requested reviewer const headSlackIds = (await Promise.all(teams.filter((t) => t.headId).map((t) => getUserSlackId(t.headId!)))).filter( (id): id is string => !!id ); From e96d2150aad0cd9903713615203b4288fb191ebe Mon Sep 17 00:00:00 2001 From: wavehassman Date: Mon, 27 Apr 2026 17:25:59 -0400 Subject: [PATCH 09/29] #4173 made reviewer a user, fixed up change request form --- .../src/hooks/change-requests.hooks.ts | 1 + .../CreateChangeRequest.tsx | 21 ++++-------- .../CreateChangeRequestView.tsx | 32 ++++++++++++------- .../ProjectForm/ProjectCreateContainer.tsx | 1 + .../ProjectForm/ProjectEditContainer.tsx | 1 + 5 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/frontend/src/hooks/change-requests.hooks.ts b/src/frontend/src/hooks/change-requests.hooks.ts index 0a007ef40a..085c62904e 100644 --- a/src/frontend/src/hooks/change-requests.hooks.ts +++ b/src/frontend/src/hooks/change-requests.hooks.ts @@ -151,6 +151,7 @@ export const useDeleteChangeRequest = () => { export type CreateStandardChangeRequestPayload = { wbsNum: WbsNumber; why: string; + requestedReviewerId?: string; projectProposedChanges?: ProjectProposedChangesCreateArgs; workPackageProposedChanges?: WorkPackageProposedChangesCreateArgs; }; diff --git a/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequest.tsx b/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequest.tsx index 6c995cdd08..ef48ecd500 100644 --- a/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequest.tsx +++ b/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequest.tsx @@ -26,30 +26,23 @@ const CreateChangeRequest: React.FC = () => { const [wbsNum, setWbsNum] = useState(query.get('wbsNum') || ''); const toast = useToast(); const changeRequestSchema = yup.object().shape({ - why: yup.string().required('Why Explain is required') + why: yup.string().required('Why Explain is required'), + requestedReviewerId: yup.string().optional() }); const { reset: resetChangeRequestForm, ...changeRequestFormMethods } = useForm({ resolver: yupResolver(changeRequestSchema), defaultValues: query.get('budgetChange') - ? { - why: 'The cost of materials ended up exceeding the initial budget' - } + ? { why: 'The cost of materials ended up exceeding the initial budget' } : query.get('timelineDelay') - ? { - why: 'Decided to extend timeline after design review' - } + ? { why: 'Decided to extend timeline after design review' } : query.get('createWP') - ? { - why: 'Creating a Work Package on this Project' - } - : { - why: '' - } + ? { why: 'Creating a Work Package on this Project' } + : { why: '' } }); - if (isLoading) return ; if (isError) return ; + if (isLoading) return ; const handleConfirm = async (data: FormInput) => { try { diff --git a/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestView.tsx b/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestView.tsx index 277126b55c..1e769cfad8 100644 --- a/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestView.tsx +++ b/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestView.tsx @@ -10,6 +10,8 @@ import { FormControl, FormLabel } from '@mui/material'; import ReactHookTextField from '../../components/ReactHookTextField'; import NERAutocomplete from '../../components/NERAutocomplete'; import { useAllProjects } from '../../hooks/projects.hooks'; +import { useAllMembers } from '../../hooks/users.hooks'; +import { userToAutocompleteOption } from '../../utils/teams.utils'; import ErrorPage from '../ErrorPage'; import LoadingIndicator from '../../components/LoadingIndicator'; import NERFailButton from '../../components/NERFailButton'; @@ -56,9 +58,13 @@ const CreateChangeRequestsView: React.FC = ({ } = changeRequestFormReturn; const { isLoading, isError, error, data: projects } = useAllProjects(); + const { isLoading: membersIsLoading, isError: membersIsError, error: membersError, data: members } = useAllMembers(); - if (isLoading || !projects) return ; if (isError) return ; + if (membersIsError) return ; + if (isLoading || !projects || membersIsLoading || !members) return ; + + const memberOptions = members.map(userToAutocompleteOption); const wbsDropdownOptions: { label: string; id: string }[] = []; @@ -115,8 +121,8 @@ const CreateChangeRequestsView: React.FC = ({ Cancel )} - - Submit + + {'Submit'} } @@ -151,15 +157,17 @@ const CreateChangeRequestsView: React.FC = ({ - - Requested Reviewer (optional) - - + Requested Reviewer (optional) + { + changeRequestFormReturn.setValue('requestedReviewerId', value?.id ?? undefined); + }} + options={memberOptions} + size="small" + placeholder="Select a reviewer" + value={memberOptions.find((m) => m.id === changeRequestFormReturn.watch('requestedReviewerId')) ?? null} + /> diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx index 57deddc7d7..ed55bcd7b5 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx @@ -131,6 +131,7 @@ const ProjectCreateContainer: React.FC = () => { const changeRequestPayload: CreateStandardChangeRequestPayload = { wbsNum: { carNumber, projectNumber: 0, workPackageNumber: 0 }, why, + requestedReviewerId: data.requestedReviewerId, projectProposedChanges: projectPayload }; await mutateCRAsync(changeRequestPayload); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx index 8f70d6dd33..58b7105a6c 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx @@ -183,6 +183,7 @@ const ProjectEditContainer: React.FC = ({ project, ex const changeRequestPayload: CreateStandardChangeRequestPayload = { wbsNum: project.wbsNum, why, + requestedReviewerId: data.requestedReviewerId, projectProposedChanges: projectPayload }; await mutateCRAsync(changeRequestPayload); From 3265598321eadaa3f86214ff522eb86b34e6df1c Mon Sep 17 00:00:00 2001 From: wavehassman Date: Mon, 27 Apr 2026 21:45:15 -0400 Subject: [PATCH 10/29] #4174 removed old cr stuff and fixed up all the frontend --- .../change-requests.controllers.ts | 4 +- src/backend/src/prisma/seed.ts | 54 +++++++------- .../src/routes/change-requests.routes.ts | 3 +- .../src/services/change-requests.services.ts | 34 ++++----- src/backend/tests/test-utils.ts | 1 + .../tests/unit/change-requests.test.ts | 30 ++++---- src/frontend/src/apis/change-requests.api.ts | 11 +-- .../src/hooks/change-requests.hooks.ts | 6 +- .../ChangeRequestActionMenu.tsx | 22 +----- .../ReviewChangeRequest.tsx | 11 ++- .../ReviewChangeRequestView.tsx | 16 +---- .../CreateChangeRequest.tsx | 3 +- .../ProjectForm/ProjectForm.tsx | 4 +- .../ProjectViewContainer.tsx | 13 +--- .../WorkPackageDetailPage/WorkPackagePage.tsx | 1 - .../WorkPackageViewContainer.tsx | 12 ---- .../WorkPackageForm/WorkPackageFormView.tsx | 70 +++++++------------ .../WorkPackageViewContainer.test.tsx | 2 - 18 files changed, 105 insertions(+), 192 deletions(-) diff --git a/src/backend/src/controllers/change-requests.controllers.ts b/src/backend/src/controllers/change-requests.controllers.ts index 48284b7843..8f42c26144 100644 --- a/src/backend/src/controllers/change-requests.controllers.ts +++ b/src/backend/src/controllers/change-requests.controllers.ts @@ -87,9 +87,9 @@ export default class ChangeRequestsController { const id = await ChangeRequestsService.reviewChangeRequest( req.currentUser, crId, - reviewNotes, accepted, - req.organization + req.organization, + reviewNotes ); res.status(200).json({ message: `Change request #${id} successfully reviewed.` }); } catch (error: unknown) { diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 05f57d0973..52bcb917c1 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -367,7 +367,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(batman, changeRequest1.crId, 'LGTM', true, ner); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequest1.crId, true, ner, 'LGTM'); /** Set the organization ID in the current process environment and update .env */ process.env.DEV_ORGANIZATION_ID = organizationId; @@ -1113,7 +1113,7 @@ const performSeed: () => Promise = async () => { const changeRequestProjectHuskies1Id = changeRequestHuskiesProject1.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectHuskies1Id, 'LGTM', true, ner); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectHuskies1Id, true, ner, 'LGTM'); const changeRequestProjectSlackbot1 = await ChangeRequestsService.createStandardChangeRequest( cyborg, @@ -1127,7 +1127,7 @@ const performSeed: () => Promise = async () => { const changeRequestProjectSlackbot1Id = changeRequestProjectSlackbot1.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectSlackbot1Id, 'LGTM', true, ner); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectSlackbot1Id, true, ner, 'LGTM'); const changeRequestProjectAvatar1 = await ChangeRequestsService.createStandardChangeRequest( cyborg, @@ -1152,10 +1152,10 @@ const performSeed: () => Promise = async () => { const changeRequestProjectJustice1Id = changeRequestProjectJustice1.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectJustice1Id, 'LGTM', true, ner); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectJustice1Id, true, ner, 'LGTM'); // approve the change request - await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectAvatar1Id, 'LGTM', true, ner); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectAvatar1Id, true, ner, 'LGTM'); const changeRequestProjectJustice2 = await ChangeRequestsService.createStandardChangeRequest( cyborg, @@ -1169,7 +1169,7 @@ const performSeed: () => Promise = async () => { const changeRequestProjectJustice2Id = changeRequestProjectJustice2.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectJustice2Id, 'LGTM', true, ner); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectJustice2Id, true, ner, 'LGTM'); const changeRequestProjectRavens1 = await ChangeRequestsService.createStandardChangeRequest( cyborg, @@ -1183,7 +1183,7 @@ const performSeed: () => Promise = async () => { const changeRequestProjectRavens1Id = changeRequestProjectRavens1.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectRavens1Id, 'LGTM', true, ner); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectRavens1Id, true, ner, 'LGTM'); const changeRequestProjectSlackbot2 = await ChangeRequestsService.createStandardChangeRequest( cyborg, @@ -1197,7 +1197,7 @@ const performSeed: () => Promise = async () => { const changeRequestProjectSlackbot2Id = changeRequestProjectSlackbot2.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectSlackbot2Id, 'LGTM', true, ner); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectSlackbot2Id, true, ner, 'LGTM'); const changeRequestProjectKrusty1 = await ChangeRequestsService.createStandardChangeRequest( squidward, @@ -1211,7 +1211,7 @@ const performSeed: () => Promise = async () => { const changeRequestProjectKrusty1Id = changeRequestProjectKrusty1.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectKrusty1Id, 'LGTM', true, ner); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectKrusty1Id, true, ner, 'LGTM'); // Project 2 @@ -1227,7 +1227,7 @@ const performSeed: () => Promise = async () => { const changeRequestProjectKrusty2Id = changeRequestProjectKrusty2.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectKrusty2Id, 'LGTM', true, ner); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectKrusty2Id, true, ner, 'LGTM'); // Penguins // For Project 1 @@ -1243,7 +1243,7 @@ const performSeed: () => Promise = async () => { const changeRequestProjectPenguin1Id = changeRequestProjectPenguin1.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectPenguin1Id, 'LGTM', true, ner); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectPenguin1Id, true, ner, 'LGTM'); // For Project 2 @@ -1259,7 +1259,7 @@ const performSeed: () => Promise = async () => { const changeRequestProjectPenguin2Id = changeRequestProjectPenguin2.crId; // approve the change request - await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectPenguin2Id, 'LGTM', true, ner); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectPenguin2Id, true, ner, 'LGTM'); /** * Work Packages @@ -1294,7 +1294,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackage1ActivationCrId, 'Looks good to me!', true, ner); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackage1ActivationCrId, true, ner, 'Looks good to me!'); // await DescriptionBulletsService.checkDescriptionBullet(thomasEmrax, workPackage1.description[0].descriptionId); @@ -1350,7 +1350,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageSlackbot1ActivationCrId, 'LGTM!', true, ner); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageSlackbot1ActivationCrId, true, ner, 'LGTM!'); /** Work Package Slackbot 2 */ const { workPackageWbsNumber: workPackageSlackbot2WbsNumber, workPackage: workPackage4 } = await seedWorkPackage( @@ -1382,7 +1382,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageSlackbot2ActivationCrId, 'LGTM!', true, ner); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageSlackbot2ActivationCrId, true, ner, 'LGTM!'); /** AVATAR TEAM */ /** Work Packages for Project 1 */ @@ -1420,9 +1420,9 @@ const performSeed: () => Promise = async () => { await ChangeRequestsService.reviewChangeRequest( joeShmoe, workPackageAvatarProject1ActivationCrId, - 'Very cute LGTM!', true, - ner + ner, + 'Very cute LGTM!' ); /** Work Package 2 */ @@ -1456,7 +1456,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageAvatarProject2ActivationCrId, 'LGTM!', true, ner); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageAvatarProject2ActivationCrId, true, ner, 'LGTM!'); /** Work Package 3 */ const { workPackageWbsNumber: workPackageAvatarProject3WbsNumber, workPackage: workPackageAvatarProject3 } = @@ -1489,7 +1489,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageAvatarProject3ActivationCrId, 'LFG', true, ner); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageAvatarProject3ActivationCrId, true, ner, 'LFG'); /** Work Packages for Justice League */ /** Project 1 */ @@ -1523,7 +1523,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectJustice1WP1ActivationCrId, 'Approved!', true, ner); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectJustice1WP1ActivationCrId, true, ner, 'Approved!'); /** Work Package 2 */ await seedWorkPackage( @@ -1592,7 +1592,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectJustice2WP1ActivationCrId, 'Approved!', true, ner); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectJustice2WP1ActivationCrId, true, ner, 'Approved!'); /** Work Package 2 */ await seedWorkPackage( @@ -1643,7 +1643,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, project4WP1ActivationCrId, 'Approved!', true, ner); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, project4WP1ActivationCrId, true, ner, 'Approved!'); /** Work Package 2 */ await seedWorkPackage( @@ -1712,7 +1712,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectKrusty1WP1ActivationCrId, 'Approved!', true, ner); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectKrusty1WP1ActivationCrId, true, ner, 'Approved!'); /** Work Package 2 */ await seedWorkPackage( @@ -1781,7 +1781,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectKrusty2WP1ActivationCrId, 'Approved!', true, ner); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectKrusty2WP1ActivationCrId, true, ner, 'Approved!'); /** Work Package 2 */ await seedWorkPackage( @@ -2157,7 +2157,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectPenguin1WP1ActivationCrId, 'Approved!', true, ner); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectPenguin1WP1ActivationCrId, true, ner, 'Approved!'); /** Work Packages for Penguin Project 2*/ /** Work Package 1 */ @@ -2244,7 +2244,7 @@ const performSeed: () => Promise = async () => { 'Change the bodywork to be hot pink', ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, changeRequest2.crId, 'What the hell Thomas', false, ner); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, changeRequest2.crId, false, ner, 'What the hell Thomas'); await ChangeRequestsService.createActivationChangeRequest( thomasEmrax, @@ -3136,7 +3136,7 @@ const performSeed: () => Promise = async () => { 'This is a wpchange test', ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, newWorkPackageChangeRequest.crId, 'create wp', true, ner); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, newWorkPackageChangeRequest.crId, true, ner, 'create wp'); const { workPackageWbsNumber: workPackage9WbsNumber } = await seedWorkPackage( thomasEmrax, diff --git a/src/backend/src/routes/change-requests.routes.ts b/src/backend/src/routes/change-requests.routes.ts index 6748f6cb90..593d02f102 100644 --- a/src/backend/src/routes/change-requests.routes.ts +++ b/src/backend/src/routes/change-requests.routes.ts @@ -25,9 +25,8 @@ changeRequestsRouter.post( '/review', nonEmptyString(body('reviewerId')), nonEmptyString(body('crId')), - body('reviewNotes').isString(), + body('reviewNotes').isString().optional(), body('accepted').isBoolean(), - body('psId').optional().isString().not().isEmpty(), validateInputs, ChangeRequestsController.reviewChangeRequest ); diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 305dbb1b79..dff70da29a 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -243,21 +243,12 @@ export default class ChangeRequestsService { return changeRequests.map(changeRequestManyTransformer); } - /** - * Reviews the change request for the given Id and automates any changes that are made - * @param reviewer The user reviewing the change request - * @param crId the change request id - * @param reviewNotes any notes passed in by the reviewer - * @param accepted whether or not the change request is accepted - * @param organization the organization the user is currently in - * @returns the id of the reviewed change request - */ static async reviewChangeRequest( reviewer: User, crId: string, - reviewNotes: string, accepted: boolean, - organization: Organization + organization: Organization, + reviewNotes?: string ): Promise { const foundCR = await prisma.change_Request.findUnique({ where: { crId }, @@ -284,16 +275,7 @@ export default class ChangeRequestsService { throw new AccessDeniedMemberException('review change requests'); } - if (accepted && foundCR.type === CR_Type.STAGE_GATE) { - await this.reviewStageGateChangeRequest(foundCR, reviewer); - } else if (foundCR.type === CR_Type.ACTIVATION && foundCR.activationChangeRequest && accepted) { - await this.reviewActivationChangeRequest(foundCR, reviewer); - } else if (foundCR.type === CR_Type.BUDGET && foundCR.budgetChangeRequest && accepted) { - await this.reviewBudgetChangeRequest(foundCR, reviewer); - } else if (foundCR.type === CR_Type.STANDARD && accepted) { - await this.reviewStandardChangeRequest(foundCR, reviewer, organization); - } - + // Mark as reviewed FIRST so validateChangeRequestAccepted passes when applying proposed changes const updated = await prisma.change_Request.update({ where: { crId }, data: { @@ -308,6 +290,16 @@ export default class ChangeRequestsService { } }); + if (accepted && foundCR.type === CR_Type.STAGE_GATE) { + await this.reviewStageGateChangeRequest(foundCR, reviewer); + } else if (foundCR.type === CR_Type.ACTIVATION && foundCR.activationChangeRequest && accepted) { + await this.reviewActivationChangeRequest(foundCR, reviewer); + } else if (foundCR.type === CR_Type.BUDGET && foundCR.budgetChangeRequest && accepted) { + await this.reviewBudgetChangeRequest(foundCR, reviewer); + } else if (foundCR.type === CR_Type.STANDARD && accepted) { + await this.reviewStandardChangeRequest(foundCR, reviewer, organization); + } + await sendCRSubmitterReviewedNotification(updated); await sendCrReviewedPopUp(foundCR, updated.submitter, accepted, organization.organizationId); await sendSlackCRStatusToThread(updated.notificationSlackThreads, foundCR.crId, foundCR.identifier, accepted); diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index 9c4d40ce39..14b07e2dff 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -138,6 +138,7 @@ export const resetUsers = async () => { await prisma.activation_CR.deleteMany(); await prisma.change.deleteMany(); await prisma.budget_CR.deleteMany(); + await prisma.leadership_CR.deleteMany(); await prisma.change_Request.deleteMany(); await prisma.link.deleteMany(); await prisma.link_Type.deleteMany(); diff --git a/src/backend/tests/unit/change-requests.test.ts b/src/backend/tests/unit/change-requests.test.ts index 224ac950d7..623094b0b2 100644 --- a/src/backend/tests/unit/change-requests.test.ts +++ b/src/backend/tests/unit/change-requests.test.ts @@ -230,9 +230,9 @@ describe('Change Request Tests', () => { const reviewResult = await ChangeRequestsService.reviewChangeRequest( nonRequestedLeadership, changeRequestId, - 'Looks good', false, - organization + organization, + 'Looks good' ); expect(reviewResult).toBe(changeRequestId); @@ -256,9 +256,9 @@ describe('Change Request Tests', () => { const reviewResult = await ChangeRequestsService.reviewChangeRequest( leadershipUser1, changeRequestId, - 'Approved', false, - organization + organization, + 'Approved' ); expect(reviewResult).toBe(changeRequestId); @@ -282,9 +282,9 @@ describe('Change Request Tests', () => { const reviewResult = await ChangeRequestsService.reviewChangeRequest( nonRequestedLeadership, changeRequestId, - 'I want to review this', false, - organization + organization, + 'I want to review this' ); expect(reviewResult).toBe(changeRequestId); @@ -301,9 +301,9 @@ describe('Change Request Tests', () => { const reviewResult = await ChangeRequestsService.reviewChangeRequest( leadershipUser2, changeRequestId, - 'Approved by second reviewer', false, - organization + organization, + 'Approved by second reviewer' ); expect(reviewResult).toBe(changeRequestId); @@ -343,16 +343,16 @@ describe('Change Request Tests', () => { const reviewResult = await ChangeRequestsService.reviewChangeRequest( nonRequestedLeadership, changeRequestId, - 'Rejecting this', false, - organization + organization, + 'Rejecting this' ); expect(reviewResult).toBe(changeRequestId); }); it('rejects member user from reviewing even when no specific reviewer is requested', async () => { await expect( - ChangeRequestsService.reviewChangeRequest(memberUser, changeRequestId, 'trying to review', false, organization) + ChangeRequestsService.reviewChangeRequest(memberUser, changeRequestId, false, organization, 'trying to review') ).rejects.toThrow(AccessDeniedMemberException); }); }); @@ -451,8 +451,8 @@ describe('Change Request Tests', () => { const adminUser = await createTestUser(batmanAppAdmin, orgId); // getApprovedChangeRequests requires dateReviewed >= fiveDaysAgo - review both CRs to satisfy this - await ChangeRequestsService.reviewChangeRequest(adminUser, crA.crId, '', false, organization); - await ChangeRequestsService.reviewChangeRequest(adminUser, crB.crId, '', false, organization); + await ChangeRequestsService.reviewChangeRequest(adminUser, crA.crId, false, organization, ''); + await ChangeRequestsService.reviewChangeRequest(adminUser, crB.crId, false, organization, ''); const results = await ChangeRequestsService.getApprovedChangeRequests(user, undefined, organization, carAId); @@ -467,8 +467,8 @@ describe('Change Request Tests', () => { const adminUser = await createTestUser(batmanAppAdmin, orgId); // getApprovedChangeRequests requires dateReviewed >= fiveDaysAgo - review both CRs to satisfy this - await ChangeRequestsService.reviewChangeRequest(adminUser, crA.crId, '', false, organization); - await ChangeRequestsService.reviewChangeRequest(adminUser, crB.crId, '', false, organization); + await ChangeRequestsService.reviewChangeRequest(adminUser, crA.crId, false, organization, ''); + await ChangeRequestsService.reviewChangeRequest(adminUser, crB.crId, false, organization, ''); // wbsNum scopes to car A's project; carId points to car B - car filter should be ignored const wbsNum = { carNumber: 0, projectNumber: 1, workPackageNumber: 0 }; diff --git a/src/frontend/src/apis/change-requests.api.ts b/src/frontend/src/apis/change-requests.api.ts index 638cbe93dc..22e46f18ce 100644 --- a/src/frontend/src/apis/change-requests.api.ts +++ b/src/frontend/src/apis/change-requests.api.ts @@ -61,19 +61,12 @@ export const getSingleChangeRequest = (id: string) => { * @param accepted Is the change request being accepted? * @param reviewNotes The notes attached to reviewing the change request. */ -export const reviewChangeRequest = ( - reviewerId: string, - crId: string, - accepted: boolean, - reviewNotes: string, - psId?: string -) => { +export const reviewChangeRequest = (reviewerId: string, crId: string, accepted: boolean, reviewNotes?: string) => { return axios.post<{ message: string }>(apiUrls.changeRequestsReview(), { reviewerId, crId, accepted, - reviewNotes, - psId + reviewNotes }); }; diff --git a/src/frontend/src/hooks/change-requests.hooks.ts b/src/frontend/src/hooks/change-requests.hooks.ts index 0a007ef40a..89a9e0373d 100644 --- a/src/frontend/src/hooks/change-requests.hooks.ts +++ b/src/frontend/src/hooks/change-requests.hooks.ts @@ -100,8 +100,7 @@ export interface ReviewPayload { reviewerId: string; crId: string; accepted: boolean; - reviewNotes: string; - psId?: string; + reviewNotes?: string; } /** @@ -116,8 +115,7 @@ export const useReviewChangeRequest = () => { reviewPayload.reviewerId, reviewPayload.crId, reviewPayload.accepted, - reviewPayload.reviewNotes, - reviewPayload.psId + reviewPayload.reviewNotes ); return data; }, diff --git a/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestActionMenu.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestActionMenu.tsx index ef45cff182..c4e96253b4 100644 --- a/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestActionMenu.tsx +++ b/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestActionMenu.tsx @@ -1,19 +1,16 @@ import { ChangeRequest, ChangeRequestStatus, isLeadership, User, wbsPipe } from 'shared'; import ActionsMenu from '../../components/ActionsMenu'; import { Autocomplete, Checkbox, TextField, Box } from '@mui/material'; -import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import ContentPasteIcon from '@mui/icons-material/ContentPaste'; import CheckBoxIcon from '@mui/icons-material/CheckBox'; import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; -import PostAddIcon from '@mui/icons-material/PostAdd'; -import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder'; +import EditIcon from '@mui/icons-material/Edit'; import { useHistory } from 'react-router-dom'; import { NERButton } from '../../components/NERButton'; import { useRequestCRReview } from '../../hooks/change-requests.hooks'; import { useToast } from '../../hooks/toasts.hooks'; import { useCurrentUser, useAllMembers } from '../../hooks/users.hooks'; -import { projectWbsPipe } from '../../utils/pipes'; import { routes } from '../../utils/routes'; import { useState } from 'react'; import ErrorPage from '../ErrorPage'; @@ -93,6 +90,7 @@ const ChangeRequestActionMenu: React.FC = ({ /> ); + const requestReviewerDropdown = () => ( <> = ({ return ( - history.push(`${routes.PROJECTS_NEW}?crId=${changeRequest.crId}&wbs=${projectWbsPipe(changeRequest.wbsNum!)}`), - disabled: !isUserAllowedToImplement, - icon: - }, - { - title: 'Create New Work Package', - onClick: () => - history.push( - `${routes.WORK_PACKAGE_NEW}?crId=${changeRequest.crId}&wbs=${projectWbsPipe(changeRequest.wbsNum!)}` - ), - disabled: !isUserAllowedToImplement, - icon: - }, { title: `Edit ${changeRequest.wbsNum.workPackageNumber === 0 ? 'Project' : 'Work Package'}`, onClick: () => diff --git a/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequest.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequest.tsx index 1cfe70f0df..be28b5c618 100644 --- a/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequest.tsx +++ b/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequest.tsx @@ -20,9 +20,8 @@ interface ReviewChangeRequestProps { } export interface FormInput { - reviewNotes: string; + reviewNotes?: string; accepted: boolean; - psId?: string; } const ReviewChangeRequest: React.FC = ({ @@ -39,7 +38,7 @@ const ReviewChangeRequest: React.FC = ({ const { isLoading, isError, error, mutateAsync } = useReviewChangeRequest(); const toast = useToast(); - const handleConfirm = async ({ reviewNotes, accepted, psId }: FormInput) => { + const handleConfirm = async ({ reviewNotes, accepted }: FormInput) => { handleClose(); if (auth.user?.userId === undefined) throw new Error('Cannot review change request without being logged in'); @@ -47,8 +46,7 @@ const ReviewChangeRequest: React.FC = ({ reviewerId: auth.user?.userId, crId, reviewNotes, - accepted, - psId + accepted }).catch((error) => { if (error instanceof Error) { toast.error(error.message); @@ -56,9 +54,8 @@ const ReviewChangeRequest: React.FC = ({ }); }; - if (isLoading) return ; - if (isError) return ; + if (isLoading) return ; if (cr.wbsNum) { return ( diff --git a/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequestView.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequestView.tsx index 7edf058266..4e382eed55 100644 --- a/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequestView.tsx +++ b/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequestView.tsx @@ -33,9 +33,8 @@ interface ReviewChangeRequestViewProps { } const schema = yup.object().shape({ - reviewNotes: yup.string().required(), - accepted: yup.boolean().required(), - psId: yup.string().optional() + reviewNotes: yup.string().optional(), + accepted: yup.boolean().required() }); const ReviewChangeRequestsView: React.FC = ({ @@ -51,17 +50,10 @@ const ReviewChangeRequestsView: React.FC = ({ resolver: yupResolver(schema) }); - /** - * Register (or set registered field) to the appropriate boolean based on which action button was clicked - * @param value true if review accepted, false if denied - */ const handleAcceptDeny = (value: boolean) => { getFieldState('accepted') ? setValue('accepted', value) : register('accepted', { value }); }; - /** - * Wrapper function for onSubmit so that form data is reset after submit - */ const onSubmitWrapper = async (data: FormInput) => { await onSubmit(data); reset({ reviewNotes: '' }); @@ -90,14 +82,12 @@ const ReviewChangeRequestsView: React.FC = ({ ( <> - {'Additional Comments'} + {'Additional Comments (optional)'} = () => { }; const handleCancel = () => { - history.push(routes.CHANGE_REQUESTS); + const returnUrl = query.get('returnUrl'); + history.push(returnUrl ? decodeURIComponent(returnUrl) : routes.CHANGE_REQUESTS); }; return ( diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx index 8829498789..d775563e63 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx @@ -240,7 +240,7 @@ const ProjectFormContainer: React.FC = ({ sx={{ mx: 1 }} disabled={changeRequestInputExists || onlyLeadershipChanged} > - Create Change Request + Submit Change Request )} @@ -253,7 +253,7 @@ const ProjectFormContainer: React.FC = ({ type="submit" sx={{ mx: 1 }} > - Submit + Submit & Implement } diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx index 3a6a158e81..0dcbb23640 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx @@ -3,13 +3,12 @@ * See the LICENSE file in the repository root folder for details. */ -import { Link, useHistory } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { Project, isGuest, isAdmin, isLeadership } from 'shared'; import { projectWbsPipe, wbsPipe } from '../../../utils/pipes'; import ProjectDetails from './ProjectDetails'; import { routes } from '../../../utils/routes'; import EditIcon from '@mui/icons-material/Edit'; -import SyncAltIcon from '@mui/icons-material/SyncAlt'; import { Box } from '@mui/material'; import { useState } from 'react'; import { useSetProjectTeam } from '../../../hooks/projects.hooks'; @@ -120,19 +119,11 @@ const ProjectViewContainer: React.FC = ({ project, en disabled: isGuest(user.role), icon: }, - { - title: 'Request Change', - onClick: handleDropdownClose, - disabled: isGuest(user.role), - icon: , - component: Link, - to: routes.CHANGE_REQUESTS_NEW_WITH_WBS + wbsPipe(project.wbsNum) - }, { title: 'Suggest Budget Increase', onClick: () => { history.push( - `${routes.CHANGE_REQUESTS_NEW}?wbsNum=${projectWbsPipe(project.wbsNum)}&budgetChange=${budgetIncrease}` + `${routes.CHANGE_REQUESTS_NEW}?wbsNum=${projectWbsPipe(project.wbsNum)}&budgetChange=${budgetIncrease}&returnUrl=${encodeURIComponent(`${routes.PROJECTS}/${wbsPipe(project.wbsNum)}`)}` ); }, disabled: !isLeadership(user.role) || budgetIncrease <= 0, diff --git a/src/frontend/src/pages/WorkPackageDetailPage/WorkPackagePage.tsx b/src/frontend/src/pages/WorkPackageDetailPage/WorkPackagePage.tsx index d2ea12863c..567da24c7d 100644 --- a/src/frontend/src/pages/WorkPackageDetailPage/WorkPackagePage.tsx +++ b/src/frontend/src/pages/WorkPackageDetailPage/WorkPackagePage.tsx @@ -37,7 +37,6 @@ const WorkPackagePage: React.FC = ({ wbsNum }) => { allowEdit={!isGuest(auth.user.role)} allowActivate={!isGuest(auth.user.role)} allowStageGate={!isGuest(auth.user.role)} - allowRequestChange={!isGuest(auth.user.role)} allowDelete={isAdmin(auth.user.role)} /> ); diff --git a/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx b/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx index 0bc079f640..8eeb9525d0 100644 --- a/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx +++ b/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx @@ -4,7 +4,6 @@ */ import { useState } from 'react'; -import { Link as RouterLink } from 'react-router-dom'; import { WbsElementStatus, WorkPackage } from 'shared'; import { wbsPipe } from '../../../utils/pipes'; import { routes } from '../../../utils/routes'; @@ -13,7 +12,6 @@ import WorkPackageDetails from './WorkPackageDetails'; import ChangesList from '../../../components/ChangesList'; import StageGateWorkPackageModalContainer from '../StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer'; import EditIcon from '@mui/icons-material/Edit'; -import SyncAltIcon from '@mui/icons-material/SyncAlt'; import KeyboardDoubleArrowUpIcon from '@mui/icons-material/KeyboardDoubleArrowUp'; import DoneOutlineIcon from '@mui/icons-material/DoneOutline'; import Delete from '@mui/icons-material/Delete'; @@ -33,7 +31,6 @@ interface WorkPackageViewContainerProps { allowEdit: boolean; allowActivate: boolean; allowStageGate: boolean; - allowRequestChange: boolean; allowDelete: boolean; } @@ -43,7 +40,6 @@ const WorkPackageViewContainer: React.FC = ({ allowEdit, allowActivate, allowStageGate, - allowRequestChange, allowDelete }) => { const [showActivateModal, setShowActivateModal] = useState(false); @@ -108,14 +104,6 @@ const WorkPackageViewContainer: React.FC = ({ }, ...(workPackage.status === WbsElementStatus.Inactive ? [activateButton] : []), ...(workPackage.status === WbsElementStatus.Active ? [stageGateButton] : []), - { - title: 'Request Change', - component: RouterLink, - to: routes.CHANGE_REQUESTS_NEW_WITH_WBS + wbsPipe(workPackage.wbsNum), - onClick: handleDropdownClose, - disabled: !allowRequestChange, - icon: - }, { title: 'Delete', onClick: handleClickDelete, diff --git a/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx b/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx index 8282dcafac..ddf9cfe3c4 100644 --- a/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx +++ b/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx @@ -139,7 +139,6 @@ const WorkPackageFormView: React.FC = ({ let changeRequestFormInput: FormInput | undefined = undefined; const pageTitle = defaultValues ? 'Edit Work Package' : 'Create Work Package'; - // lists of stuff const { fields: descriptionBullets, append: appendDescriptionBullet, @@ -168,20 +167,12 @@ const WorkPackageFormView: React.FC = ({ const { reset: resetChangeRequestForm, ...changeRequestFormMethods } = useForm({ resolver: yupResolver(changeRequestSchema), defaultValues: query.get('budgetChange') - ? { - why: 'The cost of materials ended up exceeding the initial budget' - } + ? { why: 'The cost of materials ended up exceeding the initial budget' } : query.get('timelineDelay') - ? { - why: 'Decided to extend timeline after design review' - } + ? { why: 'Decided to extend timeline after design review' } : query.get('createWP') - ? { - why: 'Creating a Work Package on this Project' - } - : { - why: '' - } + ? { why: 'Creating a Work Package on this Project' } + : { why: '' } }); useEffect(() => { @@ -201,7 +192,6 @@ const WorkPackageFormView: React.FC = ({ if (workPackageTemplateisLoading || !workPackageTemplates) return ; if (workPackageTemplateisError) return ; - // Check if only lead/manager changed const checkOnlyLeadershipChanged = ( formName: string, formStartDate: Date, @@ -210,7 +200,7 @@ const WorkPackageFormView: React.FC = ({ formStage: string, formDescriptionBullets: DescriptionBulletPreview[] ) => { - if (!defaultValues) return false; // Only relevant for edits + if (!defaultValues) return false; return ( formName === defaultValues.name && @@ -244,7 +234,6 @@ const WorkPackageFormView: React.FC = ({ managerId }; await createLeadershipCR(autoCRPayload); - // fixes cache issue await queryClient.refetchQueries(['work packages']); exitActiveMode(); return; @@ -292,7 +281,6 @@ const WorkPackageFormView: React.FC = ({ const startDate = watch('startDate'); const duration = watch('duration'); - // Calculate for submit button status const onlyLeadershipChanged = defaultValues ? checkOnlyLeadershipChanged( watch('name'), @@ -329,31 +317,27 @@ const WorkPackageFormView: React.FC = ({ title={pageTitle} headerRight={ - { - - - {`If you don't enter a Change Request ID into this form, you can create one here that when accepted will - ${ - defaultValues ? `edit the selected Work Package` : `create a new Work Package` - } with the inputted values`} - - } - placement="left" - > - - - setIsModalOpen(true)} - sx={{ mx: 1 }} - > - Create Change Request - - - } + + + {`If you don't enter a Change Request ID into this form, you can create one here that when accepted will + ${defaultValues ? `edit the selected Work Package` : `create a new Work Package`} with the inputted values`} + + } + placement="left" + > + + + setIsModalOpen(true)} + sx={{ mx: 1 }} + > + Submit Change Request + + Cancel @@ -364,7 +348,7 @@ const WorkPackageFormView: React.FC = ({ sx={{ mx: 1 }} disabled={!changeRequestInputExists && !!defaultValues && !onlyLeadershipChanged} > - Submit + Submit & Implement diff --git a/src/frontend/src/tests/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.test.tsx b/src/frontend/src/tests/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.test.tsx index 26ef66ecc8..64b52de74d 100644 --- a/src/frontend/src/tests/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.test.tsx +++ b/src/frontend/src/tests/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.test.tsx @@ -19,7 +19,6 @@ const renderComponent = ( allowEdit = true, allowActivate = true, allowStageGate = true, - allowRequestChange = true, allowDelete = true ) => { const RouterWrapper = routerWrapperBuilder({}); @@ -33,7 +32,6 @@ const renderComponent = ( allowEdit={allowEdit} allowActivate={allowActivate} allowStageGate={allowStageGate} - allowRequestChange={allowRequestChange} allowDelete={allowDelete} /> From 93ea2b7296190c32376ccef572fee460b74529a2 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Mon, 27 Apr 2026 22:31:12 -0400 Subject: [PATCH 11/29] #4174 added check on reviewer id and added bullets to diff --- .../src/services/change-requests.services.ts | 5 ++++ src/frontend/src/utils/diff-page.utils.ts | 29 +++++++++++-------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index dff70da29a..2e575d83fa 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -994,6 +994,11 @@ export default class ChangeRequestsService { throw new DeletedException('WBS Element', wbsPipe({ carNumber, projectNumber, workPackageNumber })); if (wbsElement.organizationId !== organization.organizationId) throw new InvalidOrganizationException('WBS Element'); + if (requestedReviewerId) { + const reviewer = await prisma.user.findUnique({ where: { userId: requestedReviewerId } }); + if (!reviewer) throw new NotFoundException('User', requestedReviewerId); + } + if ( projectNumber !== 0 && !(projectProposedChanges && projectProposedChanges.workPackageProposedChanges.length === 0) && diff --git a/src/frontend/src/utils/diff-page.utils.ts b/src/frontend/src/utils/diff-page.utils.ts index da13d9fe03..05fbb89bd1 100644 --- a/src/frontend/src/utils/diff-page.utils.ts +++ b/src/frontend/src/utils/diff-page.utils.ts @@ -181,20 +181,25 @@ export const getWbsChanges = ( ) ); - lines.push( - genListChange( - 'Description Bullets', - '', - (originalElement?.descriptionBullets.map((db) => ({ ...db, value: db.type + ' - ' + db.detail })) ?? []).sort((a, b) => - a.value.localeCompare(b.value) - ), - (proposedChanges?.descriptionBullets.map((db) => ({ ...db, value: db.type + ' - ' + db.detail })) ?? []).sort((a, b) => - a.value.localeCompare(b.value) - ), - (a, b) => a?.value !== b?.value - ) + const bulletTypes = Array.from( + new Set([ + ...(originalElement?.descriptionBullets.map((db) => db.type) ?? []), + ...(proposedChanges?.descriptionBullets.map((db) => db.type) ?? []) + ]) ); + bulletTypes.forEach((type) => { + const originalBullets = (originalElement?.descriptionBullets.filter((db) => db.type === type) ?? []) + .map((db) => ({ ...db, value: db.detail })) + .sort((a, b) => a.value.localeCompare(b.value)); + + const proposedBullets = (proposedChanges?.descriptionBullets.filter((db) => db.type === type) ?? []) + .map((db) => ({ ...db, value: db.detail })) + .sort((a, b) => a.value.localeCompare(b.value)); + + lines.push(genListChange(displayEnum(type), '', originalBullets, proposedBullets, (a, b) => a?.value !== b?.value)); + }); + return lines; }; From 9ea9b597ab22a80b3134b720f2c459f59469ae98 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Mon, 27 Apr 2026 22:54:49 -0400 Subject: [PATCH 12/29] #4174 fix tests --- .../ChangeRequestDetailsView.test.tsx | 9 +++------ .../ProjectViewContainer.test.tsx | 14 +++----------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/frontend/src/tests/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.test.tsx b/src/frontend/src/tests/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.test.tsx index 3547c9babc..6401c3a0e2 100644 --- a/src/frontend/src/tests/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.test.tsx +++ b/src/frontend/src/tests/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.test.tsx @@ -78,8 +78,7 @@ describe('Implement change request permission tests', () => { }); const actionBtnText = 'Implement Change Request'; - const newPrjBtnText = 'Create New Project'; - const newWPBtnText = 'Create New Work Package'; + const editBtnText = 'Edit Project'; it('Implementation actions disabled when not allowed', () => { mockSingleProjectHook(false, false, exampleProject1); @@ -87,8 +86,7 @@ describe('Implement change request permission tests', () => { mockUseLogUserInHook(false, false); renderComponent(exampleStandardChangeRequest); fireEvent.click(screen.getByText(actionBtnText)); - expect(screen.getByText(newPrjBtnText)).toHaveAttribute('aria-disabled'); - expect(screen.getByText(newWPBtnText)).toHaveAttribute('aria-disabled'); + expect(screen.getByText(editBtnText)).toHaveAttribute('aria-disabled'); }); it('Implementation actions enabled when allowed', () => { @@ -97,7 +95,6 @@ describe('Implement change request permission tests', () => { mockUseLogUserInHook(false, false); renderComponent(exampleStandardChangeRequest, true); fireEvent.click(screen.getByText(actionBtnText)); - expect(screen.getByText(newPrjBtnText)).not.toHaveAttribute('aria-disabled'); - expect(screen.getByText(newWPBtnText)).not.toHaveAttribute('aria-disabled'); + expect(screen.getByText(editBtnText)).not.toHaveAttribute('aria-disabled'); }); }); diff --git a/src/frontend/src/tests/pages/ProjectDetailPage/ProjectViewContainer.test.tsx b/src/frontend/src/tests/pages/ProjectDetailPage/ProjectViewContainer.test.tsx index 0327b0381d..1d18ae78f8 100644 --- a/src/frontend/src/tests/pages/ProjectDetailPage/ProjectViewContainer.test.tsx +++ b/src/frontend/src/tests/pages/ProjectDetailPage/ProjectViewContainer.test.tsx @@ -93,20 +93,13 @@ describe('Rendering Project View Container', () => { }); it('disables the buttons for guest users', () => { - renderComponent(); vi.spyOn(userHooks, 'useCurrentUser').mockReturnValue({ ...exampleAuthenticatedGuestUser }); + renderComponent(); - fireEvent.click(screen.getByText('Actions')); - - const editMenuItem = screen.getByText('Edit').closest('li'); - expect(editMenuItem).not.toBeNull(); - expect(editMenuItem?.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); - - const reqChangeMenuItem = screen.getByText('Request Change'); - expect(reqChangeMenuItem).not.toBeNull(); - expect(reqChangeMenuItem?.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); + const actionsButton = screen.getByText('Actions').closest('button'); + expect(actionsButton).toBeDisabled(); }); it('enables the buttons for admin users', async () => { @@ -116,7 +109,6 @@ describe('Rendering Project View Container', () => { }); fireEvent.click(screen.getByText('Actions')); expect(screen.getByText('Edit')).not.toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByText('Request Change')).not.toHaveAttribute('aria-disabled', 'true'); }); describe('Work Package Preview', () => { From 838f8bf2853502d01bd4064a5182e56c3b6e5442 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Wed, 29 Apr 2026 20:51:21 -0400 Subject: [PATCH 13/29] #4176 added dateCompleted to stage gate --- .../change-requests.controllers.ts | 3 +- src/backend/src/prisma/seed.ts | 1 + .../src/routes/change-requests.routes.ts | 2 + .../src/services/change-requests.services.ts | 29 ++++- .../tests/unit/change-requests.test.ts | 110 +++++++++++++++++- src/frontend/src/apis/change-requests.api.ts | 11 +- .../src/hooks/change-requests.hooks.ts | 8 +- .../StageGateWorkPackageModal.tsx | 43 ++++++- .../StageGateWorkPackageModalContainer.tsx | 6 +- 9 files changed, 198 insertions(+), 15 deletions(-) diff --git a/src/backend/src/controllers/change-requests.controllers.ts b/src/backend/src/controllers/change-requests.controllers.ts index 48284b7843..09a264b258 100644 --- a/src/backend/src/controllers/change-requests.controllers.ts +++ b/src/backend/src/controllers/change-requests.controllers.ts @@ -120,13 +120,14 @@ export default class ChangeRequestsController { static async createStageGateChangeRequest(req: Request, res: Response, next: NextFunction) { try { - const { wbsNum, confirmDone } = req.body; + const { wbsNum, confirmDone, dateCompleted } = req.body; const id = await ChangeRequestsService.createStageGateChangeRequest( req.currentUser, wbsNum.carNumber, wbsNum.projectNumber, wbsNum.workPackageNumber, confirmDone, + new Date(dateCompleted), req.organization ); res.status(200).json({ message: `Successfully created stage gate request with id #${id}` }); diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 05f57d0973..61a3304958 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -2233,6 +2233,7 @@ const performSeed: () => Promise = async () => { workPackageHuskies1WbsNumber.projectNumber, workPackageHuskies1WbsNumber.workPackageNumber, true, + new Date(), ner ); diff --git a/src/backend/src/routes/change-requests.routes.ts b/src/backend/src/routes/change-requests.routes.ts index 6748f6cb90..c918cd84b4 100644 --- a/src/backend/src/routes/change-requests.routes.ts +++ b/src/backend/src/routes/change-requests.routes.ts @@ -3,6 +3,7 @@ import { body } from 'express-validator'; import ChangeRequestsController from '../controllers/change-requests.controllers.js'; import { intMinZero, + isDate, isDateOnly, nonEmptyString, projectProposedChangesValidators, @@ -53,6 +54,7 @@ changeRequestsRouter.post( intMinZero(body('wbsNum.projectNumber')), intMinZero(body('wbsNum.workPackageNumber')), body('confirmDone').isBoolean(), + isDate(body('dateCompleted')), validateInputs, ChangeRequestsController.createStageGateChangeRequest ); diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 305dbb1b79..d40130c70a 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -285,7 +285,7 @@ export default class ChangeRequestsService { } if (accepted && foundCR.type === CR_Type.STAGE_GATE) { - await this.reviewStageGateChangeRequest(foundCR, reviewer); + await this.reviewStageGateChangeRequest(foundCR, reviewer, new Date()); } else if (foundCR.type === CR_Type.ACTIVATION && foundCR.activationChangeRequest && accepted) { await this.reviewActivationChangeRequest(foundCR, reviewer); } else if (foundCR.type === CR_Type.BUDGET && foundCR.budgetChangeRequest && accepted) { @@ -372,10 +372,12 @@ export default class ChangeRequestsService { * Reviews the stage gate change request and automates any changes that are made * @param foundCR the change request to be reviewed * @param reviewer the user reviewing the change request + * @param dateCompleted the date the work package was completed */ static async reviewStageGateChangeRequest( foundCR: Prisma.Change_RequestGetPayload, - reviewer: User + reviewer: User, + dateCompleted: Date ): Promise { if (!foundCR.wbsElement?.workPackage) { throw new HttpException(400, 'Stage gate can only be made on work packages!'); @@ -383,8 +385,10 @@ export default class ChangeRequestsService { throwIfUncheckedDescriptionBullets(foundCR.wbsElement.descriptionBullets); + const { workPackage } = foundCR.wbsElement; const shouldChangeStatus = foundCR.wbsElement.status !== WBS_Element_Status.COMPLETE; const changesList = []; + if (shouldChangeStatus) { changesList.push({ changeRequestId: foundCR.crId, @@ -393,9 +397,27 @@ export default class ChangeRequestsService { }); } + // Calculate new duration from startDate to dateCompleted if provided + let newDuration = workPackage.duration; + const { startDate } = workPackage; + + // Edge case: dateCompleted before startDate + const effectiveDate = dateCompleted < startDate ? startDate : dateCompleted; + const msPerWeek = 7 * 24 * 60 * 60 * 1000; + newDuration = Math.max(1, Math.round((effectiveDate.getTime() - startDate.getTime()) / msPerWeek)); + + if (newDuration !== workPackage.duration) { + changesList.push({ + changeRequestId: foundCR.crId, + implementerId: reviewer.userId, + detail: buildChangeDetail('duration', workPackage.duration.toString(), newDuration.toString()) + }); + } + await prisma.work_Package.update({ where: { wbsElementId: foundCR.wbsElement.wbsElementId }, data: { + duration: newDuration, wbsElement: { update: { status: WBS_Element_Status.COMPLETE, @@ -640,6 +662,7 @@ export default class ChangeRequestsService { projectNumber: number, workPackageNumber: number, confirmDone: boolean, + dateCompleted: Date, organization: Organization ): Promise { if (await userHasPermission(submitter.userId, organization.organizationId, isGuest)) @@ -704,7 +727,7 @@ export default class ChangeRequestsService { await addSlackThreadsToChangeRequest(createdChangeRequest.crId, notifications); } - await ChangeRequestsService.reviewStageGateChangeRequest(createdChangeRequest, submitter); + await ChangeRequestsService.reviewStageGateChangeRequest(createdChangeRequest, submitter, dateCompleted); return createdChangeRequest.crId; } diff --git a/src/backend/tests/unit/change-requests.test.ts b/src/backend/tests/unit/change-requests.test.ts index 224ac950d7..536e27250f 100644 --- a/src/backend/tests/unit/change-requests.test.ts +++ b/src/backend/tests/unit/change-requests.test.ts @@ -1,5 +1,12 @@ import { Organization, User, WBS_Element_Status } from '@prisma/client'; -import { createTestCar, createTestOrganization, createTestProject, createTestUser, resetUsers } from '../test-utils.js'; +import { + createTestCar, + createTestOrganization, + createTestProject, + createTestUser, + createTestWorkPackage, + resetUsers +} from '../test-utils.js'; import ChangeRequestsService from '../../src/services/change-requests.services.js'; import { supermanAdmin, @@ -16,6 +23,9 @@ describe('Change Request Tests', () => { let orgId: string; let organization: Organization; let user: User; + let wpWbsElementId: string; + let workPackageId: string; + let startDate: Date; beforeEach(async () => { organization = await createTestOrganization(); @@ -31,6 +41,12 @@ describe('Change Request Tests', () => { status: WBS_Element_Status.INACTIVE } }); + const car = await createTestCar(orgId, user.userId, 2); + const project = await createTestProject(user, orgId, undefined, car.carId, 2, 1); + const wp = await createTestWorkPackage(user, orgId, project.projectId, 2, 1, 1); + + wpWbsElementId = wp.wbsElement.wbsElementId; + ({ workPackageId, startDate } = wp); }); afterEach(async () => { @@ -479,4 +495,96 @@ describe('Change Request Tests', () => { }, 15000); }); }); + + describe('Stage Gate Change Requests', () => { + it('sets work package status to COMPLETE on stage gate', async () => { + await ChangeRequestsService.createStageGateChangeRequest(user, 2, 1, 1, true, new Date(), organization); + + const updatedWbs = await prisma.wBS_Element.findUnique({ where: { wbsElementId: wpWbsElementId } }); + expect(updatedWbs?.status).toEqual(WBS_Element_Status.COMPLETE); + }); + + it('updates work package duration based on dateCompleted', async () => { + const wp = await prisma.work_Package.findUnique({ where: { workPackageId } }); + const originalDuration = wp!.duration; + + // dateCompleted 2 weeks beyond current end date + const dateCompleted = new Date(startDate); + dateCompleted.setDate(dateCompleted.getDate() + (originalDuration + 2) * 7); + + await ChangeRequestsService.createStageGateChangeRequest(user, 2, 1, 1, true, dateCompleted, organization); + + const updatedWp = await prisma.work_Package.findUnique({ where: { workPackageId } }); + expect(updatedWp?.duration).toEqual(originalDuration + 2); + }); + + it('duration to 1 when dateCompleted is before startDate', async () => { + const dateCompleted = new Date(startDate); + dateCompleted.setDate(dateCompleted.getDate() - 7); + + await ChangeRequestsService.createStageGateChangeRequest(user, 2, 1, 1, true, dateCompleted, organization); + + const updatedWp = await prisma.work_Package.findUnique({ where: { workPackageId } }); + expect(updatedWp?.duration).toEqual(1); + }); + + it('leaves duration unchanged when dateCompleted matches existing end date', async () => { + const wp = await prisma.work_Package.findUnique({ where: { workPackageId } }); + const originalDuration = wp!.duration; + + const dateCompleted = new Date(startDate); + dateCompleted.setDate(dateCompleted.getDate() + originalDuration * 7); + + await ChangeRequestsService.createStageGateChangeRequest(user, 2, 1, 1, true, dateCompleted, organization); + + const updatedWp = await prisma.work_Package.findUnique({ where: { workPackageId } }); + expect(updatedWp?.duration).toEqual(originalDuration); + }); + + it('creates a change record for duration when it changes', async () => { + const wp = await prisma.work_Package.findUnique({ where: { workPackageId } }); + const originalDuration = wp!.duration; + + const dateCompleted = new Date(startDate); + dateCompleted.setDate(dateCompleted.getDate() + (originalDuration + 2) * 7); + + const crId = await ChangeRequestsService.createStageGateChangeRequest( + user, + 2, + 1, + 1, + true, + dateCompleted, + organization + ); + + const changes = await prisma.change.findMany({ where: { changeRequestId: crId } }); + const durationChange = changes.find((c) => c.detail.includes('duration')); + expect(durationChange).toBeDefined(); + expect(durationChange?.detail).toContain(originalDuration.toString()); + expect(durationChange?.detail).toContain((originalDuration + 2).toString()); + }); + + it('does not create a duration change record when duration is unchanged', async () => { + const wp = await prisma.work_Package.findUnique({ where: { workPackageId } }); + const originalDuration = wp!.duration; + + const dateCompleted = new Date(startDate); + dateCompleted.setDate(dateCompleted.getDate() + originalDuration * 7); + + const crId = await ChangeRequestsService.createStageGateChangeRequest( + user, + 2, + 1, + 1, + true, + dateCompleted, + organization + ); + + const changes = await prisma.change.findMany({ where: { changeRequestId: crId } }); + const durationChange = changes.find((c) => c.detail.includes('duration')); + expect(durationChange).toBeUndefined(); + }); + }); }); diff --git a/src/frontend/src/apis/change-requests.api.ts b/src/frontend/src/apis/change-requests.api.ts index 638cbe93dc..31ece36815 100644 --- a/src/frontend/src/apis/change-requests.api.ts +++ b/src/frontend/src/apis/change-requests.api.ts @@ -128,13 +128,20 @@ export const createActivationChangeRequest = ( * @param submitterId The ID of the user creating the change request. * @param wbsNumber the wbsNumber of the WBS element the change request is for. * @param confirmDone are all details of the WBS element being stage gated fully completed? + * @param dateCompleted the date the work package was completed */ -export const createStageGateChangeRequest = (submitterId: string, wbsNum: WbsNumber, confirmDone: boolean) => { +export const createStageGateChangeRequest = ( + submitterId: string, + wbsNum: WbsNumber, + confirmDone: boolean, + dateCompleted: Date +) => { return axios.post<{ message: string }>(apiUrls.changeRequestsCreateStageGate(), { submitterId, wbsNum, type: ChangeRequestType.StageGate, - confirmDone + confirmDone, + dateCompleted }); }; diff --git a/src/frontend/src/hooks/change-requests.hooks.ts b/src/frontend/src/hooks/change-requests.hooks.ts index 0a007ef40a..e51d4fb278 100644 --- a/src/frontend/src/hooks/change-requests.hooks.ts +++ b/src/frontend/src/hooks/change-requests.hooks.ts @@ -189,6 +189,7 @@ export interface CreateStageGateChangeRequestPayload { wbsNum: WbsNumber; confirmDone: boolean; type: string; + dateCompleted: Date; } export interface CreateBudgetChangeRequestPayload { @@ -226,7 +227,12 @@ export const useCreateStageGateChangeRequest = () => { return useMutation<{ message: string }, Error, CreateStageGateChangeRequestPayload>( ['change requests', 'create', 'stage gate'], async (payload: CreateStageGateChangeRequestPayload) => { - const { data } = await createStageGateChangeRequest(payload.submitterId, payload.wbsNum, payload.confirmDone); + const { data } = await createStageGateChangeRequest( + payload.submitterId, + payload.wbsNum, + payload.confirmDone, + payload.dateCompleted + ); return data; } ); diff --git a/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.tsx b/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.tsx index 0926166f2f..41f2cdc9ee 100644 --- a/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.tsx +++ b/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.tsx @@ -9,8 +9,9 @@ import { Controller, useForm } from 'react-hook-form'; import { WbsNumber } from 'shared'; import { FormInput } from './StageGateWorkPackageModalContainer'; import { wbsPipe } from '../../../utils/pipes'; -import { FormControlLabel, Radio, RadioGroup, Typography } from '@mui/material'; +import { FormControlLabel, FormHelperText, Radio, RadioGroup, Typography } from '@mui/material'; import NERFormModal from '../../../components/NERFormModal'; +import { DatePicker } from '@mui/x-date-pickers'; interface StageGateWorkPackageModalProps { wbsNum: WbsNumber; @@ -20,12 +21,24 @@ interface StageGateWorkPackageModalProps { } const schema = yup.object().shape({ - confirmDone: yup.boolean().required() + confirmDone: yup.boolean().required(), + dateCompleted: yup + .date() + .required('Date completed is required') + .max(new Date(new Date().setHours(23, 59, 59, 999)), 'Date completed cannot be in the future') }); const StageGateWorkPackageModal: React.FC = ({ wbsNum, modalShow, onHide, onSubmit }) => { - const { reset, handleSubmit, control } = useForm({ - resolver: yupResolver(schema) + const { + reset, + handleSubmit, + control, + formState: { errors } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + dateCompleted: new Date() + } }); return ( @@ -44,7 +57,6 @@ const StageGateWorkPackageModal: React.FC = ({ w rules={{ required: true }} render={({ field: { onChange, value } }) => ( <> - {/* TODO: slide deck changed to confluence in frontend - needs to be updated in the backend */} Is everything done?
  • Updated confluence & documentation
  • @@ -73,6 +85,27 @@ const StageGateWorkPackageModal: React.FC = ({ w )} /> + Date completed + ( + onChange(newValue ?? new Date())} + disableFuture + slotProps={{ + textField: { + variant: 'outlined', + size: 'small', + fullWidth: true, + error: !!errors.dateCompleted + } + }} + /> + )} + /> + {errors.dateCompleted && {errors.dateCompleted.message}} ); }; diff --git a/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.tsx b/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.tsx index 7281d171f5..6da933b91c 100644 --- a/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.tsx +++ b/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.tsx @@ -23,6 +23,7 @@ interface StageGateWorkPackageModalContainerProps { export interface FormInput { confirmDone: boolean; + dateCompleted: Date; } const StageGateWorkPackageModalContainer: React.FC = ({ @@ -36,7 +37,7 @@ const StageGateWorkPackageModalContainer: React.FC { + const handleConfirm = async ({ confirmDone, dateCompleted }: FormInput) => { handleClose(); if (auth.user?.userId === undefined) throw new Error('Cannot create stage gate change request without being logged in'); try { @@ -44,7 +45,8 @@ const StageGateWorkPackageModalContainer: React.FC { confetti({ From cc998cd3e4e67965d0722c699a11da119bc75fdb Mon Sep 17 00:00:00 2001 From: wavehassman Date: Wed, 29 Apr 2026 21:47:43 -0400 Subject: [PATCH 14/29] #4176 cant mark an end date before the start date --- .../src/services/change-requests.services.ts | 10 +++---- .../tests/unit/change-requests.test.ts | 9 +++--- .../StageGateWorkPackageModal.tsx | 28 +++++++++++++------ .../StageGateWorkPackageModalContainer.tsx | 17 +++++++++-- .../StageGateWorkPackageModal.test.tsx | 1 + ...tageGateWorkPackageModalContainer.test.tsx | 17 +++++++---- 6 files changed, 56 insertions(+), 26 deletions(-) diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index d40130c70a..9059b6df2c 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -389,6 +389,10 @@ export default class ChangeRequestsService { const shouldChangeStatus = foundCR.wbsElement.status !== WBS_Element_Status.COMPLETE; const changesList = []; + if (dateCompleted < workPackage.startDate) { + throw new HttpException(400, 'Date completed cannot be before the work package start date'); + } + if (shouldChangeStatus) { changesList.push({ changeRequestId: foundCR.crId, @@ -399,12 +403,8 @@ export default class ChangeRequestsService { // Calculate new duration from startDate to dateCompleted if provided let newDuration = workPackage.duration; - const { startDate } = workPackage; - - // Edge case: dateCompleted before startDate - const effectiveDate = dateCompleted < startDate ? startDate : dateCompleted; const msPerWeek = 7 * 24 * 60 * 60 * 1000; - newDuration = Math.max(1, Math.round((effectiveDate.getTime() - startDate.getTime()) / msPerWeek)); + newDuration = Math.max(1, Math.round((dateCompleted.getTime() - workPackage.startDate.getTime()) / msPerWeek)); if (newDuration !== workPackage.duration) { changesList.push({ diff --git a/src/backend/tests/unit/change-requests.test.ts b/src/backend/tests/unit/change-requests.test.ts index 536e27250f..212f9d1613 100644 --- a/src/backend/tests/unit/change-requests.test.ts +++ b/src/backend/tests/unit/change-requests.test.ts @@ -518,14 +518,13 @@ describe('Change Request Tests', () => { expect(updatedWp?.duration).toEqual(originalDuration + 2); }); - it('duration to 1 when dateCompleted is before startDate', async () => { + it('throws an error when dateCompleted is before startDate', async () => { const dateCompleted = new Date(startDate); dateCompleted.setDate(dateCompleted.getDate() - 7); - await ChangeRequestsService.createStageGateChangeRequest(user, 2, 1, 1, true, dateCompleted, organization); - - const updatedWp = await prisma.work_Package.findUnique({ where: { workPackageId } }); - expect(updatedWp?.duration).toEqual(1); + await expect( + ChangeRequestsService.createStageGateChangeRequest(user, 2, 1, 1, true, dateCompleted, organization) + ).rejects.toThrow('Date completed cannot be before the work package start date'); }); it('leaves duration unchanged when dateCompleted matches existing end date', async () => { diff --git a/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.tsx b/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.tsx index 41f2cdc9ee..4db38ee8fc 100644 --- a/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.tsx +++ b/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.tsx @@ -18,24 +18,33 @@ interface StageGateWorkPackageModalProps { modalShow: boolean; onHide: () => void; onSubmit: (data: FormInput) => Promise; + startDate: Date; } -const schema = yup.object().shape({ - confirmDone: yup.boolean().required(), - dateCompleted: yup - .date() - .required('Date completed is required') - .max(new Date(new Date().setHours(23, 59, 59, 999)), 'Date completed cannot be in the future') -}); +const buildSchema = (startDate: Date) => + yup.object().shape({ + confirmDone: yup.boolean().required(), + dateCompleted: yup + .date() + .required('Date completed is required') + .min(startDate, 'Date completed cannot be before the start date') + .max(new Date(new Date().setHours(23, 59, 59, 999)), 'Date completed cannot be in the future') + }); -const StageGateWorkPackageModal: React.FC = ({ wbsNum, modalShow, onHide, onSubmit }) => { +const StageGateWorkPackageModal: React.FC = ({ + wbsNum, + modalShow, + onHide, + onSubmit, + startDate +}) => { const { reset, handleSubmit, control, formState: { errors } } = useForm({ - resolver: yupResolver(schema), + resolver: yupResolver(buildSchema(startDate)), defaultValues: { dateCompleted: new Date() } @@ -94,6 +103,7 @@ const StageGateWorkPackageModal: React.FC = ({ w value={value} onChange={(newValue) => onChange(newValue ?? new Date())} disableFuture + minDate={startDate} slotProps={{ textField: { variant: 'outlined', diff --git a/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.tsx b/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.tsx index 6da933b91c..2f33f61584 100644 --- a/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.tsx +++ b/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.tsx @@ -8,6 +8,7 @@ import { useHistory } from 'react-router-dom'; import { ChangeRequestType, WbsNumber, wbsPipe } from 'shared'; import { useAuth } from '../../../hooks/auth.hooks'; import { useCreateStageGateChangeRequest } from '../../../hooks/change-requests.hooks'; +import { useSingleWorkPackage } from '../../../hooks/work-packages.hooks'; import { routes } from '../../../utils/routes'; import ErrorPage from '../../ErrorPage'; import LoadingIndicator from '../../../components/LoadingIndicator'; @@ -36,6 +37,7 @@ const StageGateWorkPackageModalContainer: React.FC { handleClose(); @@ -67,11 +69,22 @@ const StageGateWorkPackageModalContainer: React.FC; + if (isLoading || wpIsLoading) return ; if (isError) return ; + if (wpIsError) return ; } - return ; + if (!workPackage) return ; + + return ( + + ); }; export default StageGateWorkPackageModalContainer; diff --git a/src/frontend/src/tests/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.test.tsx b/src/frontend/src/tests/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.test.tsx index e8d4cbd5c9..48391e961a 100644 --- a/src/frontend/src/tests/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.test.tsx +++ b/src/frontend/src/tests/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.test.tsx @@ -31,6 +31,7 @@ const renderComponent = (modalShow: boolean) => { onHide={mockHandleHide} onSubmit={mockHandleSubmit} wbsNum={exampleWbs1} + startDate={new Date('2024-01-01')} /> diff --git a/src/frontend/src/tests/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.test.tsx b/src/frontend/src/tests/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.test.tsx index 6bd932e9d7..57555e55b1 100644 --- a/src/frontend/src/tests/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.test.tsx +++ b/src/frontend/src/tests/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.test.tsx @@ -3,25 +3,31 @@ * See the LICENSE file in the repository root folder for details. */ -import { UseMutationResult } from 'react-query'; +import { UseMutationResult, UseQueryResult } from 'react-query'; import { render, screen } from '../../../test-support/test-utils'; import { wbsPipe } from '../../../../utils/pipes'; import { exampleWbs1 } from '../../../test-support/test-data/wbs-numbers.stub'; import StageGateWorkPackageModalContainer from '../../../../pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer'; -import { mockUseMutationResult } from '../../../test-support/test-data/test-utils.stub'; +import { mockUseMutationResult, mockUseQueryResult } from '../../../test-support/test-data/test-utils.stub'; import { useCreateStageGateChangeRequest } from '../../../../hooks/change-requests.hooks'; +import { useSingleWorkPackage } from '../../../../hooks/work-packages.hooks'; +import { WorkPackage } from 'shared'; +import { exampleWorkPackage5 } from '../../../test-support/test-data/work-packages.stub'; vi.mock('../../../../hooks/change-requests.hooks'); - +vi.mock('../../../../hooks/work-packages.hooks'); vi.mock('../../../../hooks/toasts.hooks'); -// random shit to make test happy by mocking out this hook const mockedUseCreateStageGateCR = useCreateStageGateChangeRequest as jest.Mock; - const mockUseCreateStageGateCRHook = (isLoading: boolean, isError: boolean, error?: Error) => { mockedUseCreateStageGateCR.mockReturnValue(mockUseMutationResult<{ in: string }>(isLoading, isError, { in: 'hi' }, error)); }; +const mockedUseSingleWorkPackage = useSingleWorkPackage as jest.Mock>; +const mockUseSingleWorkPackageHook = (isLoading: boolean, isError: boolean, data?: WorkPackage, error?: Error) => { + mockedUseSingleWorkPackage.mockReturnValue(mockUseQueryResult(isLoading, isError, data, error)); +}; + const renderComponent = () => { return render( null} wbsNum={exampleWbs1} />); }; @@ -29,6 +35,7 @@ const renderComponent = () => { describe('stage gate work package modal container test suite', () => { it('renders component without crashing', () => { mockUseCreateStageGateCRHook(false, false); + mockUseSingleWorkPackageHook(false, false, exampleWorkPackage5); renderComponent(); expect(screen.getByText(`Stage Gate #${wbsPipe(exampleWbs1)}`)).toBeInTheDocument(); From db4e3b417d580a3d9be87a9c80f1427ee1651d5b Mon Sep 17 00:00:00 2001 From: wavehassman Date: Wed, 29 Apr 2026 21:58:19 -0400 Subject: [PATCH 15/29] #4176 put error above loading --- .../StageGateWorkPackageModalContainer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.tsx b/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.tsx index 2f33f61584..d72c6d6048 100644 --- a/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.tsx +++ b/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer.tsx @@ -69,9 +69,9 @@ const StageGateWorkPackageModalContainer: React.FC; if (isError) return ; if (wpIsError) return ; + if (isLoading || wpIsLoading) return ; } if (!workPackage) return ; From d5cdc6fb43f250770fde1dc07f64d4951b4f3447 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sat, 2 May 2026 16:22:14 -0400 Subject: [PATCH 16/29] #4171 add wp deliverables to diff --- .../src/services/change-requests.services.ts | 6 +++- .../src/utils/change-requests.utils.ts | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index cc9fcc6054..2bcb43606b 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -1000,7 +1000,11 @@ export default class ChangeRequestsService { include: { links: { where: { dateDeleted: null }, include: { linkType: { select: { name: true } } } }, project: { select: { budget: true, summary: true } }, - workPackage: { select: { startDate: true, duration: true, stage: true } } + workPackage: { select: { startDate: true, duration: true, stage: true } }, + descriptionBullets: { + where: { dateDeleted: null }, + include: { descriptionBulletType: { select: { name: true } } } + } } }); diff --git a/src/backend/src/utils/change-requests.utils.ts b/src/backend/src/utils/change-requests.utils.ts index 1ea52d0af8..ab3a42a7a3 100644 --- a/src/backend/src/utils/change-requests.utils.ts +++ b/src/backend/src/utils/change-requests.utils.ts @@ -396,6 +396,7 @@ export const buildCRDiff = ( links: { url: string; linkType: { name: string } }[]; project?: { budget: number; summary: string } | null; workPackage?: { startDate: Date; duration: number; stage: string | null } | null; + descriptionBullets?: { detail: string; descriptionBulletType: { name: string } }[]; }, proposed: ProjectProposedChangesCreateArgs | WorkPackageProposedChangesCreateArgs | undefined ): string => { @@ -433,6 +434,30 @@ export const buildCRDiff = ( } }; + const addBulletDiffs = ( + currentBullets: { detail: string; descriptionBulletType: { name: string } }[], + proposedBullets: DescriptionBulletPreview[] + ) => { + const allBulletTypeNames = new Set([ + ...currentBullets.map((b) => b.descriptionBulletType.name), + ...proposedBullets.map((b) => b.type) + ]); + for (const bulletTypeName of allBulletTypeNames) { + const currentDetails = new Set( + currentBullets.filter((b) => b.descriptionBulletType.name === bulletTypeName).map((b) => b.detail) + ); + const proposedDetails = new Set(proposedBullets.filter((b) => b.type === bulletTypeName).map((b) => b.detail)); + // add "- Type: detail" for each detail of this type that is in current but not proposed + for (const detail of currentDetails) { + if (!proposedDetails.has(detail)) lines.push(`− ${bulletTypeName}: ${detail}`); + } + // add "+ Type: detail" for each detail of this type that is in proposed but not current + for (const detail of proposedDetails) { + if (!currentDetails.has(detail)) lines.push(`+ ${bulletTypeName}: ${detail}`); + } + } + }; + if (isWpChange) { const wpProposed = proposed as WorkPackageProposedChangesCreateArgs; if (!currentWbs.workPackage) { @@ -443,6 +468,7 @@ export const buildCRDiff = ( addNew('Duration', wpProposed.duration); addNew('Stage', wpProposed.stage); wpProposed.links.forEach((l) => addNew(l.linkTypeName, l.url)); + wpProposed.descriptionBullets.forEach((b) => addNew(b.type, b.detail)); } else { addDiff('Name', currentWbs.name, wpProposed.name); addDiff('Lead', currentWbs.leadId, wpProposed.leadId); @@ -451,6 +477,7 @@ export const buildCRDiff = ( addDiff('Duration', currentWbs.workPackage.duration, wpProposed.duration); addDiff('Stage', currentWbs.workPackage.stage, wpProposed.stage); addLinkDiffs(currentWbs.links, wpProposed.links); + addBulletDiffs(currentWbs.descriptionBullets ?? [], wpProposed.descriptionBullets); } } else { const projProposed = proposed as ProjectProposedChangesCreateArgs; @@ -461,6 +488,7 @@ export const buildCRDiff = ( addNew('Budget', projProposed.budget); addNew('Summary', projProposed.summary); projProposed.links.forEach((l) => addNew(l.linkTypeName, l.url)); + projProposed.descriptionBullets.forEach((b) => addNew(b.type, b.detail)); } else { addDiff('Name', currentWbs.name, projProposed.name); addDiff('Lead', currentWbs.leadId, projProposed.leadId); @@ -468,6 +496,7 @@ export const buildCRDiff = ( addDiff('Budget', currentWbs.project.budget, projProposed.budget); addDiff('Summary', currentWbs.project.summary, projProposed.summary); addLinkDiffs(currentWbs.links, projProposed.links); + addBulletDiffs(currentWbs.descriptionBullets ?? [], projProposed.descriptionBullets); } } From 9c7ff1a674ba915ddce81fe2cc26d863a09ca814 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sun, 3 May 2026 11:56:52 -0400 Subject: [PATCH 17/29] #4174 added toast, fixed button formatting --- .../ProjectForm/ProjectCreateContainer.tsx | 3 +- .../ProjectForm/ProjectEditContainer.tsx | 2 + .../ProjectForm/ProjectForm.tsx | 16 ++--- .../WorkPackageForm/WorkPackageFormView.tsx | 68 +++++++++---------- 4 files changed, 43 insertions(+), 46 deletions(-) diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx index 57deddc7d7..2edd9d2f9c 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx @@ -134,6 +134,7 @@ const ProjectCreateContainer: React.FC = () => { projectProposedChanges: projectPayload }; await mutateCRAsync(changeRequestPayload); + toast.success('Change request submitted successfully'); history.push(routes.CHANGE_REQUESTS_OVERVIEW); } catch (e) { if (e instanceof Error) { @@ -222,7 +223,7 @@ const ProjectCreateContainer: React.FC = () => { idToWbs.set(wp.workPackageId, created.wbsNum); } - + toast.success('Project created successfully'); history.push(routes.PROJECTS_ALL); } catch (e) { if (e instanceof Error) { diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx index 8f70d6dd33..6f57039311 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx @@ -208,6 +208,7 @@ const ProjectEditContainer: React.FC = ({ project, ex managerId }; await mutateLeadershipCR(autoCRPayload); + toast.success('Changes submitted successfully'); // fixes cache issue await queryClient.refetchQueries(['projects']); exitEditMode(); @@ -228,6 +229,7 @@ const ProjectEditContainer: React.FC = ({ project, ex managerId }; await mutateAsync(payload); + toast.success('Project updated successfully'); exitEditMode(); } catch (e) { if (e instanceof Error) { diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx index d775563e63..2d20494875 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx @@ -220,14 +220,14 @@ const ProjectFormContainer: React.FC = ({ title={project ? `${wbsPipe(project.wbsNum)} - ${project.name}` : 'New Project'} previousPages={[{ name: 'Projects', route: routes.PROJECTS }]} headerRight={ - + {onSubmitChangeRequest && ( - + <> {`If you don't enter a Change Request ID into this form, you can create one here that when accepted will - ${project ? `edit the selected Project` : `create a new Project`} with the inputted values`} + ${project ? `edit the selected Project` : `create a new Project`} with the inputted values`} } placement="left" @@ -237,23 +237,21 @@ const ProjectFormContainer: React.FC = ({ setIsModalOpen(true)} - sx={{ mx: 1 }} disabled={changeRequestInputExists || onlyLeadershipChanged} > - Submit Change Request + Change Request - + )} - + Cancel - Submit & Implement + Implement } diff --git a/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx b/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx index ddf9cfe3c4..c789e5514f 100644 --- a/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx +++ b/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx @@ -234,6 +234,7 @@ const WorkPackageFormView: React.FC = ({ managerId }; await createLeadershipCR(autoCRPayload); + toast.success('Changes submitted successfully'); await queryClient.refetchQueries(['work packages']); exitActiveMode(); return; @@ -262,10 +263,11 @@ const WorkPackageFormView: React.FC = ({ ...payload } }); - + toast.success('Change request submitted successfully'); history.push(`${routes.PROJECTS}/${wbsPipe(wbsElement.wbsNum)}/change-requests`); } else { await workPackageMutateAsync(payload); + toast.success('Work package updated successfully'); exitActiveMode(); } } catch (e) { @@ -316,41 +318,35 @@ const WorkPackageFormView: React.FC = ({ stickyHeader title={pageTitle} headerRight={ - - - - {`If you don't enter a Change Request ID into this form, you can create one here that when accepted will - ${defaultValues ? `edit the selected Work Package` : `create a new Work Package`} with the inputted values`} - - } - placement="left" - > - - - setIsModalOpen(true)} - sx={{ mx: 1 }} - > - Submit Change Request - - - - - Cancel - - - Submit & Implement - - + + + {`If you don't enter a Change Request ID into this form, you can create one here that when accepted will + ${defaultValues ? `edit the selected Work Package` : `create a new Work Package`} with the inputted values`} + + } + placement="left" + > + + + setIsModalOpen(true)} + > + Change Request + + + Cancel + + + Implement + } > From 2d76eb10cbeaa1db3f338bdcbe130f166e6934e7 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sun, 3 May 2026 13:12:42 -0400 Subject: [PATCH 18/29] #4176 removed newDuration --- src/backend/src/services/change-requests.services.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 9059b6df2c..ef6c31b01b 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -402,9 +402,8 @@ export default class ChangeRequestsService { } // Calculate new duration from startDate to dateCompleted if provided - let newDuration = workPackage.duration; const msPerWeek = 7 * 24 * 60 * 60 * 1000; - newDuration = Math.max(1, Math.round((dateCompleted.getTime() - workPackage.startDate.getTime()) / msPerWeek)); + const newDuration = Math.max(1, Math.round((dateCompleted.getTime() - workPackage.startDate.getTime()) / msPerWeek)); if (newDuration !== workPackage.duration) { changesList.push({ From 4f316f793ffef683f8143a6c4f6624ecff0f4436 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 8 May 2026 17:22:24 -0400 Subject: [PATCH 19/29] #4174 made reviewer optional, do not need to make a change rquest when making a new project --- .../pages/CreateChangeRequestPage/CreateChangeRequestView.tsx | 1 + .../ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx | 3 ++- .../src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx | 3 ++- src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx | 3 ++- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestView.tsx b/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestView.tsx index 1e769cfad8..d40e49158c 100644 --- a/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestView.tsx +++ b/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestView.tsx @@ -167,6 +167,7 @@ const CreateChangeRequestsView: React.FC = ({ size="small" placeholder="Select a reviewer" value={memberOptions.find((m) => m.id === changeRequestFormReturn.watch('requestedReviewerId')) ?? null} + required={false} /> diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx index 4fc612add4..73afd32d81 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx @@ -67,7 +67,8 @@ const ProjectEditContainer: React.FC = ({ project, ex }); const changeRequestSchema = yup.object().shape({ - why: yup.string().required('Why Explain is required') + why: yup.string().required('Why Explain is required'), + requestedReviewerId: yup.string().optional() }); const { reset: resetChangeRequestForm, ...changeRequestFormMethods } = useForm({ diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx index 2d20494875..b2c8ce55c4 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx @@ -238,6 +238,7 @@ const ProjectFormContainer: React.FC = ({ variant="contained" onClick={() => setIsModalOpen(true)} disabled={changeRequestInputExists || onlyLeadershipChanged} + sx={{ display: project ? 'block' : 'none' }} > Change Request @@ -251,7 +252,7 @@ const ProjectFormContainer: React.FC = ({ variant="contained" type="submit" > - Implement + {project ? 'Implement' : 'Create Project'} } diff --git a/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx b/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx index c789e5514f..541640a1a0 100644 --- a/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx +++ b/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx @@ -161,7 +161,8 @@ const WorkPackageFormView: React.FC = ({ const watchedDescriptionBullets = watch('descriptionBullets'); const changeRequestSchema = yup.object().shape({ - why: yup.string().required('Why Explain is required') + why: yup.string().required('Why Explain is required'), + requestedReviewerId: yup.string().optional() }); const { reset: resetChangeRequestForm, ...changeRequestFormMethods } = useForm({ From f94c8d72a47a865a6f455f31e0159771aec54c93 Mon Sep 17 00:00:00 2001 From: Sarah Taylor <150694563+staysgt@users.noreply.github.com> Date: Fri, 8 May 2026 18:09:26 -0400 Subject: [PATCH 20/29] #4172 started cr approve slack button --- .../src/controllers/slack.controllers.ts | 66 +++++++++++- src/backend/src/routes/slack.routes.ts | 16 +++ src/backend/src/services/slack.services.ts | 101 +++++++++++++++++- src/backend/src/utils/slack.utils.ts | 29 +++++ 4 files changed, 209 insertions(+), 3 deletions(-) diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts index 506f953740..08df2f50f5 100644 --- a/src/backend/src/controllers/slack.controllers.ts +++ b/src/backend/src/controllers/slack.controllers.ts @@ -1,6 +1,10 @@ import { getWorkspaceId, replyToMessageInThread } from '../integrations/slack.js'; import OrganizationsService from '../services/organizations.services.js'; -import SlackServices, { SlackBlockActionBody, SaboSubmissionActionValue } from '../services/slack.services.js'; +import SlackServices, { + SlackBlockActionBody, + SaboSubmissionActionValue, + CrApprovalActionValue +} from '../services/slack.services.js'; export default class SlackController { static async processMessageEvent(event: any) { @@ -82,4 +86,64 @@ export default class SlackController { throw error; } } + + static async handleApproveCRAction(body: SlackBlockActionBody, respond: any) { + const { user, container, actions } = body; + const channelId = container.channel_id; + const threadTs = container.thread_ts || container.message_ts; + const [firstAction] = actions; + + try { + // Action-specific validation: verify action_id + if (firstAction.action_id !== 'approve_cr') { + console.error('Unexpected action_id:', firstAction.action_id); + await replyToMessageInThread( + channelId, + threadTs, + `❌ An error occurred: Unexpected action type "${firstAction.action_id}". Please contact the software team.` + ); + return; + } + + // Action-specific validation: verify value format + let actionValue: CrApprovalActionValue; + try { + actionValue = JSON.parse(firstAction.value); + } catch (parseError) { + const parseErrorMsg = parseError instanceof Error ? parseError.message : 'Unknown parse error'; + await replyToMessageInThread( + channelId, + threadTs, + `❌ An error occurred: Invalid action data format.\n\n*Error:* ${parseErrorMsg}\n*Value:* \`${firstAction.value}\`\n\nPlease contact the software team.` + ); + return; + } + + // Validate that changeRequestId exists in the parsed value + if (!actionValue.crId || typeof actionValue.crId !== 'string') { + const actionValueStr = JSON.stringify(actionValue, null, 2); + await replyToMessageInThread( + channelId, + threadTs, + `❌ An error occurred: Missing or invalid reimbursement request ID.\n\n*Parsed value:*\n\`\`\`${actionValueStr}\`\`\`\n\nPlease contact the software team.` + ); + return; + } + + // Extract validated fields + const userSlackId = user.id; + const { crId } = actionValue; + + // Pass the extracted fields to the service layer for business logic + await SlackServices.handleApproveCRAction(userSlackId, crId, respond); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await replyToMessageInThread( + channelId, + threadTs, + `❌ An unexpected error occurred while processing your request.\n\n*Error message:* ${errorMessage}\n\nPlease contact the software team and provide them with this information.` + ); + throw error; + } + } } diff --git a/src/backend/src/routes/slack.routes.ts b/src/backend/src/routes/slack.routes.ts index 1863e87869..4f103b3aea 100644 --- a/src/backend/src/routes/slack.routes.ts +++ b/src/backend/src/routes/slack.routes.ts @@ -129,6 +129,22 @@ if (slackApp) { } }); + // Register interactive action handler for CR approval + slackApp.action('approve_cr', async ({ ack, body, logger, respond }: any) => { + await ack(); + + try { + if (!validateSlackActionBody(body)) { + logger.error('Invalid Slack action body structure'); + return; + } + + await SlackController.handleApproveCRAction(body, respond); + } catch (error) { + logger.error('Error handling approve_cr action:', error); + } + }); + // Error handler slackApp.error(async (error: Error) => { console.error('Slack app error:', error); diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index 5afb0914fe..36d4114efc 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -2,9 +2,15 @@ import { getChannelName, getUserName } from '../integrations/slack.js'; import AnnouncementService from './announcement.services.js'; import { Announcement, ReimbursementStatusType } from 'shared'; import prisma from '../prisma/prisma.js'; -import { blockToMentionedUsers, blockToString } from '../utils/slack.utils.js'; -import { InvalidOrganizationException, NotFoundException } from '../utils/errors.utils.js'; +import { blockToMentionedUsers, blockToString, getUserIdFromSlackId } from '../utils/slack.utils.js'; +import { + AccessDeniedException, + HttpException, + InvalidOrganizationException, + NotFoundException +} from '../utils/errors.utils.js'; import ReimbursementRequestService from './reimbursement-requests.services.js'; +import ChangeRequestsService from './change-requests.services.js'; /** * Represents a slack event for a message in a channel. @@ -125,6 +131,13 @@ export interface SaboSubmissionActionValue { reimbursementRequestId: string; } +/** + * Represents the parsed value from a CR approval action + */ +export interface CrApprovalActionValue { + crId: string; +} + export default class SlackServices { /** * Handles the Slack button click for marking a reimbursement request as SABO submitted. @@ -199,6 +212,90 @@ export default class SlackServices { ); } + /** + * Approves a change request from a Slack interactive button click. + * Auth (admin/head/requested-reviewer) is enforced inside reviewChangeRequest. + * + * @param userSlackId Slack id of the user who clicked the button + * @param crId the change request to approve + * @param respond Bolt response callback bound to this interaction's response_url + */ + static async handleApproveCRAction( + userSlackId: string, + crId: string, + respond: (msg: { + response_type?: 'ephemeral'; + text?: string; + replace_original?: boolean; + delete_original?: boolean; + }) => Promise + ): Promise { + const reviewer = await prisma.user.findFirst({ + where: { + userSettings: { + slackId: userSlackId + } + } + }); + + if (!reviewer) { + console.error('User not found for slack ID:', userSlackId); + throw new NotFoundException('User', userSlackId); + } + + const cr = await prisma.change_Request.findUnique({ + where: { + crId + } + }); + + if (!cr) { + throw new NotFoundException('Change Request', crId); + } + + const org = await prisma.organization.findUnique({ + where: { + organizationId: cr.organizationId + } + }); + + if (!org) { + throw new NotFoundException('Organization', cr.organizationId); + } + + try { + await ChangeRequestsService.reviewChangeRequest(reviewer, crId, '', true, org); + await respond({ + replace_original: true, + text: `✅ CR #${cr.identifier} approved by ${reviewer.firstName} ${reviewer.lastName}.` + }); + } catch (error) { + if (error instanceof AccessDeniedException) { + await respond({ + response_type: 'ephemeral', + text: `❌ You're not authorized to approve this CR. Only admins, team heads, or requested reviewers can approve.` + }); + } else if (error instanceof NotFoundException) { + await respond({ + response_type: 'ephemeral', + text: `❌ ${error.message}` + }); + } else if (error instanceof HttpException) { + await respond({ + response_type: 'ephemeral', + text: `❌ ${error.message}` + }); + } else { + const msg = error instanceof Error ? error.message : 'Unknown error'; + console.error('Error approving CR via Slack:', error); + await respond({ + response_type: 'ephemeral', + text: `❌ An unexpected error occurred while approving this CR.\n\n*Error:* ${msg}` + }); + } + } + } + /** * Given a slack event representing a message in a channel, * make the appropriate announcement change in prisma. diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index c083200340..0d5788a09e 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -608,6 +608,35 @@ export const sendStandardCRCreatedNotification = async ( notifications.map((n) => replyToMessageInThread(n.channelId, n.ts, reviewMsg, crLink, `View CR #${cr.identifier}`)) ); } + + // Send the approve button as an ephemeral message to each head and requested reviewer, + // so only authorized approvers see it. reviewChangeRequest still enforces auth on click. + const approveBlocks = [ + { + type: 'section', + text: { type: 'mrkdwn', text: `Approve CR #${cr.identifier}?` } + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'Approve Change Request' }, + style: 'primary', + action_id: 'approve_cr', + value: JSON.stringify({ crId: cr.crId, organizationId: cr.organizationId }) + } + ] + } + ]; + + await Promise.all( + notifications.flatMap((n) => + [...allSlackIds].map((slackId) => + sendEphemeralMessage(n.channelId, n.ts, slackId, `Approve CR #${cr.identifier}?`, approveBlocks) + ) + ) + ); }; /** From ba4baaa867ba9c799fc72eb7f4972121dd1392bb Mon Sep 17 00:00:00 2001 From: Sarah Taylor <150694563+staysgt@users.noreply.github.com> Date: Tue, 12 May 2026 09:10:05 -0400 Subject: [PATCH 21/29] #4172 fix lint --- src/backend/src/services/slack.services.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index 36d4114efc..b72be2b995 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -2,7 +2,7 @@ import { getChannelName, getUserName } from '../integrations/slack.js'; import AnnouncementService from './announcement.services.js'; import { Announcement, ReimbursementStatusType } from 'shared'; import prisma from '../prisma/prisma.js'; -import { blockToMentionedUsers, blockToString, getUserIdFromSlackId } from '../utils/slack.utils.js'; +import { blockToMentionedUsers, blockToString } from '../utils/slack.utils.js'; import { AccessDeniedException, HttpException, From 0b3225a93d3c4d0607e912b658c6301e48152659 Mon Sep 17 00:00:00 2001 From: Sarah Taylor <150694563+staysgt@users.noreply.github.com> Date: Tue, 12 May 2026 09:25:23 -0400 Subject: [PATCH 22/29] #4172 fix build issue --- src/backend/src/services/slack.services.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index b72be2b995..f54587f041 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -11,6 +11,8 @@ import { } from '../utils/errors.utils.js'; import ReimbursementRequestService from './reimbursement-requests.services.js'; import ChangeRequestsService from './change-requests.services.js'; +import { userTransformer } from '../transformers/user.transformer.js'; +import { getUserQueryArgs } from '../prisma-query-args/user.query-args.js'; /** * Represents a slack event for a message in a channel. @@ -235,7 +237,8 @@ export default class SlackServices { userSettings: { slackId: userSlackId } - } + }, + ...getUserQueryArgs() }); if (!reviewer) { @@ -264,7 +267,7 @@ export default class SlackServices { } try { - await ChangeRequestsService.reviewChangeRequest(reviewer, crId, '', true, org); + await ChangeRequestsService.reviewChangeRequest(userTransformer(reviewer), crId, '', true, org); await respond({ replace_original: true, text: `✅ CR #${cr.identifier} approved by ${reviewer.firstName} ${reviewer.lastName}.` From 94e7fe2f1cb72bfd46dd7a9d0fa0d6ac47a585c3 Mon Sep 17 00:00:00 2001 From: Sarah Taylor <150694563+staysgt@users.noreply.github.com> Date: Tue, 12 May 2026 09:41:33 -0400 Subject: [PATCH 23/29] #4172 fix build --- src/backend/src/services/slack.services.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index f54587f041..b0710a2d8f 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -13,6 +13,7 @@ import ReimbursementRequestService from './reimbursement-requests.services.js'; import ChangeRequestsService from './change-requests.services.js'; import { userTransformer } from '../transformers/user.transformer.js'; import { getUserQueryArgs } from '../prisma-query-args/user.query-args.js'; +import { User } from 'shared'; /** * Represents a slack event for a message in a channel. @@ -266,8 +267,10 @@ export default class SlackServices { throw new NotFoundException('Organization', cr.organizationId); } + const reviewerShared: User = userTransformer(reviewer); + try { - await ChangeRequestsService.reviewChangeRequest(userTransformer(reviewer), crId, '', true, org); + await ChangeRequestsService.reviewChangeRequest(reviewerShared, crId, '', true, org); await respond({ replace_original: true, text: `✅ CR #${cr.identifier} approved by ${reviewer.firstName} ${reviewer.lastName}.` From 0ad7c3931dc3c752db021c007453df038a0ee6dc Mon Sep 17 00:00:00 2001 From: wavehassman Date: Tue, 12 May 2026 20:47:20 -0400 Subject: [PATCH 24/29] #4172 fix build hopefully --- src/backend/src/services/slack.services.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index b0710a2d8f..989ed5d682 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -233,13 +233,23 @@ export default class SlackServices { delete_original?: boolean; }) => Promise ): Promise { + const cr = await prisma.change_Request.findUnique({ + where: { + crId + } + }); + + if (!cr) { + throw new NotFoundException('Change Request', crId); + } + const reviewer = await prisma.user.findFirst({ where: { userSettings: { slackId: userSlackId } }, - ...getUserQueryArgs() + ...getUserQueryArgs(cr.organizationId) }); if (!reviewer) { @@ -247,16 +257,6 @@ export default class SlackServices { throw new NotFoundException('User', userSlackId); } - const cr = await prisma.change_Request.findUnique({ - where: { - crId - } - }); - - if (!cr) { - throw new NotFoundException('Change Request', crId); - } - const org = await prisma.organization.findUnique({ where: { organizationId: cr.organizationId From 19d40584c41616a26c73c24644b2d1d7a4cdf187 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Tue, 12 May 2026 21:40:42 -0400 Subject: [PATCH 25/29] #4172 merged in new changes --- src/backend/src/services/slack.services.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index 989ed5d682..7878782e13 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -270,7 +270,7 @@ export default class SlackServices { const reviewerShared: User = userTransformer(reviewer); try { - await ChangeRequestsService.reviewChangeRequest(reviewerShared, crId, '', true, org); + await ChangeRequestsService.reviewChangeRequest(reviewerShared, crId, true, org); await respond({ replace_original: true, text: `✅ CR #${cr.identifier} approved by ${reviewer.firstName} ${reviewer.lastName}.` From de2130eb207396675e3f49ea70575a0ff3951eee Mon Sep 17 00:00:00 2001 From: Sarah Taylor <150694563+staysgt@users.noreply.github.com> Date: Wed, 13 May 2026 09:52:32 -0400 Subject: [PATCH 26/29] #4172 fix from review --- src/backend/src/controllers/slack.controllers.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts index 08df2f50f5..7a3f3d4beb 100644 --- a/src/backend/src/controllers/slack.controllers.ts +++ b/src/backend/src/controllers/slack.controllers.ts @@ -87,7 +87,15 @@ export default class SlackController { } } - static async handleApproveCRAction(body: SlackBlockActionBody, respond: any) { + static async handleApproveCRAction( + body: SlackBlockActionBody, + respond: (msg: { + response_type?: 'ephemeral'; + text?: string; + replace_original?: boolean; + delete_original?: boolean; + }) => Promise + ) { const { user, container, actions } = body; const channelId = container.channel_id; const threadTs = container.thread_ts || container.message_ts; From 3b8d9d9162a8b7a199999e0c697fca1296ed1d58 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Wed, 13 May 2026 17:21:47 -0400 Subject: [PATCH 27/29] merge into develop From e54a70f1681ec327107afdfd36fdc1ff6443970e Mon Sep 17 00:00:00 2001 From: wavehassman Date: Wed, 13 May 2026 17:59:42 -0400 Subject: [PATCH 28/29] fix: update e2e tests for descoped CR workflow --- .../change-request-overview.cy.js | 52 +++----------- .../change-requests/new-change-request.cy.js | 39 +++------- .../e2e/projects/projects-overview.cy.js | 14 +--- .../cypress/utils/change-request.utils.cy.js | 72 +------------------ 4 files changed, 25 insertions(+), 152 deletions(-) diff --git a/system-tests/cypress/e2e/change-requests/change-request-overview.cy.js b/system-tests/cypress/e2e/change-requests/change-request-overview.cy.js index 067efebf66..a4c0ad96d3 100644 --- a/system-tests/cypress/e2e/change-requests/change-request-overview.cy.js +++ b/system-tests/cypress/e2e/change-requests/change-request-overview.cy.js @@ -1,45 +1,15 @@ -/// -import { - CR_ROW, - NEW_CHANGE_REQUEST_BUTTON, - ALL_CHANGE_REQUESTS_TAB, - CHANGE_REQUEST_TABLE -} from '../../utils/selectors.utils'; +/* eslint-disable no-undef */ +import { PROJECT_OR_WORKPACKAGE_PLACEHOLDER, SUBMIT_BUTTON, CR_ROW } from './selectors.utils'; +import { INCLUDE } from './cypress-actions.utils'; -import { LENGTH_GREATER_THAN, INCLUDE } from '../../utils/cypress-actions.utils'; +export const createChangeRequest = ({ wbsTitle = '25.1.0 - Impact Attenuator', why = 'test why' }) => { + cy.get(PROJECT_OR_WORKPACKAGE_PLACEHOLDER).click(); + cy.contains(wbsTitle).click(); -// To learn more about how Cypress works and -// what makes it such an awesome testing tool, -// please read our getting started guide: -// https://on.cypress.io/introduction-to-cypress + cy.contains('Why are you making this change?').parent().find('textarea').first().type(why); -describe('Change Request Overview', () => { - beforeEach(() => { - // Cypress starts out with a blank slate for each test - // so we must tell it to visit our website with the `cy.visit()` command. - // Since we want to visit the same URL at the start of all our tests, - // we include it in our beforeEach function so that it runs before each test - cy.login('Thomas Emrax', '/change-requests'); - }); + cy.contains(SUBMIT_BUTTON).click(); + cy.url().should(INCLUDE, '/change-requests'); - it('Change Requests to Review Should Display At Least One CR', () => { - // We use the `cy.get()` command to get all elements that match the selector. - cy.get(CR_ROW('Change Requests To Review')).children().should(LENGTH_GREATER_THAN, 0); - }); - - it('My Aproved Change Requests Should Display At Least Three CRs', () => { - cy.get(CR_ROW('My Approved Change Requests')).children().should(LENGTH_GREATER_THAN, 2); - }); - - it('New Change Request Button Redirects to New Change Requeest Form', () => { - cy.contains(NEW_CHANGE_REQUEST_BUTTON).click(); - - cy.url().should(INCLUDE, '/change-requests/new'); - }); - - it('Can Switch to All Change Requests Table', () => { - cy.contains(ALL_CHANGE_REQUESTS_TAB).click(); - cy.url().should(INCLUDE, '/change-requests/all'); - cy.get(CHANGE_REQUEST_TABLE); - }); -}); + cy.get(CR_ROW('Un-reviewed Change Requests')).contains('Change Request').should('exist'); +}; diff --git a/system-tests/cypress/e2e/change-requests/new-change-request.cy.js b/system-tests/cypress/e2e/change-requests/new-change-request.cy.js index 8cc8d261ff..575b2ab8f7 100644 --- a/system-tests/cypress/e2e/change-requests/new-change-request.cy.js +++ b/system-tests/cypress/e2e/change-requests/new-change-request.cy.js @@ -1,18 +1,6 @@ /// -import { - PROJECT_OR_WORKPACKAGE_PLACEHOLDER, - ISSUE_BUTTON, - DEFINITION_CHANGE_BUTTON, - OTHER_BUTTON, - WHAT_DESCRIPTOR, - EXPLAIN_TEXT_BOX_PLACEHOLDER, - WHY_DESCRIPTOR, - WHY_DELETE_OPTION, - WHY_TYPE_OPTION, - WHY_EXPLAIN_TEXT_BOX, - ADD_PROPOSED_SOLUTION_BUTTON -} from '../../utils/selectors.utils'; -import { VISIBLE, LENGTH_GREATER_THAN, EXIST } from '../../utils/cypress-actions.utils'; +import { PROJECT_OR_WORKPACKAGE_PLACEHOLDER } from '../../utils/selectors.utils'; +import { VISIBLE } from '../../utils/cypress-actions.utils'; import { createChangeRequest } from '../../utils/change-request.utils.cy'; describe('New Change Request', () => { @@ -22,22 +10,15 @@ describe('New Change Request', () => { it('Displays all new CR Fields', () => { cy.get(PROJECT_OR_WORKPACKAGE_PLACEHOLDER).should(VISIBLE); - cy.contains(ISSUE_BUTTON).should(VISIBLE); - cy.contains(DEFINITION_CHANGE_BUTTON).should(VISIBLE); - cy.contains(OTHER_BUTTON).should(VISIBLE); - cy.contains(WHAT_DESCRIPTOR).should(VISIBLE); - cy.contains(WHAT_DESCRIPTOR).parent().find(EXPLAIN_TEXT_BOX_PLACEHOLDER); - cy.contains(WHY_DESCRIPTOR).should(VISIBLE); - cy.get(WHY_TYPE_OPTION(0)).should(EXIST); - cy.get(WHY_EXPLAIN_TEXT_BOX(0)).should(EXIST); - cy.get(WHY_DELETE_OPTION).should(VISIBLE); - cy.get(WHY_DELETE_OPTION).should(LENGTH_GREATER_THAN, 0); - cy.contains(ADD_PROPOSED_SOLUTION_BUTTON).should(VISIBLE); + cy.contains('Why are you making this change?').should(VISIBLE); + cy.contains('Requested Reviewer (optional)').should(VISIBLE); }); - [{}].forEach((args) => { - it('Creating a Change Request Works', () => { - createChangeRequest(args); - }); + it('Creating a Change Request Works', () => { + createChangeRequest({}); + }); + + it('Creating a Change Request Works Without a Reviewer', () => { + createChangeRequest({ why: 'test why no reviewer' }); }); }); diff --git a/system-tests/cypress/e2e/projects/projects-overview.cy.js b/system-tests/cypress/e2e/projects/projects-overview.cy.js index 37222ee1c1..ac613b30b6 100644 --- a/system-tests/cypress/e2e/projects/projects-overview.cy.js +++ b/system-tests/cypress/e2e/projects/projects-overview.cy.js @@ -1,6 +1,5 @@ /// import { NEW_PROJECT_BUTTON, ALL_PROJECTS_TAB, MY_TEAMS_PROJECTS, PROJECTS_IM_LEADING } from '../../utils/selectors.utils'; - import { VISIBLE, LENGTH_GREATER_THAN, INCLUDE } from '../../utils/cypress-actions.utils'; describe('Projects Overview', () => { @@ -29,33 +28,22 @@ describe('Projects Overview', () => { it('Creating a Project Writes to DB and Appears in All Projects', () => { const projectName = 'E2E Test Project'; - // Click New Project cy.contains(NEW_PROJECT_BUTTON).click(); cy.url().should(INCLUDE, '/projects/new'); - // Fill in Project Name cy.get('[placeholder="Enter project name..."]').type(projectName); - // Car is pre-selected (NER-25), keep default - - // Select a Team - // Target the Teams label (not the sidebar link) and find its sibling combobox cy.get('label').contains('Teams').parent().find('[role="combobox"]').click({ force: true }); cy.get('[role="listbox"]').contains('Huskies').click(); - // Close any open dropdowns cy.get('body').click(0, 0); - // Fill in Summary cy.get('[placeholder="Enter a summary..."]').type('An e2e test project for automated testing', { force: true }); - // Submit - cy.contains('Submit').click({ force: true }); + cy.contains('Create Project').click({ force: true }); - // Should redirect to All Projects cy.url().should(INCLUDE, '/projects/all'); - // Verify the project appears in the table cy.contains(projectName, { timeout: 10000 }).should(VISIBLE); }); }); diff --git a/system-tests/cypress/utils/change-request.utils.cy.js b/system-tests/cypress/utils/change-request.utils.cy.js index 9653350a8a..138218f045 100644 --- a/system-tests/cypress/utils/change-request.utils.cy.js +++ b/system-tests/cypress/utils/change-request.utils.cy.js @@ -1,81 +1,15 @@ /* eslint-disable no-undef */ -import { - PROPOSED_SOLUTION_BUDGET_INPUT, - PROPOSED_SOLUTION_DESCRIPTION_INPUT, - PROPOSED_SOLUTION_SCOPE_INPUT, - PROPOSED_SOLUTION_TIMELINE_INPUT, - DIALOG, - ADD_BUTTON, - PROJECT_OR_WORKPACKAGE_PLACEHOLDER, - EXPLAIN_TEXT_BOX_PLACEHOLDER, - WHAT_DESCRIPTOR, - WHY_EXPLAIN_TEXT_BOX, - ADD_PROPOSED_SOLUTION_BUTTON, - SUBMIT_BUTTON, - CR_ROW, - WHY_TYPE_OPTION, - ADD_REASON -} from './selectors.utils'; +import { PROJECT_OR_WORKPACKAGE_PLACEHOLDER, SUBMIT_BUTTON, CR_ROW } from './selectors.utils'; import { INCLUDE } from './cypress-actions.utils'; -const createProposedSolution = ({ - description = 'Test Description', - scopeImpact = 'Test Scope', - budgetImpact = 1, - timelineImpact = 2 -}) => { - cy.get(PROPOSED_SOLUTION_DESCRIPTION_INPUT).type(description); - cy.get(PROPOSED_SOLUTION_SCOPE_INPUT).type(scopeImpact); - cy.get(PROPOSED_SOLUTION_BUDGET_INPUT).type(budgetImpact); - cy.get(PROPOSED_SOLUTION_TIMELINE_INPUT).type(timelineImpact); - cy.get(DIALOG).find('button').contains(ADD_BUTTON).click(); -}; - -export const createChangeRequest = ({ - wbsTitle = '25.1.0 - Impact Attenuator', - what = 'test what', - type = 'ISSUE', - whys = [ - { - type: 'OTHER', - description: 'test why' - } - ], - psArguments = [ - { - description: 'Test Description', - scopeImpact: 'Test Scope', - budgetImpact: 1, - timelineImpact: 2 - } - ] -}) => { +export const createChangeRequest = ({ wbsTitle = '25.1.0 - Impact Attenuator', why = 'test why' } = {}) => { cy.get(PROJECT_OR_WORKPACKAGE_PLACEHOLDER).click(); cy.contains(wbsTitle).click(); - cy.contains(WHAT_DESCRIPTOR).parent().find(EXPLAIN_TEXT_BOX_PLACEHOLDER).type(what); - cy.contains(type).click(); - whys.forEach((why, index) => { - cy.get(WHY_TYPE_OPTION(index)).parent().click(); - cy.get('li') - .contains(new RegExp(`^${why.type}$`, 'g')) - .click(); - cy.get(WHY_EXPLAIN_TEXT_BOX(index)).type(why.description); - if (index !== whys.length - 1) { - cy.contains(ADD_REASON).click(); - } - }); - cy.contains(ADD_PROPOSED_SOLUTION_BUTTON).click(); - psArguments.forEach((argument, index) => { - createProposedSolution(argument); - if (index !== psArguments.length - 1) { - cy.contains(ADD_PROPOSED_SOLUTION_BUTTON).click(); - } - }); + cy.contains('Why are you making this change?').parent().find('textarea').first().type(why); cy.contains(SUBMIT_BUTTON).click(); cy.url().should(INCLUDE, '/change-requests'); - // Verify the created CR appears in Un-reviewed Change Requests cy.get(CR_ROW('Un-reviewed Change Requests')).contains('Change Request').should('exist'); }; From 8d3c591d206d284dca45dc2052bd46f162ad04ba Mon Sep 17 00:00:00 2001 From: wavehassman Date: Wed, 13 May 2026 18:13:38 -0400 Subject: [PATCH 29/29] hopefully works now --- .../change-request-overview.cy.js | 52 +++++++++++++++---- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/system-tests/cypress/e2e/change-requests/change-request-overview.cy.js b/system-tests/cypress/e2e/change-requests/change-request-overview.cy.js index a4c0ad96d3..067efebf66 100644 --- a/system-tests/cypress/e2e/change-requests/change-request-overview.cy.js +++ b/system-tests/cypress/e2e/change-requests/change-request-overview.cy.js @@ -1,15 +1,45 @@ -/* eslint-disable no-undef */ -import { PROJECT_OR_WORKPACKAGE_PLACEHOLDER, SUBMIT_BUTTON, CR_ROW } from './selectors.utils'; -import { INCLUDE } from './cypress-actions.utils'; +/// +import { + CR_ROW, + NEW_CHANGE_REQUEST_BUTTON, + ALL_CHANGE_REQUESTS_TAB, + CHANGE_REQUEST_TABLE +} from '../../utils/selectors.utils'; -export const createChangeRequest = ({ wbsTitle = '25.1.0 - Impact Attenuator', why = 'test why' }) => { - cy.get(PROJECT_OR_WORKPACKAGE_PLACEHOLDER).click(); - cy.contains(wbsTitle).click(); +import { LENGTH_GREATER_THAN, INCLUDE } from '../../utils/cypress-actions.utils'; - cy.contains('Why are you making this change?').parent().find('textarea').first().type(why); +// To learn more about how Cypress works and +// what makes it such an awesome testing tool, +// please read our getting started guide: +// https://on.cypress.io/introduction-to-cypress - cy.contains(SUBMIT_BUTTON).click(); - cy.url().should(INCLUDE, '/change-requests'); +describe('Change Request Overview', () => { + beforeEach(() => { + // Cypress starts out with a blank slate for each test + // so we must tell it to visit our website with the `cy.visit()` command. + // Since we want to visit the same URL at the start of all our tests, + // we include it in our beforeEach function so that it runs before each test + cy.login('Thomas Emrax', '/change-requests'); + }); - cy.get(CR_ROW('Un-reviewed Change Requests')).contains('Change Request').should('exist'); -}; + it('Change Requests to Review Should Display At Least One CR', () => { + // We use the `cy.get()` command to get all elements that match the selector. + cy.get(CR_ROW('Change Requests To Review')).children().should(LENGTH_GREATER_THAN, 0); + }); + + it('My Aproved Change Requests Should Display At Least Three CRs', () => { + cy.get(CR_ROW('My Approved Change Requests')).children().should(LENGTH_GREATER_THAN, 2); + }); + + it('New Change Request Button Redirects to New Change Requeest Form', () => { + cy.contains(NEW_CHANGE_REQUEST_BUTTON).click(); + + cy.url().should(INCLUDE, '/change-requests/new'); + }); + + it('Can Switch to All Change Requests Table', () => { + cy.contains(ALL_CHANGE_REQUESTS_TAB).click(); + cy.url().should(INCLUDE, '/change-requests/all'); + cy.get(CHANGE_REQUEST_TABLE); + }); +});