diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts index f4d231343f..9ebe903e29 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, RuleStatusHistoryEntry } from 'shared'; import { HttpException } from '../utils/errors.utils.js'; export default class RulesController { @@ -179,20 +179,70 @@ 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, + projectRuleId, + status + ); + + res.status(200).json(projectRule); + } catch (error: unknown) { + next(error); + } + } + + 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; + const { projectRuleId } = req.query as { projectRuleId?: string }; + + const history: RuleStatusHistoryEntry[] = await RulesService.getRuleStatusHistory( req.currentUser, req.organization, ruleId, - isComplete, - projectId + projectRuleId ); - res.status(200).json(rule); + res.status(200).json(history); } 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 32dfb65f55..ab43154b19 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, @@ -46,23 +55,23 @@ export const getRulePreviewQueryArgs = () => } } } + }, + _count: { + select: { + statusHistory: true + } } } }, - completedBy: { + statusUpdatedBy: { select: { firstName: true, lastName: true } }, - completedInProject: { + _count: { select: { - projectId: true, - wbsElement: { - select: { - name: true - } - } + statusHistory: true } } } @@ -73,7 +82,45 @@ export type ProjectRuleQueryArgs = ReturnType; export const getProjectRuleQueryArgs = () => Prisma.validator()({ include: { - rule: getRulePreviewQueryArgs() + rule: getRulePreviewQueryArgs(), + statusUpdatedBy: { + select: { + 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 572123879b..bd8378db93 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; @@ -161,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 65f2c39160..3c6f684158 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,8 @@ 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") + ruleStatusHistoryUpdates Rule_Status_History[] @relation(name: "ruleStatusHistoryUpdater") deletedGuestDefinitions Guest_Definition[] @relation(name: "guestDefinitionDeleter") createdGuestDefinitions Guest_Definition[] @relation(name: "guestDefinitionCreator") requiredEvents Event[] @relation(name: "requiredEventAttendee") @@ -542,7 +550,6 @@ model Project { abbreviation String? parts Part[] rules Project_Rule[] @relation(name: "projectsForRule") - completedRules Rule[] @relation(name: "ruleCompletedInProject") @@index([carId]) } @@ -1809,54 +1816,74 @@ 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? + statusHistory Rule_Status_History[] + 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? + statusHistory Rule_Status_History[] + 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]) } +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/prisma/seed-data/rules.seed.ts b/src/backend/src/prisma/seed-data/rules.seed.ts index 3decdfec09..829329ccde 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 => { @@ -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: @@ -776,9 +776,16 @@ 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); + + // 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({ diff --git a/src/backend/src/routes/rules.routes.ts b/src/backend/src/routes/rules.routes.ts index e090a2c8ae..fcdaae3b9a 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(); @@ -67,12 +67,25 @@ 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.get( + '/rule/:ruleId/status-history', + query('projectRuleId').optional().isString(), + 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 caf52d2167..66af0a2dc5 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -8,7 +8,9 @@ import { User, Rule as SharedRule, isHead, - Ruleset + Ruleset, + RuleStatus, + RuleStatusHistoryEntry } from 'shared'; import prisma from '../prisma/prisma.js'; import { @@ -25,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'; @@ -112,6 +116,87 @@ 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 } + }); + + // 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 }, + 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 } + }); + + // 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 }, + 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 * @@ -224,6 +309,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 RulesService.recalculateRuleStatusChain(parentRuleId ?? null); + return ruleTransformer(rule); } @@ -326,6 +414,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 RulesService.recalculateRuleStatusChain(rule.parentRuleId); + const deletedRule = await prisma.rule.findUnique({ where: { ruleId } }); @@ -452,6 +543,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 RulesService.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 } }, @@ -559,6 +653,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 RulesService.recalculateRuleStatusChain(currentRule.parentRuleId); + await RulesService.recalculateRuleStatusChain(parentRuleId); + } + return ruleTransformer(updatedRule); } @@ -788,24 +888,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({ @@ -825,17 +923,251 @@ 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({ + data: { ruleId, status, updatedByUserId: submitter.userId } + }); + } + 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() }); + // updating a rules status may update the status of its parent chain, so recalculate the parent chain's statuses + await RulesService.recalculateRuleStatusChain(rule.parentRuleId); + 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 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({ + data: { ruleId: projectRule.ruleId, projectRuleId, status, updatedByUserId: submitter.userId } + }); + } + + 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() + }); + + // updating a project rule's status may update the status of its parent chain, so recalculate the parent chain's statuses + await RulesService.recalculateProjectRuleStatusChain(projectRule.projectId, projectRule.rule.parentRuleId); + + return projectRuleTransformer(updatedProjectRule); + } + + /** + * 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 + * @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. @@ -1112,6 +1444,9 @@ export default class RulesService { ...getProjectRuleQueryArgs() }); + // this project rule no longer counts towards its parents' status calculations + await RulesService.recalculateProjectRuleStatusChain(projectRule.projectId, projectRule.rule.parentRuleId); + return projectRuleTransformer(deletedProjectRule); } @@ -1404,7 +1739,7 @@ export default class RulesService { ...getRulePreviewQueryArgs() }); - return rules.map(ruleTransformer); + return rules.map((rule) => ruleTransformer(rule)); } /** @@ -1447,7 +1782,7 @@ export default class RulesService { ...getRulePreviewQueryArgs() }); - return rules.map(ruleTransformer); + return rules.map((rule) => ruleTransformer(rule)); } /** diff --git a/src/backend/src/transformers/rules.transformer.ts b/src/backend/src/transformers/rules.transformer.ts index f072b9c887..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 } 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 { @@ -23,21 +28,27 @@ 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, + hasStatusHistory: projectRule._count.statusHistory > 0 })), - 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, + hasStatusHistory: rule._count.statusHistory > 0 }; }; @@ -45,7 +56,30 @@ 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, + 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/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index ad8fa3e458..6a827b3d29 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,479 @@ 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 { leafRule1 } = await setupRules(car); + + const updatedRule = await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.PASS); + + 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); + expect(updatedRule.statusUpdatedAt).toBeInstanceOf(Date); + }); + + it('Marks a rule back to Pending in the general view and clears who/when', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + + 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(); + expect(updatedRule.statusUpdatedAt).toBeUndefined(); + }); + + it('Set rule status fails if user does not have permission', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + + await expect( + 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); - const updatedRule = await RulesService.setRuleCompletion( + 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); + 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); + + const updatedProjectRule = await RulesService.setProjectRuleStatus( admin, organization, - topLevelRule.ruleId, - true, - project.projectId + projectRule.projectRuleId, + 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(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('Marks a rule complete without a project (general view)', async () => { + 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); - const updatedRule = await RulesService.setRuleCompletion(admin, organization, topLevelRule.ruleId, true); + await expect( + async () => + await RulesService.setProjectRuleStatus(nonLeadership, organization, projectRule.projectRuleId, RuleStatus.PASS) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule status')); + }); - expect(updatedRule.isComplete).toBe(true); - expect(updatedRule.completedBy?.firstName).toBe(admin.firstName); - expect(updatedRule.completedInProject).toBeUndefined(); + 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('Marks a rule incomplete and clears completion info', async () => { + 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 } = 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, 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, leafRule1.ruleId, RuleStatus.FAIL); - await RulesService.setRuleCompletion(admin, organization, topLevelRule.ruleId, true, project.projectId); - const updatedRule = await RulesService.setRuleCompletion(admin, organization, topLevelRule.ruleId, false); + const projectRules2 = await RulesService.getProjectRules(ruleset1.rulesetId, project2.projectId, organization); + const rule2Entry = projectRules2.find((pr) => pr.projectRuleId === projectRule2.projectRuleId); - expect(updatedRule.isComplete).toBe(false); - expect(updatedRule.completedBy).toBeUndefined(); - expect(updatedRule.completedInProject).toBeUndefined(); + expect(rule2Entry?.status).toBe(RuleStatus.PENDING); + expect(rule2Entry?.rule.status).toBe(RuleStatus.FAIL); }); - it('Set rule completion fails if user does not have permission', async () => { + it('Deleting a rule leaves its parent chain stale once the parent becomes childless', async () => { const car = await createUniqueCar(orgId); - const { topLevelRule } = await setupRules(car); + 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); + }); + + 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', () => { + 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); + + // 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); + + 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, leafRule1 } = await setupRules(car); + + await RulesService.setRuleStatus(admin, organization, leafRule1.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, leafRule1 } = 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, leafRule1.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.setRuleCompletion(nonLeadership, organization, topLevelRule.ruleId, true, project.projectId) - ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule completion')); + 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); }); }); diff --git a/src/frontend/src/apis/rules.api.ts b/src/frontend/src/apis/rules.api.ts index 6078448427..2479205733 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 } 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'; /** @@ -101,13 +102,50 @@ 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 }); +}; + +/** + * 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) + }); +}; + +/** + * 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)); }; /** diff --git a/src/frontend/src/apis/transformers/rules.transformers.ts b/src/frontend/src/apis/transformers/rules.transformers.ts index 11e81419dc..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. @@ -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 }; }; @@ -59,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 6e3868a86e..e07690e940 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 } from 'shared'; +import { ProjectRule, Rule as SharedRule, Ruleset, RulesetType, RuleStatus, RuleStatusHistoryEntry } from 'shared'; import { createRulesetType, getAllRulesetTypes, @@ -14,7 +14,11 @@ import { getUnassignedRulesForRuleset, createProjectRule, deleteProjectRule, - setRuleCompletion, + setRuleStatus, + setProjectRuleStatus, + getRuleStatusHistory, + resetRulesetStatuses, + resetProjectRuleStatuses, getChildRules, getTopLevelRules, getAllRulesForRuleset, @@ -153,6 +157,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. */ @@ -215,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. */ @@ -295,23 +366,46 @@ 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: () => { + onSuccess: (_data, { ruleId }) => { + queryClient.invalidateQueries(['rules', 'allRules', rulesetId]); + queryClient.invalidateQueries(['rules', 'top-level', rulesetId]); + queryClient.invalidateQueries(['rules', 'children']); + queryClient.invalidateQueries(['rules', 'statusHistory', ruleId]); + } + } + ); +}; + +/** + * 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; + }, + { + 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 41d033a874..a899d94452 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -19,11 +19,13 @@ 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'; 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'; @@ -31,8 +33,9 @@ import { useAllRulesetTypes, useActiveRuleset, useProjectRules, - useSetRuleCompletion, - useCreateProjectRule + useSetProjectRuleStatus, + useCreateProjectRule, + useResetProjectRuleStatuses } from '../../../../hooks/rules.hooks'; import { useToast } from '../../../../hooks/toasts.hooks'; import { InfoOutlined } from '@mui/icons-material'; @@ -56,6 +59,8 @@ 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); + const [showResetModal, setShowResetModal] = useState(false); // Fetch all ruleset types const { data: rulesetTypes, isLoading: rulesetTypesLoading, isError: rulesetTypesError } = useAllRulesetTypes(); @@ -77,22 +82,36 @@ 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 ); 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); - // 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, + // history modal for PASS/FAIL only scoped to this project + hasStatusHistory: pr.hasStatusHistory + })) + .sort(compareRuleCodes); }, [projectRules]); // Get top-level rules (rules without a parent) @@ -107,11 +126,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); @@ -152,6 +171,24 @@ 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 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); @@ -188,9 +225,10 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { return ( handleStatusClick(e, rule) : undefined} + onInfoClick={handleInfoClick} /> ); }; @@ -226,9 +264,14 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { ))} {activeRuleset && ( - - {areAllExpanded ? 'Collapse All' : 'Expand All'} - + + + {areAllExpanded ? 'Collapse All' : 'Expand All'} + + setShowResetModal(true)}> + Reset Status + + )} @@ -371,11 +414,32 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { )} + {/* Status History Modal, scoped to this project */} + {historyModalProjectRule && ( + setHistoryModalProjectRule(null)} + rule={historyModalProjectRule.rule} + projectRuleId={historyModalProjectRule.projectRuleId} + /> + )} + + {/* 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) && ( 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/RulesetViewPage.tsx b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx index 337b511a80..a10dc159b7 100644 --- a/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx +++ b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx @@ -10,12 +10,22 @@ import ErrorPage from '../ErrorPage'; import LoadingIndicator from '../../components/LoadingIndicator'; import RulesetGeneralView from './components/RulesetGeneralView'; import RulesetTeamView from './components/RulesetTeamView'; -import { useSingleRuleset, useAllRulesForRuleset, useGetTopLevelRules, useFetchFullRuleTree } from '../../hooks/rules.hooks'; +import ResetStatusesModal from './components/ResetStatusesModal'; +import { + useSingleRuleset, + useAllRulesForRuleset, + useGetTopLevelRules, + useFetchFullRuleTree, + useResetRulesetStatuses +} from '../../hooks/rules.hooks'; import { useRuleTreeNavigation } from './useRuleTreeNavigation'; import { useTeamRuleOrganization } from './useTeamRuleOrganization'; const RulesetViewPage = () => { 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/pages/RulesPage/components/RuleStatusHistoryModal.tsx b/src/frontend/src/pages/RulesPage/components/RuleStatusHistoryModal.tsx new file mode 100644 index 0000000000..54829c2a98 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RuleStatusHistoryModal.tsx @@ -0,0 +1,57 @@ +/* + * 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, 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'; + +interface RuleStatusHistoryModalProps { + open: boolean; + onClose: () => void; + rule: Rule; + // if provided, scopes history to just this project; otherwise shows every context the rule appears in + projectRuleId?: string; +} + +/** + * 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, projectRuleId }) => { + const { data: entries, isLoading, isError, error } = useRuleStatusHistory(rule.ruleId, open, projectRuleId); + + if (isLoading) return ; + if (isError) return ; + + return ( + + {!entries || entries.length === 0 ? ( + No status history yet. + ) : ( + + {entries.map((entry, index) => { + const { color } = getRuleStatusConfig(entry.status); + return ( + + + + Marked {entry.status.toUpperCase()} by {entry.updatedBy.firstName} {entry.updatedBy.lastName} 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 9137569525..e575848209 100644 --- a/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx +++ b/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx @@ -5,44 +5,49 @@ 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 } 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 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. - */ -const RuleStatusTag: React.FC = ({ rule, allRules, onClick, popoverOpen = false }) => { - // Determine completion status based on whether all rules are provided (general view) or just the rule itself - const isComplete = allRules ? isRuleComplete(rule, allRules) : rule.isComplete; - const { label, color } = getRuleStatusConfig(isComplete); +/** 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 statusUpdatedByName = rule.statusUpdatedBy && `${rule.statusUpdatedBy.firstName} ${rule.statusUpdatedBy.lastName}`; + const statusMessage = + statusUpdatedByName && rule.statusUpdatedAt + ? `Marked ${label.toUpperCase()} by ${statusUpdatedByName} on ${formatTimestamp(rule.statusUpdatedAt)}` + : ''; - const isLeaf = allRules ? !allRules.some((r) => r.parentRule?.ruleId === rule.ruleId) : rule.subRuleIds.length === 0; - // 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}` : ''}` - : ''; + // 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); 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 c8eb7166eb..0552aa8aff 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'; @@ -32,14 +33,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 top-level rows render in a stable numeric order. const sortedTopLevelRules = useMemo(() => [...topLevelRules].sort(compareRuleCodes), [topLevelRules]); @@ -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); @@ -75,11 +77,13 @@ const RulesetGeneralView: React.FC = ({ rightContent={(r) => ( { setSelectedRule(r); setStatusPopoverAnchor(e.currentTarget); }} + onInfoClick={setHistoryModalRule} /> )} backgroundColor={tableBackgroundColor} @@ -98,10 +102,13 @@ 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..633b63c082 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 @@ -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]; @@ -38,22 +38,18 @@ 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. - * @param rule - The rule to check - * @param allRules - All rules in scope - * @returns True if the rule (or all its leaves) are complete + * Status chip label and color for a rule status. */ -export const isRuleComplete = (rule: Rule, allRules: Rule[]): boolean => { - const leafRules = getDescendantLeafRules(rule, allRules); - return leafRules.every((leafRule) => leafRule.isComplete); -}; - -/** - * Status chip label and color for a completion state. - */ -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 +198,6 @@ export const makeSectionRow = (ruleId: string, ruleCode: string, subRuleIds: str parentRule: undefined, subRuleIds, referencedRules: [], - isComplete: false + status: RuleStatus.PENDING, + hasStatusHistory: false }); diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 4dffc124bd..448ff3275e 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -478,7 +478,12 @@ 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 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`; @@ -906,7 +911,11 @@ export const apiUrls = { rulesGetUnassignedRulesForRuleset, rulesCreateProjectRule, rulesDeleteProjectRule, - rulesSetRuleCompletion, + rulesSetRuleStatus, + rulesSetProjectRuleStatus, + rulesGetStatusHistory, + rulesResetRulesetStatuses, + rulesResetProjectRuleStatuses, rulesEdit, rulesDelete, rulesAddReferences, diff --git a/src/shared/src/types/rules-types.ts b/src/shared/src/types/rules-types.ts index a11f9eb0e4..45941fb883 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,47 @@ export interface Rule { projectId: string; projectName: string; teamIds: string[]; + projectRuleId: string; + status: RuleStatus; + statusUpdatedBy?: { + firstName: string; + lastName: string; + }; + statusUpdatedAt?: Date; + hasStatusHistory: boolean; }>; - isComplete: boolean; - completedBy?: { + status: RuleStatus; + statusUpdatedBy?: { firstName: string; lastName: string; }; - completedInProject?: { projectId: string; projectName: 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 { projectRuleId: string; rule: Rule; projectId: string; + status: RuleStatus; + statusUpdatedBy?: { + firstName: string; + 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 {