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..a9a663d321 100644 --- a/src/backend/src/prisma-query-args/rules.query-args.ts +++ b/src/backend/src/prisma-query-args/rules.query-args.ts @@ -27,29 +27,32 @@ export const getRulePreviewQueryArgs = () => teamId: true, teamName: true } + }, + completedBy: { + select: { + firstName: true, + lastName: true + } + }, + completedInProject: { + select: { + projectId: true, + wbsElement: { + select: { + name: true + } + } + } } } }); +export type ProjectRuleQueryArgs = ReturnType; + 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' - } - } + rule: getRulePreviewQueryArgs() } }); 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/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..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 } }, @@ -105,7 +68,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 +76,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 } } @@ -126,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 6ac2c904d5..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,393 +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); - - // 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: { 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 86bd54f5bd..386baba4d2 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, @@ -385,13 +385,56 @@ 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 - }, + // ensure we assign all ancestors of a rule to the project + 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 } + }); + // 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; + } + + // skip ancestors already assigned to this project + 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)); + + // create all project rules + await prisma.$transaction([ + ...ancestorsToCreate.map((ancestorId) => + prisma.project_Rule.create({ + data: { + ruleId: ancestorId, + projectId, + createdByUserId: submitter.userId + } + }) + ), + prisma.project_Rule.create({ + data: { + ruleId, + projectId, + createdByUserId: submitter.userId + } + }) + ]); + + // return only original project rule being assigned (leaf rule) + const projectRule = await prisma.project_Rule.findUnique({ + where: { ruleId_projectId: { ruleId, projectId } }, ...getProjectRuleQueryArgs() }); @@ -658,64 +701,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 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 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 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..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 { @@ -19,17 +19,28 @@ 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 }; }; -export const projectRuleTransformer = (projectRule: any): ProjectRule => { +export const projectRuleTransformer = (projectRule: Prisma.Project_RuleGetPayload): 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 3304562f7a..db093f3135 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, @@ -367,6 +367,150 @@ describe('Create Rules Tests', () => { }); }); + describe('Create Project Rule', () => { + 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, + topLevelRule.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); // 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 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, + 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); + + const project = await createTestProject(aquaman, orgId, undefined, carId); + + await RulesService.createProjectRule(aquaman, organization, grandchild1.ruleId, project.projectId); + // 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); // grandchild1, grandchild2, child, topLevelRule + expect(assignedRuleIds).toEqual( + expect.arrayContaining([topLevelRule.ruleId, child.ruleId, grandchild1.ruleId, grandchild2.ruleId]) + ); + }); + + 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, + topLevelRule.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); // child and topLevelRule, not grandchild + expect(assignedRuleIds).toEqual(expect.arrayContaining([topLevelRule.ruleId, child.ruleId])); + }); + + 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, + topLevelRule.ruleId + ); + const grandchild = await RulesService.createRule(batman, 'T.1.1.1', 'Wheels', rulesetId, organization, child.ruleId); + + // 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 } } } + }); + + const project = await createTestProject(aquaman, orgId, undefined, carId); + + // 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)); + + // nothing should have been assigned (not the grandchild, the deleted parent, or the root) + const projectRules = await RulesService.getProjectRules(rulesetId, project.projectId, organization); + expect(projectRules).toHaveLength(0); + + const grandchildProjectRule = await prisma.project_Rule.findUnique({ + where: { ruleId_projectId: { ruleId: grandchild.ruleId, projectId: project.projectId } } + }); + expect(grandchildProjectRule).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); @@ -804,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); @@ -817,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); @@ -875,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')); }); }); @@ -1238,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); @@ -1612,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..581320a63f 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'; +import { InfoOutlined, KeyboardArrowRight, KeyboardArrowDown } from '@mui/icons-material'; 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; + return false; } - - if (leafStatuses.some((s) => s === RuleCompletion.INCOMPLETE)) { - return RuleCompletion.INCOMPLETE; - } - - 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,16 +197,37 @@ 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}` : ''}` + : ''; + + // 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', @@ -259,31 +257,19 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { : {} }} > + {isLeafRule && + (isPopoverOpenForRule ? ( + + ) : ( + + ))} {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' - } - }} - > - - - )} - + ); }; + const backgroundColor = theme.palette.background.default; const tableBackgroundColor = theme.palette.background.paper; const tableTextColor = theme.palette.text.primary; const tableHoverColor = theme.palette.action.hover; @@ -336,9 +322,19 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { ) : ( - - - + +
+ {topLevelRules.map((rule) => ( { hoverColor={tableHoverColor} rowHeight="40px" verticalPadding="8px" + indentRow /> ))} @@ -402,16 +399,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..66287f4303 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: 'Complete' }, + { 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/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} /> ))} 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 {