From 4d240149af1842e994aacdce9fa22b4476efdda4 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 12 Aug 2026 15:02:34 -0400 Subject: [PATCH 01/10] #4370 updated statuses schema, project status endpoint, and history modal --- .../src/controllers/rules.controllers.ts | 28 ++++-- .../src/prisma-query-args/rules.query-args.ts | 29 +++--- .../migration.sql | 20 ++-- src/backend/src/prisma/schema.prisma | 89 ++++++++++-------- .../src/prisma/seed-data/rules.seed.ts | 8 +- src/backend/src/routes/rules.routes.ts | 15 ++- src/backend/src/services/rules.services.ts | 82 ++++++++++++++--- .../src/transformers/rules.transformer.ts | 38 +++++--- src/backend/tests/unit/rule.test.ts | 92 +++++++++++++------ src/frontend/src/apis/rules.api.ts | 20 ++-- .../apis/transformers/rules.transformers.ts | 10 +- src/frontend/src/hooks/rules.hooks.ts | 36 ++++++-- .../ProjectRules/ProjectRulesTab.tsx | 23 +++-- .../ProjectRules/UpdateStatusPopover.tsx | 21 +++-- .../components/RuleStatusHistoryModal.tsx | 76 +++++++++++++++ .../RulesPage/components/RuleStatusTag.tsx | 48 ++++++---- .../components/RulesetGeneralView.tsx | 24 +++-- src/frontend/src/utils/rules.utils.ts | 31 +++++-- src/frontend/src/utils/urls.ts | 6 +- src/shared/src/types/rules-types.ts | 25 ++++- 20 files changed, 514 insertions(+), 207 deletions(-) create mode 100644 src/frontend/src/pages/RulesPage/components/RuleStatusHistoryModal.tsx diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts index d3e75ca083..3f5d809555 100644 --- a/src/backend/src/controllers/rules.controllers.ts +++ b/src/backend/src/controllers/rules.controllers.ts @@ -1,6 +1,6 @@ import { NextFunction, Request, Response } from 'express'; import RulesService from '../services/rules.services.js'; -import { ProjectRule, Rule, Ruleset } from 'shared'; +import { ProjectRule, Rule, Ruleset, RuleStatus } from 'shared'; import { HttpException } from '../utils/errors.utils.js'; export default class RulesController { @@ -185,20 +185,32 @@ export default class RulesController { } } - static async setRuleCompletion(req: Request, res: Response, next: NextFunction) { + static async setRuleStatus(req: Request, res: Response, next: NextFunction) { try { const { ruleId } = req.params as Record; - const { isComplete, projectId } = req.body; + const { status } = req.body as { status: RuleStatus }; - const rule: Rule = await RulesService.setRuleCompletion( + const rule: Rule = await RulesService.setRuleStatus(req.currentUser, req.organization, ruleId, status); + + res.status(200).json(rule); + } catch (error: unknown) { + next(error); + } + } + + static async setProjectRuleStatus(req: Request, res: Response, next: NextFunction) { + try { + const { projectRuleId } = req.params as Record; + const { status } = req.body as { status: RuleStatus }; + + const projectRule: ProjectRule = await RulesService.setProjectRuleStatus( req.currentUser, req.organization, - ruleId, - isComplete, - projectId + projectRuleId, + status ); - res.status(200).json(rule); + res.status(200).json(projectRule); } catch (error: unknown) { next(error); } diff --git a/src/backend/src/prisma-query-args/rules.query-args.ts b/src/backend/src/prisma-query-args/rules.query-args.ts index 2e0b88cda3..ced9ef4189 100644 --- a/src/backend/src/prisma-query-args/rules.query-args.ts +++ b/src/backend/src/prisma-query-args/rules.query-args.ts @@ -32,6 +32,15 @@ export const getRulePreviewQueryArgs = () => projects: { where: { dateDeleted: null }, select: { + projectRuleId: true, + status: true, + statusUpdatedAt: true, + statusUpdatedBy: { + select: { + firstName: true, + lastName: true + } + }, project: { select: { projectId: true, @@ -49,21 +58,11 @@ export const getRulePreviewQueryArgs = () => } } }, - completedBy: { + statusUpdatedBy: { select: { firstName: true, lastName: true } - }, - completedInProject: { - select: { - projectId: true, - wbsElement: { - select: { - name: true - } - } - } } } }); @@ -73,7 +72,13 @@ export type ProjectRuleQueryArgs = ReturnType; export const getProjectRuleQueryArgs = () => Prisma.validator()({ include: { - rule: getRulePreviewQueryArgs() + rule: getRulePreviewQueryArgs(), + statusUpdatedBy: { + select: { + firstName: true, + lastName: true + } + } } }); diff --git a/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql b/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql index 572123879b..662b2b6fcf 100644 --- a/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql +++ b/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql @@ -1,3 +1,6 @@ +-- CreateEnum +CREATE TYPE "Rule_Status" AS ENUM ('PENDING', 'PASS', 'FAIL'); + -- CreateTable CREATE TABLE "Ruleset_Type" ( "rulesetTypeId" TEXT NOT NULL, @@ -36,9 +39,9 @@ CREATE TABLE "Rule" ( "imageFileIds" TEXT[], "rulesetId" TEXT NOT NULL, "parentRuleId" TEXT, - "isComplete" BOOLEAN NOT NULL DEFAULT false, - "completedByUserId" TEXT, - "completedInProjectId" TEXT, + "status" "Rule_Status" NOT NULL DEFAULT 'PENDING', + "statusUpdatedByUserId" TEXT, + "statusUpdatedAt" TIMESTAMP(3), "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "dateUpdated" TIMESTAMP(3), "dateDeleted" TIMESTAMP(3), @@ -54,6 +57,9 @@ CREATE TABLE "Project_Rule" ( "projectRuleId" TEXT NOT NULL, "ruleId" TEXT NOT NULL, "projectId" TEXT NOT NULL, + "status" "Rule_Status" NOT NULL DEFAULT 'PENDING', + "statusUpdatedByUserId" TEXT, + "statusUpdatedAt" TIMESTAMP(3), "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdByUserId" TEXT NOT NULL, "dateDeleted" TIMESTAMP(3), @@ -124,10 +130,7 @@ ALTER TABLE "Rule" ADD CONSTRAINT "Rule_rulesetId_fkey" FOREIGN KEY ("rulesetId" ALTER TABLE "Rule" ADD CONSTRAINT "Rule_parentRuleId_fkey" FOREIGN KEY ("parentRuleId") REFERENCES "Rule"("ruleId") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "Rule" ADD CONSTRAINT "Rule_completedByUserId_fkey" FOREIGN KEY ("completedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Rule" ADD CONSTRAINT "Rule_completedInProjectId_fkey" FOREIGN KEY ("completedInProjectId") REFERENCES "Project"("projectId") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Rule" ADD CONSTRAINT "Rule_statusUpdatedByUserId_fkey" FOREIGN KEY ("statusUpdatedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "Rule" ADD CONSTRAINT "Rule_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -144,6 +147,9 @@ ALTER TABLE "Project_Rule" ADD CONSTRAINT "Project_Rule_ruleId_fkey" FOREIGN KEY -- AddForeignKey ALTER TABLE "Project_Rule" ADD CONSTRAINT "Project_Rule_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("projectId") ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "Project_Rule" ADD CONSTRAINT "Project_Rule_statusUpdatedByUserId_fkey" FOREIGN KEY ("statusUpdatedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "Project_Rule" ADD CONSTRAINT "Project_Rule_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 65f2c39160..59da1ff086 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -32,6 +32,12 @@ enum Task_Status { DONE } +enum Rule_Status { + PENDING + PASS + FAIL +} + enum WBS_Element_Status { INACTIVE ACTIVE @@ -277,7 +283,7 @@ model User { deletedSponsorTiers Sponsor_Tier[] financeDelegateForOrganizations Organization[] @relation(name: "financeDelegates") assignedReimbursementRequests Reimbursement_Request[] @relation(name: "reimbursementRequestAssignee") - completedRules Rule[] @relation(name: "ruleCompleter") + ruleStatusUpdates Rule[] @relation(name: "ruleStatusUpdater") createdRulesetTypes Ruleset_Type[] @relation(name: "rulesetTypeCreator") deletedRulesetTypes Ruleset_Type[] @relation(name: "rulesetTypeDeleter") createdRulesets Ruleset[] @relation(name: "rulesetCreator") @@ -287,6 +293,7 @@ model User { deletedRules Rule[] @relation(name: "ruleDeletor") createdProjectRules Project_Rule[] @relation(name: "projectRuleCreator") deletedProjectRules Project_Rule[] @relation(name: "projectRuleDeletor") + projectRuleStatusUpdates Project_Rule[] @relation(name: "projectRuleStatusUpdater") deletedGuestDefinitions Guest_Definition[] @relation(name: "guestDefinitionDeleter") createdGuestDefinitions Guest_Definition[] @relation(name: "guestDefinitionCreator") requiredEvents Event[] @relation(name: "requiredEventAttendee") @@ -542,7 +549,6 @@ model Project { abbreviation String? parts Part[] rules Project_Rule[] @relation(name: "projectsForRule") - completedRules Rule[] @relation(name: "ruleCompletedInProject") @@index([carId]) } @@ -1809,50 +1815,53 @@ model Ruleset { } model Rule { - ruleId String @id @default(uuid()) - ruleCode String - ruleContent String - imageFileIds String[] - rulesetId String - ruleset Ruleset @relation(fields: [rulesetId], references: [rulesetId]) - parentRuleId String? - parentRule Rule? @relation(name: "subRules", fields: [parentRuleId], references: [ruleId]) - subRules Rule[] @relation(name: "subRules") - referencedRule Rule[] @relation(name: "ruleReferences") - referencedBy Rule[] @relation(name: "ruleReferences") - projects Project_Rule[] @relation(name: "rulesInProject") - teams Team[] @relation(name: "teamRules") - isComplete Boolean @default(false) - completedByUserId String? - completedBy User? @relation(name: "ruleCompleter", fields: [completedByUserId], references: [userId]) - completedInProjectId String? - completedInProject Project? @relation(name: "ruleCompletedInProject", fields: [completedInProjectId], references: [projectId]) - dateCreated DateTime @default(now()) - dateUpdated DateTime? @updatedAt - dateDeleted DateTime? - createdByUserId String - createdBy User @relation(name: "ruleCreator", fields: [createdByUserId], references: [userId]) - updatedByUserId String? - updatedBy User? @relation(name: "ruleUpdater", fields: [updatedByUserId], references: [userId]) - deletedByUserId String? - deletedBy User? @relation(name: "ruleDeletor", fields: [deletedByUserId], references: [userId]) + ruleId String @id @default(uuid()) + ruleCode String + ruleContent String + imageFileIds String[] + rulesetId String + ruleset Ruleset @relation(fields: [rulesetId], references: [rulesetId]) + parentRuleId String? + parentRule Rule? @relation(name: "subRules", fields: [parentRuleId], references: [ruleId]) + subRules Rule[] @relation(name: "subRules") + referencedRule Rule[] @relation(name: "ruleReferences") + referencedBy Rule[] @relation(name: "ruleReferences") + projects Project_Rule[] @relation(name: "rulesInProject") + teams Team[] @relation(name: "teamRules") + status Rule_Status @default(PENDING) + statusUpdatedByUserId String? + statusUpdatedBy User? @relation(name: "ruleStatusUpdater", fields: [statusUpdatedByUserId], references: [userId]) + statusUpdatedAt DateTime? + dateCreated DateTime @default(now()) + dateUpdated DateTime? @updatedAt + dateDeleted DateTime? + createdByUserId String + createdBy User @relation(name: "ruleCreator", fields: [createdByUserId], references: [userId]) + updatedByUserId String? + updatedBy User? @relation(name: "ruleUpdater", fields: [updatedByUserId], references: [userId]) + deletedByUserId String? + deletedBy User? @relation(name: "ruleDeletor", fields: [deletedByUserId], references: [userId]) @@unique([rulesetId, ruleCode]) @@index([parentRuleId, rulesetId, ruleCode]) } model Project_Rule { - projectRuleId String @id @default(uuid()) - ruleId String - rule Rule @relation(name: "rulesInProject", fields: [ruleId], references: [ruleId]) - projectId String - project Project @relation(name: "projectsForRule", fields: [projectId], references: [projectId]) - dateCreated DateTime @default(now()) - createdByUserId String - createdBy User @relation(name: "projectRuleCreator", fields: [createdByUserId], references: [userId]) - dateDeleted DateTime? - deletedByUserId String? - deletedBy User? @relation(name: "projectRuleDeletor", fields: [deletedByUserId], references: [userId]) + projectRuleId String @id @default(uuid()) + ruleId String + rule Rule @relation(name: "rulesInProject", fields: [ruleId], references: [ruleId]) + projectId String + project Project @relation(name: "projectsForRule", fields: [projectId], references: [projectId]) + status Rule_Status @default(PENDING) + statusUpdatedByUserId String? + statusUpdatedBy User? @relation(name: "projectRuleStatusUpdater", fields: [statusUpdatedByUserId], references: [userId]) + statusUpdatedAt DateTime? + dateCreated DateTime @default(now()) + createdByUserId String + createdBy User @relation(name: "projectRuleCreator", fields: [createdByUserId], references: [userId]) + dateDeleted DateTime? + deletedByUserId String? + deletedBy User? @relation(name: "projectRuleDeletor", fields: [deletedByUserId], references: [userId]) @@unique([ruleId, projectId]) } diff --git a/src/backend/src/prisma/seed-data/rules.seed.ts b/src/backend/src/prisma/seed-data/rules.seed.ts index 3decdfec09..50d4dca42f 100644 --- a/src/backend/src/prisma/seed-data/rules.seed.ts +++ b/src/backend/src/prisma/seed-data/rules.seed.ts @@ -1,7 +1,7 @@ import type { Prisma } from '@prisma/client'; import { Organization, PrismaClient } from '@prisma/client'; import RulesService from '../../services/rules.services.js'; -import { User } from 'shared'; +import { User, RuleStatus } from 'shared'; // ruleset types const rulesetTypeFSAE = (userCreatedId: string, organizationId: string): Prisma.Ruleset_TypeCreateInput => { @@ -776,9 +776,9 @@ export const seedFsaeRules = async ( await RulesService.toggleRuleTeam(IC81Rule.ruleId, huskyTeamId, batman, organization); await RulesService.createProjectRule(batman, organization, IC81Rule.ruleId, projectId); - // Add the leaf rule to the bodywork project and mark it complete. - await RulesService.createProjectRule(batman, organization, T112ARule.ruleId, projectId); - await RulesService.setRuleCompletion(batman, organization, T112ARule.ruleId, true, projectId); + // Add the leaf rule to the bodywork project and mark it as passed for that project. + const bodyworkT112AProjectRule = await RulesService.createProjectRule(batman, organization, T112ARule.ruleId, projectId); + await RulesService.setProjectRuleStatus(batman, organization, bodyworkT112AProjectRule.projectRuleId, RuleStatus.PASS); }; export const ruleSeedData = { diff --git a/src/backend/src/routes/rules.routes.ts b/src/backend/src/routes/rules.routes.ts index 7627316e7f..9a0c236e9a 100644 --- a/src/backend/src/routes/rules.routes.ts +++ b/src/backend/src/routes/rules.routes.ts @@ -2,7 +2,7 @@ import express from 'express'; import RulesController from '../controllers/rules.controllers.js'; import { nonEmptyString, validateInputs } from '../utils/validation.utils.js'; import { body, query } from 'express-validator'; -import { MAX_FILE_SIZE } from 'shared'; +import { MAX_FILE_SIZE, RuleStatus } from 'shared'; import multer, { memoryStorage } from 'multer'; const rulesRouter = express.Router(); @@ -70,11 +70,16 @@ rulesRouter.post('/projectRule/:projectRuleId/delete', RulesController.deletePro rulesRouter.get('/rulesets/:rulesetTypeId', RulesController.getRulesetsByRulesetType); rulesRouter.post( - '/rule/:ruleId/setCompletion', - body('isComplete').isBoolean(), - body('projectId').optional().isString(), + '/rule/:ruleId/setStatus', + body('status').isIn(Object.values(RuleStatus)), validateInputs, - RulesController.setRuleCompletion + RulesController.setRuleStatus +); +rulesRouter.post( + '/projectRule/:projectRuleId/setStatus', + body('status').isIn(Object.values(RuleStatus)), + validateInputs, + RulesController.setProjectRuleStatus ); rulesRouter.post( diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 5070b11f94..3ddea8db41 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -8,7 +8,8 @@ import { User, Rule as SharedRule, isHead, - Ruleset + Ruleset, + RuleStatus } from 'shared'; import prisma from '../prisma/prisma.js'; import { @@ -880,24 +881,22 @@ export default class RulesService { } /** - * Sets the completion of a rule. Completion is global to the rule, so marking it complete - * (or incomplete) is reflected everywhere the rule appears. - * @param submitter the user updating the completion + * Sets a rule's general-view status. This status is independent of any project. + * It is unaffected by the status of the rule in any project it's assigned to. + * @param submitter the user updating the status * @param organization the organization of the rule * @param ruleId the id of the rule to update - * @param isComplete whether the rule is complete or incomplete - * @param projectId the project the rule was completed from (optional - omitted if updated in general view) - * @returns the rule with updated completion + * @param status the new status of the rule + * @returns the rule with updated status */ - static async setRuleCompletion( + static async setRuleStatus( submitter: User, organization: Organization, ruleId: string, - isComplete: boolean, - projectId?: string + status: RuleStatus ): Promise { if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) { - throw new AccessDeniedException('You do not have permissions to update rule completion'); + throw new AccessDeniedException('You do not have permissions to update rule status'); } const rule = await prisma.rule.findUnique({ @@ -919,15 +918,70 @@ export default class RulesService { const updatedRule = await prisma.rule.update({ where: { ruleId }, - data: isComplete - ? { isComplete: true, completedByUserId: submitter.userId, completedInProjectId: projectId ?? null } - : { isComplete: false, completedByUserId: null, completedInProjectId: null }, + data: + status === RuleStatus.PENDING + ? { status, statusUpdatedByUserId: null, statusUpdatedAt: null } + : { status, statusUpdatedByUserId: submitter.userId, statusUpdatedAt: new Date() }, ...getRulePreviewQueryArgs() }); return ruleTransformer(updatedRule); } + /** + * Sets a rule's status within a single project. This status is local to that project. + * It does not affect the rule's general-view status, or its status in any other project. + * @param submitter the user updating the status + * @param organization the organization of the project rule + * @param projectRuleId the id of the project rule to update + * @param status the new status of the rule in this project + * @returns the project rule with updated status + */ + static async setProjectRuleStatus( + submitter: User, + organization: Organization, + projectRuleId: string, + status: RuleStatus + ): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) { + throw new AccessDeniedException('You do not have permissions to update rule status'); + } + + const projectRule = await prisma.project_Rule.findUnique({ + where: { projectRuleId }, + include: { + project: { include: { wbsElement: true } }, + rule: { include: { ruleset: { include: { car: { include: { wbsElement: true } } } } } } + } + }); + + if (!projectRule) { + throw new NotFoundException('Project Rule', projectRuleId); + } + + if (projectRule.dateDeleted) { + throw new DeletedException('Project Rule', projectRuleId); + } + + if ( + projectRule.project.wbsElement.organizationId !== organization.organizationId || + projectRule.rule.ruleset.car.wbsElement.organizationId !== organization.organizationId + ) { + throw new InvalidOrganizationException('Project Rule'); + } + + const updatedProjectRule = await prisma.project_Rule.update({ + where: { projectRuleId }, + data: + status === RuleStatus.PENDING + ? { status, statusUpdatedByUserId: null, statusUpdatedAt: null } + : { status, statusUpdatedByUserId: submitter.userId, statusUpdatedAt: new Date() }, + ...getProjectRuleQueryArgs() + }); + + return projectRuleTransformer(updatedProjectRule); + } + /** * Assigns a rule to a team. If the team already is assigned to the * rule, removes the team from the rule. diff --git a/src/backend/src/transformers/rules.transformer.ts b/src/backend/src/transformers/rules.transformer.ts index f072b9c887..4b2bdc0662 100644 --- a/src/backend/src/transformers/rules.transformer.ts +++ b/src/backend/src/transformers/rules.transformer.ts @@ -1,5 +1,5 @@ import { Prisma } from '@prisma/client'; -import { Rule, ProjectRule, Ruleset, RulesetType } from 'shared'; +import { Rule, ProjectRule, Ruleset, RulesetType, RuleStatus } from 'shared'; import { RulesetQueryArgs, RulePreviewQueryArgs, ProjectRuleQueryArgs } from '../prisma-query-args/rules.query-args.js'; export const ruleTransformer = (rule: Prisma.RuleGetPayload): Rule => { @@ -23,21 +23,25 @@ export const ruleTransformer = (rule: Prisma.RuleGetPayload ({ projectId: projectRule.project.projectId, projectName: projectRule.project.wbsElement.name, - teamIds: projectRule.project.teams.map((team) => team.teamId) + teamIds: projectRule.project.teams.map((team) => team.teamId), + projectRuleId: projectRule.projectRuleId, + status: projectRule.status as RuleStatus, + statusUpdatedBy: projectRule.statusUpdatedBy + ? { + firstName: projectRule.statusUpdatedBy.firstName, + lastName: projectRule.statusUpdatedBy.lastName + } + : undefined, + statusUpdatedAt: projectRule.statusUpdatedAt ?? undefined })), - isComplete: rule.isComplete, - completedBy: rule.completedBy + status: rule.status as RuleStatus, + statusUpdatedBy: rule.statusUpdatedBy ? { - firstName: rule.completedBy.firstName, - lastName: rule.completedBy.lastName + firstName: rule.statusUpdatedBy.firstName, + lastName: rule.statusUpdatedBy.lastName } : undefined, - completedInProject: rule.completedInProject - ? { - projectId: rule.completedInProject.projectId, - projectName: rule.completedInProject.wbsElement.name - } - : undefined + statusUpdatedAt: rule.statusUpdatedAt ?? undefined }; }; @@ -45,7 +49,15 @@ export const projectRuleTransformer = (projectRule: Prisma.Project_RuleGetPayloa return { projectRuleId: projectRule.projectRuleId, rule: ruleTransformer(projectRule.rule), - projectId: projectRule.projectId + projectId: projectRule.projectId, + status: projectRule.status as RuleStatus, + statusUpdatedBy: projectRule.statusUpdatedBy + ? { + firstName: projectRule.statusUpdatedBy.firstName, + lastName: projectRule.statusUpdatedBy.lastName + } + : undefined, + statusUpdatedAt: projectRule.statusUpdatedAt ?? undefined }; }; diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index e45ced0f6f..8d1e35b290 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -29,6 +29,7 @@ import { } from '../../src/utils/errors.utils'; import TeamsService from '../../src/services/teams.services'; import ProjectsService from '../../src/services/projects.services'; +import { RuleStatus } from 'shared'; describe('Create Rules Tests', () => { let orgId: string; @@ -1124,7 +1125,7 @@ describe('Rule Tests', () => { expect(projectRule.rule.ruleId).toBe(topLevelRule.ruleId); expect(projectRule.rule.ruleCode).toBe(topLevelRule.ruleCode); expect(projectRule.projectId).toBe(project.projectId); - expect(projectRule.rule.isComplete).toBe(false); + expect(projectRule.status).toBe(RuleStatus.PENDING); }); it('Creates a project rule successfully for a leaf rule', async () => { const car = await createUniqueCar(orgId); @@ -1140,7 +1141,7 @@ describe('Rule Tests', () => { expect(projectRule.rule.ruleId).toBe(leafRule1.ruleId); expect(projectRule.rule.ruleCode).toBe(leafRule1.ruleCode); expect(projectRule.projectId).toBe(project.projectId); - expect(projectRule.rule.isComplete).toBe(false); + expect(projectRule.status).toBe(RuleStatus.PENDING); }); it('Create project rule fails if user does not have permission', async () => { const car = await createUniqueCar(orgId); @@ -1201,57 +1202,90 @@ describe('Rule Tests', () => { ); }); - // Setting Rule Completion - it('Marks a rule complete successfully and records who/where', async () => { + // Setting Rule Status (general view) + it('Marks a rule Pass in the general view and records who updated it', async () => { const car = await createUniqueCar(orgId); const { topLevelRule } = await setupRules(car); - const updatedRule = await RulesService.setRuleCompletion( - admin, - organization, - topLevelRule.ruleId, - true, - project.projectId - ); + const updatedRule = await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.PASS); expect(updatedRule.ruleId).toBe(topLevelRule.ruleId); - expect(updatedRule.isComplete).toBe(true); - expect(updatedRule.completedBy?.firstName).toBe(admin.firstName); - expect(updatedRule.completedBy?.lastName).toBe(admin.lastName); - expect(updatedRule.completedInProject?.projectId).toBe(project.projectId); + expect(updatedRule.status).toBe(RuleStatus.PASS); + expect(updatedRule.statusUpdatedBy?.firstName).toBe(admin.firstName); + expect(updatedRule.statusUpdatedBy?.lastName).toBe(admin.lastName); + expect(updatedRule.statusUpdatedAt).toBeInstanceOf(Date); }); - it('Marks a rule complete without a project (general view)', async () => { + it('Marks a rule back to Pending in the general view and clears who/when', async () => { const car = await createUniqueCar(orgId); const { topLevelRule } = await setupRules(car); - const updatedRule = await RulesService.setRuleCompletion(admin, organization, topLevelRule.ruleId, true); + await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.FAIL); + const updatedRule = await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.PENDING); - expect(updatedRule.isComplete).toBe(true); - expect(updatedRule.completedBy?.firstName).toBe(admin.firstName); - expect(updatedRule.completedInProject).toBeUndefined(); + expect(updatedRule.status).toBe(RuleStatus.PENDING); + expect(updatedRule.statusUpdatedBy).toBeUndefined(); + expect(updatedRule.statusUpdatedAt).toBeUndefined(); }); - it('Marks a rule incomplete and clears completion info', async () => { + it('Set rule status fails if user does not have permission', async () => { const car = await createUniqueCar(orgId); const { topLevelRule } = await setupRules(car); - await RulesService.setRuleCompletion(admin, organization, topLevelRule.ruleId, true, project.projectId); - const updatedRule = await RulesService.setRuleCompletion(admin, organization, topLevelRule.ruleId, false); + await expect( + async () => await RulesService.setRuleStatus(nonLeadership, organization, topLevelRule.ruleId, RuleStatus.PASS) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule status')); + }); + + // Setting Project Rule Status (per-project view) + it('Marks a rule Pass within a project and records who updated it', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); - expect(updatedRule.isComplete).toBe(false); - expect(updatedRule.completedBy).toBeUndefined(); - expect(updatedRule.completedInProject).toBeUndefined(); + const updatedProjectRule = await RulesService.setProjectRuleStatus( + admin, + organization, + projectRule.projectRuleId, + RuleStatus.PASS + ); + + expect(updatedProjectRule.projectRuleId).toBe(projectRule.projectRuleId); + expect(updatedProjectRule.status).toBe(RuleStatus.PASS); + expect(updatedProjectRule.statusUpdatedBy?.firstName).toBe(admin.firstName); + expect(updatedProjectRule.statusUpdatedBy?.lastName).toBe(admin.lastName); + expect(updatedProjectRule.statusUpdatedAt).toBeInstanceOf(Date); }); - it('Set rule completion fails if user does not have permission', async () => { + it('Set project rule status fails if user does not have permission', async () => { const car = await createUniqueCar(orgId); const { topLevelRule } = await setupRules(car); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); await expect( async () => - await RulesService.setRuleCompletion(nonLeadership, organization, topLevelRule.ruleId, true, project.projectId) - ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule completion')); + await RulesService.setProjectRuleStatus(nonLeadership, organization, projectRule.projectRuleId, RuleStatus.PASS) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule status')); + }); + + it('A rule status in one project is independent of its general-view status and its status in other projects', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule, ruleset1 } = await setupRules(car); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + const project2 = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + const projectRule1 = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + const projectRule2 = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project2.projectId); + + await RulesService.setProjectRuleStatus(admin, organization, projectRule1.projectRuleId, RuleStatus.PASS); + await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.FAIL); + + const projectRules2 = await RulesService.getProjectRules(ruleset1.rulesetId, project2.projectId, organization); + const rule2Entry = projectRules2.find((pr) => pr.projectRuleId === projectRule2.projectRuleId); + + expect(rule2Entry?.status).toBe(RuleStatus.PENDING); + expect(rule2Entry?.rule.status).toBe(RuleStatus.FAIL); }); }); diff --git a/src/frontend/src/apis/rules.api.ts b/src/frontend/src/apis/rules.api.ts index e2df230649..0f2e75ef90 100644 --- a/src/frontend/src/apis/rules.api.ts +++ b/src/frontend/src/apis/rules.api.ts @@ -4,7 +4,7 @@ */ import axios from '../utils/axios'; -import { ProjectRule, Rule as SharedRule, RulesetType, Ruleset } from 'shared'; +import { ProjectRule, Rule as SharedRule, RulesetType, Ruleset, RuleStatus } from 'shared'; import { apiUrls } from '../utils/urls'; import { CreateRulesetPayload, ParseRulesetPayload, CreateRulePayload } from '../hooks/rules.hooks'; import { @@ -117,13 +117,21 @@ export const deleteProjectRule = (projectRuleId: string) => { }; /** - * Sets a rule's completion. Completion is global to the rule. + * Sets a rule's general-view status. This status is independent of any project. * @param ruleId the rule to update - * @param isComplete whether the rule is complete - * @param projectId the project the rule was completed from (optional) + * @param status the new status of the rule */ -export const setRuleCompletion = (ruleId: string, isComplete: boolean, projectId?: string) => { - return axios.post(apiUrls.rulesSetRuleCompletion(ruleId), { isComplete, projectId }); +export const setRuleStatus = (ruleId: string, status: RuleStatus) => { + return axios.post(apiUrls.rulesSetRuleStatus(ruleId), { status }); +}; + +/** + * Sets a rule's status within a single project. This status is local to that project. + * @param projectRuleId the project rule to update + * @param status the new status of the rule in this project + */ +export const setProjectRuleStatus = (projectRuleId: string, status: RuleStatus) => { + return axios.post(apiUrls.rulesSetProjectRuleStatus(projectRuleId), { status }); }; /** diff --git a/src/frontend/src/apis/transformers/rules.transformers.ts b/src/frontend/src/apis/transformers/rules.transformers.ts index 11e81419dc..a7feafa9be 100644 --- a/src/frontend/src/apis/transformers/rules.transformers.ts +++ b/src/frontend/src/apis/transformers/rules.transformers.ts @@ -15,7 +15,12 @@ export const ruleTransformer = (rule: Rule): Rule => { return { ...rule, subRuleIds: rule.subRuleIds || [], - referencedRules: rule.referencedRules || [] + referencedRules: rule.referencedRules || [], + statusUpdatedAt: rule.statusUpdatedAt ? new Date(rule.statusUpdatedAt) : undefined, + projects: rule.projects?.map((project) => ({ + ...project, + statusUpdatedAt: project.statusUpdatedAt ? new Date(project.statusUpdatedAt) : undefined + })) }; }; @@ -28,7 +33,8 @@ export const ruleTransformer = (rule: Rule): Rule => { export const projectRuleTransformer = (projectRule: ProjectRule): ProjectRule => { return { ...projectRule, - rule: ruleTransformer(projectRule.rule) + rule: ruleTransformer(projectRule.rule), + statusUpdatedAt: projectRule.statusUpdatedAt ? new Date(projectRule.statusUpdatedAt) : undefined }; }; diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index 8e3cddf799..7f5a930676 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -4,7 +4,7 @@ */ import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { ProjectRule, Rule as SharedRule, Ruleset, RulesetType } from 'shared'; +import { ProjectRule, Rule as SharedRule, Ruleset, RulesetType, RuleStatus } from 'shared'; import { createRulesetType, getAllRulesetTypes, @@ -13,7 +13,8 @@ import { getUnassignedRulesForRuleset, createProjectRule, deleteProjectRule, - setRuleCompletion, + setRuleStatus, + setProjectRuleStatus, getChildRules, getTopLevelRules, toggleRuleTeam, @@ -350,14 +351,33 @@ export const useDeleteProjectRule = (rulesetId: string, projectId: string) => { }; /** - * Hook to set a rule's completion. Completion is global to the rule. + * Hook to set a rule's general-view status. This status is independent of any project. */ -export const useSetRuleCompletion = (rulesetId: string, projectId: string) => { +export const useSetRuleStatus = (rulesetId: string) => { const queryClient = useQueryClient(); - return useMutation( - ['rules', 'setCompletion'], - async ({ ruleId, isComplete, projectId: pId }) => { - const { data } = await setRuleCompletion(ruleId, isComplete, pId); + return useMutation( + ['rules', 'setStatus'], + async ({ ruleId, status }) => { + const { data } = await setRuleStatus(ruleId, status); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rules', 'allRules', rulesetId]); + } + } + ); +}; + +/** + * Hook to set a rule's status within a single project. This status is local to that project. + */ +export const useSetProjectRuleStatus = (rulesetId: string, projectId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['rules', 'setProjectRuleStatus'], + async ({ projectRuleId, status }) => { + const { data } = await setProjectRuleStatus(projectRuleId, status); return data; }, { diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index 41d033a874..a484df341f 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -19,7 +19,7 @@ import { IconButton, Tooltip } from '@mui/material'; -import { Project, ProjectRule, Rule } from 'shared'; +import { Project, ProjectRule, Rule, RuleStatus } from 'shared'; import LoadingIndicator from '../../../../components/LoadingIndicator'; import ErrorPage from '../../../ErrorPage'; import RuleRow from '../../../RulesPage/RuleRow'; @@ -31,7 +31,7 @@ import { useAllRulesetTypes, useActiveRuleset, useProjectRules, - useSetRuleCompletion, + useSetProjectRuleStatus, useCreateProjectRule } from '../../../../hooks/rules.hooks'; import { useToast } from '../../../../hooks/toasts.hooks'; @@ -77,7 +77,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { } = useProjectRules(activeRuleset?.rulesetId || '', project.id); // Mutations - const { mutateAsync: setCompletionMutation, isLoading: isUpdatingStatus } = useSetRuleCompletion( + const { mutateAsync: setStatusMutation, isLoading: isUpdatingStatus } = useSetProjectRuleStatus( activeRuleset?.rulesetId || '', project.id ); @@ -88,11 +88,13 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { const teamId = project.teams[0]?.teamId || ''; const teamNames = project.teams.map((team) => team.teamName); - // Convert project rules to rules for display + // Convert project rules to rules for display, merging in each rule's local status for this project // Sorted by rule code so both top-level rows and their children render in stable numeric order const projectRuleList = useMemo(() => { if (!projectRules) return []; - return projectRules.map((pr) => pr.rule).sort(compareRuleCodes); + return projectRules + .map((pr) => ({ ...pr.rule, status: pr.status, statusUpdatedBy: pr.statusUpdatedBy, statusUpdatedAt: pr.statusUpdatedAt })) + .sort(compareRuleCodes); }, [projectRules]); // Get top-level rules (rules without a parent) @@ -107,11 +109,11 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { const { expandedIds, toggleExpand, navigateToRule, expandAll, collapseAll, areAllExpanded } = useRuleTreeNavigation(projectRuleList); - // Handle completion update - const handleStatusUpdate = async (ruleId: string, isComplete: boolean) => { + // Handle status update, local to this project + const handleStatusUpdate = async (projectRuleId: string, status: RuleStatus) => { try { - await setCompletionMutation({ ruleId, isComplete, projectId: project.id }); - toast.success('Rule completion updated successfully'); + await setStatusMutation({ projectRuleId, status }); + toast.success('Rule status updated successfully'); } catch (error) { if (error instanceof Error) { toast.error(error.message); @@ -371,7 +373,8 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { )} diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx index 1437603c9a..963f92eb51 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx @@ -4,26 +4,29 @@ */ import { Box, Checkbox, FormControlLabel, Popover, Typography } from '@mui/material'; -import { Rule } from 'shared'; +import { RuleStatus } from 'shared'; interface UpdateStatusPopoverProps { anchorEl: HTMLElement | null; onClose: () => void; - rule: Rule; - onStatusChange: (ruleId: string, isComplete: boolean) => void; + // id of the thing being updated - a ruleId in general view, or a projectRuleId in a project's view + id: string; + status: RuleStatus; + onStatusChange: (id: string, status: RuleStatus) => void; } -const UpdateStatusPopover = ({ anchorEl, onClose, rule, onStatusChange }: UpdateStatusPopoverProps) => { +const UpdateStatusPopover = ({ anchorEl, onClose, id, status, onStatusChange }: UpdateStatusPopoverProps) => { const open = Boolean(anchorEl); - const handleStatusChange = (isComplete: boolean) => { - onStatusChange(rule.ruleId, isComplete); + // Selecting the already-selected option reverts to Pending + const handleStatusChange = (selected: RuleStatus) => { + onStatusChange(id, status === selected ? RuleStatus.PENDING : selected); onClose(); }; const statusOptions = [ - { value: true, label: 'Complete' }, - { value: false, label: 'Incomplete' } + { value: RuleStatus.PASS, label: 'Pass' }, + { value: RuleStatus.FAIL, label: 'Fail' } ]; return ( @@ -54,7 +57,7 @@ const UpdateStatusPopover = ({ anchorEl, onClose, rule, onStatusChange }: Update key={option.label} control={ handleStatusChange(option.value)} sx={{ color: 'white', diff --git a/src/frontend/src/pages/RulesPage/components/RuleStatusHistoryModal.tsx b/src/frontend/src/pages/RulesPage/components/RuleStatusHistoryModal.tsx new file mode 100644 index 0000000000..93cd533e88 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RuleStatusHistoryModal.tsx @@ -0,0 +1,76 @@ +/* + * 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, Typography } from '@mui/material'; +import { Rule, RuleStatus, formatTimestamp } from 'shared'; +import NERModal from '../../../components/NERModal'; +import { getRuleStatusConfig } from '../../../utils/rules.utils'; + +interface RuleStatusHistoryModalProps { + open: boolean; + onClose: () => void; + rule: Rule; +} + +interface StatusHistoryEntry { + status: RuleStatus; + updatedByName: string; + updatedAt: Date; + projectName?: string; +} + +/** + * Full status history for a rule: its general-view status, plus its status in every project + * it's assigned to (each tracked independently), with most recent update at the top. + */ +const RuleStatusHistoryModal: React.FC = ({ open, onClose, rule }) => { + const entries: StatusHistoryEntry[] = []; + + if (rule.statusUpdatedBy && rule.statusUpdatedAt) { + entries.push({ + status: rule.status, + updatedByName: `${rule.statusUpdatedBy.firstName} ${rule.statusUpdatedBy.lastName}`, + updatedAt: rule.statusUpdatedAt + }); + } + + rule.projects?.forEach((project) => { + if (project.statusUpdatedBy && project.statusUpdatedAt) { + entries.push({ + status: project.status, + updatedByName: `${project.statusUpdatedBy.firstName} ${project.statusUpdatedBy.lastName}`, + updatedAt: project.statusUpdatedAt, + projectName: project.projectName + }); + } + }); + + entries.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + + return ( + + {entries.length === 0 ? ( + No status history yet. + ) : ( + + {entries.map((entry, index) => { + const { color } = getRuleStatusConfig(entry.status); + return ( + + + + Marked {entry.status.toUpperCase()} by {entry.updatedByName} on {formatTimestamp(entry.updatedAt)} + {entry.projectName ? ` in ${entry.projectName}` : ''} + + + ); + })} + + )} + + ); +}; + +export default RuleStatusHistoryModal; diff --git a/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx b/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx index beec41bac5..22155ccc6b 100644 --- a/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx +++ b/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx @@ -5,8 +5,8 @@ import { Box, IconButton, Tooltip } from '@mui/material'; import { InfoOutlined, KeyboardArrowRight, KeyboardArrowDown } from '@mui/icons-material'; -import { Rule } from 'shared'; -import { getRuleStatusConfig, isRuleComplete } from '../../../utils/rules.utils'; +import { Rule, formatTimestamp } from 'shared'; +import { getRuleStatusConfig, getRuleStatus } from '../../../utils/rules.utils'; interface RuleStatusTagProps { rule: Rule; @@ -15,33 +15,47 @@ interface RuleStatusTagProps { onClick?: (event: React.MouseEvent) => void; // controls chevron direction when completion is interactive popoverOpen?: boolean; + // if provided, the info icon opens a full status-history modal instead of a one-line tooltip + onInfoClick?: (rule: Rule) => void; } /** - * Completion status chip for a rule. - * A leaf shows its own completion, while a parent is only complete if all - * of its descendant leaf rules are complete. Completed leafs also show an - * info tooltip with who completed it and in which project. + * Status chip for a rule. + * A leaf shows its own status, while a parent's status rolls up from its descendant leaf + * rules (Fail > Pending > Pass). Leafs with a Pass/Fail status also show an info icon - + * a one-line "who marked it and when" tooltip by default, or (if onInfoClick is given) a + * button that opens the full status-history modal instead. */ -const RuleStatusTag: React.FC = ({ rule, allRules, onClick, popoverOpen = false }) => { - const isComplete = isRuleComplete(rule, allRules); - const { label, color } = getRuleStatusConfig(isComplete); +const RuleStatusTag: React.FC = ({ rule, allRules, onClick, popoverOpen = false, onInfoClick }) => { + const status = getRuleStatus(rule, allRules); + const { label, color } = getRuleStatusConfig(status); const isLeaf = !allRules.some((r) => r.parentRule?.ruleId === rule.ruleId); - // Note: Info tooltip only says "Completed by {User}" if completed in general view - const completedByName = rule.completedBy && `${rule.completedBy.firstName} ${rule.completedBy.lastName}`; - const completionMessage = completedByName - ? `Completed by ${completedByName}${rule.completedInProject ? ` in ${rule.completedInProject.projectName}` : ''}` - : ''; + const statusUpdatedByName = + rule.statusUpdatedBy && `${rule.statusUpdatedBy.firstName} ${rule.statusUpdatedBy.lastName}`; + const statusMessage = + statusUpdatedByName && rule.statusUpdatedAt + ? `Marked ${label.toUpperCase()} by ${statusUpdatedByName} on ${formatTimestamp(rule.statusUpdatedAt)}` + : ''; + const hasProjectHistory = rule.projects?.some((project) => project.statusUpdatedAt) ?? false; + + const showInfo = isLeaf && (onInfoClick ? Boolean(statusMessage) || hasProjectHistory : Boolean(statusMessage)); // only leafs are interactive const isInteractive = isLeaf && Boolean(onClick); return ( - {isLeaf && isComplete && completionMessage && ( - - e.stopPropagation()} sx={{ padding: '2px', color: 'text.secondary' }}> + {showInfo && ( + + { + e.stopPropagation(); + onInfoClick?.(rule); + }} + sx={{ padding: '2px', color: 'text.secondary' }} + > diff --git a/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx index a576156659..b27d4b894e 100644 --- a/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx +++ b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx @@ -1,11 +1,12 @@ import React, { useMemo, useState } from 'react'; import { Box, Paper, Table, TableBody, TableContainer, useTheme } from '@mui/material'; -import { Rule } from 'shared'; +import { Rule, RuleStatus } from 'shared'; import RuleRow from '../RuleRow'; import RuleStatusTag from './RuleStatusTag'; import RuleContent from './RuleContent'; +import RuleStatusHistoryModal from './RuleStatusHistoryModal'; import UpdateStatusPopover from '../../ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover'; -import { useSetRuleCompletion } from '../../../hooks/rules.hooks'; +import { useSetRuleStatus } from '../../../hooks/rules.hooks'; import { useToast } from '../../../hooks/toasts.hooks'; import { compareRuleCodes } from '../../../utils/rules.utils'; @@ -31,14 +32,15 @@ const RulesetGeneralView: React.FC = ({ const toast = useToast(); const [statusPopoverAnchor, setStatusPopoverAnchor] = useState(null); const [selectedRule, setSelectedRule] = useState(null); + const [historyModalRule, setHistoryModalRule] = useState(null); const backgroundColor = theme.palette.background.default; const tableBackgroundColor = theme.palette.background.paper; const tableTextColor = theme.palette.text.primary; const tableHoverColor = theme.palette.action.hover; - // Completion in general view is for the whole ruleset, so no projectId is passed in - const { mutateAsync: setCompletion } = useSetRuleCompletion(rulesetId, ''); + // Status in general view is independent of any project + const { mutateAsync: setStatus } = useSetRuleStatus(rulesetId); // Sort once by rule code so both top-level rows and their children render in a stable numeric order. const sortedRules = useMemo(() => [...allRules].sort(compareRuleCodes), [allRules]); @@ -49,10 +51,10 @@ const RulesetGeneralView: React.FC = ({ setSelectedRule(null); }; - const handleStatusChange = async (ruleId: string, isComplete: boolean) => { + const handleStatusChange = async (ruleId: string, status: RuleStatus) => { try { - await setCompletion({ ruleId, isComplete }); - toast.success('Rule completion updated successfully'); + await setStatus({ ruleId, status }); + toast.success('Rule status updated successfully'); } catch (error) { if (error instanceof Error) { toast.error(error.message); @@ -82,6 +84,7 @@ const RulesetGeneralView: React.FC = ({ setSelectedRule(r); setStatusPopoverAnchor(e.currentTarget); }} + onInfoClick={setHistoryModalRule} /> )} backgroundColor={tableBackgroundColor} @@ -100,10 +103,15 @@ const RulesetGeneralView: React.FC = ({ )} + + {historyModalRule && ( + setHistoryModalRule(null)} rule={historyModalRule} /> + )} ); }; diff --git a/src/frontend/src/utils/rules.utils.ts b/src/frontend/src/utils/rules.utils.ts index ba6f6a0ee6..22e8cf869b 100644 --- a/src/frontend/src/utils/rules.utils.ts +++ b/src/frontend/src/utils/rules.utils.ts @@ -3,7 +3,7 @@ * See the LICENSE file in the repository root folder for details. */ -import { Rule } from 'shared'; +import { Rule, RuleStatus } from 'shared'; /** * Counts the total number of rules that will be deleted when deleting a rule, including @@ -38,22 +38,33 @@ export const getDescendantLeafRules = (rule: Rule, allRules: Rule[]): Rule[] => }; /** - * Whether a rule is complete. A leaf uses its own completion; a parent is - * complete only if all of its descendant leaf rules are complete. + * A rule's status, rolled up from its descendant leaves. A leaf uses its own status; a parent + * rolls up with priority Fail > Pending > Pass: Fail if any leaf has failed, else Pending if any + * leaf is still pending, else Pass only once every leaf has passed. * @param rule - The rule to check * @param allRules - All rules in scope - * @returns True if the rule (or all its leaves) are complete + * @returns The status of the rule (or its status computed by its leaves) */ -export const isRuleComplete = (rule: Rule, allRules: Rule[]): boolean => { +export const getRuleStatus = (rule: Rule, allRules: Rule[]): RuleStatus => { const leafRules = getDescendantLeafRules(rule, allRules); - return leafRules.every((leafRule) => leafRule.isComplete); + if (leafRules.some((leafRule) => leafRule.status === RuleStatus.FAIL)) return RuleStatus.FAIL; + if (leafRules.some((leafRule) => leafRule.status === RuleStatus.PENDING)) return RuleStatus.PENDING; + return RuleStatus.PASS; }; /** - * Status chip label and color for a completion state. + * Status chip label and color for a rule status. */ -export const getRuleStatusConfig = (isComplete: boolean): { label: string; color: string } => { - return isComplete ? { label: 'Complete', color: '#4caf50' } : { label: 'Incomplete', color: '#f44336' }; +export const getRuleStatusConfig = (status: RuleStatus): { label: string; color: string } => { + switch (status) { + case RuleStatus.PASS: + return { label: 'Pass', color: '#4caf50' }; + case RuleStatus.FAIL: + return { label: 'Fail', color: '#f44336' }; + case RuleStatus.PENDING: + default: + return { label: 'Pending', color: '#9e9e9e' }; + } }; /** @@ -202,5 +213,5 @@ export const makeSectionRow = (ruleId: string, ruleCode: string, subRuleIds: str parentRule: undefined, subRuleIds, referencedRules: [], - isComplete: false + status: RuleStatus.PENDING }); diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 7f46c665f8..7e43249741 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -478,7 +478,8 @@ const rulesGetUnassignedRulesForRuleset = (rulesetId: string, projectId: string) `${rules()}/ruleset/${rulesetId}/project/${projectId}/rules/unassigned`; const rulesCreateProjectRule = () => `${rules()}/projectRule/create`; const rulesDeleteProjectRule = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/delete`; -const rulesSetRuleCompletion = (ruleId: string) => `${rules()}/rule/${ruleId}/setCompletion`; +const rulesSetRuleStatus = (ruleId: string) => `${rules()}/rule/${ruleId}/setStatus`; +const rulesSetProjectRuleStatus = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/setStatus`; const rulesEdit = (ruleId: string) => `${rules()}/rule/${ruleId}/edit`; const rulesDelete = (ruleId: string) => `${rules()}/rule/${ruleId}/delete`; const rulesAddReferences = (ruleId: string) => `${rules()}/rule/${ruleId}/references/add`; @@ -907,7 +908,8 @@ export const apiUrls = { rulesGetUnassignedRulesForRuleset, rulesCreateProjectRule, rulesDeleteProjectRule, - rulesSetRuleCompletion, + rulesSetRuleStatus, + rulesSetProjectRuleStatus, rulesEdit, rulesDelete, rulesAddReferences, diff --git a/src/shared/src/types/rules-types.ts b/src/shared/src/types/rules-types.ts index a11f9eb0e4..5e9c544724 100644 --- a/src/shared/src/types/rules-types.ts +++ b/src/shared/src/types/rules-types.ts @@ -3,6 +3,12 @@ * See the LICENSE file in the repository root folder for details. */ +export enum RuleStatus { + PENDING = 'PENDING', + PASS = 'PASS', + FAIL = 'FAIL' +} + export interface RulesetType { rulesetTypeId: string; name: string; @@ -47,19 +53,32 @@ export interface Rule { projectId: string; projectName: string; teamIds: string[]; + projectRuleId: string; + status: RuleStatus; + statusUpdatedBy?: { + firstName: string; + lastName: string; + }; + statusUpdatedAt?: Date; }>; - isComplete: boolean; - completedBy?: { + status: RuleStatus; + statusUpdatedBy?: { firstName: string; lastName: string; }; - completedInProject?: { projectId: string; projectName: string }; + statusUpdatedAt?: Date; } export interface ProjectRule { projectRuleId: string; rule: Rule; projectId: string; + status: RuleStatus; + statusUpdatedBy?: { + firstName: string; + lastName: string; + }; + statusUpdatedAt?: Date; } export interface RulesetPreview { From e8f6419d9fc741edc1a8b44c2ccc612de0884ed8 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 13 Aug 2026 09:00:30 -0400 Subject: [PATCH 02/10] #4370 get descendant rule undefined fix --- .../src/pages/RulesPage/components/RuleStatusTag.tsx | 5 ++--- src/frontend/src/utils/rules.utils.ts | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx b/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx index 2da0fb1242..e80e73c88c 100644 --- a/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx +++ b/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx @@ -30,9 +30,8 @@ const RuleStatusTag: React.FC = ({ rule, allRules, onClick, const status = getRuleStatus(rule, allRules); const { label, color } = getRuleStatusConfig(status); - const isLeaf = !allRules.some((r) => r.parentRule?.ruleId === rule.ruleId); - const statusUpdatedByName = - rule.statusUpdatedBy && `${rule.statusUpdatedBy.firstName} ${rule.statusUpdatedBy.lastName}`; + const isLeaf = allRules ? !allRules.some((r) => r.parentRule?.ruleId === rule.ruleId) : rule.subRuleIds.length === 0; + const statusUpdatedByName = rule.statusUpdatedBy && `${rule.statusUpdatedBy.firstName} ${rule.statusUpdatedBy.lastName}`; const statusMessage = statusUpdatedByName && rule.statusUpdatedAt ? `Marked ${label.toUpperCase()} by ${statusUpdatedByName} on ${formatTimestamp(rule.statusUpdatedAt)}` diff --git a/src/frontend/src/utils/rules.utils.ts b/src/frontend/src/utils/rules.utils.ts index 22e8cf869b..217dc735a2 100644 --- a/src/frontend/src/utils/rules.utils.ts +++ b/src/frontend/src/utils/rules.utils.ts @@ -29,7 +29,7 @@ export const countRulesToDelete = (rule: Rule, allRules: Rule[]): number => { * @param allRules - all rules in scope * @returns The leaf rules under the given rule, or rule if it is already a leaf */ -export const getDescendantLeafRules = (rule: Rule, allRules: Rule[]): Rule[] => { +export const getDescendantLeafRules = (rule: Rule, allRules: Rule[] = []): Rule[] => { const children = allRules.filter((r) => r.parentRule?.ruleId === rule.ruleId); if (children.length === 0) { return [rule]; @@ -45,7 +45,7 @@ export const getDescendantLeafRules = (rule: Rule, allRules: Rule[]): Rule[] => * @param allRules - All rules in scope * @returns The status of the rule (or its status computed by its leaves) */ -export const getRuleStatus = (rule: Rule, allRules: Rule[]): RuleStatus => { +export const getRuleStatus = (rule: Rule, allRules: Rule[] = []): RuleStatus => { const leafRules = getDescendantLeafRules(rule, allRules); if (leafRules.some((leafRule) => leafRule.status === RuleStatus.FAIL)) return RuleStatus.FAIL; if (leafRules.some((leafRule) => leafRule.status === RuleStatus.PENDING)) return RuleStatus.PENDING; From 553a8bb62c20b9b1e5e42df8d0b4d2b896d1b925 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 13 Aug 2026 09:11:11 -0400 Subject: [PATCH 03/10] #4370 seed data fix for car filtering --- src/backend/src/prisma/seed-data/rules.seed.ts | 11 +++++++++-- src/backend/src/prisma/seed.ts | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/backend/src/prisma/seed-data/rules.seed.ts b/src/backend/src/prisma/seed-data/rules.seed.ts index 50d4dca42f..829329ccde 100644 --- a/src/backend/src/prisma/seed-data/rules.seed.ts +++ b/src/backend/src/prisma/seed-data/rules.seed.ts @@ -595,7 +595,7 @@ export const seedFsaeRules = async ( } }); - await prisma.rule.create({ + const T211Rule = await prisma.rule.create({ data: { ruleCode: 'T2.1.1', ruleContent: @@ -606,7 +606,7 @@ export const seedFsaeRules = async ( } }); - await prisma.rule.create({ + const T22Rule = await prisma.rule.create({ data: { ruleCode: 'T2.2', ruleContent: @@ -779,6 +779,13 @@ export const seedFsaeRules = async ( // Add the leaf rule to the bodywork project and mark it as passed for that project. const bodyworkT112AProjectRule = await RulesService.createProjectRule(batman, organization, T112ARule.ruleId, projectId); await RulesService.setProjectRuleStatus(batman, organization, bodyworkT112AProjectRule.projectRuleId, RuleStatus.PASS); + + // Assign FHE T2.1.1 and T2.2 to Husky along with their shared ancestors (T -> T2 -> T2.1 -> T2.1.1, T2 -> T2.2) + for (const rule of [TRule, T2Rule, T21Rule, T211Rule, T22Rule]) { + await RulesService.toggleRuleTeam(rule.ruleId, huskyTeamId, batman, organization); + } + await RulesService.createProjectRule(batman, organization, T211Rule.ruleId, projectId); + await RulesService.createProjectRule(batman, organization, T22Rule.ruleId, projectId); }; export const ruleSeedData = { diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 08d155598c..c225cfcc8e 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -4288,11 +4288,11 @@ const performSeed: () => Promise = async () => { // create rulesets const rulesetFSAE = await prisma.ruleset.create({ - data: ruleSeedData.rulesetFSAE(fergus.carId, batman.userId, fsaeRulesetType.rulesetTypeId) + data: ruleSeedData.rulesetFSAE(car25.carId, batman.userId, fsaeRulesetType.rulesetTypeId) }); const rulesetFHE = await prisma.ruleset.create({ - data: ruleSeedData.rulesetFHE(fergus.carId, batman.userId, fheRulesetType.rulesetTypeId) + data: ruleSeedData.rulesetFHE(car25.carId, batman.userId, fheRulesetType.rulesetTypeId) }); const rulesetMock = await prisma.ruleset.create({ From 59dbed11816b693f72ddd9390059efce1d9b5b82 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 13 Aug 2026 12:45:57 -0400 Subject: [PATCH 04/10] #4370 status history many to many --- .../src/controllers/rules.controllers.ts | 20 +++++- .../src/prisma-query-args/rules.query-args.ts | 42 ++++++++++++ .../migration.sql | 27 ++++++++ src/backend/src/prisma/schema.prisma | 64 ++++++++++++------- src/backend/src/routes/rules.routes.ts | 6 ++ src/backend/src/services/rules.services.ts | 64 ++++++++++++++++++- .../src/transformers/rules.transformer.ts | 32 ++++++++-- src/backend/tests/test-utils.ts | 1 + src/frontend/src/apis/rules.api.ts | 17 ++++- .../apis/transformers/rules.transformers.ts | 13 +++- src/frontend/src/hooks/rules.hooks.ts | 26 +++++++- .../ProjectRules/ProjectRulesTab.tsx | 30 ++++++++- .../components/RuleStatusHistoryModal.tsx | 52 +++++---------- .../RulesPage/components/RuleStatusTag.tsx | 4 +- src/frontend/src/utils/rules.utils.ts | 3 +- src/frontend/src/utils/urls.ts | 2 + src/shared/src/types/rules-types.ts | 15 +++++ 17 files changed, 340 insertions(+), 78 deletions(-) diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts index 3734adaf3f..f97fb5e244 100644 --- a/src/backend/src/controllers/rules.controllers.ts +++ b/src/backend/src/controllers/rules.controllers.ts @@ -1,6 +1,6 @@ import { NextFunction, Request, Response } from 'express'; import RulesService from '../services/rules.services.js'; -import { ProjectRule, Rule, Ruleset, RuleStatus } from 'shared'; +import { ProjectRule, Rule, Ruleset, RuleStatus, RuleStatusHistoryEntry } from 'shared'; import { HttpException } from '../utils/errors.utils.js'; export default class RulesController { @@ -210,6 +210,24 @@ export default class RulesController { } } + static async getRuleStatusHistory(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId } = req.params as Record; + const { projectRuleId } = req.query as { projectRuleId?: string }; + + const history: RuleStatusHistoryEntry[] = await RulesService.getRuleStatusHistory( + req.currentUser, + req.organization, + ruleId, + projectRuleId + ); + + res.status(200).json(history); + } catch (error: unknown) { + next(error); + } + } + static async toggleRuleTeam(req: Request, res: Response, next: NextFunction) { try { const { ruleId } = req.params as Record; diff --git a/src/backend/src/prisma-query-args/rules.query-args.ts b/src/backend/src/prisma-query-args/rules.query-args.ts index af8f1d3623..ab43154b19 100644 --- a/src/backend/src/prisma-query-args/rules.query-args.ts +++ b/src/backend/src/prisma-query-args/rules.query-args.ts @@ -55,6 +55,11 @@ export const getRulePreviewQueryArgs = () => } } } + }, + _count: { + select: { + statusHistory: true + } } } }, @@ -63,6 +68,11 @@ export const getRulePreviewQueryArgs = () => firstName: true, lastName: true } + }, + _count: { + select: { + statusHistory: true + } } } }); @@ -78,6 +88,38 @@ export const getProjectRuleQueryArgs = () => firstName: true, lastName: true } + }, + _count: { + select: { + statusHistory: true + } + } + } + }); + +export type RuleStatusHistoryQueryArgs = ReturnType; + +export const getRuleStatusHistoryQueryArgs = () => + Prisma.validator()({ + include: { + updatedBy: { + select: { + firstName: true, + lastName: true + } + }, + projectRule: { + select: { + project: { + select: { + wbsElement: { + select: { + name: true + } + } + } + } + } } } }); diff --git a/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql b/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql index 662b2b6fcf..bd8378db93 100644 --- a/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql +++ b/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql @@ -167,3 +167,30 @@ ALTER TABLE "_teamRules" ADD CONSTRAINT "_teamRules_A_fkey" FOREIGN KEY ("A") RE -- AddForeignKey ALTER TABLE "_teamRules" ADD CONSTRAINT "_teamRules_B_fkey" FOREIGN KEY ("B") REFERENCES "Team"("teamId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "Rule_Status_History" ( + "ruleStatusHistoryId" TEXT NOT NULL, + "ruleId" TEXT NOT NULL, + "projectRuleId" TEXT, + "status" "Rule_Status" NOT NULL, + "updatedByUserId" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Rule_Status_History_pkey" PRIMARY KEY ("ruleStatusHistoryId") +); + +-- CreateIndex +CREATE INDEX "Rule_Status_History_ruleId_idx" ON "Rule_Status_History"("ruleId"); + +-- CreateIndex +CREATE INDEX "Rule_Status_History_projectRuleId_idx" ON "Rule_Status_History"("projectRuleId"); + +-- AddForeignKey +ALTER TABLE "Rule_Status_History" ADD CONSTRAINT "Rule_Status_History_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "Rule"("ruleId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Rule_Status_History" ADD CONSTRAINT "Rule_Status_History_projectRuleId_fkey" FOREIGN KEY ("projectRuleId") REFERENCES "Project_Rule"("projectRuleId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Rule_Status_History" ADD CONSTRAINT "Rule_Status_History_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 59da1ff086..3c6f684158 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -294,6 +294,7 @@ model User { createdProjectRules Project_Rule[] @relation(name: "projectRuleCreator") deletedProjectRules Project_Rule[] @relation(name: "projectRuleDeletor") projectRuleStatusUpdates Project_Rule[] @relation(name: "projectRuleStatusUpdater") + ruleStatusHistoryUpdates Rule_Status_History[] @relation(name: "ruleStatusHistoryUpdater") deletedGuestDefinitions Guest_Definition[] @relation(name: "guestDefinitionDeleter") createdGuestDefinitions Guest_Definition[] @relation(name: "guestDefinitionCreator") requiredEvents Event[] @relation(name: "requiredEventAttendee") @@ -1815,57 +1816,74 @@ model Ruleset { } model Rule { - ruleId String @id @default(uuid()) + ruleId String @id @default(uuid()) ruleCode String ruleContent String imageFileIds String[] rulesetId String - ruleset Ruleset @relation(fields: [rulesetId], references: [rulesetId]) + ruleset Ruleset @relation(fields: [rulesetId], references: [rulesetId]) parentRuleId String? - parentRule Rule? @relation(name: "subRules", fields: [parentRuleId], references: [ruleId]) - subRules Rule[] @relation(name: "subRules") - referencedRule Rule[] @relation(name: "ruleReferences") - referencedBy Rule[] @relation(name: "ruleReferences") - projects Project_Rule[] @relation(name: "rulesInProject") - teams Team[] @relation(name: "teamRules") - status Rule_Status @default(PENDING) + parentRule Rule? @relation(name: "subRules", fields: [parentRuleId], references: [ruleId]) + subRules Rule[] @relation(name: "subRules") + referencedRule Rule[] @relation(name: "ruleReferences") + referencedBy Rule[] @relation(name: "ruleReferences") + projects Project_Rule[] @relation(name: "rulesInProject") + teams Team[] @relation(name: "teamRules") + status Rule_Status @default(PENDING) statusUpdatedByUserId String? - statusUpdatedBy User? @relation(name: "ruleStatusUpdater", fields: [statusUpdatedByUserId], references: [userId]) + statusUpdatedBy User? @relation(name: "ruleStatusUpdater", fields: [statusUpdatedByUserId], references: [userId]) statusUpdatedAt DateTime? - dateCreated DateTime @default(now()) - dateUpdated DateTime? @updatedAt + statusHistory Rule_Status_History[] + dateCreated DateTime @default(now()) + dateUpdated DateTime? @updatedAt dateDeleted DateTime? createdByUserId String - createdBy User @relation(name: "ruleCreator", fields: [createdByUserId], references: [userId]) + createdBy User @relation(name: "ruleCreator", fields: [createdByUserId], references: [userId]) updatedByUserId String? - updatedBy User? @relation(name: "ruleUpdater", fields: [updatedByUserId], references: [userId]) + updatedBy User? @relation(name: "ruleUpdater", fields: [updatedByUserId], references: [userId]) deletedByUserId String? - deletedBy User? @relation(name: "ruleDeletor", fields: [deletedByUserId], references: [userId]) + deletedBy User? @relation(name: "ruleDeletor", fields: [deletedByUserId], references: [userId]) @@unique([rulesetId, ruleCode]) @@index([parentRuleId, rulesetId, ruleCode]) } model Project_Rule { - projectRuleId String @id @default(uuid()) + projectRuleId String @id @default(uuid()) ruleId String - rule Rule @relation(name: "rulesInProject", fields: [ruleId], references: [ruleId]) + rule Rule @relation(name: "rulesInProject", fields: [ruleId], references: [ruleId]) projectId String - project Project @relation(name: "projectsForRule", fields: [projectId], references: [projectId]) - status Rule_Status @default(PENDING) + project Project @relation(name: "projectsForRule", fields: [projectId], references: [projectId]) + status Rule_Status @default(PENDING) statusUpdatedByUserId String? - statusUpdatedBy User? @relation(name: "projectRuleStatusUpdater", fields: [statusUpdatedByUserId], references: [userId]) + statusUpdatedBy User? @relation(name: "projectRuleStatusUpdater", fields: [statusUpdatedByUserId], references: [userId]) statusUpdatedAt DateTime? - dateCreated DateTime @default(now()) + statusHistory Rule_Status_History[] + dateCreated DateTime @default(now()) createdByUserId String - createdBy User @relation(name: "projectRuleCreator", fields: [createdByUserId], references: [userId]) + createdBy User @relation(name: "projectRuleCreator", fields: [createdByUserId], references: [userId]) dateDeleted DateTime? deletedByUserId String? - deletedBy User? @relation(name: "projectRuleDeletor", fields: [deletedByUserId], references: [userId]) + deletedBy User? @relation(name: "projectRuleDeletor", fields: [deletedByUserId], references: [userId]) @@unique([ruleId, projectId]) } +model Rule_Status_History { + ruleStatusHistoryId String @id @default(uuid()) + ruleId String + rule Rule @relation(fields: [ruleId], references: [ruleId]) + projectRuleId String? + projectRule Project_Rule? @relation(fields: [projectRuleId], references: [projectRuleId]) + status Rule_Status + updatedByUserId String + updatedBy User @relation(name: "ruleStatusHistoryUpdater", fields: [updatedByUserId], references: [userId]) + dateCreated DateTime @default(now()) + + @@index([ruleId]) + @@index([projectRuleId]) +} + model Guest_Definition { definitionId String @id @default(uuid()) term String diff --git a/src/backend/src/routes/rules.routes.ts b/src/backend/src/routes/rules.routes.ts index 29bb96ea8b..296485f4f4 100644 --- a/src/backend/src/routes/rules.routes.ts +++ b/src/backend/src/routes/rules.routes.ts @@ -78,6 +78,12 @@ rulesRouter.post( validateInputs, RulesController.setProjectRuleStatus ); +rulesRouter.get( + '/rule/:ruleId/status-history', + query('projectRuleId').optional().isString(), + validateInputs, + RulesController.getRuleStatusHistory +); rulesRouter.post( '/rule/:ruleId/toggle-team', diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 44de77e064..d88e775067 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -9,7 +9,8 @@ import { Rule as SharedRule, isHead, Ruleset, - RuleStatus + RuleStatus, + RuleStatusHistoryEntry } from 'shared'; import prisma from '../prisma/prisma.js'; import { @@ -26,13 +27,15 @@ import { getProjectRuleQueryArgs, getRulesetQueryArgs, getRulePreviewQueryArgs, - getRulesetTypeQueryArgs + getRulesetTypeQueryArgs, + getRuleStatusHistoryQueryArgs } from '../prisma-query-args/rules.query-args.js'; import { ruleTransformer, projectRuleTransformer, rulesetTransformer, - rulesetTypeTransformer + rulesetTypeTransformer, + ruleStatusHistoryTransformer } from '../transformers/rules.transformer.js'; import { ParsedRule, parseRulesFromPdf } from '../utils/parse.utils.js'; import { uploadFile, downloadFile } from '../utils/google-integration.utils.js'; @@ -824,6 +827,13 @@ export default class RulesService { throw new InvalidOrganizationException('Rule'); } + // only PASS/FAIL are tracked in history; PENDING does not create an entry + if (status !== RuleStatus.PENDING) { + await prisma.rule_Status_History.create({ + data: { ruleId, status, updatedByUserId: submitter.userId } + }); + } + const updatedRule = await prisma.rule.update({ where: { ruleId }, data: @@ -878,6 +888,13 @@ export default class RulesService { throw new InvalidOrganizationException('Project Rule'); } + // only PASS/FAIL are tracked in history; PENDING does not create an entry + if (status !== RuleStatus.PENDING) { + await prisma.rule_Status_History.create({ + data: { ruleId: projectRule.ruleId, projectRuleId, status, updatedByUserId: submitter.userId } + }); + } + const updatedProjectRule = await prisma.project_Rule.update({ where: { projectRuleId }, data: @@ -890,6 +907,47 @@ export default class RulesService { return projectRuleTransformer(updatedProjectRule); } + /** + * Gets the full status history for a rule, every time status was marked PASS or FAIL. + * Reverting to PENDING does not create history. + * @param user a user who is requesting the status history + * @param organization the organization of the rule + * @param ruleId the id of the rule to get history for + * @param projectRuleId if provided, scopes the history to just this project rule instead of every context the rule appears in + * @returns the rule's status history, most recent first + */ + static async getRuleStatusHistory( + user: User, + organization: Organization, + ruleId: string, + projectRuleId?: string + ): Promise { + if (!(await userHasPermission(user.userId, organization.organizationId, notGuest))) { + throw new AccessDeniedGuestException('view rule status history'); + } + + const rule = await prisma.rule.findUnique({ + where: { ruleId }, + include: { ruleset: { include: { car: { include: { wbsElement: true } } } } } + }); + + if (!rule) { + throw new NotFoundException('Rule', ruleId); + } + + if (rule.ruleset.car.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Rule'); + } + + const history = await prisma.rule_Status_History.findMany({ + where: { ruleId, ...(projectRuleId && { projectRuleId }) }, + orderBy: { dateCreated: 'desc' }, + ...getRuleStatusHistoryQueryArgs() + }); + + return history.map(ruleStatusHistoryTransformer); + } + /** * Assigns a rule to a team. If the team already is assigned to the * rule, removes the team from the rule. diff --git a/src/backend/src/transformers/rules.transformer.ts b/src/backend/src/transformers/rules.transformer.ts index 4b2bdc0662..78597b0d40 100644 --- a/src/backend/src/transformers/rules.transformer.ts +++ b/src/backend/src/transformers/rules.transformer.ts @@ -1,6 +1,11 @@ import { Prisma } from '@prisma/client'; -import { Rule, ProjectRule, Ruleset, RulesetType, RuleStatus } from 'shared'; -import { RulesetQueryArgs, RulePreviewQueryArgs, ProjectRuleQueryArgs } from '../prisma-query-args/rules.query-args.js'; +import { Rule, ProjectRule, Ruleset, RulesetType, RuleStatus, RuleStatusHistoryEntry } from 'shared'; +import { + RulesetQueryArgs, + RulePreviewQueryArgs, + ProjectRuleQueryArgs, + RuleStatusHistoryQueryArgs +} from '../prisma-query-args/rules.query-args.js'; export const ruleTransformer = (rule: Prisma.RuleGetPayload): Rule => { return { @@ -32,7 +37,8 @@ export const ruleTransformer = (rule: Prisma.RuleGetPayload 0 })), status: rule.status as RuleStatus, statusUpdatedBy: rule.statusUpdatedBy @@ -41,7 +47,8 @@ export const ruleTransformer = (rule: Prisma.RuleGetPayload 0 }; }; @@ -57,7 +64,22 @@ export const projectRuleTransformer = (projectRule: Prisma.Project_RuleGetPayloa lastName: projectRule.statusUpdatedBy.lastName } : undefined, - statusUpdatedAt: projectRule.statusUpdatedAt ?? undefined + statusUpdatedAt: projectRule.statusUpdatedAt ?? undefined, + hasStatusHistory: projectRule._count.statusHistory > 0 + }; +}; + +export const ruleStatusHistoryTransformer = ( + entry: Prisma.Rule_Status_HistoryGetPayload +): RuleStatusHistoryEntry => { + return { + status: entry.status as RuleStatus, + updatedBy: { + firstName: entry.updatedBy.firstName, + lastName: entry.updatedBy.lastName + }, + updatedAt: entry.dateCreated, + projectName: entry.projectRule?.project.wbsElement.name }; }; diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index eb99f9ef58..27dc01a48a 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -116,6 +116,7 @@ export const resetUsers = async () => { await prisma.part_Review.deleteMany(); await prisma.part_Submission.deleteMany(); await prisma.part.deleteMany(); + await prisma.rule_Status_History.deleteMany(); await prisma.project_Rule.deleteMany(); await prisma.rule.deleteMany(); await prisma.ruleset.deleteMany(); diff --git a/src/frontend/src/apis/rules.api.ts b/src/frontend/src/apis/rules.api.ts index 9aac8420e6..5f61e517ae 100644 --- a/src/frontend/src/apis/rules.api.ts +++ b/src/frontend/src/apis/rules.api.ts @@ -4,14 +4,15 @@ */ import axios from '../utils/axios'; -import { ProjectRule, Rule as SharedRule, RulesetType, Ruleset, RuleStatus } from 'shared'; +import { ProjectRule, Rule as SharedRule, RulesetType, Ruleset, RuleStatus, RuleStatusHistoryEntry } from 'shared'; import { apiUrls } from '../utils/urls'; import { CreateRulesetPayload, ParseRulesetPayload, CreateRulePayload } from '../hooks/rules.hooks'; import { projectRuleTransformer, rulesetTransformer, rulesetTypeTransformer, - ruleTransformer + ruleTransformer, + ruleStatusHistoryTransformer } from './transformers/rules.transformers'; /** @@ -118,6 +119,18 @@ export const setProjectRuleStatus = (projectRuleId: string, status: RuleStatus) return axios.post(apiUrls.rulesSetProjectRuleStatus(projectRuleId), { status }); }; +/** + * Gets a rule's full status history - every time it was marked PASS or FAIL. + * @param ruleId the rule to get history for + * @param projectRuleId if provided, scopes the history to just this project rule instead of every context the rule appears in + */ +export const getRuleStatusHistory = (ruleId: string, projectRuleId?: string) => { + return axios.get(apiUrls.rulesGetStatusHistory(ruleId), { + params: { projectRuleId }, + transformResponse: (data) => JSON.parse(data).map(ruleStatusHistoryTransformer) + }); +}; + /** * Gets child rules */ diff --git a/src/frontend/src/apis/transformers/rules.transformers.ts b/src/frontend/src/apis/transformers/rules.transformers.ts index a7feafa9be..f063530632 100644 --- a/src/frontend/src/apis/transformers/rules.transformers.ts +++ b/src/frontend/src/apis/transformers/rules.transformers.ts @@ -3,7 +3,7 @@ * See the LICENSE file in the repository root folder for details. */ -import { ProjectRule, Rule, RulesetType, Ruleset } from 'shared'; +import { ProjectRule, Rule, RulesetType, Ruleset, RuleStatusHistoryEntry } from 'shared'; /** * Transforms a rule to proper field types. @@ -65,3 +65,14 @@ export const rulesetTransformer = (ruleset: Ruleset): Ruleset => { rulesetType: rulesetTypeTransformer(ruleset.rulesetType) }; }; + +/** + * Transforms a rule status history entry + * + * @param entry Incoming status history entry + * @returns Properly transformed status history entry. + */ +export const ruleStatusHistoryTransformer = (entry: RuleStatusHistoryEntry): RuleStatusHistoryEntry => ({ + ...entry, + updatedAt: new Date(entry.updatedAt) +}); diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index 486c9fdd57..3e1987f560 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -5,7 +5,7 @@ import { useCallback } from 'react'; import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { ProjectRule, Rule as SharedRule, Ruleset, RulesetType, RuleStatus } from 'shared'; +import { ProjectRule, Rule as SharedRule, Ruleset, RulesetType, RuleStatus, RuleStatusHistoryEntry } from 'shared'; import { createRulesetType, getAllRulesetTypes, @@ -16,6 +16,7 @@ import { deleteProjectRule, setRuleStatus, setProjectRuleStatus, + getRuleStatusHistory, getChildRules, getTopLevelRules, getAllRulesForRuleset, @@ -154,6 +155,23 @@ export const useGetChildRules = (ruleId: string, enabled: boolean = true) => { ); }; +/** + * Hook to get a rule's full status history. Only fetched when enabled is true, such as when a modal is open. + * @param projectRuleId if provided, scopes the history to just this project rule instead of every context the rule appears in + */ +export const useRuleStatusHistory = (ruleId: string, enabled: boolean, projectRuleId?: string) => { + return useQuery( + ['rules', 'statusHistory', ruleId, projectRuleId], + async () => { + const { data } = await getRuleStatusHistory(ruleId, projectRuleId); + return data; + }, + { + enabled + } + ); +}; + /** * Hook to get a single ruleset by ID. */ @@ -307,8 +325,9 @@ export const useSetRuleStatus = (rulesetId: string) => { return data; }, { - onSuccess: () => { + onSuccess: (_data, { ruleId }) => { queryClient.invalidateQueries(['rules', 'allRules', rulesetId]); + queryClient.invalidateQueries(['rules', 'statusHistory', ruleId]); } } ); @@ -326,12 +345,13 @@ export const useSetProjectRuleStatus = (rulesetId: string, projectId: string) => return data; }, { - onSuccess: () => { + onSuccess: (updatedProjectRule) => { queryClient.invalidateQueries(['rules', 'projectRules', rulesetId, projectId]); queryClient.invalidateQueries(['rules', 'unassigned']); queryClient.invalidateQueries(['rules', 'allRules', rulesetId]); queryClient.invalidateQueries(['rules', 'top-level', rulesetId]); queryClient.invalidateQueries(['rules', 'children']); + queryClient.invalidateQueries(['rules', 'statusHistory', updatedProjectRule.rule.ruleId]); } } ); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index a484df341f..1c919d0426 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -24,6 +24,7 @@ import LoadingIndicator from '../../../../components/LoadingIndicator'; import ErrorPage from '../../../ErrorPage'; import RuleRow from '../../../RulesPage/RuleRow'; import RuleContent from '../../../RulesPage/components/RuleContent'; +import RuleStatusHistoryModal from '../../../RulesPage/components/RuleStatusHistoryModal'; import { useRuleTreeNavigation } from '../../../RulesPage/useRuleTreeNavigation'; import UpdateStatusPopover from './UpdateStatusPopover'; import AddRuleModal from './AddProjectRuleModal'; @@ -56,6 +57,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { const [statusPopoverAnchor, setStatusPopoverAnchor] = useState(null); const [addRuleModalOpen, setAddRuleModalOpen] = useState(false); const [selectedProjectRule, setSelectedProjectRule] = useState(null); + const [historyModalProjectRule, setHistoryModalProjectRule] = useState(null); // Fetch all ruleset types const { data: rulesetTypes, isLoading: rulesetTypesLoading, isError: rulesetTypesError } = useAllRulesetTypes(); @@ -93,7 +95,14 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { const projectRuleList = useMemo(() => { if (!projectRules) return []; return projectRules - .map((pr) => ({ ...pr.rule, status: pr.status, statusUpdatedBy: pr.statusUpdatedBy, statusUpdatedAt: pr.statusUpdatedAt })) + .map((pr) => ({ + ...pr.rule, + status: pr.status, + statusUpdatedBy: pr.statusUpdatedBy, + statusUpdatedAt: pr.statusUpdatedAt, + // history modal for PASS/FAIL only scoped to this project + hasStatusHistory: pr.hasStatusHistory + })) .sort(compareRuleCodes); }, [projectRules]); @@ -154,6 +163,14 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { setSelectedProjectRule(null); }; + // Handle opening the status history modal, scoped to this project + const handleInfoClick = (rule: Rule) => { + const projectRule = projectRules?.find((pr) => pr.rule.ruleId === rule.ruleId); + if (projectRule) { + setHistoryModalProjectRule(projectRule); + } + }; + // Handle tab change const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { setSelectedRulesetTypeIndex(newValue); @@ -193,6 +210,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { allRules={projectRuleList} popoverOpen={isPopoverOpenForRule} onClick={isLeafRule ? (e) => handleStatusClick(e, rule) : undefined} + onInfoClick={handleInfoClick} /> ); }; @@ -379,6 +397,16 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { /> )} + {/* Status History Modal, scoped to this project */} + {historyModalProjectRule && ( + setHistoryModalProjectRule(null)} + rule={historyModalProjectRule.rule} + projectRuleId={historyModalProjectRule.projectRuleId} + /> + )} + {/* Add Rule Modal */} {activeRuleset && ( void; rule: Rule; -} - -interface StatusHistoryEntry { - status: RuleStatus; - updatedByName: string; - updatedAt: Date; - projectName?: string; + // if provided, scopes history to just this project; otherwise shows every context the rule appears in + projectRuleId?: string; } /** - * Full status history for a rule: its general-view status, plus its status in every project - * it's assigned to (each tracked independently), with most recent update at the top. + * Full status history for a rule: every time status was marked PASS or FAIL, most recent first. + * Reverting to PENDING doesn't add an entry. */ -const RuleStatusHistoryModal: React.FC = ({ open, onClose, rule }) => { - const entries: StatusHistoryEntry[] = []; - - if (rule.statusUpdatedBy && rule.statusUpdatedAt) { - entries.push({ - status: rule.status, - updatedByName: `${rule.statusUpdatedBy.firstName} ${rule.statusUpdatedBy.lastName}`, - updatedAt: rule.statusUpdatedAt - }); - } - - rule.projects?.forEach((project) => { - if (project.statusUpdatedBy && project.statusUpdatedAt) { - entries.push({ - status: project.status, - updatedByName: `${project.statusUpdatedBy.firstName} ${project.statusUpdatedBy.lastName}`, - updatedAt: project.statusUpdatedAt, - projectName: project.projectName - }); - } - }); - - entries.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); +const RuleStatusHistoryModal: React.FC = ({ open, onClose, rule, projectRuleId }) => { + const { data: entries, isLoading } = useRuleStatusHistory(rule.ruleId, open, projectRuleId); return ( - {entries.length === 0 ? ( + {isLoading ? ( + + + + ) : !entries || entries.length === 0 ? ( No status history yet. ) : ( @@ -61,7 +40,8 @@ const RuleStatusHistoryModal: React.FC = ({ open, o - Marked {entry.status.toUpperCase()} by {entry.updatedByName} on {formatTimestamp(entry.updatedAt)} + Marked {entry.status.toUpperCase()} by {entry.updatedBy.firstName} {entry.updatedBy.lastName} on{' '} + {formatTimestamp(entry.updatedAt)} {entry.projectName ? ` in ${entry.projectName}` : ''} diff --git a/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx b/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx index e80e73c88c..21f0aca54a 100644 --- a/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx +++ b/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx @@ -36,9 +36,9 @@ const RuleStatusTag: React.FC = ({ rule, allRules, onClick, statusUpdatedByName && rule.statusUpdatedAt ? `Marked ${label.toUpperCase()} by ${statusUpdatedByName} on ${formatTimestamp(rule.statusUpdatedAt)}` : ''; - const hasProjectHistory = rule.projects?.some((project) => project.statusUpdatedAt) ?? false; - const showInfo = isLeaf && (onInfoClick ? Boolean(statusMessage) || hasProjectHistory : Boolean(statusMessage)); + // hasStatusHistory persists even after the current status is reverted to PENDING + const showInfo = isLeaf && (onInfoClick ? rule.hasStatusHistory : Boolean(statusMessage)); // only leafs are interactive const isInteractive = isLeaf && Boolean(onClick); diff --git a/src/frontend/src/utils/rules.utils.ts b/src/frontend/src/utils/rules.utils.ts index 217dc735a2..f859c64a72 100644 --- a/src/frontend/src/utils/rules.utils.ts +++ b/src/frontend/src/utils/rules.utils.ts @@ -213,5 +213,6 @@ export const makeSectionRow = (ruleId: string, ruleCode: string, subRuleIds: str parentRule: undefined, subRuleIds, referencedRules: [], - status: RuleStatus.PENDING + status: RuleStatus.PENDING, + hasStatusHistory: false }); diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index ffcbd87776..47edfd41c0 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -480,6 +480,7 @@ const rulesCreateProjectRule = () => `${rules()}/projectRule/create`; const rulesDeleteProjectRule = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/delete`; const rulesSetRuleStatus = (ruleId: string) => `${rules()}/rule/${ruleId}/setStatus`; const rulesSetProjectRuleStatus = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/setStatus`; +const rulesGetStatusHistory = (ruleId: string) => `${rules()}/rule/${ruleId}/status-history`; const rulesEdit = (ruleId: string) => `${rules()}/rule/${ruleId}/edit`; const rulesDelete = (ruleId: string) => `${rules()}/rule/${ruleId}/delete`; const rulesAddReferences = (ruleId: string) => `${rules()}/rule/${ruleId}/references/add`; @@ -909,6 +910,7 @@ export const apiUrls = { rulesDeleteProjectRule, rulesSetRuleStatus, rulesSetProjectRuleStatus, + rulesGetStatusHistory, rulesEdit, rulesDelete, rulesAddReferences, diff --git a/src/shared/src/types/rules-types.ts b/src/shared/src/types/rules-types.ts index 5e9c544724..45941fb883 100644 --- a/src/shared/src/types/rules-types.ts +++ b/src/shared/src/types/rules-types.ts @@ -60,6 +60,7 @@ export interface Rule { lastName: string; }; statusUpdatedAt?: Date; + hasStatusHistory: boolean; }>; status: RuleStatus; statusUpdatedBy?: { @@ -67,6 +68,8 @@ export interface Rule { lastName: string; }; statusUpdatedAt?: Date; + // true if this rule (general-view status, or its status in any project) has ever been marked PASS or FAIL + hasStatusHistory: boolean; } export interface ProjectRule { @@ -79,6 +82,18 @@ export interface ProjectRule { lastName: string; }; statusUpdatedAt?: Date; + // true if this rule has ever been marked PASS or FAIL within this specific project + hasStatusHistory: boolean; +} + +export interface RuleStatusHistoryEntry { + status: RuleStatus; + updatedBy: { + firstName: string; + lastName: string; + }; + updatedAt: Date; + projectName?: string; } export interface RulesetPreview { From 396fd56b8543a26d34ab850fc35cbbf96c7b0393 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 13 Aug 2026 12:56:34 -0400 Subject: [PATCH 05/10] #4370 refresh fix --- src/frontend/src/hooks/rules.hooks.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index 3e1987f560..af9bf3d257 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -327,6 +327,8 @@ export const useSetRuleStatus = (rulesetId: string) => { { onSuccess: (_data, { ruleId }) => { queryClient.invalidateQueries(['rules', 'allRules', rulesetId]); + queryClient.invalidateQueries(['rules', 'top-level', rulesetId]); + queryClient.invalidateQueries(['rules', 'children']); queryClient.invalidateQueries(['rules', 'statusHistory', ruleId]); } } From 0b888262838978a7eabb20073bb9d5fe4ab85179 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sat, 15 Aug 2026 13:58:21 -0400 Subject: [PATCH 06/10] #4370 reset functionality --- .../src/controllers/rules.controllers.ts | 24 +++ src/backend/src/routes/rules.routes.ts | 2 + src/backend/src/services/rules.services.ts | 104 +++++++++- src/backend/tests/unit/rule.test.ts | 180 ++++++++++++++++++ src/frontend/src/apis/rules.api.ts | 17 ++ src/frontend/src/hooks/rules.hooks.ts | 52 +++++ .../ProjectRules/ProjectRulesTab.tsx | 43 ++++- .../src/pages/RulesPage/RulesetViewPage.tsx | 31 ++- .../components/ResetStatusesModal.tsx | 34 ++++ src/frontend/src/utils/urls.ts | 5 + 10 files changed, 485 insertions(+), 7 deletions(-) create mode 100644 src/frontend/src/pages/RulesPage/components/ResetStatusesModal.tsx diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts index f97fb5e244..0492e99c26 100644 --- a/src/backend/src/controllers/rules.controllers.ts +++ b/src/backend/src/controllers/rules.controllers.ts @@ -210,6 +210,30 @@ export default class RulesController { } } + static async resetRulesetStatuses(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetId } = req.params as Record; + + const count = await RulesService.resetRulesetStatuses(req.currentUser, req.organization, rulesetId); + + res.status(200).json({ count }); + } catch (error: unknown) { + next(error); + } + } + + static async resetProjectRuleStatuses(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetId, projectId } = req.params as Record; + + const count = await RulesService.resetProjectRuleStatuses(req.currentUser, req.organization, rulesetId, projectId); + + res.status(200).json({ count }); + } catch (error: unknown) { + next(error); + } + } + static async getRuleStatusHistory(req: Request, res: Response, next: NextFunction) { try { const { ruleId } = req.params as Record; diff --git a/src/backend/src/routes/rules.routes.ts b/src/backend/src/routes/rules.routes.ts index 296485f4f4..fcdaae3b9a 100644 --- a/src/backend/src/routes/rules.routes.ts +++ b/src/backend/src/routes/rules.routes.ts @@ -84,6 +84,8 @@ rulesRouter.get( validateInputs, RulesController.getRuleStatusHistory ); +rulesRouter.post('/ruleset/:rulesetId/resetStatuses', RulesController.resetRulesetStatuses); +rulesRouter.post('/ruleset/:rulesetId/project/:projectId/resetStatuses', RulesController.resetProjectRuleStatuses); rulesRouter.post( '/rule/:ruleId/toggle-team', diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index d88e775067..18be179bee 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -908,7 +908,109 @@ export default class RulesService { } /** - * Gets the full status history for a rule, every time status was marked PASS or FAIL. + * Resets every rule's general-view status back to Pending, for a whole ruleset. + * Does not affect any rule's status within a project. Never creates history entries, + * since reverting to PENDING is not tracked. + * @param submitter the user resetting the statuses + * @param organization the organization of the ruleset + * @param rulesetId the id of the ruleset to reset + * @returns the number of rules that were reset + */ + static async resetRulesetStatuses(submitter: User, organization: Organization, rulesetId: string): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) { + throw new AccessDeniedException('You do not have permissions to update rule status'); + } + + const ruleset = await prisma.ruleset.findUnique({ + where: { rulesetId }, + include: { car: { include: { wbsElement: true } } } + }); + + if (!ruleset) { + throw new NotFoundException('Ruleset', rulesetId); + } + + if (ruleset.deletedByUserId) { + throw new DeletedException('Ruleset', rulesetId); + } + + if (ruleset.car.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Ruleset'); + } + + const result = await prisma.rule.updateMany({ + where: { rulesetId, dateDeleted: null, status: { not: RuleStatus.PENDING } }, + data: { status: RuleStatus.PENDING, statusUpdatedByUserId: null, statusUpdatedAt: null } + }); + + return result.count; + } + + /** + * Resets every project rule's status back to Pending, for a single project, scoped to a + * single ruleset (a project can have rules from multiple ruleset types). Does not affect + * any rule's general-view status, or its status in any other project. Never creates history + * entries, since reverting to PENDING is not tracked. + * @param submitter the user resetting the statuses + * @param organization the organization of the project and ruleset + * @param rulesetId the ruleset to scope the reset to + * @param projectId the project whose rules should be reset + * @returns the number of project rules that were reset + */ + static async resetProjectRuleStatuses( + submitter: User, + organization: Organization, + rulesetId: string, + projectId: string + ): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) { + throw new AccessDeniedException('You do not have permissions to update rule status'); + } + + const ruleset = await prisma.ruleset.findUnique({ + where: { rulesetId }, + include: { car: { include: { wbsElement: true } } } + }); + + if (!ruleset) { + throw new NotFoundException('Ruleset', rulesetId); + } + + if (ruleset.deletedByUserId) { + throw new DeletedException('Ruleset', rulesetId); + } + + if (ruleset.car.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Ruleset'); + } + + const project = await prisma.project.findUnique({ + where: { projectId }, + include: { wbsElement: true } + }); + + if (!project) { + throw new NotFoundException('Project', projectId); + } + + if (project.wbsElement.dateDeleted) { + throw new DeletedException('Project', projectId); + } + + if (project.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Project'); + } + + const result = await prisma.project_Rule.updateMany({ + where: { projectId, dateDeleted: null, rule: { rulesetId }, status: { not: RuleStatus.PENDING } }, + data: { status: RuleStatus.PENDING, statusUpdatedByUserId: null, statusUpdatedAt: null } + }); + + return result.count; + } + + /** + * Gets the full status history for a rule, every time status was marked PASS or FAIL. * Reverting to PENDING does not create history. * @param user a user who is requesting the status history * @param organization the organization of the rule diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index 927377c5f7..fe2147ddad 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -1289,6 +1289,186 @@ describe('Rule Tests', () => { }); }); + describe('Reset ruleset statuses', () => { + it('Resets every rule status in the ruleset to Pending and clears who/when', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule, leafRule1 } = await setupRules(car); + + await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.PASS); + await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.FAIL); + + const count = await RulesService.resetRulesetStatuses(admin, organization, ruleset1.rulesetId); + + expect(count).toBe(2); + + const rules = await RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId); + const updatedTopLevel = rules.find((r) => r.ruleId === topLevelRule.ruleId); + const updatedLeaf = rules.find((r) => r.ruleId === leafRule1.ruleId); + + expect(updatedTopLevel?.status).toBe(RuleStatus.PENDING); + expect(updatedTopLevel?.statusUpdatedBy).toBeUndefined(); + expect(updatedTopLevel?.statusUpdatedAt).toBeUndefined(); + expect(updatedLeaf?.status).toBe(RuleStatus.PENDING); + }); + + it('Reset does not create Rule_Status_History entries', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule } = await setupRules(car); + + await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.PASS); + const countBefore = await prisma.rule_Status_History.count(); + + await RulesService.resetRulesetStatuses(admin, organization, ruleset1.rulesetId); + const countAfter = await prisma.rule_Status_History.count(); + + expect(countAfter).toBe(countBefore); + }); + + it('Reset status fails if user does not have permission', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + await expect( + async () => await RulesService.resetRulesetStatuses(nonLeadership, organization, ruleset1.rulesetId) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule status')); + }); + + it('Reset status only affects the given ruleset', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, ruleset2, topLevelRule } = await setupRules(car); + + const otherRule = await prisma.rule.create({ + data: { + ruleCode: 'X', + ruleContent: 'Rule in a different ruleset', + ruleset: { connect: { rulesetId: ruleset2.rulesetId } }, + createdBy: { connect: { userId: admin.userId } } + } + }); + + await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.PASS); + await RulesService.setRuleStatus(admin, organization, otherRule.ruleId, RuleStatus.PASS); + + await RulesService.resetRulesetStatuses(admin, organization, ruleset1.rulesetId); + + const rules = await RulesService.getAllRulesForRuleset(ruleset2.rulesetId, organization.organizationId); + const untouchedRule = rules.find((r) => r.ruleId === otherRule.ruleId); + + expect(untouchedRule?.status).toBe(RuleStatus.PASS); + }); + + it('Reset status when there is nothing to reset', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + const count = await RulesService.resetRulesetStatuses(admin, organization, ruleset1.rulesetId); + + expect(count).toBe(0); + }); + }); + + describe('Reset project rule statuses', () => { + it('Resets every project rule status for the project+ruleset', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + + await RulesService.setProjectRuleStatus(admin, organization, projectRule.projectRuleId, RuleStatus.PASS); + + const count = await RulesService.resetProjectRuleStatuses(admin, organization, ruleset1.rulesetId, project.projectId); + + expect(count).toBe(1); + + const projectRules = await RulesService.getProjectRules(ruleset1.rulesetId, project.projectId, organization); + const updated = projectRules.find((pr) => pr.projectRuleId === projectRule.projectRuleId); + + expect(updated?.status).toBe(RuleStatus.PENDING); + expect(updated?.statusUpdatedBy).toBeUndefined(); + expect(updated?.statusUpdatedAt).toBeUndefined(); + }); + + it('Reset project status does not create Rule_Status_History entries', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + + await RulesService.setProjectRuleStatus(admin, organization, projectRule.projectRuleId, RuleStatus.PASS); + const countBefore = await prisma.rule_Status_History.count(); + + await RulesService.resetProjectRuleStatuses(admin, organization, ruleset1.rulesetId, project.projectId); + const countAfter = await prisma.rule_Status_History.count(); + + expect(countAfter).toBe(countBefore); + }); + + it('Reset project status fails if user does not have permission', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + + await expect( + async () => + await RulesService.resetProjectRuleStatuses(nonLeadership, organization, ruleset1.rulesetId, project.projectId) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule status')); + }); + + it('Reset project status only affects the given project', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule } = await setupRules(car); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + const project1 = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber, 1); + const project2 = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber, 2); + const projectRule1 = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project1.projectId); + const projectRule2 = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project2.projectId); + + await RulesService.setProjectRuleStatus(admin, organization, projectRule1.projectRuleId, RuleStatus.PASS); + await RulesService.setProjectRuleStatus(admin, organization, projectRule2.projectRuleId, RuleStatus.PASS); + + await RulesService.resetProjectRuleStatuses(admin, organization, ruleset1.rulesetId, project1.projectId); + + const project2Rules = await RulesService.getProjectRules(ruleset1.rulesetId, project2.projectId, organization); + const untouched = project2Rules.find((pr) => pr.projectRuleId === projectRule2.projectRuleId); + + expect(untouched?.status).toBe(RuleStatus.PASS); + }); + + it('Reset project status only affects the given ruleset within that project', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, ruleset2, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + + const otherRule = await prisma.rule.create({ + data: { + ruleCode: 'Y', + ruleContent: 'Rule in a different ruleset, same project', + ruleset: { connect: { rulesetId: ruleset2.rulesetId } }, + createdBy: { connect: { userId: admin.userId } } + } + }); + await RulesService.toggleRuleTeam(otherRule.ruleId, testTeam.teamId, admin, organization); + + const projectRule1 = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + const projectRule2 = await RulesService.createProjectRule(admin, organization, otherRule.ruleId, project.projectId); + + await RulesService.setProjectRuleStatus(admin, organization, projectRule1.projectRuleId, RuleStatus.PASS); + await RulesService.setProjectRuleStatus(admin, organization, projectRule2.projectRuleId, RuleStatus.PASS); + + await RulesService.resetProjectRuleStatuses(admin, organization, ruleset1.rulesetId, project.projectId); + + const projectRules = await RulesService.getProjectRules(ruleset2.rulesetId, project.projectId, organization); + const untouched = projectRules.find((pr) => pr.projectRuleId === projectRule2.projectRuleId); + + expect(untouched?.status).toBe(RuleStatus.PASS); + }); + }); + describe('Edit Rule', () => { it('Fails if user is not an admin', async () => { const car = await createUniqueCar(orgId); diff --git a/src/frontend/src/apis/rules.api.ts b/src/frontend/src/apis/rules.api.ts index 5f61e517ae..2479205733 100644 --- a/src/frontend/src/apis/rules.api.ts +++ b/src/frontend/src/apis/rules.api.ts @@ -131,6 +131,23 @@ export const getRuleStatusHistory = (ruleId: string, projectRuleId?: string) => }); }; +/** + * Resets every rule's general-view status back to Pending, for a whole ruleset. + * @param rulesetId the ruleset to reset + */ +export const resetRulesetStatuses = (rulesetId: string) => { + return axios.post<{ count: number }>(apiUrls.rulesResetRulesetStatuses(rulesetId)); +}; + +/** + * Resets every project rule's status back to Pending, for a single project scoped to a single ruleset. + * @param rulesetId the ruleset to scope the reset to + * @param projectId the project whose rules should be reset + */ +export const resetProjectRuleStatuses = (rulesetId: string, projectId: string) => { + return axios.post<{ count: number }>(apiUrls.rulesResetProjectRuleStatuses(rulesetId, projectId)); +}; + /** * Gets child rules */ diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index af9bf3d257..e07690e940 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -17,6 +17,8 @@ import { setRuleStatus, setProjectRuleStatus, getRuleStatusHistory, + resetRulesetStatuses, + resetProjectRuleStatuses, getChildRules, getTopLevelRules, getAllRulesForRuleset, @@ -234,6 +236,56 @@ export const useBulkToggleRuleTeam = () => { ); }; +/** + * Hook to reset every rule's general-view status back to Pending, for a whole ruleset. + */ +export const useResetRulesetStatuses = (rulesetId: string) => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation<{ count: number }, Error, void>( + ['rules', 'resetRulesetStatuses', rulesetId], + async () => { + const { data } = await resetRulesetStatuses(rulesetId); + return data; + }, + { + onSuccess: ({ count }) => { + queryClient.invalidateQueries(['rules']); + toast.success(`Reset ${count} rule status${count === 1 ? '' : 'es'} to Pending`); + }, + onError: (error: Error) => { + toast.error(error.message); + } + } + ); +}; + +/** + * Hook to reset every project rule's status back to Pending, for a single project scoped to a single ruleset. + */ +export const useResetProjectRuleStatuses = (rulesetId: string, projectId: string) => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation<{ count: number }, Error, void>( + ['rules', 'resetProjectRuleStatuses', rulesetId, projectId], + async () => { + const { data } = await resetProjectRuleStatuses(rulesetId, projectId); + return data; + }, + { + onSuccess: ({ count }) => { + queryClient.invalidateQueries(['rules']); + toast.success(`Reset ${count} rule status${count === 1 ? '' : 'es'} to Pending`); + }, + onError: (error: Error) => { + toast.error(error.message); + } + } + ); +}; + /** * Hook to create a new ruleset type. */ diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index 1c919d0426..7d279f1be9 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -25,6 +25,7 @@ import ErrorPage from '../../../ErrorPage'; import RuleRow from '../../../RulesPage/RuleRow'; import RuleContent from '../../../RulesPage/components/RuleContent'; import RuleStatusHistoryModal from '../../../RulesPage/components/RuleStatusHistoryModal'; +import ResetStatusesModal from '../../../RulesPage/components/ResetStatusesModal'; import { useRuleTreeNavigation } from '../../../RulesPage/useRuleTreeNavigation'; import UpdateStatusPopover from './UpdateStatusPopover'; import AddRuleModal from './AddProjectRuleModal'; @@ -33,7 +34,8 @@ import { useActiveRuleset, useProjectRules, useSetProjectRuleStatus, - useCreateProjectRule + useCreateProjectRule, + useResetProjectRuleStatuses } from '../../../../hooks/rules.hooks'; import { useToast } from '../../../../hooks/toasts.hooks'; import { InfoOutlined } from '@mui/icons-material'; @@ -58,6 +60,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { const [addRuleModalOpen, setAddRuleModalOpen] = useState(false); const [selectedProjectRule, setSelectedProjectRule] = useState(null); const [historyModalProjectRule, setHistoryModalProjectRule] = useState(null); + const [showResetModal, setShowResetModal] = useState(false); // Fetch all ruleset types const { data: rulesetTypes, isLoading: rulesetTypesLoading, isError: rulesetTypesError } = useAllRulesetTypes(); @@ -86,6 +89,11 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { const { mutateAsync: createProjectRuleMutation, isLoading: isCreating } = useCreateProjectRule(); + const { mutateAsync: resetProjectRuleStatuses, isLoading: isResetting } = useResetProjectRuleStatuses( + activeRuleset?.rulesetId || '', + project.id + ); + // First team's ID, used only to pre-select a team tab on the assign-rules deep link const teamId = project.teams[0]?.teamId || ''; const teamNames = project.teams.map((team) => team.teamName); @@ -171,6 +179,16 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { } }; + // Handle resetting all of this project's statuses (for the active ruleset) back to Pending + const handleResetStatuses = async () => { + await resetProjectRuleStatuses(); + setShowResetModal(false); + // close any open history modal or status popover since statuses are changing + setStatusPopoverAnchor(null); + setSelectedProjectRule(null); + setHistoryModalProjectRule(null); + }; + // Handle tab change const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { setSelectedRulesetTypeIndex(newValue); @@ -246,9 +264,14 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { ))} {activeRuleset && ( - - {areAllExpanded ? 'Collapse All' : 'Expand All'} - + + + {areAllExpanded ? 'Collapse All' : 'Expand All'} + + setShowResetModal(true)}> + Reset Status + + )} @@ -407,6 +430,16 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { /> )} + {/* Reset Statuses Modal, scoped to this project + active ruleset */} + {showResetModal && activeRuleset && ( + setShowResetModal(false)} + onReset={handleResetStatuses} + /> + )} + {/* Add Rule Modal */} {activeRuleset && ( { )} {/* Loading overlay */} - {(isUpdatingStatus || isCreating) && ( + {(isUpdatingStatus || isCreating || isResetting) && ( { const [tabIndex, setTabIndex] = useState(0); + const [showResetModal, setShowResetModal] = useState(false); + // bumped after a reset to force RulesetGeneralView to remount, clearing any open popover/history modal + const [resetNonce, setResetNonce] = useState(0); const tabs = [ { tabUrlValue: 'generalView', tabName: 'General View' }, { tabUrlValue: 'teamView', tabName: 'Team View' } @@ -23,6 +33,8 @@ const RulesetViewPage = () => { const { rulesetId } = useParams<{ rulesetId: string }>(); + const { mutateAsync: resetRulesetStatuses, isLoading: isResetting } = useResetRulesetStatuses(rulesetId!); + const { data: ruleset, isError: isRulesetError, @@ -114,6 +126,9 @@ const RulesetViewPage = () => { {areAllExpanded ? 'Collapse All' : 'Expand All'} + setShowResetModal(true)}> + Reset Status + )} {tabIndex === 1 && ( @@ -130,6 +145,7 @@ const RulesetViewPage = () => { {tabIndex === 0 ? ( { )} + + {showResetModal && ( + setShowResetModal(false)} + onReset={async () => { + await resetRulesetStatuses(); + setShowResetModal(false); + setResetNonce((n) => n + 1); + }} + /> + )} ); }; diff --git a/src/frontend/src/pages/RulesPage/components/ResetStatusesModal.tsx b/src/frontend/src/pages/RulesPage/components/ResetStatusesModal.tsx new file mode 100644 index 0000000000..d23ed894d4 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/ResetStatusesModal.tsx @@ -0,0 +1,34 @@ +/* + * 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 { Typography } from '@mui/material'; +import NERModal from '../../../components/NERModal'; + +interface ResetStatusesModalProps { + // e.g. "the Mock FSAE ruleset" or "the Bodywork project's Mock FSAE rules" + scopeDescription: string; + onReset: () => void; + onHide: () => void; + disabled?: boolean; +} + +const ResetStatusesModal: React.FC = ({ scopeDescription, onReset, onHide, disabled }) => { + return ( + + Are you sure you want to reset all rule statuses + to PENDING for {scopeDescription}? + + ); +}; + +export default ResetStatusesModal; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 47edfd41c0..448ff3275e 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -481,6 +481,9 @@ const rulesDeleteProjectRule = (projectRuleId: string) => `${rules()}/projectRul const rulesSetRuleStatus = (ruleId: string) => `${rules()}/rule/${ruleId}/setStatus`; const rulesSetProjectRuleStatus = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/setStatus`; const rulesGetStatusHistory = (ruleId: string) => `${rules()}/rule/${ruleId}/status-history`; +const rulesResetRulesetStatuses = (rulesetId: string) => `${rules()}/ruleset/${rulesetId}/resetStatuses`; +const rulesResetProjectRuleStatuses = (rulesetId: string, projectId: string) => + `${rules()}/ruleset/${rulesetId}/project/${projectId}/resetStatuses`; const rulesEdit = (ruleId: string) => `${rules()}/rule/${ruleId}/edit`; const rulesDelete = (ruleId: string) => `${rules()}/rule/${ruleId}/delete`; const rulesAddReferences = (ruleId: string) => `${rules()}/rule/${ruleId}/references/add`; @@ -911,6 +914,8 @@ export const apiUrls = { rulesSetRuleStatus, rulesSetProjectRuleStatus, rulesGetStatusHistory, + rulesResetRulesetStatuses, + rulesResetProjectRuleStatuses, rulesEdit, rulesDelete, rulesAddReferences, From 979f0b00257eea62518ce7ebf245f36fc35e9b38 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 10:20:18 -0400 Subject: [PATCH 07/10] #4370 new parent status calculation logic --- src/backend/src/services/rules.services.ts | 89 ++++++++++++++++++- .../ProjectRules/ProjectRulesTab.tsx | 2 +- .../RulesPage/components/RuleStatusTag.tsx | 19 ++-- .../components/RulesetGeneralView.tsx | 1 + src/frontend/src/utils/rules.utils.ts | 15 ---- 5 files changed, 95 insertions(+), 31 deletions(-) diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 18be179bee..83fbdf5fec 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -40,6 +40,67 @@ import { import { ParsedRule, parseRulesFromPdf } from '../utils/parse.utils.js'; import { uploadFile, downloadFile } from '../utils/google-integration.utils.js'; +/** Rolls up children's statuses: Fail > Pending > Pass. */ +const rollUpStatus = (childStatuses: RuleStatus[]): RuleStatus => { + // If any children FAIL, the parent fails. + if (childStatuses.includes(RuleStatus.FAIL)) return RuleStatus.FAIL; + // If no children FAIL, and any children are PENDING, the parent is PENDING. + if (childStatuses.includes(RuleStatus.PENDING)) return RuleStatus.PENDING; + // If all children PASS, the parent passes. + return RuleStatus.PASS; +}; + +/** Recomputes and saves ruleId's status, then repeats up the parent chain. */ +const recalculateRuleStatusChain = async (ruleId: string | null): Promise => { + if (!ruleId) return; + + const children = await prisma.rule.findMany({ + where: { parentRuleId: ruleId, dateDeleted: null }, + select: { status: true } + }); + + if (children.length === 0) return; + + const status = rollUpStatus(children.map((child) => child.status as RuleStatus)); + + const { parentRuleId } = await prisma.rule.update({ + where: { ruleId }, + data: { status }, + select: { parentRuleId: true } + }); + + await recalculateRuleStatusChain(parentRuleId); +}; + +/** Same as recalculateRuleStatusChain, scoped to one project's assigned rules; stops at an unassigned ancestor. */ +const recalculateProjectRuleStatusChain = async (projectId: string, ruleId: string | null): Promise => { + if (!ruleId) return; + + const projectRule = await prisma.project_Rule.findFirst({ + where: { projectId, ruleId, dateDeleted: null }, + select: { projectRuleId: true } + }); + + if (!projectRule) return; + + const children = await prisma.project_Rule.findMany({ + where: { projectId, dateDeleted: null, rule: { parentRuleId: ruleId } }, + select: { status: true } + }); + + if (children.length === 0) return; + + const status = rollUpStatus(children.map((child) => child.status as RuleStatus)); + + await prisma.project_Rule.update({ + where: { projectRuleId: projectRule.projectRuleId }, + data: { status } + }); + + const rule = await prisma.rule.findUnique({ where: { ruleId }, select: { parentRuleId: true } }); + await recalculateProjectRuleStatusChain(projectId, rule?.parentRuleId ?? null); +}; + export default class RulesService { /** * Gets the active ruleset for the given ruleset type ID and car @@ -228,6 +289,9 @@ export default class RulesService { ...getRulePreviewQueryArgs() }); + // a fresh child defaults to PENDING, which can flip the parent chain's statuses (e.g. PASS -> PENDING) + await recalculateRuleStatusChain(parentRuleId ?? null); + return ruleTransformer(rule); } @@ -330,6 +394,9 @@ export default class RulesService { await deleteParentChildReferencing(ruleId); }); + // this rule (and its descendants) are gone, so its old parent chain may update their statuses + await recalculateRuleStatusChain(rule.parentRuleId); + const deletedRule = await prisma.rule.findUnique({ where: { ruleId } }); @@ -456,6 +523,9 @@ export default class RulesService { await prisma.$transaction([...ancestorsToCreate.map(reviveOrCreate), reviveOrCreate(ruleId)]); + // new (or revived) project rule leaf defaults to PENDING so parent statuses need to be recomputed + await recalculateProjectRuleStatusChain(projectId, rule.parentRuleId); + // return only original project rule being assigned (leaf rule) const projectRule = await prisma.project_Rule.findUnique({ where: { ruleId_projectId: { ruleId, projectId } }, @@ -563,6 +633,12 @@ export default class RulesService { ...getRulePreviewQueryArgs() }); + // reparenting moves this rule out of the old parent chain and into the new one, so recompute statuses for both parent chains + if (parentRuleId && parentRuleId !== currentRule.parentRuleId) { + await recalculateRuleStatusChain(currentRule.parentRuleId); + await recalculateRuleStatusChain(parentRuleId); + } + return ruleTransformer(updatedRule); } @@ -843,6 +919,9 @@ export default class RulesService { ...getRulePreviewQueryArgs() }); + // updating a rules status may update the status of its parent chain, so recalculate the parent chain's statuses + await recalculateRuleStatusChain(rule.parentRuleId); + return ruleTransformer(updatedRule); } @@ -904,6 +983,9 @@ export default class RulesService { ...getProjectRuleQueryArgs() }); + // updating a project rule's status may update the status of its parent chain, so recalculate the parent chain's statuses + await recalculateProjectRuleStatusChain(projectRule.projectId, projectRule.rule.parentRuleId); + return projectRuleTransformer(updatedProjectRule); } @@ -1326,6 +1408,9 @@ export default class RulesService { ...getProjectRuleQueryArgs() }); + // this project rule no longer counts towards its parents' status calculations + await recalculateProjectRuleStatusChain(projectRule.projectId, projectRule.rule.parentRuleId); + return projectRuleTransformer(deletedProjectRule); } @@ -1618,7 +1703,7 @@ export default class RulesService { ...getRulePreviewQueryArgs() }); - return rules.map(ruleTransformer); + return rules.map((rule) => ruleTransformer(rule)); } /** @@ -1661,7 +1746,7 @@ export default class RulesService { ...getRulePreviewQueryArgs() }); - return rules.map(ruleTransformer); + return rules.map((rule) => ruleTransformer(rule)); } /** diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index 7d279f1be9..a899d94452 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -225,7 +225,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { return ( handleStatusClick(e, rule) : undefined} onInfoClick={handleInfoClick} diff --git a/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx b/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx index 21f0aca54a..e575848209 100644 --- a/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx +++ b/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx @@ -6,11 +6,12 @@ import { Box, IconButton, Tooltip } from '@mui/material'; import { InfoOutlined, KeyboardArrowRight, KeyboardArrowDown } from '@mui/icons-material'; import { Rule, formatTimestamp } from 'shared'; -import { getRuleStatusConfig, getRuleStatus } from '../../../utils/rules.utils'; +import { getRuleStatusConfig } from '../../../utils/rules.utils'; interface RuleStatusTagProps { rule: Rule; - allRules?: Rule[]; + // whether this rule is a leaf in the tree being displayed + isLeaf: boolean; // ability to update completion status onClick?: (event: React.MouseEvent) => void; // controls chevron direction when completion is interactive @@ -19,18 +20,10 @@ interface RuleStatusTagProps { onInfoClick?: (rule: Rule) => void; } -/** - * Status chip for a rule. - * A leaf shows its own status, while a parent's status rolls up from its descendant leaf - * rules (Fail > Pending > Pass). Leafs with a Pass/Fail status also show an info icon - - * a one-line "who marked it and when" tooltip by default, or (if onInfoClick is given) a - * button that opens the full status-history modal instead. - */ -const RuleStatusTag: React.FC = ({ rule, allRules, onClick, popoverOpen = false, onInfoClick }) => { - const status = getRuleStatus(rule, allRules); - const { label, color } = getRuleStatusConfig(status); +/** Status chip for a rule. Leafs with a Pass/Fail status also show an info icon/tooltip. */ +const RuleStatusTag: React.FC = ({ rule, isLeaf, onClick, popoverOpen = false, onInfoClick }) => { + const { label, color } = getRuleStatusConfig(rule.status); - const isLeaf = allRules ? !allRules.some((r) => r.parentRule?.ruleId === rule.ruleId) : rule.subRuleIds.length === 0; const statusUpdatedByName = rule.statusUpdatedBy && `${rule.statusUpdatedBy.firstName} ${rule.statusUpdatedBy.lastName}`; const statusMessage = statusUpdatedByName && rule.statusUpdatedAt diff --git a/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx index 0833eb9aba..ddbb38ce78 100644 --- a/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx +++ b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx @@ -77,6 +77,7 @@ const RulesetGeneralView: React.FC = ({ rightContent={(r) => ( { setSelectedRule(r); diff --git a/src/frontend/src/utils/rules.utils.ts b/src/frontend/src/utils/rules.utils.ts index f859c64a72..633b63c082 100644 --- a/src/frontend/src/utils/rules.utils.ts +++ b/src/frontend/src/utils/rules.utils.ts @@ -37,21 +37,6 @@ export const getDescendantLeafRules = (rule: Rule, allRules: Rule[] = []): Rule[ return children.flatMap((child) => getDescendantLeafRules(child, allRules)); }; -/** - * A rule's status, rolled up from its descendant leaves. A leaf uses its own status; a parent - * rolls up with priority Fail > Pending > Pass: Fail if any leaf has failed, else Pending if any - * leaf is still pending, else Pass only once every leaf has passed. - * @param rule - The rule to check - * @param allRules - All rules in scope - * @returns The status of the rule (or its status computed by its leaves) - */ -export const getRuleStatus = (rule: Rule, allRules: Rule[] = []): RuleStatus => { - const leafRules = getDescendantLeafRules(rule, allRules); - if (leafRules.some((leafRule) => leafRule.status === RuleStatus.FAIL)) return RuleStatus.FAIL; - if (leafRules.some((leafRule) => leafRule.status === RuleStatus.PENDING)) return RuleStatus.PENDING; - return RuleStatus.PASS; -}; - /** * Status chip label and color for a rule status. */ From f3d7a74c693ed71afe6330b8a7039a876bb27a96 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 10:39:37 -0400 Subject: [PATCH 08/10] #4370 prettier and test fixes --- .../src/controllers/rules.controllers.ts | 4 - src/backend/src/services/rules.services.ts | 152 ++++++++++-------- src/backend/tests/unit/rule.test.ts | 26 ++- .../components/RulesetGeneralView.tsx | 4 +- 4 files changed, 106 insertions(+), 80 deletions(-) diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts index 0492e99c26..9ebe903e29 100644 --- a/src/backend/src/controllers/rules.controllers.ts +++ b/src/backend/src/controllers/rules.controllers.ts @@ -213,9 +213,7 @@ export default class RulesController { static async resetRulesetStatuses(req: Request, res: Response, next: NextFunction) { try { const { rulesetId } = req.params as Record; - const count = await RulesService.resetRulesetStatuses(req.currentUser, req.organization, rulesetId); - res.status(200).json({ count }); } catch (error: unknown) { next(error); @@ -225,9 +223,7 @@ export default class RulesController { static async resetProjectRuleStatuses(req: Request, res: Response, next: NextFunction) { try { const { rulesetId, projectId } = req.params as Record; - const count = await RulesService.resetProjectRuleStatuses(req.currentUser, req.organization, rulesetId, projectId); - res.status(200).json({ count }); } catch (error: unknown) { next(error); diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 83fbdf5fec..4f55cfbd7d 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -40,67 +40,6 @@ import { import { ParsedRule, parseRulesFromPdf } from '../utils/parse.utils.js'; import { uploadFile, downloadFile } from '../utils/google-integration.utils.js'; -/** Rolls up children's statuses: Fail > Pending > Pass. */ -const rollUpStatus = (childStatuses: RuleStatus[]): RuleStatus => { - // If any children FAIL, the parent fails. - if (childStatuses.includes(RuleStatus.FAIL)) return RuleStatus.FAIL; - // If no children FAIL, and any children are PENDING, the parent is PENDING. - if (childStatuses.includes(RuleStatus.PENDING)) return RuleStatus.PENDING; - // If all children PASS, the parent passes. - return RuleStatus.PASS; -}; - -/** Recomputes and saves ruleId's status, then repeats up the parent chain. */ -const recalculateRuleStatusChain = async (ruleId: string | null): Promise => { - if (!ruleId) return; - - const children = await prisma.rule.findMany({ - where: { parentRuleId: ruleId, dateDeleted: null }, - select: { status: true } - }); - - if (children.length === 0) return; - - const status = rollUpStatus(children.map((child) => child.status as RuleStatus)); - - const { parentRuleId } = await prisma.rule.update({ - where: { ruleId }, - data: { status }, - select: { parentRuleId: true } - }); - - await recalculateRuleStatusChain(parentRuleId); -}; - -/** Same as recalculateRuleStatusChain, scoped to one project's assigned rules; stops at an unassigned ancestor. */ -const recalculateProjectRuleStatusChain = async (projectId: string, ruleId: string | null): Promise => { - if (!ruleId) return; - - const projectRule = await prisma.project_Rule.findFirst({ - where: { projectId, ruleId, dateDeleted: null }, - select: { projectRuleId: true } - }); - - if (!projectRule) return; - - const children = await prisma.project_Rule.findMany({ - where: { projectId, dateDeleted: null, rule: { parentRuleId: ruleId } }, - select: { status: true } - }); - - if (children.length === 0) return; - - const status = rollUpStatus(children.map((child) => child.status as RuleStatus)); - - await prisma.project_Rule.update({ - where: { projectRuleId: projectRule.projectRuleId }, - data: { status } - }); - - const rule = await prisma.rule.findUnique({ where: { ruleId }, select: { parentRuleId: true } }); - await recalculateProjectRuleStatusChain(projectId, rule?.parentRuleId ?? null); -}; - export default class RulesService { /** * Gets the active ruleset for the given ruleset type ID and car @@ -177,6 +116,81 @@ export default class RulesService { } } + /** + * Compute a rule's status given the statuses of its children. + * @param childStatuses the statuses of the child rules + * @returns status of the parent rule based on its children's statuses + */ + private static computeRolledUpStatus(childStatuses: RuleStatus[]): RuleStatus { + // If any children FAIL, the parent fails. + if (childStatuses.includes(RuleStatus.FAIL)) return RuleStatus.FAIL; + // If no children FAIL, and any children are PENDING, the parent is PENDING. + if (childStatuses.includes(RuleStatus.PENDING)) return RuleStatus.PENDING; + // If all children PASS, the parent passes. + return RuleStatus.PASS; + } + + /** + * Recomputes and saves ruleId's status, then repeats up the parent chain. + * @param ruleId the rule to recompute the status for + * @returns the updated status of the rule + */ + private static async recalculateRuleStatusChain(ruleId: string | null): Promise { + if (!ruleId) return; + + const children = await prisma.rule.findMany({ + where: { parentRuleId: ruleId, dateDeleted: null }, + select: { status: true } + }); + + if (children.length === 0) return; + + const status = RulesService.computeRolledUpStatus(children.map((child) => child.status as RuleStatus)); + + const { parentRuleId } = await prisma.rule.update({ + where: { ruleId }, + data: { status }, + select: { parentRuleId: true } + }); + + await RulesService.recalculateRuleStatusChain(parentRuleId); + } + + /** + * Same as recalculateRuleStatusChain, but scoped to one project's assigned rules. + * Recomputes and saves the status of the project rule for the given ruleId, then repeats up the parent chain. + * @param projectId the project to scope the status recomputation to + * @param ruleId the rule to recompute the status for + * @returns the updated status of the project rule + */ + private static async recalculateProjectRuleStatusChain(projectId: string, ruleId: string | null): Promise { + if (!ruleId) return; + + const projectRule = await prisma.project_Rule.findFirst({ + where: { projectId, ruleId, dateDeleted: null }, + select: { projectRuleId: true } + }); + + if (!projectRule) return; + + const children = await prisma.project_Rule.findMany({ + where: { projectId, dateDeleted: null, rule: { parentRuleId: ruleId } }, + select: { status: true } + }); + + if (children.length === 0) return; + + const status = RulesService.computeRolledUpStatus(children.map((child) => child.status as RuleStatus)); + + await prisma.project_Rule.update({ + where: { projectRuleId: projectRule.projectRuleId }, + data: { status } + }); + + const rule = await prisma.rule.findUnique({ where: { ruleId }, select: { parentRuleId: true } }); + await RulesService.recalculateProjectRuleStatusChain(projectId, rule?.parentRuleId ?? null); + } + /** * Creates a new rule in the database * @@ -290,7 +304,7 @@ export default class RulesService { }); // a fresh child defaults to PENDING, which can flip the parent chain's statuses (e.g. PASS -> PENDING) - await recalculateRuleStatusChain(parentRuleId ?? null); + await RulesService.recalculateRuleStatusChain(parentRuleId ?? null); return ruleTransformer(rule); } @@ -395,7 +409,7 @@ export default class RulesService { }); // this rule (and its descendants) are gone, so its old parent chain may update their statuses - await recalculateRuleStatusChain(rule.parentRuleId); + await RulesService.recalculateRuleStatusChain(rule.parentRuleId); const deletedRule = await prisma.rule.findUnique({ where: { ruleId } @@ -524,7 +538,7 @@ export default class RulesService { await prisma.$transaction([...ancestorsToCreate.map(reviveOrCreate), reviveOrCreate(ruleId)]); // new (or revived) project rule leaf defaults to PENDING so parent statuses need to be recomputed - await recalculateProjectRuleStatusChain(projectId, rule.parentRuleId); + await RulesService.recalculateProjectRuleStatusChain(projectId, rule.parentRuleId); // return only original project rule being assigned (leaf rule) const projectRule = await prisma.project_Rule.findUnique({ @@ -635,8 +649,8 @@ export default class RulesService { // reparenting moves this rule out of the old parent chain and into the new one, so recompute statuses for both parent chains if (parentRuleId && parentRuleId !== currentRule.parentRuleId) { - await recalculateRuleStatusChain(currentRule.parentRuleId); - await recalculateRuleStatusChain(parentRuleId); + await RulesService.recalculateRuleStatusChain(currentRule.parentRuleId); + await RulesService.recalculateRuleStatusChain(parentRuleId); } return ruleTransformer(updatedRule); @@ -920,7 +934,7 @@ export default class RulesService { }); // updating a rules status may update the status of its parent chain, so recalculate the parent chain's statuses - await recalculateRuleStatusChain(rule.parentRuleId); + await RulesService.recalculateRuleStatusChain(rule.parentRuleId); return ruleTransformer(updatedRule); } @@ -984,7 +998,7 @@ export default class RulesService { }); // updating a project rule's status may update the status of its parent chain, so recalculate the parent chain's statuses - await recalculateProjectRuleStatusChain(projectRule.projectId, projectRule.rule.parentRuleId); + await RulesService.recalculateProjectRuleStatusChain(projectRule.projectId, projectRule.rule.parentRuleId); return projectRuleTransformer(updatedProjectRule); } @@ -1409,7 +1423,7 @@ export default class RulesService { }); // this project rule no longer counts towards its parents' status calculations - await recalculateProjectRuleStatusChain(projectRule.projectId, projectRule.rule.parentRuleId); + await RulesService.recalculateProjectRuleStatusChain(projectRule.projectId, projectRule.rule.parentRuleId); return projectRuleTransformer(deletedProjectRule); } diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index fe2147ddad..d58c7d88ad 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -1241,6 +1241,7 @@ describe('Rule Tests', () => { it('Marks a rule Pass within a project and records who updated it', async () => { const car = await createUniqueCar(orgId); const { topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); @@ -1261,6 +1262,7 @@ describe('Rule Tests', () => { it('Set project rule status fails if user does not have permission', async () => { const car = await createUniqueCar(orgId); const { topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); @@ -1274,9 +1276,15 @@ describe('Rule Tests', () => { const car = await createUniqueCar(orgId); const { topLevelRule, ruleset1 } = await setupRules(car); await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); - const project2 = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber, 1); + const project2 = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber, 2); const projectRule1 = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); - const projectRule2 = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project2.projectId); + const projectRule2 = await RulesService.createProjectRule( + admin, + organization, + topLevelRule.ruleId, + project2.projectId + ); await RulesService.setProjectRuleStatus(admin, organization, projectRule1.projectRuleId, RuleStatus.PASS); await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.FAIL); @@ -1424,8 +1432,18 @@ describe('Rule Tests', () => { await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); const project1 = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber, 1); const project2 = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber, 2); - const projectRule1 = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project1.projectId); - const projectRule2 = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project2.projectId); + const projectRule1 = await RulesService.createProjectRule( + admin, + organization, + topLevelRule.ruleId, + project1.projectId + ); + const projectRule2 = await RulesService.createProjectRule( + admin, + organization, + topLevelRule.ruleId, + project2.projectId + ); await RulesService.setProjectRuleStatus(admin, organization, projectRule1.projectRuleId, RuleStatus.PASS); await RulesService.setProjectRuleStatus(admin, organization, projectRule2.projectRuleId, RuleStatus.PASS); diff --git a/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx index ddbb38ce78..0552aa8aff 100644 --- a/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx +++ b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx @@ -108,9 +108,7 @@ const RulesetGeneralView: React.FC = ({ /> )} - {historyModalRule && ( - setHistoryModalRule(null)} rule={historyModalRule} /> - )} + {historyModalRule && setHistoryModalRule(null)} rule={historyModalRule} />} ); }; From 9de41f08f02abac3732459ccf4025a14b39a6307 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Fri, 21 Aug 2026 15:10:01 -0400 Subject: [PATCH 09/10] #4370 status logic updates + tests --- src/backend/src/services/rules.services.ts | 17 +- src/backend/tests/unit/rule.test.ts | 149 +++++++++++++++--- .../components/RuleStatusHistoryModal.tsx | 15 +- 3 files changed, 149 insertions(+), 32 deletions(-) diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 4f55cfbd7d..5fb6045688 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -143,9 +143,12 @@ export default class RulesService { select: { status: true } }); - if (children.length === 0) return; - - const status = RulesService.computeRolledUpStatus(children.map((child) => child.status as RuleStatus)); + // a rule with no remaining children has no rolled-up status to derive; reset it to Pending + // and keep walking up so its own parent's rollup reflects the change + const status = + children.length === 0 + ? RuleStatus.PENDING + : RulesService.computeRolledUpStatus(children.map((child) => child.status as RuleStatus)); const { parentRuleId } = await prisma.rule.update({ where: { ruleId }, @@ -917,6 +920,14 @@ export default class RulesService { throw new InvalidOrganizationException('Rule'); } + const childRuleCount = await prisma.rule.count({ + where: { parentRuleId: ruleId, dateDeleted: null } + }); + + if (childRuleCount > 0) { + throw new HttpException(400, 'Only child rule statuses can be updated directly.'); + } + // only PASS/FAIL are tracked in history; PENDING does not create an entry if (status !== RuleStatus.PENDING) { await prisma.rule_Status_History.create({ diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index d58c7d88ad..0357361992 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -1205,11 +1205,11 @@ describe('Rule Tests', () => { // Setting Rule Status (general view) it('Marks a rule Pass in the general view and records who updated it', async () => { const car = await createUniqueCar(orgId); - const { topLevelRule } = await setupRules(car); + const { leafRule1 } = await setupRules(car); - const updatedRule = await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.PASS); + const updatedRule = await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.PASS); - expect(updatedRule.ruleId).toBe(topLevelRule.ruleId); + expect(updatedRule.ruleId).toBe(leafRule1.ruleId); expect(updatedRule.status).toBe(RuleStatus.PASS); expect(updatedRule.statusUpdatedBy?.firstName).toBe(admin.firstName); expect(updatedRule.statusUpdatedBy?.lastName).toBe(admin.lastName); @@ -1218,10 +1218,10 @@ describe('Rule Tests', () => { it('Marks a rule back to Pending in the general view and clears who/when', async () => { const car = await createUniqueCar(orgId); - const { topLevelRule } = await setupRules(car); + const { leafRule1 } = await setupRules(car); - await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.FAIL); - const updatedRule = await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.PENDING); + await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.FAIL); + const updatedRule = await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.PENDING); expect(updatedRule.status).toBe(RuleStatus.PENDING); expect(updatedRule.statusUpdatedBy).toBeUndefined(); @@ -1230,13 +1230,22 @@ describe('Rule Tests', () => { it('Set rule status fails if user does not have permission', async () => { const car = await createUniqueCar(orgId); - const { topLevelRule } = await setupRules(car); + const { leafRule1 } = await setupRules(car); await expect( - async () => await RulesService.setRuleStatus(nonLeadership, organization, topLevelRule.ruleId, RuleStatus.PASS) + async () => await RulesService.setRuleStatus(nonLeadership, organization, leafRule1.ruleId, RuleStatus.PASS) ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule status')); }); + it('Set rule status fails if the rule has sub-rules', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + + await expect( + async () => await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.PASS) + ).rejects.toThrow(new HttpException(400, 'Only child rule statuses can be updated directly.')); + }); + // Setting Project Rule Status (per-project view) it('Marks a rule Pass within a project and records who updated it', async () => { const car = await createUniqueCar(orgId); @@ -1274,20 +1283,16 @@ describe('Rule Tests', () => { it('A rule status in one project is independent of its general-view status and its status in other projects', async () => { const car = await createUniqueCar(orgId); - const { topLevelRule, ruleset1 } = await setupRules(car); + const { topLevelRule, leafRule1, ruleset1 } = await setupRules(car); await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule1.ruleId, testTeam.teamId, admin, organization); const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber, 1); const project2 = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber, 2); - const projectRule1 = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); - const projectRule2 = await RulesService.createProjectRule( - admin, - organization, - topLevelRule.ruleId, - project2.projectId - ); + const projectRule1 = await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + const projectRule2 = await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project2.projectId); await RulesService.setProjectRuleStatus(admin, organization, projectRule1.projectRuleId, RuleStatus.PASS); - await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.FAIL); + await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.FAIL); const projectRules2 = await RulesService.getProjectRules(ruleset1.rulesetId, project2.projectId, organization); const rule2Entry = projectRules2.find((pr) => pr.projectRuleId === projectRule2.projectRuleId); @@ -1295,6 +1300,105 @@ describe('Rule Tests', () => { expect(rule2Entry?.status).toBe(RuleStatus.PENDING); expect(rule2Entry?.rule.status).toBe(RuleStatus.FAIL); }); + + it('Deleting a rule leaves its parent chain stale once the parent becomes childless', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + const grandparentRule = await RulesService.createRule( + admin, + 'G', + 'Grandparent Rule', + ruleset1.rulesetId, + organization + ); + const parentRule = await RulesService.createRule( + admin, + 'G.1', + 'Parent Rule', + ruleset1.rulesetId, + organization, + grandparentRule.ruleId + ); + const childRule = await RulesService.createRule( + admin, + 'G.1.1', + 'Child Rule', + ruleset1.rulesetId, + organization, + parentRule.ruleId + ); + + // childRule is parentRule's only child, and parentRule is grandparentRule's only child, + // so FAIL rolls all the way up the chain + await RulesService.setRuleStatus(admin, organization, childRule.ruleId, RuleStatus.FAIL); + + const rulesBeforeDelete = await RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId); + expect(rulesBeforeDelete.find((r) => r.ruleId === parentRule.ruleId)?.status).toBe(RuleStatus.FAIL); + expect(rulesBeforeDelete.find((r) => r.ruleId === grandparentRule.ruleId)?.status).toBe(RuleStatus.FAIL); + + // deleting parentRule's last remaining child leaves parentRule childless; its rolled-up + // status should reset to Pending, and that change should keep propagating up to grandparentRule + await RulesService.deleteRule(childRule.ruleId, admin, organization); + + const rulesAfterDelete = await RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId); + const updatedParent = rulesAfterDelete.find((r) => r.ruleId === parentRule.ruleId); + const updatedGrandparent = rulesAfterDelete.find((r) => r.ruleId === grandparentRule.ruleId); + + expect(updatedParent?.status).toBe(RuleStatus.PENDING); + expect(updatedGrandparent?.status).toBe(RuleStatus.PENDING); + }); + + it('A rule that gains then loses a child returns to Pending without extra status history entries', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + const rule = await RulesService.createRule( + admin, + 'H', + 'Rule that will gain a child', + ruleset1.rulesetId, + organization + ); + + // rule starts as a leaf, so it can be marked FAIL directly; this is the only direct write + await RulesService.setRuleStatus(admin, organization, rule.ruleId, RuleStatus.FAIL); + + // giving it a child rolls rule's status to Pending, since the new child defaults to Pending + const childRule = await RulesService.createRule( + admin, + 'H.1', + 'New child rule', + ruleset1.rulesetId, + organization, + rule.ruleId + ); + + // marking the child Pass rolls parent rule up to Pass too + await RulesService.setRuleStatus(admin, organization, childRule.ruleId, RuleStatus.PASS); + const rules = await RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId); + const parentRule = rules.find((r) => r.ruleId === rule.ruleId); + expect(parentRule!.status).toBe(RuleStatus.PASS); + + // rule still has history from its FAIL write back when it was a leaf + // the frontend relies on subRuleIds being non-empty (i.e. isLeaf being false) + // to hide the status-history tooltip for a rule that is currently a parent + expect(parentRule!.hasStatusHistory).toBe(true); + expect(parentRule!.subRuleIds.length).toBeGreaterThan(0); + + // deleting the only child makes rule a leaf again, so it should reset to Pending + await RulesService.deleteRule(childRule.ruleId, admin, organization); + + const allRules = await RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId); + const updatedRule = allRules.find((r) => r.ruleId === rule.ruleId); + + expect(updatedRule?.status).toBe(RuleStatus.PENDING); + + // the Pending -> Pass -> Pending transitions caused by gaining and losing a child are + // rollups, not direct writes, so only the original direct FAIL write is recorded + const historyCount = await prisma.rule_Status_History.count({ where: { ruleId: rule.ruleId } }); + expect(historyCount).toBe(1); + }); }); describe('Reset ruleset statuses', () => { @@ -1302,7 +1406,8 @@ describe('Rule Tests', () => { const car = await createUniqueCar(orgId); const { ruleset1, topLevelRule, leafRule1 } = await setupRules(car); - await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.PASS); + // topLevelRule has sub-rules, so its status can't be set directly; setting leafRule1 + // rolls up to also mark topLevelRule non-Pending await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.FAIL); const count = await RulesService.resetRulesetStatuses(admin, organization, ruleset1.rulesetId); @@ -1321,9 +1426,9 @@ describe('Rule Tests', () => { it('Reset does not create Rule_Status_History entries', async () => { const car = await createUniqueCar(orgId); - const { ruleset1, topLevelRule } = await setupRules(car); + const { ruleset1, leafRule1 } = await setupRules(car); - await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.PASS); + await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.PASS); const countBefore = await prisma.rule_Status_History.count(); await RulesService.resetRulesetStatuses(admin, organization, ruleset1.rulesetId); @@ -1343,7 +1448,7 @@ describe('Rule Tests', () => { it('Reset status only affects the given ruleset', async () => { const car = await createUniqueCar(orgId); - const { ruleset1, ruleset2, topLevelRule } = await setupRules(car); + const { ruleset1, ruleset2, leafRule1 } = await setupRules(car); const otherRule = await prisma.rule.create({ data: { @@ -1354,7 +1459,7 @@ describe('Rule Tests', () => { } }); - await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.PASS); + await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.PASS); await RulesService.setRuleStatus(admin, organization, otherRule.ruleId, RuleStatus.PASS); await RulesService.resetRulesetStatuses(admin, organization, ruleset1.rulesetId); diff --git a/src/frontend/src/pages/RulesPage/components/RuleStatusHistoryModal.tsx b/src/frontend/src/pages/RulesPage/components/RuleStatusHistoryModal.tsx index b2e8690180..54829c2a98 100644 --- a/src/frontend/src/pages/RulesPage/components/RuleStatusHistoryModal.tsx +++ b/src/frontend/src/pages/RulesPage/components/RuleStatusHistoryModal.tsx @@ -3,9 +3,11 @@ * See the LICENSE file in the repository root folder for details. */ -import { Box, CircularProgress, Typography } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import { Rule, formatTimestamp } from 'shared'; import NERModal from '../../../components/NERModal'; +import ErrorPage from '../../ErrorPage'; +import LoadingIndicator from '../../../components/LoadingIndicator'; import { getRuleStatusConfig } from '../../../utils/rules.utils'; import { useRuleStatusHistory } from '../../../hooks/rules.hooks'; @@ -22,15 +24,14 @@ interface RuleStatusHistoryModalProps { * Reverting to PENDING doesn't add an entry. */ const RuleStatusHistoryModal: React.FC = ({ open, onClose, rule, projectRuleId }) => { - const { data: entries, isLoading } = useRuleStatusHistory(rule.ruleId, open, projectRuleId); + const { data: entries, isLoading, isError, error } = useRuleStatusHistory(rule.ruleId, open, projectRuleId); + + if (isLoading) return ; + if (isError) return ; return ( - {isLoading ? ( - - - - ) : !entries || entries.length === 0 ? ( + {!entries || entries.length === 0 ? ( No status history yet. ) : ( From 5cbad8e03c178654944274703e372eb6e0a77878 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sun, 23 Aug 2026 13:40:48 -0400 Subject: [PATCH 10/10] #4370 backend checks for updating status --- src/backend/src/services/rules.services.ts | 17 ++++- src/backend/tests/unit/rule.test.ts | 86 ++++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 5fb6045688..66af0a2dc5 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -181,9 +181,12 @@ export default class RulesService { select: { status: true } }); - if (children.length === 0) return; - - const status = RulesService.computeRolledUpStatus(children.map((child) => child.status as RuleStatus)); + // a project rule with no remaining children has no rolled-up status to derive; reset it to Pending + // and keep walking up so its own parent's rollup reflects the change + const status = + children.length === 0 + ? RuleStatus.PENDING + : RulesService.computeRolledUpStatus(children.map((child) => child.status as RuleStatus)); await prisma.project_Rule.update({ where: { projectRuleId: projectRule.projectRuleId }, @@ -992,6 +995,14 @@ export default class RulesService { throw new InvalidOrganizationException('Project Rule'); } + const childProjectRuleCount = await prisma.project_Rule.count({ + where: { projectId: projectRule.projectId, dateDeleted: null, rule: { parentRuleId: projectRule.ruleId } } + }); + + if (childProjectRuleCount > 0) { + throw new HttpException(400, 'Only child rule statuses can be updated directly.'); + } + // only PASS/FAIL are tracked in history; PENDING does not create an entry if (status !== RuleStatus.PENDING) { await prisma.rule_Status_History.create({ diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index 0357361992..6a827b3d29 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -1281,6 +1281,24 @@ describe('Rule Tests', () => { ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule status')); }); + it('Set project rule status fails if the rule has sub-rules assigned to the project', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule, leafRule1 } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule1.ruleId, testTeam.teamId, admin, organization); + // creating leafRule1's project rule also assigns topLevelRule as its ancestor + await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + const topLevelProjectRule = await prisma.project_Rule.findUniqueOrThrow({ + where: { ruleId_projectId: { ruleId: topLevelRule.ruleId, projectId: project.projectId } } + }); + + await expect( + async () => + await RulesService.setProjectRuleStatus(admin, organization, topLevelProjectRule.projectRuleId, RuleStatus.PASS) + ).rejects.toThrow(new HttpException(400, 'Only child rule statuses can be updated directly.')); + }); + it('A rule status in one project is independent of its general-view status and its status in other projects', async () => { const car = await createUniqueCar(orgId); const { topLevelRule, leafRule1, ruleset1 } = await setupRules(car); @@ -1399,6 +1417,74 @@ describe('Rule Tests', () => { const historyCount = await prisma.rule_Status_History.count({ where: { ruleId: rule.ruleId } }); expect(historyCount).toBe(1); }); + + it('Deleting a project rule resets its parent chain to Pending once the parent becomes childless in the project', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + const grandparentRule = await RulesService.createRule( + admin, + 'G', + 'Grandparent Rule', + ruleset1.rulesetId, + organization + ); + const parentRule = await RulesService.createRule( + admin, + 'G.1', + 'Parent Rule', + ruleset1.rulesetId, + organization, + grandparentRule.ruleId + ); + const childRule = await RulesService.createRule( + admin, + 'G.1.1', + 'Child Rule', + ruleset1.rulesetId, + organization, + parentRule.ruleId + ); + await RulesService.toggleRuleTeam(grandparentRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(parentRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(childRule.ruleId, testTeam.teamId, admin, organization); + + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + // creating childRule's project rule also assigns parentRule and grandparentRule as ancestors + const childProjectRule = await RulesService.createProjectRule( + admin, + organization, + childRule.ruleId, + project.projectId + ); + + // childRule is parentRule's only child assigned to the project, and parentRule is grandparentRule's + // only child assigned to the project, so FAIL rolls all the way up the chain + await RulesService.setProjectRuleStatus(admin, organization, childProjectRule.projectRuleId, RuleStatus.FAIL); + + const projectRulesBeforeDelete = await RulesService.getProjectRules( + ruleset1.rulesetId, + project.projectId, + organization + ); + expect(projectRulesBeforeDelete.find((pr) => pr.rule.ruleId === parentRule.ruleId)?.status).toBe(RuleStatus.FAIL); + expect(projectRulesBeforeDelete.find((pr) => pr.rule.ruleId === grandparentRule.ruleId)?.status).toBe(RuleStatus.FAIL); + + // deleting childRule's project rule leaves parentRule's project rule childless (in this project); its + // rolled-up status should reset to Pending, and that change should keep propagating up to grandparentRule + await RulesService.deleteProjectRule(childProjectRule.projectRuleId, admin, organization); + + const projectRulesAfterDelete = await RulesService.getProjectRules( + ruleset1.rulesetId, + project.projectId, + organization + ); + const updatedParent = projectRulesAfterDelete.find((pr) => pr.rule.ruleId === parentRule.ruleId); + const updatedGrandparent = projectRulesAfterDelete.find((pr) => pr.rule.ruleId === grandparentRule.ruleId); + + expect(updatedParent?.status).toBe(RuleStatus.PENDING); + expect(updatedGrandparent?.status).toBe(RuleStatus.PENDING); + }); }); describe('Reset ruleset statuses', () => {