From d1df37d98e53b3b05412af8f8c2712c8b9588fd6 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 18 Jun 2026 12:29:33 -0400 Subject: [PATCH 01/16] #4284 project rules viewable --- src/backend/src/services/rules.services.ts | 56 +++++++- src/backend/tests/unit/rule.test.ts | 144 +++++++++++++++++++++ 2 files changed, 193 insertions(+), 7 deletions(-) diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 86bd54f5bd..353212bf28 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -385,13 +385,55 @@ export default class RulesService { throw new HttpException(400, 'This rule is already associated with the project'); } - const projectRule = await prisma.project_Rule.create({ - data: { - ruleId, - projectId, - currentStatus: Rule_Completion.REVIEW, - createdByUserId: submitter.userId - }, + // Walk up the parent chain to assign all ancestors of a rule to the project as well. + // visited guards against cycles, which editRule does not currently prevent. + const ancestorIds: string[] = []; + const visited = new Set([ruleId]); + let currentParentId = rule.parentRuleId; + + while (currentParentId && !visited.has(currentParentId)) { + visited.add(currentParentId); + const parent = await prisma.rule.findUnique({ + where: { ruleId: currentParentId }, + select: { parentRuleId: true, dateDeleted: true } + }); + // Stop if the ancestor is missing or deleted - deleted rules should not be assigned to projects + if (!parent || parent.dateDeleted) break; + ancestorIds.push(currentParentId); + currentParentId = parent.parentRuleId; + } + + // Only create ancestors that aren't already assigned to the project to avoid duplicate assignment issues + const existingAncestors = await prisma.project_Rule.findMany({ + where: { projectId, ruleId: { in: ancestorIds }, dateDeleted: null }, + select: { ruleId: true } + }); + const existingAncestorIds = new Set(existingAncestors.map((projectRule) => projectRule.ruleId)); + const ancestorsToCreate = ancestorIds.filter((id) => !existingAncestorIds.has(id)); + + await prisma.$transaction([ + ...ancestorsToCreate.map((ancestorId) => + prisma.project_Rule.create({ + data: { + ruleId: ancestorId, + projectId, + currentStatus: Rule_Completion.INCOMPLETE, + createdByUserId: submitter.userId + } + }) + ), + prisma.project_Rule.create({ + data: { + ruleId, + projectId, + currentStatus: Rule_Completion.INCOMPLETE, + createdByUserId: submitter.userId + } + }) + ]); + + const projectRule = await prisma.project_Rule.findUnique({ + where: { ruleId_projectId: { ruleId, projectId } }, ...getProjectRuleQueryArgs() }); diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index 3304562f7a..e997bdbee4 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -367,6 +367,150 @@ describe('Create Rules Tests', () => { }); }); + describe('Create Project Rule', () => { + it('Creates project rules for the full ancestor chain when assigning a nested sub-rule', async () => { + const root = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + const child = await RulesService.createRule( + batman, + 'T.1.1', + 'Vehicle Requirements', + rulesetId, + organization, + root.ruleId + ); + const grandchild = await RulesService.createRule(batman, 'T.1.1.1', 'Wheels', rulesetId, organization, child.ruleId); + + const project = await createTestProject(aquaman, orgId, undefined, carId); + + await RulesService.createProjectRule(aquaman, organization, grandchild.ruleId, project.projectId); + + const projectRules = await RulesService.getProjectRules(rulesetId, project.projectId, organization); + const assignedRuleIds = projectRules.map((pr) => pr.rule.ruleId); + expect(assignedRuleIds).toHaveLength(3); + expect(assignedRuleIds).toEqual(expect.arrayContaining([root.ruleId, child.ruleId, grandchild.ruleId])); + }); + + it('Creates project rules for shared ancestors when adding a sibling sub-rule', async () => { + const root = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + const child = await RulesService.createRule( + batman, + 'T.1.1', + 'Vehicle Requirements', + rulesetId, + organization, + root.ruleId + ); + const grandchild1 = await RulesService.createRule(batman, 'T.1.1.1', 'Wheels', rulesetId, organization, child.ruleId); + const grandchild2 = await RulesService.createRule(batman, 'T.1.1.2', 'Brakes', rulesetId, organization, child.ruleId); + + const project = await createTestProject(aquaman, orgId, undefined, carId); + + await RulesService.createProjectRule(aquaman, organization, grandchild1.ruleId, project.projectId); + // Adding a sibling must not error on the already-present parent/root and must not duplicate them. + await RulesService.createProjectRule(aquaman, organization, grandchild2.ruleId, project.projectId); + + const projectRules = await RulesService.getProjectRules(rulesetId, project.projectId, organization); + const assignedRuleIds = projectRules.map((pr) => pr.rule.ruleId); + expect(assignedRuleIds).toHaveLength(4); + expect(assignedRuleIds).toEqual( + expect.arrayContaining([root.ruleId, child.ruleId, grandchild1.ruleId, grandchild2.ruleId]) + ); + }); + + it('does not assign descendants of the selected rule', async () => { + const root = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + const child = await RulesService.createRule( + batman, + 'T.1.1', + 'Vehicle Requirements', + rulesetId, + organization, + root.ruleId + ); + await RulesService.createRule(batman, 'T.1.1.1', 'Wheels', rulesetId, organization, child.ruleId); + + const project = await createTestProject(aquaman, orgId, undefined, carId); + + await RulesService.createProjectRule(aquaman, organization, child.ruleId, project.projectId); + + const projectRules = await RulesService.getProjectRules(rulesetId, project.projectId, organization); + const assignedRuleIds = projectRules.map((pr) => pr.rule.ruleId); + expect(assignedRuleIds).toHaveLength(2); + expect(assignedRuleIds).toEqual(expect.arrayContaining([root.ruleId, child.ruleId])); + }); + + it('does not assign deleted ancestors when assigning a nested sub-rule', async () => { + const root = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + const child = await RulesService.createRule( + batman, + 'T.1.1', + 'Vehicle Requirements', + rulesetId, + organization, + root.ruleId + ); + const grandchild = await RulesService.createRule(batman, 'T.1.1.1', 'Wheels', rulesetId, organization, child.ruleId); + + // Soft-delete the immediate parent directly so the grandchild remains assignable + await prisma.rule.update({ + where: { ruleId: child.ruleId }, + data: { dateDeleted: new Date(), deletedBy: { connect: { userId: batman.userId } } } + }); + + const project = await createTestProject(aquaman, orgId, undefined, carId); + + await RulesService.createProjectRule(aquaman, organization, grandchild.ruleId, project.projectId); + + // The walk stops at the deleted ancestor, so neither it nor the root above it are assigned. + const projectRules = await RulesService.getProjectRules(rulesetId, project.projectId, organization); + const assignedRuleIds = projectRules.map((pr) => pr.rule.ruleId); + expect(assignedRuleIds).toHaveLength(1); + expect(assignedRuleIds).toEqual([grandchild.ruleId]); + + // getProjectRules hides deleted rules, so assert directly that none was created for the deleted ancestor + const deletedAncestorProjectRule = await prisma.project_Rule.findUnique({ + where: { ruleId_projectId: { ruleId: child.ruleId, projectId: project.projectId } } + }); + expect(deletedAncestorProjectRule).toBeNull(); + }); + + it('throws when the rule is already associated with the project', async () => { + const rule = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + const project = await createTestProject(aquaman, orgId, undefined, carId); + + await RulesService.createProjectRule(aquaman, organization, rule.ruleId, project.projectId); + + await expect( + async () => await RulesService.createProjectRule(aquaman, organization, rule.ruleId, project.projectId) + ).rejects.toThrow(new HttpException(400, 'This rule is already associated with the project')); + }); + + it('throws when a guest tries to assign a rule to a project', async () => { + const rule = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + const project = await createTestProject(aquaman, orgId, undefined, carId); + + await expect( + async () => await RulesService.createProjectRule(wonderwoman, organization, rule.ruleId, project.projectId) + ).rejects.toThrow(AccessDeniedException); + }); + + it('throws when the rule does not exist', async () => { + const project = await createTestProject(aquaman, orgId, undefined, carId); + + await expect( + async () => await RulesService.createProjectRule(aquaman, organization, 'fake-rule-id', project.projectId) + ).rejects.toThrow(new NotFoundException('Rule', 'fake-rule-id')); + }); + + it('throws when the project does not exist', async () => { + const rule = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + + await expect( + async () => await RulesService.createProjectRule(aquaman, organization, rule.ruleId, 'fake-project-id') + ).rejects.toThrow(new NotFoundException('Project', 'fake-project-id')); + }); + }); + describe('Get rulesets by ruleset type', () => { it('Successful get rulesets by ruleset types', async () => { const rulesets = await RulesService.getRulesetsByRulesetType(rulesetType.rulesetTypeId, orgId); From 410dd8523128f4298f623a3444787dc1838e5c65 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 23 Jun 2026 15:42:36 -0400 Subject: [PATCH 02/16] #4284 remove rule completion reviewing --- .../src/controllers/rules.controllers.ts | 15 +-- .../src/prisma-query-args/rules.query-args.ts | 32 ++--- src/backend/src/prisma/schema.prisma | 87 ++++++-------- .../src/prisma/seed-data/rules.seed.ts | 2 - src/backend/src/prisma/seed.ts | 3 + src/backend/src/routes/rules.routes.ts | 7 +- src/backend/src/services/rules.services.ts | 74 +++++------- .../src/transformers/rules.transformer.ts | 19 ++- src/backend/tests/unit/rule.test.ts | 86 ++++++------- src/frontend/src/apis/rules.api.ts | 11 +- .../apis/transformers/rules.transformers.ts | 6 +- src/frontend/src/hooks/rules.hooks.ts | 17 +-- .../ProjectRules/ProjectRulesTab.tsx | 113 ++++++------------ .../ProjectRules/RuleHistoryModal.tsx | 101 ---------------- .../ProjectRules/UpdateStatusPopover.tsx | 16 +-- src/frontend/src/utils/urls.ts | 4 +- src/shared/src/types/rules-types.ts | 25 +--- 17 files changed, 220 insertions(+), 398 deletions(-) delete mode 100644 src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/RuleHistoryModal.tsx diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts index b044f16429..081a4be299 100644 --- a/src/backend/src/controllers/rules.controllers.ts +++ b/src/backend/src/controllers/rules.controllers.ts @@ -158,19 +158,20 @@ export default class RulesController { } } - static async editProjectRuleStatus(req: Request, res: Response, next: NextFunction) { + static async setRuleCompletion(req: Request, res: Response, next: NextFunction) { try { - const { projectRuleId } = req.params; - const { newStatus } = req.body; + const { ruleId } = req.params; + const { isComplete, projectId } = req.body; - const projectRule: ProjectRule = await RulesService.editProjectRuleStatus( + const rule: Rule = await RulesService.setRuleCompletion( req.currentUser, req.organization, - projectRuleId as string, - newStatus + ruleId as string, + isComplete, + projectId ); - res.status(200).json(projectRule); + res.status(200).json(rule); } 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 32a602408c..f7c657de38 100644 --- a/src/backend/src/prisma-query-args/rules.query-args.ts +++ b/src/backend/src/prisma-query-args/rules.query-args.ts @@ -27,6 +27,22 @@ export const getRulePreviewQueryArgs = () => teamId: true, teamName: true } + }, + completedBy: { + select: { + firstName: true, + lastName: true + } + }, + completedInProject: { + select: { + projectId: true, + wbsElement: { + select: { + name: true + } + } + } } } }); @@ -35,21 +51,7 @@ export const getProjectRuleQueryArgs = () => Prisma.validator()({ include: { rule: getRulePreviewQueryArgs(), - project: { select: { projectId: true } }, - statusHistory: { - include: { - createdBy: { - select: { - userId: true, - firstName: true, - lastName: true - } - } - }, - orderBy: { - dateCreated: 'desc' - } - } + project: { select: { projectId: true } } } }); diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 0b52cdbe63..0719d745d8 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -180,12 +180,6 @@ enum Sponsor_Value_Type { DISCOUNT } -enum Rule_Completion { - REVIEW // all rules start as REVIEW - INCOMPLETE // rules that need an action - COMPLETED -} - model User { userId String @id @default(uuid()) firstName String @@ -299,8 +293,7 @@ model User { deletedSponsorTiers Sponsor_Tier[] financeDelegateForOrganizations Organization[] @relation(name: "financeDelegates") assignedReimbursementRequests Reimbursement_Request[] @relation(name: "reimbursementRequestAssignee") - createdRuleStatuses Rule_Status_Change[] @relation(name: "ruleStatusCreator") - deletedRuleStatuses Rule_Status_Change[] @relation(name: "ruleStatusDeletor") + completedRules Rule[] @relation(name: "ruleCompleter") createdRulesetTypes Ruleset_Type[] @relation(name: "rulesetTypeCreator") deletedRulesetTypes Ruleset_Type[] @relation(name: "rulesetTypeDeleter") createdRulesets Ruleset[] @relation(name: "rulesetCreator") @@ -601,6 +594,7 @@ model Project { abbreviation String? parts Part[] rules Project_Rule[] @relation(name: "projectsForRule") + completedRules Rule[] @relation(name: "ruleCompletedInProject") @@index([carId]) } @@ -1850,61 +1844,50 @@ 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") - 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") + 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]) @@unique([rulesetId, ruleCode]) @@index([parentRuleId, rulesetId, ruleCode]) } -model Rule_Status_Change { - historyId String @id @default(uuid()) - projectRule Project_Rule @relation(name: "ruleStatusHistory", fields: [projectRuleId], references: [projectRuleId]) - projectRuleId String - dateCreated DateTime @default(now()) - createdByUserId String - createdBy User @relation(name: "ruleStatusCreator", fields: [createdByUserId], references: [userId]) - dateDeleted DateTime? - deletedByUserId String? - deletedBy User? @relation(name: "ruleStatusDeletor", fields: [deletedByUserId], references: [userId]) - newStatus Rule_Completion - note String -} - model Project_Rule { - projectRuleId String @id @default(uuid()) + projectRuleId String @id @default(uuid()) ruleId String - rule Rule @relation(name: "rulesInProject", fields: [ruleId], references: [ruleId]) + rule Rule @relation(name: "rulesInProject", fields: [ruleId], references: [ruleId]) projectId String - project Project @relation(name: "projectsForRule", fields: [projectId], references: [projectId]) - currentStatus Rule_Completion - statusHistory Rule_Status_Change[] @relation(name: "ruleStatusHistory") - dateCreated DateTime @default(now()) + project Project @relation(name: "projectsForRule", fields: [projectId], references: [projectId]) + dateCreated DateTime @default(now()) createdByUserId String - createdBy User @relation(name: "projectRuleCreator", fields: [createdByUserId], references: [userId]) + createdBy User @relation(name: "projectRuleCreator", fields: [createdByUserId], references: [userId]) dateDeleted DateTime? deletedByUserId String? - deletedBy User? @relation(name: "projectRuleDeletor", fields: [deletedByUserId], references: [userId]) + deletedBy User? @relation(name: "projectRuleDeletor", fields: [deletedByUserId], references: [userId]) @@unique([ruleId, projectId]) } diff --git a/src/backend/src/prisma/seed-data/rules.seed.ts b/src/backend/src/prisma/seed-data/rules.seed.ts index 622f2187d9..45d46a020a 100644 --- a/src/backend/src/prisma/seed-data/rules.seed.ts +++ b/src/backend/src/prisma/seed-data/rules.seed.ts @@ -105,7 +105,6 @@ const secondActiveRuleset = (carId: string, userCreatedId: string, rulesetTypeId // project rules const projectRule1 = (projectId: string, ruleId: string, createdByUserId: string): Prisma.Project_RuleCreateInput => { return { - currentStatus: 'REVIEW', rule: { connect: { ruleId } }, project: { connect: { projectId } }, createdBy: { connect: { userId: createdByUserId } } @@ -114,7 +113,6 @@ const projectRule1 = (projectId: string, ruleId: string, createdByUserId: string const projectRule2 = (projectId: string, ruleId: string, createdByUserId: string): Prisma.Project_RuleCreateInput => { return { - currentStatus: 'REVIEW', rule: { connect: { ruleId } }, project: { connect: { projectId } }, createdBy: { connect: { userId: createdByUserId } } diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 6ac2c904d5..9f4ecb7bd2 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -4785,6 +4785,9 @@ const performSeed: () => Promise = async () => { // project rules await RulesService.createProjectRule(batman, ner, ruleT211.ruleId, projectHuskies1Id); + // mark the leaf rule complete from the project to demonstrate global rule completion + await RulesService.setRuleCompletion(batman, ner, ruleT211.ruleId, true, projectHuskies1Id); + // Technical Rules Section const techRule = await prisma.rule.create({ data: { diff --git a/src/backend/src/routes/rules.routes.ts b/src/backend/src/routes/rules.routes.ts index 7fd70825b0..94c67058c2 100644 --- a/src/backend/src/routes/rules.routes.ts +++ b/src/backend/src/routes/rules.routes.ts @@ -52,10 +52,11 @@ rulesRouter.post('/projectRule/:projectRuleId/delete', RulesController.deletePro rulesRouter.get('/rulesets/:rulesetTypeId', RulesController.getRulesetsByRulesetType); rulesRouter.post( - '/projectRule/:projectRuleId/editStatus', - nonEmptyString(body('newStatus')), + '/rule/:ruleId/setCompletion', + body('isComplete').isBoolean(), + body('projectId').optional().isString(), validateInputs, - RulesController.editProjectRuleStatus + RulesController.setRuleCompletion ); rulesRouter.post( diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 353212bf28..a1586226d0 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -1,4 +1,4 @@ -import { Organization, Rule, Rule_Completion } from '@prisma/client'; +import { Organization, Rule } from '@prisma/client'; import { isAdmin, isLeadership, @@ -417,7 +417,6 @@ export default class RulesService { data: { ruleId: ancestorId, projectId, - currentStatus: Rule_Completion.INCOMPLETE, createdByUserId: submitter.userId } }) @@ -426,7 +425,6 @@ export default class RulesService { data: { ruleId, projectId, - currentStatus: Rule_Completion.INCOMPLETE, createdByUserId: submitter.userId } }) @@ -700,64 +698,52 @@ export default class RulesService { } /** - * Updates the status of a project rule - * Such as changing a project rule from INCOMPLETE to COMPLETED - * @param submitter the user updating the status + * 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 * @param organization the organization of the rule - * @param projectRuleId the id of the project rule to update - * @param newStatus the new status of the project rule - * @returns the project rule with updated status + * @param ruleId the id of the rule to update + * @param isComplete whether the rule is complete + * @param projectId the project the rule was completed from (optional - omitted for general view) + * @returns the rule with updated completion */ - static async editProjectRuleStatus( + static async setRuleCompletion( submitter: User, organization: Organization, - projectRuleId: string, - newStatus: Rule_Completion - ): Promise { - // Ensure new satus is a valid Rule_Completion value - if (!Object.values(Rule_Completion).includes(newStatus as Rule_Completion)) { - throw new HttpException(400, `status must be one of: ${Object.values(Rule_Completion).join(', ')}`); - } - + ruleId: string, + isComplete: boolean, + projectId?: string + ): Promise { if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) { - throw new AccessDeniedException('You do not have permissions to update a project rule status'); + throw new AccessDeniedException('You do not have permissions to update a rule completion'); } - const projectRule = await prisma.project_Rule.findUnique({ - where: { projectRuleId }, - include: { rule: { include: { ruleset: { include: { car: { include: { wbsElement: true } } } } } } } + const rule = await prisma.rule.findUnique({ + where: { ruleId }, + include: { ruleset: { include: { car: { include: { wbsElement: true } } } } } }); - if (!projectRule) { - throw new NotFoundException('Project Rule', projectRuleId); + if (!rule) { + throw new NotFoundException('Rule', ruleId); } - if (projectRule.rule.ruleset.car.wbsElement.organizationId !== organization.organizationId) { - throw new InvalidOrganizationException('Project Rule'); + if (rule.dateDeleted) { + throw new DeletedException('Rule', ruleId); } - // If the status does not change, simply return the project rule - if (projectRule.currentStatus === newStatus) { - const originalProjectRule = await prisma.project_Rule.findUnique({ - where: { projectRuleId }, - ...getProjectRuleQueryArgs() - }); - return projectRuleTransformer(originalProjectRule); + if (rule.ruleset.car.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Rule'); } - const newStatusHistory = { - createdByUserId: submitter.userId, - newStatus, - note: `${submitter.firstName} ${submitter.lastName} marked as ${newStatus}` - }; - - const updatedProjectRule = await prisma.project_Rule.update({ - where: { projectRuleId }, - data: { currentStatus: newStatus, statusHistory: { create: newStatusHistory } }, - ...getProjectRuleQueryArgs() + const updatedRule = await prisma.rule.update({ + where: { ruleId }, + data: isComplete + ? { isComplete: true, completedByUserId: submitter.userId, completedInProjectId: projectId ?? null } + : { isComplete: false, completedByUserId: null, completedInProjectId: null }, + ...getRulePreviewQueryArgs() }); - return projectRuleTransformer(updatedProjectRule); + return ruleTransformer(updatedRule); } /** diff --git a/src/backend/src/transformers/rules.transformer.ts b/src/backend/src/transformers/rules.transformer.ts index 8dea729b74..5159ae5f55 100644 --- a/src/backend/src/transformers/rules.transformer.ts +++ b/src/backend/src/transformers/rules.transformer.ts @@ -19,7 +19,20 @@ export const ruleTransformer = (rule: Prisma.RuleGetPayload ({ teamId: team.teamId, teamName: team.teamName - })) + })), + isComplete: rule.isComplete, + completedBy: rule.completedBy + ? { + firstName: rule.completedBy.firstName, + lastName: rule.completedBy.lastName + } + : undefined, + completedInProject: rule.completedInProject + ? { + projectId: rule.completedInProject.projectId, + projectName: rule.completedInProject.wbsElement.name + } + : undefined }; }; @@ -27,9 +40,7 @@ export const projectRuleTransformer = (projectRule: any): ProjectRule => { return { projectRuleId: projectRule.projectRuleId, rule: ruleTransformer(projectRule.rule), - projectId: projectRule.projectId, - currentStatus: projectRule.currentStatus, - statusHistory: projectRule.statusHistory + projectId: projectRule.projectId }; }; diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index e997bdbee4..6f6ec65050 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -1,5 +1,5 @@ import RulesService from '../../src/services/rules.services'; -import { Organization, User, Project, Car, Ruleset_Type, Ruleset, Rule_Completion, Team } from '@prisma/client'; +import { Organization, User, Project, Car, Ruleset_Type, Ruleset, Team } from '@prisma/client'; import { supermanAdmin, financeMember, @@ -948,8 +948,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.statusHistory).toEqual([]); - expect(projectRule.currentStatus).toBe(Rule_Completion.REVIEW); + expect(projectRule.rule.isComplete).toBe(false); }); it('Creates a project rule successfully for a leaf rule', async () => { const car = await createUniqueCar(orgId); @@ -961,8 +960,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.statusHistory).toEqual([]); - expect(projectRule.currentStatus).toBe(Rule_Completion.REVIEW); + expect(projectRule.rule.isComplete).toBe(false); }); it('Create project rule fails if user does not have permission', async () => { const car = await createUniqueCar(orgId); @@ -1019,59 +1017,57 @@ describe('Rule Tests', () => { ); }); - // Updating Project Rule Status - it('Updates a project rule status successfully', async () => { + // Setting Rule Completion + it('Marks a rule complete successfully and records who/where', async () => { const car = await createUniqueCar(orgId); const { topLevelRule } = await setupRules(car); - const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); - const updatedProjectRule = await RulesService.editProjectRuleStatus( + const updatedRule = await RulesService.setRuleCompletion( admin, organization, - projectRule.projectRuleId, - Rule_Completion.COMPLETED + topLevelRule.ruleId, + true, + project.projectId ); - expect(updatedProjectRule.projectRuleId).toBe(projectRule.projectRuleId); - expect(updatedProjectRule.currentStatus).toBe(Rule_Completion.COMPLETED); - expect(updatedProjectRule.statusHistory.length).toBe(1); - expect(updatedProjectRule.statusHistory[0].newStatus).toBe(Rule_Completion.COMPLETED); - expect(updatedProjectRule.statusHistory[0].projectRuleId).toBe(projectRule.projectRuleId); - expect(updatedProjectRule.statusHistory[0].createdBy.userId).toBe(admin.userId); - expect(new Date(updatedProjectRule.statusHistory[0].dateCreated).getTime()).toBeGreaterThan(Date.now() - 10000); + 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); }); - it('Updates a project rule status to the same status', async () => { + it('Marks a rule complete without a project (general view)', async () => { const car = await createUniqueCar(orgId); const { topLevelRule } = await setupRules(car); - const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); - const updatedProjectRule = await RulesService.editProjectRuleStatus( - admin, - organization, - projectRule.projectRuleId, - Rule_Completion.REVIEW - ); + const updatedRule = await RulesService.setRuleCompletion(admin, organization, topLevelRule.ruleId, true); - expect(updatedProjectRule.projectRuleId).toBe(projectRule.projectRuleId); - expect(updatedProjectRule.currentStatus).toBe(Rule_Completion.REVIEW); - expect(updatedProjectRule.statusHistory).toHaveLength(0); + expect(updatedRule.isComplete).toBe(true); + expect(updatedRule.completedBy?.firstName).toBe(admin.firstName); + expect(updatedRule.completedInProject).toBeUndefined(); }); - it('Update project rule fails if user does not have permission', async () => { + it('Marks a rule incomplete and clears completion info', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + + await RulesService.setRuleCompletion(admin, organization, topLevelRule.ruleId, true, project.projectId); + const updatedRule = await RulesService.setRuleCompletion(admin, organization, topLevelRule.ruleId, false); + + expect(updatedRule.isComplete).toBe(false); + expect(updatedRule.completedBy).toBeUndefined(); + expect(updatedRule.completedInProject).toBeUndefined(); + }); + + it('Set rule completion fails if user does not have permission', async () => { const car = await createUniqueCar(orgId); const { topLevelRule } = await setupRules(car); - const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); await expect( async () => - await RulesService.editProjectRuleStatus( - nonLeadership, - organization, - projectRule.projectRuleId, - Rule_Completion.REVIEW - ) - ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update a project rule status')); + await RulesService.setRuleCompletion(nonLeadership, organization, topLevelRule.ruleId, true, project.projectId) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update a rule completion')); }); }); @@ -1382,22 +1378,13 @@ describe('Rule Tests', () => { const { leafRule1 } = await setupRules(car); const projectRule = await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); - await RulesService.editProjectRuleStatus(admin, organization, projectRule.projectRuleId, Rule_Completion.COMPLETED); - await RulesService.editProjectRuleStatus(admin, organization, projectRule.projectRuleId, Rule_Completion.INCOMPLETE); - const deletedProjectRule = await RulesService.deleteProjectRule(projectRule.projectRuleId, admin, organization); expect(deletedProjectRule).toBeDefined(); expect(deletedProjectRule.projectRuleId).toBe(projectRule.projectRuleId); - const statusChanges = await prisma.rule_Status_Change.findMany({ - where: { projectRuleId: projectRule.projectRuleId } - }); - expect(statusChanges.length).toBeGreaterThan(0); - statusChanges.forEach((statusChange) => { - expect(statusChange.dateDeleted).toBeDefined(); - // expect(statusChange.deletedByUserId).toBe(admin.userId); - }); + const found = await prisma.project_Rule.findUnique({ where: { projectRuleId: projectRule.projectRuleId } }); + expect(found?.dateDeleted).toBeDefined(); }); it('Delete project rule fails if user does not have permission', async () => { const car = await createUniqueCar(orgId); @@ -1756,7 +1743,6 @@ describe('Rule Tests', () => { data: { projectId: project.projectId, ruleId: ruleWithProject.ruleId, - currentStatus: Rule_Completion.REVIEW, createdByUserId: admin.userId } }); diff --git a/src/frontend/src/apis/rules.api.ts b/src/frontend/src/apis/rules.api.ts index 071e0bc891..424bbfc589 100644 --- a/src/frontend/src/apis/rules.api.ts +++ b/src/frontend/src/apis/rules.api.ts @@ -4,7 +4,7 @@ */ import axios from '../utils/axios'; -import { ProjectRule, Rule as SharedRule, RuleCompletion, RulesetType, Ruleset } from 'shared'; +import { ProjectRule, Rule as SharedRule, RulesetType, Ruleset } from 'shared'; import { apiUrls } from '../utils/urls'; import { CreateRulesetPayload, ParseRulesetPayload, CreateRulePayload } from '../hooks/rules.hooks'; import { @@ -116,10 +116,13 @@ export const deleteProjectRule = (projectRuleId: string) => { }; /** - * Updates project rule status + * Sets a rule's completion. Completion is global to the rule. + * @param ruleId the rule to update + * @param isComplete whether the rule is complete + * @param projectId the project the rule was completed from (optional) */ -export const editProjectRuleStatus = (projectRuleId: string, newStatus: RuleCompletion) => { - return axios.post(apiUrls.rulesEditProjectRuleStatus(projectRuleId), { newStatus }); +export const setRuleCompletion = (ruleId: string, isComplete: boolean, projectId?: string) => { + return axios.post(apiUrls.rulesSetRuleCompletion(ruleId), { isComplete, projectId }); }; /** diff --git a/src/frontend/src/apis/transformers/rules.transformers.ts b/src/frontend/src/apis/transformers/rules.transformers.ts index 7d187ed7b4..1c5545e2f7 100644 --- a/src/frontend/src/apis/transformers/rules.transformers.ts +++ b/src/frontend/src/apis/transformers/rules.transformers.ts @@ -28,11 +28,7 @@ export const ruleTransformer = (rule: Rule): Rule => { export const projectRuleTransformer = (projectRule: ProjectRule): ProjectRule => { return { ...projectRule, - rule: ruleTransformer(projectRule.rule), - statusHistory: (projectRule.statusHistory || []).map((history) => ({ - ...history, - dateCreated: new Date(history.dateCreated) - })) + rule: ruleTransformer(projectRule.rule) }; }; diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index 08113c250f..c9895120e1 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -4,7 +4,7 @@ */ import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { ProjectRule, Rule as SharedRule, RuleCompletion, Ruleset, RulesetType } from 'shared'; +import { ProjectRule, Rule as SharedRule, Ruleset, RulesetType } from 'shared'; import { createRulesetType, getAllRulesetTypes, @@ -13,7 +13,7 @@ import { getUnassignedRulesForRuleset, createProjectRule, deleteProjectRule, - editProjectRuleStatus, + setRuleCompletion, getChildRules, getTopLevelRules, toggleRuleTeam, @@ -346,19 +346,20 @@ export const useDeleteProjectRule = (rulesetId: string, projectId: string) => { }; /** - * Hook to update project rule status. + * Hook to set a rule's completion. Completion is global to the rule. */ -export const useEditProjectRuleStatus = (rulesetId: string, projectId: string) => { +export const useSetRuleCompletion = (rulesetId: string, projectId: string) => { const queryClient = useQueryClient(); - return useMutation( - ['rules', 'projectRules', 'editStatus'], - async ({ projectRuleId, newStatus }) => { - const { data } = await editProjectRuleStatus(projectRuleId, newStatus); + return useMutation( + ['rules', 'setCompletion'], + async ({ ruleId, isComplete, projectId: pId }) => { + const { data } = await setRuleCompletion(ruleId, isComplete, pId); return data; }, { onSuccess: () => { queryClient.invalidateQueries(['rules', 'projectRules', rulesetId, projectId]); + queryClient.invalidateQueries(['rules', 'unassigned']); } } ); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index e29bfeef92..1e570acea4 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -17,9 +17,10 @@ import { TableContainer, Paper, useTheme, - IconButton + IconButton, + Tooltip } from '@mui/material'; -import { Project, ProjectRule, Rule, RuleCompletion } from 'shared'; +import { Project, ProjectRule, Rule } from 'shared'; import LoadingIndicator from '../../../../components/LoadingIndicator'; import ErrorPage from '../../../ErrorPage'; import RuleRow from '../../../RulesPage/RuleRow'; @@ -29,12 +30,11 @@ import { useAllRulesetTypes, useActiveRuleset, useProjectRules, - useEditProjectRuleStatus, + useSetRuleCompletion, useCreateProjectRule } from '../../../../hooks/rules.hooks'; import { useToast } from '../../../../hooks/toasts.hooks'; import { InfoOutlined } from '@mui/icons-material'; -import { RuleHistoryModal } from './RuleHistoryModal'; interface ProjectRulesTabProps { project: Project; @@ -43,16 +43,8 @@ interface ProjectRulesTabProps { /** * Get the status chip configuration */ -const getStatusConfig = (status: RuleCompletion) => { - switch (status) { - case RuleCompletion.COMPLETED: - return { label: 'Complete', color: '#4caf50' }; - case RuleCompletion.INCOMPLETE: - return { label: 'Incomplete', color: '#f44336' }; - case RuleCompletion.REVIEW: - default: - return { label: 'Review', color: '#ff9800' }; - } +const getStatusConfig = (isComplete: boolean) => { + return isComplete ? { label: 'Complete', color: '#4caf50' } : { label: 'Incomplete', color: '#f44336' }; }; export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { @@ -65,9 +57,6 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { const [addRuleModalOpen, setAddRuleModalOpen] = useState(false); const [selectedProjectRule, setSelectedProjectRule] = useState(null); - const [selectedRuleForHistory, setSelectedRuleForHistory] = useState(null); - const [showHistoryModal, setShowHistoryModal] = useState(false); - // Fetch all ruleset types const { data: rulesetTypes, isLoading: rulesetTypesLoading, isError: rulesetTypesError } = useAllRulesetTypes(); @@ -87,7 +76,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { } = useProjectRules(activeRuleset?.rulesetId || '', project.id); // Mutations - const { mutateAsync: editStatusMutation, isLoading: isUpdatingStatus } = useEditProjectRuleStatus( + const { mutateAsync: setCompletionMutation, isLoading: isUpdatingStatus } = useSetRuleCompletion( activeRuleset?.rulesetId || '', project.id ); @@ -119,34 +108,21 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { return children.flatMap((child) => getDescendantLeafRules(child)); }; - // Helper function to calculate aggregated status from leaf rules - const getAggregatedStatus = (rule: Rule): RuleCompletion => { + // Helper function to calculate aggregated completion from leaf rules. + // A parent is complete only if all of its descendant leaf rules are complete. + const getAggregatedStatus = (rule: Rule): boolean => { const leafRules = getDescendantLeafRules(rule); if (leafRules.length === 0) { - return RuleCompletion.REVIEW; - } - - const leafStatuses = leafRules.map((leafRule) => { - const projectRule = projectRules?.find((pr) => pr.rule.ruleId === leafRule.ruleId); - return projectRule?.currentStatus || RuleCompletion.REVIEW; - }); - - if (leafStatuses.every((s) => s === RuleCompletion.COMPLETED)) { - return RuleCompletion.COMPLETED; - } - - if (leafStatuses.some((s) => s === RuleCompletion.INCOMPLETE)) { - return RuleCompletion.INCOMPLETE; + return false; } - - return RuleCompletion.REVIEW; + return leafRules.every((leafRule) => leafRule.isComplete); }; - // Handle status update - const handleStatusUpdate = async (projectRuleId: string, newStatus: RuleCompletion) => { + // Handle completion update + const handleStatusUpdate = async (ruleId: string, isComplete: boolean) => { try { - await editStatusMutation({ projectRuleId, newStatus }); - toast.success('Rule status updated successfully'); + await setCompletionMutation({ ruleId, isComplete, projectId: project.id }); + toast.success('Rule completion updated successfully'); } catch (error) { if (error instanceof Error) { toast.error(error.message); @@ -221,13 +197,14 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { const hasChildren = allRules.some((r) => r.parentRule?.ruleId === rule.ruleId); const isLeafRule = !hasChildren; - // Get status - for leaf rules use their own status, for parents calculate from children - const status = isLeafRule - ? projectRules?.find((pr) => pr.rule.ruleId === rule.ruleId)?.currentStatus || RuleCompletion.REVIEW - : getAggregatedStatus(rule); - const statusConfig = getStatusConfig(status); + // Completion - for leaf rules use their own, for parents aggregate from children + const isComplete = isLeafRule ? rule.isComplete : getAggregatedStatus(rule); + const statusConfig = getStatusConfig(isComplete); - const projectRule = projectRules?.find((pr) => pr.rule.ruleId === rule.ruleId); + const completedByName = rule.completedBy && `${rule.completedBy.firstName} ${rule.completedBy.lastName}`; + const completionMessage = completedByName + ? `Completed by ${completedByName}${rule.completedInProject ? ` in ${rule.completedInProject.projectName}` : ''}` + : ''; return ( <> @@ -261,24 +238,22 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { > {statusConfig.label} - {isLeafRule && projectRule && projectRule.statusHistory && projectRule.statusHistory.length > 0 && ( - { - e.stopPropagation(); - setSelectedRuleForHistory(rule); - setShowHistoryModal(true); - }} - sx={{ - padding: '2px', - color: 'text.secondary', - '&:hover': { - color: 'primary.main' - } - }} - > - - + {isLeafRule && isComplete && completionMessage && ( + + e.stopPropagation()} + sx={{ + padding: '2px', + color: 'text.secondary', + '&:hover': { + color: 'primary.main' + } + }} + > + + + )} ); @@ -402,16 +377,6 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { /> )} - { - setShowHistoryModal(false); - setSelectedRuleForHistory(null); - }} - rule={selectedRuleForHistory} - projectRules={projectRules} - /> - {/* Add Rule Modal */} {activeRuleset && teamId && ( void; - rule: Rule | null; - projectRules?: ProjectRule[]; -} - -/** - * Get the status chip configuration - */ -const getStatusConfig = (status: RuleCompletion) => { - switch (status) { - case RuleCompletion.COMPLETED: - return { label: 'Complete', color: '#4caf50' }; - case RuleCompletion.INCOMPLETE: - return { label: 'Incomplete', color: '#f44336' }; - case RuleCompletion.REVIEW: - default: - return { label: 'Review', color: '#ff9800' }; - } -}; - -export const RuleHistoryModal = ({ open, onClose, rule, projectRules }: RuleHistoryModalProps) => { - if (!rule) return null; - - const projectRule = projectRules?.find((pr) => pr.rule.ruleId === rule.ruleId); - const statusHistory = projectRule?.statusHistory || []; - - const formatDate = (date: Date) => { - return new Intl.DateTimeFormat('en-US', { - month: 'numeric', - day: 'numeric', - year: 'numeric' - }).format(date); - }; - - const formatUserName = (user: { firstName: string; lastName: string }) => { - return `${user.firstName} ${user.lastName}`; - }; - - const getStatusLabel = (status: RuleCompletion) => { - const config = getStatusConfig(status); - return config.label; - }; - - return ( - - - - - {statusHistory.map((history) => ( - - - •{formatDate(history.dateCreated)} - {formatUserName(history.createdBy)} Marked as{' '} - {getStatusLabel(history.newStatus)} - - - ))} - - - - Exit - - - - ); -}; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx index ff5f877ed6..50346b29ed 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx @@ -4,26 +4,26 @@ */ import { Box, Checkbox, FormControlLabel, Popover, Typography } from '@mui/material'; -import { ProjectRule, RuleCompletion } from 'shared'; +import { ProjectRule } from 'shared'; interface UpdateStatusPopoverProps { anchorEl: HTMLElement | null; onClose: () => void; projectRule: ProjectRule; - onStatusChange: (projectRuleId: string, newStatus: RuleCompletion) => void; + onStatusChange: (ruleId: string, isComplete: boolean) => void; } const UpdateStatusPopover = ({ anchorEl, onClose, projectRule, onStatusChange }: UpdateStatusPopoverProps) => { const open = Boolean(anchorEl); - const handleStatusChange = (status: RuleCompletion) => { - onStatusChange(projectRule.projectRuleId, status); + const handleStatusChange = (isComplete: boolean) => { + onStatusChange(projectRule.rule.ruleId, isComplete); onClose(); }; const statusOptions = [ - { value: RuleCompletion.COMPLETED, label: 'Completed' }, - { value: RuleCompletion.INCOMPLETE, label: 'Incomplete' } + { value: true, label: 'Completed' }, + { value: false, label: 'Incomplete' } ]; return ( @@ -51,10 +51,10 @@ const UpdateStatusPopover = ({ anchorEl, onClose, projectRule, onStatusChange }: {statusOptions.map((option) => ( handleStatusChange(option.value)} sx={{ color: 'white', diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 32fd50e945..874c22250e 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -469,7 +469,7 @@ const rulesGetUnassignedRulesForRuleset = (rulesetId: string, teamId: string) => `${rules()}/ruleset/${rulesetId}/team/${teamId}/rules/unassigned`; const rulesCreateProjectRule = () => `${rules()}/projectRule/create`; const rulesDeleteProjectRule = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/delete`; -const rulesEditProjectRuleStatus = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/editStatus`; +const rulesSetRuleCompletion = (ruleId: string) => `${rules()}/rule/${ruleId}/setCompletion`; const rulesEdit = (ruleId: string) => `${rules()}/rule/${ruleId}/edit`; const rulesDelete = (ruleId: string) => `${rules()}/rule/${ruleId}/delete`; const rulesetUpdate = (rulesetId: string) => `${ruleset()}/${rulesetId}/update`; @@ -887,7 +887,7 @@ export const apiUrls = { rulesGetUnassignedRulesForRuleset, rulesCreateProjectRule, rulesDeleteProjectRule, - rulesEditProjectRuleStatus, + rulesSetRuleCompletion, rulesEdit, rulesDelete, rulesetUpdate, diff --git a/src/shared/src/types/rules-types.ts b/src/shared/src/types/rules-types.ts index a538320540..d08770ac84 100644 --- a/src/shared/src/types/rules-types.ts +++ b/src/shared/src/types/rules-types.ts @@ -3,14 +3,6 @@ * See the LICENSE file in the repository root folder for details. */ -import { User } from './user-types.js'; - -export enum RuleCompletion { - REVIEW = 'REVIEW', - INCOMPLETE = 'INCOMPLETE', - COMPLETED = 'COMPLETED' -} - export interface RulesetType { rulesetTypeId: string; name: string; @@ -48,23 +40,18 @@ export interface Rule { teamId: string; teamName: string; }>; -} - -export interface RuleStatusChange { - historyId: string; - projectRuleId: string; - createdBy: User; - dateCreated: Date; - newStatus: RuleCompletion; - note: string; + isComplete: boolean; + completedBy?: { + firstName: string; + lastName: string; + }; + completedInProject?: { projectId: string; projectName: string }; } export interface ProjectRule { projectRuleId: string; rule: Rule; projectId: string; - currentStatus: RuleCompletion; - statusHistory: RuleStatusChange[]; } export interface RulesetPreview { From 467aad553441d9b2e2cf0946c1dd51e887640640 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 23 Jun 2026 15:53:09 -0400 Subject: [PATCH 03/16] #4284 rule completion migration --- .../migration.sql | 35 +++++++++++++++++++ .../ProjectRules/UpdateStatusPopover.tsx | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 src/backend/src/prisma/migrations/20260623194325_rule_completion/migration.sql diff --git a/src/backend/src/prisma/migrations/20260623194325_rule_completion/migration.sql b/src/backend/src/prisma/migrations/20260623194325_rule_completion/migration.sql new file mode 100644 index 0000000000..a51a98e7b9 --- /dev/null +++ b/src/backend/src/prisma/migrations/20260623194325_rule_completion/migration.sql @@ -0,0 +1,35 @@ +/* + Warnings: + + - You are about to drop the column `currentStatus` on the `Project_Rule` table. All the data in the column will be lost. + - You are about to drop the `Rule_Status_Change` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Rule_Status_Change" DROP CONSTRAINT "Rule_Status_Change_createdByUserId_fkey"; + +-- DropForeignKey +ALTER TABLE "Rule_Status_Change" DROP CONSTRAINT "Rule_Status_Change_deletedByUserId_fkey"; + +-- DropForeignKey +ALTER TABLE "Rule_Status_Change" DROP CONSTRAINT "Rule_Status_Change_projectRuleId_fkey"; + +-- AlterTable +ALTER TABLE "Project_Rule" DROP COLUMN "currentStatus"; + +-- AlterTable +ALTER TABLE "Rule" ADD COLUMN "completedByUserId" TEXT, +ADD COLUMN "completedInProjectId" TEXT, +ADD COLUMN "isComplete" BOOLEAN NOT NULL DEFAULT false; + +-- DropTable +DROP TABLE "Rule_Status_Change"; + +-- DropEnum +DROP TYPE "Rule_Completion"; + +-- 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; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx index 50346b29ed..66287f4303 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx @@ -22,7 +22,7 @@ const UpdateStatusPopover = ({ anchorEl, onClose, projectRule, onStatusChange }: }; const statusOptions = [ - { value: true, label: 'Completed' }, + { value: true, label: 'Complete' }, { value: false, label: 'Incomplete' } ]; From a7d056347f075c4f1d02a47ab026d81e064f46b6 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 23 Jun 2026 20:05:54 -0400 Subject: [PATCH 04/16] #4284 add completion dropdown and spacing between project rules --- .../ProjectRules/ProjectRulesTab.tsx | 75 +++++++++++++------ 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index 1e570acea4..36cb061713 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -34,7 +34,7 @@ import { useCreateProjectRule } from '../../../../hooks/rules.hooks'; import { useToast } from '../../../../hooks/toasts.hooks'; -import { InfoOutlined } from '@mui/icons-material'; +import { InfoOutlined, KeyboardArrowRight, KeyboardArrowDown } from '@mui/icons-material'; interface ProjectRulesTabProps { project: Project; @@ -206,8 +206,28 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { ? `Completed by ${completedByName}${rule.completedInProject ? ` in ${rule.completedInProject.projectName}` : ''}` : ''; + // Whether the status popover is currently open for this rule + const isPopoverOpenForRule = Boolean(statusPopoverAnchor) && selectedProjectRule?.rule.ruleId === rule.ruleId; + return ( - <> + + {isLeafRule && isComplete && completionMessage && ( + + e.stopPropagation()} + sx={{ + padding: '2px', + color: 'text.secondary', + '&:hover': { + color: 'primary.main' + } + }} + > + + + + )} { color: 'white', fontSize: '11px', fontWeight: 600, - px: 0.75, + pl: isLeafRule ? 0.25 : 0.75, + pr: 0.75, py: 0.25, borderRadius: '3px', cursor: isLeafRule ? 'pointer' : 'default', @@ -236,29 +257,19 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { : {} }} > + {isLeafRule && + (isPopoverOpenForRule ? ( + + ) : ( + + ))} {statusConfig.label} - {isLeafRule && isComplete && completionMessage && ( - - e.stopPropagation()} - sx={{ - padding: '2px', - color: 'text.secondary', - '&:hover': { - color: 'primary.main' - } - }} - > - - - - )} - + ); }; + const backgroundColor = theme.palette.background.default; const tableBackgroundColor = theme.palette.background.paper; const tableTextColor = theme.palette.text.primary; const tableHoverColor = theme.palette.action.hover; @@ -311,8 +322,26 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { ) : ( - - + +
{topLevelRules.map((rule) => ( Date: Tue, 23 Jun 2026 22:25:37 -0400 Subject: [PATCH 05/16] #4284 child rule indentation --- .../ProjectRules/ProjectRulesTab.tsx | 13 ++--- src/frontend/src/pages/RulesPage/RuleRow.tsx | 50 ++++++++++++++++--- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index 36cb061713..581320a63f 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -331,18 +331,10 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { sx={{ borderCollapse: 'separate', borderSpacing: '0 8px', - backgroundColor: backgroundColor, - '& tbody td:first-of-type': { - borderTopLeftRadius: '8px', - borderBottomLeftRadius: '8px' - }, - '& tbody td:last-of-type': { - borderTopRightRadius: '8px', - borderBottomRightRadius: '8px' - } + backgroundColor: backgroundColor }} > - + {topLevelRules.map((rule) => ( { hoverColor={tableHoverColor} rowHeight="40px" verticalPadding="8px" + indentRow /> ))} diff --git a/src/frontend/src/pages/RulesPage/RuleRow.tsx b/src/frontend/src/pages/RulesPage/RuleRow.tsx index 2904a5ed3e..66de98a816 100644 --- a/src/frontend/src/pages/RulesPage/RuleRow.tsx +++ b/src/frontend/src/pages/RulesPage/RuleRow.tsx @@ -27,6 +27,10 @@ interface RuleRowProps { middleWidth?: string; rightWidth?: string; initiallyExpanded?: boolean; + // When true, the entire rule is shifted right per child depth + indentRow?: boolean; + // Amount of indentation per child depth when indentRow is enabled + indentWidth?: number; } /** @@ -47,10 +51,12 @@ const RuleRow: React.FC = ({ rowHeight, verticalPadding = '12px', horizontalPadding = '16px', - leftWidth = '20%', - middleWidth = '70%', + leftWidth = '10%', + middleWidth = '80%', rightWidth = '10%', - initiallyExpanded = false + initiallyExpanded = false, + indentRow = false, + indentWidth = 10 }) => { const [isExpanded, setIsExpanded] = useState(initiallyExpanded); const hasSubRules = rule.subRuleIds.length > 0; @@ -86,13 +92,28 @@ const RuleRow: React.FC = ({ height: rowHeight }; + const cardRadius = 8; + const cardCellBg = indentRow ? { backgroundColor: bgColor } : {}; + const cardCellClass = indentRow ? 'rule-card-cell' : undefined; + // Indent left edge of rule with transparent left border + const leftInset = indentRow ? level * indentWidth : 0; + const leftCellRadius = indentRow + ? { + borderTopLeftRadius: `${leftInset + cardRadius}px ${cardRadius}px`, + borderBottomLeftRadius: `${leftInset + cardRadius}px ${cardRadius}px` + } + : {}; + const rightCellRadius = indentRow + ? { borderTopRightRadius: `${cardRadius}px`, borderBottomRightRadius: `${cardRadius}px` } + : {}; + const defaultLeftContent = ( @@ -121,8 +142,10 @@ const RuleRow: React.FC = ({ = ({ > = ({ = ({ @@ -186,6 +222,8 @@ const RuleRow: React.FC = ({ leftWidth={leftWidth} middleWidth={middleWidth} rightWidth={rightWidth} + indentRow={indentRow} + indentWidth={indentWidth} /> ))} From a91af46d1db1737d36c3e7caba38ce57e94cba4d Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 24 Jun 2026 12:36:21 -0400 Subject: [PATCH 06/16] #4284 update transformer and comments --- .../src/prisma-query-args/rules.query-args.ts | 5 +- src/backend/src/prisma/seed.ts | 2 +- src/backend/src/services/rules.services.ts | 19 +++--- .../src/transformers/rules.transformer.ts | 4 +- src/backend/tests/unit/rule.test.ts | 58 +++++++++---------- 5 files changed, 46 insertions(+), 42 deletions(-) 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 f7c657de38..a9a663d321 100644 --- a/src/backend/src/prisma-query-args/rules.query-args.ts +++ b/src/backend/src/prisma-query-args/rules.query-args.ts @@ -47,11 +47,12 @@ export const getRulePreviewQueryArgs = () => } }); +export type ProjectRuleQueryArgs = ReturnType; + export const getProjectRuleQueryArgs = () => Prisma.validator()({ include: { - rule: getRulePreviewQueryArgs(), - project: { select: { projectId: true } } + rule: getRulePreviewQueryArgs() } }); diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 9f4ecb7bd2..6d8f46e431 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -4785,7 +4785,7 @@ const performSeed: () => Promise = async () => { // project rules await RulesService.createProjectRule(batman, ner, ruleT211.ruleId, projectHuskies1Id); - // mark the leaf rule complete from the project to demonstrate global rule completion + // mark the leaf rule complete await RulesService.setRuleCompletion(batman, ner, ruleT211.ruleId, true, projectHuskies1Id); // Technical Rules Section diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index a1586226d0..386baba4d2 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -385,8 +385,7 @@ export default class RulesService { throw new HttpException(400, 'This rule is already associated with the project'); } - // Walk up the parent chain to assign all ancestors of a rule to the project as well. - // visited guards against cycles, which editRule does not currently prevent. + // ensure we assign all ancestors of a rule to the project const ancestorIds: string[] = []; const visited = new Set([ruleId]); let currentParentId = rule.parentRuleId; @@ -397,13 +396,15 @@ export default class RulesService { where: { ruleId: currentParentId }, select: { parentRuleId: true, dateDeleted: true } }); - // Stop if the ancestor is missing or deleted - deleted rules should not be assigned to projects - if (!parent || parent.dateDeleted) break; + // rule only displays if the full chain to a top-level rule exists, so a missing or deleted + // ancestor means this rule would not display - do not assign it OR its ancestors to the project + if (!parent) throw new NotFoundException('Rule', currentParentId); + if (parent.dateDeleted) throw new DeletedException('Rule', currentParentId); ancestorIds.push(currentParentId); currentParentId = parent.parentRuleId; } - // Only create ancestors that aren't already assigned to the project to avoid duplicate assignment issues + // skip ancestors already assigned to this project const existingAncestors = await prisma.project_Rule.findMany({ where: { projectId, ruleId: { in: ancestorIds }, dateDeleted: null }, select: { ruleId: true } @@ -411,6 +412,7 @@ export default class RulesService { const existingAncestorIds = new Set(existingAncestors.map((projectRule) => projectRule.ruleId)); const ancestorsToCreate = ancestorIds.filter((id) => !existingAncestorIds.has(id)); + // create all project rules await prisma.$transaction([ ...ancestorsToCreate.map((ancestorId) => prisma.project_Rule.create({ @@ -430,6 +432,7 @@ export default class RulesService { }) ]); + // return only original project rule being assigned (leaf rule) const projectRule = await prisma.project_Rule.findUnique({ where: { ruleId_projectId: { ruleId, projectId } }, ...getProjectRuleQueryArgs() @@ -703,8 +706,8 @@ export default class RulesService { * @param submitter the user updating the completion * @param organization the organization of the rule * @param ruleId the id of the rule to update - * @param isComplete whether the rule is complete - * @param projectId the project the rule was completed from (optional - omitted for general view) + * @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 */ static async setRuleCompletion( @@ -715,7 +718,7 @@ export default class RulesService { projectId?: string ): Promise { if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) { - throw new AccessDeniedException('You do not have permissions to update a rule completion'); + throw new AccessDeniedException('You do not have permissions to update rule completion'); } const rule = await prisma.rule.findUnique({ diff --git a/src/backend/src/transformers/rules.transformer.ts b/src/backend/src/transformers/rules.transformer.ts index 5159ae5f55..38fc480d60 100644 --- a/src/backend/src/transformers/rules.transformer.ts +++ b/src/backend/src/transformers/rules.transformer.ts @@ -1,6 +1,6 @@ import { Prisma } from '@prisma/client'; import { Rule, ProjectRule, Ruleset, RulesetType } from 'shared'; -import { RulesetQueryArgs, RulePreviewQueryArgs } from '../prisma-query-args/rules.query-args'; +import { RulesetQueryArgs, RulePreviewQueryArgs, ProjectRuleQueryArgs } from '../prisma-query-args/rules.query-args.js'; export const ruleTransformer = (rule: Prisma.RuleGetPayload): Rule => { return { @@ -36,7 +36,7 @@ export const ruleTransformer = (rule: Prisma.RuleGetPayload { +export const projectRuleTransformer = (projectRule: Prisma.Project_RuleGetPayload): ProjectRule => { return { projectRuleId: projectRule.projectRuleId, rule: ruleTransformer(projectRule.rule), diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index 6f6ec65050..db093f3135 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -368,15 +368,15 @@ describe('Create Rules Tests', () => { }); describe('Create Project Rule', () => { - it('Creates project rules for the full ancestor chain when assigning a nested sub-rule', async () => { - const root = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + it('Creates project rules for all ancestors when assigning deep child rule', async () => { + const topLevelRule = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); const child = await RulesService.createRule( batman, 'T.1.1', 'Vehicle Requirements', rulesetId, organization, - root.ruleId + topLevelRule.ruleId ); const grandchild = await RulesService.createRule(batman, 'T.1.1.1', 'Wheels', rulesetId, organization, child.ruleId); @@ -386,19 +386,19 @@ describe('Create Rules Tests', () => { const projectRules = await RulesService.getProjectRules(rulesetId, project.projectId, organization); const assignedRuleIds = projectRules.map((pr) => pr.rule.ruleId); - expect(assignedRuleIds).toHaveLength(3); - expect(assignedRuleIds).toEqual(expect.arrayContaining([root.ruleId, child.ruleId, grandchild.ruleId])); + expect(assignedRuleIds).toHaveLength(3); // grandchild, child, topLevelRule + expect(assignedRuleIds).toEqual(expect.arrayContaining([topLevelRule.ruleId, child.ruleId, grandchild.ruleId])); }); - it('Creates project rules for shared ancestors when adding a sibling sub-rule', async () => { - const root = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + it('Creates project rules for shared ancestors when adding a sibling child rule', async () => { + const topLevelRule = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); const child = await RulesService.createRule( batman, 'T.1.1', 'Vehicle Requirements', rulesetId, organization, - root.ruleId + topLevelRule.ruleId ); const grandchild1 = await RulesService.createRule(batman, 'T.1.1.1', 'Wheels', rulesetId, organization, child.ruleId); const grandchild2 = await RulesService.createRule(batman, 'T.1.1.2', 'Brakes', rulesetId, organization, child.ruleId); @@ -406,26 +406,26 @@ describe('Create Rules Tests', () => { const project = await createTestProject(aquaman, orgId, undefined, carId); await RulesService.createProjectRule(aquaman, organization, grandchild1.ruleId, project.projectId); - // Adding a sibling must not error on the already-present parent/root and must not duplicate them. + // adding sibling must not error or duplicate the already-present parent/root rules await RulesService.createProjectRule(aquaman, organization, grandchild2.ruleId, project.projectId); const projectRules = await RulesService.getProjectRules(rulesetId, project.projectId, organization); const assignedRuleIds = projectRules.map((pr) => pr.rule.ruleId); - expect(assignedRuleIds).toHaveLength(4); + expect(assignedRuleIds).toHaveLength(4); // grandchild1, grandchild2, child, topLevelRule expect(assignedRuleIds).toEqual( - expect.arrayContaining([root.ruleId, child.ruleId, grandchild1.ruleId, grandchild2.ruleId]) + expect.arrayContaining([topLevelRule.ruleId, child.ruleId, grandchild1.ruleId, grandchild2.ruleId]) ); }); - it('does not assign descendants of the selected rule', async () => { - const root = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + it('Creating project rule does not assign descendants of the selected rule', async () => { + const topLevelRule = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); const child = await RulesService.createRule( batman, 'T.1.1', 'Vehicle Requirements', rulesetId, organization, - root.ruleId + topLevelRule.ruleId ); await RulesService.createRule(batman, 'T.1.1.1', 'Wheels', rulesetId, organization, child.ruleId); @@ -435,23 +435,23 @@ describe('Create Rules Tests', () => { const projectRules = await RulesService.getProjectRules(rulesetId, project.projectId, organization); const assignedRuleIds = projectRules.map((pr) => pr.rule.ruleId); - expect(assignedRuleIds).toHaveLength(2); - expect(assignedRuleIds).toEqual(expect.arrayContaining([root.ruleId, child.ruleId])); + expect(assignedRuleIds).toHaveLength(2); // child and topLevelRule, not grandchild + expect(assignedRuleIds).toEqual(expect.arrayContaining([topLevelRule.ruleId, child.ruleId])); }); - it('does not assign deleted ancestors when assigning a nested sub-rule', async () => { - const root = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + it('Creating project rule is refused entirely when an ancestor has been deleted', async () => { + const topLevelRule = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); const child = await RulesService.createRule( batman, 'T.1.1', 'Vehicle Requirements', rulesetId, organization, - root.ruleId + topLevelRule.ruleId ); const grandchild = await RulesService.createRule(batman, 'T.1.1.1', 'Wheels', rulesetId, organization, child.ruleId); - // Soft-delete the immediate parent directly so the grandchild remains assignable + // soft-delete an ancestor so the chain to the top-level rule is broken await prisma.rule.update({ where: { ruleId: child.ruleId }, data: { dateDeleted: new Date(), deletedBy: { connect: { userId: batman.userId } } } @@ -459,19 +459,19 @@ describe('Create Rules Tests', () => { const project = await createTestProject(aquaman, orgId, undefined, carId); - await RulesService.createProjectRule(aquaman, organization, grandchild.ruleId, project.projectId); + // a broken chain means the grandchild could never display, so no rules are assigned to the project and an error is thrown + await expect( + async () => await RulesService.createProjectRule(aquaman, organization, grandchild.ruleId, project.projectId) + ).rejects.toThrow(new DeletedException('Rule', child.ruleId)); - // The walk stops at the deleted ancestor, so neither it nor the root above it are assigned. + // nothing should have been assigned (not the grandchild, the deleted parent, or the root) const projectRules = await RulesService.getProjectRules(rulesetId, project.projectId, organization); - const assignedRuleIds = projectRules.map((pr) => pr.rule.ruleId); - expect(assignedRuleIds).toHaveLength(1); - expect(assignedRuleIds).toEqual([grandchild.ruleId]); + expect(projectRules).toHaveLength(0); - // getProjectRules hides deleted rules, so assert directly that none was created for the deleted ancestor - const deletedAncestorProjectRule = await prisma.project_Rule.findUnique({ - where: { ruleId_projectId: { ruleId: child.ruleId, projectId: project.projectId } } + const grandchildProjectRule = await prisma.project_Rule.findUnique({ + where: { ruleId_projectId: { ruleId: grandchild.ruleId, projectId: project.projectId } } }); - expect(deletedAncestorProjectRule).toBeNull(); + expect(grandchildProjectRule).toBeNull(); }); it('throws when the rule is already associated with the project', async () => { From 1efea503770a46b977dd6fa0094b872686f47792 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 24 Jun 2026 15:13:05 -0400 Subject: [PATCH 07/16] #4284 update seed data with real rules --- .../src/prisma/seed-data/rules.seed.ts | 716 ++++++++++++++++-- src/backend/src/prisma/seed.ts | 409 +--------- 2 files changed, 678 insertions(+), 447 deletions(-) diff --git a/src/backend/src/prisma/seed-data/rules.seed.ts b/src/backend/src/prisma/seed-data/rules.seed.ts index 45d46a020a..e179f8befb 100644 --- a/src/backend/src/prisma/seed-data/rules.seed.ts +++ b/src/backend/src/prisma/seed-data/rules.seed.ts @@ -1,59 +1,10 @@ import type { Prisma } from '@prisma/client'; -import { Organization } from '@prisma/client'; +import { Organization, PrismaClient } from '@prisma/client'; import RulesService from '../../services/rules.services.js'; import { User } from 'shared'; -// rules -const topLevelRule = (rulesetId: string, userCreatedId: string): Prisma.RuleCreateInput => { - return { - ruleCode: 'T', - ruleContent: 'PART T - GENERAL TECHNICAL REQUIREMENTS', - imageFileIds: [], - dateCreated: new Date('2025-09-01T10:00:00Z'), - ruleset: { connect: { rulesetId } }, - createdBy: { connect: { userId: userCreatedId } } - }; -}; - -const secondLevelRule = (rulesetId: string, userCreatedId: string, parentRuleId: string): Prisma.RuleCreateInput => { - return { - ruleCode: 'T2', - ruleContent: 'ARTICLE T2 GENERAL DESIGN REQUIREMENTS', - imageFileIds: [], - dateCreated: new Date('2025-09-01T10:00:00Z'), - ruleset: { connect: { rulesetId } }, - createdBy: { connect: { userId: userCreatedId } }, - parentRule: { connect: { ruleId: parentRuleId } } - }; -}; - -const thirdLevelRule = (rulesetId: string, userCreatedId: string, parentRuleId: string): Prisma.RuleCreateInput => { - return { - ruleCode: 'T2.1', - ruleContent: 'T2.1 Vehicle Configuration', - imageFileIds: [], - dateCreated: new Date('2025-09-01T10:00:00Z'), - ruleset: { connect: { rulesetId } }, - createdBy: { connect: { userId: userCreatedId } }, - parentRule: { connect: { ruleId: parentRuleId } } - }; -}; - -const leafRule = (rulesetId: string, userCreatedId: string, parentRuleId: string): Prisma.RuleCreateInput => { - return { - ruleCode: 'T2.1.1', - ruleContent: - 'The vehicle must be open-wheeled and open-cockpit (a formula style body) with four (4) wheels that are not in a straight line.', - imageFileIds: [], - dateCreated: new Date('2025-09-01T10:00:00Z'), - ruleset: { connect: { rulesetId } }, - createdBy: { connect: { userId: userCreatedId } }, - parentRule: { connect: { ruleId: parentRuleId } } - }; -}; - // ruleset types -const rulesetType1 = (userCreatedId: string, organizationId: string): Prisma.Ruleset_TypeCreateInput => { +const rulesetTypeFSAE = (userCreatedId: string, organizationId: string): Prisma.Ruleset_TypeCreateInput => { return { name: 'FSAE', createdBy: { connect: { userId: userCreatedId } }, @@ -61,7 +12,7 @@ const rulesetType1 = (userCreatedId: string, organizationId: string): Prisma.Rul }; }; -const rulesetType2 = (userCreatedId: string, organizationId: string): Prisma.Ruleset_TypeCreateInput => { +const rulesetTypeFHE = (userCreatedId: string, organizationId: string): Prisma.Ruleset_TypeCreateInput => { return { name: 'FHE', createdBy: { connect: { userId: userCreatedId } }, @@ -69,19 +20,19 @@ const rulesetType2 = (userCreatedId: string, organizationId: string): Prisma.Rul }; }; -const emptyRulesetType = (userCreatedId: string, organizationId: string): Prisma.Ruleset_TypeCreateInput => { +const mockRulesetType = (userCreatedId: string, organizationId: string): Prisma.Ruleset_TypeCreateInput => { return { - name: 'Empty Ruleset Type', + name: 'Mock Ruleset Type', createdBy: { connect: { userId: userCreatedId } }, organization: { connect: { organizationId } } }; }; // rulesets -const ruleset1 = (carId: string, userCreatedId: string, rulesetTypeId: string): Prisma.RulesetCreateInput => { +const rulesetFSAE = (carId: string, userCreatedId: string, rulesetTypeId: string): Prisma.RulesetCreateInput => { return { - name: 'FSAE Rules 2025', - fileId: 'fsae-rules-2025', + name: 'Mock FSAE', + fileId: 'mock-fsae-rules', active: true, dateCreated: new Date('2025-01-01T10:00:00Z'), car: { connect: { carId } }, @@ -90,10 +41,22 @@ const ruleset1 = (carId: string, userCreatedId: string, rulesetTypeId: string): }; }; -const secondActiveRuleset = (carId: string, userCreatedId: string, rulesetTypeId: string): Prisma.RulesetCreateInput => { +const rulesetFHE = (carId: string, userCreatedId: string, rulesetTypeId: string): Prisma.RulesetCreateInput => { + return { + name: 'Mock FHE', + fileId: 'mock-fhe-rules', + active: true, + dateCreated: new Date('2024-12-31T10:00:00Z'), + car: { connect: { carId } }, + createdBy: { connect: { userId: userCreatedId } }, + rulesetType: { connect: { rulesetTypeId } } + }; +}; + +const rulesetMock = (carId: string, userCreatedId: string, rulesetTypeId: string): Prisma.RulesetCreateInput => { return { - name: 'Another Active FSAE Rules 2025 Revision', - fileId: '2active-fsae-rules-2025', + name: 'Mock Ruleset', + fileId: 'mock-rules', active: true, dateCreated: new Date('2024-12-31T10:00:00Z'), car: { connect: { carId } }, @@ -124,16 +87,631 @@ export const seedRulesetType = async (submitter: User, name: string, organizatio return createdRulesetType; }; +/** + * Seeds the FSAE and FHE rulesets, including parent/child relationships and + * cross-references between rules. Also assigns a leaf rule to the given project + * and marks it complete to demonstrate project rules and global rule completion. + * + * @param prisma the prisma client used by the seed script + * @param fsaeRulesetId fsae mock ruleset the bulk of the rules belong to + * @param fheRulesetId fhe mock ruleset + * @param mockRulesetId a mock ruleset used for testing + * @param users the users credited as rule creators + * @param organization the organization the rules/project belong to + * @param projectId the project a leaf rule is assigned to and completed in + * @param huskyTeamId the team a leaf rule is assigned to + */ +export const seedFsaeRules = async ( + prisma: PrismaClient, + fsaeRulesetId: string, + fheRulesetId: string, + mockRulesetId: string, + users: { batman: User; thomasEmrax: User; joeShmoe: User; joeBlow: User; superman: User }, + organization: Organization, + projectId: string, + huskyTeamId: string +) => { + const { batman, thomasEmrax, joeShmoe, joeBlow, superman } = users; + + // Technical Rules (from FSAE 2026 rules) + const topLevelTechnical = await prisma.rule.create({ + data: { + ruleCode: 'T', + ruleContent: 'TECHNICAL ASPECTS', + rulesetId: fsaeRulesetId, + createdByUserId: batman.userId + } + }); + + const T1Rule = await prisma.rule.create({ + data: { + ruleCode: 'T.1', + ruleContent: 'COCKPIT', + rulesetId: fsaeRulesetId, + parentRuleId: topLevelTechnical.ruleId, + createdByUserId: batman.userId + } + }); + + const T11Rule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.1', + ruleContent: 'Cockpit Opening', + rulesetId: fsaeRulesetId, + parentRuleId: T1Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'T.1.1.1', + ruleContent: 'The template shown below must pass through the cockpit opening', + rulesetId: fsaeRulesetId, + parentRuleId: T11Rule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + const T112Rule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.1.2', + ruleContent: + 'The template will be held horizontally, parallel to the ground, and inserted vertically from a height above any Primary Structure or bodywork that is between the Front Hoop and the Main Hoop until it meets the two of: ( refer to F.6.4 and F.7.5.1 )', + rulesetId: fsaeRulesetId, + parentRuleId: T11Rule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + const T112ARule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.1.2.a', + ruleContent: 'Has passed 25 mm below the lowest point of the top of the Side Impact Structure', + rulesetId: fsaeRulesetId, + parentRuleId: T112Rule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + const T112BRule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.1.2.b', + ruleContent: 'Is less than or equal to 320 mm above the lowest point inside the cockpit', + rulesetId: fsaeRulesetId, + parentRuleId: T112Rule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + const T12Rule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.2', + ruleContent: 'Internal Cross Section', + rulesetId: fsaeRulesetId, + parentRuleId: T1Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + const T121Rule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.2.1', + ruleContent: 'Requirement:', + rulesetId: fsaeRulesetId, + parentRuleId: T12Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'T.1.2.1.a', + ruleContent: 'The cockpit must have a free internal cross section', + rulesetId: fsaeRulesetId, + parentRuleId: T121Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'T.1.2.1.b', + ruleContent: 'The template shown below must pass through the cockpit', + rulesetId: fsaeRulesetId, + parentRuleId: T121Rule.ruleId, + createdByUserId: thomasEmrax.userId, + imageFileIds: [] // add image here when implemented (page 56 of FSAE 2026) + } + }); + + // IC Rules (from FSAE 2026 rules) + const ICRule = await prisma.rule.create({ + data: { + ruleCode: 'IC', + ruleContent: 'INTERNAL COMBUSTION ENGINE VEHICLES', + rulesetId: fsaeRulesetId, + createdByUserId: thomasEmrax.userId + } + }); + + const IC1Rule = await prisma.rule.create({ + data: { + ruleCode: 'IC.1', + ruleContent: 'GENERAL REQUIREMENTS', + rulesetId: fsaeRulesetId, + parentRuleId: ICRule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + const IC5Rule = await prisma.rule.create({ + data: { + ruleCode: 'IC.5', + ruleContent: 'FUEL AND FUEL SYSTEM', + rulesetId: fsaeRulesetId, + parentRuleId: ICRule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + const IC56Rule = await prisma.rule.create({ + data: { + ruleCode: 'IC.5.6', + ruleContent: 'Venting Systems', + rulesetId: fsaeRulesetId, + parentRuleId: IC5Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + const IC561Rule = await prisma.rule.create({ + data: { + ruleCode: 'IC.5.6.1', + ruleContent: + 'Venting systems for the fuel tank and fuel delivery system must not let fuel spill during hard cornering or acceleration', + rulesetId: fsaeRulesetId, + parentRuleId: IC56Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + const IC562Rule = await prisma.rule.create({ + data: { + ruleCode: 'IC.5.6.2', + ruleContent: 'All fuel vent lines must have a check valve to prevent fuel leakage when the tank is inverted', + rulesetId: fsaeRulesetId, + parentRuleId: IC56Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + const IC563Rule = await prisma.rule.create({ + data: { + ruleCode: 'IC.5.6.3', + ruleContent: 'All fuel vent lines must exit outside the bodywork', + rulesetId: fsaeRulesetId, + parentRuleId: IC56Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + // Chassis and Structural Rules (from FSAE 2025 rules) + const FRule = await prisma.rule.create({ + data: { + ruleCode: 'F', + ruleContent: 'CHASSIS AND STRUCTURAL', + rulesetId: fsaeRulesetId, + createdByUserId: thomasEmrax.userId + } + }); + + const F3Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.3', + ruleContent: 'TUBING AND MATERIAL', + rulesetId: fsaeRulesetId, + parentRuleId: FRule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + const F34Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.3.4', + ruleContent: 'Steel Tubing and Material', + rulesetId: fsaeRulesetId, + parentRuleId: F3Rule.ruleId, + createdByUserId: joeBlow.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'F.3.4.1', + ruleContent: + 'Minimum Requirements for Steel Tubing. A tube must have all four minimum requirements for each Size specified:', + rulesetId: fsaeRulesetId, + parentRuleId: F34Rule.ruleId, + createdByUserId: batman.userId, + imageFileIds: [] // table FSAE 2025 page 26 + } + }); + + const F342Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.3.4.2', + ruleContent: 'Properties for ANY steel material for calculations submitted in an SES must be:', + rulesetId: fsaeRulesetId, + parentRuleId: F34Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'F.3.4.2.a', + ruleContent: + 'Non Welded Properties for continuous material calculations: Young’s Modulus (E) = 200 GPa (29,000 ksi) Yield Strength (Sy) = 305 MPa (44.2 ksi) Ultimate Strength (Su) = 365 MPa (52.9 ksi)', + rulesetId: fsaeRulesetId, + parentRuleId: F342Rule.ruleId, + createdByUserId: batman.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'F.3.4.2.b', + ruleContent: + 'Welded Properties for discontinuous material such as joint calculations: Yield Strength (Sy) = 180 MPa (26 ksi) Ultimate Strength (Su) = 300 MPa (43.5 ksi)', + rulesetId: fsaeRulesetId, + parentRuleId: F342Rule.ruleId, + createdByUserId: batman.userId + } + }); + + const F32Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.3.2', + ruleContent: 'Tubing Requirements', + rulesetId: fsaeRulesetId, + parentRuleId: F3Rule.ruleId, + createdByUserId: batman.userId + } + }); + + const F321Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.3.2.1', + ruleContent: 'Requirements by Application', + rulesetId: fsaeRulesetId, + parentRuleId: F32Rule.ruleId, + createdByUserId: batman.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'F.3.2.1.b', + ruleContent: 'Front Bulkhead Support Size C Yes', // info is part of first table from FSAE page 26 + rulesetId: fsaeRulesetId, + parentRuleId: F321Rule.ruleId, + createdByUserId: batman.userId + } + }); + + // Referenced later by F.5.7.1 + const F321cRule = await prisma.rule.create({ + data: { + ruleCode: 'F.3.2.1.c', + ruleContent: 'Front Hoop Size A Yes', // info is part of first table from FSAE page 26 + rulesetId: fsaeRulesetId, + parentRuleId: F321Rule.ruleId, + createdByUserId: batman.userId + } + }); + + // F siblings + const F5Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.5', + ruleContent: 'CHASSIS REQUIREMENTS', + rulesetId: fsaeRulesetId, + parentRuleId: FRule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + const F57Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.5.7', + ruleContent: 'Front Hoop', + rulesetId: fsaeRulesetId, + parentRuleId: F5Rule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + // Rule F.5.7.1 references F.3.2.1.c + const F571Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.5.7.1', + ruleContent: 'The Front Hoop must be constructed of closed section metal tubing meeting F.3.2.1.c', + rulesetId: fsaeRulesetId, + parentRuleId: F57Rule.ruleId, + createdByUserId: joeShmoe.userId, + referencedRule: { + connect: [{ ruleId: F321cRule.ruleId }] // Referenced rule + } + } + }); + + // Dynamic Events (from FSAE 2025 rules) + const DRule = await prisma.rule.create({ + data: { + ruleCode: 'D', + ruleContent: 'DYNAMIC EVENTS', + rulesetId: fsaeRulesetId, + createdByUserId: batman.userId + } + }); + + const D3Rule = await prisma.rule.create({ + data: { + ruleCode: 'D.3', + ruleContent: 'DRIVING', + rulesetId: fsaeRulesetId, + parentRuleId: DRule.ruleId, + createdByUserId: superman.userId + } + }); + + const D35Rule = await prisma.rule.create({ + data: { + ruleCode: 'D.3.5', + ruleContent: 'Driver Equipment', + rulesetId: fsaeRulesetId, + parentRuleId: D3Rule.ruleId, + createdByUserId: superman.userId + } + }); + + const D351Rule = await prisma.rule.create({ + data: { + ruleCode: 'D.3.5.1', + ruleContent: 'All Driver Equipment and Harness must be worn by the driver anytime in the cockpit with:', + rulesetId: fsaeRulesetId, + parentRuleId: D35Rule.ruleId, + createdByUserId: batman.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'D.3.5.1.a', + ruleContent: '(IC) Engine running or (EV) Tractive System Active', + rulesetId: fsaeRulesetId, + parentRuleId: D351Rule.ruleId, + createdByUserId: batman.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'D.3.5.1.b', + ruleContent: 'Anytime between starting a Dynamic run and finishing or abandoning that Dynamic run', + rulesetId: fsaeRulesetId, + parentRuleId: D351Rule.ruleId, + createdByUserId: batman.userId + } + }); + + // Technical Requirements from FHE 2026 + const TRule = await prisma.rule.create({ + data: { + ruleCode: 'T', + ruleContent: 'PART T - GENERAL TECHNICAL REQUIREMENTS', + rulesetId: fheRulesetId, + createdByUserId: batman.userId + } + }); + + const T2Rule = await prisma.rule.create({ + data: { + ruleCode: 'T2', + ruleContent: 'ARTICLE T2 - GENERAL DESIGN REQUIREMENTS', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: TRule.ruleId + } + }); + + const T21Rule = await prisma.rule.create({ + data: { + ruleCode: 'T2.1', + ruleContent: 'Vehicle Configuration', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T2Rule.ruleId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'T2.1.1', + ruleContent: + 'The vehicle must be open-wheeled and open-cockpit (a formula style body) with four (4) wheels that are not in a straight line.', + rulesetId: fheRulesetId, + parentRuleId: T21Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'T2.2', + ruleContent: + 'Bodywork There must be no openings through the bodywork into the driver compartment from the front of the vehicle back to the roll bar main hoop or firewall other than that required for the cockpit opening. Minimal openings around the front suspension components are allowed.', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T2Rule.ruleId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'T2.3', + ruleContent: + 'Wheelbase The car must have a wheelbase of at least 1524 mm. The wheelbase is measured from the center of ground contact of the front and rear tires with the wheels pointed straight ahead.', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T2Rule.ruleId + } + }); + + const T3Rule = await prisma.rule.create({ + data: { + ruleCode: 'T3', + ruleContent: 'ARTICLE T3 - SAFETY REQUIREMENTS', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: TRule.ruleId + } + }); + + const T33Rule = await prisma.rule.create({ + data: { + ruleCode: 'T3.3', + ruleContent: 'Minimum Material Requirements', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T3Rule.ruleId + } + }); + + const T332Rule = await prisma.rule.create({ + data: { + ruleCode: 'T3.3.2', + ruleContent: + 'When a cutout, or a hole greater in diameter than 3/16 inch (4 mm), is made in a regulated tube, e.g. to mount the safety harness or suspension and steering components, in order to regain the baseline, cold rolled strength of the original tubing, the tubing must be reinforced by the use of a welded insert or other reinforcement. The welded strength figures given above must be used for the additional material. And the details, including dimensioned drawings, must be included in the SES.', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T33Rule.ruleId + } + }); + + const T331Rule = await prisma.rule.create({ + data: { + ruleCode: 'T3.3.1', + ruleContent: + 'Baseline Steel Material The Primary Structure of the car must be constructed of: Either: Round, mild or alloy, steel tubing (minimum 0.1% carbon) of the minimum dimensions specified in Table 4 . Or: Approved alternatives per Rules T3.3, T3.3.2, T3.5 and T3.6.', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T33Rule.ruleId, + referencedRule: { + connect: [{ ruleId: T33Rule.ruleId }, { ruleId: T332Rule.ruleId }] // add other references later + } + } + }); + + const T312Rule = await prisma.rule.create({ + data: { + ruleCode: 'T3.12', + ruleContent: 'Main Hoop Bracing', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T3Rule.ruleId + } + }); + + // T3.12.1 references T3.3.1 + await prisma.rule.create({ + data: { + ruleCode: 'T3.12.1', + ruleContent: 'Main Hoop braces must be constructed of closed section steel tubing per Rule T3.3.1.', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T312Rule.ruleId, + referencedRule: { + connect: [{ ruleId: T331Rule.ruleId }] // Referenced rule + } + } + }); + + // Add mock rules to mock ruleset for depth testing + + const rule1 = await prisma.rule.create({ + data: { + ruleCode: '1', + ruleContent: '', + rulesetId: mockRulesetId, + createdByUserId: superman.userId + } + }); + + const rule2 = await prisma.rule.create({ + data: { + ruleCode: '1.1', + ruleContent: '', + rulesetId: mockRulesetId, + parentRuleId: rule1.ruleId, + createdByUserId: superman.userId + } + }); + + const rule3 = await prisma.rule.create({ + data: { + ruleCode: '1.1.1', + ruleContent: '', + rulesetId: mockRulesetId, + parentRuleId: rule2.ruleId, + createdByUserId: superman.userId + } + }); + + const rule4 = await prisma.rule.create({ + data: { + ruleCode: '1.1.1.1', + ruleContent: '', + rulesetId: mockRulesetId, + parentRuleId: rule3.ruleId, + createdByUserId: superman.userId + } + }); + + const rule5 = await prisma.rule.create({ + data: { + ruleCode: '1.1.1.1.1', + ruleContent: '', + rulesetId: mockRulesetId, + parentRuleId: rule4.ruleId, + createdByUserId: superman.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: '1.1.1.1.1.1', + ruleContent: '', + rulesetId: mockRulesetId, + parentRuleId: rule5.ruleId, + createdByUserId: superman.userId + } + }); + + // Add rule to husky team and then bodywork project and mark as completed + await RulesService.toggleRuleTeam(T112ARule.ruleId, huskyTeamId, batman, organization); + await RulesService.createProjectRule(batman, organization, T112ARule.ruleId, projectId); + await RulesService.setRuleCompletion(batman, organization, T112ARule.ruleId, true, projectId); +}; + export const ruleSeedData = { - topLevelRule, - secondLevelRule, - thirdLevelRule, - leafRule, - rulesetType1, - rulesetType2, - emptyRulesetType, - ruleset1, - secondActiveRuleset, + rulesetTypeFHE, + rulesetTypeFSAE, + mockRulesetType, + rulesetFSAE, + rulesetFHE, + rulesetMock, projectRule1, projectRule2 }; diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 6d8f46e431..8772312dbd 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -48,8 +48,7 @@ import AnnouncementService from '../services/announcement.services.js'; import OnboardingServices from '../services/onboarding.services.js'; import { dbSeedAllParts, dbSeedAllPartTags } from './seed-data/parts.seed.js'; import FinanceServices from '../services/finance.services.js'; -import { ruleSeedData } from './seed-data/rules.seed.js'; -import RulesService from '../services/rules.services.js'; +import { ruleSeedData, seedFsaeRules } from './seed-data/rules.seed.js'; import CalendarService from '../services/calendar.services.js'; import { allChangeRequestsReviewed } from '../utils/change-requests.utils.js'; @@ -4726,396 +4725,50 @@ const performSeed: () => Promise = async () => { * Rules */ - // ruleset types + // create ruleset types const fsaeRulesetType = await prisma.ruleset_Type.create({ - data: ruleSeedData.rulesetType1(batman.userId, ner.organizationId) + data: ruleSeedData.rulesetTypeFSAE(batman.userId, ner.organizationId) }); - await prisma.ruleset_Type.create({ - data: ruleSeedData.rulesetType2(batman.userId, ner.organizationId) + const fheRulesetType = await prisma.ruleset_Type.create({ + data: ruleSeedData.rulesetTypeFHE(batman.userId, ner.organizationId) }); - await prisma.ruleset_Type.create({ - data: ruleSeedData.emptyRulesetType(batman.userId, ner.organizationId) + const mockRulesetType = await prisma.ruleset_Type.create({ + data: ruleSeedData.mockRulesetType(batman.userId, ner.organizationId) }); - // rulesets - const ruleset1 = await prisma.ruleset.create({ - data: ruleSeedData.ruleset1(fergus.carId, batman.userId, fsaeRulesetType.rulesetTypeId) + // create rulesets + const rulesetFSAE = await prisma.ruleset.create({ + data: ruleSeedData.rulesetFSAE(fergus.carId, batman.userId, fsaeRulesetType.rulesetTypeId) }); - await prisma.ruleset.create({ - data: ruleSeedData.secondActiveRuleset(fergus.carId, batman.userId, fsaeRulesetType.rulesetTypeId) + const rulesetFHE = await prisma.ruleset.create({ + data: ruleSeedData.rulesetFHE(fergus.carId, batman.userId, fheRulesetType.rulesetTypeId) }); - const fsae2025Ruleset = await prisma.ruleset.create({ - data: { - fileId: 'fsae-2025-rules-file-id', - name: '2025 FSAE Electric Rules', - active: true, - rulesetTypeId: fsaeRulesetType.rulesetTypeId, - carId: fergus.carId, - createdByUserId: batman.userId - } - }); - - const fsae2024Ruleset = await prisma.ruleset.create({ - data: { - fileId: 'fsae-2024-rules-file-id', - name: '2024 FSAE Electric Rules', - active: false, - rulesetTypeId: fsaeRulesetType.rulesetTypeId, - carId: fergus.carId, - createdByUserId: batman.userId - } - }); - - // rules - const ruleT = await prisma.rule.create({ data: ruleSeedData.topLevelRule(ruleset1.rulesetId, batman.userId) }); - const ruleT2 = await prisma.rule.create({ - data: ruleSeedData.secondLevelRule(ruleset1.rulesetId, batman.userId, ruleT.ruleId) - }); - const ruleT21 = await prisma.rule.create({ - data: ruleSeedData.thirdLevelRule(ruleset1.rulesetId, batman.userId, ruleT2.ruleId) - }); - const ruleT211 = await prisma.rule.create({ - data: ruleSeedData.leafRule(ruleset1.rulesetId, batman.userId, ruleT21.ruleId) - }); - - // project rules - await RulesService.createProjectRule(batman, ner, ruleT211.ruleId, projectHuskies1Id); - - // mark the leaf rule complete - await RulesService.setRuleCompletion(batman, ner, ruleT211.ruleId, true, projectHuskies1Id); - - // Technical Rules Section - const techRule = await prisma.rule.create({ - data: { - ruleCode: 'T.1', - ruleContent: 'Technical Rules - All technical requirements for the vehicle must be met to compete', - rulesetId: fsae2025Ruleset.rulesetId, - createdByUserId: batman.userId - } - }); - - const vehicleConfigRule = await prisma.rule.create({ - data: { - ruleCode: 'T.1.1', - ruleContent: 'Vehicle Configuration - The vehicle must be a four-wheeled, open-wheel, open-cockpit vehicle', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: techRule.ruleId, - createdByUserId: thomasEmrax.userId, - imageFileIds: [] - } - }); - - const wheelRule = await prisma.rule.create({ - data: { - ruleCode: 'T.1.1.1', - ruleContent: 'All four wheels must be visible when viewed from above. Wheels must not exceed 13 inches in diameter', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: vehicleConfigRule.ruleId, - createdByUserId: joeShmoe.userId - } - }); - - const wheelbaseRule = await prisma.rule.create({ - data: { - ruleCode: 'T.1.1.2', - ruleContent: 'The wheelbase must be at least 1525 mm (60 inches)', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: vehicleConfigRule.ruleId, - createdByUserId: joeShmoe.userId - } - }); - - const trackWidthRule = await prisma.rule.create({ - data: { - ruleCode: 'T.1.1.3', - ruleContent: 'The smaller track width must be no less than 75% of the wheelbase', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: vehicleConfigRule.ruleId, - createdByUserId: thomasEmrax.userId - } - }); - - // Powertrain Rules - const powertrainRule = await prisma.rule.create({ - data: { - ruleCode: 'T.1.2', - ruleContent: 'Powertrain - Electric powertrain systems must comply with all electrical safety requirements', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: techRule.ruleId, - createdByUserId: thomasEmrax.userId - } - }); - - const motorRule = await prisma.rule.create({ - data: { - ruleCode: 'T.1.2.1', - ruleContent: 'The maximum nominal voltage of the accumulator must not exceed 600 VDC', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: powertrainRule.ruleId, - createdByUserId: joeShmoe.userId - } - }); - - const motorPowerRule = await prisma.rule.create({ - data: { - ruleCode: 'T.1.2.2', - ruleContent: 'The maximum continuous power delivered by the accumulator must not exceed 80 kW', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: powertrainRule.ruleId, - createdByUserId: joeBlow.userId - } - }); - - // Chassis Rules - const chassisRule = await prisma.rule.create({ - data: { - ruleCode: 'T.1.3', - ruleContent: 'Chassis and Frame - The chassis must provide adequate driver protection', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: techRule.ruleId, - createdByUserId: batman.userId, - imageFileIds: ['chassis-spec-drawing-1', 'chassis-spec-drawing-2'] - } - }); - - const chassisMaterialRule = await prisma.rule.create({ - data: { - ruleCode: 'T.1.3.1', - ruleContent: 'The frame must be a space frame design or a carbon fiber monocoque meeting specific standards', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: chassisRule.ruleId, - createdByUserId: thomasEmrax.userId - } - }); - - // Safety Rules Section - const safetyRule = await prisma.rule.create({ - data: { - ruleCode: 'S.1', - ruleContent: 'Safety Rules - All safety requirements must be met before the vehicle is allowed to compete', - rulesetId: fsae2025Ruleset.rulesetId, - createdByUserId: batman.userId - } - }); - - const frameRule = await prisma.rule.create({ - data: { - ruleCode: 'S.1.1', - ruleContent: - 'Frame Requirements - The main hoop must be directly behind the driver and be the tallest part of the car', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: safetyRule.ruleId, - createdByUserId: batman.userId - } - }); - - const rollHoopRule = await prisma.rule.create({ - data: { - ruleCode: 'S.1.1.1', - ruleContent: 'The main roll hoop must extend from the lowest chassis frame members on one side to the other', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: frameRule.ruleId, - createdByUserId: superman.userId - } - }); - - const harnessRule = await prisma.rule.create({ - data: { - ruleCode: 'S.1.2', - ruleContent: 'Harness - A 5-point or 6-point harness must be used, meeting SFI 16.1 or FIA 8853/98 standards', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: safetyRule.ruleId, - createdByUserId: superman.userId - } - }); - - const fireExtinguisherRule = await prisma.rule.create({ - data: { - ruleCode: 'S.1.3', - ruleContent: 'Fire Extinguisher - An onboard fire extinguisher system must be installed and accessible', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: safetyRule.ruleId, - createdByUserId: batman.userId - } + const rulesetMock = await prisma.ruleset.create({ + data: ruleSeedData.rulesetMock(fergus.carId, batman.userId, mockRulesetType.rulesetTypeId) }); - // Braking System Rules with Cross-References - const brakingRule = await prisma.rule.create({ - data: { - ruleCode: 'T.2.1', - ruleContent: - 'Braking System - The vehicle must have a braking system that acts on all four wheels and operates on two independent hydraulic circuits', - rulesetId: fsae2025Ruleset.rulesetId, - createdByUserId: thomasEmrax.userId, - referencedRule: { - connect: [{ ruleId: vehicleConfigRule.ruleId }, { ruleId: wheelRule.ruleId }] - } - } - }); - - const brakePedalRule = await prisma.rule.create({ - data: { - ruleCode: 'T.2.1.1', - ruleContent: 'The brake pedal must be capable of locking all four wheels in both dry and wet conditions', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: brakingRule.ruleId, - createdByUserId: joeShmoe.userId - } - }); - - // Electrical System Rules with References - const electricalSystemRule = await prisma.rule.create({ - data: { - ruleCode: 'T.3.1', - ruleContent: 'Electrical System - All high voltage components must be protected and isolated per safety requirements', - rulesetId: fsae2025Ruleset.rulesetId, - createdByUserId: thomasEmrax.userId, - imageFileIds: ['electrical-diagram-1', 'electrical-diagram-2', 'electrical-diagram-3'], - referencedRule: { - connect: [{ ruleId: powertrainRule.ruleId }, { ruleId: safetyRule.ruleId }] - } - } - }); - - const shutdownCircuitRule = await prisma.rule.create({ - data: { - ruleCode: 'T.3.1.1', - ruleContent: 'A shutdown circuit must be installed that disables the tractive system when activated', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: electricalSystemRule.ruleId, - createdByUserId: joeBlow.userId - } - }); - - const shutdownButtonRule = await prisma.rule.create({ - data: { - ruleCode: 'T.3.1.2', - ruleContent: 'Shutdown buttons must be located on both sides of the vehicle and be easily accessible', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: electricalSystemRule.ruleId, - createdByUserId: thomasEmrax.userId - } - }); - - // Accumulator Container Rules - const accumulatorRule = await prisma.rule.create({ - data: { - ruleCode: 'T.3.2', - ruleContent: 'Accumulator Container - The accumulator container must protect the cells from impact and debris', - rulesetId: fsae2025Ruleset.rulesetId, - createdByUserId: batman.userId, - referencedRule: { - connect: [{ ruleId: safetyRule.ruleId }] - } - } - }); - - const accumulatorMountingRule = await prisma.rule.create({ - data: { - ruleCode: 'T.3.2.1', - ruleContent: 'The accumulator container must be rigidly mounted to the frame', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: accumulatorRule.ruleId, - createdByUserId: thomasEmrax.userId - } - }); - - // General Rules (Orphan - no parent) - const generalRule = await prisma.rule.create({ - data: { - ruleCode: 'G.1', - ruleContent: - 'General - All rules are subject to interpretation by competition officials. When in doubt, contact the rules committee', - rulesetId: fsae2025Ruleset.rulesetId, - createdByUserId: batman.userId - } - }); - - const competitionEligibilityRule = await prisma.rule.create({ - data: { - ruleCode: 'G.2', - ruleContent: 'Competition Eligibility - Teams must register before the deadline and submit all required documentation', - rulesetId: fsae2025Ruleset.rulesetId, - createdByUserId: superman.userId - } - }); - - // Driver Requirements - const driverRule = await prisma.rule.create({ - data: { - ruleCode: 'S.2', - ruleContent: 'Driver Requirements - All drivers must meet safety equipment and training requirements', - rulesetId: fsae2025Ruleset.rulesetId, - createdByUserId: superman.userId - } - }); - - const helmetRule = await prisma.rule.create({ - data: { - ruleCode: 'S.2.1', - ruleContent: 'Helmet - Driver must wear a helmet meeting Snell SA2020, FIA 8859-2015, or equivalent standards', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: driverRule.ruleId, - createdByUserId: batman.userId - } - }); - - const suitRule = await prisma.rule.create({ - data: { - ruleCode: 'S.2.2', - ruleContent: 'Suit - Driver must wear a driving suit meeting SFI 3.2A/1 or FIA 8856-2000 standards', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: driverRule.ruleId, - createdByUserId: superman.userId - } - }); - - // Suspension Rules - const suspensionRule = await prisma.rule.create({ - data: { - ruleCode: 'T.4.1', - ruleContent: 'Suspension - All vehicles must have a fully operational suspension system on all wheels', - rulesetId: fsae2025Ruleset.rulesetId, - createdByUserId: thomasEmrax.userId, - referencedRule: { - connect: [{ ruleId: wheelRule.ruleId }] - } - } - }); - - const suspensionTravelRule = await prisma.rule.create({ - data: { - ruleCode: 'T.4.1.1', - ruleContent: 'The suspension must have at least 50.8 mm (2 inches) of travel', - rulesetId: fsae2025Ruleset.rulesetId, - parentRuleId: suspensionRule.ruleId, - createdByUserId: joeShmoe.userId - } - }); - - // Adding some rules to the 2024 ruleset as well - const tech2024Rule = await prisma.rule.create({ - data: { - ruleCode: 'T.1', - ruleContent: 'Technical Rules - 2024 Edition', - rulesetId: fsae2024Ruleset.rulesetId, - createdByUserId: batman.userId - } - }); + // seed the rulesets, and add rules to bodywork project + await seedFsaeRules( + prisma, + rulesetFSAE.rulesetId, + rulesetFHE.rulesetId, + rulesetMock.rulesetId, + { + batman, + thomasEmrax, + joeShmoe, + joeBlow, + superman + }, + ner, + projectHuskies1Id, + huskies.teamId + ); - const vehicle2024Rule = await prisma.rule.create({ - data: { - ruleCode: 'T.1.1', - ruleContent: 'Vehicle must be four-wheeled (2024 rules)', - rulesetId: fsae2024Ruleset.rulesetId, - parentRuleId: tech2024Rule.ruleId, - createdByUserId: thomasEmrax.userId - } - }); // Create shops for machinery const advancedShop = await prisma.shop.create({ data: { From cf26c615203a04f18aa35435df8c0ed439f03c6b Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 25 Jun 2026 14:05:32 -0400 Subject: [PATCH 08/16] #4284 project rule seed update --- src/backend/src/prisma/seed-data/rules.seed.ts | 7 +++++-- src/backend/src/prisma/seed.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/backend/src/prisma/seed-data/rules.seed.ts b/src/backend/src/prisma/seed-data/rules.seed.ts index e179f8befb..30f4c993e8 100644 --- a/src/backend/src/prisma/seed-data/rules.seed.ts +++ b/src/backend/src/prisma/seed-data/rules.seed.ts @@ -699,8 +699,11 @@ export const seedFsaeRules = async ( } }); - // Add rule to husky team and then bodywork project and mark as completed - await RulesService.toggleRuleTeam(T112ARule.ruleId, huskyTeamId, batman, organization); + // Add the rule to the husky team, then the bodywork project, and mark it complete. + for (const rule of [topLevelTechnical, T1Rule, T11Rule, T112Rule, T112ARule]) { + await RulesService.toggleRuleTeam(rule.ruleId, huskyTeamId, batman, organization); + } + // TODO: the above logic should be in the service function, not handled in the assign team frontend await RulesService.createProjectRule(batman, organization, T112ARule.ruleId, projectId); await RulesService.setRuleCompletion(batman, organization, T112ARule.ruleId, true, projectId); }; diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 8772312dbd..09de35bcb2 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -4765,7 +4765,7 @@ const performSeed: () => Promise = async () => { superman }, ner, - projectHuskies1Id, + projectHuskies2Id, huskies.teamId ); From c017f0f289315875fd3b3741068e466875d581d0 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 25 Jun 2026 14:24:29 -0400 Subject: [PATCH 09/16] #4284 add active ruleset name and assign rules link --- .../ProjectRules/ProjectRulesTab.tsx | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index 581320a63f..801d44dca4 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -35,6 +35,8 @@ import { } from '../../../../hooks/rules.hooks'; import { useToast } from '../../../../hooks/toasts.hooks'; import { InfoOutlined, KeyboardArrowRight, KeyboardArrowDown } from '@mui/icons-material'; +import { useHistory } from 'react-router-dom'; +import { routes } from '../../../../utils/routes'; interface ProjectRulesTabProps { project: Project; @@ -50,6 +52,7 @@ const getStatusConfig = (isComplete: boolean) => { export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { const toast = useToast(); const theme = useTheme(); + const history = useHistory(); // State for modals and popovers const [selectedRulesetTypeIndex, setSelectedRulesetTypeIndex] = useState(0); @@ -218,10 +221,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { onClick={(e) => e.stopPropagation()} sx={{ padding: '2px', - color: 'text.secondary', - '&:hover': { - color: 'primary.main' - } + color: 'text.secondary' }} > @@ -277,7 +277,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { return ( {/* Ruleset Type Tabs */} - + { /> ))} + + + e.stopPropagation()} + sx={{ + padding: '5px', + color: 'text.secondary' + }} + > + + + + + + {/* Active ruleset name for this ruleset type */} + {activeRuleset && ( + + {activeRuleset.name} + + )} + {/* Rules Content */} {activeRulesetLoading || projectRulesLoading ? ( From 1b2b7faf52797dc88944b967f488c0d233cb0fa2 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 25 Jun 2026 14:30:38 -0400 Subject: [PATCH 10/16] #4284 team highlighted in assign team view + added team name visibility --- .../ProjectRules/AddRuleModal.tsx | 5 +++-- .../ProjectRules/ProjectRulesTab.tsx | 13 +++++++++++-- src/frontend/src/pages/RulesPage/AssignRulesTab.tsx | 12 ++++++++++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx index 03745e7b78..b150e66674 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx @@ -27,10 +27,11 @@ interface AddRuleModalProps { onHide: () => void; rulesetId: string; teamId: string; + teamName: string; onSubmit: (ruleIds: string[]) => void; } -const AddRuleModal = ({ open, onHide, rulesetId, teamId, onSubmit }: AddRuleModalProps) => { +const AddRuleModal = ({ open, onHide, rulesetId, teamId, teamName, onSubmit }: AddRuleModalProps) => { const theme = useTheme(); const [selectedRuleIds, setSelectedRuleIds] = useState([]); @@ -148,7 +149,7 @@ const AddRuleModal = ({ open, onHide, rulesetId, teamId, onSubmit }: AddRuleModa Failed to load rules ) : !unassignedRules || unassignedRules.length === 0 ? ( - No unassigned rules available for this team. + No unassigned rules available for the {teamName} team. ) : ( diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index 801d44dca4..593cb67a29 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -300,7 +300,11 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { ))} - + e.stopPropagation()} @@ -316,7 +320,11 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { disabled={!activeRuleset} onClick={() => activeRuleset && - history.push(`${routes.RULESET_EDIT.replace(':rulesetId', activeRuleset.rulesetId)}/assign-rules`) + history.push( + `${routes.RULESET_EDIT.replace(':rulesetId', activeRuleset.rulesetId)}/assign-rules${ + teamId ? `?teamId=${teamId}` : '' + }` + ) } sx={{ border: 1, @@ -442,6 +450,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { onHide={() => setAddRuleModalOpen(false)} rulesetId={activeRuleset.rulesetId} teamId={teamId} + teamName={project.teams[0]?.teamName ?? ''} onSubmit={handleAddRules} /> )} diff --git a/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx b/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx index 42c6b7ec53..601872aa76 100644 --- a/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx +++ b/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx @@ -20,7 +20,7 @@ import { Rule, TeamPreview } from 'shared'; import { useAllTeams } from '../../hooks/teams.hooks'; import LoadingIndicator from '../../components/LoadingIndicator'; import ErrorPage from '../ErrorPage'; -import { useHistory, useParams } from 'react-router-dom'; +import { useHistory, useLocation, useParams } from 'react-router-dom'; import { routes } from '../../utils/routes'; import { useToast } from '../../hooks/toasts.hooks'; import { NERButton } from '../../components/NERButton'; @@ -94,6 +94,7 @@ const AssignRulesTab: React.FC = ({ rules }) => { const theme = useTheme(); const history = useHistory(); const { rulesetId } = useParams<{ rulesetId: string }>(); + const location = useLocation(); const toast = useToast(); const [selectedTeamId, setSelectedTeamId] = useState(null); const [assignments, setAssignments] = useState>(new Set()); @@ -116,8 +117,15 @@ const AssignRulesTab: React.FC = ({ rules }) => { setOriginalAssignments(initialAssignments); setAssignments(new Set(initialAssignments)); + + // Pre-select the team passed in via query param (e.g. when navigating from a project's rules tab) + const teamIdParam = new URLSearchParams(location.search).get('teamId'); + if (teamIdParam && teams.some((team) => team.teamId === teamIdParam)) { + setSelectedTeamId(teamIdParam); + } + setIsInitialized(true); - }, [rules, teams, isInitialized]); + }, [rules, teams, isInitialized, location.search]); const handleTeamSelect = (teamId: string) => setSelectedTeamId(teamId); From 01fdb86f227d5e5ad91c667997b9e55e4d38c50f Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 25 Jun 2026 18:48:52 -0400 Subject: [PATCH 11/16] #4284 rules can be in multiple projects for the same team --- .../src/controllers/rules.controllers.ts | 2 ++ src/backend/src/services/rules.services.ts | 19 +++++++++++++++++-- src/frontend/src/apis/rules.api.ts | 4 ++-- src/frontend/src/hooks/rules.hooks.ts | 8 ++++---- .../ProjectRules/AddRuleModal.tsx | 5 +++-- .../ProjectRules/ProjectRulesTab.tsx | 1 + src/frontend/src/pages/RulesPage/RuleRow.tsx | 10 +++++++--- src/frontend/src/utils/urls.ts | 4 ++-- 8 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts index 081a4be299..9a227eafd3 100644 --- a/src/backend/src/controllers/rules.controllers.ts +++ b/src/backend/src/controllers/rules.controllers.ts @@ -265,9 +265,11 @@ export default class RulesController { static async getUnassignedRulesForRuleset(req: Request, res: Response, next: NextFunction) { try { const { rulesetId, teamId } = req.params; + const { projectId } = req.query; const rules = await RulesService.getUnassignedRulesForRuleset( rulesetId as string, teamId, + projectId as string, req.organization.organizationId ); res.status(200).json(rules); diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 386baba4d2..4d8cb369db 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -1162,7 +1162,7 @@ export default class RulesService { * @param organizationId the organization id * @returns the rules in this team that do not have an associated project rule */ - static async getUnassignedRulesForRuleset(rulesetId: string, teamId: string, organizationId: string) { + static async getUnassignedRulesForRuleset(rulesetId: string, teamId: string, projectId: string, organizationId: string) { const ruleset = await prisma.ruleset.findUnique({ where: { rulesetId }, select: { @@ -1202,6 +1202,19 @@ export default class RulesService { throw new InvalidOrganizationException('Team'); } + const project = await prisma.project.findUnique({ + where: { projectId }, + select: { wbsElement: { select: { organizationId: true } } } + }); + + if (!project) { + throw new NotFoundException('Project', projectId); + } + + if (project.wbsElement.organizationId !== organizationId) { + throw new InvalidOrganizationException('Project'); + } + const rules = await prisma.rule.findMany({ where: { rulesetId, @@ -1211,8 +1224,10 @@ export default class RulesService { organizationId } }, + // a rule can belong to many projects within a team + // only hide it from a project that already has it assigned projects: { - none: {} + none: { projectId } }, deletedByUserId: null }, diff --git a/src/frontend/src/apis/rules.api.ts b/src/frontend/src/apis/rules.api.ts index 424bbfc589..e4797fb297 100644 --- a/src/frontend/src/apis/rules.api.ts +++ b/src/frontend/src/apis/rules.api.ts @@ -95,8 +95,8 @@ export const getProjectRules = (rulesetId: string, projectId: string) => { /** * Gets unassigned rules for a ruleset and team. */ -export const getUnassignedRulesForRuleset = (rulesetId: string, teamId: string) => { - return axios.get(apiUrls.rulesGetUnassignedRulesForRuleset(rulesetId, teamId), { +export const getUnassignedRulesForRuleset = (rulesetId: string, teamId: string, projectId: string) => { + return axios.get(apiUrls.rulesGetUnassignedRulesForRuleset(rulesetId, teamId, projectId), { transformResponse: (data) => JSON.parse(data).map(ruleTransformer) }); }; diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index c9895120e1..22a3ea7df3 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -80,14 +80,14 @@ export const useProjectRules = (rulesetId: string, projectId: string) => { /** * Hook to get unassigned rules for a ruleset and team. */ -export const useUnassignedRulesForRuleset = (rulesetId: string, teamId: string) => { +export const useUnassignedRulesForRuleset = (rulesetId: string, teamId: string, projectId: string) => { return useQuery( - ['rules', 'unassigned', rulesetId, teamId], + ['rules', 'unassigned', rulesetId, teamId, projectId], async () => { - const { data } = await getUnassignedRulesForRuleset(rulesetId, teamId); + const { data } = await getUnassignedRulesForRuleset(rulesetId, teamId, projectId); return data; }, - { enabled: !!rulesetId && !!teamId } + { enabled: !!rulesetId && !!teamId && !!projectId } ); }; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx index b150e66674..3290b2d6bc 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx @@ -28,14 +28,15 @@ interface AddRuleModalProps { rulesetId: string; teamId: string; teamName: string; + projectId: string; onSubmit: (ruleIds: string[]) => void; } -const AddRuleModal = ({ open, onHide, rulesetId, teamId, teamName, onSubmit }: AddRuleModalProps) => { +const AddRuleModal = ({ open, onHide, rulesetId, teamId, teamName, projectId, onSubmit }: AddRuleModalProps) => { const theme = useTheme(); const [selectedRuleIds, setSelectedRuleIds] = useState([]); - const { data: unassignedRules, isLoading, isError } = useUnassignedRulesForRuleset(rulesetId, teamId); + const { data: unassignedRules, isLoading, isError } = useUnassignedRulesForRuleset(rulesetId, teamId, projectId); type ParentInfo = { ruleId: string; ruleCode: string }; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index 593cb67a29..b80cc893cc 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -451,6 +451,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { rulesetId={activeRuleset.rulesetId} teamId={teamId} teamName={project.teams[0]?.teamName ?? ''} + projectId={project.id} onSubmit={handleAddRules} /> )} diff --git a/src/frontend/src/pages/RulesPage/RuleRow.tsx b/src/frontend/src/pages/RulesPage/RuleRow.tsx index 66de98a816..58799b7b75 100644 --- a/src/frontend/src/pages/RulesPage/RuleRow.tsx +++ b/src/frontend/src/pages/RulesPage/RuleRow.tsx @@ -59,13 +59,17 @@ const RuleRow: React.FC = ({ indentWidth = 10 }) => { const [isExpanded, setIsExpanded] = useState(initiallyExpanded); - const hasSubRules = rule.subRuleIds.length > 0; + + // a parent rule whose sub rules aren't in the set (e.g. rule T.1 was assigned to a project but T.1.1 wasn't) + // will render as a leaf rule but with no expand dropdown + const presentSubRules = allRules ? allRules.filter((r) => rule.subRuleIds.includes(r.ruleId)) : null; + const hasSubRules = presentSubRules ? presentSubRules.length > 0 : rule.subRuleIds.length > 0; // Lazy load if allRules not provided const { data: fetchedSubRules = [] } = useGetChildRules(rule.ruleId, !allRules && isExpanded && hasSubRules); // Use allRules if provided, otherwise use fetched - const subRules = allRules ? allRules.filter((r) => rule.subRuleIds.includes(r.ruleId)) : fetchedSubRules; + const subRules = presentSubRules ?? fetchedSubRules; const bgColor = typeof backgroundColor === 'function' ? backgroundColor(rule) : backgroundColor; const color = typeof textColor === 'function' ? textColor(rule) : textColor; @@ -95,7 +99,7 @@ const RuleRow: React.FC = ({ const cardRadius = 8; const cardCellBg = indentRow ? { backgroundColor: bgColor } : {}; const cardCellClass = indentRow ? 'rule-card-cell' : undefined; - // Indent left edge of rule with transparent left border + // Indent left edge of rule with transparent left border const leftInset = indentRow ? level * indentWidth : 0; const leftCellRadius = indentRow ? { diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 874c22250e..ee5c4fa4a0 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -465,8 +465,8 @@ const uploadRulesetFile = () => `${rules()}/upload/file`; const rulesGetActiveRuleset = (rulesetTypeId: string) => `${rules()}/rulesetType/${rulesetTypeId}/active`; const rulesGetProjectRules = (rulesetId: string, projectId: string) => `${rules()}/ruleset/${rulesetId}/project/${projectId}/rules`; -const rulesGetUnassignedRulesForRuleset = (rulesetId: string, teamId: string) => - `${rules()}/ruleset/${rulesetId}/team/${teamId}/rules/unassigned`; +const rulesGetUnassignedRulesForRuleset = (rulesetId: string, teamId: string, projectId: string) => + `${rules()}/ruleset/${rulesetId}/team/${teamId}/rules/unassigned?projectId=${projectId}`; const rulesCreateProjectRule = () => `${rules()}/projectRule/create`; const rulesDeleteProjectRule = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/delete`; const rulesSetRuleCompletion = (ruleId: string) => `${rules()}/rule/${ruleId}/setCompletion`; From 272d9c3fc2c6593026b2654271dbe28d0faf707c Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sat, 4 Jul 2026 10:24:57 -0400 Subject: [PATCH 12/16] #4284 combine migrations + fix type casting --- .../src/controllers/rules.controllers.ts | 92 +++++---- .../migration.sql | 181 ------------------ .../migration.sql | 35 ---- .../migration.sql | 163 ++++++++++++++++ src/backend/src/services/rules.services.ts | 10 +- 5 files changed, 215 insertions(+), 266 deletions(-) delete mode 100644 src/backend/src/prisma/migrations/20251111150202_rules_dashboard/migration.sql delete mode 100644 src/backend/src/prisma/migrations/20260623194325_rule_completion/migration.sql create mode 100644 src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts index 9a227eafd3..c384b07af7 100644 --- a/src/backend/src/controllers/rules.controllers.ts +++ b/src/backend/src/controllers/rules.controllers.ts @@ -6,8 +6,8 @@ import { HttpException } from '../utils/errors.utils.js'; export default class RulesController { static async getActiveRuleset(req: Request, res: Response, next: NextFunction) { try { - const { rulesetTypeId } = req.params; - const rulesetType = await RulesService.getActiveRuleset(req.currentUser, rulesetTypeId as string, req.organization); + const { rulesetTypeId } = req.params as Record; + const rulesetType = await RulesService.getActiveRuleset(req.currentUser, rulesetTypeId, req.organization); res.status(200).json(rulesetType); } catch (error: unknown) { next(error); @@ -16,8 +16,8 @@ export default class RulesController { static async getRulesetById(req: Request, res: Response, next: NextFunction) { try { - const { rulesetId } = req.params; - const ruleset = await RulesService.getRulesetById(rulesetId as string, req.organization.organizationId); + const { rulesetId } = req.params as Record; + const ruleset = await RulesService.getRulesetById(rulesetId, req.organization.organizationId); res.status(200).json(ruleset); } catch (error: unknown) { next(error); @@ -47,8 +47,8 @@ export default class RulesController { static async deleteRule(req: Request, res: Response, next: NextFunction) { try { - const { ruleId } = req.params; - const deletedRule = await RulesService.deleteRule(ruleId as string, req.currentUser, req.organization); + const { ruleId } = req.params as Record; + const deletedRule = await RulesService.deleteRule(ruleId, req.currentUser, req.organization); res.status(200).json(deletedRule); } catch (error: unknown) { next(error); @@ -83,13 +83,13 @@ export default class RulesController { static async editRule(req: Request, res: Response, next: NextFunction) { try { - const { ruleId } = req.params; + const { ruleId } = req.params as Record; const { ruleContent, ruleCode, imageFileIds, parentRuleId } = req.body; const rule = await RulesService.editRule( req.currentUser, ruleContent, - ruleId as string, + ruleId, ruleCode, imageFileIds, req.organization, @@ -112,8 +112,8 @@ export default class RulesController { static async getRulesetsByRulesetType(req: Request, res: Response, next: NextFunction) { try { - const { rulesetTypeId } = req.params; - const rulesets = await RulesService.getRulesetsByRulesetType(rulesetTypeId as string, req.organization.organizationId); + const { rulesetTypeId } = req.params as Record; + const rulesets = await RulesService.getRulesetsByRulesetType(rulesetTypeId, req.organization.organizationId); res.status(200).json(rulesets); } catch (error: unknown) { next(error); @@ -122,8 +122,8 @@ export default class RulesController { static async getRulesetType(req: Request, res: Response, next: NextFunction) { try { - const { rulesetTypeId } = req.params; - const rulesetType = await RulesService.getRulesetType(rulesetTypeId as string, req.organization.organizationId); + const { rulesetTypeId } = req.params as Record; + const rulesetType = await RulesService.getRulesetType(rulesetTypeId, req.organization.organizationId); res.status(200).json(rulesetType); } catch (error: unknown) { next(error); @@ -132,12 +132,8 @@ export default class RulesController { static async deleteRuleset(req: Request, res: Response, next: NextFunction) { try { - const { rulesetId } = req.params; - const ruleset = await RulesService.deleteRuleset( - rulesetId as string, - req.currentUser.userId, - req.organization.organizationId - ); + const { rulesetId } = req.params as Record; + const ruleset = await RulesService.deleteRuleset(rulesetId, req.currentUser.userId, req.organization.organizationId); res.status(200).json(ruleset); } catch (error: unknown) { next(error); @@ -146,12 +142,8 @@ export default class RulesController { static async deleteProjectRule(req: Request, res: Response, next: NextFunction) { try { - const { projectRuleId } = req.params; - const deletedProjectRule = await RulesService.deleteProjectRule( - projectRuleId as string, - req.currentUser, - req.organization - ); + const { projectRuleId } = req.params as Record; + const deletedProjectRule = await RulesService.deleteProjectRule(projectRuleId, req.currentUser, req.organization); res.status(200).json(deletedProjectRule); } catch (error: unknown) { next(error); @@ -160,13 +152,13 @@ export default class RulesController { static async setRuleCompletion(req: Request, res: Response, next: NextFunction) { try { - const { ruleId } = req.params; + const { ruleId } = req.params as Record; const { isComplete, projectId } = req.body; const rule: Rule = await RulesService.setRuleCompletion( req.currentUser, req.organization, - ruleId as string, + ruleId, isComplete, projectId ); @@ -179,10 +171,10 @@ export default class RulesController { static async toggleRuleTeam(req: Request, res: Response, next: NextFunction) { try { - const { ruleId } = req.params; + const { ruleId } = req.params as Record; const { teamId } = req.body; - const changedRule = await RulesService.toggleRuleTeam(ruleId as string, teamId, req.currentUser, req.organization); + const changedRule = await RulesService.toggleRuleTeam(ruleId, teamId, req.currentUser, req.organization); res.status(200).json(changedRule); } catch (error: unknown) { @@ -212,9 +204,9 @@ export default class RulesController { static async deleteRulesetType(req: Request, res: Response, next: NextFunction) { try { - const { rulesetTypeId } = req.params; + const { rulesetTypeId } = req.params as Record; - const rulesetType = await RulesService.deleteRulesetType(req.currentUser, rulesetTypeId as string, req.organization); + const rulesetType = await RulesService.deleteRulesetType(req.currentUser, rulesetTypeId, req.organization); res.status(200).json(rulesetType); } catch (error: unknown) { @@ -224,13 +216,13 @@ export default class RulesController { static async updateRuleset(req: Request, res: Response, next: NextFunction) { try { - const { rulesetId } = req.params; + const { rulesetId } = req.params as Record; const { name, isActive } = req.body; const ruleset: Ruleset = await RulesService.updateRuleset( req.currentUser, req.organization.organizationId, - rulesetId as string, + rulesetId, name, isActive ); @@ -243,8 +235,8 @@ export default class RulesController { static async getChildRules(req: Request, res: Response, next: NextFunction) { try { - const { ruleId: parentRuleId } = req.params; - const childrenRules: Rule[] = await RulesService.getChildRules(parentRuleId as string, req.organization); + const { ruleId: parentRuleId } = req.params as Record; + const childrenRules: Rule[] = await RulesService.getChildRules(parentRuleId, req.organization); res.status(200).json(childrenRules); } catch (error: unknown) { @@ -254,8 +246,8 @@ export default class RulesController { static async getUnassignedRules(req: Request, res: Response, next: NextFunction) { try { - const { rulesetId } = req.params; - const rules = await RulesService.getUnassignedRules(rulesetId as string, req.organization); + const { rulesetId } = req.params as Record; + const rules = await RulesService.getUnassignedRules(rulesetId, req.organization); res.status(200).json(rules); } catch (error: unknown) { next(error); @@ -264,12 +256,14 @@ export default class RulesController { static async getUnassignedRulesForRuleset(req: Request, res: Response, next: NextFunction) { try { - const { rulesetId, teamId } = req.params; + const { rulesetId, teamId } = req.params as Record; const { projectId } = req.query; + const projectIdString = typeof projectId === 'string' ? projectId : undefined; + const rules = await RulesService.getUnassignedRulesForRuleset( - rulesetId as string, + rulesetId, teamId, - projectId as string, + projectIdString, req.organization.organizationId ); res.status(200).json(rules); @@ -280,9 +274,9 @@ export default class RulesController { static async getProjectRules(req: Request, res: Response, next: NextFunction) { try { - const { rulesetId, projectId } = req.params; + const { rulesetId, projectId } = req.params as Record; - const projectRules = await RulesService.getProjectRules(rulesetId as string, projectId, req.organization); + const projectRules = await RulesService.getProjectRules(rulesetId, projectId, req.organization); res.status(200).json(projectRules); } catch (error: unknown) { @@ -292,8 +286,8 @@ export default class RulesController { static async getTeamRulesInRulesetType(req: Request, res: Response, next: NextFunction) { try { - const { rulesetTypeId, teamId } = req.params; - const rules = await RulesService.getTeamRulesInRulesetType(teamId as string, rulesetTypeId, req.organization); + const { rulesetTypeId, teamId } = req.params as Record; + const rules = await RulesService.getTeamRulesInRulesetType(teamId, rulesetTypeId, req.organization); res.status(200).json(rules); } catch (error: unknown) { next(error); @@ -302,8 +296,8 @@ export default class RulesController { static async getTopLevelRules(req: Request, res: Response, next: NextFunction) { try { - const { rulesetId } = req.params; - const rules = await RulesService.getTopLevelRules(rulesetId as string, req.organization.organizationId); + const { rulesetId } = req.params as Record; + const rules = await RulesService.getTopLevelRules(rulesetId, req.organization.organizationId); res.status(200).json(rules); } catch (error: unknown) { next(error); @@ -313,13 +307,13 @@ export default class RulesController { static async parseRuleset(req: Request, res: Response, next: NextFunction) { try { const { fileId, parserType } = req.body; - const { rulesetId } = req.params; + const { rulesetId } = req.params as Record; const parseResult = await RulesService.parseRuleset( req.currentUser, req.organization.organizationId, fileId, - rulesetId as string, + rulesetId, parserType ); @@ -344,8 +338,8 @@ export default class RulesController { static async getSingleRuleset(req: Request, res: Response, next: NextFunction) { try { - const { rulesetId } = req.params; - const ruleset = await RulesService.getSingleRuleset(req.currentUser, rulesetId as string, req.organization); + const { rulesetId } = req.params as Record; + const ruleset = await RulesService.getSingleRuleset(req.currentUser, rulesetId, req.organization); res.status(200).json(ruleset); } catch (error: unknown) { next(error); diff --git a/src/backend/src/prisma/migrations/20251111150202_rules_dashboard/migration.sql b/src/backend/src/prisma/migrations/20251111150202_rules_dashboard/migration.sql deleted file mode 100644 index c72bbb5c14..0000000000 --- a/src/backend/src/prisma/migrations/20251111150202_rules_dashboard/migration.sql +++ /dev/null @@ -1,181 +0,0 @@ --- CreateEnum -CREATE TYPE "public"."Rule_Completion" AS ENUM ('REVIEW', 'INCOMPLETE', 'COMPLETED'); - --- CreateTable -CREATE TABLE "public"."Ruleset_Type" ( - "rulesetTypeId" TEXT NOT NULL, - "name" TEXT NOT NULL, - "lastUpdated" TIMESTAMP(3) NOT NULL, - "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "createdByUserId" TEXT NOT NULL, - "dateDeleted" TIMESTAMP(3), - "deletedByUserId" TEXT, - "organizationId" TEXT NOT NULL, - - CONSTRAINT "Ruleset_Type_pkey" PRIMARY KEY ("rulesetTypeId") -); - --- CreateTable -CREATE TABLE "public"."Ruleset" ( - "rulesetId" TEXT NOT NULL, - "fileId" TEXT NOT NULL, - "name" TEXT NOT NULL, - "active" BOOLEAN NOT NULL, - "rulesetTypeId" TEXT NOT NULL, - "carId" TEXT NOT NULL, - "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "createdByUserId" TEXT NOT NULL, - "dateDeleted" TIMESTAMP(3), - "deletedByUserId" TEXT, - - CONSTRAINT "Ruleset_pkey" PRIMARY KEY ("rulesetId") -); - --- CreateTable -CREATE TABLE "public"."Rule" ( - "ruleId" TEXT NOT NULL, - "ruleCode" TEXT NOT NULL, - "ruleContent" TEXT NOT NULL, - "imageFileIds" TEXT[], - "rulesetId" TEXT NOT NULL, - "parentRuleId" TEXT, - "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "dateUpdated" TIMESTAMP(3), - "dateDeleted" TIMESTAMP(3), - "createdByUserId" TEXT NOT NULL, - "updatedByUserId" TEXT, - "deletedByUserId" TEXT, - - CONSTRAINT "Rule_pkey" PRIMARY KEY ("ruleId") -); - --- CreateTable -CREATE TABLE "public"."Rule_Status_Change" ( - "historyId" TEXT NOT NULL, - "projectRuleId" TEXT NOT NULL, - "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "createdByUserId" TEXT NOT NULL, - "dateDeleted" TIMESTAMP(3), - "deletedByUserId" TEXT, - "newStatus" "public"."Rule_Completion" NOT NULL, - "note" TEXT NOT NULL, - - CONSTRAINT "Rule_Status_Change_pkey" PRIMARY KEY ("historyId") -); - --- CreateTable -CREATE TABLE "public"."Project_Rule" ( - "projectRuleId" TEXT NOT NULL, - "ruleId" TEXT NOT NULL, - "projectId" TEXT NOT NULL, - "currentStatus" "public"."Rule_Completion" NOT NULL, - "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "createdByUserId" TEXT NOT NULL, - "dateDeleted" TIMESTAMP(3), - "deletedByUserId" TEXT, - - CONSTRAINT "Project_Rule_pkey" PRIMARY KEY ("projectRuleId") -); - --- CreateTable -CREATE TABLE "public"."_ruleReferences" ( - "A" TEXT NOT NULL, - "B" TEXT NOT NULL, - - CONSTRAINT "_ruleReferences_AB_pkey" PRIMARY KEY ("A","B") -); - --- CreateTable -CREATE TABLE "public"."_teamRules" ( - "A" TEXT NOT NULL, - "B" TEXT NOT NULL, - - CONSTRAINT "_teamRules_AB_pkey" PRIMARY KEY ("A","B") -); - --- CreateIndex -CREATE INDEX "Ruleset_Type_organizationId_idx" ON "public"."Ruleset_Type"("organizationId"); - --- CreateIndex -CREATE INDEX "Rule_parentRuleId_rulesetId_ruleCode_idx" ON "public"."Rule"("parentRuleId", "rulesetId", "ruleCode"); - --- CreateIndex -CREATE UNIQUE INDEX "Rule_rulesetId_ruleCode_key" ON "public"."Rule"("rulesetId", "ruleCode"); - --- CreateIndex -CREATE UNIQUE INDEX "Project_Rule_ruleId_projectId_key" ON "public"."Project_Rule"("ruleId", "projectId"); - --- CreateIndex -CREATE INDEX "_ruleReferences_B_index" ON "public"."_ruleReferences"("B"); - --- CreateIndex -CREATE INDEX "_teamRules_B_index" ON "public"."_teamRules"("B"); - --- AddForeignKey -ALTER TABLE "public"."Ruleset_Type" ADD CONSTRAINT "Ruleset_Type_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "public"."User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Ruleset_Type" ADD CONSTRAINT "Ruleset_Type_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Ruleset_Type" ADD CONSTRAINT "Ruleset_Type_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Ruleset" ADD CONSTRAINT "Ruleset_rulesetTypeId_fkey" FOREIGN KEY ("rulesetTypeId") REFERENCES "public"."Ruleset_Type"("rulesetTypeId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Ruleset" ADD CONSTRAINT "Ruleset_carId_fkey" FOREIGN KEY ("carId") REFERENCES "public"."Car"("carId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Ruleset" ADD CONSTRAINT "Ruleset_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "public"."User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Ruleset" ADD CONSTRAINT "Ruleset_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Rule" ADD CONSTRAINT "Rule_rulesetId_fkey" FOREIGN KEY ("rulesetId") REFERENCES "public"."Ruleset"("rulesetId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Rule" ADD CONSTRAINT "Rule_parentRuleId_fkey" FOREIGN KEY ("parentRuleId") REFERENCES "public"."Rule"("ruleId") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Rule" ADD CONSTRAINT "Rule_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "public"."User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Rule" ADD CONSTRAINT "Rule_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Rule" ADD CONSTRAINT "Rule_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Rule_Status_Change" ADD CONSTRAINT "Rule_Status_Change_projectRuleId_fkey" FOREIGN KEY ("projectRuleId") REFERENCES "public"."Project_Rule"("projectRuleId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Rule_Status_Change" ADD CONSTRAINT "Rule_Status_Change_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "public"."User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Rule_Status_Change" ADD CONSTRAINT "Rule_Status_Change_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Project_Rule" ADD CONSTRAINT "Project_Rule_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "public"."Rule"("ruleId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Project_Rule" ADD CONSTRAINT "Project_Rule_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("projectId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Project_Rule" ADD CONSTRAINT "Project_Rule_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "public"."User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."Project_Rule" ADD CONSTRAINT "Project_Rule_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."_ruleReferences" ADD CONSTRAINT "_ruleReferences_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."Rule"("ruleId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."_ruleReferences" ADD CONSTRAINT "_ruleReferences_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."Rule"("ruleId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."_teamRules" ADD CONSTRAINT "_teamRules_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."Rule"("ruleId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."_teamRules" ADD CONSTRAINT "_teamRules_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."Team"("teamId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/migrations/20260623194325_rule_completion/migration.sql b/src/backend/src/prisma/migrations/20260623194325_rule_completion/migration.sql deleted file mode 100644 index a51a98e7b9..0000000000 --- a/src/backend/src/prisma/migrations/20260623194325_rule_completion/migration.sql +++ /dev/null @@ -1,35 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `currentStatus` on the `Project_Rule` table. All the data in the column will be lost. - - You are about to drop the `Rule_Status_Change` table. If the table is not empty, all the data it contains will be lost. - -*/ --- DropForeignKey -ALTER TABLE "Rule_Status_Change" DROP CONSTRAINT "Rule_Status_Change_createdByUserId_fkey"; - --- DropForeignKey -ALTER TABLE "Rule_Status_Change" DROP CONSTRAINT "Rule_Status_Change_deletedByUserId_fkey"; - --- DropForeignKey -ALTER TABLE "Rule_Status_Change" DROP CONSTRAINT "Rule_Status_Change_projectRuleId_fkey"; - --- AlterTable -ALTER TABLE "Project_Rule" DROP COLUMN "currentStatus"; - --- AlterTable -ALTER TABLE "Rule" ADD COLUMN "completedByUserId" TEXT, -ADD COLUMN "completedInProjectId" TEXT, -ADD COLUMN "isComplete" BOOLEAN NOT NULL DEFAULT false; - --- DropTable -DROP TABLE "Rule_Status_Change"; - --- DropEnum -DROP TYPE "Rule_Completion"; - --- 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; diff --git a/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql b/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql new file mode 100644 index 0000000000..572123879b --- /dev/null +++ b/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql @@ -0,0 +1,163 @@ +-- CreateTable +CREATE TABLE "Ruleset_Type" ( + "rulesetTypeId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "lastUpdated" TIMESTAMP(3) NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdByUserId" TEXT NOT NULL, + "dateDeleted" TIMESTAMP(3), + "deletedByUserId" TEXT, + "organizationId" TEXT NOT NULL, + + CONSTRAINT "Ruleset_Type_pkey" PRIMARY KEY ("rulesetTypeId") +); + +-- CreateTable +CREATE TABLE "Ruleset" ( + "rulesetId" TEXT NOT NULL, + "fileId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "active" BOOLEAN NOT NULL, + "rulesetTypeId" TEXT NOT NULL, + "carId" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdByUserId" TEXT NOT NULL, + "dateDeleted" TIMESTAMP(3), + "deletedByUserId" TEXT, + + CONSTRAINT "Ruleset_pkey" PRIMARY KEY ("rulesetId") +); + +-- CreateTable +CREATE TABLE "Rule" ( + "ruleId" TEXT NOT NULL, + "ruleCode" TEXT NOT NULL, + "ruleContent" TEXT NOT NULL, + "imageFileIds" TEXT[], + "rulesetId" TEXT NOT NULL, + "parentRuleId" TEXT, + "isComplete" BOOLEAN NOT NULL DEFAULT false, + "completedByUserId" TEXT, + "completedInProjectId" TEXT, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "dateUpdated" TIMESTAMP(3), + "dateDeleted" TIMESTAMP(3), + "createdByUserId" TEXT NOT NULL, + "updatedByUserId" TEXT, + "deletedByUserId" TEXT, + + CONSTRAINT "Rule_pkey" PRIMARY KEY ("ruleId") +); + +-- CreateTable +CREATE TABLE "Project_Rule" ( + "projectRuleId" TEXT NOT NULL, + "ruleId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdByUserId" TEXT NOT NULL, + "dateDeleted" TIMESTAMP(3), + "deletedByUserId" TEXT, + + CONSTRAINT "Project_Rule_pkey" PRIMARY KEY ("projectRuleId") +); + +-- CreateTable +CREATE TABLE "_ruleReferences" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_ruleReferences_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "_teamRules" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_teamRules_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "Ruleset_Type_organizationId_idx" ON "Ruleset_Type"("organizationId"); + +-- CreateIndex +CREATE INDEX "Rule_parentRuleId_rulesetId_ruleCode_idx" ON "Rule"("parentRuleId", "rulesetId", "ruleCode"); + +-- CreateIndex +CREATE UNIQUE INDEX "Rule_rulesetId_ruleCode_key" ON "Rule"("rulesetId", "ruleCode"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_Rule_ruleId_projectId_key" ON "Project_Rule"("ruleId", "projectId"); + +-- CreateIndex +CREATE INDEX "_ruleReferences_B_index" ON "_ruleReferences"("B"); + +-- CreateIndex +CREATE INDEX "_teamRules_B_index" ON "_teamRules"("B"); + +-- AddForeignKey +ALTER TABLE "Ruleset_Type" ADD CONSTRAINT "Ruleset_Type_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ruleset_Type" ADD CONSTRAINT "Ruleset_Type_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ruleset_Type" ADD CONSTRAINT "Ruleset_Type_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ruleset" ADD CONSTRAINT "Ruleset_rulesetTypeId_fkey" FOREIGN KEY ("rulesetTypeId") REFERENCES "Ruleset_Type"("rulesetTypeId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ruleset" ADD CONSTRAINT "Ruleset_carId_fkey" FOREIGN KEY ("carId") REFERENCES "Car"("carId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ruleset" ADD CONSTRAINT "Ruleset_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ruleset" ADD CONSTRAINT "Ruleset_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Rule" ADD CONSTRAINT "Rule_rulesetId_fkey" FOREIGN KEY ("rulesetId") REFERENCES "Ruleset"("rulesetId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +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; + +-- AddForeignKey +ALTER TABLE "Rule" ADD CONSTRAINT "Rule_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Rule" ADD CONSTRAINT "Rule_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Rule" ADD CONSTRAINT "Rule_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project_Rule" ADD CONSTRAINT "Project_Rule_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "Rule"("ruleId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- 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_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project_Rule" ADD CONSTRAINT "Project_Rule_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ruleReferences" ADD CONSTRAINT "_ruleReferences_A_fkey" FOREIGN KEY ("A") REFERENCES "Rule"("ruleId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ruleReferences" ADD CONSTRAINT "_ruleReferences_B_fkey" FOREIGN KEY ("B") REFERENCES "Rule"("ruleId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_teamRules" ADD CONSTRAINT "_teamRules_A_fkey" FOREIGN KEY ("A") REFERENCES "Rule"("ruleId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_teamRules" ADD CONSTRAINT "_teamRules_B_fkey" FOREIGN KEY ("B") REFERENCES "Team"("teamId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 4d8cb369db..bee8b81245 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -1162,7 +1162,15 @@ export default class RulesService { * @param organizationId the organization id * @returns the rules in this team that do not have an associated project rule */ - static async getUnassignedRulesForRuleset(rulesetId: string, teamId: string, projectId: string, organizationId: string) { + static async getUnassignedRulesForRuleset( + rulesetId: string, + teamId: string, + projectId: string | undefined, + organizationId: string + ) { + if (!projectId) { + throw new HttpException(400, 'Query parameter projectId is required'); + } const ruleset = await prisma.ruleset.findUnique({ where: { rulesetId }, select: { From f9719ee51d86f8329a8922c2251ec7f42db9d673 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sat, 4 Jul 2026 10:36:40 -0400 Subject: [PATCH 13/16] #4284 fix loading and error ordering --- src/backend/src/prisma/seed-data/rules.seed.ts | 2 +- ...ddRuleModal.tsx => AddProjectRuleModal.tsx} | 6 +++--- .../ProjectRules/ProjectRulesTab.tsx | 18 ++++++++---------- .../src/pages/RulesPage/AssignRulesTab.tsx | 8 ++++---- .../src/pages/RulesPage/RulesetPage.tsx | 2 +- .../src/pages/RulesPage/RulesetViewPage.tsx | 8 ++++---- .../RulesPage/components/RulesetTable.tsx | 2 +- .../RulesPage/components/RulesetTypeTable.tsx | 2 +- 8 files changed, 23 insertions(+), 25 deletions(-) rename src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/{AddRuleModal.tsx => AddProjectRuleModal.tsx} (99%) diff --git a/src/backend/src/prisma/seed-data/rules.seed.ts b/src/backend/src/prisma/seed-data/rules.seed.ts index 30f4c993e8..bcd12b7ba6 100644 --- a/src/backend/src/prisma/seed-data/rules.seed.ts +++ b/src/backend/src/prisma/seed-data/rules.seed.ts @@ -703,7 +703,7 @@ export const seedFsaeRules = async ( for (const rule of [topLevelTechnical, T1Rule, T11Rule, T112Rule, T112ARule]) { await RulesService.toggleRuleTeam(rule.ruleId, huskyTeamId, batman, organization); } - // TODO: the above logic should be in the service function, not handled in the assign team frontend + // TODO: the above logic should be in the service function, not handled in the assign team frontend await RulesService.createProjectRule(batman, organization, T112ARule.ruleId, projectId); await RulesService.setRuleCompletion(batman, organization, T112ARule.ruleId, true, projectId); }; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddProjectRuleModal.tsx similarity index 99% rename from src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx rename to src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddProjectRuleModal.tsx index 3290b2d6bc..35188edc4e 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddProjectRuleModal.tsx @@ -142,12 +142,12 @@ const AddRuleModal = ({ open, onHide, rulesetId, teamId, teamName, projectId, on disabled={selectedRuleIds.length === 0} > - {isLoading ? ( + {isError ? ( + Failed to load rules + ) : isLoading ? ( - ) : isError ? ( - Failed to load rules ) : !unassignedRules || unassignedRules.length === 0 ? ( No unassigned rules available for the {teamName} team. diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index b80cc893cc..221b56307e 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -25,7 +25,7 @@ import LoadingIndicator from '../../../../components/LoadingIndicator'; import ErrorPage from '../../../ErrorPage'; import RuleRow from '../../../RulesPage/RuleRow'; import UpdateStatusPopover from './UpdateStatusPopover'; -import AddRuleModal from './AddRuleModal'; +import AddRuleModal from './AddProjectRuleModal'; import { useAllRulesetTypes, useActiveRuleset, @@ -171,16 +171,14 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { setSelectedRulesetTypeIndex(newValue); }; - // Loading state - if (rulesetTypesLoading) { - return ; - } - - // Error state if (rulesetTypesError) { return ; } + if (rulesetTypesLoading) { + return ; + } + // No ruleset types if (!rulesetTypes || rulesetTypes.length === 0) { return ( @@ -346,7 +344,9 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { )} {/* Rules Content */} - {activeRulesetLoading || projectRulesLoading ? ( + {projectRulesError ? ( + Failed to load rules + ) : activeRulesetLoading || projectRulesLoading ? ( @@ -356,8 +356,6 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { No active ruleset configured for this ruleset type. - ) : projectRulesError ? ( - Failed to load rules ) : topLevelRules.length === 0 ? ( diff --git a/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx b/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx index 601872aa76..b1e00811ac 100644 --- a/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx +++ b/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx @@ -224,14 +224,14 @@ const AssignRulesTab: React.FC = ({ rules }) => { }); }; - if (teamsLoading) { - return ; - } - if (teamsError) { return ; } + if (teamsLoading) { + return ; + } + const topLevelRules = rules.filter((rule) => !rule.parentRule); return ( diff --git a/src/frontend/src/pages/RulesPage/RulesetPage.tsx b/src/frontend/src/pages/RulesPage/RulesetPage.tsx index d3618acb02..b63fceb5ff 100644 --- a/src/frontend/src/pages/RulesPage/RulesetPage.tsx +++ b/src/frontend/src/pages/RulesPage/RulesetPage.tsx @@ -73,8 +73,8 @@ const RulesetPage: React.FC = () => { } }; - if (isLoading) return ; if (isError) return ; + if (isLoading) return ; return ( <> diff --git a/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx index e1457510e8..4e7ece0ba1 100644 --- a/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx +++ b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx @@ -72,10 +72,6 @@ const RulesetViewPage = () => { isLoading: isRulesLoading } = useAllRulesForRuleset(rulesetId!); - if (isRulesetLoading || isRulesLoading) { - return ; - } - if (isRulesetError) { return ; } @@ -84,6 +80,10 @@ const RulesetViewPage = () => { return ; } + if (isRulesetLoading || isRulesLoading) { + return ; + } + if (!ruleset || !allRules) { return ; } diff --git a/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx b/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx index 003dbc408b..d494d3d2d6 100644 --- a/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx +++ b/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx @@ -129,8 +129,8 @@ const RulesetTable: React.FC = () => { ); }; - if (isLoading) return ; if (error) return ; + if (isLoading) return ; return ( diff --git a/src/frontend/src/pages/RulesPage/components/RulesetTypeTable.tsx b/src/frontend/src/pages/RulesPage/components/RulesetTypeTable.tsx index 4ef2c1c2eb..daa3b9bbc4 100644 --- a/src/frontend/src/pages/RulesPage/components/RulesetTypeTable.tsx +++ b/src/frontend/src/pages/RulesPage/components/RulesetTypeTable.tsx @@ -118,8 +118,8 @@ const RulesetTypeTable: React.FC = () => { ); }; - if (isLoading) return ; if (error) return ; + if (isLoading) return ; return ( From d099db7b3d5cb6d79927d122f04f521bced17423 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sat, 4 Jul 2026 10:40:07 -0400 Subject: [PATCH 14/16] #4284 add pdf parser to yarn lock to hopefully fix failing tests --- yarn.lock | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/yarn.lock b/yarn.lock index 3527f9fa4f..ee85d356a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9282,6 +9282,7 @@ __metadata: multer: "npm:^1.4.5-lts.1" nodemailer: "npm:^6.9.1" nodemon: "npm:^2.0.16" + pdf-parse-new: "npm:^1.4.1" prisma: "npm:^6.2.1" shared: "npm:1.0.0" supertest: "npm:^6.2.4" @@ -19059,6 +19060,13 @@ __metadata: languageName: node linkType: hard +"node-ensure@npm:^0.0.0": + version: 0.0.0 + resolution: "node-ensure@npm:0.0.0" + checksum: 10/1124623beb1dec66889794286ec1761ac3bfac42ce93b8652903efa5af14227edce5bafa06c4e0675cdc850360931d7c9413e3b5406150f05b2c9bf5bb3ccce4 + languageName: node + linkType: hard + "node-exports-info@npm:^1.6.0": version: 1.6.0 resolution: "node-exports-info@npm:1.6.0" @@ -19896,6 +19904,16 @@ __metadata: languageName: node linkType: hard +"pdf-parse-new@npm:^1.4.1": + version: 1.4.1 + resolution: "pdf-parse-new@npm:1.4.1" + dependencies: + debug: "npm:^4.3.4" + node-ensure: "npm:^0.0.0" + checksum: 10/ac7a64517ce855de5d1da77ec7b0c657e51597c7ba5dfbdf75526447167ea647683aae69ccc721352d29e988ddc6995ae5a5c27036e60c956b076afb032f9272 + languageName: node + linkType: hard + "pdfjs-dist@npm:4.8.69": version: 4.8.69 resolution: "pdfjs-dist@npm:4.8.69" From e6d5de9bb75a72eaa4ccc9eea8d9dcf86b77a58e Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sat, 4 Jul 2026 11:14:49 -0400 Subject: [PATCH 15/16] #4284 linting and typescript fixes --- .../src/prisma/seed-data/rules.seed.ts | 12 +++---- src/backend/src/services/rules.services.ts | 4 +++ src/backend/tests/test-utils.ts | 1 - src/backend/tests/unit/rule.test.ts | 32 +++++++++++++++---- .../ProjectRules/ProjectRulesTab.tsx | 8 ++--- .../RulesPage/components/RulesetTeamView.tsx | 12 ++++--- 6 files changed, 46 insertions(+), 23 deletions(-) diff --git a/src/backend/src/prisma/seed-data/rules.seed.ts b/src/backend/src/prisma/seed-data/rules.seed.ts index bcd12b7ba6..1a70c4386f 100644 --- a/src/backend/src/prisma/seed-data/rules.seed.ts +++ b/src/backend/src/prisma/seed-data/rules.seed.ts @@ -174,7 +174,7 @@ export const seedFsaeRules = async ( } }); - const T112BRule = await prisma.rule.create({ + await prisma.rule.create({ data: { ruleCode: 'T.1.1.2.b', ruleContent: 'Is less than or equal to 320 mm above the lowest point inside the cockpit', @@ -235,7 +235,7 @@ export const seedFsaeRules = async ( } }); - const IC1Rule = await prisma.rule.create({ + await prisma.rule.create({ data: { ruleCode: 'IC.1', ruleContent: 'GENERAL REQUIREMENTS', @@ -265,7 +265,7 @@ export const seedFsaeRules = async ( } }); - const IC561Rule = await prisma.rule.create({ + await prisma.rule.create({ data: { ruleCode: 'IC.5.6.1', ruleContent: @@ -276,7 +276,7 @@ export const seedFsaeRules = async ( } }); - const IC562Rule = await prisma.rule.create({ + await prisma.rule.create({ data: { ruleCode: 'IC.5.6.2', ruleContent: 'All fuel vent lines must have a check valve to prevent fuel leakage when the tank is inverted', @@ -286,7 +286,7 @@ export const seedFsaeRules = async ( } }); - const IC563Rule = await prisma.rule.create({ + await prisma.rule.create({ data: { ruleCode: 'IC.5.6.3', ruleContent: 'All fuel vent lines must exit outside the bodywork', @@ -433,7 +433,7 @@ export const seedFsaeRules = async ( }); // Rule F.5.7.1 references F.3.2.1.c - const F571Rule = await prisma.rule.create({ + await prisma.rule.create({ data: { ruleCode: 'F.5.7.1', ruleContent: 'The Front Hoop must be constructed of closed section metal tubing meeting F.3.2.1.c', diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index bee8b81245..531bfeb496 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -438,6 +438,10 @@ export default class RulesService { ...getProjectRuleQueryArgs() }); + if (!projectRule) { + throw new NotFoundException('Project Rule', ruleId); + } + return projectRuleTransformer(projectRule); } diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index 767f2ede41..bdbc11b173 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -116,7 +116,6 @@ export const resetUsers = async () => { await prisma.part_Review.deleteMany(); await prisma.part_Submission.deleteMany(); await prisma.part.deleteMany(); - await prisma.rule_Status_Change.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 db093f3135..95c864afe6 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -704,8 +704,6 @@ describe('Create Rules Tests', () => { name: 'wrong org', userCreatedId: batman.userId, description: 'desc', - applyInterestImageId: '1', - exploreAsGuestImageId: '1', applicationLink: '1' } }); @@ -1676,7 +1674,12 @@ describe('Rule Tests', () => { } }); await expect( - RulesService.getUnassignedRulesForRuleset(otherRuleset.rulesetId, testTeam.teamId, organization.organizationId) + RulesService.getUnassignedRulesForRuleset( + otherRuleset.rulesetId, + testTeam.teamId, + project.projectId, + organization.organizationId + ) ).rejects.toThrow(InvalidOrganizationException); }); it('fails if team is in the wrong org', async () => { @@ -1691,19 +1694,34 @@ describe('Rule Tests', () => { }); const { ruleset1 } = await setupRules(car); await expect( - RulesService.getUnassignedRulesForRuleset(ruleset1.rulesetId, otherTeam.teamId, organization.organizationId) + RulesService.getUnassignedRulesForRuleset( + ruleset1.rulesetId, + otherTeam.teamId, + project.projectId, + organization.organizationId + ) ).rejects.toThrow(InvalidOrganizationException); }); it('fails if ruleset does not exist', async () => { await expect( - RulesService.getUnassignedRulesForRuleset('nonexistent-ruleset-id', testTeam.teamId, organization.organizationId) + RulesService.getUnassignedRulesForRuleset( + 'nonexistent-ruleset-id', + testTeam.teamId, + project.projectId, + organization.organizationId + ) ).rejects.toThrow(new NotFoundException('Ruleset', 'nonexistent-ruleset-id')); }); it('fails if team does not exist', async () => { const car = await createUniqueCar(orgId); const { ruleset1 } = await setupRules(car); await expect( - RulesService.getUnassignedRulesForRuleset(ruleset1.rulesetId, 'fake-team-id', organization.organizationId) + RulesService.getUnassignedRulesForRuleset( + ruleset1.rulesetId, + 'fake-team-id', + project.projectId, + organization.organizationId + ) ).rejects.toThrow(new NotFoundException('Team', 'fake-team-id')); }); it('successfully returns rules in the team that have no projects', async () => { @@ -1749,6 +1767,7 @@ describe('Rule Tests', () => { const rules = await RulesService.getUnassignedRulesForRuleset( ruleset1.rulesetId, testTeam.teamId, + project.projectId, organization.organizationId ); expect(rules.length).toEqual(2); @@ -1765,6 +1784,7 @@ describe('Rule Tests', () => { const rules = await RulesService.getUnassignedRulesForRuleset( ruleset1.rulesetId, testTeam.teamId, + project.projectId, organization.organizationId ); expect(rules).toEqual([]); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index 221b56307e..a80bc77c2e 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -364,16 +364,12 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { ) : ( - +
diff --git a/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx b/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx index fe84ccc53e..7e89317248 100644 --- a/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx +++ b/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx @@ -38,7 +38,8 @@ const RulesetTeamView: React.FC = ({ allRules, teamRules, ...team.projects.map((p) => `project-${p.projectId}`), ...(team.unassignedRules.length > 0 ? [`team-${team.teamId}-unassigned`] : []) ], - referencedRuleIds: [] + referencedRuleIds: [], + isComplete: false })); // Convert projects to mock rules for rendering with RuleRow @@ -53,7 +54,8 @@ const RulesetTeamView: React.FC = ({ allRules, teamRules, ruleCode: `${team.teamName}` }, subRuleIds: project.rules.map((r) => r.ruleId), - referencedRuleIds: [] + referencedRuleIds: [], + isComplete: false })) ); @@ -70,7 +72,8 @@ const RulesetTeamView: React.FC = ({ allRules, teamRules, ruleCode: `${team.teamName}` }, subRuleIds: team.unassignedRules.map((r) => r.ruleId), - referencedRuleIds: [] + referencedRuleIds: [], + isComplete: false })); // Create unassigned to team mock rule @@ -83,7 +86,8 @@ const RulesetTeamView: React.FC = ({ allRules, teamRules, imageFileIds: [], parentRule: undefined, subRuleIds: unassignedToTeam.map((r) => r.ruleId), - referencedRuleIds: [] + referencedRuleIds: [], + isComplete: false } : null; From 669a5623d28fd7f69fb6cd4f5c5a1cf4f4745b2d Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sat, 4 Jul 2026 11:21:32 -0400 Subject: [PATCH 16/16] #4284 .js and test fix --- src/backend/src/services/rules.services.ts | 2 +- src/backend/tests/unit/rule.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 531bfeb496..b0636a6d17 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -10,7 +10,7 @@ import { isHead, Ruleset } from 'shared'; -import prisma from '../prisma/prisma'; +import prisma from '../prisma/prisma.js'; import { AccessDeniedAdminOnlyException, AccessDeniedGuestException, diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index 95c864afe6..0fdcc9699e 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -1065,7 +1065,7 @@ describe('Rule Tests', () => { await expect( async () => await RulesService.setRuleCompletion(nonLeadership, organization, topLevelRule.ruleId, true, project.projectId) - ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update a rule completion')); + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule completion')); }); });