diff --git a/src/backend/src/controllers/change-requests.controllers.ts b/src/backend/src/controllers/change-requests.controllers.ts index 0dd94267ba..5c09e5362f 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 + reviewNotes ); res.status(200).json({ message: `Change request #${id} successfully reviewed.` }); } catch (error: unknown) { @@ -100,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, @@ -122,14 +120,14 @@ export default class ChangeRequestsController { static async createStageGateChangeRequest(req: Request, res: Response, next: NextFunction) { try { - const { wbsNum, type, confirmDone } = req.body; + const { wbsNum, confirmDone, dateCompleted } = req.body; const id = await ChangeRequestsService.createStageGateChangeRequest( req.currentUser, wbsNum.carNumber, wbsNum.projectNumber, wbsNum.workPackageNumber, - type, confirmDone, + new Date(dateCompleted), req.organization ); res.status(200).json({ message: `Successfully created stage gate request with id #${id}` }); @@ -140,10 +138,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, @@ -176,7 +173,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 +183,9 @@ export default class ChangeRequestsController { wbsNum.carNumber, wbsNum.projectNumber, wbsNum.workPackageNumber, - type, - what, why, - proposedSolutions, req.organization, + requestedReviewerId, projectProposedChanges, workPackageProposedChanges ); @@ -200,24 +195,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/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts index 506f953740..7a3f3d4beb 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,72 @@ export default class SlackController { throw error; } } + + 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; + 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/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..52f0691018 --- /dev/null +++ b/src/backend/src/prisma/migrations/20260420022045_descoping_change_requests/migration.sql @@ -0,0 +1,105 @@ +/* + 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. + +*/ + +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/schema.prisma b/src/backend/src/prisma/schema.prisma index a757bbaa43..e74ba7f671 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 @@ -209,7 +194,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") @@ -383,6 +367,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") @@ -392,12 +377,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]) @@ -421,46 +407,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 @@ -1287,14 +1233,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 07c95fa1ed..4997535e4b 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, true, ner, 'LGTM'); /** 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, true, ner, 'LGTM'); 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, true, ner, 'LGTM'); 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, true, ner, 'LGTM'); // approve the change request - await ChangeRequestsService.reviewChangeRequest( - batman, - changeRequestProjectAvatar1Id, - 'LGTM', - true, - ner, - proposedSolutionAvatar1Id - ); + await ChangeRequestsService.reviewChangeRequest(batman, changeRequestProjectAvatar1Id, true, ner, 'LGTM'); 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, true, ner, 'LGTM'); 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, true, ner, 'LGTM'); 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, true, ner, 'LGTM'); 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, true, ner, 'LGTM'); // 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, true, ner, 'LGTM'); // 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, true, ner, 'LGTM'); // 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, true, ner, 'LGTM'); /** * Work Packages @@ -1732,7 +1287,6 @@ const performSeed: () => Promise = async () => { workPackageHuskies1.wbsNum.carNumber, workPackageHuskies1.wbsNum.projectNumber, workPackageHuskies1.wbsNum.workPackageNumber, - 'ACTIVATION', thomasEmrax.userId, joeShmoe.userId, weeksAgo(12), @@ -1740,14 +1294,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest( - joeShmoe, - workPackage1ActivationCrId, - 'Looks good to me!', - true, - ner, - null - ); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackage1ActivationCrId, true, ner, 'Looks good to me!'); // await DescriptionBulletsService.checkDescriptionBullet(thomasEmrax, workPackage1.description[0].descriptionId); @@ -1796,7 +1343,6 @@ const performSeed: () => Promise = async () => { workPackageSlackbot1WbsNumber.carNumber, workPackageSlackbot1WbsNumber.projectNumber, workPackageSlackbot1WbsNumber.workPackageNumber, - CR_Type.ACTIVATION, regina.userId, janis.userId, weeksAgo(9), @@ -1804,7 +1350,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageSlackbot1ActivationCrId, 'LGTM!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageSlackbot1ActivationCrId, true, ner, 'LGTM!'); /** Work Package Slackbot 2 */ const { workPackageWbsNumber: workPackageSlackbot2WbsNumber, workPackage: workPackage4 } = await seedWorkPackage( @@ -1829,7 +1375,6 @@ const performSeed: () => Promise = async () => { workPackageSlackbot2WbsNumber.carNumber, workPackageSlackbot2WbsNumber.projectNumber, workPackageSlackbot2WbsNumber.workPackageNumber, - CR_Type.ACTIVATION, joeShmoe.userId, thomasEmrax.userId, weeksAgo(5), @@ -1837,7 +1382,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageSlackbot2ActivationCrId, 'LGTM!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageSlackbot2ActivationCrId, true, ner, 'LGTM!'); /** AVATAR TEAM */ /** Work Packages for Project 1 */ @@ -1865,7 +1410,6 @@ const performSeed: () => Promise = async () => { workPackageAvatarProject1WbsNumber.carNumber, workPackageAvatarProject1WbsNumber.projectNumber, workPackageAvatarProject1WbsNumber.workPackageNumber, - CR_Type.ACTIVATION, katara.userId, aang.userId, weeksAgo(16), @@ -1876,10 +1420,9 @@ const performSeed: () => Promise = async () => { await ChangeRequestsService.reviewChangeRequest( joeShmoe, workPackageAvatarProject1ActivationCrId, - 'Very cute LGTM!', true, ner, - null + 'Very cute LGTM!' ); /** Work Package 2 */ @@ -1906,7 +1449,6 @@ const performSeed: () => Promise = async () => { workPackageAvatarProject2WbsNumber.carNumber, workPackageAvatarProject2WbsNumber.projectNumber, workPackageAvatarProject2WbsNumber.workPackageNumber, - CR_Type.ACTIVATION, katara.userId, aang.userId, weeksAgo(9), @@ -1914,14 +1456,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest( - joeShmoe, - workPackageAvatarProject2ActivationCrId, - 'LGTM!', - true, - ner, - null - ); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageAvatarProject2ActivationCrId, true, ner, 'LGTM!'); /** Work Package 3 */ const { workPackageWbsNumber: workPackageAvatarProject3WbsNumber, workPackage: workPackageAvatarProject3 } = @@ -1947,7 +1482,6 @@ const performSeed: () => Promise = async () => { workPackageAvatarProject3WbsNumber.carNumber, workPackageAvatarProject3WbsNumber.projectNumber, workPackageAvatarProject3WbsNumber.workPackageNumber, - CR_Type.ACTIVATION, katara.userId, aang.userId, weeksAgo(4), @@ -1955,7 +1489,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageAvatarProject3ActivationCrId, 'LFG', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, workPackageAvatarProject3ActivationCrId, true, ner, 'LFG'); /** Work Packages for Justice League */ /** Project 1 */ @@ -1982,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), @@ -1990,7 +1523,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectJustice1WP1ActivationCrId, 'Approved!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectJustice1WP1ActivationCrId, true, ner, 'Approved!'); /** Work Package 2 */ await seedWorkPackage( @@ -2052,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), @@ -2060,7 +1592,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectJustice2WP1ActivationCrId, 'Approved!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectJustice2WP1ActivationCrId, true, ner, 'Approved!'); /** Work Package 2 */ await seedWorkPackage( @@ -2104,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), @@ -2112,7 +1643,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, project4WP1ActivationCrId, 'Approved!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, project4WP1ActivationCrId, true, ner, 'Approved!'); /** Work Package 2 */ await seedWorkPackage( @@ -2174,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), @@ -2182,7 +1712,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectKrusty1WP1ActivationCrId, 'Approved!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectKrusty1WP1ActivationCrId, true, ner, 'Approved!'); /** Work Package 2 */ await seedWorkPackage( @@ -2244,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), @@ -2252,7 +1781,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectKrusty2WP1ActivationCrId, 'Approved!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectKrusty2WP1ActivationCrId, true, ner, 'Approved!'); /** Work Package 2 */ await seedWorkPackage( @@ -2621,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), @@ -2629,7 +2157,7 @@ const performSeed: () => Promise = async () => { ner ); - await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectPenguin1WP1ActivationCrId, 'Approved!', true, ner, null); + await ChangeRequestsService.reviewChangeRequest(joeShmoe, projectPenguin1WP1ActivationCrId, true, ner, 'Approved!'); /** Work Packages for Penguin Project 2*/ /** Work Package 1 */ @@ -2655,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), @@ -2705,8 +2232,8 @@ const performSeed: () => Promise = async () => { workPackageHuskies1WbsNumber.carNumber, workPackageHuskies1WbsNumber.projectNumber, workPackageHuskies1WbsNumber.workPackageNumber, - CR_Type.STAGE_GATE, true, + new Date(), ner ); @@ -2715,38 +2242,16 @@ 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, false, ner, 'What the hell Thomas'); await ChangeRequestsService.createActivationChangeRequest( thomasEmrax, workPackageSlackbot1WbsNumber.carNumber, workPackageSlackbot1WbsNumber.projectNumber, workPackageSlackbot1WbsNumber.workPackageNumber, - CR_Type.ACTIVATION, thomasEmrax.userId, joeShmoe.userId, weeksAgo(9), @@ -3498,7 +3003,6 @@ const performSeed: () => Promise = async () => { const budgetCR = await ChangeRequestsService.createBudgetChangeRequest( thomasEmrax, - 'BUDGET', 50, ner, otherProductReasonConsumables.otherProductReasonId @@ -3630,25 +3134,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, true, ner, 'create wp'); const { workPackageWbsNumber: workPackage9WbsNumber } = await seedWorkPackage( thomasEmrax, @@ -3672,23 +3161,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..b04305735b 100644 --- a/src/backend/src/routes/change-requests.routes.ts +++ b/src/backend/src/routes/change-requests.routes.ts @@ -1,9 +1,9 @@ import express from 'express'; import { body } from 'express-validator'; -import { ChangeRequestReason, ChangeRequestType } from 'shared'; import ChangeRequestsController from '../controllers/change-requests.controllers.js'; import { intMinZero, + isDate, isDateOnly, nonEmptyString, projectProposedChangesValidators, @@ -26,9 +26,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 ); @@ -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,8 +52,8 @@ changeRequestsRouter.post( intMinZero(body('wbsNum.carNumber')), intMinZero(body('wbsNum.projectNumber')), intMinZero(body('wbsNum.workPackageNumber')), - body('type').custom((value) => value === ChangeRequestType.StageGate), body('confirmDone').isBoolean(), + isDate(body('dateCompleted')), validateInputs, ChangeRequestsController.createStageGateChangeRequest ); @@ -65,7 +63,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 @@ -73,22 +70,11 @@ 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('why')), + nonEmptyString(body('requestedReviewerId')).optional(), ...projectProposedChangesValidators, ...workPackageProposedChangesValidators('workPackageProposedChanges'), validateInputs, @@ -97,18 +83,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/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/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 8afab03ff5..eb8d2b6d5a 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -5,17 +5,15 @@ import { isAdmin, isGuest, isLeadership, - isNotLeadership, isProjectWbs, ProjectProposedChangesCreateArgs, - ProposedSolution, - ProposedSolutionCreateArgs, StageGateChangeRequest, StandardChangeRequest, WbsNumber, wbsPipe, WorkPackageProposedChangesCreateArgs, - User + User, + isHead } from 'shared'; import prisma from '../prisma/prisma.js'; import { @@ -34,17 +32,17 @@ import changeRequestTransformer, { } from '../transformers/change-requests.transformer.js'; import { allChangeRequestsReviewed, + buildCRDiff, validateProposedChangesFields, 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'; @@ -52,7 +50,8 @@ import { addSlackThreadsToChangeRequest, sendAndGetSlackCRNotifications, sendSlackCRStatusToThread, - sendSlackRequestedReviewNotification + sendSlackRequestedReviewNotification, + sendStandardCRCreatedNotification } from '../utils/slack.utils.js'; import { ChangeRequestWithProjectAndWorkPackageQueryArgs, @@ -61,8 +60,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 +86,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 +104,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 +118,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 +143,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 +171,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 +182,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 +206,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 +218,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 +230,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, @@ -275,30 +245,13 @@ 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 - * @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 + reviewNotes?: string ): 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,31 +263,21 @@ 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) + 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!"); - // 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) { - 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'); } - // 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) { - 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); - } - // finally we can update change request + // Mark as reviewed FIRST so validateChangeRequestAccepted passes when applying proposed changes const updated = await prisma.change_Request.update({ where: { crId }, data: { @@ -349,143 +292,73 @@ export default class ChangeRequestsService { } }); - // send a notification to the submitter that their change request has been reviewed - await sendCRSubmitterReviewedNotification(updated); + if (accepted && foundCR.type === CR_Type.STAGE_GATE) { + 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) { + 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); - - // 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 + ); } } @@ -493,10 +366,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!'); @@ -504,9 +379,14 @@ export default class ChangeRequestsService { throwIfUncheckedDescriptionBullets(foundCR.wbsElement.descriptionBullets); - // update the status of the associated wp to be complete if needed + const { workPackage } = foundCR.wbsElement; 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, @@ -515,17 +395,26 @@ export default class ChangeRequestsService { }); } + // Calculate new duration from startDate to dateCompleted if provided + const msPerWeek = 7 * 24 * 60 * 60 * 1000; + const newDuration = Math.max(1, Math.round((dateCompleted.getTime() - workPackage.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, - changes: { - createMany: { - data: changesList - } - } + changes: { createMany: { data: changesList } } } } } @@ -637,9 +526,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 +545,7 @@ export default class ChangeRequestsService { await prisma.account_Code.update({ where: { accountCodeId: foundCR.accountCodeId ?? '' }, - data: { - amount: budgetChangeRequest.proposedBudget - } + data: { amount: budgetChangeRequest.proposedBudget } }); } @@ -670,66 +555,47 @@ 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 * @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, carNumber: number, projectNumber: number, workPackageNumber: number, - type: CR_Type, leadId: string, managerId: string, startDate: Date, 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` ); } @@ -741,7 +607,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 } }, @@ -765,12 +631,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,54 +642,40 @@ 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 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, carNumber: number, projectNumber: number, workPackageNumber: number, - type: CR_Type, confirmDone: boolean, + dateCompleted: Date, 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 +686,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` ); } @@ -850,12 +698,9 @@ 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 - } + create: { leftoverBudget: 0, confirmDone } }, organization: { connect: { organizationId: organization.organizationId } }, identifier: numChangeRequests + 1 @@ -872,56 +717,44 @@ 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, dateCompleted); 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 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, - type: CR_Type, proposedBudget: number, 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; @@ -940,12 +773,8 @@ export default class ChangeRequestsService { data: { submitter: { connect: { userId: submitter.userId } }, category: { connect: { otherReimbursementProductReasonId: otherReasonId } }, - type, - budgetChangeRequest: { - create: { - proposedBudget - } - }, + type: CR_Type.BUDGET, + budgetChangeRequest: { create: { proposedBudget } }, organization: { connect: { organizationId: organization.organizationId } }, identifier: numChangeRequests + 1 }, @@ -953,10 +782,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 +794,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; @@ -1000,12 +820,8 @@ export default class ChangeRequestsService { data: { submitter: { connect: { userId: submitter.userId } }, accountCode: { connect: { accountCodeId } }, - type, - budgetChangeRequest: { - create: { - proposedBudget - } - }, + type: CR_Type.BUDGET, + budgetChangeRequest: { create: { proposedBudget } }, organization: { connect: { organizationId: organization.organizationId } }, identifier: numChangeRequests + 1 }, @@ -1013,10 +829,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 +842,6 @@ export default class ChangeRequestsService { undefined, accountCode ); - - // save the slack references to the change request await addSlackThreadsToChangeRequest(createdChangeRequest.crId, notifications); } } @@ -1044,7 +855,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 +876,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 +894,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 +922,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 +952,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 +963,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,55 +982,42 @@ 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 } + }, + include: { + links: { where: { dateDeleted: null }, include: { linkType: { select: { name: true } } } }, + project: { select: { budget: true, summary: true } }, + workPackage: { select: { startDate: true, duration: true, stage: true } }, + descriptionBullets: { + where: { dateDeleted: null }, + include: { descriptionBulletType: { select: { name: true } } } } } }); @@ -1242,11 +1026,16 @@ 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 (requestedReviewerId) { + const reviewer = await prisma.user.findUnique({ where: { userId: requestedReviewerId } }); + if (!reviewer) throw new NotFoundException('User', requestedReviewerId); + } + 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 +1048,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 +1060,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 +1078,8 @@ export default class ChangeRequestsService { summary, teamIds, descriptionBullets, - workPackageProposedChanges, - carNumber + workPackageProposedChanges: wpChanges, + carNumber: proposedCarNumber } = projectProposedChanges; const validationResult = await validateProposedChangesFields( @@ -1308,9 +1087,9 @@ export default class ChangeRequestsService { links, descriptionBullets, [], - workPackageProposedChanges, + wpChanges, organization.organizationId, - carNumber, + proposedCarNumber, leadId, managerId ); @@ -1322,15 +1101,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 +1146,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 +1167,7 @@ export default class ChangeRequestsService { data: { leadId, managerId, - projectProposedChanges: { - update: { - carId: validationResult.carId - } - } + projectProposedChanges: { update: { carId: validationResult.carId } } } }); } else if (workPackageProposedChanges) { @@ -1415,11 +1186,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 +1199,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,40 +1223,24 @@ 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) { - 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 ); - - // save the slack references to the change request - await addSlackThreadsToChangeRequest(createdCR.crId, notifications); } const finishedCR = await prisma.change_Request.findUnique({ @@ -1510,61 +1253,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 +1260,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 +1277,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 +1295,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 +1306,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 +1329,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/slack.services.ts b/src/backend/src/services/slack.services.ts index 5afb0914fe..7878782e13 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -3,8 +3,17 @@ 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 { + AccessDeniedException, + HttpException, + InvalidOrganizationException, + NotFoundException +} 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'; +import { User } from 'shared'; /** * Represents a slack event for a message in a channel. @@ -125,6 +134,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 +215,93 @@ 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 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(cr.organizationId) + }); + + if (!reviewer) { + console.error('User not found for slack ID:', userSlackId); + throw new NotFoundException('User', userSlackId); + } + + const org = await prisma.organization.findUnique({ + where: { + organizationId: cr.organizationId + } + }); + + if (!org) { + throw new NotFoundException('Organization', cr.organizationId); + } + + const reviewerShared: User = userTransformer(reviewer); + + try { + await ChangeRequestsService.reviewChangeRequest(reviewerShared, 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/services/work-packages.services.ts b/src/backend/src/services/work-packages.services.ts index 63c4a1a095..f80794de6d 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 { @@ -468,13 +468,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..ab3a42a7a3 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,31 @@ import { Organization } from '@prisma/client'; import { - addWeeksToDate, - ChangeRequestReason, DescriptionBulletPreview, + formatDateOnly, LinkCreateArgs, + ProjectProposedChangesCreateArgs, 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 @@ -476,123 +385,122 @@ 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 + * 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 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); +export const buildCRDiff = ( + currentWbs: { + 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; + descriptionBullets?: { detail: string; descriptionBulletType: { name: string } }[]; + }, + 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}`); } + }; - // 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 - } - } - } + const addNew = (label: string, value: string | number | null | undefined) => { + 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}`); } - }); + } + }; - //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 }); + 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}`); + } + } + }; - await Promise.all(changePromises); + 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); + 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); + 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); + addLinkDiffs(currentWbs.links, wpProposed.links); + addBulletDiffs(currentWbs.descriptionBullets ?? [], wpProposed.descriptionBullets); + } + } 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); + 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); + 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); + addBulletDiffs(currentWbs.descriptionBullets ?? [], projProposed.descriptionBullets); + } } - // finally update the proposed solution - await prisma.proposed_Solution.update({ - where: { proposedSolutionId: psId }, - data: { - approved: true - } - }); + return lines.join('\n'); }; /** diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index b85a4ca721..0d5788a09e 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -549,6 +549,96 @@ 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 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 ( + 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; + + // 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: 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: 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}`)) + ); + } + + // 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) + ) + ) + ); +}; + /** * Adds the relevant slack notifications for a change request to the change request * 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..14b07e2dff 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -137,10 +137,8 @@ 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.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 cfdd23fecc..382c73c770 100644 --- a/src/backend/tests/unit/change-requests.test.ts +++ b/src/backend/tests/unit/change-requests.test.ts @@ -1,21 +1,31 @@ -import { CR_Type, Organization, Scope_CR_Why_Type, User, WBS_Element_Status } from '@prisma/client'; -import { createTestCar, createTestOrganization, createTestProject, createTestUser, resetUsers } from '../test-utils.js'; +import { Organization, User, WBS_Element_Status } from '@prisma/client'; +import { + createTestCar, + createTestOrganization, + createTestProject, + createTestUser, + createTestWorkPackage, + resetUsers +} from '../test-utils.js'; import ChangeRequestsService from '../../src/services/change-requests.services.js'; import { supermanAdmin, aquamanLeadership, greenlanternHead, flashAdmin, - robinMember + robinMember, + batmanAppAdmin } 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'; +import { AccessDeniedException, AccessDeniedMemberException } from '../../src/utils/errors.utils.js'; 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 () => { @@ -39,43 +55,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 +98,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 +131,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 +148,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 +173,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 +235,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; @@ -357,10 +246,9 @@ describe('Change Request Tests', () => { const reviewResult = await ChangeRequestsService.reviewChangeRequest( nonRequestedLeadership, changeRequestId, - 'Looks good', false, organization, - null + 'Looks good' ); expect(reviewResult).toBe(changeRequestId); @@ -384,10 +272,9 @@ describe('Change Request Tests', () => { const reviewResult = await ChangeRequestsService.reviewChangeRequest( leadershipUser1, changeRequestId, - 'Approved', false, organization, - null + 'Approved' ); expect(reviewResult).toBe(changeRequestId); @@ -400,7 +287,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], @@ -408,27 +295,15 @@ describe('Change Request Tests', () => { organization ); - await expect( - ChangeRequestsService.reviewChangeRequest( - nonRequestedLeadership, - changeRequestId, - 'I want to review this', - true, - organization, - null - ) - ).rejects.toThrow(AccessDeniedException); + const reviewResult = await ChangeRequestsService.reviewChangeRequest( + nonRequestedLeadership, + changeRequestId, + false, + organization, + 'I want to review this' + ); - await expect( - ChangeRequestsService.reviewChangeRequest( - nonRequestedLeadership, - changeRequestId, - 'I want to review this', - true, - organization, - null - ) - ).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 () => { @@ -442,10 +317,9 @@ describe('Change Request Tests', () => { const reviewResult = await ChangeRequestsService.reviewChangeRequest( leadershipUser2, changeRequestId, - 'Approved by second reviewer', false, organization, - null + 'Approved by second reviewer' ); expect(reviewResult).toBe(changeRequestId); @@ -479,19 +353,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, + false, + 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( - nonRequestedLeadership, - changeRequestId, - 'Rejecting this', - false, - organization, - null - ) - ).rejects.toThrow(AccessDeniedException); + ChangeRequestsService.reviewChangeRequest(memberUser, changeRequestId, false, organization, 'trying to review') + ).rejects.toThrow(AccessDeniedMemberException); }); }); @@ -500,19 +378,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 +395,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 +405,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 +416,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 +426,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 +437,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 +447,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 +461,14 @@ 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); + + const adminUser = await createTestUser(batmanAppAdmin, orgId); // 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(adminUser, crA.crId, false, organization, ''); + await ChangeRequestsService.reviewChangeRequest(adminUser, crB.crId, false, organization, ''); const results = await ChangeRequestsService.getApprovedChangeRequests(user, undefined, organization, carAId); @@ -778,36 +477,14 @@ 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); + + const adminUser = await createTestUser(batmanAppAdmin, orgId); // 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(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 }; @@ -818,4 +495,95 @@ 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('throws an error when dateCompleted is before startDate', async () => { + const dateCompleted = new Date(startDate); + dateCompleted.setDate(dateCompleted.getDate() - 7); + + 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 () => { + 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 9892caddeb..ea65e803c6 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 }); }; @@ -128,13 +121,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 }); }; @@ -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 8f45ad0c7d..f0126c759f 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, @@ -23,7 +20,6 @@ import { getAllChangeRequests, getSingleChangeRequest, reviewChangeRequest, - addProposedSolution, deleteChangeRequest, requestCRReview, getToReviewChangeRequests, @@ -104,8 +100,7 @@ export interface ReviewPayload { reviewerId: string; crId: string; accepted: boolean; - reviewNotes: string; - psId?: string; + reviewNotes?: string; } /** @@ -120,8 +115,7 @@ export const useReviewChangeRequest = () => { reviewPayload.reviewerId, reviewPayload.crId, reviewPayload.accepted, - reviewPayload.reviewNotes, - reviewPayload.psId + reviewPayload.reviewNotes ); return data; }, @@ -154,10 +148,8 @@ export const useDeleteChangeRequest = () => { export type CreateStandardChangeRequestPayload = { wbsNum: WbsNumber; - type: Exclude; - what: string; - why: { explain: string; type: ChangeRequestReason }[]; - proposedSolutions: ProposedSolutionCreateArgs[]; + why: string; + requestedReviewerId?: string; projectProposedChanges?: ProjectProposedChangesCreateArgs; workPackageProposedChanges?: WorkPackageProposedChangesCreateArgs; }; @@ -196,6 +188,7 @@ export interface CreateStageGateChangeRequestPayload { wbsNum: WbsNumber; confirmDone: boolean; type: string; + dateCompleted: Date; } export interface CreateBudgetChangeRequestPayload { @@ -206,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. */ @@ -242,7 +226,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; } ); @@ -293,32 +282,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/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/ChangeRequestDetailsView.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.tsx index 0b4ba90a14..242bb2403c 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/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 009bafbf0c..4e382eed55 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; @@ -37,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 = ({ @@ -49,176 +44,22 @@ 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) }); - /** - * 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 }); - if (selected !== -1) { - setValue('psId', (cr as StandardChangeRequest).proposedSolutions[selected].id); - } }; - /** - * Wrapper function for onSubmit so that form data is reset after submit - */ const onSubmitWrapper = async (data: FormInput) => { await onSubmit(data); 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 ( @@ -241,14 +82,12 @@ const ReviewChangeRequestsView: React.FC = ({ ( <> - {'Additional Comments'} + {'Additional Comments (optional)'} = ({ 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..09fad240ab 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,102 +23,45 @@ 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'), + requestedReviewerId: yup.string().optional() }); 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 - } - : { - what: '', - why: [{ type: ChangeRequestReason.Other, explain: '' }], - type: ChangeRequestType.Issue - } + ? { why: 'Creating a Work Package on this Project' } + : { why: '' } }); - if (isLoading) return ; if (isError) return ; + if (isLoading) 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`); } }; const handleCancel = () => { - history.push(routes.CHANGE_REQUESTS); + const returnUrl = query.get('returnUrl'); + history.push(returnUrl ? decodeURIComponent(returnUrl) : routes.CHANGE_REQUESTS); }; return ( @@ -128,8 +69,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..d40e49158c 100644 --- a/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestView.tsx +++ b/src/frontend/src/pages/CreateChangeRequestPage/CreateChangeRequestView.tsx @@ -2,53 +2,27 @@ * 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 { 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'; 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 +38,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 +47,6 @@ const CreateChangeRequestsView: React.FC = ({ wbsNum, setWbsNum, onSubmit, - proposedSolutions, - setProposedSolutions, handleCancel, modalView = false, changeRequestFormReturn @@ -84,27 +54,17 @@ 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 { isLoading: membersIsLoading, isError: membersIsError, error: membersError, data: members } = useAllMembers(); - 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 ; + if (membersIsError) return ; + if (isLoading || !projects || membersIsLoading || !members) return ; - const projectOptions: { label: string; id: string }[] = []; + const memberOptions = members.map(userToAutocompleteOption); const wbsDropdownOptions: { label: string; id: string }[] = []; @@ -113,10 +73,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 +98,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 (
= ({ Cancel )} - - Submit + + {'Submit'} } > - + {!modalView && ( @@ -220,100 +142,35 @@ 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} - - - - - - - {!modalView && ( - - 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} + required={false} /> - )} + 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 021054f570..a6aa75f0ea 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'); @@ -247,7 +209,7 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim 0 && (!reasonForChange || !explanationForChange)} + disableSuccessButton={editedWorkPackages.length > 0 && !explanationForChange} handleSubmit={handleSubmit} onHide={() => handleClose(true)} > @@ -263,11 +225,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,13 +130,12 @@ const ProjectCreateContainer: React.FC = () => { }; const changeRequestPayload: CreateStandardChangeRequestPayload = { wbsNum: { carNumber, projectNumber: 0, workPackageNumber: 0 }, - type, - what, why, - proposedSolutions: [], + requestedReviewerId: data.requestedReviewerId, projectProposedChanges: projectPayload }; await mutateCRAsync(changeRequestPayload); + toast.success('Change request submitted successfully'); history.push(routes.CHANGE_REQUESTS_OVERVIEW); } catch (e) { if (e instanceof Error) { @@ -255,7 +224,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 65c1c43731..73afd32d81 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,26 @@ 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'), + requestedReviewerId: yup.string().optional() }); 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 +167,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 +183,8 @@ const ProjectEditContainer: React.FC = ({ project, ex }; const changeRequestPayload: CreateStandardChangeRequestPayload = { wbsNum: project.wbsNum, - type, - what, why, - proposedSolutions: [], + requestedReviewerId: data.requestedReviewerId, projectProposedChanges: projectPayload }; await mutateCRAsync(changeRequestPayload); @@ -238,6 +210,7 @@ const ProjectEditContainer: React.FC = ({ project, ex managerId }; await mutateLeadershipCR(autoCRPayload); + toast.success('Changes submitted successfully'); // fixes cache issue await queryClient.refetchQueries(['projects']); exitEditMode(); @@ -258,6 +231,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 8829498789..b2c8ce55c4 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,22 @@ const ProjectFormContainer: React.FC = ({ setIsModalOpen(true)} - sx={{ mx: 1 }} disabled={changeRequestInputExists || onlyLeadershipChanged} + sx={{ display: project ? 'block' : 'none' }} > - Create Change Request + Change Request - + )} - + Cancel - Submit + {project ? 'Implement' : 'Create Project'} } diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx index 91433f3c76..b7af91e07d 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/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.tsx b/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.tsx index 0926166f2f..4db38ee8fc 100644 --- a/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.tsx +++ b/src/frontend/src/pages/WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModal.tsx @@ -9,23 +9,45 @@ 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; modalShow: boolean; onHide: () => void; onSubmit: (data: FormInput) => Promise; + startDate: Date; } -const schema = yup.object().shape({ - confirmDone: yup.boolean().required() -}); +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 { reset, handleSubmit, control } = useForm({ - resolver: yupResolver(schema) +const StageGateWorkPackageModal: React.FC = ({ + wbsNum, + modalShow, + onHide, + onSubmit, + startDate +}) => { + const { + reset, + handleSubmit, + control, + formState: { errors } + } = useForm({ + resolver: yupResolver(buildSchema(startDate)), + defaultValues: { + dateCompleted: new Date() + } }); return ( @@ -44,7 +66,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 +94,28 @@ const StageGateWorkPackageModal: React.FC = ({ w )} /> + Date completed + ( + onChange(newValue ?? new Date())} + disableFuture + minDate={startDate} + 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..d72c6d6048 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'; @@ -23,6 +24,7 @@ interface StageGateWorkPackageModalContainerProps { export interface FormInput { confirmDone: boolean; + dateCompleted: Date; } const StageGateWorkPackageModalContainer: React.FC = ({ @@ -35,8 +37,9 @@ 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 +47,8 @@ const StageGateWorkPackageModalContainer: React.FC { confetti({ @@ -65,11 +69,22 @@ const StageGateWorkPackageModalContainer: React.FC; if (isError) return ; + if (wpIsError) return ; + if (isLoading || wpIsLoading) return ; } - return ; + if (!workPackage) return ; + + return ( + + ); }; export default StageGateWorkPackageModalContainer; 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 3553cea89d..da4e0a22d1 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'; @@ -34,7 +32,6 @@ interface WorkPackageViewContainerProps { allowEdit: boolean; allowActivate: boolean; allowStageGate: boolean; - allowRequestChange: boolean; allowDelete: boolean; } @@ -44,7 +41,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 eb148e43d1..541640a1a0 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'; @@ -143,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, @@ -166,52 +161,19 @@ 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'), + requestedReviewerId: yup.string().optional() }); 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 - } - : { - what: '', - why: [{ type: ChangeRequestReason.Other, explain: '' }], - type: ChangeRequestType.Issue - } + ? { why: 'Creating a Work Package on this Project' } + : { why: '' } }); useEffect(() => { @@ -231,7 +193,6 @@ const WorkPackageFormView: React.FC = ({ if (workPackageTemplateisLoading || !workPackageTemplates) return ; if (workPackageTemplateisError) return ; - // Check if only lead/manager changed const checkOnlyLeadershipChanged = ( formName: string, formStartDate: Date, @@ -240,7 +201,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 && @@ -274,7 +235,7 @@ const WorkPackageFormView: React.FC = ({ managerId }; await createLeadershipCR(autoCRPayload); - // fixes cache issue + toast.success('Changes submitted successfully'); await queryClient.refetchQueries(['work packages']); exitActiveMode(); return; @@ -301,13 +262,13 @@ const WorkPackageFormView: React.FC = ({ wbsNum: wbsElement.wbsNum, workPackageProposedChanges: { ...payload - }, - proposedSolutions: [] + } }); - + 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) { @@ -323,7 +284,6 @@ const WorkPackageFormView: React.FC = ({ const startDate = watch('startDate'); const duration = watch('duration'); - // Calculate for submit button status const onlyLeadershipChanged = defaultValues ? checkOnlyLeadershipChanged( watch('name'), @@ -359,45 +319,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 }} - > - Create Change Request - - - } - - - Cancel - - - Submit - - + + + {`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 + } > 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/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/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', () => { 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(); 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} /> 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/diff-page.utils.ts b/src/frontend/src/utils/diff-page.utils.ts index d6af2ec377..0aa460a19c 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; }; 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; 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'); };