Skip to content
19 changes: 19 additions & 0 deletions src/backend/src/controllers/change-requests.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,25 @@ export default class ChangeRequestsController {
}
}

static async createLeadershipChangeRequest(req: Request, res: Response, next: NextFunction) {
try {
const { wbsNum, leadId, managerId } = req.body;

const cr = await ChangeRequestsService.createLeadershipChangeRequest(
req.currentUser,
wbsNum.carNumber,
wbsNum.projectNumber,
wbsNum.workPackageNumber,
leadId,
managerId,
req.organization
);
res.status(200).json(cr);
} catch (error: unknown) {
next(error);
}
}

static async createStandardChangeRequest(req: Request, res: Response, next: NextFunction) {
try {
const { wbsNum, type, what, why, proposedSolutions, projectProposedChanges, workPackageProposedChanges } = req.body;
Expand Down
15 changes: 12 additions & 3 deletions src/backend/src/prisma-query-args/change-requests.query-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ export const getChangeRequestQueryArgs = (organizationId: string) =>
},
budgetChangeRequest: true,
deletedBy: getUserQueryArgs(organizationId),
requestedReviewers: getUserQueryArgs(organizationId)
requestedReviewers: getUserQueryArgs(organizationId),
leadershipChangeRequest: {
include: { lead: getUserQueryArgs(organizationId), manager: getUserQueryArgs(organizationId) }
}
}
});

Expand All @@ -58,7 +61,10 @@ export const getManyChangeRequestQueryArgs = (organizationId: string) =>
},
budgetChangeRequest: true,
deletedBy: getUserQueryArgs(organizationId),
requestedReviewers: getUserQueryArgs(organizationId)
requestedReviewers: getUserQueryArgs(organizationId),
leadershipChangeRequest: {
include: { lead: getUserQueryArgs(organizationId), manager: getUserQueryArgs(organizationId) }
}
}
});

Expand Down Expand Up @@ -101,6 +107,9 @@ export const getChangeRequestWithProjectAndWorkPackageQueryArgs = (organizationI
},
budgetChangeRequest: true,
deletedBy: getUserQueryArgs(organizationId),
requestedReviewers: getUserQueryArgs(organizationId)
requestedReviewers: getUserQueryArgs(organizationId),
leadershipChangeRequest: {
include: { lead: getUserQueryArgs(organizationId), manager: getUserQueryArgs(organizationId) }
}
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- AlterEnum
ALTER TYPE "CR_Type" ADD VALUE 'LEADERSHIP';

-- CreateTable
CREATE TABLE "Leadership_CR" (
"leadershipCrId" TEXT NOT NULL,
"changeRequestId" TEXT NOT NULL,
"leadId" TEXT,
"managerId" TEXT,

CONSTRAINT "Leadership_CR_pkey" PRIMARY KEY ("leadershipCrId")
);

-- CreateIndex
CREATE UNIQUE INDEX "Leadership_CR_changeRequestId_key" ON "Leadership_CR"("changeRequestId");

-- CreateIndex
CREATE INDEX "Leadership_CR_changeRequestId_idx" ON "Leadership_CR"("changeRequestId");

-- AddForeignKey
ALTER TABLE "Leadership_CR" ADD CONSTRAINT "Leadership_CR_changeRequestId_fkey" FOREIGN KEY ("changeRequestId") REFERENCES "Change_Request"("crId") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Leadership_CR" ADD CONSTRAINT "Leadership_CR_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Leadership_CR" ADD CONSTRAINT "Leadership_CR_managerId_fkey" FOREIGN KEY ("managerId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE;
16 changes: 16 additions & 0 deletions src/backend/src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ generator client {
enum CR_Type {
ISSUE
DEFINITION_CHANGE
LEADERSHIP
OTHER
STAGE_GATE
ACTIVATION
Expand Down Expand Up @@ -278,6 +279,8 @@ model User {
deniedEvents Event[] @relation(name: "deniedEventAttendee")
deletedDocuments Document[] @relation(name: "deletedDocuments")
createdDocuments Document[] @relation(name: "documentsCreatedBy")
leadershipCrAsLead Leadership_CR[] @relation(name: "leadershipCrLead")
leadershipCrAsManager Leadership_CR[] @relation(name: "leadershipCrManager")
}

model Role {
Expand Down Expand Up @@ -367,6 +370,7 @@ model Change_Request {
stageGateChangeRequest Stage_Gate_CR?
activationChangeRequest Activation_CR?
budgetChangeRequest Budget_CR?
leadershipChangeRequest Leadership_CR?
notificationSlackThreads Message_Info[]

@@unique([identifier, organizationId], name: "uniqueChangeRequest")
Expand Down Expand Up @@ -464,6 +468,18 @@ model Budget_CR {
@@index([changeRequestId])
}

model Leadership_CR {
leadershipCrId String @id @default(uuid())
changeRequestId String @unique
changeRequest Change_Request @relation(fields: [changeRequestId], references: [crId])
leadId String?
lead User? @relation(name: "leadershipCrLead", fields: [leadId], references: [userId])
managerId String?
manager User? @relation(name: "leadershipCrManager", fields: [managerId], references: [userId])

@@index([changeRequestId])
}

model Change {
changeId String @id @default(uuid())
changeRequestId String
Expand Down
11 changes: 11 additions & 0 deletions src/backend/src/routes/change-requests.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,15 @@ changeRequestsRouter.post(
ChangeRequestsController.requestCRReview
);

changeRequestsRouter.post(
'/new/leadership',
intMinZero(body('wbsNum.carNumber')),
intMinZero(body('wbsNum.projectNumber')),
intMinZero(body('wbsNum.workPackageNumber')),
nonEmptyString(body('leadId')).optional(),
nonEmptyString(body('managerId')).optional(),
validateInputs,
ChangeRequestsController.createLeadershipChangeRequest
);

export default changeRequestsRouter;
129 changes: 129 additions & 0 deletions src/backend/src/services/change-requests.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,135 @@ export default class ChangeRequestsService {
return changeRequestTransformer(createdChangeRequest);
}

/**
* 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
* @param workPackageNumber the work package number for the wbs element
* @param leadId the id of the new lead
* @param managerId the id of the new manager
* @param organization the organization the user is currently in
* @returns the id of the created cr
*/
static async createLeadershipChangeRequest(
submitter: User,
carNumber: number,
projectNumber: number,
workPackageNumber: number,
leadId: string | undefined,
managerId: string | undefined,
organization: Organization
): Promise<string> {
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
}
}
});

if (!wbsElement) throw new NotFoundException('WBS Element', wbsPipe({ carNumber, projectNumber, workPackageNumber }));
if (wbsElement.dateDeleted)
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({
where: { organizationId: organization.organizationId }
});

const createdCR = await prisma.change_Request.create({
data: {
submitter: { connect: { userId: submitter.userId } },
wbsElement: { connect: { wbsElementId: wbsElement.wbsElementId } },
type: CR_Type.LEADERSHIP,
organization: { connect: { organizationId: organization.organizationId } },
identifier: numChangeRequests + 1,
leadershipChangeRequest: {
create: {
...(leadId && { lead: { connect: { userId: leadId } } }),
...(managerId && { manager: { connect: { userId: managerId } } })
}
}
}
});

await ChangeRequestsService.applyLeadershipChangeRequest(createdCR.crId, wbsElement, submitter, leadId, managerId);

return createdCR.crId;
}
Comment on lines +1023 to +1078
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

Missing validation to ensure at least one of leadId or managerId is provided. A leadership change request with neither field set is meaningless. Add validation to check that at least one field is defined before creating the change request.

Copilot uses AI. Check for mistakes.

/**
* Applies a leadership change request by updating the wbs element's lead/manager
* and auto-approving the change request.
*/
private static async applyLeadershipChangeRequest(
crId: string,
wbsElement: { wbsElementId: string; leadId: string | null; managerId: string | null },
submitter: User,
leadId: string | undefined,
managerId: string | undefined
): Promise<void> {
await prisma.$transaction(async (tx) => {
await tx.change_Request.update({
where: { crId },
data: {
reviewer: { connect: { userId: submitter.userId } },
dateReviewed: new Date(),
accepted: true,
reviewNotes: 'Auto-approved: leadership change only'
}
});

await tx.wBS_Element.update({
where: { wbsElementId: wbsElement.wbsElementId },
data: {
...(leadId && { lead: { connect: { userId: leadId } } }),
...(managerId && { manager: { connect: { userId: managerId } } })
}
});

const changes: { changeRequestId: string; implementerId: string; wbsElementId: string; detail: string }[] = [];

if (leadId !== undefined) {
const oldLead = await getUserFullName(wbsElement.leadId ?? null);
const newLead = await getUserFullName(leadId);
changes.push({
changeRequestId: crId,
implementerId: submitter.userId,
wbsElementId: wbsElement.wbsElementId,
detail: buildChangeDetail('lead', oldLead, newLead)
});
}

if (managerId !== undefined) {
const oldManager = await getUserFullName(wbsElement.managerId ?? null);
const newManager = await getUserFullName(managerId);
changes.push({
changeRequestId: crId,
implementerId: submitter.userId,
wbsElementId: wbsElement.wbsElementId,
detail: buildChangeDetail('manager', oldManager, newManager)
});
}
Comment on lines +1112 to +1132
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

Creating change records for fields that haven't actually changed. The code creates a change record if leadId is defined (line 1112) or managerId is defined (line 1123), but doesn't check if the value actually changed from the original. This will create misleading change records like "manager changed from Bob to Bob" when only the lead was changed. Add a check to compare the new value with wbsElement.leadId or wbsElement.managerId before creating the change record. For example: if (leadId !== undefined && leadId !== wbsElement.leadId)

Copilot uses AI. Check for mistakes.

if (changes.length > 0) {
await tx.change.createMany({ data: changes });
}
});
}

/**
* Validates and creates a standard change request
* @param submitter The user creating the cr
Expand Down
55 changes: 38 additions & 17 deletions src/backend/src/transformers/change-requests.transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
WorkPackageProposedChanges,
WorkPackageStage,
BudgetChangeRequest,
isWorkPackageWbs
isWorkPackageWbs,
LeadershipChangeRequest
} from 'shared';
import { wbsNumOf } from '../utils/utils.js';
import { calculateChangeRequestStatus, convertCRScopeWhyType } from '../utils/change-requests.utils.js';
Expand Down Expand Up @@ -76,7 +77,13 @@ const workPackageProposedChangesTransformer = (

export const changeRequestManyTransformer = (
changeRequest: Prisma.Change_RequestGetPayload<ChangeRequestManyQueryArgs>
): ChangeRequest | StandardChangeRequest | ActivationChangeRequest | StageGateChangeRequest | BudgetChangeRequest => {
):
| ChangeRequest
| StandardChangeRequest
| ActivationChangeRequest
| StageGateChangeRequest
| BudgetChangeRequest
| LeadershipChangeRequest => {
const status = calculateChangeRequestStatus(changeRequest);

return {
Expand Down Expand Up @@ -108,13 +115,17 @@ export const changeRequestManyTransformer = (
proposedSolutions: undefined,
originalProjectData: undefined,
originalWorkPackageData: undefined,
// activation cr fields
lead: changeRequest.activationChangeRequest?.lead
? userTransformer(changeRequest.activationChangeRequest.lead)
: undefined,
manager: changeRequest.activationChangeRequest?.manager
? userTransformer(changeRequest.activationChangeRequest.manager)
: undefined,
// activation + leadership cr fields
lead: changeRequest.leadershipChangeRequest?.lead
? userTransformer(changeRequest.leadershipChangeRequest.lead)
: changeRequest.activationChangeRequest?.lead
? userTransformer(changeRequest.activationChangeRequest.lead)
: undefined,
manager: changeRequest.leadershipChangeRequest?.manager
? userTransformer(changeRequest.leadershipChangeRequest.manager)
: changeRequest.activationChangeRequest?.manager
? userTransformer(changeRequest.activationChangeRequest.manager)
: undefined,
startDate: changeRequest.activationChangeRequest?.startDate ?? undefined,
confirmDetails: changeRequest.activationChangeRequest?.confirmDetails ?? undefined,
// stage gate cr fields
Expand All @@ -128,7 +139,13 @@ export const changeRequestManyTransformer = (

const changeRequestTransformer = (
changeRequest: Prisma.Change_RequestGetPayload<ChangeRequestWithProjectAndWorkPackageQueryArgs>
): ChangeRequest | StandardChangeRequest | ActivationChangeRequest | StageGateChangeRequest | BudgetChangeRequest => {
):
| ChangeRequest
| StandardChangeRequest
| ActivationChangeRequest
| StageGateChangeRequest
| BudgetChangeRequest
| LeadershipChangeRequest => {
const status = calculateChangeRequestStatus(changeRequest);

const wbsName = changeRequest.wbsElement
Expand Down Expand Up @@ -189,13 +206,17 @@ const changeRequestTransformer = (
originalWorkPackageData: changeRequest.scopeChangeRequest?.wbsOriginalData?.workPackageProposedChanges
? workPackageProposedChangesTransformer(changeRequest.scopeChangeRequest.wbsOriginalData.workPackageProposedChanges)
: undefined,
// activation cr fields
lead: changeRequest.activationChangeRequest?.lead
? userTransformer(changeRequest.activationChangeRequest.lead)
: undefined,
manager: changeRequest.activationChangeRequest?.manager
? userTransformer(changeRequest.activationChangeRequest.manager)
: undefined,
// activation + leadership cr fields
lead: changeRequest.leadershipChangeRequest?.lead
? userTransformer(changeRequest.leadershipChangeRequest.lead)
: changeRequest.activationChangeRequest?.lead
? userTransformer(changeRequest.activationChangeRequest.lead)
: undefined,
manager: changeRequest.leadershipChangeRequest?.manager
? userTransformer(changeRequest.leadershipChangeRequest.manager)
: changeRequest.activationChangeRequest?.manager
? userTransformer(changeRequest.activationChangeRequest.manager)
: undefined,
startDate: changeRequest.activationChangeRequest?.startDate ?? undefined,
confirmDetails: changeRequest.activationChangeRequest?.confirmDetails ?? undefined,
// stage gate cr fields
Expand Down
Loading