diff --git a/src/backend/index.ts b/src/backend/index.ts index 0960a82393..ee69de2013 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -26,6 +26,7 @@ import statisticsRouter from './src/routes/statistics.routes.js'; import retrospectiveRouter from './src/routes/retrospective.routes.js'; import partsRouter from './src/routes/parts.routes.js'; import financeRouter from './src/routes/finance.routes.js'; +import rulesRouter from './src/routes/rules.routes.js'; import calendarRouter from './src/routes/calendar.routes.js'; import prospectiveSponsorRouter from './src/routes/prospective-sponsor.routes.js'; import attendanceRouter from './src/routes/attendance.routes.js'; @@ -119,6 +120,7 @@ app.use('/statistics', statisticsRouter); app.use('/retrospective', retrospectiveRouter); app.use('/parts', partsRouter); app.use('/finance', financeRouter); +app.use('/rules', rulesRouter); app.use('/calendar', calendarRouter); app.use('/prospective-sponsors', prospectiveSponsorRouter); app.use('/attendance', attendanceRouter); diff --git a/src/backend/package.json b/src/backend/package.json index faa6faf427..46cb4bd87a 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -35,6 +35,7 @@ "jsonwebtoken": "^8.5.1", "multer": "^1.4.5-lts.1", "nodemailer": "^6.9.1", + "pdf-parse-new": "^1.4.1", "prisma": "^6.2.1", "shared": "1.0.0" }, diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts new file mode 100644 index 0000000000..c384b07af7 --- /dev/null +++ b/src/backend/src/controllers/rules.controllers.ts @@ -0,0 +1,348 @@ +import { NextFunction, Request, Response } from 'express'; +import RulesService from '../services/rules.services.js'; +import { ProjectRule, Rule, Ruleset } from 'shared'; +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 as Record; + const rulesetType = await RulesService.getActiveRuleset(req.currentUser, rulesetTypeId, req.organization); + res.status(200).json(rulesetType); + } catch (error: unknown) { + next(error); + } + } + + static async getRulesetById(req: Request, res: Response, next: NextFunction) { + try { + 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); + } + } + + static async createRule(req: Request, res: Response, next: NextFunction) { + try { + const { ruleCode, ruleContent, rulesetId, parentRuleId, referencedRules, imageFileIds } = req.body; + + const rule = await RulesService.createRule( + req.currentUser, + ruleCode, + ruleContent, + rulesetId, + req.organization, + parentRuleId, + referencedRules || [], + imageFileIds || [] + ); + + res.status(201).json(rule); + } catch (error: unknown) { + next(error); + } + } + + static async deleteRule(req: Request, res: Response, next: NextFunction) { + try { + 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); + } + } + + static async createRulesetType(req: Request, res: Response, next: NextFunction) { + try { + const { name } = req.body; + const rulesetType = await RulesService.createRulesetType(req.currentUser, name, req.organization); + res.status(200).json(rulesetType); + } catch (error: unknown) { + next(error); + } + } + + static async createProjectRule(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId, projectId } = req.body; + const projectRule: ProjectRule = await RulesService.createProjectRule( + req.currentUser, + req.organization, + ruleId, + projectId + ); + + res.status(200).json(projectRule); + } catch (error: unknown) { + next(error); + } + } + + static async editRule(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId } = req.params as Record; + const { ruleContent, ruleCode, imageFileIds, parentRuleId } = req.body; + + const rule = await RulesService.editRule( + req.currentUser, + ruleContent, + ruleId, + ruleCode, + imageFileIds, + req.organization, + parentRuleId + ); + res.status(200).json(rule); + } catch (error: unknown) { + next(error); + } + } + + static async getAllRulesetTypes(req: Request, res: Response, next: NextFunction) { + try { + const rulesets = await RulesService.getAllRulesetTypes(req.organization); + res.status(200).json(rulesets); + } catch (error: unknown) { + next(error); + } + } + + static async getRulesetsByRulesetType(req: Request, res: Response, next: NextFunction) { + try { + 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); + } + } + + static async getRulesetType(req: Request, res: Response, next: NextFunction) { + try { + 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); + } + } + + static async deleteRuleset(req: Request, res: Response, next: NextFunction) { + try { + 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); + } + } + + static async deleteProjectRule(req: Request, res: Response, next: NextFunction) { + try { + 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); + } + } + + static async setRuleCompletion(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId } = req.params as Record; + const { isComplete, projectId } = req.body; + + const rule: Rule = await RulesService.setRuleCompletion( + req.currentUser, + req.organization, + ruleId, + isComplete, + projectId + ); + + res.status(200).json(rule); + } catch (error: unknown) { + next(error); + } + } + + static async toggleRuleTeam(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId } = req.params as Record; + const { teamId } = req.body; + + const changedRule = await RulesService.toggleRuleTeam(ruleId, teamId, req.currentUser, req.organization); + + res.status(200).json(changedRule); + } catch (error: unknown) { + next(error); + } + } + + static async createRuleset(req: Request, res: Response, next: NextFunction) { + try { + const { name, rulesetTypeId, carNumber, active, fileId } = req.body; + + const ruleset = await RulesService.createRuleset( + req.currentUser, + req.organization, + name, + rulesetTypeId, + carNumber, + active, + fileId + ); + + res.status(200).json(ruleset); + } catch (error: unknown) { + next(error); + } + } + + static async deleteRulesetType(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetTypeId } = req.params as Record; + + const rulesetType = await RulesService.deleteRulesetType(req.currentUser, rulesetTypeId, req.organization); + + res.status(200).json(rulesetType); + } catch (error: unknown) { + next(error); + } + } + + static async updateRuleset(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetId } = req.params as Record; + const { name, isActive } = req.body; + + const ruleset: Ruleset = await RulesService.updateRuleset( + req.currentUser, + req.organization.organizationId, + rulesetId, + name, + isActive + ); + + res.status(200).json(ruleset); + } catch (error: unknown) { + next(error); + } + } + + static async getChildRules(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId: parentRuleId } = req.params as Record; + const childrenRules: Rule[] = await RulesService.getChildRules(parentRuleId, req.organization); + + res.status(200).json(childrenRules); + } catch (error: unknown) { + next(error); + } + } + + static async getUnassignedRules(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetId } = req.params as Record; + const rules = await RulesService.getUnassignedRules(rulesetId, req.organization); + res.status(200).json(rules); + } catch (error: unknown) { + next(error); + } + } + + static async getUnassignedRulesForRuleset(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetId, teamId } = req.params as Record; + const { projectId } = req.query; + const projectIdString = typeof projectId === 'string' ? projectId : undefined; + + const rules = await RulesService.getUnassignedRulesForRuleset( + rulesetId, + teamId, + projectIdString, + req.organization.organizationId + ); + res.status(200).json(rules); + } catch (error: unknown) { + next(error); + } + } + + static async getProjectRules(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetId, projectId } = req.params as Record; + + const projectRules = await RulesService.getProjectRules(rulesetId, projectId, req.organization); + + res.status(200).json(projectRules); + } catch (error: unknown) { + next(error); + } + } + + static async getTeamRulesInRulesetType(req: Request, res: Response, next: NextFunction) { + try { + 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); + } + } + + static async getTopLevelRules(req: Request, res: Response, next: NextFunction) { + try { + 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); + } + } + + static async parseRuleset(req: Request, res: Response, next: NextFunction) { + try { + const { fileId, parserType } = req.body; + const { rulesetId } = req.params as Record; + + const parseResult = await RulesService.parseRuleset( + req.currentUser, + req.organization.organizationId, + fileId, + rulesetId, + parserType + ); + + res.status(200).json(parseResult); + } catch (error: unknown) { + next(error); + } + } + + static async uploadRulesetFile(req: Request, res: Response, next: NextFunction) { + try { + if (!req.file) { + throw new HttpException(400, 'Invalid or undefined file data'); + } + + const fileId = await RulesService.uploadRulesetFile(req.file, req.currentUser, req.organization); + res.status(200).json(fileId); + } catch (error: unknown) { + next(error); + } + } + + static async getSingleRuleset(req: Request, res: Response, next: NextFunction) { + try { + 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-query-args/rules.query-args.ts b/src/backend/src/prisma-query-args/rules.query-args.ts new file mode 100644 index 0000000000..a9a663d321 --- /dev/null +++ b/src/backend/src/prisma-query-args/rules.query-args.ts @@ -0,0 +1,97 @@ +import { Prisma } from '@prisma/client'; + +export type RulePreviewQueryArgs = ReturnType; + +// preview for rule display +export const getRulePreviewQueryArgs = () => + Prisma.validator()({ + include: { + parentRule: { + select: { + ruleId: true, + ruleCode: true + } + }, + subRules: { + select: { + ruleId: true + } + }, + referencedRule: { + select: { + ruleId: true + } + }, + teams: { + select: { + 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() + } + }); + +export type RulesetQueryArgs = ReturnType; + +export const getRulesetQueryArgs = () => + Prisma.validator()({ + include: { + rules: { + where: { dateDeleted: null }, + select: { + ruleId: true, + _count: { + select: { + teams: true + } + } + } + }, + rulesetType: true, + car: { + include: { + wbsElement: true + } + } + } + }); + +export const getRulesetPreviewQueryArgs = () => + Prisma.validator()({ + select: { + name: true, + dateCreated: true, + rulesetType: true, + active: true, + car: { + include: { + wbsElement: true + } + } + } + }); diff --git a/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql b/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql 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/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index a757bbaa43..0719d745d8 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -293,6 +293,16 @@ model User { deletedSponsorTiers Sponsor_Tier[] financeDelegateForOrganizations Organization[] @relation(name: "financeDelegates") assignedReimbursementRequests Reimbursement_Request[] @relation(name: "reimbursementRequestAssignee") + completedRules Rule[] @relation(name: "ruleCompleter") + createdRulesetTypes Ruleset_Type[] @relation(name: "rulesetTypeCreator") + deletedRulesetTypes Ruleset_Type[] @relation(name: "rulesetTypeDeleter") + createdRulesets Ruleset[] @relation(name: "rulesetCreator") + deletedRulesets Ruleset[] @relation(name: "rulesetDeleter") + createdRules Rule[] @relation(name: "ruleCreator") + updatedRules Rule[] @relation(name: "ruleUpdater") + deletedRules Rule[] @relation(name: "ruleDeletor") + createdProjectRules Project_Rule[] @relation(name: "projectRuleCreator") + deletedProjectRules Project_Rule[] @relation(name: "projectRuleDeletor") deletedGuestDefinitions Guest_Definition[] @relation(name: "guestDefinitionDeleter") createdGuestDefinitions Guest_Definition[] @relation(name: "guestDefinitionCreator") requiredEvents Event[] @relation(name: "requiredEventAttendee") @@ -343,6 +353,7 @@ model Team { checklists Checklist[] projectTemplates Project_Template[] meetingAttendances Meeting_Attendance[] + rules Rule[] @relation(name: "teamRules") @@index([headId]) @@index([organizationId]) @@ -582,6 +593,8 @@ model Project { featuredByOrganization Organization? @relation(fields: [featuredByOrganizationId], references: [organizationId]) abbreviation String? parts Part[] + rules Project_Rule[] @relation(name: "projectsForRule") + completedRules Rule[] @relation(name: "ruleCompletedInProject") @@index([carId]) } @@ -1380,6 +1393,7 @@ model Car { wbsElementId String @unique wbsElement WBS_Element @relation(fields: [wbsElementId], references: [wbsElementId]) linkedGraphs Graph[] @relation(name: "graphCars") + rulesets Ruleset[] @relation(name: "rulesetCar") @@index([wbsElementId]) } @@ -1444,6 +1458,7 @@ model Organization { prospectiveSponsors Prospective_Sponsor[] indexCodes Index_Code[] financeDelegates User[] @relation(name: "financeDelegates") + rulesetTypes Ruleset_Type[] guestDefinitions Guest_Definition[] shops Shop[] machineries Machinery[] @@ -1793,6 +1808,90 @@ enum Guest_Definition_Type { INFO_PAGE } +model Ruleset_Type { + rulesetTypeId String @id @default(uuid()) + name String + lastUpdated DateTime @updatedAt + revisionFiles Ruleset[] @relation(name: "rulesetFileType") + dateCreated DateTime @default(now()) + createdByUserId String + createdBy User @relation(name: "rulesetTypeCreator", fields: [createdByUserId], references: [userId]) + dateDeleted DateTime? + deletedByUserId String? + deletedBy User? @relation(name: "rulesetTypeDeleter", fields: [deletedByUserId], references: [userId]) + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) + + @@index([organizationId]) +} + +model Ruleset { + rulesetId String @id @default(uuid()) + fileId String + name String + active Boolean + rules Rule[] + rulesetTypeId String + rulesetType Ruleset_Type @relation(name: "rulesetFileType", fields: [rulesetTypeId], references: [rulesetTypeId]) + carId String + car Car @relation(name: "rulesetCar", fields: [carId], references: [carId]) + dateCreated DateTime @default(now()) + createdByUserId String + createdBy User @relation(name: "rulesetCreator", fields: [createdByUserId], references: [userId]) + dateDeleted DateTime? + deletedByUserId String? + deletedBy User? @relation(name: "rulesetDeleter", fields: [deletedByUserId], references: [userId]) +} + +model Rule { + ruleId String @id @default(uuid()) + ruleCode String + ruleContent String + imageFileIds String[] + rulesetId String + ruleset Ruleset @relation(fields: [rulesetId], references: [rulesetId]) + parentRuleId String? + parentRule Rule? @relation(name: "subRules", fields: [parentRuleId], references: [ruleId]) + subRules Rule[] @relation(name: "subRules") + referencedRule Rule[] @relation(name: "ruleReferences") + referencedBy Rule[] @relation(name: "ruleReferences") + projects Project_Rule[] @relation(name: "rulesInProject") + teams Team[] @relation(name: "teamRules") + isComplete Boolean @default(false) + completedByUserId String? + completedBy User? @relation(name: "ruleCompleter", fields: [completedByUserId], references: [userId]) + completedInProjectId String? + completedInProject Project? @relation(name: "ruleCompletedInProject", fields: [completedInProjectId], references: [projectId]) + dateCreated DateTime @default(now()) + dateUpdated DateTime? @updatedAt + dateDeleted DateTime? + createdByUserId String + createdBy User @relation(name: "ruleCreator", fields: [createdByUserId], references: [userId]) + updatedByUserId String? + updatedBy User? @relation(name: "ruleUpdater", fields: [updatedByUserId], references: [userId]) + deletedByUserId String? + deletedBy User? @relation(name: "ruleDeletor", fields: [deletedByUserId], references: [userId]) + + @@unique([rulesetId, ruleCode]) + @@index([parentRuleId, rulesetId, ruleCode]) +} + +model Project_Rule { + projectRuleId String @id @default(uuid()) + ruleId String + rule Rule @relation(name: "rulesInProject", fields: [ruleId], references: [ruleId]) + projectId String + project Project @relation(name: "projectsForRule", fields: [projectId], references: [projectId]) + dateCreated DateTime @default(now()) + createdByUserId String + createdBy User @relation(name: "projectRuleCreator", fields: [createdByUserId], references: [userId]) + dateDeleted DateTime? + deletedByUserId String? + deletedBy User? @relation(name: "projectRuleDeletor", fields: [deletedByUserId], references: [userId]) + + @@unique([ruleId, projectId]) +} + model Guest_Definition { definitionId String @id @default(uuid()) term String diff --git a/src/backend/src/prisma/seed-data/rules.seed.ts b/src/backend/src/prisma/seed-data/rules.seed.ts new file mode 100644 index 0000000000..1a70c4386f --- /dev/null +++ b/src/backend/src/prisma/seed-data/rules.seed.ts @@ -0,0 +1,720 @@ +import type { Prisma } from '@prisma/client'; +import { Organization, PrismaClient } from '@prisma/client'; +import RulesService from '../../services/rules.services.js'; +import { User } from 'shared'; + +// ruleset types +const rulesetTypeFSAE = (userCreatedId: string, organizationId: string): Prisma.Ruleset_TypeCreateInput => { + return { + name: 'FSAE', + createdBy: { connect: { userId: userCreatedId } }, + organization: { connect: { organizationId } } + }; +}; + +const rulesetTypeFHE = (userCreatedId: string, organizationId: string): Prisma.Ruleset_TypeCreateInput => { + return { + name: 'FHE', + createdBy: { connect: { userId: userCreatedId } }, + organization: { connect: { organizationId } } + }; +}; + +const mockRulesetType = (userCreatedId: string, organizationId: string): Prisma.Ruleset_TypeCreateInput => { + return { + name: 'Mock Ruleset Type', + createdBy: { connect: { userId: userCreatedId } }, + organization: { connect: { organizationId } } + }; +}; + +// rulesets +const rulesetFSAE = (carId: string, userCreatedId: string, rulesetTypeId: string): Prisma.RulesetCreateInput => { + return { + name: 'Mock FSAE', + fileId: 'mock-fsae-rules', + active: true, + dateCreated: new Date('2025-01-01T10:00:00Z'), + car: { connect: { carId } }, + createdBy: { connect: { userId: userCreatedId } }, + rulesetType: { connect: { rulesetTypeId } } + }; +}; + +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: 'Mock Ruleset', + fileId: 'mock-rules', + active: true, + dateCreated: new Date('2024-12-31T10:00:00Z'), + car: { connect: { carId } }, + createdBy: { connect: { userId: userCreatedId } }, + rulesetType: { connect: { rulesetTypeId } } + }; +}; + +// project rules +const projectRule1 = (projectId: string, ruleId: string, createdByUserId: string): Prisma.Project_RuleCreateInput => { + return { + rule: { connect: { ruleId } }, + project: { connect: { projectId } }, + createdBy: { connect: { userId: createdByUserId } } + }; +}; + +const projectRule2 = (projectId: string, ruleId: string, createdByUserId: string): Prisma.Project_RuleCreateInput => { + return { + rule: { connect: { ruleId } }, + project: { connect: { projectId } }, + createdBy: { connect: { userId: createdByUserId } } + }; +}; + +export const seedRulesetType = async (submitter: User, name: string, organization: Organization) => { + const createdRulesetType = await RulesService.createRulesetType(submitter, name, organization); + 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 + } + }); + + 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 + } + }); + + 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 + } + }); + + 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 + } + }); + + 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 + } + }); + + 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 + 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 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); +}; + +export const ruleSeedData = { + 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 49dc47aa32..09de35bcb2 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -48,6 +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, seedFsaeRules } from './seed-data/rules.seed.js'; import CalendarService from '../services/calendar.services.js'; import { allChangeRequestsReviewed } from '../utils/change-requests.utils.js'; @@ -4720,6 +4721,54 @@ const performSeed: () => Promise = async () => { } }); + /** + * Rules + */ + + // create ruleset types + const fsaeRulesetType = await prisma.ruleset_Type.create({ + data: ruleSeedData.rulesetTypeFSAE(batman.userId, ner.organizationId) + }); + + const fheRulesetType = await prisma.ruleset_Type.create({ + data: ruleSeedData.rulesetTypeFHE(batman.userId, ner.organizationId) + }); + + const mockRulesetType = await prisma.ruleset_Type.create({ + data: ruleSeedData.mockRulesetType(batman.userId, ner.organizationId) + }); + + // create rulesets + const rulesetFSAE = await prisma.ruleset.create({ + data: ruleSeedData.rulesetFSAE(fergus.carId, batman.userId, fsaeRulesetType.rulesetTypeId) + }); + + const rulesetFHE = await prisma.ruleset.create({ + data: ruleSeedData.rulesetFHE(fergus.carId, batman.userId, fheRulesetType.rulesetTypeId) + }); + + const rulesetMock = await prisma.ruleset.create({ + data: ruleSeedData.rulesetMock(fergus.carId, batman.userId, mockRulesetType.rulesetTypeId) + }); + + // seed the rulesets, and add rules to bodywork project + await seedFsaeRules( + prisma, + rulesetFSAE.rulesetId, + rulesetFHE.rulesetId, + rulesetMock.rulesetId, + { + batman, + thomasEmrax, + joeShmoe, + joeBlow, + superman + }, + ner, + projectHuskies2Id, + huskies.teamId + ); + // 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 new file mode 100644 index 0000000000..94c67058c2 --- /dev/null +++ b/src/backend/src/routes/rules.routes.ts @@ -0,0 +1,109 @@ +import express from 'express'; +import RulesController from '../controllers/rules.controllers.js'; +import { nonEmptyString, validateInputs } from '../utils/validation.utils.js'; +import { body } from 'express-validator'; +import { MAX_FILE_SIZE } from 'shared'; +import multer, { memoryStorage } from 'multer'; + +const rulesRouter = express.Router(); + +rulesRouter.get('/rulesetType/:rulesetTypeId/active', RulesController.getActiveRuleset); +rulesRouter.get('/ruleset/:rulesetId', RulesController.getRulesetById); + +rulesRouter.post( + '/rule/create', + nonEmptyString(body('ruleCode')), + nonEmptyString(body('ruleContent')), + nonEmptyString(body('rulesetId')), + body('parentRuleId').optional().isString(), + body('referencedRules').optional().isArray(), + body('referencedRules.*').optional().isString(), + body('imageFileIds').optional().isArray(), + body('imageFileIds.*').optional().isString(), + validateInputs, + RulesController.createRule +); +rulesRouter.post( + '/rule/:ruleId/edit', + nonEmptyString(body('ruleContent')), + body('ruleCode').optional().isString(), + body('imageFileIds').optional().isArray(), + body('imageFileIds.*').optional().isString(), + body('parentRuleId').optional().isString(), + validateInputs, + RulesController.editRule +); +rulesRouter.post('/rule/:ruleId/delete', RulesController.deleteRule); + +rulesRouter.post('/rulesetType/create', nonEmptyString(body('name')), validateInputs, RulesController.createRulesetType); + +rulesRouter.post( + '/projectRule/create', + nonEmptyString(body('ruleId')), + nonEmptyString(body('projectId')), + validateInputs, + RulesController.createProjectRule +); + +rulesRouter.get('/rulesetTypes', RulesController.getAllRulesetTypes); +rulesRouter.get('/ruleset/:rulesetId/rules/unassigned', RulesController.getUnassignedRules); +rulesRouter.post('/ruleset/:rulesetId/delete', RulesController.deleteRuleset); +rulesRouter.post('/projectRule/:projectRuleId/delete', RulesController.deleteProjectRule); + +rulesRouter.get('/rulesets/:rulesetTypeId', RulesController.getRulesetsByRulesetType); +rulesRouter.post( + '/rule/:ruleId/setCompletion', + body('isComplete').isBoolean(), + body('projectId').optional().isString(), + validateInputs, + RulesController.setRuleCompletion +); + +rulesRouter.post( + '/rule/:ruleId/toggle-team', + nonEmptyString(body('teamId')), + validateInputs, + RulesController.toggleRuleTeam +); + +rulesRouter.post( + '/ruleset/create', + nonEmptyString(body('name')), + nonEmptyString(body('rulesetTypeId')), + body('carNumber').isInt(), + body('active').isBoolean(), + nonEmptyString(body('fileId')), + validateInputs, + RulesController.createRuleset +); +rulesRouter.post('/rulesetType/:rulesetTypeId/delete', RulesController.deleteRulesetType); +rulesRouter.get('/:rulesetTypeId/team/:teamId', RulesController.getTeamRulesInRulesetType); + +rulesRouter.post( + '/ruleset/:rulesetId/update', + body('isActive').isBoolean(), + nonEmptyString(body('name')), + validateInputs, + RulesController.updateRuleset +); +rulesRouter.get('/ruleset/:rulesetId/team/:teamId/rules/unassigned', RulesController.getUnassignedRulesForRuleset); + +rulesRouter.get('/ruleset/:rulesetId/project/:projectId/rules', RulesController.getProjectRules); + +rulesRouter.get('/:ruleId/subrules', RulesController.getChildRules); +rulesRouter.get('/:rulesetId/parentRules', RulesController.getTopLevelRules); +rulesRouter.get('/ruleset/:rulesetId', RulesController.getSingleRuleset); +rulesRouter.get('/:rulesetTypeId', RulesController.getRulesetType); + +rulesRouter.post( + '/ruleset/:rulesetId/parse', + nonEmptyString(body('fileId')), + nonEmptyString(body('parserType')), // 'FSAE' or 'FHE' + validateInputs, + RulesController.parseRuleset +); + +const upload = multer({ limits: { fileSize: MAX_FILE_SIZE }, storage: memoryStorage() }); +rulesRouter.post('/upload/file', upload.single('file'), RulesController.uploadRulesetFile); + +export default rulesRouter; diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts new file mode 100644 index 0000000000..b0636a6d17 --- /dev/null +++ b/src/backend/src/services/rules.services.ts @@ -0,0 +1,1524 @@ +import { Organization, Rule } from '@prisma/client'; +import { + isAdmin, + isLeadership, + ProjectRule, + RulesetType, + notGuest, + User, + Rule as SharedRule, + isHead, + Ruleset +} from 'shared'; +import prisma from '../prisma/prisma.js'; +import { + AccessDeniedAdminOnlyException, + AccessDeniedGuestException, + AccessDeniedException, + DeletedException, + HttpException, + InvalidOrganizationException, + NotFoundException +} from '../utils/errors.utils.js'; +import { userHasPermission } from '../utils/users.utils.js'; +import { + getProjectRuleQueryArgs, + getRulesetQueryArgs, + getRulePreviewQueryArgs +} from '../prisma-query-args/rules.query-args.js'; +import { + ruleTransformer, + projectRuleTransformer, + rulesetTransformer, + rulesetTypeTransformer +} from '../transformers/rules.transformer.js'; +import { ParsedRule, parseRulesFromPdf } from '../utils/parse.utils.js'; +import { uploadFile, downloadFile } from '../utils/google-integration.utils.js'; + +export default class RulesService { + /** + * Gets the active ruleset for the given ruleset type ID + * @param user a user who is requesting for the active ruleset + * @param rulesetTypeId the given ruleset type id + * @param organization the organization for permission check + * @returns a ruleset with the given id if it exists, otherwise throws an error + */ + static async getActiveRuleset(user: User, rulesetTypeId: string, organization: Organization) { + if (!(await userHasPermission(user.userId, organization.organizationId, notGuest))) + throw new AccessDeniedException('only members and above can view ruleset types!'); + + const rulesetType = await prisma.ruleset_Type.findUnique({ + where: { rulesetTypeId, organizationId: organization.organizationId } + }); + + if (!rulesetType) { + throw new NotFoundException('Ruleset Type', rulesetTypeId); + } + + if (rulesetType?.deletedByUserId != null) { + throw new DeletedException('Ruleset Type', rulesetTypeId); + } + + const activeRuleset = await prisma.ruleset.findFirst({ + where: { rulesetTypeId, deletedByUserId: null, active: true }, + ...getRulesetQueryArgs() + }); + + if (!activeRuleset) { + throw new NotFoundException('Active Ruleset for given Ruleset Type', rulesetTypeId); + } + + return rulesetTransformer(activeRuleset); + } + + /** + * Gets a single ruleset by its ID + * @param rulesetId The ID of the ruleset to retrieve + * @param organizationId The ID of the organization the ruleset belongs to + * @returns The ruleset if found, otherwise throws an error + */ + static async getRulesetById(rulesetId: string, organizationId: string): Promise { + const ruleset = await prisma.ruleset.findFirst({ + where: { + rulesetId, + deletedByUserId: null, + rulesetType: { + organizationId + } + }, + ...getRulesetQueryArgs() + }); + + if (!ruleset) { + throw new NotFoundException('Ruleset', rulesetId); + } + + return rulesetTransformer(ruleset); + } + + /** + * Creates a new rule in the database + * + * @param user The user creating the rule, must be a member or above + * @param ruleCode The unique code identifier for the rule (e.g., "T.1.1.1") + * @param ruleContent The text content of the rule + * @param rulesetId The ID of the ruleset this rule belongs to + * @param organization The organization the rule belongs to + * @param parentRuleId Optional ID of the parent rule if this is a sub-rule + * @param referencedRuleIds Optional array of rule IDs that this rule references + * @param imageFileIds Optional array of Google Drive file IDs for images + * @returns The created rule + */ + static async createRule( + user: User, + ruleCode: string, + ruleContent: string, + rulesetId: string, + organization: Organization, + parentRuleId?: string, + referencedRuleIds: string[] = [], + imageFileIds: string[] = [] + ) { + // Check user has permission (members and above) + if (!(await userHasPermission(user.userId, organization.organizationId, notGuest))) { + throw new AccessDeniedException('Only members and above can create rules'); + } + + // Verify ruleset exists and belongs to organization + const ruleset = await prisma.ruleset.findUnique({ + where: { rulesetId }, + include: { + car: { + include: { + wbsElement: true + } + } + } + }); + + if (!ruleset) { + throw new NotFoundException('Ruleset', rulesetId); + } + + if (ruleset.deletedByUserId) { + throw new DeletedException('Ruleset', rulesetId); + } + + if (ruleset.car.wbsElement.organizationId !== organization.organizationId) { + throw new AccessDeniedException('Cannot create rule in a ruleset from another organization'); + } + + // Check for duplicate rule code within the same ruleset + const existingRule = await prisma.rule.findUnique({ + where: { + rulesetId_ruleCode: { + rulesetId, + ruleCode + } + } + }); + + if (existingRule) { + throw new HttpException(400, `Rule with code ${ruleCode} already exists in this ruleset`); + } + + // Verify parent rule exists if provided + if (parentRuleId) { + const parentRule = await prisma.rule.findUnique({ + where: { ruleId: parentRuleId } + }); + + if (!parentRule) { + throw new NotFoundException('Parent Rule', parentRuleId); + } + + if (parentRule.dateDeleted) { + throw new DeletedException('Parent Rule', parentRuleId); + } + + if (parentRule.rulesetId !== rulesetId) { + throw new HttpException(400, 'Parent rule must be in the same ruleset'); + } + } + + // Verify referenced rules exist + if (referencedRuleIds.length > 0) { + const referencedRules = await prisma.rule.findMany({ + where: { + ruleId: { in: referencedRuleIds } + } + }); + + if (referencedRules.length !== referencedRuleIds.length) { + throw new NotFoundException('Referenced Rule', 'provided IDs'); + } + + const deletedReferencedRule = referencedRules.find((rule) => rule.dateDeleted !== null); + if (deletedReferencedRule) { + throw new DeletedException('Referenced Rule', deletedReferencedRule.ruleId); + } + } + + // Create the rule + const rule = await prisma.rule.create({ + data: { + ruleCode, + ruleContent, + imageFileIds, + ruleset: { connect: { rulesetId } }, + createdBy: { connect: { userId: user.userId } }, + ...(parentRuleId && { parentRule: { connect: { ruleId: parentRuleId } } }), + ...(referencedRuleIds.length > 0 && { + referencedRule: { + connect: referencedRuleIds.map((id) => ({ ruleId: id })) + } + }) + }, + ...getRulePreviewQueryArgs() + }); + + return ruleTransformer(rule); + } + + /** + * Creates new ruleset type with the given information + * @param submitter a user who is making this request + * @param name the name of the ruleset type + * @param organizationId the organization ID for permission check + * @returns A newly created ruleset type + */ + static async createRulesetType(submitter: User, name: string, organization: Organization) { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) + throw new AccessDeniedException('only leadership and above can create ruleset types!'); + + const rulesetType = await prisma.ruleset_Type.create({ + data: { + name, + createdByUserId: submitter.userId, + organizationId: organization.organizationId + } + }); + + return rulesetType; + } + + /** + * Deletes a rule + * @param ruleId id of a rule to be deleted + * @param deleter user deleting the rule + * @param org the org of the user deleting the rule + * @returns the deleted rule + */ + static async deleteRule(ruleId: string, deleter: User, org: Organization): Promise { + const rule = await prisma.rule.findUnique({ + where: { ruleId }, + include: { + ruleset: { + include: { + car: { + include: { + wbsElement: true + } + } + } + } + } + }); + + if (!(await userHasPermission(deleter.userId, org.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('delete rules'); + } + + if (!rule) throw new NotFoundException('Rule', ruleId); + if (rule.dateDeleted) throw new DeletedException('Rule', ruleId); + + if (rule.ruleset?.car?.wbsElement?.organizationId !== org.organizationId) throw new InvalidOrganizationException('Rule'); + + await prisma.$transaction(async (tx) => { + const deleteParentChildReferencing = async (currRuleId: string): Promise => { + const referencingRules = await tx.rule.findMany({ + where: { + referencedRule: { + some: { ruleId: currRuleId } + }, + dateDeleted: null + }, + select: { ruleId: true } + }); + + for (const referencingRule of referencingRules) { + await tx.rule.update({ + where: { ruleId: referencingRule.ruleId }, + data: { + referencedRule: { + disconnect: { ruleId: currRuleId } + } + } + }); + } + + const childRules = await tx.rule.findMany({ + where: { + parentRuleId: currRuleId, + dateDeleted: null + } + }); + for (const childRule of childRules) { + await deleteParentChildReferencing(childRule.ruleId); + } + + await tx.rule.update({ + where: { ruleId: currRuleId }, + data: { + dateDeleted: new Date(), + deletedByUserId: deleter.userId + } + }); + }; + + await deleteParentChildReferencing(ruleId); + }); + + const deletedRule = await prisma.rule.findUnique({ + where: { ruleId } + }); + + return deletedRule!; + } + + /** + * Add a preexisting rule to a specific project + * + * @param submitter The user creating the project rule + * @param organization The organization the project rule is being created in + * @param ruleId The rule ID being added to the project + * @param projectId The project ID to add the rule to + * @returns The created project rule + */ + static async createProjectRule( + submitter: User, + organization: Organization, + ruleId: string, + projectId: string + ): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) { + throw new AccessDeniedException('You do not have permissions to assign rules to projects'); + } + + const rule = await prisma.rule.findUnique({ + where: { ruleId }, + include: { + subRules: true, + ruleset: { select: { car: { include: { wbsElement: { select: { organizationId: true } } } } } } + } + }); + + if (!rule) { + throw new NotFoundException('Rule', ruleId); + } + if (rule.ruleset.car.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Rule'); + } + + if (rule.dateDeleted) throw new DeletedException('Rule', ruleId); + + const project = await prisma.project.findUnique({ + where: { projectId }, + include: { wbsElement: true } + }); + + if (!project) { + throw new NotFoundException('Project', projectId); + } + if (project.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Project'); + } + + if (project.wbsElement.dateDeleted) throw new DeletedException('Project', projectId); + + // Checks if this rule was already assigned to this project + const existingProjectRule = await prisma.project_Rule.findUnique({ + where: { ruleId_projectId: { ruleId, projectId } } + }); + + if (existingProjectRule) { + throw new HttpException(400, 'This rule is already associated with the project'); + } + + // 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() + }); + + if (!projectRule) { + throw new NotFoundException('Project Rule', ruleId); + } + + return projectRuleTransformer(projectRule); + } + + /** + * Edits a rule with the given id + * @param submitter a user who is making this request + * @param ruleContent the rule content to edit + * @param ruleId The rule ID being edited + * @param ruleCode The rule code to update (optional, keeps existing if not provided) + * @param imageFileIds The image files to update (optional, keeps existing if not provided) + * @param parentRuleId The parent rule ID to update + * @param organization the organization the rule belongs to + * @returns the edited rule + */ + static async editRule( + submitter: User, + ruleContent: string, + ruleId: string, + ruleCode: string | undefined, + imageFileIds: string[] | undefined, + organization: Organization, + parentRuleId?: string + ) { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) + throw new AccessDeniedAdminOnlyException('edit a rule'); + + const currentRule = await prisma.rule.findUnique({ + where: { ruleId }, + include: { + ruleset: { + include: { + car: { + include: { + wbsElement: true + } + } + } + } + } + }); + + if (!currentRule) { + throw new NotFoundException('Rule', ruleId); + } + + if (currentRule.dateDeleted) { + throw new DeletedException('Rule', ruleId); + } + + if (currentRule.ruleset?.car?.wbsElement?.organizationId !== organization.organizationId) + throw new InvalidOrganizationException('Rule'); + + if (parentRuleId) { + const parentRule = await prisma.rule.findUnique({ + where: { ruleId: parentRuleId } + }); + + if (!parentRule) { + throw new NotFoundException('Parent Rule', parentRuleId); + } + + if (parentRule.dateDeleted) { + throw new DeletedException('Parent Rule', parentRuleId); + } + } + + const updatedRule = await prisma.rule.update({ + where: { + ruleId + }, + data: { + ruleContent, + ...(ruleCode !== undefined && { ruleCode }), + ...(imageFileIds !== undefined && { imageFileIds }), + ...(parentRuleId && { parentRuleId }), + dateUpdated: new Date(), + updatedByUserId: submitter.userId + }, + ...getRulePreviewQueryArgs() + }); + + return ruleTransformer(updatedRule); + } + + /** + * Given a ruleset id, retrieves the ruleset and throws errors if + * it does not exist or is already deleted + * @param rulesetId the id of the ruleset + * @param organizationId the id of the organization the ruleset is being deleted in + * @returns the ruleset with query args + */ + static async getRulesetWithQueryArgs(rulesetId: string) { + const ruleset = await prisma.ruleset.findUnique({ + where: { rulesetId }, + ...getRulesetQueryArgs() + }); + + if (!ruleset) throw new NotFoundException('Ruleset', rulesetId); + if (ruleset.deletedByUserId) throw new DeletedException('Ruleset', rulesetId); + + return ruleset; + } + + /** + * Deletes a specific Ruleset + * @param rulesetId the id of the ruleset to be deleted + * @param deleterId the id of the user deleting the ruleset + * @param organizationID the id of the organization the ruleset is being deleted in + * @returns the deleted Ruleset + */ + static async deleteRuleset(rulesetId: string, deleterId: string, organizationId: string) { + const ruleset = await RulesService.getRulesetWithQueryArgs(rulesetId); + + const hasPermission = + (await userHasPermission(deleterId, organizationId, isAdmin)) || deleterId === ruleset.createdByUserId; + + if (!hasPermission) throw new AccessDeniedException('Only admins can delete a ruleset.'); + + if (ruleset.active) { + throw new HttpException(400, 'Cannot delete an active ruleset. Please deactivate it first.'); + } + + const deletedRuleset = await prisma.ruleset.update({ + where: { rulesetId }, + data: { deletedBy: { connect: { userId: deleterId } }, active: false }, + ...getRulesetQueryArgs() + }); + + return rulesetTransformer(deletedRuleset); + } + + static async getAllRulesetTypes(organization: Organization): Promise { + const rulesets = await prisma.ruleset_Type.findMany({ + where: { + organizationId: organization.organizationId, + deletedByUserId: null + }, + include: { + revisionFiles: true + } + }); + return rulesets.map(rulesetTypeTransformer); + } + + /** + * Gets a ruleset type for a given ruleset type ID + * @param rulesetTypeId id of ruleset type + * @param organizationId id of organization + * @returns ruleset type associated with provided ruleset type ID + */ + static async getRulesetType(rulesetTypeId: string, organizationId: string): Promise { + const rulesetType = await prisma.ruleset_Type.findUnique({ + where: { + rulesetTypeId, + organizationId, + deletedBy: null + }, + include: { + revisionFiles: true + } + }); + + if (!organizationId) { + throw new NotFoundException('Organization', organizationId); + } + + if (!rulesetType) { + throw new NotFoundException('Ruleset Type', rulesetTypeId); + } + + return rulesetTypeTransformer(rulesetType); + } + + /** + * Gets rulesets for a given ruleset type + * @param rulesetTypeId id of ruleset type + * @param organizationId id of organization + * @returns rulesets associated with provided ruleset type + */ + static async getRulesetsByRulesetType(rulesetTypeId: string, organizationId: string): Promise { + const rulesets = await prisma.ruleset.findMany({ + where: { + rulesetTypeId, + deletedByUserId: null, + rulesetType: { + organizationId + } + }, + orderBy: { + dateCreated: 'desc' + }, + ...getRulesetQueryArgs() + }); + + return rulesets.map(rulesetTransformer); + } + + /** + * Gets all rules assigned to a team that are in the active ruleset of a given ruleset type + * @param teamId id of the team + * @param rulesetTypeId id of ruleset type + * @param organization the organization + * @returns array of rule previews + */ + static async getTeamRulesInRulesetType(teamId: string, rulesetTypeId: string, organization: Organization) { + const team = await prisma.team.findUnique({ + where: { teamId, dateArchived: null } + }); + + if (!team) { + throw new NotFoundException('Team', teamId); + } + + if (team.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Team'); + } + + const rulesetType = await prisma.ruleset_Type.findUnique({ + where: { rulesetTypeId } + }); + + if (!rulesetType) { + throw new NotFoundException('Ruleset Type', rulesetTypeId); + } + + if (rulesetType.deletedByUserId) { + throw new DeletedException('Ruleset Type', rulesetTypeId); + } + + if (rulesetType.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Ruleset Type'); + } + + const activeRuleset = await prisma.ruleset.findFirst({ + where: { + rulesetTypeId, + active: true, + deletedByUserId: null + }, + ...getRulesetQueryArgs() + }); + + if (!activeRuleset) { + throw new NotFoundException('Active Ruleset for given Ruleset Type', rulesetTypeId); + } + + const rules = await prisma.rule.findMany({ + where: { + rulesetId: activeRuleset.rulesetId, + dateDeleted: null, + teams: { + some: { + teamId + } + } + }, + ...getRulePreviewQueryArgs() + }); + + return rules.map(ruleTransformer); + } + + /** + * 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 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 setRuleCompletion( + submitter: User, + organization: Organization, + 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 rule completion'); + } + + const rule = await prisma.rule.findUnique({ + where: { ruleId }, + include: { ruleset: { include: { car: { include: { wbsElement: true } } } } } + }); + + if (!rule) { + throw new NotFoundException('Rule', ruleId); + } + + if (rule.dateDeleted) { + throw new DeletedException('Rule', ruleId); + } + + if (rule.ruleset.car.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Rule'); + } + + 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 ruleTransformer(updatedRule); + } + + /** + * Assigns a rule to a team. If the team already is assigned to the + * rule, removes the team from the rule. + * @param ruleId The ruleId of the rule to be added to + * @param teamIds The team to be added to the rule + * @param user The user adding the team to the rule + * @param org The organization the rule belongs to + * @returns the updated rule + * @throws If the user is a guest, the rule does not exist or + * is deleted, or a team does not exist, is in the wrong + * organization, or is archived. + * + */ + static async toggleRuleTeam(ruleId: string, teamId: string, user: User, org: Organization) { + // Checks that the user is not a guest + if (!(await userHasPermission(user.userId, org.organizationId, notGuest))) { + throw new AccessDeniedGuestException('Toggle Rule Team'); + } + + // Checks that the rule exists and is not deleted + const rule = await prisma.rule.findUnique({ + where: { ruleId }, + include: { + teams: true, + ruleset: { select: { car: { include: { wbsElement: { select: { organizationId: true } } } } } } + } + }); + if (!rule) { + throw new NotFoundException('Rule', ruleId); + } + if (rule.deletedByUserId) { + throw new DeletedException('Rule', ruleId); + } + if (rule.ruleset.car.wbsElement.organizationId !== org.organizationId) { + throw new InvalidOrganizationException('Rule'); + } + + // Checks based on the team + const team = await prisma.team.findUnique({ where: { teamId } }); + if (!team) throw new NotFoundException('Team', teamId); + if (team.organizationId !== org.organizationId) throw new InvalidOrganizationException('Rule'); + if (team.dateArchived) throw new HttpException(400, 'Cannot toggle an archived team.'); + + // We add the team to the rule if it is not already in the rule + // If the rule is not in this team, add the team to the rule + // If the rule is already in this team, remove the team from the rule + if (!rule.teams.some((currTeam) => currTeam.teamId === teamId)) { + await prisma.rule.update({ + where: { ruleId: rule.ruleId }, + data: { + teams: { + connect: { + teamId + } + } + } + }); + } else { + await prisma.rule.update({ + where: { ruleId: rule.ruleId }, + data: { + teams: { + disconnect: { + teamId + } + } + } + }); + } + + // retrieve and return the updated rule + const newRule = await prisma.rule.findUnique({ + where: { ruleId }, + ...getRulePreviewQueryArgs() + }); + + return ruleTransformer(newRule!); + } + + static async createRuleset( + submitter: User, + organization: Organization, + name: string, + rulesetTypeId: string, + carNumber: number, + active: boolean, + fileId: string + ) { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) { + throw new AccessDeniedException('only leadership and above can create ruleset!'); + } + + const rulesetType = await prisma.ruleset_Type.findUnique({ + where: { + rulesetTypeId + } + }); + + if (!rulesetType) { + throw new NotFoundException('Ruleset Type', rulesetTypeId); + } + if (rulesetType.dateDeleted !== null) { + throw new DeletedException('Ruleset Type', rulesetTypeId); + } + + if (rulesetType.organizationId !== organization.organizationId) throw new InvalidOrganizationException('Ruleset Type'); + + const car = await prisma.car.findFirst({ + where: { + wbsElement: { + carNumber, + organizationId: organization.organizationId, + dateDeleted: null + } + }, + include: { wbsElement: true } + }); + + if (!car) { + throw new NotFoundException('Car', carNumber); + } + + const ruleset = await prisma.ruleset.create({ + data: { + fileId, + rulesetTypeId, + name, + carId: car.carId, + active, + createdByUserId: submitter.userId + }, + ...getRulesetQueryArgs() + }); + + return rulesetTransformer(ruleset); + } + + /** + * Deletes a ruleset type and all the rulesets in the ruleset type's revision files. + * + * @param user The user who is deleting the ruleset type + * @param rulesetTypeId The ruleset type to be deleted + * @param organization The organization that the ruleset is being deleted for + */ + static async deleteRulesetType(deleter: User, id: string, organization: Organization): Promise { + // check if user is admin + if (!(await userHasPermission(deleter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('delete ruleset types'); + } + + const rulesetType = await prisma.ruleset_Type.findUnique({ + where: { rulesetTypeId: id, organizationId: organization.organizationId }, + include: { + revisionFiles: true + } + }); + + if (!rulesetType) { + throw new NotFoundException('Ruleset Type', id); + } + if (rulesetType.deletedByUserId) { + throw new DeletedException('Ruleset Type', id); + } + + await prisma.$transaction(async (tx) => { + // delete all rulesets in revision files + for (const ruleset of rulesetType.revisionFiles) { + await tx.ruleset.update({ + where: { rulesetId: ruleset.rulesetId }, + data: { deletedByUserId: deleter.userId } + }); + } + + // delete the actual ruleset type itself + await tx.ruleset_Type.update({ + where: { rulesetTypeId: id }, + data: { deletedByUserId: deleter.userId } + }); + }); + + const deletedRule = await prisma.ruleset_Type.findUnique({ + where: { rulesetTypeId: id }, + include: { + revisionFiles: true + } + }); + + return rulesetTypeTransformer(deletedRule); + } + + /** + * Deletes a project rule and its associated rule status changes + * @param projectRuleId The ID of the project rule to delete + * @param deleter The user deleting the project rule (must be admin) + * @param organization The organization the project rule belongs to + * @returns The deleted project rule + */ + static async deleteProjectRule(projectRuleId: string, deleter: User, organization: Organization): Promise { + if (!(await userHasPermission(deleter.userId, organization.organizationId, isHead))) { + throw new AccessDeniedAdminOnlyException('delete project rules'); + } + + const projectRule = await prisma.project_Rule.findUnique({ + where: { projectRuleId }, + include: { + project: { + include: { + wbsElement: true + } + }, + rule: { + include: { + ruleset: { + include: { + car: { + include: { + wbsElement: true + } + } + } + } + } + } + } + }); + + if (!projectRule) { + throw new NotFoundException('Project Rule', projectRuleId); + } + + if (projectRule.project.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Project Rule'); + } + + if (projectRule.rule.ruleset.car.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Project Rule'); + } + + if (projectRule.dateDeleted) { + throw new DeletedException('Project Rule', projectRuleId); + } + + const deletedProjectRule = await prisma.project_Rule.update({ + where: { projectRuleId }, + data: { + dateDeleted: new Date(), + deletedByUserId: deleter.userId + }, + ...getProjectRuleQueryArgs() + }); + + return projectRuleTransformer(deletedProjectRule); + } + + /** + * Updates a rulesets status + * @param submitter user updating the ruleset + * @param organizationId organization of ruleset being updated + * @param rulesetId id of ruleset being updated + * @param status new status of ruleset + * @returns + */ + static async updateRuleset(submitter: User, organizationId: string, rulesetId: string, name: string, isActive: boolean) { + if (!(await userHasPermission(submitter.userId, organizationId, isHead))) { + throw new AccessDeniedException('You do not have permissions to update ruleset status'); + } + + const rulesetExists = await prisma.ruleset.findUnique({ + where: { + rulesetId, + rulesetType: { + organizationId + }, + deletedByUserId: null + } + }); + + if (!rulesetExists) { + throw new NotFoundException('Ruleset', rulesetId); + } + + if (!rulesetExists.active && isActive) { + const activeRuleset = await prisma.ruleset.findFirst({ + where: { + active: true, + rulesetType: { + rulesetTypeId: rulesetExists.rulesetTypeId, + organizationId + }, + deletedByUserId: null + } + }); + + if (activeRuleset) { + throw new HttpException(400, 'There is already an active ruleset for this ruleset type'); + } + } + const ruleset = await prisma.ruleset.update({ + where: { + rulesetId, + rulesetType: { + organizationId + } + }, + data: { + name, + active: isActive + }, + ...getRulesetQueryArgs() + }); + + return rulesetTransformer(ruleset); + } + + /** + * Gets all subrules of a specific rule. + * @param ruleId the ID of the parent rule + * @param organization the organization the rule belongs to + * @returns an array of all child rules (the Rule object) + */ + static async getChildRules(ruleId: string, organization: Organization): Promise { + // Verify the parent rule exists and belongs to the organization + const parentRule = await prisma.rule.findUnique({ + where: { ruleId }, + include: { + ruleset: { + include: { + car: { + include: { + wbsElement: true + } + } + } + } + } + }); + + if (!parentRule) { + throw new NotFoundException('Rule', ruleId); + } + + if (parentRule.dateDeleted) { + throw new DeletedException('Rule', ruleId); + } + + if (parentRule.ruleset.car.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Rule'); + } + + const subRules = await prisma.rule.findMany({ + where: { + parentRuleId: ruleId, + dateDeleted: null + }, + ...getRulePreviewQueryArgs() + }); + return subRules.map((rule) => ruleTransformer(rule)); + } + + /** + * Gets all unassigned rules (rules with no team assignments) for a given ruleset + * @param rulesetId the id of the ruleset + * @param organization the organization the ruleset belongs to + * @returns an array of rules with no team assignments, ordered by ruleCode ascending + */ + static async getUnassignedRules(rulesetId: string, organization: Organization): Promise { + const ruleset = await prisma.ruleset.findUnique({ + where: { rulesetId }, + include: { + car: { + include: { + wbsElement: true + } + } + } + }); + + if (!ruleset) { + throw new NotFoundException('Ruleset', rulesetId); + } + + if (ruleset.deletedByUserId) { + throw new DeletedException('Ruleset', rulesetId); + } + + if (ruleset.car.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Ruleset'); + } + + const rules = await prisma.rule.findMany({ + where: { + rulesetId, + dateDeleted: null, + teams: { + none: {} + } + }, + orderBy: { + ruleCode: 'asc' + }, + ...getRulePreviewQueryArgs() + }); + + return rules.map(ruleTransformer); + } + + /** + * Gets team rules that are unassigned to a project + * @param rulesetId ruleset the rules are in + * @param teamId team that rules are assigned to + * @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 | undefined, + organizationId: string + ) { + if (!projectId) { + throw new HttpException(400, 'Query parameter projectId is required'); + } + const ruleset = await prisma.ruleset.findUnique({ + where: { rulesetId }, + select: { + dateDeleted: true, + rulesetType: { + select: { + organizationId: true + } + } + } + }); + + if (!ruleset) { + throw new NotFoundException('Ruleset', rulesetId); + } + + if (ruleset.dateDeleted) { + throw new DeletedException('Ruleset', rulesetId); + } + + if (ruleset.rulesetType.organizationId !== organizationId) { + throw new InvalidOrganizationException('Ruleset'); + } + + const team = await prisma.team.findUnique({ + where: { teamId }, + select: { + organizationId: true + } + }); + + if (!team) { + throw new NotFoundException('Team', teamId); + } + + if (team.organizationId !== organizationId) { + 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, + teams: { + some: { + teamId, + organizationId + } + }, + // a rule can belong to many projects within a team + // only hide it from a project that already has it assigned + projects: { + none: { projectId } + }, + deletedByUserId: null + }, + ...getRulePreviewQueryArgs(), + orderBy: { + ruleCode: 'asc' + } + }); + return rules.map(ruleTransformer); + } + + /** + * Gets all rules associated with a specific project and ruleset + * @param rulesetId the id of the ruleset + * @param projectId the id of the project + * @param organization the organization the project and ruleset belong to + * @returns Array of ProjectRule objects + */ + static async getProjectRules(rulesetId: string, projectId: string, organization: Organization): Promise { + const ruleset = await prisma.ruleset.findUnique({ + where: { rulesetId }, + include: { + car: { + include: { + wbsElement: true + } + } + } + }); + + if (!ruleset) { + throw new NotFoundException('Ruleset', rulesetId); + } + + if (ruleset.deletedByUserId) { + throw new DeletedException('Ruleset', rulesetId); + } + + if (ruleset.car.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Ruleset'); + } + + const project = await prisma.project.findUnique({ + where: { projectId }, + include: { + wbsElement: true + } + }); + + if (!project) { + throw new NotFoundException('Project', projectId); + } + + if (project.wbsElement.dateDeleted) { + throw new DeletedException('Project', projectId); + } + + if (project.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Project'); + } + + const projectRules = await prisma.project_Rule.findMany({ + where: { + projectId, + rule: { + rulesetId, + dateDeleted: null + }, + dateDeleted: null + }, + ...getProjectRuleQueryArgs() + }); + + return projectRules.map(projectRuleTransformer); + } + + /** + * Gets all rules with no parent id + * @param rulesetId id of ruleset + * @returns an array of rules with no parent Id + */ + static async getTopLevelRules(rulesetId: string, organizationId: string) { + const ruleset = await prisma.ruleset.findUnique({ + where: { rulesetId }, + select: { + dateDeleted: true, + rulesetType: { + select: { + organizationId: true + } + } + } + }); + + if (!ruleset) { + throw new NotFoundException('Ruleset', rulesetId); + } + + if (ruleset.dateDeleted) { + throw new DeletedException('Ruleset', rulesetId); + } + + if (ruleset.rulesetType.organizationId !== organizationId) { + throw new InvalidOrganizationException('Ruleset'); + } + + const rules = await prisma.rule.findMany({ + where: { + rulesetId, + dateDeleted: null, + parentRuleId: null + }, + ...getRulePreviewQueryArgs() + }); + + return rules.map(ruleTransformer); + } + + /** + * Parses a PDF ruleset file and saves the extracted rules to the database. + * Extracts rules based on parser type (FSAE or FHE). + * Creates all rules in the database and then sets up parent-child relationships + * @param user user who uploaded the ruleset pdf + * @param organizationId organization id of the ruleset + * @param fileId google drive file id of the ruleset pdf + * @param rulesetId id of the ruleset to save the parsed rules into + * @param parserType type of parser to use (FSAE or FHE) + * @returns array of saved rules with parent relationships established + * @throws AccessDeniedException if user lacks permissions or ruleset belongs to another organization + * @throws NotFoundException if ruleset doesn't exist + * @throws DeletedException if ruleset has been deleted + * @throws HttpException(400) if file is not a PDF or contains no rules + * @throws HttpException(500) if PDF parsing fails + */ + static async parseRuleset( + user: User, + organizationId: string, + fileId: string, + rulesetId: string, + parserType: 'FSAE' | 'FHE' + ): Promise { + if (!(await userHasPermission(user.userId, organizationId, isLeadership))) { + throw new AccessDeniedException('You do not have permissions to upload and parse rulesets'); + } + + // Verify ruleset exists and belongs to organization + const ruleset = await prisma.ruleset.findUnique({ + where: { rulesetId }, + include: { + car: { + include: { + wbsElement: true + } + } + } + }); + + if (!ruleset) { + throw new NotFoundException('Ruleset', rulesetId); + } + + if (ruleset.deletedByUserId) { + throw new DeletedException('Ruleset', rulesetId); + } + if (ruleset.car.wbsElement.organizationId !== organizationId) { + throw new AccessDeniedException('Cannot parse rules into a ruleset from another organization'); + } + + // get file from Google Drive + const { buffer, type } = await downloadFile(fileId); + + // ensure the file is a PDF + if (type !== 'application/pdf') { + throw new HttpException(400, 'Ruleset File must be a PDF'); + } + let parsedRules: ParsedRule[]; + try { + parsedRules = await parseRulesFromPdf(buffer, parserType); + if (parsedRules.length === 0) { + throw new HttpException(400, 'No rules found in provided file'); + } + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + if (process.env && process.env.NODE_ENV === 'development') { + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new HttpException(500, `Error parsing rules from PDF file: ${message}`); + } + throw new HttpException(500, 'Error parsing rules from PDF file'); + } + + await prisma.$transaction(async (tx) => { + await tx.rule.createMany({ + data: parsedRules.map((rule) => ({ + ruleCode: rule.ruleCode, + ruleContent: rule.ruleContent, + imageFileIds: [], + rulesetId, + createdByUserId: user.userId + })) + }); + + const createdRules = await tx.rule.findMany({ + where: { rulesetId }, + select: { + ruleId: true, + ruleCode: true + } + }); + + const ruleMap = new Map(); + createdRules.forEach((rule) => { + ruleMap.set(rule.ruleCode, rule.ruleId); + }); + + // update parent relationships + const parentUpdates = parsedRules + .filter((rule) => rule.parentRuleCode) + .map((rule) => { + const parentId = ruleMap.get(rule.parentRuleCode!); + const ruleId = ruleMap.get(rule.ruleCode); + + if (!parentId || !ruleId) return null; + + return tx.rule.update({ + where: { ruleId }, + data: { parentRuleId: parentId } + }); + }) + .filter(Boolean); + + await Promise.all(parentUpdates); + }); + + const savedRules = await prisma.rule.findMany({ + where: { rulesetId }, + ...getRulePreviewQueryArgs() + }); + + return savedRules.map(ruleTransformer); + } + + static async uploadRulesetFile(file: Express.Multer.File, uploader: User, organization: Organization) { + if (!(await userHasPermission(uploader.userId, organization.organizationId, isLeadership))) { + throw new AccessDeniedException('Only leadership and above can upload ruleset files'); + } + const data = await uploadFile(file); + return data.id; + } + + /** + * Gets a single ruleset by ID + * @param rulesetId the id of the ruleset + * @param user the user requesting the ruleset + * @param organization the organization the user belongs to + * @returns the ruleset with the given id + */ + static async getSingleRuleset(user: User, rulesetId: string, organization: Organization): Promise { + if (!(await userHasPermission(user.userId, organization.organizationId, notGuest))) + throw new AccessDeniedException('Only members and above can view rulesets!'); + + const ruleset = await prisma.ruleset.findUnique({ + where: { rulesetId }, + ...getRulesetQueryArgs() + }); + + if (!ruleset) { + throw new NotFoundException('Ruleset', rulesetId); + } + + if (ruleset.deletedByUserId) { + throw new DeletedException('Ruleset', rulesetId); + } + + if (ruleset.car.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Ruleset'); + } + + return rulesetTransformer(ruleset); + } +} diff --git a/src/backend/src/transformers/rules.transformer.ts b/src/backend/src/transformers/rules.transformer.ts new file mode 100644 index 0000000000..38fc480d60 --- /dev/null +++ b/src/backend/src/transformers/rules.transformer.ts @@ -0,0 +1,77 @@ +import { Prisma } from '@prisma/client'; +import { Rule, ProjectRule, Ruleset, RulesetType } from 'shared'; +import { RulesetQueryArgs, RulePreviewQueryArgs, ProjectRuleQueryArgs } from '../prisma-query-args/rules.query-args.js'; + +export const ruleTransformer = (rule: Prisma.RuleGetPayload): Rule => { + return { + ruleId: rule.ruleId, + ruleCode: rule.ruleCode, + ruleContent: rule.ruleContent, + imageFileIds: rule.imageFileIds, + parentRule: rule.parentRule + ? { + ruleId: rule.parentRule.ruleId, + ruleCode: rule.parentRule.ruleCode + } + : undefined, + subRuleIds: rule.subRules.map((subRule) => subRule.ruleId), + referencedRuleIds: rule.referencedRule.map((ref) => ref.ruleId), + teams: rule.teams?.map((team) => ({ + 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: Prisma.Project_RuleGetPayload): ProjectRule => { + return { + projectRuleId: projectRule.projectRuleId, + rule: ruleTransformer(projectRule.rule), + projectId: projectRule.projectId + }; +}; + +export const rulesetTypeTransformer = (rulesetType: any): RulesetType => { + return { + rulesetTypeId: rulesetType.rulesetTypeId, + name: rulesetType.name, + lastUpdated: rulesetType.lastUpdated, + revisionFiles: rulesetType.revisionFiles + ? rulesetType.revisionFiles.filter((ruleset: any) => ruleset.deletedByUserId === null) + : [] + }; +}; + +export const rulesetTransformer = (ruleset: Prisma.RulesetGetPayload): Ruleset => { + const rulesWithTeams = ruleset.rules.filter((rule) => rule._count.teams > 0).length; + const totalRulesLength = ruleset.rules.length; + const teamsPercentage = totalRulesLength > 0 ? (rulesWithTeams / totalRulesLength) * 100 : 0; + + return { + fileId: ruleset.fileId, + rulesetId: ruleset.rulesetId, + name: ruleset.name, + dateCreated: ruleset.dateCreated, + active: ruleset.active, + assignedPercentage: teamsPercentage, + rulesetType: rulesetTypeTransformer(ruleset.rulesetType), + car: { + carId: ruleset.car.carId, + name: ruleset.car.wbsElement.name + }, + ruleAmount: totalRulesLength + }; +}; diff --git a/src/backend/src/utils/errors.utils.ts b/src/backend/src/utils/errors.utils.ts index 327763a4a7..cd52da1d60 100644 --- a/src/backend/src/utils/errors.utils.ts +++ b/src/backend/src/utils/errors.utils.ts @@ -207,6 +207,13 @@ export type ExceptionObjectNames = | 'Reimbursement Product Other Reason' | 'Encryption Key' | 'Reimbursement Request Comment' + | 'Ruleset' + | 'Parent Rule' + | 'Referenced Rule' + | 'Rule' + | 'Project Rule' + | 'Ruleset Type' + | 'Active Ruleset for given Ruleset Type' | 'Calendar' | 'Event Type' | 'Event' diff --git a/src/backend/src/utils/parse.utils.ts b/src/backend/src/utils/parse.utils.ts new file mode 100644 index 0000000000..8da38dc61f --- /dev/null +++ b/src/backend/src/utils/parse.utils.ts @@ -0,0 +1,399 @@ +import pdf from 'pdf-parse-new'; + +export interface ParsedRule { + ruleCode: string; + ruleContent: string; + parentRuleCode?: string; +} + +export const parseRulesFromPdf = async (buffer: Buffer, parserType: 'FSAE' | 'FHE'): Promise => { + const options = { + // max page number to parse, 0 = all pages + max: 0, + // errors: 0, warnings: 1, infos: 5 + verbosityLevel: 0 as const + }; + const pdfData = await pdf(buffer, options); + + if (parserType === 'FSAE') { + return parseFSAERules(pdfData.text); + } + if (parserType === 'FHE') { + return parseFHERules(pdfData.text); + } + throw new Error(`Invalid parser type: ${parserType}. Must be 'FSAE' or 'FHE'`); +}; + +/** + * Extracts lettered sub-rules from rule content (a, b, c, etc.) + * "EV.5.2 Main text a. Sub-rule" becomes: + * - EV.5.2 Main text + * - EV.5.2.a Sub-rule + * If no subrules exist, returns the original rule + * @param ruleCode parent rule code + * @param content rule content to extract from + * @returns array of parsed rules including main rule and any subrules + */ +const extractSubRules = (ruleCode: string, content: string): ParsedRule[] => { + const letterPattern = /\s+([a-z])\.\s+/g; + const matches = [...content.matchAll(letterPattern)]; + + if (matches.length === 0) { + // no subrules found, return original rule + return [ + { + ruleCode, + ruleContent: content.trim(), + parentRuleCode: findParentRuleCode(ruleCode) + } + ]; + } + const subRules: ParsedRule[] = []; + + // Extract the main rule content (everything before the first lettered item) + const firstMatchIndex = matches[0].index!; + const mainContent = content.substring(0, firstMatchIndex).trim(); + + // add main rule + subRules.push({ + ruleCode, + ruleContent: mainContent, + parentRuleCode: findParentRuleCode(ruleCode) + }); + + // Extract lettered sub-rules + for (let i = 0; i < matches.length; i++) { + const [, letter] = matches[i]; + const startIndex = matches[i].index! + matches[i][0].length; + + // Find where this sub-rule ends (either at next letter or end of rule content) + const endIndex = i < matches.length - 1 ? matches[i + 1].index! : content.length; + const subRuleContent = content.substring(startIndex, endIndex).trim(); + const subRuleCode = `${ruleCode}.${letter}`; + + subRules.push({ + ruleCode: subRuleCode, + ruleContent: subRuleContent, + parentRuleCode: ruleCode + }); + } + return subRules; +}; + +/** + * Determines parent rule code by removing last value. + * Top level rules return undefined. + * EV.5.2.2 -> EV.5.2 + * GR -> undefined + * @param ruleCode rule code to find a parent for + * @returns Parent rule code, or undefined if top level + */ +const findParentRuleCode = (ruleCode: string): string | undefined => { + const parts = ruleCode.split('.'); + if (parts.length <= 1) { + return undefined; + } + return parts.slice(0, -1).join('.'); +}; + +/** + * Updates rules with duplicate rule codes by appending .duplicate suffix + * and updates parent references to maintain parent-child relationships + * @param rules array of parsed rules + * @returns array of rules without duplicate rule codes and updated parent references + */ +const handleDuplicateCodes = (rules: ParsedRule[]): ParsedRule[] => { + const seenRuleCodes = new Map(); + const codeMapping = new Map(); // Maps original code to new code for duplicates + + // First pass: rename duplicates and track the mapping + const renamedRules = rules.map((rule) => { + const originalCode = rule.ruleCode; + + if (seenRuleCodes.has(originalCode)) { + // duplicate found + const count = seenRuleCodes.get(originalCode)!; + seenRuleCodes.set(originalCode, count + 1); + const suffix = count === 1 ? '.duplicate' : `.duplicate${count}`; + const newCode = `${originalCode}${suffix}`; + + // Track that this code was renamed + codeMapping.set(originalCode, newCode); + + return { + ...rule, + ruleCode: newCode + }; + } + seenRuleCodes.set(originalCode, 1); + return rule; + }); + + // Second pass: update parent references for rules whose parent was renamed + return renamedRules.map((rule) => { + if (rule.parentRuleCode && codeMapping.has(rule.parentRuleCode)) { + return { + ...rule, + parentRuleCode: codeMapping.get(rule.parentRuleCode) + }; + } + return rule; + }); +}; + +/**************** FSAE ****************/ + +const parseFSAERules = (text: string): ParsedRule[] => { + const rules: ParsedRule[] = []; + const lines = text.split('\n'); + + let currentRule: { code: string; text: string } | null = null; + + const saveCurrentRule = () => { + if (!currentRule) return; + const parsedRules = extractSubRules(currentRule.code, currentRule.text); + rules.push(...parsedRules); + }; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) continue; + + // Skip page headers/footers + if (isHeaderFooterFSAE(trimmedLine)) { + continue; + } + + // Skip table of contents + if (/\.{4,}\s+\d+\s*$/.test(trimmedLine)) { + continue; + } + + // Check if this line starts a new rule + const rule = parseRuleNumberFSAE(trimmedLine); + if (rule) { + saveCurrentRule(); + currentRule = { + code: rule.ruleCode, + text: rule.ruleContent + }; + } else if (currentRule) { + currentRule.text += ' ' + trimmedLine; // else append to existing rule + } + } + saveCurrentRule(); + + const fixedRules = fixOrphanedRulesFSAE(rules); + return handleDuplicateCodes(fixedRules); +}; + +/** + * Determines if this line starts a new rule, if so extracts code and content of the rule + * Matches rule pattern (e.g. GR.1.1 some text) or section pattern (e.g. GR - TEXT) + * @param line single line in the extracted text from the ruleset pdf + * @returns rule code and content, or null if this line does not start a new rule + */ +const parseRuleNumberFSAE = (line: string): ParsedRule | null => { + // Match rule patterns like "GR.1.1" followed by text + const rulePattern = /^([A-Z]{1,4}(?:\.[\d]+)+)\s+(.+)$/; + // Match section patterns like "GR - GENERAL REGULATIONS or PS - PRE-COMPETITION SUBMISSIONS" + const sectionPattern = /^([A-Z]{1,4})\s*-\s*(.+)$/; + + const match = line.match(rulePattern) || line.match(sectionPattern); + if (match) { + const cleanContent = match[2].replace(/\.{5,}/g, '.....'); + return { + ruleCode: match[1], + ruleContent: cleanContent + }; + } + return null; +}; + +/** + * Checks if a line is a page header/footer that should be skipped + * @param line line to check + * @returns true if line should be skipped + */ +const isHeaderFooterFSAE = (line: string): boolean => { + const trimmed = line.trim(); + + // Match FSAE headers like "Formula SAE® Rules 2025 © 2024 SAE International Page 7 of 143 Version 1.0 31 Aug 2024" + if (/Formula SAE.*Rules.*\d{4}.*SAE International.*Page \d+ of \d+/i.test(trimmed)) { + return true; + } + // Match standalone page numbers + if (/^Page \d+ of \d+$/i.test(trimmed)) { + return true; + } + // Match version strings + if (/^Version \d+\.\d+.*\d{1,2}\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{4}$/i.test(trimmed)) { + return true; + } + + return false; +}; + +/** + * Updates rules to point to nearest existing parent if their assigned parent doesn't exist. + * D.8.1.2 -> checks for D.8.1, if missing goes to D.8, then D + * @param rules array of parsed rules + * @returns rules with corrected parent references + */ +const fixOrphanedRulesFSAE = (rules: ParsedRule[]): ParsedRule[] => { + const existingCodes = new Set(rules.map((r) => r.ruleCode)); + + return rules.map((rule) => { + // skip if no parent or parent exists + if (!rule.parentRuleCode || existingCodes.has(rule.parentRuleCode)) { + return rule; // Top-level rule + } + + // Set parent doesn't exist, walk up the hierarchy + const parts = rule.ruleCode.split('.'); + for (let i = parts.length - 2; i > 0; i--) { + const ancestorCode = parts.slice(0, i).join('.'); + if (existingCodes.has(ancestorCode)) { + return { ...rule, parentRuleCode: ancestorCode }; + } + } + + // No ancestor exists, becomes top-level + return { ...rule, parentRuleCode: undefined }; + }); +}; + +/**************** FHE *****************/ + +const parseFHERules = (text: string): ParsedRule[] => { + const rules: ParsedRule[] = []; + const lines = text.split('\n'); + let inRulesSection = false; + let currentRule: { code: string; text: string } | null = null; + + const saveCurrentRule = () => { + if (!currentRule) return; + const parsedRules = extractSubRules(currentRule.code, currentRule.text); + rules.push(...parsedRules); + }; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) continue; + if (/^Index of Tables/i.test(trimmedLine)) { + inRulesSection = true; + } + // Skip table of contents + if (inRulesSection) { + if (/^2025 Formula Hybrid.*Rules/i.test(trimmedLine)) { + saveCurrentRule(); + currentRule = null; + continue; + } + + // Check if this line starts a new rule + const rule = parseRuleNumberFHE(trimmedLine); + if (rule) { + saveCurrentRule(); + currentRule = { + code: rule.ruleCode, + text: rule.ruleContent + }; + } else if (currentRule) { + // Append to existing rule + currentRule.text += ' ' + trimmedLine; + } + } + } + saveCurrentRule(); + + const fixedRules = fixOrphanedRulesFHE(rules); + return handleDuplicateCodes(fixedRules); +}; + +/** + * Determines if this line starts a new rule, if so extracts code and content of the rule + * Matches three patterns: rule ("1T3.17.1 Text"), part ("PART A1 - Text"), and article ("ARTICLE A1 Text") + * @param line single line in the extracted text from the ruleset pdf + * @returns rule code and content, or null if this line does not start a new rule + */ +const parseRuleNumberFHE = (line: string): ParsedRule | null => { + // Match FHE rule patterns like "1T3.17.1" followed by text + const rulePattern = /^(\d+[A-Z]+\d+(?:\.\d+)*)\s+(.+)$/; + + // "PART A1 - ADMINISTRATIVE REGULATIONS" removes "PART" and captures "A1" as rule code, rest as content + const partMatch = line.match(/^PART\s+([A-Z0-9]+)\s+-\s+(.+)$/); + if (partMatch) { + return { + ruleCode: partMatch[1], // "A1", not "PART A1" + ruleContent: partMatch[2] + }; + } + + // "ARTICLE A1 FORMULA HYBRID + ELECTRIC OVERVIEW" + // Captures "A1" as rule code, removes "ARTICLE" and adds rest as content + const articleMatch = line.match(/^ARTICLE\s+([A-Z]+\d+)\s+(.+)$/); + if (articleMatch) { + return { + ruleCode: articleMatch[1], // "A11", not "ARTICLE A11" + ruleContent: articleMatch[2] + }; + } + + const match = line.match(rulePattern); + if (match) { + return { + ruleCode: match[1], + ruleContent: match[2] + }; + } + + return null; +}; + +/** + * Updates rules to point to nearest existing parent if their assigned parent doesn't exist. + * D.8.1.2 -> checks for D.8.1, if missing goes to D.8, then D + * Also for FHE formatting 1A11.1 -> checks for 1A11, if missing tries A11 (article format) + * @param rules array of parsed rules + * @returns rules with corrected parent references + */ +const fixOrphanedRulesFHE = (rules: ParsedRule[]): ParsedRule[] => { + const existingCodes = new Set(rules.map((r) => r.ruleCode)); + + return rules.map((rule) => { + // skip if no parent or parent exists + if (!rule.parentRuleCode || existingCodes.has(rule.parentRuleCode)) { + return rule; + } + + // Set parent doesn't exist, walk up the hierarchy + const parts = rule.ruleCode.split('.'); + for (let i = parts.length - 2; i > 0; i--) { + const ancestorCode = parts.slice(0, i).join('.'); + + if (existingCodes.has(ancestorCode)) { + return { ...rule, parentRuleCode: ancestorCode }; + } + + // Also check stripped version (1A5 -> A5) + if (/^\d+[A-Z]+/.test(ancestorCode)) { + const strippedAncestor = ancestorCode.replace(/^\d+/, ''); + if (existingCodes.has(strippedAncestor)) { + return { ...rule, parentRuleCode: strippedAncestor }; + } + } + } + + // Special case: if parent is like "1A11" and doesn't exist, try "A11" (article format) + // This handles rules like "1A11.1" whose parent "1A11" doesn't exist but should be "A11" + if (rule.parentRuleCode && /^\d+[A-Z]+\d+$/.test(rule.parentRuleCode)) { + const withoutLeadingDigit = rule.parentRuleCode.substring(1); // "1A11" -> "A11" + if (existingCodes.has(withoutLeadingDigit)) { + return { ...rule, parentRuleCode: withoutLeadingDigit }; + } + } + + return { ...rule, parentRuleCode: undefined }; + }); +}; diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index 430e4ee640..bdbc11b173 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -116,6 +116,10 @@ export const resetUsers = async () => { await prisma.part_Review.deleteMany(); await prisma.part_Submission.deleteMany(); await prisma.part.deleteMany(); + await prisma.project_Rule.deleteMany(); + await prisma.rule.deleteMany(); + await prisma.ruleset.deleteMany(); + await prisma.ruleset_Type.deleteMany(); await prisma.project.deleteMany(); await prisma.frequentlyAskedQuestion.deleteMany(); await prisma.material.deleteMany(); @@ -132,6 +136,9 @@ export const resetUsers = async () => { await prisma.reimbursement_Request.deleteMany(); await prisma.vendor.deleteMany(); await prisma.account_Code.deleteMany(); + await prisma.rule.deleteMany(); + await prisma.ruleset.deleteMany(); + await prisma.ruleset_Type.deleteMany(); await prisma.car.deleteMany(); await prisma.task.deleteMany(); await prisma.stage_Gate_CR.deleteMany(); diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts new file mode 100644 index 0000000000..0fdcc9699e --- /dev/null +++ b/src/backend/tests/unit/rule.test.ts @@ -0,0 +1,2103 @@ +import RulesService from '../../src/services/rules.services'; +import { Organization, User, Project, Car, Ruleset_Type, Ruleset, Team } from '@prisma/client'; +import { + supermanAdmin, + financeMember, + wonderwomanGuest, + batmanAppAdmin, + aquamanLeadership, + alfred, + flashAdmin +} from '../test-data/users.test-data'; +import { + createTestOrganization, + createTestProject, + createTestUser, + resetUsers, + createTestTeam, + createTestTeamType +} from '../test-utils'; +import prisma from '../../src/prisma/prisma'; +import { + AccessDeniedException, + AccessDeniedGuestException, + DeletedException, + HttpException, + NotFoundException, + AccessDeniedAdminOnlyException, + InvalidOrganizationException +} from '../../src/utils/errors.utils'; +import TeamsService from '../../src/services/teams.services'; + +describe('Create Rules Tests', () => { + let orgId: string; + let organization: Organization; + let batman: User; + let superman: User; + let aquaman: User; + let wonderwoman: User; + let rulesetId: string; + let carId: string; + let rulesetType: Ruleset_Type; + + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + batman = await createTestUser(batmanAppAdmin, orgId); + superman = await createTestUser(supermanAdmin, orgId); + aquaman = await createTestUser(aquamanLeadership, orgId); + wonderwoman = await createTestUser(wonderwomanGuest, orgId); + + const car = await prisma.car.create({ + data: { + wbsElement: { + create: { + name: 'Test Car', + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0, + organizationId: orgId + } + } + } + }); + ({ carId } = car); + + rulesetType = await prisma.ruleset_Type.create({ + data: { + name: 'FSAE Rules', + createdBy: { connect: { userId: batman.userId } }, + organization: { connect: { organizationId: organization.organizationId } } + } + }); + + const ruleset1 = await prisma.ruleset.create({ + data: { + fileId: 'test-file-id', + name: '2025 FSAE Rules', + active: true, + rulesetType: { connect: { rulesetTypeId: rulesetType.rulesetTypeId } }, + car: { connect: { carId } }, + createdBy: { connect: { userId: batman.userId } }, + dateCreated: new Date('2025-01-01T10:00:00Z') + } + }); + + ({ rulesetId } = ruleset1); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('Create Rule', () => { + it('successfully creates a basic rule', async () => { + const rule = await RulesService.createRule( + batman, + 'T.1.1.1', + 'The vehicle must have four wheels', + rulesetId, + organization + ); + + expect(rule.ruleCode).toBe('T.1.1.1'); + expect(rule.ruleContent).toBe('The vehicle must have four wheels'); + expect(rule.parentRule).toBeUndefined(); + expect(rule.subRuleIds).toHaveLength(0); + expect(rule.referencedRuleIds).toHaveLength(0); + expect(rule.imageFileIds).toHaveLength(0); + }); + + it('successfully creates a rule with a parent', async () => { + const parentRule = await RulesService.createRule(batman, 'T.1.1', 'Vehicle Requirements', rulesetId, organization); + + const childRule = await RulesService.createRule( + superman, + 'T.1.1.1', + 'The vehicle must have four wheels', + rulesetId, + organization, + parentRule.ruleId + ); + + expect(childRule.parentRule?.ruleId).toBe(parentRule.ruleId); + expect(childRule.ruleCode).toBe('T.1.1.1'); + }); + + it('successfully creates a rule with referenced rules', async () => { + const rule1 = await RulesService.createRule(batman, 'T.1.1', 'Vehicle must have wheels', rulesetId, organization); + + const rule2 = await RulesService.createRule(batman, 'T.1.2', 'Vehicle must have brakes', rulesetId, organization); + + const rule3 = await RulesService.createRule( + superman, + 'T.2.1', + 'Braking system must work with wheels (see T.1.1 and T.1.2)', + rulesetId, + organization, + undefined, + [rule1.ruleId, rule2.ruleId] + ); + + expect(rule3.referencedRuleIds).toHaveLength(2); + expect(rule3.referencedRuleIds).toContain(rule1.ruleId); + expect(rule3.referencedRuleIds).toContain(rule2.ruleId); + }); + + it('successfully creates a rule with image file IDs', async () => { + const rule = await RulesService.createRule( + batman, + 'T.3.1', + 'Chassis must meet specifications', + rulesetId, + organization, + undefined, + [], + ['file-id-1', 'file-id-2'] + ); + + expect(rule.imageFileIds).toHaveLength(2); + expect(rule.imageFileIds).toContain('file-id-1'); + expect(rule.imageFileIds).toContain('file-id-2'); + }); + + it('fails when guest tries to create a rule', async () => { + await expect(RulesService.createRule(wonderwoman, 'T.1.1', 'Some rule', rulesetId, organization)).rejects.toThrow( + new AccessDeniedException('Only members and above can create rules') + ); + }); + + it('fails when ruleset does not exist', async () => { + await expect(RulesService.createRule(batman, 'T.1.1', 'Some rule', 'fake-ruleset-id', organization)).rejects.toThrow( + new NotFoundException('Ruleset', 'fake-ruleset-id') + ); + }); + + it('fails when ruleset is deleted', async () => { + await prisma.ruleset.update({ + where: { rulesetId }, + data: { deletedBy: { connect: { userId: batman.userId } } } + }); + + await expect(RulesService.createRule(batman, 'T.1.1', 'Some rule', rulesetId, organization)).rejects.toThrow( + new DeletedException('Ruleset', rulesetId) + ); + }); + + it('fails when duplicate rule code in same ruleset', async () => { + await RulesService.createRule(batman, 'T.1.1', 'First rule', rulesetId, organization); + + await expect(RulesService.createRule(superman, 'T.1.1', 'Duplicate code', rulesetId, organization)).rejects.toThrow( + new HttpException(400, 'Rule with code T.1.1 already exists in this ruleset') + ); + }); + + it('fails when parent rule does not exist', async () => { + await expect( + RulesService.createRule(batman, 'T.1.1', 'Some rule', rulesetId, organization, 'fake-parent-id') + ).rejects.toThrow(new NotFoundException('Parent Rule', 'fake-parent-id')); + }); + + it('fails when parent rule is deleted', async () => { + const parentRule = await RulesService.createRule(batman, 'T.1', 'Parent', rulesetId, organization); + + await prisma.rule.update({ + where: { ruleId: parentRule.ruleId }, + data: { dateDeleted: new Date(), deletedBy: { connect: { userId: batman.userId } } } + }); + + await expect( + RulesService.createRule(superman, 'T.1.1', 'Child', rulesetId, organization, parentRule.ruleId) + ).rejects.toThrow(new DeletedException('Parent Rule', parentRule.ruleId)); + }); + + it('fails when parent rule is in different ruleset', async () => { + const otherRuleset = await prisma.ruleset.create({ + data: { + fileId: 'other-file', + name: 'Other Rules', + active: true, + rulesetTypeId: (await prisma.ruleset_Type.findFirst())!.rulesetTypeId, + carId, + createdByUserId: batman.userId + } + }); + + const parentRule = await RulesService.createRule(batman, 'T.1', 'Parent', otherRuleset.rulesetId, organization); + + await expect( + RulesService.createRule(superman, 'T.1.1', 'Child', rulesetId, organization, parentRule.ruleId) + ).rejects.toThrow(new HttpException(400, 'Parent rule must be in the same ruleset')); + }); + + it('fails when referenced rule does not exist', async () => { + await expect( + RulesService.createRule(batman, 'T.1.1', 'Some rule', rulesetId, organization, undefined, ['fake-rule-id']) + ).rejects.toThrow(new NotFoundException('Referenced Rule', 'provided IDs')); + }); + + it('fails when referenced rule is deleted', async () => { + const rule1 = await RulesService.createRule(batman, 'T.1.1', 'Rule 1', rulesetId, organization); + + await prisma.rule.update({ + where: { ruleId: rule1.ruleId }, + data: { dateDeleted: new Date(), deletedBy: { connect: { userId: batman.userId } } } + }); + + await expect( + RulesService.createRule(superman, 'T.1.2', 'Rule 2', rulesetId, organization, undefined, [rule1.ruleId]) + ).rejects.toThrow(new DeletedException('Referenced Rule', rule1.ruleId)); + }); + + it('allows members and above to create rules', async () => { + await RulesService.createRule(aquaman, 'T.1.1', 'Member created rule', rulesetId, organization); + await RulesService.createRule(aquaman, 'T.1.2', 'Leadership created rule', rulesetId, organization); + await RulesService.createRule(superman, 'T.1.3', 'Admin created rule', rulesetId, organization); + }); + + describe('Create ruleset', () => { + it('successful create ruleset', async () => { + const ruleset = await RulesService.createRuleset( + superman, + organization, + 'ruleset name', + rulesetType.rulesetTypeId, + 0, + false, + 'fileId' + ); + + expect(ruleset.name).toEqual('ruleset name'); + }); + it('Create ruleset fails when submitters is not leadership', async () => { + await expect( + async () => + await RulesService.createRuleset( + wonderwoman, + organization, + 'ruleset name', + rulesetType.rulesetTypeId, + 0, + false, + 'fileId' + ) + ).rejects.toThrow(new AccessDeniedException('only leadership and above can create ruleset!')); + }); + it('Create ruleset fails when given bad ruleset id', async () => { + await expect( + async () => + await RulesService.createRuleset(superman, organization, 'ruleset name', 'bad ruleset type', 0, false, 'fileId') + ).rejects.toThrow(new NotFoundException('Ruleset Type', 'bad ruleset type')); + }); + it('Create ruleset fails when given bad car number', async () => { + await expect( + async () => + await RulesService.createRuleset( + superman, + organization, + 'ruleset name', + rulesetType.rulesetTypeId, + 12312312, + false, + 'fileId' + ) + ).rejects.toThrow(new NotFoundException('Car', 12312312)); + }); + }); + }); + + describe('Complex Rule Scenarios', () => { + it('creates a hierarchical rule structure', async () => { + const root = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + + const child1 = await RulesService.createRule( + batman, + 'T.1.1', + 'Vehicle Requirements', + rulesetId, + organization, + root.ruleId + ); + + const child2 = await RulesService.createRule( + batman, + 'T.1.2', + 'Safety Requirements', + rulesetId, + organization, + root.ruleId + ); + + const grandchild1 = await RulesService.createRule( + superman, + 'T.1.1.1', + 'Wheels', + rulesetId, + organization, + child1.ruleId + ); + + expect(root.parentRule).toBeUndefined(); + expect(child1.parentRule?.ruleId).toBe(root.ruleId); + expect(child2.parentRule?.ruleId).toBe(root.ruleId); + expect(grandchild1.parentRule?.ruleId).toBe(child1.ruleId); + }); + + it('creates rules with cross-references', async () => { + const wheelRule = await RulesService.createRule(batman, 'T.1.1', 'Wheel specifications', rulesetId, organization); + + const brakeRule = await RulesService.createRule(batman, 'T.1.2', 'Brake specifications', rulesetId, organization); + + const brakingSystemRule = await RulesService.createRule( + superman, + 'T.2.1', + 'Braking system must comply with T.1.1 and T.1.2', + rulesetId, + organization, + undefined, + [wheelRule.ruleId, brakeRule.ruleId] + ); + + const wheelRuleFromDb = await prisma.rule.findUnique({ + where: { ruleId: wheelRule.ruleId }, + include: { referencedBy: true } + }); + + expect(wheelRuleFromDb?.referencedBy.some((r) => r.ruleId === brakingSystemRule.ruleId)).toBe(true); + }); + }); + + 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); + expect(rulesets.length).toBe(1); + expect(rulesets[0].name).toBe('2025 FSAE Rules'); + expect(rulesets[0].active).toBeTruthy(); + expect(rulesets[0].assignedPercentage).toBe(0); + }); + + it('Successful get rulesets by ruleset types after deleting ruleset', async () => { + // Deactivate the ruleset before deleting + await prisma.ruleset.update({ + where: { rulesetId }, + data: { active: false } + }); + + await RulesService.deleteRuleset(rulesetId, batman.userId, orgId); + const rulesets = await RulesService.getRulesetsByRulesetType(rulesetType.rulesetTypeId, orgId); + expect(rulesets.length).toBe(0); + }); + + it('Successful get rulesets by ruleset types after adding ruleset', async () => { + await prisma.ruleset.create({ + data: { + fileId: 'test-file-id2', + name: '2025 FSAE Rules2', + active: true, + rulesetType: { connect: { rulesetTypeId: rulesetType.rulesetTypeId } }, + car: { connect: { carId } }, + createdBy: { connect: { userId: batman.userId } } + } + }); + const rulesets = await RulesService.getRulesetsByRulesetType(rulesetType.rulesetTypeId, orgId); + expect(rulesets.length).toBe(2); + expect(rulesets[0].name).toBe('2025 FSAE Rules2'); + expect(rulesets[1].name).toBe('2025 FSAE Rules'); + }); + }); + + describe('Get Child Rules', () => { + it('Successfully gets child rules for a parent rule', async () => { + const parentRule = await RulesService.createRule(batman, 'T.1', 'Parent Rule', rulesetId, organization); + await RulesService.createRule(batman, 'T.1.1', 'Child Rule 1', rulesetId, organization, parentRule.ruleId); + await RulesService.createRule(batman, 'T.1.2', 'Child Rule 2', rulesetId, organization, parentRule.ruleId); + const childRules = await RulesService.getChildRules(parentRule.ruleId, organization); + expect(childRules.length).toBe(2); + expect(childRules[0].ruleCode).toBe('T.1.1'); + expect(childRules[1].ruleCode).toBe('T.1.2'); + }); + + it('Successfully gets child rules after deleting child rule', async () => { + const parentRule = await RulesService.createRule(batman, 'T.2', 'Parent Rule', rulesetId, organization); + const childRule = await RulesService.createRule( + batman, + 'T.2.1', + 'Child Rule', + rulesetId, + organization, + parentRule.ruleId + ); + await RulesService.deleteRule(childRule.ruleId, batman, organization); + const childRules = await RulesService.getChildRules(parentRule.ruleId, organization); + expect(childRules.length).toBe(0); + }); + + it('Successfully gets child rules after adding child rule', async () => { + const parentRule = await RulesService.createRule(batman, 'T.3', 'Parent Rule', rulesetId, organization); + await RulesService.createRule(batman, 'T.3.1', 'Child Rule 1', rulesetId, organization, parentRule.ruleId); + const childRulesAfterOne = await RulesService.getChildRules(parentRule.ruleId, organization); + expect(childRulesAfterOne.length).toBe(1); + await RulesService.createRule(batman, 'T.3.2', 'Child Rule 2', rulesetId, organization, parentRule.ruleId); + const childRulesAfterTwo = await RulesService.getChildRules(parentRule.ruleId, organization); + expect(childRulesAfterTwo.length).toBe(2); + expect(childRulesAfterTwo[0].ruleCode).toBe('T.3.1'); + expect(childRulesAfterTwo[1].ruleCode).toBe('T.3.2'); + }); + + it('Fails if parent rule does not exist', async () => { + await expect(async () => await RulesService.getChildRules('fake-rule-id', organization)).rejects.toThrow( + new NotFoundException('Rule', 'fake-rule-id') + ); + }); + + it('Fails if parent rule is deleted', async () => { + const parentRule = await RulesService.createRule(batman, 'T.4', 'Parent Rule', rulesetId, organization); + await RulesService.deleteRule(parentRule.ruleId, batman, organization); + await expect(async () => await RulesService.getChildRules(parentRule.ruleId, organization)).rejects.toThrow( + new DeletedException('Rule', parentRule.ruleId) + ); + }); + + it('Fails if parent rule is from another organization', async () => { + //manually create a user to avoid same googleAuthID as otherBatman + const otherUser = await prisma.user.create({ + data: { + firstName: alfred.firstName, + lastName: alfred.lastName, + email: alfred.email, + googleAuthId: alfred.googleAuthId + } + }); + const otherOrganization = await prisma.organization.create({ + data: { + name: 'Other Org', + description: 'Another organization', + applicationLink: '', + userCreated: { + connect: { + userId: otherUser.userId + } + } + } + }); + const otherBatman = await createTestUser(flashAdmin, otherOrganization.organizationId); + const otherCar = await prisma.car.create({ + data: { + wbsElement: { + create: { + name: 'Other Car', + carNumber: 2, + projectNumber: 0, + workPackageNumber: 0, + organizationId: otherOrganization.organizationId + } + } + }, + include: { wbsElement: true } + }); + + const otherRuleset = await prisma.ruleset.create({ + data: { + name: 'Other Ruleset', + fileId: 'other', + active: true, + carId: otherCar.carId, + createdByUserId: otherBatman.userId, + rulesetTypeId: rulesetType.rulesetTypeId + } + }); + const otherParentRule = await prisma.rule.create({ + data: { + ruleCode: 'O.1', + ruleContent: 'Other Parent', + imageFileIds: [], + rulesetId: otherRuleset.rulesetId, + createdByUserId: otherBatman.userId + } + }); + await expect(async () => await RulesService.getChildRules(otherParentRule.ruleId, organization)).rejects.toThrow( + new InvalidOrganizationException('Rule') + ); + }); + }); + describe('Update ruleset status', () => { + it('update ruleset status - successful', async () => { + const ruleset1 = await RulesService.updateRuleset(batman, orgId, rulesetId, 'name1', false); + expect(ruleset1.active).toBe(false); + expect(ruleset1.name).toBe('name1'); + const ruleset2 = await RulesService.updateRuleset(batman, orgId, rulesetId, 'name2', true); + expect(ruleset2.active).toBe(true); + expect(ruleset2.name).toBe('name2'); + }); + it('update ruleset status on deleted ruleset fails', async () => { + // Deactivate the ruleset before deleting + await prisma.ruleset.update({ + where: { rulesetId }, + data: { active: false } + }); + await RulesService.deleteRuleset(rulesetId, batman.userId, orgId); + await expect(async () => await RulesService.updateRuleset(batman, orgId, rulesetId, 'name', false)).rejects.toThrow( + new NotFoundException('Ruleset', rulesetId) + ); + }); + it('update active ruleset successful with active ruleset in different type', async () => { + const ruleset2 = await RulesService.createRuleset( + superman, + organization, + 'ruleset name', + (await RulesService.createRulesetType(batman, 'ruleset type 2', organization)).rulesetTypeId, + 0, + false, + 'fileId' + ); + await RulesService.updateRuleset(batman, orgId, ruleset2.rulesetId, 'name', false); + const ruleset = await RulesService.updateRuleset(batman, orgId, rulesetId, 'name', true); + expect(ruleset.active).toBe(true); + }); + it('update ruleset status fails with wrong org', async () => { + const wrongOrg = await prisma.organization.create({ + data: { + name: 'wrong org', + userCreatedId: batman.userId, + description: 'desc', + applicationLink: '1' + } + }); + + const wrongOrgCar = await prisma.car.create({ + data: { + wbsElement: { + create: { + name: 'wrong org car', + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0, + organizationId: wrongOrg.organizationId + } + } + } + }); + + const wrongOrgRulesetType = await prisma.ruleset_Type.create({ + data: { + name: 'ruleset type 2', + createdBy: { connect: { userId: batman.userId } }, + organization: { connect: { organizationId: wrongOrg.organizationId } } + } + }); + + const wrongOrgRuleset = await prisma.ruleset.create({ + data: { + fileId: 'fileId', + name: 'ruleset name', + active: false, + rulesetType: { connect: { rulesetTypeId: wrongOrgRulesetType.rulesetTypeId } }, + car: { connect: { carId: wrongOrgCar.carId } }, + createdBy: { connect: { userId: batman.userId } } + } + }); + + await expect( + async () => await RulesService.updateRuleset(batman, orgId, wrongOrgRuleset.rulesetId, 'name', false) + ).rejects.toThrow(new NotFoundException('Ruleset', wrongOrgRuleset.rulesetId)); + }); + it('update ruleset status - fails non leadership', async () => { + await expect( + async () => await RulesService.updateRuleset(wonderwoman, orgId, rulesetId, 'name', false) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update ruleset status')); + }); + it('update ruleset status - fails if one is already active in same type', async () => { + const ruleset2 = await RulesService.createRuleset( + superman, + organization, + 'ruleset name', + rulesetType.rulesetTypeId, + 0, + false, + 'fileId' + ); + await expect( + async () => await RulesService.updateRuleset(batman, orgId, ruleset2.rulesetId, 'name', true) + ).rejects.toThrow(new HttpException(400, 'There is already an active ruleset for this ruleset type')); + }); + }); +}); + +describe('Rule Tests', () => { + let organization: Organization; + let orgId: string; + let otherOrg: Organization; + let admin: User; + let nonLeadership: User; + let guest: User; + let project: Project; + let fsaeRulesetType: Ruleset_Type; + let emptyRulesetType: Ruleset_Type; + let testTeam: Team; + + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + admin = await createTestUser(supermanAdmin, organization.organizationId); + nonLeadership = await createTestUser(financeMember, organization.organizationId); + guest = await createTestUser(wonderwomanGuest, organization.organizationId); + project = await createTestProject(admin, organization.organizationId); + testTeam = await prisma.team.create({ + data: { + teamName: 'Test', + slackId: 'test-slack', + headId: admin.userId, + organizationId: organization.organizationId + } + }); + const otherOrgUser = await prisma.user.create({ + data: { + firstName: 'Other', + lastName: 'Admin', + email: 'other@test.com', + googleAuthId: 'otherOrganizationCreator' // different googleAuthId + } + }); + otherOrg = await prisma.organization.create({ + data: { + name: 'Other Organization', + description: 'Other test organization', + applicationLink: '', + userCreated: { + connect: { + userId: otherOrgUser.userId + } + } + } + }); + + fsaeRulesetType = await prisma.ruleset_Type.create({ + data: { + name: 'FSAE', + createdBy: { connect: { userId: admin.userId } }, + organization: { connect: { organizationId: organization.organizationId } } + } + }); + + emptyRulesetType = await prisma.ruleset_Type.create({ + data: { + name: 'Ruleset Type with no Active Rulesets or Anything', + createdBy: { connect: { userId: admin.userId } }, + organization: { connect: { organizationId: organization.organizationId } } + } + }); + }); + + afterEach(async () => { + await resetUsers(); + }); + + let carCounter = 1; + const createUniqueCar = async (orgId: string) => { + const car = await prisma.car.create({ + data: { + wbsElement: { + create: { + name: `Test Car ${carCounter}`, + carNumber: carCounter, + projectNumber: 0, + workPackageNumber: 0, + organizationId: orgId + } + } + }, + include: { + wbsElement: true + } + }); + carCounter++; + return car; + }; + + const setupRules = async (car: Car) => { + const ruleset1 = await prisma.ruleset.create({ + data: { + name: 'FSAE Rules 2025', + fileId: 'fsae-rules-2025', + active: true, + dateCreated: new Date(), + car: { connect: { carId: car.carId } }, + createdBy: { connect: { userId: admin.userId } }, + rulesetType: { connect: { rulesetTypeId: fsaeRulesetType.rulesetTypeId } } + } + }); + + const ruleset2 = await prisma.ruleset.create({ + data: { + fileId: 'test-file-id', + name: 'Inactive 2025 FSAE Rules', + active: false, + rulesetType: { connect: { rulesetTypeId: fsaeRulesetType.rulesetTypeId } }, + car: { connect: { carId: car.carId } }, + createdBy: { connect: { userId: admin.userId } }, + dateCreated: new Date('2024-12-31T10:00:00Z') + } + }); + + const topLevelRule = await prisma.rule.create({ + data: { + ruleCode: 'T', + ruleContent: 'PART T - GENERAL TECHNICAL REQUIREMENTS', + imageFileIds: [], + dateCreated: new Date(), + ruleset: { connect: { rulesetId: ruleset1.rulesetId } }, + createdBy: { connect: { userId: admin.userId } } + } + }); + + const leafRule1 = await prisma.rule.create({ + data: { + ruleCode: 'T2', + ruleContent: 'The vehicle must be open-wheeled and open-cockpit...', + imageFileIds: [], + dateCreated: new Date(), + ruleset: { connect: { rulesetId: ruleset1.rulesetId } }, + createdBy: { connect: { userId: admin.userId } }, + parentRule: { connect: { ruleId: topLevelRule.ruleId } } + } + }); + + const leafRule2 = await prisma.rule.create({ + data: { + ruleCode: 'T2.1', + ruleContent: 'T2.1 Vehicle Configuration', + imageFileIds: [], + dateCreated: new Date(), + ruleset: { connect: { rulesetId: ruleset1.rulesetId } }, + createdBy: { connect: { userId: admin.userId } }, + parentRule: { connect: { ruleId: topLevelRule.ruleId } } + } + }); + + return { ruleset1, ruleset2, topLevelRule, leafRule1, leafRule2 }; + }; + + describe('Create Ruleset Type', () => { + it('Fails if user is not leadership or above', async () => { + await expect(async () => await RulesService.createRulesetType(guest, 'FSAE', organization)).rejects.toThrow( + new AccessDeniedException('only leadership and above can create ruleset types!') + ); + }); + + it('Succeeds and creates a ruleset type', async () => { + const result = await RulesService.createRulesetType(await createTestUser(batmanAppAdmin, orgId), 'FSAE', organization); + + expect(result.name).toEqual('FSAE'); + }); + }); + + describe('Project Rule endpoints', () => { + it('Creates a project rule successfully', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + + expect(projectRule.projectRuleId).toBeDefined(); + expect(projectRule.rule).toBeDefined(); + expect(projectRule.rule.ruleId).toBe(topLevelRule.ruleId); + expect(projectRule.rule.ruleCode).toBe(topLevelRule.ruleCode); + expect(projectRule.projectId).toBe(project.projectId); + expect(projectRule.rule.isComplete).toBe(false); + }); + it('Creates a project rule successfully for a leaf rule', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + const projectRule = await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + + expect(projectRule.projectRuleId).toBeDefined(); + expect(projectRule.rule).toBeDefined(); + expect(projectRule.rule.ruleId).toBe(leafRule1.ruleId); + expect(projectRule.rule.ruleCode).toBe(leafRule1.ruleCode); + expect(projectRule.projectId).toBe(project.projectId); + expect(projectRule.rule.isComplete).toBe(false); + }); + it('Create project rule fails if user does not have permission', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + await expect( + async () => await RulesService.createProjectRule(nonLeadership, organization, leafRule1.ruleId, project.projectId) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to assign rules to projects')); + }); + it('Create project rule fails if rule was deleted', async () => { + const car = await createUniqueCar(orgId); + const { leafRule2 } = await setupRules(car); + + await prisma.rule.update({ + where: { ruleId: leafRule2.ruleId }, + data: { dateDeleted: new Date() } + }); + await expect( + async () => await RulesService.createProjectRule(admin, organization, leafRule2.ruleId, project.projectId) + ).rejects.toThrow(new DeletedException('Rule', leafRule2.ruleId)); + }); + it('Create project rule fails if rule does not exist', async () => { + await expect( + async () => await RulesService.createProjectRule(admin, organization, '019263825673825738', project.projectId) + ).rejects.toThrow(new NotFoundException('Rule', '019263825673825738')); + }); + it('Create project rule fails if project was deleted', async () => { + const car = await createUniqueCar(orgId); + const { leafRule2 } = await setupRules(car); + await prisma.project.update({ + where: { projectId: project.projectId }, + data: { + wbsElement: { + update: { dateDeleted: new Date() } + } + } + }); + await expect( + async () => await RulesService.createProjectRule(admin, organization, leafRule2.ruleId, project.projectId) + ).rejects.toThrow(new DeletedException('Project', project.projectId)); + }); + it('Create project rule fails if project does not exist', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + await expect(RulesService.createProjectRule(admin, organization, leafRule1.ruleId, 'fake-project-id')).rejects.toThrow( + new NotFoundException('Project', 'fake-project-id') + ); + }); + it('Create project rule fails if project rule assignment already exists', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + await expect(RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId)).rejects.toThrow( + new HttpException(400, 'This rule is already associated with the project') + ); + }); + + // 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 updatedRule = await RulesService.setRuleCompletion( + admin, + organization, + topLevelRule.ruleId, + true, + project.projectId + ); + + 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('Marks a rule complete without a project (general view)', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + + const updatedRule = await RulesService.setRuleCompletion(admin, organization, topLevelRule.ruleId, true); + + expect(updatedRule.isComplete).toBe(true); + expect(updatedRule.completedBy?.firstName).toBe(admin.firstName); + expect(updatedRule.completedInProject).toBeUndefined(); + }); + + 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); + + await expect( + async () => + await RulesService.setRuleCompletion(nonLeadership, organization, topLevelRule.ruleId, true, project.projectId) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule completion')); + }); + }); + + describe('Edit Rule', () => { + it('Fails if user is not an admin', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + await expect( + async () => + await RulesService.editRule( + guest, + 'Some rule content', + leafRule1.ruleId, + leafRule1.ruleCode, + ['newfile'], + organization + ) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('edit a rule')); + }); + + it('Fails if rule doesn`t exist', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + await expect( + async () => + await RulesService.editRule( + await createTestUser(batmanAppAdmin, orgId), + 'Some more rule content', + '1', + leafRule1.ruleCode, + ['samefile'], + organization + ) + ).rejects.toThrow(new NotFoundException('Rule', 1)); + }); + + it('Succeeds and edits a rule', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + const updatedRule = await RulesService.editRule( + admin, + 'BRAND NEW RULE CONTENT', + leafRule1.ruleId, + leafRule1.ruleCode, + leafRule1.imageFileIds, + organization + ); + + expect(updatedRule.ruleContent).toEqual('BRAND NEW RULE CONTENT'); + }); + }); + + describe('Delete Ruleset', () => { + it('Deletes a ruleset successfully and returns the correct information', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + // deactivate before deleting + await prisma.ruleset.update({ + where: { rulesetId: ruleset1.rulesetId }, + data: { active: false } + }); + + const totalRules = await prisma.rule.count({ + where: { rulesetId: ruleset1.rulesetId } + }); + const rulesWithTeams = await prisma.rule.count({ + where: { + rulesetId: ruleset1.rulesetId, + teams: { some: {} } + } + }); + const expectedPercentage = totalRules > 0 ? (rulesWithTeams / totalRules) * 100 : 0; + const deleted = await RulesService.deleteRuleset(ruleset1.rulesetId, admin.userId, organization.organizationId); + + expect(deleted).toBeDefined(); + expect(deleted.rulesetId).toBe(ruleset1.rulesetId); + expect(deleted.assignedPercentage).toBeCloseTo(expectedPercentage, 2); + }); + it('Throws error when trying to delete an active ruleset', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + // Ensure the ruleset is active + await prisma.ruleset.update({ + where: { rulesetId: ruleset1.rulesetId }, + data: { active: true } + }); + + await expect( + RulesService.deleteRuleset(ruleset1.rulesetId, admin.userId, organization.organizationId) + ).rejects.toThrow('Cannot delete an active ruleset. Please deactivate it first.'); + }); + it('Delete ruleset fails if user does not have permission', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + await expect( + async () => await RulesService.deleteRuleset(ruleset1.rulesetId, nonLeadership.userId, organization.organizationId) + ).rejects.toThrow(new AccessDeniedException('Only admins can delete a ruleset.')); + }); + it('Delete ruleset fails if ruleset was already deleted', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + // Deactivate the ruleset before deleting + await prisma.ruleset.update({ + where: { rulesetId: ruleset1.rulesetId }, + data: { active: false } + }); + + await RulesService.deleteRuleset(ruleset1.rulesetId, admin.userId, organization.organizationId); + await expect( + async () => await RulesService.deleteRuleset(ruleset1.rulesetId, admin.userId, organization.organizationId) + ).rejects.toThrow(new DeletedException('Ruleset', ruleset1.rulesetId)); + }); + it('Delete ruleset fails if ruleset does not exist', async () => { + await expect( + async () => await RulesService.deleteRuleset('fake-ruleset-id', admin.userId, organization.organizationId) + ).rejects.toThrow(new NotFoundException('Ruleset', 'fake-ruleset-id')); + }); + }); + + describe('Get all ruleset types', () => { + it('Successful get all ruleset types', async () => { + const rulesetTypes = await RulesService.getAllRulesetTypes(organization); + expect(rulesetTypes.length).toEqual(2); + expect(rulesetTypes[0].name).toEqual('FSAE'); + expect(rulesetTypes[1].name).toEqual('Ruleset Type with no Active Rulesets or Anything'); + }); + it('Get all ruleset types successful after adding ruleset type', async () => { + await prisma.ruleset_Type.create({ + data: { + name: 'FSAE2', + createdByUserId: admin.userId, + organizationId: orgId + } + }); + const rulesetTypes = await RulesService.getAllRulesetTypes(organization); + expect(rulesetTypes.length).toEqual(3); + expect(rulesetTypes[2].name).toEqual('FSAE2'); + }); + it('Get all ruleset types successful after deleting ruleset type', async () => { + await prisma.ruleset_Type.update({ + where: { + rulesetTypeId: fsaeRulesetType.rulesetTypeId + }, + data: { + deletedByUserId: admin.userId + } + }); + const rulesetTypes = await RulesService.getAllRulesetTypes(organization); + expect(rulesetTypes.length).toEqual(1); + }); + }); + + describe('Get Active Ruleset', () => { + it('Fails if user is a guest', async () => { + await expect(RulesService.getActiveRuleset(guest, fsaeRulesetType.rulesetTypeId, organization)).rejects.toThrow( + new AccessDeniedException('only members and above can view ruleset types!') + ); + }); + + it('Fails if ruleset type does not exist', async () => { + await expect(RulesService.getActiveRuleset(admin, 'fake-ruleset-type-id', organization)).rejects.toThrow( + new NotFoundException('Ruleset Type', 'fake-ruleset-type-id') + ); + }); + + it('Fails if ruleset type is already deleted', async () => { + await prisma.ruleset_Type.update({ + where: { + rulesetTypeId: emptyRulesetType.rulesetTypeId + }, + data: { + deletedByUserId: admin.userId + } + }); + + await expect(RulesService.getActiveRuleset(admin, emptyRulesetType.rulesetTypeId, organization)).rejects.toThrow( + new DeletedException('Ruleset Type', emptyRulesetType.rulesetTypeId) + ); + }); + + it('Fails if there are no rulesets in the given ruleset type', async () => { + await expect(RulesService.getActiveRuleset(admin, emptyRulesetType.rulesetTypeId, organization)).rejects.toThrow( + new NotFoundException('Active Ruleset for given Ruleset Type', emptyRulesetType.rulesetTypeId) + ); + }); + + it('Successfully gets the active ruleset for a ruleset type', async () => { + await setupRules(await createUniqueCar(orgId)); + + const activeRuleset = await RulesService.getActiveRuleset(admin, fsaeRulesetType.rulesetTypeId, organization); + expect(activeRuleset).toBeDefined(); + if (Array.isArray(activeRuleset)) { + throw new Error('Expected a single active ruleset, but got an array'); + } + + expect(activeRuleset.name).toBe('FSAE Rules 2025'); + expect(activeRuleset.active).toBe(true); + }); + }); + + describe('Toggle Rule Team', () => { + it('Fails if user is a guest', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + await expect( + async () => await RulesService.toggleRuleTeam(topLevelRule.ruleId, '', guest, organization) + ).rejects.toThrow(new AccessDeniedGuestException('Toggle Rule Team')); + }); + it('Fails if rule does not exist', async () => { + await expect(async () => await RulesService.toggleRuleTeam('fake-rule-id', '', admin, organization)).rejects.toThrow( + new NotFoundException('Rule', 'fake-rule-id') + ); + }); + it('Fails if rule is deleted', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + await RulesService.deleteRule(topLevelRule.ruleId, admin, organization); + await expect( + async () => await RulesService.toggleRuleTeam(topLevelRule.ruleId, '', admin, organization) + ).rejects.toThrow(new DeletedException('Rule', topLevelRule.ruleId)); + }); + it('Fails if a team does not exist', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + await expect( + async () => await RulesService.toggleRuleTeam(topLevelRule.ruleId, 'fake-team-id', admin, organization) + ).rejects.toThrow(new NotFoundException('Team', 'fake-team-id')); + }); + it('Fails if a team is not in the correct organization', async () => { + const user = await prisma.user.create({ + data: { + firstName: 'Admin', + lastName: 'Admin', + email: 'testemail@hotmail.com', + googleAuthId: 'orgCreator1' + } + }); + const org2 = await prisma.organization.create({ + data: { + name: 'Joe mama', + description: 'Joe mama`s organization', + applicationLink: '', + userCreated: { + connect: { + userId: user.userId + } + } + } + }); + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + const teamType = await createTestTeamType('electrical', org2.organizationId); + const team = await createTestTeam(admin.userId, teamType.teamTypeId, org2.organizationId); + await expect(RulesService.toggleRuleTeam(topLevelRule.ruleId, team.teamId, admin, organization)).rejects.toThrow( + new InvalidOrganizationException('Rule') + ); + }); + it('Fails if a team is archived', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + const teamType = await createTestTeamType('electrical', organization.organizationId); + const team = await createTestTeam(admin.userId, teamType.teamTypeId, organization.organizationId); + await TeamsService.archiveTeam(admin, team.teamId, organization); + await expect(RulesService.toggleRuleTeam(topLevelRule.ruleId, team.teamId, admin, organization)).rejects.toThrow( + new HttpException(400, 'Cannot toggle an archived team.') + ); + }); + it('Successfully adds a team to a rule', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + const teamType = await createTestTeamType('electrical', organization.organizationId); + const team = await createTestTeam(admin.userId, teamType.teamTypeId, organization.organizationId); + const updRule = await RulesService.toggleRuleTeam(topLevelRule.ruleId, team.teamId, admin, organization); + const ruleWithTeams = await prisma.rule.findUnique({ + where: { ruleId: topLevelRule.ruleId }, + include: { teams: true } + }); + expect(updRule).toBeDefined(); + expect(ruleWithTeams?.teams.length).toBe(1); + expect(ruleWithTeams?.teams[0].teamId).toBe(team.teamId); + }); + it('Successfully removes a team from a rule', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + const teamType = await createTestTeamType('electrical', organization.organizationId); + const team = await createTestTeam(admin.userId, teamType.teamTypeId, organization.organizationId); + const teamAddedRule = await RulesService.toggleRuleTeam(topLevelRule.ruleId, team.teamId, admin, organization); + expect(teamAddedRule).toBeDefined(); + + const teamRemovedRule = await RulesService.toggleRuleTeam(topLevelRule.ruleId, team.teamId, admin, organization); + const ruleWithTeams = await prisma.rule.findUnique({ + where: { ruleId: topLevelRule.ruleId }, + include: { teams: true } + }); + expect(teamRemovedRule).toBeDefined(); + expect(ruleWithTeams?.teams.length).toBe(0); + expect(ruleWithTeams?.teams[0]).toBeUndefined(); + }); + }); + + describe('Delete Project Rule', () => { + it('Deletes a project rule successfully and returns the correct information', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + const projectRule = await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + + const deletedProjectRule = await RulesService.deleteProjectRule(projectRule.projectRuleId, admin, organization); + + expect(deletedProjectRule).toBeDefined(); + expect(deletedProjectRule.projectRuleId).toBe(projectRule.projectRuleId); + + 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); + const { leafRule1 } = await setupRules(car); + const projectRule = await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + + await expect( + async () => await RulesService.deleteProjectRule(projectRule.projectRuleId, nonLeadership, organization) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('delete project rules')); + }); + it('Delete project rule fails if project rule was already deleted', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + const projectRule = await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + + await RulesService.deleteProjectRule(projectRule.projectRuleId, admin, organization); + await expect( + async () => await RulesService.deleteProjectRule(projectRule.projectRuleId, admin, organization) + ).rejects.toThrow(new DeletedException('Project Rule', projectRule.projectRuleId)); + }); + it('Delete project rule fails if project rule does not exist', async () => { + await expect( + async () => await RulesService.deleteProjectRule('fake-project-rule-id', admin, organization) + ).rejects.toThrow(new NotFoundException('Project Rule', 'fake-project-rule-id')); + }); + }); + + describe('Delete Ruleset Type', () => { + it('Fails if user not an admin', async () => { + await expect(async () => await RulesService.deleteRulesetType(nonLeadership, 'FSAE', organization)).rejects.toThrow( + new AccessDeniedAdminOnlyException('delete ruleset types') + ); + }); + + it('Fails if the ruleset type has already been deleted', async () => { + const appAdmin = await createTestUser(batmanAppAdmin, orgId); + await RulesService.deleteRulesetType(appAdmin, fsaeRulesetType.rulesetTypeId, organization); + + await expect(RulesService.deleteRulesetType(appAdmin, fsaeRulesetType.rulesetTypeId, organization)).rejects.toThrow( + new DeletedException('Ruleset Type', fsaeRulesetType.rulesetTypeId) + ); + }); + + it('Successfully deletes the ruleset type', async () => { + let rulesetTypes = await RulesService.getAllRulesetTypes(organization); + expect(rulesetTypes.length).toEqual(2); + + const appAdmin = await createTestUser(batmanAppAdmin, orgId); + const result = await RulesService.deleteRulesetType(appAdmin, fsaeRulesetType.rulesetTypeId, organization); + + rulesetTypes = await RulesService.getAllRulesetTypes(organization); + + expect(rulesetTypes.length).toEqual(1); + + expect(result.rulesetTypeId).toBe(fsaeRulesetType.rulesetTypeId); + }); + + it('Successfully deletes all revision files in revision files', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + const revFiles: Ruleset[] = [ruleset1]; + + const fsaeRulesetType2WithRevisionFiles = await prisma.ruleset_Type.create({ + data: { + name: 'FSAE2', + createdBy: { connect: { userId: admin.userId } }, + organization: { connect: { organizationId: organization.organizationId } }, + revisionFiles: { connect: revFiles } + } + }); + + let rulesets = await RulesService.getRulesetsByRulesetType(fsaeRulesetType2WithRevisionFiles.rulesetTypeId, orgId); + expect(rulesets.length).toBe(1); + await RulesService.deleteRulesetType(admin, fsaeRulesetType2WithRevisionFiles.rulesetTypeId, organization); + rulesets = await RulesService.getRulesetsByRulesetType(fsaeRulesetType2WithRevisionFiles.rulesetTypeId, orgId); + expect(rulesets.length).toBe(0); + }); + }); + + describe('Get Unassigned Rules', () => { + it('Successfully gets unassigned rules', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + const unassignedRules = await RulesService.getUnassignedRules(ruleset1.rulesetId, organization); + + expect(unassignedRules.length).toBe(3); + expect(unassignedRules.map((r) => r.ruleCode)).toContain('T'); + expect(unassignedRules.map((r) => r.ruleCode)).toContain('T2'); + expect(unassignedRules.map((r) => r.ruleCode)).toContain('T2.1'); + }); + + it('Returns only rules with no team assignments', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule, leafRule1, leafRule2 } = await setupRules(car); + + const teamType = await createTestTeamType('TestTeamType', orgId); + const team = await createTestTeam(admin.userId, teamType.teamTypeId, orgId); + await prisma.rule.update({ + where: { ruleId: topLevelRule.ruleId }, + data: { + teams: { connect: { teamId: team.teamId } } + } + }); + + const unassignedRules = await RulesService.getUnassignedRules(ruleset1.rulesetId, organization); + + expect(unassignedRules.length).toBe(2); + expect(unassignedRules.map((r) => r.ruleId)).not.toContain(topLevelRule.ruleId); + expect(unassignedRules.map((r) => r.ruleId)).toContain(leafRule1.ruleId); + expect(unassignedRules.map((r) => r.ruleId)).toContain(leafRule2.ruleId); + }); + + it('Returns only non-deleted rules', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule, leafRule1, leafRule2 } = await setupRules(car); + + // Delete one rule + await prisma.rule.update({ + where: { ruleId: leafRule1.ruleId }, + data: { dateDeleted: new Date(), deletedBy: { connect: { userId: admin.userId } } } + }); + + const unassignedRules = await RulesService.getUnassignedRules(ruleset1.rulesetId, organization); + + expect(unassignedRules.length).toBe(2); + expect(unassignedRules.map((r) => r.ruleId)).not.toContain(leafRule1.ruleId); + expect(unassignedRules.map((r) => r.ruleId)).toContain(topLevelRule.ruleId); + expect(unassignedRules.map((r) => r.ruleId)).toContain(leafRule2.ruleId); + }); + + it('Returns rules ordered by ruleCode ascending', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + // Create additional rules with different codes + await prisma.rule.create({ + data: { + ruleCode: 'A.1', + ruleContent: 'Rule A', + imageFileIds: [], + ruleset: { connect: { rulesetId: ruleset1.rulesetId } }, + createdBy: { connect: { userId: admin.userId } } + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'Z.1', + ruleContent: 'Rule Z', + imageFileIds: [], + ruleset: { connect: { rulesetId: ruleset1.rulesetId } }, + createdBy: { connect: { userId: admin.userId } } + } + }); + + const unassignedRules = await RulesService.getUnassignedRules(ruleset1.rulesetId, organization); + + expect(unassignedRules.length).toBe(5); + // Check that rules are sorted by ruleCode + for (let i = 0; i < unassignedRules.length - 1; i++) { + expect(unassignedRules[i].ruleCode <= unassignedRules[i + 1].ruleCode).toBe(true); + } + }); + + it('Returns empty array when all rules are assigned to teams', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule, leafRule1, leafRule2 } = await setupRules(car); + + // Create a team and assign all rules to it + const teamType = await createTestTeamType('TestTeamType', orgId); + const team = await createTestTeam(admin.userId, teamType.teamTypeId, orgId); + await prisma.rule.updateMany({ + where: { rulesetId: ruleset1.rulesetId }, + data: {} + }); + + await prisma.rule.update({ + where: { ruleId: topLevelRule.ruleId }, + data: { + teams: { connect: { teamId: team.teamId } } + } + }); + + await prisma.rule.update({ + where: { ruleId: leafRule1.ruleId }, + data: { + teams: { connect: { teamId: team.teamId } } + } + }); + + await prisma.rule.update({ + where: { ruleId: leafRule2.ruleId }, + data: { + teams: { connect: { teamId: team.teamId } } + } + }); + + const unassignedRules = await RulesService.getUnassignedRules(ruleset1.rulesetId, organization); + + expect(unassignedRules.length).toBe(0); + }); + + it('Returns empty array when all rules are deleted', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule, leafRule1, leafRule2 } = await setupRules(car); + + // Delete all rules + await prisma.rule.update({ + where: { ruleId: topLevelRule.ruleId }, + data: { dateDeleted: new Date(), deletedBy: { connect: { userId: admin.userId } } } + }); + + await prisma.rule.update({ + where: { ruleId: leafRule1.ruleId }, + data: { dateDeleted: new Date(), deletedBy: { connect: { userId: admin.userId } } } + }); + + await prisma.rule.update({ + where: { ruleId: leafRule2.ruleId }, + data: { dateDeleted: new Date(), deletedBy: { connect: { userId: admin.userId } } } + }); + + const unassignedRules = await RulesService.getUnassignedRules(ruleset1.rulesetId, organization); + + expect(unassignedRules.length).toBe(0); + }); + + it('Fails when ruleset does not exist', async () => { + await expect(RulesService.getUnassignedRules('fake-ruleset-id', organization)).rejects.toThrow( + new NotFoundException('Ruleset', 'fake-ruleset-id') + ); + }); + + it('Fails when ruleset is deleted', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + // Deactivate the ruleset before deleting + await prisma.ruleset.update({ + where: { rulesetId: ruleset1.rulesetId }, + data: { active: false } + }); + + await RulesService.deleteRuleset(ruleset1.rulesetId, admin.userId, organization.organizationId); + + await expect(RulesService.getUnassignedRules(ruleset1.rulesetId, organization)).rejects.toThrow( + new DeletedException('Ruleset', ruleset1.rulesetId) + ); + }); + + it('Returns rules with parent and subRules correctly transformed', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule, leafRule1 } = await setupRules(car); + + const unassignedRules = await RulesService.getUnassignedRules(ruleset1.rulesetId, organization); + + const topRule = unassignedRules.find((r) => r.ruleId === topLevelRule.ruleId); + const leafRule = unassignedRules.find((r) => r.ruleId === leafRule1.ruleId); + + expect(topRule).toBeDefined(); + expect(topRule?.parentRule).toBeUndefined(); + expect(topRule?.subRuleIds).toContain(leafRule1.ruleId); + + expect(leafRule).toBeDefined(); + expect(leafRule?.parentRule?.ruleId).toBe(topLevelRule.ruleId); + expect(leafRule?.parentRule?.ruleCode).toBe(topLevelRule.ruleCode); + }); + }); + + describe('Get unassigned Rules - unassigned to project', () => { + it('fails if ruleset is in the wrong org', async () => { + const car = await createUniqueCar(orgId); + const otherOrgRulesetType = await prisma.ruleset_Type.create({ + data: { + name: 'Other Org FHE', + createdByUserId: admin.userId, + organizationId: otherOrg.organizationId + } + }); + const otherRuleset: Ruleset = await prisma.ruleset.create({ + data: { + name: '2024', + fileId: 'other-fhe-2024', + active: true, + rulesetTypeId: otherOrgRulesetType.rulesetTypeId, + carId: car.carId, + createdByUserId: admin.userId + } + }); + await expect( + RulesService.getUnassignedRulesForRuleset( + otherRuleset.rulesetId, + testTeam.teamId, + project.projectId, + organization.organizationId + ) + ).rejects.toThrow(InvalidOrganizationException); + }); + it('fails if team is in the wrong org', async () => { + const car = await createUniqueCar(orgId); + const otherTeam = await prisma.team.create({ + data: { + teamName: 'Other Team', + slackId: 'other-slack', + headId: admin.userId, + organizationId: otherOrg.organizationId + } + }); + const { ruleset1 } = await setupRules(car); + await expect( + 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, + 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', + project.projectId, + organization.organizationId + ) + ).rejects.toThrow(new NotFoundException('Team', 'fake-team-id')); + }); + it('successfully returns rules in the team that have no projects', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule, leafRule1, leafRule2 } = await setupRules(car); + // add rules to the team + await prisma.rule.update({ + where: { ruleId: topLevelRule.ruleId }, + data: { + teams: { + connect: { teamId: testTeam.teamId } + } + } + }); + await prisma.rule.update({ + where: { ruleId: leafRule1.ruleId }, + data: { + teams: { + connect: { teamId: testTeam.teamId } + } + } + }); + // rule in the team that has a project + const ruleWithProject = await prisma.rule.create({ + data: { + ruleCode: 'T.1.3', + ruleContent: 'Rule with project', + imageFileIds: [], + rulesetId: ruleset1.rulesetId, + createdByUserId: admin.userId, + teams: { + connect: { teamId: testTeam.teamId } + } + } + }); + await prisma.project_Rule.create({ + data: { + projectId: project.projectId, + ruleId: ruleWithProject.ruleId, + createdByUserId: admin.userId + } + }); + const rules = await RulesService.getUnassignedRulesForRuleset( + ruleset1.rulesetId, + testTeam.teamId, + project.projectId, + organization.organizationId + ); + expect(rules.length).toEqual(2); + expect(rules[0].ruleCode).toEqual('T'); + expect(rules[1].ruleCode).toEqual('T2'); + // leafRule2 is not in the team so should not be returned + expect(rules.find((r) => r.ruleCode === leafRule2.ruleCode)).toBeUndefined(); + // ruleWithProject has a project so should not be returned + expect(rules.find((r) => r.ruleCode === ruleWithProject.ruleCode)).toBeUndefined(); + }); + it('successfully returns empty if team has no assigned rules', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + const rules = await RulesService.getUnassignedRulesForRuleset( + ruleset1.rulesetId, + testTeam.teamId, + project.projectId, + organization.organizationId + ); + expect(rules).toEqual([]); + }); + }); + + describe('Get Project Rules', () => { + it('Successfully gets all project rules for a project', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + + const projectRules = await RulesService.getProjectRules(topLevelRule.rulesetId, projectRule.projectId, organization); + + expect(projectRules.length).toBe(1); + expect(projectRules[0].projectRuleId).toBe(projectRule.projectRuleId); + expect(projectRules[0].rule.ruleId).toBe(topLevelRule.ruleId); + }); + + it('Get project rules returns empty array if no project rules exist for the project', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + + const projectRules = await RulesService.getProjectRules(topLevelRule.rulesetId, project.projectId, organization); + expect(projectRules.length).toBe(0); + }); + + it('Get project rules fails if project is deleted', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + await prisma.project.update({ + where: { projectId: project.projectId }, + data: { + wbsElement: { + update: { dateDeleted: new Date() } + } + } + }); + + await expect( + async () => await RulesService.getProjectRules(topLevelRule.rulesetId, project.projectId, organization) + ).rejects.toThrow(new DeletedException('Project', project.projectId)); + }); + + it('Get project rules fails if ruleset does not exist', async () => { + await expect( + async () => await RulesService.getProjectRules('fake-ruleset-id', project.projectId, organization) + ).rejects.toThrow(new NotFoundException('Ruleset', 'fake-ruleset-id')); + }); + + it('Get project rules fails if project does not exist', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + + await expect( + async () => await RulesService.getProjectRules(topLevelRule.rulesetId, 'fake-project-id', organization) + ).rejects.toThrow(new NotFoundException('Project', 'fake-project-id')); + }); + + it('Get project rules fails if ruleset is deleted', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + await prisma.ruleset.update({ + where: { rulesetId: topLevelRule.rulesetId }, + data: { dateDeleted: new Date(), deletedBy: { connect: { userId: admin.userId } } } + }); + + await expect( + async () => await RulesService.getProjectRules(topLevelRule.rulesetId, project.projectId, organization) + ).rejects.toThrow(new DeletedException('Ruleset', topLevelRule.rulesetId)); + }); + }); + + describe('Get Team Rules in Ruleset Type', () => { + let team: Team; + let otherTeam: Team; + let activeRuleset: Ruleset; + let inactiveRuleset: Ruleset; + + beforeEach(async () => { + const car = await createUniqueCar(orgId); + + activeRuleset = await prisma.ruleset.create({ + data: { + name: 'FSAE Rules 2025', + fileId: 'fsae-rules-2025', + active: true, + car: { connect: { carId: car.carId } }, + createdBy: { connect: { userId: admin.userId } }, + rulesetType: { connect: { rulesetTypeId: fsaeRulesetType.rulesetTypeId } } + } + }); + + inactiveRuleset = await prisma.ruleset.create({ + data: { + name: 'FSAE Rules 2024', + fileId: 'fsae-rules-2024', + active: false, + car: { connect: { carId: car.carId } }, + createdBy: { connect: { userId: admin.userId } }, + rulesetType: { connect: { rulesetTypeId: fsaeRulesetType.rulesetTypeId } } + } + }); + + const teamType = await createTestTeamType(undefined, orgId); + team = await createTestTeam(admin.userId, teamType.teamTypeId, orgId); + const otherTeamHead = await createTestUser(batmanAppAdmin, orgId); + otherTeam = await createTestTeam(otherTeamHead.userId, teamType.teamTypeId, orgId); + }); + + it('Succeeds and returns rules assigned to team in active ruleset', async () => { + const rule1 = await RulesService.createRule(admin, 'T.1.1', 'Rule 1', activeRuleset.rulesetId, organization); + const rule2 = await RulesService.createRule(admin, 'T.1.2', 'Rule 2', activeRuleset.rulesetId, organization); + const rule3 = await RulesService.createRule(admin, 'T.1.3', 'Rule 3', activeRuleset.rulesetId, organization); + + await prisma.rule.update({ + where: { ruleId: rule1.ruleId }, + data: { teams: { connect: { teamId: team.teamId } } } + }); + await prisma.rule.update({ + where: { ruleId: rule2.ruleId }, + data: { teams: { connect: { teamId: team.teamId } } } + }); + + const rules = await RulesService.getTeamRulesInRulesetType(team.teamId, fsaeRulesetType.rulesetTypeId, organization); + + expect(rules.length).toBe(2); + expect(rules.map((r) => r.ruleId)).toContain(rule1.ruleId); + expect(rules.map((r) => r.ruleId)).toContain(rule2.ruleId); + expect(rules.map((r) => r.ruleId)).not.toContain(rule3.ruleId); + }); + + it('Fails when no active ruleset exists', async () => { + await prisma.ruleset.update({ + where: { rulesetId: activeRuleset.rulesetId }, + data: { active: false } + }); + + await expect( + RulesService.getTeamRulesInRulesetType(team.teamId, fsaeRulesetType.rulesetTypeId, organization) + ).rejects.toThrow(new NotFoundException('Active Ruleset for given Ruleset Type', activeRuleset.rulesetTypeId)); + }); + + it('Only returns rules from active ruleset, not inactive', async () => { + const activeRule = await RulesService.createRule(admin, 'T.1.1', 'Active Rule', activeRuleset.rulesetId, organization); + const inactiveRule = await RulesService.createRule( + admin, + 'T.2.1', + 'Inactive Rule', + inactiveRuleset.rulesetId, + organization + ); + + await prisma.rule.update({ + where: { ruleId: activeRule.ruleId }, + data: { teams: { connect: { teamId: team.teamId } } } + }); + await prisma.rule.update({ + where: { ruleId: inactiveRule.ruleId }, + data: { teams: { connect: { teamId: team.teamId } } } + }); + + const rules = await RulesService.getTeamRulesInRulesetType(team.teamId, fsaeRulesetType.rulesetTypeId, organization); + + expect(rules.length).toBe(1); + expect(rules[0].ruleId).toBe(activeRule.ruleId); + }); + + it('Does not return deleted rules', async () => { + const rule1 = await RulesService.createRule(admin, 'T.1.1', 'Rule 1', activeRuleset.rulesetId, organization); + const rule2 = await RulesService.createRule(admin, 'T.1.2', 'Rule 2', activeRuleset.rulesetId, organization); + + await prisma.rule.update({ + where: { ruleId: rule1.ruleId }, + data: { teams: { connect: { teamId: team.teamId } } } + }); + await prisma.rule.update({ + where: { ruleId: rule2.ruleId }, + data: { teams: { connect: { teamId: team.teamId } } } + }); + + await RulesService.deleteRule(rule1.ruleId, admin, organization); + + const rules = await RulesService.getTeamRulesInRulesetType(team.teamId, fsaeRulesetType.rulesetTypeId, organization); + + expect(rules.length).toBe(1); + expect(rules[0].ruleId).toBe(rule2.ruleId); + }); + + it('Only returns rules assigned to the specified team', async () => { + const rule1 = await RulesService.createRule(admin, 'T.1.1', 'Rule 1', activeRuleset.rulesetId, organization); + const rule2 = await RulesService.createRule(admin, 'T.1.2', 'Rule 2', activeRuleset.rulesetId, organization); + + await prisma.rule.update({ + where: { ruleId: rule1.ruleId }, + data: { teams: { connect: { teamId: team.teamId } } } + }); + await prisma.rule.update({ + where: { ruleId: rule2.ruleId }, + data: { teams: { connect: { teamId: otherTeam.teamId } } } + }); + + const rules = await RulesService.getTeamRulesInRulesetType(team.teamId, fsaeRulesetType.rulesetTypeId, organization); + + expect(rules.length).toBe(1); + expect(rules[0].ruleId).toBe(rule1.ruleId); + }); + + it('Fails if ruleset type does not exist', async () => { + await expect( + RulesService.getTeamRulesInRulesetType(team.teamId, 'fake-ruleset-type-id', organization) + ).rejects.toThrow(new NotFoundException('Ruleset Type', 'fake-ruleset-type-id')); + }); + + it('Fails if ruleset type is deleted', async () => { + await RulesService.deleteRulesetType(admin, fsaeRulesetType.rulesetTypeId, organization); + + await expect( + RulesService.getTeamRulesInRulesetType(team.teamId, fsaeRulesetType.rulesetTypeId, organization) + ).rejects.toThrow(new DeletedException('Ruleset Type', fsaeRulesetType.rulesetTypeId)); + }); + + it('Fails if team does not exist', async () => { + await expect( + RulesService.getTeamRulesInRulesetType('fake-team-id', fsaeRulesetType.rulesetTypeId, organization) + ).rejects.toThrow(new NotFoundException('Team', 'fake-team-id')); + }); + }); + + describe('Get Top Level Rules', () => { + it('Successful get all rules with no parent id', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule } = await setupRules(car); + + const rules = await RulesService.getTopLevelRules(ruleset1.rulesetId, organization.organizationId); + + expect(rules.length).toEqual(1); + expect(rules[0].ruleCode).toEqual('T'); + expect(rules[0].ruleId).toEqual(topLevelRule.ruleId); + }); + + it('Gets multiple top level rules', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + await prisma.rule.create({ + data: { + ruleCode: 'A', + ruleContent: 'PART A - ADMINISTRATIVE REQUIREMENTS', + imageFileIds: [], + dateCreated: new Date(), + ruleset: { connect: { rulesetId: ruleset1.rulesetId } }, + createdBy: { connect: { userId: admin.userId } } + } + }); + + const rules = await RulesService.getTopLevelRules(ruleset1.rulesetId, organization.organizationId); + + expect(rules.length).toEqual(2); + expect(rules.map((r) => r.ruleCode).sort()).toEqual(['A', 'T']); + }); + + it('Returns empty array when no top level rules exist', async () => { + const car = await createUniqueCar(orgId); + const ruleset = await prisma.ruleset.create({ + data: { + name: 'Empty Ruleset', + fileId: 'empty-ruleset', + active: true, + dateCreated: new Date(), + car: { connect: { carId: car.carId } }, + createdBy: { connect: { userId: admin.userId } }, + rulesetType: { connect: { rulesetTypeId: fsaeRulesetType.rulesetTypeId } } + } + }); + + const rules = await RulesService.getTopLevelRules(ruleset.rulesetId, organization.organizationId); + expect(rules.length).toEqual(0); + }); + + it('Does not return child rules', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule, leafRule1, leafRule2 } = await setupRules(car); + const rules = await RulesService.getTopLevelRules(ruleset1.rulesetId, organization.organizationId); + + expect(rules.length).toEqual(1); + expect(rules[0].ruleId).toEqual(topLevelRule.ruleId); + expect(rules.find((r) => r.ruleId === leafRule1.ruleId)).toBeUndefined(); + expect(rules.find((r) => r.ruleId === leafRule2.ruleId)).toBeUndefined(); + }); + + it('Does not return deleted top level rules', async () => { + const carr = await createUniqueCar(orgId); + const { ruleset1, topLevelRule } = await setupRules(carr); + + await prisma.rule.update({ + where: { ruleId: topLevelRule.ruleId }, + data: { + dateDeleted: new Date(), + deletedByUserId: admin.userId + } + }); + + const rules = await RulesService.getTopLevelRules(ruleset1.rulesetId, organization.organizationId); + expect(rules.length).toEqual(0); + }); + }); + + describe('Get Ruleset Type', () => { + it('Successfully gets a ruleset type by ID', async () => { + const rulesetType = await RulesService.getRulesetType(fsaeRulesetType.rulesetTypeId, organization.organizationId); + expect(rulesetType).toBeDefined(); + expect(rulesetType.rulesetTypeId).toBe(fsaeRulesetType.rulesetTypeId); + expect(rulesetType.name).toBe(fsaeRulesetType.name); + }); + }); +}); diff --git a/src/frontend/src/apis/rules.api.ts b/src/frontend/src/apis/rules.api.ts new file mode 100644 index 0000000000..e4797fb297 --- /dev/null +++ b/src/frontend/src/apis/rules.api.ts @@ -0,0 +1,226 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import axios from '../utils/axios'; +import { ProjectRule, Rule as SharedRule, RulesetType, Ruleset } from 'shared'; +import { apiUrls } from '../utils/urls'; +import { CreateRulesetPayload, ParseRulesetPayload, CreateRulePayload } from '../hooks/rules.hooks'; +import { + projectRuleTransformer, + rulesetTransformer, + rulesetTypeTransformer, + ruleTransformer +} from './transformers/rules.transformers'; + +/** + * Gets a ruleset by its ID + */ +export const getRulesetById = (rulesetId: string) => { + return axios.get(apiUrls.rulesetById(rulesetId), { + transformResponse: (data) => JSON.parse(data) + }); +}; + +/** + * Gets a single ruleset by ID (dashboard usage) + */ +export const getSingleRuleset = (rulesetId: string) => { + return axios.get(apiUrls.singleRuleset(rulesetId), { + transformResponse: (data) => rulesetTransformer(JSON.parse(data)) + }); +}; + +/** + * Toggles team assignment for a rule + */ +export const toggleRuleTeam = (ruleId: string, teamId: string) => { + return axios.post(apiUrls.rulesToggleTeam(ruleId), { teamId }); +}; + +/** + * Gets all rules assigned to a team for a specific ruleset type + */ +export const getTeamRulesInRulesetType = (rulesetTypeId: string, teamId: string) => { + return axios.get(apiUrls.rulesTeamRulesInRulesetType(rulesetTypeId, teamId)); +}; + +/** + * Creates a new ruleset type + */ +export const createRulesetType = (payload: { name: string }) => { + return axios.post(apiUrls.rulesetTypeCreate(), payload); +}; + +/** + * Creates a new rule + * + * @param payload the data for creating the rule + * @returns the created rule + */ +export const createRule = (payload: CreateRulePayload) => { + return axios.post(apiUrls.ruleCreate(), { ...payload }); +}; + +/** + * Fetches all Ruleset Types for the current organization. + * + * @returns A list of Ruleset Types. + */ +export const getAllRulesetTypes = () => { + return axios.get(apiUrls.rulesetTypes(), { + transformResponse: (data) => JSON.parse(data).map(rulesetTypeTransformer) + }); +}; + +/** + * Gets the active ruleset for a given ruleset type. + */ +export const getActiveRuleset = (rulesetTypeId: string) => { + return axios.get(apiUrls.rulesGetActiveRuleset(rulesetTypeId), { + transformResponse: (data) => rulesetTransformer(JSON.parse(data)) + }); +}; + +/** + * Gets all project rules for a given ruleset and project. + */ +export const getProjectRules = (rulesetId: string, projectId: string) => { + return axios.get(apiUrls.rulesGetProjectRules(rulesetId, projectId), { + transformResponse: (data) => JSON.parse(data).map(projectRuleTransformer) + }); +}; + +/** + * Gets unassigned rules for a ruleset and team. + */ +export const getUnassignedRulesForRuleset = (rulesetId: string, teamId: string, projectId: string) => { + return axios.get(apiUrls.rulesGetUnassignedRulesForRuleset(rulesetId, teamId, projectId), { + transformResponse: (data) => JSON.parse(data).map(ruleTransformer) + }); +}; + +/** + * Creates a project rule + */ +export const createProjectRule = (ruleId: string, projectId: string) => { + return axios.post(apiUrls.rulesCreateProjectRule(), { ruleId, projectId }); +}; + +/** + * Deletes a project rule + */ +export const deleteProjectRule = (projectRuleId: string) => { + return axios.post(apiUrls.rulesDeleteProjectRule(projectRuleId)); +}; + +/** + * 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 setRuleCompletion = (ruleId: string, isComplete: boolean, projectId?: string) => { + return axios.post(apiUrls.rulesSetRuleCompletion(ruleId), { isComplete, projectId }); +}; + +/** + * Gets child rules + */ +export const getChildRules = (ruleId: string) => { + return axios.get(apiUrls.rulesChildRules(ruleId), { + transformResponse: (data) => JSON.parse(data).map(ruleTransformer) + }); +}; + +/** + * Gets top-level rules + */ +export const getTopLevelRules = (rulesetId: string) => { + return axios.get(apiUrls.rulesTopLevel(rulesetId), { + transformResponse: (data) => JSON.parse(data).map(ruleTransformer) + }); +}; + +/** + * Fetch rulesets by type + */ +export const getRulesetsByRulesetType = (rulesetTypeId: string) => { + return axios.get(apiUrls.rulesetsByType(rulesetTypeId), { + transformResponse: (data) => JSON.parse(data) + }); +}; + +/** + * Deletes a rule + */ +export const deleteRule = (ruleId: string) => { + return axios.post(apiUrls.rulesDelete(ruleId)); +}; + +/** + * Edits a rule's content + * @param ruleId - The ID of the rule to edit + * @param ruleContent - The new content for the rule + */ +export const editRule = (ruleId: string, ruleContent: string) => { + return axios.post(apiUrls.rulesEdit(ruleId), { ruleContent }); +}; + +/** + * Updates a rulesets active status + */ +export const updateRuleset = (rulesetId: string, name: string, isActive: boolean) => { + return axios.post(apiUrls.rulesetUpdate(rulesetId), { name, isActive }); +}; + +/** + * Deletes a ruleset + */ +export const deleteRuleset = (rulesetId: string) => { + return axios.post(apiUrls.rulesetDelete(rulesetId)); +}; + +/** + * Deletes a ruleset type + */ +export const deleteRulesetType = (rulesetTypeId: string) => { + return axios.post(apiUrls.rulesetTypeDelete(rulesetTypeId)); +}; + +/** + * Gets a ruleset type given its ID + */ +export const getRulesetType = (rulesetTypeId: string) => { + return axios.get(apiUrls.rulesetType(rulesetTypeId)); +}; + +/** + * Creates a new ruleset + */ +export const createRuleset = (payload: CreateRulesetPayload) => { + return axios.post(apiUrls.rulesetsCreate(), payload); +}; + +/** + * Parses a ruleset PDF + */ +export const parseRuleset = (payload: ParseRulesetPayload) => { + return axios.post(apiUrls.parseRuleset(payload.rulesetId), { + fileId: payload.fileId, + parserType: payload.parserType + }); +}; + +/** + * Upload ruleset PDF file + */ +export const uploadRulesetFile = (file: File) => { + const formData = new FormData(); + formData.append('file', file); + + return axios.post(apiUrls.uploadRulesetFile(), formData, { + transformResponse: (data) => JSON.parse(data) + }); +}; diff --git a/src/frontend/src/apis/transformers/rules.transformers.ts b/src/frontend/src/apis/transformers/rules.transformers.ts new file mode 100644 index 0000000000..1c5545e2f7 --- /dev/null +++ b/src/frontend/src/apis/transformers/rules.transformers.ts @@ -0,0 +1,61 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { ProjectRule, Rule, RulesetType, Ruleset } from 'shared'; + +/** + * Transforms a rule to proper field types. + * + * @param rule Incoming rule object + * @returns Properly transformed rule object. + */ +export const ruleTransformer = (rule: Rule): Rule => { + return { + ...rule, + subRuleIds: rule.subRuleIds || [], + referencedRuleIds: rule.referencedRuleIds || [] + }; +}; + +/** + * Transforms a project rule (support Date objects) + * + * @param projectRule Incoming project rule object + * @returns Properly transformed project rule object. + */ +export const projectRuleTransformer = (projectRule: ProjectRule): ProjectRule => { + return { + ...projectRule, + rule: ruleTransformer(projectRule.rule) + }; +}; + +/** + * Transforms a ruleset type (support Date objects) + * + * @param rulesetType Incoming ruleset type object + * @returns Properly transformed ruleset type object. + */ +export const rulesetTypeTransformer = (rulesetType: RulesetType): RulesetType => { + return { + ...rulesetType, + lastUpdated: new Date(rulesetType.lastUpdated), + revisionFiles: rulesetType.revisionFiles || [] + }; +}; + +/** + * Transforms a ruleset (support Date objects) + * + * @param ruleset Incoming ruleset object + * @returns Properly transformed ruleset object. + */ +export const rulesetTransformer = (ruleset: Ruleset): Ruleset => { + return { + ...ruleset, + dateCreated: new Date(ruleset.dateCreated), + rulesetType: rulesetTypeTransformer(ruleset.rulesetType) + }; +}; diff --git a/src/frontend/src/app/AppAuthenticated.tsx b/src/frontend/src/app/AppAuthenticated.tsx index f4228acb34..fb2bc27043 100644 --- a/src/frontend/src/app/AppAuthenticated.tsx +++ b/src/frontend/src/app/AppAuthenticated.tsx @@ -11,6 +11,7 @@ import { PageNotFound } from '../pages/PageNotFound'; import Home from '../pages/HomePage/Home'; import Settings from '../pages/SettingsPage/SettingsPage'; import InfoPage from '../pages/InfoPage'; +import Rules from '../pages/RulesPage/Rules'; import GanttChartPage from '../pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage'; import Teams from '../pages/TeamsPage/Teams'; import AdminTools from '../pages/AdminToolsPage/AdminTools'; @@ -82,6 +83,7 @@ const AppAuthenticated: React.FC = ({ userId, userRole }) + diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts new file mode 100644 index 0000000000..d24c97cfb0 --- /dev/null +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -0,0 +1,558 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { ProjectRule, Rule as SharedRule, Ruleset, RulesetType } from 'shared'; +import { + createRulesetType, + getAllRulesetTypes, + getActiveRuleset, + getProjectRules, + getUnassignedRulesForRuleset, + createProjectRule, + deleteProjectRule, + setRuleCompletion, + getChildRules, + getTopLevelRules, + toggleRuleTeam, + getTeamRulesInRulesetType, + parseRuleset, + uploadRulesetFile, + getRulesetsByRulesetType, + deleteRule, + editRule, + updateRuleset, + deleteRuleset, + deleteRulesetType, + createRuleset, + getRulesetById, + createRule, + getSingleRuleset, + getRulesetType +} from '../apis/rules.api'; +import { useToast } from './toasts.hooks'; + +/** + * Hook to supply all ruleset types. + */ +export const useAllRulesetTypes = () => { + return useQuery(['rules', 'rulesetTypes'], async () => { + const { data } = await getAllRulesetTypes(); + return data; + }); +}; + +/** + * Hook to get the active ruleset for a given ruleset type. + */ +export const useActiveRuleset = (rulesetTypeId: string) => { + return useQuery( + ['rules', 'activeRuleset', rulesetTypeId], + async () => { + try { + const { data } = await getActiveRuleset(rulesetTypeId); + return data; + } catch { + // Return undefined if no active ruleset exists + return undefined; + } + }, + { enabled: !!rulesetTypeId } + ); +}; + +/** + * Hook to get all project rules for a given ruleset and project. + */ +export const useProjectRules = (rulesetId: string, projectId: string) => { + return useQuery( + ['rules', 'projectRules', rulesetId, projectId], + async () => { + const { data } = await getProjectRules(rulesetId, projectId); + return data; + }, + { enabled: !!rulesetId && !!projectId } + ); +}; + +/** + * Hook to get unassigned rules for a ruleset and team. + */ +export const useUnassignedRulesForRuleset = (rulesetId: string, teamId: string, projectId: string) => { + return useQuery( + ['rules', 'unassigned', rulesetId, teamId, projectId], + async () => { + const { data } = await getUnassignedRulesForRuleset(rulesetId, teamId, projectId); + return data; + }, + { enabled: !!rulesetId && !!teamId && !!projectId } + ); +}; + +/** + * Hook to get child rules of a rule. + */ +export const useChildRules = (ruleId: string) => { + return useQuery( + ['rules', 'children', ruleId], + async () => { + const { data } = await getChildRules(ruleId); + return data; + }, + { enabled: !!ruleId } + ); +}; + +/** + * Hook to get top-level rules for a ruleset. + */ +export const useTopLevelRules = (rulesetId: string) => { + return useQuery( + ['rules', 'topLevel', rulesetId], + async () => { + const { data } = await getTopLevelRules(rulesetId); + return data; + }, + { enabled: !!rulesetId } + ); +}; + +interface CreateRulesetTypePayload { + name: string; +} + +export interface ParseRulesetPayload { + rulesetId: string; + fileId: string; + parserType: 'FSAE' | 'FHE'; +} + +export interface CreateRulesetPayload { + fileId: string; + name: string; + rulesetTypeId: string; + carNumber: number; + active: boolean; +} + +export interface CreateRulePayload { + ruleCode: string; + ruleContent: string; + rulesetId: string; + parentRuleId?: string; + referencedRules?: string[]; + imageFileIds?: string[]; +} + +export const useGetTopLevelRules = (rulesetId: string) => { + return useQuery(['rules', 'top-level', rulesetId], async () => { + const { data } = await getTopLevelRules(rulesetId); + return data; + }); +}; + +export const useGetChildRules = (ruleId: string, enabled: boolean = true) => { + return useQuery( + ['rules', 'children', ruleId], + async () => { + const { data } = await getChildRules(ruleId); + return data; + }, + { + enabled // only fetch when true + } + ); +}; + +/** + * Hook to get a ruleset by ID. + * (Kept because some parts of the app may still call getRulesetById) + */ +export const useGetRuleset = (rulesetId: string) => { + return useQuery( + ['ruleset', rulesetId], + async () => { + const { data } = await getRulesetById(rulesetId); + return data; + }, + { enabled: !!rulesetId } + ); +}; + +/** + * Hook to get a single ruleset by ID (kept for compatibility with feature branch usage) + */ +export const useSingleRuleset = (rulesetId: string) => { + return useQuery( + ['rules', 'ruleset', rulesetId], + async () => { + const { data } = await getSingleRuleset(rulesetId); + return data; + }, + { enabled: !!rulesetId } + ); +}; + +export const useToggleRuleTeam = () => { + const queryClient = useQueryClient(); + return useMutation( + ['rules', 'toggle-team'], + async ({ ruleId, teamId }) => { + const { data } = await toggleRuleTeam(ruleId, teamId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rules']); + } + } + ); +}; + +/** + * Hook to toggle multiple rule-team assignments in bulk + * Processes each toggle sequentially and returns aggregate results + */ +export const useBulkToggleRuleTeam = () => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation< + { successful: number; failed: number; errors: string[] }, + Error, + Array<{ ruleId: string; teamId: string }> + >( + ['rules', 'bulk-toggle-team'], + async (toggles) => { + let successful = 0; + let failed = 0; + const errors: string[] = []; + + for (const { ruleId, teamId } of toggles) { + try { + await toggleRuleTeam(ruleId, teamId); + successful++; + } catch (error) { + failed++; + errors.push(`Failed to toggle rule ${ruleId}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + return { successful, failed, errors }; + }, + { + onSuccess: (result) => { + queryClient.invalidateQueries(['rules']); + + if (result.failed > 0) { + toast.error(`${result.failed} assignment(s) failed to save. ${result.successful} succeeded.`); + } else if (result.successful > 0) { + toast.success(`Successfully saved ${result.successful} assignment change(s)`); + } + }, + onError: (error: Error) => { + toast.error(`Failed to save assignments: ${error.message}`); + } + } + ); +}; + +export const useGetTeamRulesInRulesetType = (rulesetTypeId: string, teamId: string) => { + return useQuery(['rules', 'team-rules', rulesetTypeId, teamId], async () => { + const { data } = await getTeamRulesInRulesetType(rulesetTypeId, teamId); + return data; + }); +}; + +/** + * Hook to create a new ruleset type. + */ +export const useCreateRulesetType = () => { + const queryClient = useQueryClient(); + return useMutation( + ['rulesetTypes', 'create'], + async (payload: CreateRulesetTypePayload) => { + const { data } = await createRulesetType(payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rules', 'rulesetTypes']); + } + } + ); +}; + +/** + * Custom React Hook to create a new rule + */ +export const useCreateRule = () => { + const queryClient = useQueryClient(); + return useMutation( + ['rules', 'create'], + async (payload: CreateRulePayload) => { + const { data } = await createRule(payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rules']); + queryClient.invalidateQueries(['ruleset']); + } + } + ); +}; + +/** + * Hook to create a project rule (assign a rule to a project). + */ +export const useCreateProjectRule = () => { + const queryClient = useQueryClient(); + return useMutation( + ['rules', 'projectRules', 'create'], + async ({ ruleId, projectId: pId }) => { + const { data } = await createProjectRule(ruleId, pId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rules', 'projectRules']); + queryClient.invalidateQueries(['rules', 'unassigned']); + } + } + ); +}; + +/** + * Hook to delete a project rule. + */ +export const useDeleteProjectRule = (rulesetId: string, projectId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['rules', 'projectRules', 'delete'], + async (projectRuleId: string) => { + const { data } = await deleteProjectRule(projectRuleId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rules', 'projectRules', rulesetId, projectId]); + queryClient.invalidateQueries(['rules', 'unassigned']); + } + } + ); +}; + +/** + * Hook to set a rule's completion. Completion is global to the rule. + */ +export const useSetRuleCompletion = (rulesetId: string, projectId: string) => { + const queryClient = useQueryClient(); + 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']); + queryClient.invalidateQueries(['rules', 'allRules', rulesetId]); + } + } + ); +}; + +/** + * React Query hook to fetch all Rulesets for a specific Ruleset Type. + * + * @param rulesetTypeId The ID of the ruleset type. + * @returns Query result containing Rulesets data, loading state, and error state. + */ +export const useRulesetsByType = (rulesetTypeId: string) => { + return useQuery(['rulesets', rulesetTypeId], async () => { + const { data } = await getRulesetsByRulesetType(rulesetTypeId); + return data; + }); +}; + +/** + * React Query hook to delete a rule + */ +export const useDeleteRule = () => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation( + ['rules', 'delete'], + async (ruleId: string) => { + await deleteRule(ruleId); + }, + { + onSuccess: () => { + toast.success('Rule deleted successfully'); + queryClient.invalidateQueries(['rules']); + queryClient.invalidateQueries(['rulesets']); + }, + onError: (error: Error) => { + toast.error(error.message); + } + } + ); +}; + +/** + * React Query hook to edit a rule's content + */ +export const useEditRule = () => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation( + ['rules', 'edit'], + async ({ ruleId, ruleContent }) => { + const { data } = await editRule(ruleId, ruleContent); + return data; + }, + { + onSuccess: () => { + toast.success('Rule updated successfully'); + queryClient.invalidateQueries(['rules']); + queryClient.invalidateQueries(['rulesets']); + }, + onError: (error: Error) => { + toast.error(`Failed to update rule: ${error.message}`); + } + } + ); +}; + +export const useUpdateRuleset = () => { + const queryClient = useQueryClient(); + return useMutation( + async ({ rulesetId, name, isActive }) => { + const { data } = await updateRuleset(rulesetId, name, isActive); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rulesets']); + } + } + ); +}; + +export const useDeleteRuleset = () => { + const queryClient = useQueryClient(); + return useMutation( + async (rulesetId: string) => { + await deleteRuleset(rulesetId); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rulesets']); + } + } + ); +}; + +export const useDeleteRulesetType = () => { + const queryClient = useQueryClient(); + return useMutation( + async (rulesetTypeId: string) => { + await deleteRulesetType(rulesetTypeId); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rulesetTypes']); + } + } + ); +}; + +export const useRulesetType = (rulesetTypeId: string) => { + return useQuery(['rulesetType', rulesetTypeId], async () => { + const { data } = await getRulesetType(rulesetTypeId); + return data; + }); +}; +export const useCreateRuleset = () => { + const queryClient = useQueryClient(); + return useMutation( + ['rulesets', 'create'], + async (payload: CreateRulesetPayload) => { + const { data } = await createRuleset(payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rulesets']); + } + } + ); +}; + +export const useParseRuleset = () => { + const queryClient = useQueryClient(); + return useMutation( + ['rulesets', 'parse'], + async (payload: ParseRulesetPayload) => { + const { data } = await parseRuleset(payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rules']); + queryClient.invalidateQueries(['rulesets']); + } + } + ); +}; + +/** + * Uploads a file to the drive and returns the fileId + */ +export const useUploadRulesetFile = () => { + return useMutation(['ruleset-file', 'upload'], async (file: File) => { + const { data } = await uploadRulesetFile(file); + return data; + }); +}; + +/** + * Helper function to recursively fetch all child rules + */ +const fetchAllChildRules = async (rule: SharedRule, allRules: SharedRule[]): Promise => { + if (rule.subRuleIds.length === 0) return; + + const { data: children } = await getChildRules(rule.ruleId); + allRules.push(...children); + + for (const child of children) { + await fetchAllChildRules(child, allRules); + } +}; + +/** + * Hook to get all rules for a ruleset by fetching top-level rules + * and recursively fetching all children + */ +export const useAllRulesForRuleset = (rulesetId: string) => { + return useQuery( + ['rules', 'allRules', rulesetId], + async () => { + const { data: topLevelRules } = await getTopLevelRules(rulesetId); + const allRules: SharedRule[] = [...topLevelRules]; + + for (const rule of topLevelRules) { + await fetchAllChildRules(rule, allRules); + } + + return allRules; + }, + { enabled: !!rulesetId } + ); +}; diff --git a/src/frontend/src/layouts/Sidebar/Sidebar.tsx b/src/frontend/src/layouts/Sidebar/Sidebar.tsx index b65a213a61..735adb53ba 100644 --- a/src/frontend/src/layouts/Sidebar/Sidebar.tsx +++ b/src/frontend/src/layouts/Sidebar/Sidebar.tsx @@ -17,6 +17,7 @@ import GroupIcon from '@mui/icons-material/Group'; import QuestionMarkIcon from '@mui/icons-material/QuestionMark'; import AttachMoneyIcon from '@mui/icons-material/AttachMoney'; import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; +import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; import NavPageLink from './NavPageLink'; import NERDrawer from '../../components/NERDrawer'; import NavUserMenu from '../PageTitle/NavUserMenu'; @@ -163,6 +164,11 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid icon: , route: routes.SPONSORS }, + { + name: 'Rules', + icon: , + route: routes.RULES + }, { name: 'Info', icon: , diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddProjectRuleModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddProjectRuleModal.tsx new file mode 100644 index 0000000000..35188edc4e --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddProjectRuleModal.tsx @@ -0,0 +1,251 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { useState, useMemo } from 'react'; +import { + Box, + Typography, + CircularProgress, + Alert, + FormControl, + Select, + MenuItem, + SelectChangeEvent, + IconButton, + useTheme +} from '@mui/material'; +import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import { Rule } from 'shared'; +import NERModal from '../../../../components/NERModal'; +import { useUnassignedRulesForRuleset } from '../../../../hooks/rules.hooks'; + +interface AddRuleModalProps { + open: boolean; + onHide: () => void; + rulesetId: string; + teamId: string; + teamName: string; + projectId: string; + onSubmit: (ruleIds: string[]) => void; +} + +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, projectId); + + type ParentInfo = { ruleId: string; ruleCode: string }; + + const uniqueParents = useMemo(() => { + if (!unassignedRules) return []; + const parentMap = new Map(); + unassignedRules.forEach((rule: Rule) => { + if (rule.parentRule) { + parentMap.set(rule.parentRule.ruleId, rule.parentRule); + } + }); + return Array.from(parentMap.values()).sort((a, b) => a.ruleCode.localeCompare(b.ruleCode)); + }, [unassignedRules]); + + const [selectedParentId, setSelectedParentId] = useState(''); + + const availableRules = useMemo(() => { + if (!unassignedRules || !selectedParentId) return []; + return unassignedRules.filter((rule: Rule) => rule.parentRule?.ruleId === selectedParentId); + }, [unassignedRules, selectedParentId]); + + const handleParentChange = (event: SelectChangeEvent) => { + setSelectedParentId(event.target.value); + setSelectedRuleIds([]); + }; + + const handleRuleSelect = (event: SelectChangeEvent) => { + const ruleId = event.target.value; + if (ruleId && !selectedRuleIds.includes(ruleId)) { + setSelectedRuleIds((prev) => [...prev, ruleId]); + } + }; + + const handleRemoveRule = (ruleId: string) => { + setSelectedRuleIds((prev) => prev.filter((id) => id !== ruleId)); + }; + + const handleSubmit = () => { + onSubmit(selectedRuleIds); + resetForm(); + onHide(); + }; + + const handleClose = () => { + resetForm(); + onHide(); + }; + + const resetForm = () => { + setSelectedRuleIds([]); + setSelectedParentId(''); + }; + + // Get rule display name + const getRuleName = (ruleId: string): string => { + const rule = unassignedRules?.find((r: Rule) => r.ruleId === ruleId); + return rule ? rule.ruleCode : ruleId; + }; + + // Dropdown styling + const selectStyles = { + backgroundColor: theme.palette.action.hover, + borderRadius: '8px', + color: theme.palette.text.primary, + '& .MuiSelect-select': { + py: 1.5, + px: 2.5 + }, + '& .MuiOutlinedInput-notchedOutline': { + border: 'none' + }, + '& .MuiSvgIcon-root': { + color: theme.palette.text.primary + } + }; + + const labelStyles = { + color: theme.palette.primary.main, + textDecoration: 'underline', + fontSize: '2rem', + mb: '10px' + }; + + // Selected rule row styling + const selectedRuleStyles = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: theme.palette.action.hover, + borderRadius: '8px', + px: 2.5, + py: 1.5, + mb: 1.5 + }; + + return ( + + + {isError ? ( + Failed to load rules + ) : isLoading ? ( + + + + ) : !unassignedRules || unassignedRules.length === 0 ? ( + + No unassigned rules available for the {teamName} team. + + ) : ( + + {/* Select Section */} + + + Select Section + + + + + + + {/* Select Rules */} + + + Select Rules + + + {/* Selected Rules */} + {selectedRuleIds.map((ruleId) => ( + + + handleRemoveRule(ruleId)} + sx={{ color: theme.palette.text.primary, p: 0.5, mr: 1 }} + > + + + {getRuleName(ruleId)} + + + + ))} + + {/* Add Subtask dropdown */} + + + + + + )} + + + ); +}; + +export default AddRuleModal; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx new file mode 100644 index 0000000000..20006e7bec --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -0,0 +1,389 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { useState, useMemo } from 'react'; +import { + Box, + Button, + Typography, + CircularProgress, + Alert, + Tab, + Tabs as MuiTabs, + Table, + TableBody, + TableContainer, + Paper, + useTheme, + IconButton, + Tooltip +} from '@mui/material'; +import { Project, ProjectRule, Rule } from 'shared'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import ErrorPage from '../../../ErrorPage'; +import RuleRow from '../../../RulesPage/RuleRow'; +import UpdateStatusPopover from './UpdateStatusPopover'; +import AddRuleModal from './AddProjectRuleModal'; +import { + useAllRulesetTypes, + useActiveRuleset, + useProjectRules, + useSetRuleCompletion, + useCreateProjectRule +} from '../../../../hooks/rules.hooks'; +import { useToast } from '../../../../hooks/toasts.hooks'; +import { InfoOutlined } from '@mui/icons-material'; +import { useHistory } from 'react-router-dom'; +import { routes } from '../../../../utils/routes'; +import RuleStatusTag from '../../../RulesPage/components/RuleStatusTag'; + +interface ProjectRulesTabProps { + project: Project; +} + +export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { + const toast = useToast(); + const theme = useTheme(); + const history = useHistory(); + + // State for modals and popovers + const [selectedRulesetTypeIndex, setSelectedRulesetTypeIndex] = useState(0); + const [statusPopoverAnchor, setStatusPopoverAnchor] = useState(null); + const [addRuleModalOpen, setAddRuleModalOpen] = useState(false); + const [selectedProjectRule, setSelectedProjectRule] = useState(null); + + // Fetch all ruleset types + const { data: rulesetTypes, isLoading: rulesetTypesLoading, isError: rulesetTypesError } = useAllRulesetTypes(); + + // Get the currently selected ruleset type + const selectedRulesetType = rulesetTypes?.[selectedRulesetTypeIndex]; + + // Fetch the active ruleset for the selected ruleset type + const { data: activeRuleset, isLoading: activeRulesetLoading } = useActiveRuleset( + selectedRulesetType?.rulesetTypeId || '' + ); + + // Fetch project rules for the active ruleset + const { + data: projectRules, + isLoading: projectRulesLoading, + isError: projectRulesError + } = useProjectRules(activeRuleset?.rulesetId || '', project.id); + + // Mutations + const { mutateAsync: setCompletionMutation, isLoading: isUpdatingStatus } = useSetRuleCompletion( + activeRuleset?.rulesetId || '', + project.id + ); + + const { mutateAsync: createProjectRuleMutation, isLoading: isCreating } = useCreateProjectRule(); + + // Get the first team's ID for fetching unassigned rules + const teamId = project.teams[0]?.teamId || ''; + + // Convert project rules to rules + const allRules = useMemo(() => { + if (!projectRules) return []; + return projectRules.map((pr) => pr.rule); + }, [projectRules]); + + // Get top-level rules (rules without a parent) + const topLevelRules = useMemo(() => { + return allRules.filter((rule) => !rule.parentRule); + }, [allRules]); + + // Handle completion update + const handleStatusUpdate = async (ruleId: string, isComplete: boolean) => { + try { + await setCompletionMutation({ ruleId, isComplete, projectId: project.id }); + toast.success('Rule completion updated successfully'); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + // Handle add rules + const handleAddRules = async (ruleIds: string[]) => { + try { + for (const ruleId of ruleIds) { + await createProjectRuleMutation({ ruleId, projectId: project.id }); + } + toast.success(`${ruleIds.length} rule${ruleIds.length !== 1 ? 's' : ''} added successfully`); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + // Handle opening status popover + const handleStatusClick = (event: React.MouseEvent, rule: Rule) => { + const projectRule = projectRules?.find((pr) => pr.rule.ruleId === rule.ruleId); + if (projectRule) { + // Only allow status updates for leaf rules + const hasChildren = allRules.some((r) => r.parentRule?.ruleId === rule.ruleId); + if (!hasChildren) { + setSelectedProjectRule(projectRule); + setStatusPopoverAnchor(event.currentTarget); + } + } + }; + + // Handle closing status popover + const handleStatusPopoverClose = () => { + setStatusPopoverAnchor(null); + setSelectedProjectRule(null); + }; + + // Handle tab change + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setSelectedRulesetTypeIndex(newValue); + }; + + if (rulesetTypesError) { + return ; + } + + if (rulesetTypesLoading) { + return ; + } + + // No ruleset types + if (!rulesetTypes || rulesetTypes.length === 0) { + return ( + + + No ruleset types configured for this organization. + + + ); + } + + // Check if we have no active ruleset + const hasNoActiveRuleset = !activeRulesetLoading && !activeRuleset; + + // Right content for rule rows - status badge. Leaf rules are clickable to open + // the completion popover; parents show an aggregated, read-only status. + const renderRightContent = (rule: Rule) => { + const isLeafRule = !allRules.some((r) => r.parentRule?.ruleId === rule.ruleId); + const isPopoverOpenForRule = Boolean(statusPopoverAnchor) && selectedProjectRule?.rule.ruleId === rule.ruleId; + + return ( + handleStatusClick(e, rule) : undefined} + /> + ); + }; + + const backgroundColor = theme.palette.background.default; + const tableBackgroundColor = theme.palette.background.paper; + const tableTextColor = theme.palette.text.primary; + const tableHoverColor = theme.palette.action.hover; + + return ( + + {/* Ruleset Type Tabs */} + + + {rulesetTypes.map((rulesetType, idx) => ( + + ))} + + + + e.stopPropagation()} + sx={{ + padding: '5px', + color: 'text.secondary' + }} + > + + + + + + + + {/* Active ruleset name for this ruleset type */} + {activeRuleset && ( + + {activeRuleset.name} + + )} + + {/* Rules Content */} + {projectRulesError ? ( + Failed to load rules + ) : activeRulesetLoading || projectRulesLoading ? ( + + + + ) : hasNoActiveRuleset ? ( + + + No active ruleset configured for this ruleset type. + + + ) : topLevelRules.length === 0 ? ( + + + No rules assigned to this project yet. + + + ) : ( + + + + + {topLevelRules.map((rule) => ( + + ))} + +
+
+
+ )} + + {/* Add Rule Button */} + + + + + + + + {/* Update Status Popover */} + {selectedProjectRule && ( + + )} + + {/* Add Rule Modal */} + {activeRuleset && teamId && ( + setAddRuleModalOpen(false)} + rulesetId={activeRuleset.rulesetId} + teamId={teamId} + teamName={project.teams[0]?.teamName ?? ''} + projectId={project.id} + onSubmit={handleAddRules} + /> + )} + + {/* Loading overlay */} + {(isUpdatingStatus || isCreating) && ( + + + + )} + + ); +}; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx new file mode 100644 index 0000000000..1437603c9a --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx @@ -0,0 +1,81 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { Box, Checkbox, FormControlLabel, Popover, Typography } from '@mui/material'; +import { Rule } from 'shared'; + +interface UpdateStatusPopoverProps { + anchorEl: HTMLElement | null; + onClose: () => void; + rule: Rule; + onStatusChange: (ruleId: string, isComplete: boolean) => void; +} + +const UpdateStatusPopover = ({ anchorEl, onClose, rule, onStatusChange }: UpdateStatusPopoverProps) => { + const open = Boolean(anchorEl); + + const handleStatusChange = (isComplete: boolean) => { + onStatusChange(rule.ruleId, isComplete); + onClose(); + }; + + const statusOptions = [ + { value: true, label: 'Complete' }, + { value: false, label: 'Incomplete' } + ]; + + return ( + + + {statusOptions.map((option) => ( + handleStatusChange(option.value)} + sx={{ + color: 'white', + '&.Mui-checked': { + color: 'white' + }, + p: 0.5 + }} + /> + } + label={{option.label}} + sx={{ + display: 'flex', + m: 0, + py: 0.5 + }} + /> + ))} + + + ); +}; + +export default UpdateStatusPopover; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx index 91433f3c76..b20907c679 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx @@ -36,6 +36,7 @@ import PartsReviewPage from './PartReview/PartsReviewPage'; import ActionsMenu from '../../../components/ActionsMenu'; import ProjectSpendingHistory from '../../ProjectPage/ProjectSpendingHistory'; import { useMyTeamAsHead } from '../../../hooks/teams.hooks'; +import { ProjectRulesTab } from './ProjectRules/ProjectRulesTab'; interface ProjectViewContainerProps { project: Project; @@ -225,7 +226,8 @@ const ProjectViewContainer: React.FC = ({ project, en { tabUrlValue: 'gantt', tabName: 'Gantt' }, { tabUrlValue: 'change-requests', tabName: 'Change Requests' }, { tabUrlValue: 'parts-review', tabName: 'Parts Review' }, - { tabUrlValue: 'spending', tabName: 'Budget' } + { tabUrlValue: 'spending', tabName: 'Budget' }, + { tabUrlValue: 'rules', tabName: 'Rules' } ]} baseUrl={`${routes.PROJECTS}/${wbsNum}`} defaultTab="overview" @@ -250,8 +252,10 @@ const ProjectViewContainer: React.FC = ({ project, en ) : tab === 7 ? ( - ) : ( + ) : tab === 8 ? ( + ) : ( + )} {deleteModalShow && ( diff --git a/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx b/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx new file mode 100644 index 0000000000..b1e00811ac --- /dev/null +++ b/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx @@ -0,0 +1,352 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { + Box, + Chip, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + Typography, + useTheme +} from '@mui/material'; +import { useState, useEffect } from 'react'; +import { Rule, TeamPreview } from 'shared'; +import { useAllTeams } from '../../hooks/teams.hooks'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import ErrorPage from '../ErrorPage'; +import { useHistory, useLocation, useParams } from 'react-router-dom'; +import { routes } from '../../utils/routes'; +import { useToast } from '../../hooks/toasts.hooks'; +import { NERButton } from '../../components/NERButton'; +import RuleRow from './RuleRow'; +import { useBulkToggleRuleTeam } from '../../hooks/rules.hooks'; + +/* + * Props for the assign rules tab. + */ +interface AssignRulesTabProps { + rules: Rule[]; +} + +const getLeafRuleIds = (ruleId: string, allRules: Rule[]): string[] => { + const rule = allRules.find((r) => r.ruleId === ruleId); + if (!rule) { + return []; + } + + if (rule.subRuleIds.length === 0) { + return [ruleId]; + } + + return rule.subRuleIds.flatMap((subId) => getLeafRuleIds(subId, allRules)); +}; + +/* + * Props for the team row. + */ +interface TeamRowProps { + team: TeamPreview; + isSelected: boolean; + onClick: () => void; +} + +/** + * Row component for displaying a team in the teams table. + */ +const TeamRow: React.FC = ({ team, isSelected, onClick }) => { + return ( + + + {team.teamName} + + + ); +}; + +/** + * Tab component for assigning rules to teams. + * Displays teams and rules side-by-side for selection. + */ +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()); + const [originalAssignments, setOriginalAssignments] = useState>(new Set()); + const [isInitialized, setIsInitialized] = useState(false); + + const { data: teams, isLoading: teamsLoading, isError: teamsError, error: teamsErrorData } = useAllTeams(); + const { mutate: bulkToggle, isLoading: isSaving } = useBulkToggleRuleTeam(); + + // Load initial team assignments from rule data + useEffect(() => { + if (isInitialized || !teams || teams.length === 0) return; + + const initialAssignments = new Set(); + rules.forEach((rule) => { + rule.teams?.forEach((team) => { + initialAssignments.add(`${team.teamId}:${rule.ruleId}`); + }); + }); + + 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, location.search]); + + const handleTeamSelect = (teamId: string) => setSelectedTeamId(teamId); + + const isRuleAssigned = (ruleId: string) => { + if (!selectedTeamId) return false; + return assignments.has(`${selectedTeamId}:${ruleId}`); + }; + + const getAssignedTeamNames = (ruleId: string): string[] => { + if (!teams) return []; + const assignedTeamIds = [...assignments].filter((key) => key.endsWith(`:${ruleId}`)).map((key) => key.split(':')[0]); + return teams.filter((t) => assignedTeamIds.includes(t.teamId)).map((t) => t.teamName); + }; + + const renderTeamTags = (ruleId: string) => { + const teamNames = getAssignedTeamNames(ruleId); + if (teamNames.length === 0) return null; + return ( + + {teamNames.map((name) => ( + + ))} + + ); + }; + + const handleRuleToggle = (ruleId: string) => { + if (!selectedTeamId) { + toast.error('Please select a team first'); + return; + } + + const leafIds = getLeafRuleIds(ruleId, rules); + if (leafIds.length === 0) { + return; + } + + const newAssignments = new Set(assignments); + let allSelected = true; + for (const id of leafIds) { + if (!newAssignments.has(`${selectedTeamId}:${id}`)) { + allSelected = false; + break; + } + } + + for (const id of leafIds) { + const key = `${selectedTeamId}:${id}`; + if (allSelected) { + newAssignments.delete(key); + } else { + newAssignments.add(key); + } + } + + setAssignments(newAssignments); + }; + + const handleSaveAndExit = () => { + const toAdd = [...assignments].filter((key) => !originalAssignments.has(key)); + const toRemove = [...originalAssignments].filter((key) => !assignments.has(key)); + + if (toAdd.length === 0 && toRemove.length === 0) { + toast.info('No changes to save'); + return; + } + + // Build array of toggles to execute + const toggles: Array<{ ruleId: string; teamId: string }> = []; + + toAdd.forEach((key) => { + const [teamId, ruleId] = key.split(':'); + toggles.push({ ruleId, teamId }); + }); + + toRemove.forEach((key) => { + const [teamId, ruleId] = key.split(':'); + toggles.push({ ruleId, teamId }); + }); + + // Execute bulk toggle and navigate on success + bulkToggle(toggles, { + onSuccess: () => { + history.push(routes.RULESET_EDIT.replace(':rulesetId', rulesetId)); + } + }); + }; + + if (teamsError) { + return ; + } + + if (teamsLoading) { + return ; + } + + const topLevelRules = rules.filter((rule) => !rule.parentRule); + + return ( + + + {/* Teams Column */} + + + Teams: + + + + + {teams?.map((team) => ( + handleTeamSelect(team.teamId)} + /> + ))} + +
+
+
+ + {/* Rules Column */} + + + Rules: + + + + + {topLevelRules.map((rule) => ( + { + const leafIds = getLeafRuleIds(r.ruleId, rules); + const isSelected = leafIds.length > 0 && leafIds.every((id) => isRuleAssigned(id)); + return isSelected ? '#b36b6b' : '#CECECE'; + }} + hoverColor={(r) => { + const leafIds = getLeafRuleIds(r.ruleId, rules); + const isSelected = leafIds.length > 0 && leafIds.every((id) => isRuleAssigned(id)); + return isSelected ? '#a05858' : '#5e5e5e'; + }} + textColor="#000000" + onRowClick={(r) => handleRuleToggle(r.ruleId)} + middleContent={() => null} + rightContent={(r) => renderTeamTags(r.ruleId)} + verticalPadding="8px" + leftWidth="70%" + middleWidth="0%" + rightWidth="30%" + /> + ))} + +
+
+
+
+ + {/* Save & Exit Button */} + + + + + {isSaving ? 'Saving...' : 'Save & Exit'} + + + + + ); +}; + +export default AssignRulesTab; diff --git a/src/frontend/src/pages/RulesPage/RuleActions.tsx b/src/frontend/src/pages/RulesPage/RuleActions.tsx new file mode 100644 index 0000000000..a8ea66c07a --- /dev/null +++ b/src/frontend/src/pages/RulesPage/RuleActions.tsx @@ -0,0 +1,62 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { Box, IconButton } from '@mui/material'; +import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; +import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; +import EditIcon from '@mui/icons-material/Edit'; + +interface RuleActionsProps { + ruleId: string; + onAdd: (ruleId: string, anchorEl: HTMLElement) => void; + onRemove: (ruleId: string) => void; + onEdit: (ruleId: string) => void; + iconColor?: string; +} + +/** + * RuleActions component for displaying actions for a rule. + * Supports adding, removing, and editing a rule. + */ +const RuleActions: React.FC = ({ ruleId, onAdd, onRemove, onEdit, iconColor = '#000000' }) => { + return ( + + { + e.stopPropagation(); + onAdd(ruleId, e.currentTarget); + }} + sx={{ padding: 0.25, color: iconColor }} + > + + + + { + e.stopPropagation(); + onRemove(ruleId); + }} + sx={{ padding: 0.25, color: iconColor }} + > + + + + { + e.stopPropagation(); + onEdit(ruleId); + }} + sx={{ padding: 0.25, color: iconColor }} + > + + + + ); +}; + +export default RuleActions; diff --git a/src/frontend/src/pages/RulesPage/RuleRow.tsx b/src/frontend/src/pages/RulesPage/RuleRow.tsx new file mode 100644 index 0000000000..58799b7b75 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/RuleRow.tsx @@ -0,0 +1,237 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { TableCell, TableRow, Box } from '@mui/material'; +import { useState } from 'react'; +import { Rule } from 'shared'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import { useGetChildRules } from '../../hooks/rules.hooks'; + +interface RuleRowProps { + rule: Rule; + allRules?: Rule[]; + level?: number; + leftContent?: (rule: Rule, level: number, isExpanded: boolean, hasSubRules: boolean) => React.ReactNode; + middleContent?: (rule: Rule, level: number) => React.ReactNode; + rightContent: (rule: Rule, level: number) => React.ReactNode; + backgroundColor: string | ((rule: Rule) => string); + textColor: string | ((rule: Rule) => string); + hoverColor: string | ((rule: Rule) => string); + onRowClick?: (rule: Rule) => void; + rowHeight?: string; + verticalPadding?: string; + horizontalPadding?: string; + leftWidth?: string; + 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; +} + +/** + * Recursive component for rendering a rule row in a rules table. + * Supports expand/collapsing of rules with sub-rules. + */ +const RuleRow: React.FC = ({ + rule, + allRules, + level = 0, + leftContent, + middleContent, + rightContent, + backgroundColor, + textColor, + hoverColor, + onRowClick, + rowHeight, + verticalPadding = '12px', + horizontalPadding = '16px', + leftWidth = '10%', + middleWidth = '80%', + rightWidth = '10%', + initiallyExpanded = false, + indentRow = false, + indentWidth = 10 +}) => { + const [isExpanded, setIsExpanded] = useState(initiallyExpanded); + + // 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 = presentSubRules ?? fetchedSubRules; + + const bgColor = typeof backgroundColor === 'function' ? backgroundColor(rule) : backgroundColor; + const color = typeof textColor === 'function' ? textColor(rule) : textColor; + const hoverBgColor = typeof hoverColor === 'function' ? hoverColor(rule) : hoverColor; + + const toggleExpand = () => hasSubRules && setIsExpanded(!isExpanded); + + const handleChevronClick = (e: React.MouseEvent) => { + e.stopPropagation(); + toggleExpand(); + }; + + const handleRowClick = () => { + if (onRowClick) { + onRowClick(rule); + } + }; + + const commonCellStyles = { + fontSize: '16px', + padding: `${verticalPadding} ${horizontalPadding}`, + backgroundColor: 'inherit', + borderBottom: 'none', + 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 = ( + + {hasSubRules && ( + + )} + {rule.ruleCode} + + ); + + return ( + <> + + + {leftContent ? leftContent(rule, level, isExpanded, hasSubRules) : defaultLeftContent} + + + {middleContent + ? middleContent(rule, level) + : rule.ruleContent && {rule.ruleContent}} + + + {rightContent(rule, level)} + + + {isExpanded && + hasSubRules && + subRules.map((subRule) => ( + + ))} + + ); +}; + +export default RuleRow; diff --git a/src/frontend/src/pages/RulesPage/Rules.tsx b/src/frontend/src/pages/RulesPage/Rules.tsx new file mode 100644 index 0000000000..3ac570f131 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/Rules.tsx @@ -0,0 +1,20 @@ +// switch route page for rules +import { Route, Switch } from 'react-router-dom'; +import { routes } from '../../utils/routes'; +import RulesetTypePage from './RulesetTypePage'; +import RulesetPage from './RulesetPage'; +import RulesetEditPage from './RulesetEditPage'; +import RulesetViewPage from './RulesetViewPage'; + +const RulesPage: React.FC = () => { + return ( + + + + + + + ); +}; + +export default RulesPage; diff --git a/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx b/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx new file mode 100644 index 0000000000..bd7992f551 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx @@ -0,0 +1,370 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { Box, Button, Paper, Table, TableBody, TableContainer, TextField, useTheme } from '@mui/material'; +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import PageLayout from '../../components/PageLayout'; +import FullPageTabs from '../../components/FullPageTabs'; +import { routes } from '../../utils/routes'; +import RuleRow from './RuleRow'; +import RuleActions from './RuleActions'; +import ErrorPage from '../ErrorPage'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import AddRuleSectionModal from './components/AddRuleSectionModal'; +import AddRuleModal from './components/AddRuleModal'; +import { AddRuleBox } from './components/AddRuleBox'; +import AssignRulesTab from './AssignRulesTab'; +import DeleteRuleModal from './components/DeleteRuleModal'; +import { useDeleteRule, useEditRule, useSingleRuleset, useAllRulesForRuleset } from '../../hooks/rules.hooks'; +import { countRulesToDelete } from '../../utils/rules.utils'; +import { Rule } from 'shared'; + +/** + * RulesetPage component for displaying and managing ruleset rules. + * Supports editing and assigning rules to projects and teams. + */ +const RulesetEditPage: React.FC = () => { + const { rulesetId } = useParams<{ rulesetId: string; tabValue?: string }>(); //why tab value?? + const [tabValue, setTabValue] = useState(0); + const defaultTab = 'edit-rules'; + + const [showAddMenu, setShowAddMenu] = useState(false); + const [addMenuAnchorEl, setAddMenuAnchorEl] = useState(null); + const [activeRuleId, setActiveRuleId] = useState(null); + + // temporary placeholder useState fns for the add rule section and add rule modals + const [showAddRuleSectionModal, setShowAddRuleSectionModal] = useState(false); + const [showAddRuleModal, setShowAddRuleModal] = useState(false); + + // Delete modal state + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [ruleToDelete, setRuleToDelete] = useState(null); + + // Editing state + const [editingRuleId, setEditingRuleId] = useState(null); + const [editedContent, setEditedContent] = useState(''); + + const theme = useTheme(); + + const { + data: ruleset, + isError: isRulesetError, + error: rulesetError, + isLoading: isRulesetLoading + } = useSingleRuleset(rulesetId!); + const { + data: allRules, + isError: isRulesError, + error: rulesError, + isLoading: isRulesLoading + } = useAllRulesForRuleset(rulesetId!); + const { mutateAsync: deleteRuleMutation } = useDeleteRule(); + const { mutateAsync: editRuleMutation } = useEditRule(); + + const tabs = [ + { tabUrlValue: 'edit-rules', tabName: 'Edit Rules' }, + { tabUrlValue: 'assign-rules', tabName: 'Assign Rules' } + ]; + + if (isRulesetError) { + return ; + } + + if (isRulesError) { + return ; + } + + if (isRulesetLoading || isRulesLoading || !ruleset || !allRules) { + return ; + } + + const handleAddRuleSection = () => { + setShowAddRuleSectionModal(true); + }; + + const handleOpenAddMenu = (ruleId: string, anchorEl: HTMLElement) => { + if (showAddMenu && addMenuAnchorEl === anchorEl) { + handleCloseAddMenu(); + return; + } + + setActiveRuleId(ruleId); + setAddMenuAnchorEl(anchorEl); + setShowAddMenu(true); + }; + + const handleCloseAddMenu = () => { + setShowAddMenu(false); + setAddMenuAnchorEl(null); + }; + + const handleAddRuleFromMenu = () => { + setShowAddRuleModal(true); + handleCloseAddMenu(); + }; + + const handleRemoveRule = (ruleId: string) => { + const rule = allRules.find((r) => r.ruleId === ruleId); + if (rule) { + setRuleToDelete(rule); + setDeleteModalOpen(true); + } + }; + + const handleDeleteConfirm = async () => { + if (!ruleToDelete) return; + + try { + await deleteRuleMutation(ruleToDelete.ruleId); + setDeleteModalOpen(false); + setRuleToDelete(null); + } catch (err) { + console.error('Failed to delete rule:', err); + } + }; + + const handleDeleteCancel = () => { + setDeleteModalOpen(false); + setRuleToDelete(null); + }; + + const handleEditRule = (ruleId: string) => { + const rule = allRules.find((r) => r.ruleId === ruleId); + if (rule) { + setEditingRuleId(ruleId); + setEditedContent(rule.ruleContent); + } + }; + + const handleSaveEdit = async () => { + if (!editingRuleId) return; + + try { + await editRuleMutation({ ruleId: editingRuleId, ruleContent: editedContent }); + setEditingRuleId(null); + setEditedContent(''); + } catch (err) { + console.error('Failed to update rule:', err); + } + }; + + const handleCancelEdit = () => { + setEditingRuleId(null); + setEditedContent(''); + }; + + const totalRulesToDelete = ruleToDelete ? countRulesToDelete(ruleToDelete, allRules) : 0; + + // Filter to only show top-level rules + const topLevelRules = allRules.filter((rule) => !rule.parentRule); + + return ( + + +
+ } + > + + {tabValue === 0 ? ( + + + + + {topLevelRules.map((rule) => ( + { + const isEditing = editingRuleId === currentRule.ruleId; + if (isEditing) { + return ( + setEditedContent(e.target.value)} + variant="outlined" + size="small" + autoFocus + sx={{ + backgroundColor: theme.palette.grey[100], + '& .MuiOutlinedInput-root': { + color: '#000000', + '& fieldset': { + borderColor: '#dd514c' + }, + '&:hover fieldset': { + borderColor: '#dd514c' + }, + '&.Mui-focused fieldset': { + borderColor: '#dd514c' + } + } + }} + /> + ); + } + return ( + currentRule.ruleContent && {currentRule.ruleContent} + ); + }} + rightContent={(currentRule) => ( + + )} + backgroundColor={(currentRule) => (editingRuleId === currentRule.ruleId ? '#c0c0c0' : '#9d9d9d')} + textColor="#000000" + hoverColor="#5e5e5e" + rowHeight="10px" + verticalPadding="5px" + /> + ))} + +
+
+ + + + setShowAddRuleSectionModal(false)} + rulesetId={rulesetId} + /> + + setShowAddRuleModal(false)} + rulesetId={rulesetId} + initialParentRuleId={activeRuleId || undefined} + /> + + {ruleToDelete && ( + + )} + + + + + {editingRuleId ? ( + <> + + + + ) : ( + + )} + + + + ) : ( + + )} +
+ + ); +}; + +export default RulesetEditPage; diff --git a/src/frontend/src/pages/RulesPage/RulesetPage.tsx b/src/frontend/src/pages/RulesPage/RulesetPage.tsx new file mode 100644 index 0000000000..b63fceb5ff --- /dev/null +++ b/src/frontend/src/pages/RulesPage/RulesetPage.tsx @@ -0,0 +1,128 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ +import { useParams } from 'react-router-dom'; +import React from 'react'; +import { useToast } from '../../hooks/toasts.hooks'; +import { useCreateRuleset, useDeleteRuleset, useParseRuleset } from '../../hooks/rules.hooks'; +import { NERButton } from '../../components/NERButton'; +import AddNewFileModal from './components/AddNewFileModal'; +import PageLayout from '../../components/PageLayout'; +import { Box } from '@mui/material'; +import RulesetTable from './components/RulesetTable'; +import { routes } from '../../utils/routes'; +import { useRulesetType } from '../../hooks/rules.hooks'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import ErrorPage from '../ErrorPage'; + +/** + * RulesetPage component for displaying and managing ruleset rules. + * Supports editing and assigning rules to projects and teams. + */ +const RulesetPage: React.FC = () => { + const { rulesetTypeId } = useParams<{ rulesetTypeId: string }>(); + + const { mutateAsync: createRuleset } = useCreateRuleset(); + const { mutateAsync: parseRuleset } = useParseRuleset(); + const { mutateAsync: deleteRuleset } = useDeleteRuleset(); + const toast = useToast(); + + const [AddFileModalShow, setAddFileModalShow] = React.useState(false); + const { data: rulesetType, isLoading, isError, error } = useRulesetType(rulesetTypeId); + + const handleFileConfirm = async (data: { fileId: string; name: string; carNumber: number; parserType: string }) => { + setAddFileModalShow(false); + toast.info('Creating ruleset and parsing rules...'); + + let createdRulesetId: string | null = null; + + try { + const ruleset = await createRuleset({ + fileId: data.fileId, + name: data.name, + rulesetTypeId, + carNumber: data.carNumber, + active: false + }); + const { rulesetId } = ruleset; + + if (!rulesetId) { + throw new Error('Error creating Ruleset'); + } + + createdRulesetId = rulesetId; + + const parsedRules = await parseRuleset({ + rulesetId, + fileId: data.fileId, + parserType: data.parserType as 'FSAE' | 'FHE' + }); + toast.success(`Successfully parsed ${parsedRules.length} rules!`); + } catch (e) { + if (createdRulesetId) { + try { + await deleteRuleset(createdRulesetId); + toast.error('Parsing failed. Ruleset has been removed. ' + (e instanceof Error ? e.message : 'Unknown error')); + } catch (deleteError) { + toast.error('Error during cleanup: ' + (deleteError instanceof Error ? deleteError.message : 'Unknown error')); + } + } else { + toast.error('Error creating ruleset: ' + (e instanceof Error ? e.message : 'Unknown error')); + } + } + }; + + if (isError) return ; + if (isLoading) return ; + + return ( + <> + + + + + + + + + {/* Add New File Button */} + setAddFileModalShow(!AddFileModalShow)}> + Add New File + + setAddFileModalShow(false)} + onFormSubmit={handleFileConfirm} + /> + + + + + + ); +}; + +export default RulesetPage; diff --git a/src/frontend/src/pages/RulesPage/RulesetTypePage.tsx b/src/frontend/src/pages/RulesPage/RulesetTypePage.tsx new file mode 100644 index 0000000000..3259a9bb1c --- /dev/null +++ b/src/frontend/src/pages/RulesPage/RulesetTypePage.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import PageLayout from '../../components/PageLayout'; +import { Box } from '@mui/material'; +import RulesetTypeTable from './components/RulesetTypeTable'; +import { NERButton } from '../../components/NERButton'; +import AddRulesetTypeModal from './components/AddRulesetTypeModal'; +import { useState } from 'react'; +import { useCreateRulesetType } from '../../hooks/rules.hooks'; + +const RulesetTypePage: React.FC = () => { + const [addRulesetTypeModalShow, setAddRulesetTypeModalShow] = useState(false); + + const { mutateAsync: createRulesetType } = useCreateRulesetType(); + + const handleAddRulesetTypeConfirm = async (data: { name: string }) => { + await createRulesetType({ name: data.name }); + }; + + const handleAddRulesetTypeCancel = () => { + setAddRulesetTypeModalShow(false); + }; + + return ( + <> + + + + + + + + + setAddRulesetTypeModalShow(!addRulesetTypeModalShow)}> + Add Ruleset Type + + + + + + + + ); +}; + +export default RulesetTypePage; diff --git a/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx new file mode 100644 index 0000000000..904e5809a4 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; +import FullPageTabs from '../../components/FullPageTabs'; +import PageLayout from '../../components/PageLayout'; +import { routes } from '../../utils/routes'; +import { Box } from '@mui/system'; +import { useParams } from 'react-router-dom'; +import ErrorPage from '../ErrorPage'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import RulesetGeneralView from './components/RulesetGeneralView'; +import { Rule } from 'shared'; +import RulesetTeamView, { TeamRules } from './components/RulesetTeamView'; +import { useSingleRuleset, useAllRulesForRuleset } from '../../hooks/rules.hooks'; + +/** + * Organizes rules by team and project assignments. + * Rules without team assignments are shown in the unassigned section. + */ +const getTeamOrganization = (allRules: Rule[]): { teamRules: TeamRules[]; unassignedToTeam: Rule[] } => { + const teamMap = new Map(); + const unassignedToTeam: Rule[] = []; + + // Iterate through all rules and organize by team + allRules.forEach((rule) => { + if (!rule.teams || rule.teams.length === 0) { + // Only add to unassigned if it's a top-level rule (no parent) + if (!rule.parentRule) { + unassignedToTeam.push(rule); + } + } else { + // Add rule to each assigned team (includes both parents and children) + rule.teams.forEach((team) => { + if (!teamMap.has(team.teamId)) { + teamMap.set(team.teamId, { + teamId: team.teamId, + teamName: team.teamName, + projects: [], + unassignedRules: [] + }); + } + + const teamRules = teamMap.get(team.teamId)!; + if (!rule.parentRule) { + teamRules.unassignedRules.push(rule); + } + }); + } + }); + + return { teamRules: Array.from(teamMap.values()), unassignedToTeam }; +}; + +const RulesetViewPage = () => { + const [tabIndex, setTabIndex] = useState(0); + const tabs = [ + { tabUrlValue: 'teamView', tabName: 'Team View' }, + { tabUrlValue: 'generalView', tabName: 'General View' } + ]; + + const { rulesetId } = useParams<{ rulesetId: string }>(); + + const { + data: ruleset, + isError: isRulesetError, + error: rulesetError, + isLoading: isRulesetLoading + } = useSingleRuleset(rulesetId!); + + const { + data: allRules, + isError: isRulesError, + error: rulesError, + isLoading: isRulesLoading + } = useAllRulesForRuleset(rulesetId!); + + if (isRulesetError) { + return ; + } + + if (isRulesError) { + return ; + } + + if (isRulesetLoading || isRulesLoading) { + return ; + } + + if (!ruleset || !allRules) { + return ; + } + + const { teamRules, unassignedToTeam } = getTeamOrganization(allRules); + + return ( + + + + + } + > + + {tabIndex === 0 ? ( + + ) : ( + + )} + + +
+ ); +}; + +export default RulesetViewPage; diff --git a/src/frontend/src/pages/RulesPage/components/AddNewFileModal.tsx b/src/frontend/src/pages/RulesPage/components/AddNewFileModal.tsx new file mode 100644 index 0000000000..8df5e0bd7d --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/AddNewFileModal.tsx @@ -0,0 +1,271 @@ +import NERFormModal from '../../../components/NERFormModal'; +import { useForm, Controller } from 'react-hook-form'; +import { Box, FormControl, TextField, Typography, FormLabel, FormHelperText, Button, Select, MenuItem } from '@mui/material'; +import { useEffect, useState } from 'react'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { FileUpload } from '@mui/icons-material'; +import { MAX_FILE_SIZE } from 'shared'; +import { useUploadRulesetFile } from '../../../hooks/rules.hooks'; +import { useGetAllCars } from '../../../hooks/cars.hooks'; + +interface AddNewFileModalProps { + open: boolean; + onHide: () => void; + onFormSubmit: (data: NewFileFormData) => Promise; +} + +interface NewFileFormData { + fileId: string; + name: string; + carNumber: number; + parserType: 'FSAE' | 'FHE'; +} + +interface ButtonGroupProps { + options: string[]; + value: string; + onChange: (value: string) => any; +} + +const sectionHeaderStyle = { + fontWeight: 'bold', + color: '#ef4345', + textDecoration: 'underline', + fontSize: '1rem', + textUnderlineOffset: '5px', + marginBottom: '10px' +}; + +const isPdf = (fileName: string) => { + const extension = fileName.split('.').pop()?.toLowerCase(); + return extension === 'pdf'; +}; + +const schema = yup.object({ + fileId: yup.string().required('File is required'), + name: yup.string().required('Name is required'), + carNumber: yup.number().min(0).required('Car is required'), + parserType: yup.string().oneOf(['FSAE', 'FHE']).required('Parser type is required') +}); + +const ButtonGroup: React.FC = ({ options, value, onChange }) => { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +}; + +const AddNewFileModal: React.FC = ({ open, onHide, onFormSubmit }) => { + const toast = useToast(); + const [file, setFile] = useState(null); + const [uploading, setUploading] = useState(false); + const { mutateAsync: uploadFile } = useUploadRulesetFile(); + const { data: cars, isLoading: carsLoading, isError: carsError } = useGetAllCars(); + + const { + formState: { errors }, + handleSubmit, + reset, + setValue, + control + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + fileId: '', + name: '', + carNumber: 100, + parserType: 'FSAE' + } + }); + + useEffect(() => { + if (cars && cars.length > 0) { + setValue('carNumber', cars[0].wbsNum.carNumber); + } + }, [cars, setValue]); + + const handleFormSubmit = async (data: NewFileFormData) => { + try { + await onFormSubmit(data); + setFile(null); + reset(); + onHide(); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + const handleFileUpload = async (e: React.ChangeEvent) => { + if (!e.target.files || !e.target.files[0]) { + return; + } + + const [selectedFile] = e.target.files; + + if (!isPdf(selectedFile.name)) { + const error = 'File must be a PDF'; + toast.error(error); + return; + } + + if (selectedFile.size > MAX_FILE_SIZE) { + const error = `File exceeds the maximum size limit of ${MAX_FILE_SIZE / (1024 * 1024)} MB`; + toast.error(error); + return; + } + + setUploading(true); + + try { + const fileId = await uploadFile(selectedFile); + setValue('fileId', fileId, { shouldValidate: true }); + setFile(selectedFile); + toast.success('File uploaded successfully'); + } catch (error: unknown) { + let errorMessage = 'File upload failed. Please try again.'; + if (error instanceof Error) { + errorMessage = error.message || errorMessage; + } + toast.error(errorMessage); + setFile(null); + setValue('fileId', '', { shouldValidate: false }); + } finally { + setUploading(false); + } + }; + + const handleModalClose = () => { + setFile(null); + reset(); + onHide(); + }; + + const handleReset = () => { + setFile(null); + reset(); + }; + + return ( + + + + + {/* File Upload */} + + Upload Ruleset File: + + {file && {file.name}} + {uploading && Uploading...} + + + {errors.fileId?.message} + + + {/* Car */} + + Car: + {carsLoading ? ( + Loading cars... + ) : carsError ? ( + Failed to load cars + ) : ( + ( + + )} + /> + )} + {errors.carNumber?.message} + + + {/* Parser Type */} + + Parser Type: + ( + onChange(val as 'FSAE' | 'FHE')} /> + )} + /> + {errors.parserType?.message} + + + + {/* Ruleset Name */} + + Name Ruleset File: + ( + + )} + /> + {errors.name?.message} + + + + + ); +}; + +export default AddNewFileModal; diff --git a/src/frontend/src/pages/RulesPage/components/AddRuleBox.tsx b/src/frontend/src/pages/RulesPage/components/AddRuleBox.tsx new file mode 100644 index 0000000000..3bb9eea306 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/AddRuleBox.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { Box, Popover, useTheme } from '@mui/material'; +import { NERButton } from '../../../components/NERButton'; + +type AddRuleBoxProps = { + open: boolean; + anchorEl: HTMLElement | null; + onClose: () => void; + onAddRule: () => void; +}; + +export const AddRuleBox: React.FC = ({ open, anchorEl, onClose, onAddRule }) => { + const theme = useTheme(); + + return ( + + + + + Add Rule + + + + ); +}; diff --git a/src/frontend/src/pages/RulesPage/components/AddRuleModal.tsx b/src/frontend/src/pages/RulesPage/components/AddRuleModal.tsx new file mode 100644 index 0000000000..830fcfddda --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/AddRuleModal.tsx @@ -0,0 +1,169 @@ +import { useState, useEffect } from 'react'; +import { Box, Typography, useTheme, TextField } from '@mui/material'; +import { useForm, Controller } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import NERFormModal from '../../../components/NERFormModal'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { useCreateRule } from '../../../hooks/rules.hooks'; + +interface AddRuleModalProps { + open: boolean; + onClose: () => void; + rulesetId: string; + initialParentRuleId?: string; +} + +interface FormData { + ruleCode: string; + ruleContent: string; +} + +const schema = yup.object().shape({ + ruleCode: yup.string().required('Rule Code is required'), + ruleContent: yup.string().required('Rule Content is required') +}); + +const AddRuleModal: React.FC = ({ open, onClose, rulesetId, initialParentRuleId }) => { + const theme = useTheme(); + const toast = useToast(); + const { mutateAsync: createRule } = useCreateRule(); + + // Track the hierarchy of selected REFERENCED rules (separate from parent) + const [selectedReferenceHierarchy, setSelectedReferenceHierarchy] = useState([]); + + const { + handleSubmit, + control, + reset, + formState: { errors } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + ruleCode: '', + ruleContent: '' + } + }); + + // Reset reference hierarchy when modal opens/closes or parent changes + useEffect(() => { + if (open) { + setSelectedReferenceHierarchy([]); + } + }, [open, initialParentRuleId]); + + const onSubmit = async (data: FormData) => { + try { + const referencedRules = + selectedReferenceHierarchy.length > 0 ? [selectedReferenceHierarchy[selectedReferenceHierarchy.length - 1]] : []; + + await createRule({ + ruleCode: data.ruleCode, + ruleContent: data.ruleContent, + rulesetId, + parentRuleId: initialParentRuleId, + referencedRules, + imageFileIds: [] + }); + + toast.success('Rule created successfully'); + handleClose(); + } catch (error) { + toast.error('Failed to create rule'); + } + }; + + const handleClose = () => { + setSelectedReferenceHierarchy([]); + onClose(); + }; + + const textFieldStyles = { + '& .MuiOutlinedInput-root': { + backgroundColor: theme.palette.action.hover, + borderRadius: '8px', + '& fieldset': { + border: 'none' + }, + '&:hover fieldset': { + border: 'none' + }, + '&.Mui-focused fieldset': { + border: 'none' + } + }, + '& .MuiInputBase-input': { + color: theme.palette.text.primary, + py: 1.5, + px: 2.5 + } + }; + + return ( + { + reset(); + setSelectedReferenceHierarchy([]); + }} + title="Add Rule" + handleUseFormSubmit={handleSubmit} + onFormSubmit={onSubmit} + formId="add-rule-form" + showCloseButton + > + + {/* Rule Code */} + + + Rule Code* + + ( + + )} + /> + + + {/* Rule Content */} + + + Rule Content* + + ( + + )} + /> + + + + ); +}; + +export default AddRuleModal; diff --git a/src/frontend/src/pages/RulesPage/components/AddRuleSectionModal.tsx b/src/frontend/src/pages/RulesPage/components/AddRuleSectionModal.tsx new file mode 100644 index 0000000000..3b21e2bf11 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/AddRuleSectionModal.tsx @@ -0,0 +1,123 @@ +import { Box, Typography, useTheme, TextField } from '@mui/material'; +import { useForm, Controller } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import NERFormModal from '../../../components/NERFormModal'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { useCreateRule } from '../../../hooks/rules.hooks'; + +interface AddRuleSectionModalProps { + open: boolean; + onClose: () => void; + rulesetId: string; +} + +interface FormData { + name: string; +} + +const schema = yup.object().shape({ + name: yup.string().required('Name is required') +}); + +const AddRuleSectionModal: React.FC = ({ open, onClose, rulesetId }) => { + const theme = useTheme(); + const toast = useToast(); + const { mutateAsync: createRule } = useCreateRule(); + + const { + handleSubmit, + control, + reset, + formState: { errors } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + name: '' + } + }); + + const onSubmit = async (data: FormData) => { + try { + await createRule({ + ruleCode: data.name, + ruleContent: 'content placeholder', + rulesetId, + referencedRules: [], + imageFileIds: [] + }); + + toast.success('Rule section created successfully'); + handleClose(); + } catch (error) { + toast.error('Failed to create rule section'); + } + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const textFieldStyles = { + '& .MuiOutlinedInput-root': { + backgroundColor: theme.palette.action.hover, + borderRadius: '8px', + '& fieldset': { + border: 'none' + }, + '&:hover fieldset': { + border: 'none' + }, + '&.Mui-focused fieldset': { + border: 'none' + } + }, + '& .MuiInputBase-input': { + color: theme.palette.text.primary, + py: 1.5, + px: 2.5 + } + }; + + return ( + + + {/* Name Rule Section */} + + + Name Rule Section: + + ( + + )} + /> + + + + ); +}; + +export default AddRuleSectionModal; diff --git a/src/frontend/src/pages/RulesPage/components/AddRulesetTypeModal.tsx b/src/frontend/src/pages/RulesPage/components/AddRulesetTypeModal.tsx new file mode 100644 index 0000000000..0e9caf9c9c --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/AddRulesetTypeModal.tsx @@ -0,0 +1,104 @@ +import { FormControl, FormHelperText, FormLabel, TextField } from '@mui/material'; +import { Box } from '@mui/system'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { Controller, useForm } from 'react-hook-form'; +import NERFormModal from '../../../components/NERFormModal'; +import { useToast } from '../../../hooks/toasts.hooks'; + +interface RulesetTypeFormData { + name: string; +} + +interface AddRulesetTypeModalProps { + open: boolean; + onHide: () => void; + onFormSubmit: (data: RulesetTypeFormData) => Promise; +} + +const sectionHeaderStyle = { + fontWeight: 'bold', + color: '#ef4345', + textDecoration: 'underline', + fontSize: '1rem', + textUnderlineOffset: '5px', + marginBottom: '10px' +}; + +const schema = yup.object({ + name: yup.string().required('Name is required') +}); + +const AddRulesetTypeModal: React.FC = ({ open, onHide, onFormSubmit }) => { + const toast = useToast(); + + const { + formState: { errors }, + handleSubmit, + reset, + control + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + name: '' + } + }); + + const handleFormSubmit = async (data: RulesetTypeFormData) => { + try { + await onFormSubmit(data); + toast.success('Ruleset Type Successfully Added'); + reset(); + onHide(); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + const handleModalClose = () => { + reset(); + onHide(); + }; + + const handleReset = () => { + reset(); + }; + + return ( + + + + Name Ruleset: + ( + + )} + /> + {errors.name?.message} + + + + ); +}; + +export default AddRulesetTypeModal; diff --git a/src/frontend/src/pages/RulesPage/components/DeleteRuleModal.tsx b/src/frontend/src/pages/RulesPage/components/DeleteRuleModal.tsx new file mode 100644 index 0000000000..573ea843a6 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/DeleteRuleModal.tsx @@ -0,0 +1,51 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { Box, Typography } from '@mui/material'; +import { Rule } from 'shared'; +import WarningIcon from '@mui/icons-material/Warning'; +import NERModal from '../../../components/NERModal'; + +interface DeleteRuleModalProps { + open: boolean; + onHide: () => void; + onConfirm: () => void; + rule: Rule; + totalRulesToDelete: number; +} + +const DeleteRuleModal = ({ open, onHide, onConfirm, rule, totalRulesToDelete }: DeleteRuleModalProps) => { + const hasChildren = rule.subRuleIds.length > 0; + const titlePrefix = hasChildren ? 'Delete Rule Section:' : 'Delete Rule:'; + + const modalTitle = rule.ruleContent + ? `${titlePrefix} ${rule.ruleCode} - ${rule.ruleContent}` + : `${titlePrefix} ${rule.ruleCode}`; + + return ( + + + {modalTitle} + + + + + {totalRulesToDelete} {totalRulesToDelete === 1 ? 'rule' : 'rules'} will be deleted + + + + + ); +}; + +export default DeleteRuleModal; diff --git a/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx b/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx new file mode 100644 index 0000000000..beec41bac5 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx @@ -0,0 +1,86 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { Box, IconButton, Tooltip } from '@mui/material'; +import { InfoOutlined, KeyboardArrowRight, KeyboardArrowDown } from '@mui/icons-material'; +import { Rule } from 'shared'; +import { getRuleStatusConfig, isRuleComplete } from '../../../utils/rules.utils'; + +interface RuleStatusTagProps { + rule: Rule; + allRules: Rule[]; + // ability to update completion status + onClick?: (event: React.MouseEvent) => void; + // controls chevron direction when completion is interactive + popoverOpen?: boolean; +} + +/** + * Completion status chip for a rule. + * A leaf shows its own completion, while a parent is only complete if all + * of its descendant leaf rules are complete. Completed leafs also show an + * info tooltip with who completed it and in which project. + */ +const RuleStatusTag: React.FC = ({ rule, allRules, onClick, popoverOpen = false }) => { + const isComplete = isRuleComplete(rule, allRules); + const { label, color } = getRuleStatusConfig(isComplete); + + const isLeaf = !allRules.some((r) => r.parentRule?.ruleId === rule.ruleId); + // Note: Info tooltip only says "Completed by {User}" if completed in general view + const completedByName = rule.completedBy && `${rule.completedBy.firstName} ${rule.completedBy.lastName}`; + const completionMessage = completedByName + ? `Completed by ${completedByName}${rule.completedInProject ? ` in ${rule.completedInProject.projectName}` : ''}` + : ''; + + // only leafs are interactive + const isInteractive = isLeaf && Boolean(onClick); + + return ( + + {isLeaf && isComplete && completionMessage && ( + + e.stopPropagation()} sx={{ padding: '2px', color: 'text.secondary' }}> + + + + )} + ) => { + e.stopPropagation(); + onClick!(e); + } + : undefined + } + sx={{ + backgroundColor: color, + color: 'white', + fontSize: '11px', + fontWeight: 600, + pl: isInteractive ? 0.25 : 0.75, + pr: 0.75, + py: 0.25, + borderRadius: '3px', + cursor: isInteractive ? 'pointer' : 'default', + display: 'inline-flex', + alignItems: 'center', + whiteSpace: 'nowrap', + '&:hover': isInteractive ? { opacity: 0.85 } : {} + }} + > + {isInteractive && + (popoverOpen ? ( + + ) : ( + + ))} + {label} + + + ); +}; + +export default RuleStatusTag; diff --git a/src/frontend/src/pages/RulesPage/components/RulesetDeleteModal.tsx b/src/frontend/src/pages/RulesPage/components/RulesetDeleteModal.tsx new file mode 100644 index 0000000000..911d4c8d00 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RulesetDeleteModal.tsx @@ -0,0 +1,27 @@ +import { Typography } from '@mui/material'; +import NERModal from '../../../components/NERModal'; + +interface RulesetDeleteModalProps { + rulesetName: string; + onDelete: () => void; + onHide: () => void; +} + +const RulesetDeleteModal: React.FC = ({ rulesetName, onDelete, onHide }) => { + return ( + + Are you sure you want to delete this ruleset? + {rulesetName} + + ); +}; + +export default RulesetDeleteModal; diff --git a/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx new file mode 100644 index 0000000000..e5c5cde05d --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; +import { Box, Paper, Table, TableBody, TableContainer } from '@mui/material'; +import { Rule } from 'shared'; +import RuleRow from '../RuleRow'; +import RuleStatusTag from './RuleStatusTag'; +import UpdateStatusPopover from '../../ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover'; +import { useSetRuleCompletion } from '../../../hooks/rules.hooks'; +import { useToast } from '../../../hooks/toasts.hooks'; + +interface RulesetGeneralViewProps { + allRules: Rule[]; + rulesetId: string; +} + +/** + * general view for displaying all top-level rules as dropdowns + */ +const RulesetGeneralView: React.FC = ({ allRules, rulesetId }) => { + const toast = useToast(); + const [statusPopoverAnchor, setStatusPopoverAnchor] = useState(null); + const [selectedRule, setSelectedRule] = useState(null); + + // Completion here is ruleset-wide (no project context), so no projectId is passed + const { mutateAsync: setCompletion } = useSetRuleCompletion(rulesetId, ''); + + const topLevelRules = allRules.filter((rule) => !rule.parentRule); + + const handleStatusClose = () => { + setStatusPopoverAnchor(null); + setSelectedRule(null); + }; + + const handleStatusChange = async (ruleId: string, isComplete: boolean) => { + try { + await setCompletion({ ruleId, isComplete }); + toast.success('Rule completion updated successfully'); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + return ( + + + + + {topLevelRules.map((rule) => ( + ( + { + setSelectedRule(r); + setStatusPopoverAnchor(e.currentTarget); + }} + /> + )} + backgroundColor="#9d9d9d" + textColor="#000000" + hoverColor="#5e5e5e" + rowHeight="10px" + verticalPadding="5px" + /> + ))} + +
+
+ + {selectedRule && ( + + )} +
+ ); +}; + +export default RulesetGeneralView; diff --git a/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx b/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx new file mode 100644 index 0000000000..d494d3d2d6 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx @@ -0,0 +1,354 @@ +import React, { useState } from 'react'; +import { + Box, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + useMediaQuery, + useTheme, + Card, + CardContent, + Typography, + Stack, + Checkbox, + IconButton +} from '@mui/material'; +import { datePipe } from '../../../utils/pipes'; +import { NERButton } from '../../../components/NERButton'; +import { useHistory, useParams } from 'react-router-dom'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { useDeleteRuleset, useRulesetsByType, useUpdateRuleset } from '../../../hooks/rules.hooks'; +import { Ruleset } from 'shared'; +import { routes } from '../../../utils/routes'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { Delete } from '@mui/icons-material'; +import RulesetDeleteModal from './RulesetDeleteModal'; + +interface RulesetParams { + rulesetTypeId: string; +} + +interface RulesetDeleteButtonProps { + rulesetId: string; + name: string; + onDelete: (rulesetId: string, name: string) => void; +} + +const RulesetTable: React.FC = () => { + const { rulesetTypeId } = useParams(); + const toast = useToast(); + const history = useHistory(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + const { data: rulesets = [], isLoading, error } = useRulesetsByType(rulesetTypeId); + const updateRuleset = useUpdateRuleset(); + const { mutateAsync: deleteRuleset } = useDeleteRuleset(); + + const hasRules = (ruleset: Ruleset) => { + return ruleset.ruleAmount > 0; + }; + + // Table header configuration + const headCells = [ + { id: 'fileName', label: 'File Name' }, + { id: 'dateUploaded', label: 'Date Uploaded' }, + { id: 'percentRulesAssigned', label: '% of Rules Assigned' }, + { id: 'car', label: 'Car' }, + { id: 'isActive', label: 'Active?' }, + { id: 'actions', label: 'Actions' }, + { id: 'delete', label: '' } + ]; + + const handleToggleActive = (ruleset: Ruleset) => { + updateRuleset.mutate( + { + rulesetId: ruleset.rulesetId, + name: ruleset.name, + isActive: !ruleset.active + }, + { + onSuccess: () => { + toast.success(ruleset.active ? 'Ruleset deactivated' : 'Ruleset activated'); + }, + onError: (error: any) => { + const message = error.response?.data?.message || error.message; + toast.error(message); + } + } + ); + }; + + const handleEditRuleset = (rulesetId: string) => { + history.push(routes.RULESET_EDIT.replace(':rulesetId', rulesetId)); + }; + + const handleViewRuleset = (rulesetId: string) => { + history.push(routes.RULESET_VIEW.replace(':rulesetId', rulesetId)); + }; + + const handleDeleteRuleset = async (rulesetId: string, name: string) => { + const ruleset = rulesets.find((r) => r.rulesetId === rulesetId); + + if (ruleset && ruleset.active) { + toast.error('Cannot delete an active ruleset. Please deactivate it first.'); + return; + } + + try { + await deleteRuleset(rulesetId); + toast.success(`Ruleset: ${name} deleted successfully!`); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + const RulesetDeleteButton: React.FC = ({ rulesetId, name, onDelete }) => { + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const handleDeleteSubmit = () => { + onDelete(rulesetId, name); + setShowDeleteModal(false); + }; + return ( + <> + setShowDeleteModal(true)}> + + + {showDeleteModal && ( + setShowDeleteModal(false)} /> + )} + + ); + }; + + if (error) return ; + if (isLoading) return ; + + return ( + + {isMobile ? ( + + {rulesets.map((ruleset: Ruleset) => ( + + + + {ruleset.name} + + + + + Date Uploaded: + + + {datePipe(ruleset.dateCreated)} + + + + + % of Rules Assigned: + + + {ruleset.assignedPercentage} + + + + + Car: + + + {ruleset.car.name} + + + + + Active: + + + {ruleset.active} + + handleToggleActive(ruleset)} + disabled={updateRuleset.isLoading} + sx={{ + color: '#fff', + '&.Mui-checked': { color: '#dd514c' } + }} + /> + + + handleEditRuleset(ruleset.rulesetId)} + disabled={!hasRules(ruleset)} + sx={{ + backgroundColor: theme.palette.grey[800], + color: theme.palette.getContrastText(theme.palette.grey[600]), + '&:hover': { + backgroundColor: theme.palette.grey[700] + }, + marginRight: '10px', + padding: '4px', + lineHeight: 1, + borderRadius: '6px', + '&.Mui-disabled': { + backgroundColor: theme.palette.grey[900], + color: theme.palette.grey[600] + } + }} + > + Edit/Assign Rules + + handleViewRuleset(ruleset.rulesetId)} + disabled={!hasRules(ruleset)} + sx={{ + backgroundColor: theme.palette.grey[800], + color: theme.palette.getContrastText(theme.palette.grey[600]), + '&:hover': { + backgroundColor: theme.palette.grey[700] + }, + padding: '4px', + lineHeight: 1, + borderRadius: '6px', + '&.Mui-disabled': { + backgroundColor: theme.palette.grey[900], + color: theme.palette.grey[600] + } + }} + > + View Rules + + + + + + + ))} + + ) : ( + + + + + {headCells.map((headCell) => ( + + {headCell.label} + + ))} + + + + {/* Table rows with ruleset data */} + {rulesets.length === 0 ? ( + + + No Rulesets Found + + + ) : ( + rulesets.map((ruleset: Ruleset) => ( + + + {ruleset.name} + + {datePipe(ruleset.dateCreated)} + {ruleset.assignedPercentage?.toFixed(2) ?? '0'}% + {ruleset.car.name} + + handleToggleActive(ruleset)} + disabled={updateRuleset.isLoading} + sx={{ + color: '#fff', + '&.Mui-checked': { color: '#dd514c' } + }} + /> + + + handleEditRuleset(ruleset.rulesetId)} + disabled={!hasRules(ruleset)} + sx={{ + backgroundColor: theme.palette.grey[800], + color: theme.palette.getContrastText(theme.palette.grey[600]), + '&:hover': { + backgroundColor: theme.palette.grey[700] + }, + marginRight: '10px', + padding: '4px', + lineHeight: 1, + borderRadius: '6px', + '&.Mui-disabled': { + backgroundColor: theme.palette.grey[900], + color: theme.palette.grey[600] + } + }} + > + Edit/Assign Rules + + handleViewRuleset(ruleset.rulesetId)} + disabled={!hasRules(ruleset)} + sx={{ + backgroundColor: theme.palette.grey[800], + color: theme.palette.getContrastText(theme.palette.grey[600]), + '&:hover': { + backgroundColor: theme.palette.grey[700] + }, + padding: '4px', + lineHeight: 1, + borderRadius: '6px', + '&.Mui-disabled': { + backgroundColor: theme.palette.grey[900], + color: theme.palette.grey[600] + } + }} + > + View Rules + + + + + + + )) + )} + +
+
+ )} +
+ ); +}; + +export default RulesetTable; diff --git a/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx b/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx new file mode 100644 index 0000000000..7e89317248 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { Box, Paper, Table, TableBody, TableContainer } from '@mui/material'; +import { Rule } from 'shared'; +import RuleRow from '../RuleRow'; + +interface TeamProject { + projectId: string; + projectName: string; + rules: Rule[]; +} + +interface TeamRules { + teamId: string; + teamName: string; + projects: TeamProject[]; + unassignedRules: Rule[]; +} + +interface RulesetTeamViewProps { + allRules: Rule[]; + teamRules: TeamRules[]; + unassignedToTeam: Rule[]; +} + +/** + * Displays rules organized by team and project + * Teams and projects are rendered as RuleRows for consistent formatting + */ +const RulesetTeamView: React.FC = ({ allRules, teamRules, unassignedToTeam }) => { + // Convert teams to mock rules for rendering with RuleRow + const teamRulesAsRules: Rule[] = teamRules.map((team) => ({ + ruleId: `team-${team.teamId}`, + ruleCode: `${team.teamName}`, + ruleContent: '', + imageFileIds: [], + parentRule: undefined, + subRuleIds: [ + ...team.projects.map((p) => `project-${p.projectId}`), + ...(team.unassignedRules.length > 0 ? [`team-${team.teamId}-unassigned`] : []) + ], + referencedRuleIds: [], + isComplete: false + })); + + // Convert projects to mock rules for rendering with RuleRow + const projectRulesAsRules: Rule[] = teamRules.flatMap((team) => + team.projects.map((project) => ({ + ruleId: `project-${project.projectId}`, + ruleCode: `${project.projectName}`, + ruleContent: '', + imageFileIds: [], + parentRule: { + ruleId: `team-${team.teamId}`, + ruleCode: `${team.teamName}` + }, + subRuleIds: project.rules.map((r) => r.ruleId), + referencedRuleIds: [], + isComplete: false + })) + ); + + // Convert unassigned to project sections to mock rules + const unassignedToProjectRules: Rule[] = teamRules + .filter((team) => team.unassignedRules.length > 0) + .map((team) => ({ + ruleId: `team-${team.teamId}-unassigned`, + ruleCode: 'Unassigned Rules - Unassigned to Project', + ruleContent: '', + imageFileIds: [], + parentRule: { + ruleId: `team-${team.teamId}`, + ruleCode: `${team.teamName}` + }, + subRuleIds: team.unassignedRules.map((r) => r.ruleId), + referencedRuleIds: [], + isComplete: false + })); + + // Create unassigned to team mock rule + const unassignedToTeamRule: Rule | null = + unassignedToTeam.length > 0 + ? { + ruleId: 'unassigned-to-team', + ruleCode: 'Unassigned Rules - Unassigned to Team', + ruleContent: '', + imageFileIds: [], + parentRule: undefined, + subRuleIds: unassignedToTeam.map((r) => r.ruleId), + referencedRuleIds: [], + isComplete: false + } + : null; + + // mock team/project rules + actual rules + const allRulesIncludingMock = [ + ...teamRulesAsRules, + ...projectRulesAsRules, + ...unassignedToProjectRules, + ...(unassignedToTeamRule ? [unassignedToTeamRule] : []), + ...allRules + ]; + + // Top level items are teams and unassigned to team + const topLevelItems = [...teamRulesAsRules, ...(unassignedToTeamRule ? [unassignedToTeamRule] : [])]; + + return ( + + + + + {topLevelItems.map((item) => ( + null} + backgroundColor="#9d9d9d" + textColor="#000000" + hoverColor="#5e5e5e" + rowHeight="10px" + verticalPadding="5px" + initiallyExpanded={item.ruleId.startsWith('team-')} + /> + ))} + +
+
+
+ ); +}; + +export default RulesetTeamView; +export type { TeamProject, TeamRules }; diff --git a/src/frontend/src/pages/RulesPage/components/RulesetTypeDeleteModal.tsx b/src/frontend/src/pages/RulesPage/components/RulesetTypeDeleteModal.tsx new file mode 100644 index 0000000000..ad48f500d2 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RulesetTypeDeleteModal.tsx @@ -0,0 +1,27 @@ +import { Typography } from '@mui/material'; +import NERModal from '../../../components/NERModal'; + +interface RulesetTypeDeleteModalProps { + rulesetTypeName: string; + onDelete: () => void; + onHide: () => void; +} + +const RulesetTypeDeleteModal: React.FC = ({ rulesetTypeName, onDelete, onHide }) => { + return ( + + Are you sure you want to delete this ruleset type? + {rulesetTypeName} + + ); +}; + +export default RulesetTypeDeleteModal; diff --git a/src/frontend/src/pages/RulesPage/components/RulesetTypeTable.tsx b/src/frontend/src/pages/RulesPage/components/RulesetTypeTable.tsx new file mode 100644 index 0000000000..daa3b9bbc4 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RulesetTypeTable.tsx @@ -0,0 +1,259 @@ +import { + Box, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + useMediaQuery, + useTheme, + Card, + CardContent, + Typography, + Stack, + IconButton +} from '@mui/material'; +import { useHistory } from 'react-router-dom'; +import { datePipe } from '../../../utils/pipes'; +import { routes } from '../../../utils/routes'; +import { useAllRulesetTypes, useDeleteRulesetType } from '../../../hooks/rules.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { RulesetType } from 'shared'; +import { NERButton } from '../../../components/NERButton'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { useState } from 'react'; +import RulesetTypeDeleteModal from './RulesetTypeDeleteModal'; +import { Delete } from '@mui/icons-material'; + +type RulesetTypeColumnId = 'id' | 'name' | 'lastUpdated' | 'revisions' | 'actions' | 'delete'; + +interface RulesetTypeHeadCell { + id: RulesetTypeColumnId; + label: string; +} + +interface RulesetTypeDeleteButtonProps { + rulesetTypeId: string; + name: string; + onDelete: (rulesetTypeId: string, name: string) => void; +} + +const RulesetTypeTable: React.FC = () => { + const history = useHistory(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const toast = useToast(); + + const { data: rulesetTypes = [], isLoading, error } = useAllRulesetTypes(); + const { mutateAsync: deleteRulesetType } = useDeleteRulesetType(); + + const headCells: readonly RulesetTypeHeadCell[] = [ + { + id: 'name', + label: 'Ruleset Name' + }, + { + id: 'lastUpdated', + label: 'Last Updated' + }, + { + id: 'revisions', + label: 'Number of Revisions' + }, + { + id: 'actions', + label: 'Actions' + }, + { + id: 'delete', + label: '' + } + ]; + + const handleViewRulesetType = (rulesetTypeId: string) => { + history.push(routes.RULESET_BY_ID.replace(':rulesetTypeId', rulesetTypeId)); + }; + + const handleDeleteRulesetType = async (rulesetTypeId: string, name: string) => { + const rulesetType = rulesetTypes.find((rt) => rt.rulesetTypeId === rulesetTypeId); + if (rulesetType && rulesetType.revisionFiles.length > 0) { + toast.error('Cannot delete ruleset type with existing revisions'); + return; + } + + try { + await deleteRulesetType(rulesetTypeId); + toast.success(`Ruleset Type: ${name} deleted successfully!`); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + const RulesetTypeDeleteButton: React.FC = ({ rulesetTypeId, name, onDelete }) => { + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const handleDeleteSubmit = () => { + onDelete(rulesetTypeId, name); + setShowDeleteModal(false); + }; + + return ( + <> + setShowDeleteModal(true)}> + + + {showDeleteModal && ( + setShowDeleteModal(false)} + /> + )} + + ); + }; + + if (error) return ; + if (isLoading) return ; + + return ( + + {isMobile ? ( + + {rulesetTypes.map((rulesetType: RulesetType) => ( + + + + {rulesetType.name} + + + + + Last Updated: + + + {datePipe(rulesetType.lastUpdated)} + + + + + Revisions: + + + {rulesetType.revisionFiles.length} + + + + handleViewRulesetType(rulesetType.rulesetTypeId)} + > + View Rulesets + + + + + + + ))} + + ) : ( + + + + + {headCells.map((headCell) => ( + + {headCell.label} + + ))} + + + + {rulesetTypes.length === 0 ? ( + + + No Ruleset Types Found + + + ) : ( + rulesetTypes.map((rulesetType: RulesetType) => ( + + + {rulesetType.name} + + {datePipe(rulesetType.lastUpdated)} + {rulesetType.revisionFiles.length} + + handleViewRulesetType(rulesetType.rulesetTypeId)} + > + View Rulesets + + + + + + + )) + )} + +
+
+ )} +
+ ); +}; + +export default RulesetTypeTable; diff --git a/src/frontend/src/utils/routes.ts b/src/frontend/src/utils/routes.ts index ce658120bd..846d934397 100644 --- a/src/frontend/src/utils/routes.ts +++ b/src/frontend/src/utils/routes.ts @@ -82,6 +82,12 @@ const GRAPH_COLLECTION_BY_ID = '/statistics/graph-collections/:graphCollectionId /**************** Retrospective ****************/ const RETROSPECTIVE = `/retrospective`; +/**************** Rules ****************/ +const RULES = `/rules`; +const RULESET_BY_ID = RULES + `/:rulesetTypeId`; +const RULESET_VIEW = RULES + `/ruleset/:rulesetId/view`; +const RULESET_EDIT = RULES + `/ruleset/:rulesetId/edit`; + export const routes = { BASE, LOGIN, @@ -150,5 +156,10 @@ export const routes = { SPONSORS, - RETROSPECTIVE + RETROSPECTIVE, + + RULES, + RULESET_BY_ID, + RULESET_VIEW, + RULESET_EDIT }; diff --git a/src/frontend/src/utils/rules.utils.ts b/src/frontend/src/utils/rules.utils.ts new file mode 100644 index 0000000000..37ea526737 --- /dev/null +++ b/src/frontend/src/utils/rules.utils.ts @@ -0,0 +1,60 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { Rule } from 'shared'; + +/** + * Counts the total number of rules that will be deleted when deleting a rule, including + * the rule itself and all its descendants. Does not include parents or siblings. + * @param rule - The rule to delete + * @param allRules - All rules in the ruleset + * @returns The total number of rules that will be deleted + */ +export const countRulesToDelete = (rule: Rule, allRules: Rule[]): number => { + let count = 1; + const children = allRules.filter((r) => rule.subRuleIds.includes(r.ruleId)); + for (const child of children) { + count += countRulesToDelete(child, allRules); + } + return count; +}; + +/** + * Gets all leaf rules of a given rule (the rule itself if it has no children). + * Does not include the rule's parent or siblings. + * + * @param rule - rule to start from + * @param allRules - all rules in scope + * @returns The leaf rules under the given rule, or rule if it is already a leaf + */ +export const getDescendantLeafRules = (rule: Rule, allRules: Rule[]): Rule[] => { + const children = allRules.filter((r) => r.parentRule?.ruleId === rule.ruleId); + if (children.length === 0) { + return [rule]; + } + return children.flatMap((child) => getDescendantLeafRules(child, allRules)); +}; + +/** + * Whether a rule is complete. A leaf uses its own completion; a parent is + * complete only if all of its descendant leaf rules are complete. + * @param rule - The rule to check + * @param allRules - All rules in scope + * @returns True if the rule (or all its leaves) are complete + */ +export const isRuleComplete = (rule: Rule, allRules: Rule[]): boolean => { + const leafRules = getDescendantLeafRules(rule, allRules); + if (leafRules.length === 0) { + return false; + } + return leafRules.every((leafRule) => leafRule.isComplete); +}; + +/** + * Status chip label and color for a completion state. + */ +export const getRuleStatusConfig = (isComplete: boolean): { label: string; color: string } => { + return isComplete ? { label: 'Complete', color: '#4caf50' } : { label: 'Incomplete', color: '#f44336' }; +}; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index ee39409179..ee5c4fa4a0 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -447,6 +447,36 @@ const retrospectiveTimelines = (startDate?: Date, endDate?: Date) => (endDate ? `end=${encodeURIComponent(new Date(endDate).toISOString())}` : ''); const retrospectiveBudgets = () => `${API_URL}/retrospective/budgets`; +/**************** Rules Endpoints ****************/ +const rules = () => `${API_URL}/rules`; +const rulesTopLevel = (rulesetId: string) => `${rules()}/${rulesetId}/parentRules`; +const rulesToggleTeam = (ruleId: string) => `${rules()}/rule/${ruleId}/toggle-team`; +const rulesChildRules = (ruleId: string) => `${rules()}/${ruleId}/subrules`; +const rulesTeamRulesInRulesetType = (rulesetTypeId: string, teamId: string) => `${rules()}/${rulesetTypeId}/team/${teamId}`; +const rulesetTypes = () => `${rules()}/rulesetTypes`; +const rulesetsByType = (rulesetTypeId: string) => `${rules()}/rulesets/${rulesetTypeId}`; +const ruleset = () => `${rules()}/ruleset`; +const rulesetTypeCreate = () => `${rules()}/rulesetType/create`; +const rulesetsCreate = () => `${ruleset()}/create`; +const rulesetById = (rulesetId: string) => `${ruleset()}/${rulesetId}`; +const ruleCreate = () => `${rules()}/rule/create`; +const parseRuleset = (rulesetId: string) => `${rulesetById(rulesetId)}/parse`; +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, 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`; +const rulesEdit = (ruleId: string) => `${rules()}/rule/${ruleId}/edit`; +const rulesDelete = (ruleId: string) => `${rules()}/rule/${ruleId}/delete`; +const rulesetUpdate = (rulesetId: string) => `${ruleset()}/${rulesetId}/update`; +const rulesetDelete = (rulesetId: string) => `${ruleset()}/${rulesetId}/delete`; +const rulesetTypeDelete = (rulesetTypeId: string) => `${rules()}/rulesetType/${rulesetTypeId}/delete`; +const rulesetType = (rulesetTypeId: string) => `${rules()}/${rulesetTypeId}`; +const singleRuleset = (rulesetId: string) => `${rules()}/ruleset/${rulesetId}`; /**************** Calendar Endpoints ****************/ const calendar = () => `${API_URL}/calendar`; const calendarShops = () => `${calendar()}/shops`; @@ -840,6 +870,33 @@ export const apiUrls = { retrospectiveTimelines, retrospectiveBudgets, + rules, + rulesTopLevel, + rulesToggleTeam, + rulesChildRules, + rulesTeamRulesInRulesetType, + ruleset, + rulesetTypes, + rulesetsByType, + rulesetTypeCreate, + rulesetsCreate, + rulesetById, + ruleCreate, + rulesGetActiveRuleset, + rulesGetProjectRules, + rulesGetUnassignedRulesForRuleset, + rulesCreateProjectRule, + rulesDeleteProjectRule, + rulesSetRuleCompletion, + rulesEdit, + rulesDelete, + rulesetUpdate, + rulesetDelete, + rulesetTypeDelete, + rulesetType, + parseRuleset, + uploadRulesetFile, + singleRuleset, calendarShops, calendarCreateShop, calendarFilterEvents, diff --git a/src/shared/index.ts b/src/shared/index.ts index 123ca845f7..82aa8b5bd7 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -30,5 +30,6 @@ export * from './src/word-count.js'; export * from './src/permission-utils.js'; export * from './src/types/bom-types.js'; export * from './src/types/statistics-types.js'; +export * from './src/types/rules-types.js'; export * from './src/utils.js'; diff --git a/src/shared/src/types/rules-types.ts b/src/shared/src/types/rules-types.ts new file mode 100644 index 0000000000..d08770ac84 --- /dev/null +++ b/src/shared/src/types/rules-types.ts @@ -0,0 +1,66 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +export interface RulesetType { + rulesetTypeId: string; + name: string; + lastUpdated: Date; + revisionFiles: Ruleset[]; +} + +export interface Ruleset { + rulesetId: string; + fileId: string; + name: string; + dateCreated: Date; + active: boolean; + rulesetType: RulesetType; + assignedPercentage: number; + car: { + carId: string; + name: string; + }; + ruleAmount: number; +} + +export interface Rule { + ruleId: string; + ruleCode: string; + ruleContent: string; + imageFileIds: string[]; + parentRule?: { + ruleId: string; + ruleCode: string; + }; + subRuleIds: string[]; + referencedRuleIds: string[]; + teams?: Array<{ + teamId: string; + teamName: string; + }>; + isComplete: boolean; + completedBy?: { + firstName: string; + lastName: string; + }; + completedInProject?: { projectId: string; projectName: string }; +} + +export interface ProjectRule { + projectRuleId: string; + rule: Rule; + projectId: string; +} + +export interface RulesetPreview { + name: string; + dateCreated: Date; + active: boolean; + assignedPercentage: number; + car: { + carId: string; + name: string; + }; +} 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"