diff --git a/src/backend/index.ts b/src/backend/index.ts index 88b099ddef..c0499f99a3 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -24,6 +24,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'; const app = express(); @@ -102,6 +103,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('/', (_req, res) => { res.status(200).json('Welcome to FinishLine'); diff --git a/src/backend/package.json b/src/backend/package.json index 7f9ad3489d..07eb42dba0 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -33,6 +33,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..b044f16429 --- /dev/null +++ b/src/backend/src/controllers/rules.controllers.ts @@ -0,0 +1,351 @@ +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; + const rulesetType = await RulesService.getActiveRuleset(req.currentUser, rulesetTypeId as string, 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; + const ruleset = await RulesService.getRulesetById(rulesetId as string, 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; + const deletedRule = await RulesService.deleteRule(ruleId as string, 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; + const { ruleContent, ruleCode, imageFileIds, parentRuleId } = req.body; + + const rule = await RulesService.editRule( + req.currentUser, + ruleContent, + ruleId as string, + 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; + const rulesets = await RulesService.getRulesetsByRulesetType(rulesetTypeId as string, 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; + const rulesetType = await RulesService.getRulesetType(rulesetTypeId as string, 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; + const ruleset = await RulesService.deleteRuleset( + rulesetId as string, + 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; + const deletedProjectRule = await RulesService.deleteProjectRule( + projectRuleId as string, + req.currentUser, + req.organization + ); + res.status(200).json(deletedProjectRule); + } catch (error: unknown) { + next(error); + } + } + + static async editProjectRuleStatus(req: Request, res: Response, next: NextFunction) { + try { + const { projectRuleId } = req.params; + const { newStatus } = req.body; + + const projectRule: ProjectRule = await RulesService.editProjectRuleStatus( + req.currentUser, + req.organization, + projectRuleId as string, + newStatus + ); + + res.status(200).json(projectRule); + } catch (error: unknown) { + next(error); + } + } + + static async toggleRuleTeam(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId } = req.params; + const { teamId } = req.body; + + const changedRule = await RulesService.toggleRuleTeam(ruleId as string, 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; + + const rulesetType = await RulesService.deleteRulesetType(req.currentUser, rulesetTypeId as string, 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; + const { name, isActive } = req.body; + + const ruleset: Ruleset = await RulesService.updateRuleset( + req.currentUser, + req.organization.organizationId, + rulesetId as string, + 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; + const childrenRules: Rule[] = await RulesService.getChildRules(parentRuleId as string, 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; + const rules = await RulesService.getUnassignedRules(rulesetId as string, 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; + const rules = await RulesService.getUnassignedRulesForRuleset( + rulesetId as string, + teamId, + 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; + + const projectRules = await RulesService.getProjectRules(rulesetId as string, 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; + const rules = await RulesService.getTeamRulesInRulesetType(teamId as string, 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; + const rules = await RulesService.getTopLevelRules(rulesetId as string, 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; + + const parseResult = await RulesService.parseRuleset( + req.currentUser, + req.organization.organizationId, + fileId, + rulesetId as string, + 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; + const ruleset = await RulesService.getSingleRuleset(req.currentUser, rulesetId as string, 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..32a602408c --- /dev/null +++ b/src/backend/src/prisma-query-args/rules.query-args.ts @@ -0,0 +1,94 @@ +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 + } + } + } + }); + +export const getProjectRuleQueryArgs = () => + Prisma.validator()({ + include: { + rule: getRulePreviewQueryArgs(), + project: { select: { projectId: true } }, + statusHistory: { + include: { + createdBy: { + select: { + userId: true, + firstName: true, + lastName: true + } + } + }, + orderBy: { + dateCreated: 'desc' + } + } + } + }); + +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/20251111150202_rules_dashboard/migration.sql b/src/backend/src/prisma/migrations/20251111150202_rules_dashboard/migration.sql new file mode 100644 index 0000000000..c72bbb5c14 --- /dev/null +++ b/src/backend/src/prisma/migrations/20251111150202_rules_dashboard/migration.sql @@ -0,0 +1,181 @@ +-- CreateEnum +CREATE TYPE "public"."Rule_Completion" AS ENUM ('REVIEW', 'INCOMPLETE', 'COMPLETED'); + +-- CreateTable +CREATE TABLE "public"."Ruleset_Type" ( + "rulesetTypeId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "lastUpdated" TIMESTAMP(3) NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdByUserId" TEXT NOT NULL, + "dateDeleted" TIMESTAMP(3), + "deletedByUserId" TEXT, + "organizationId" TEXT NOT NULL, + + CONSTRAINT "Ruleset_Type_pkey" PRIMARY KEY ("rulesetTypeId") +); + +-- CreateTable +CREATE TABLE "public"."Ruleset" ( + "rulesetId" TEXT NOT NULL, + "fileId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "active" BOOLEAN NOT NULL, + "rulesetTypeId" TEXT NOT NULL, + "carId" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdByUserId" TEXT NOT NULL, + "dateDeleted" TIMESTAMP(3), + "deletedByUserId" TEXT, + + CONSTRAINT "Ruleset_pkey" PRIMARY KEY ("rulesetId") +); + +-- CreateTable +CREATE TABLE "public"."Rule" ( + "ruleId" TEXT NOT NULL, + "ruleCode" TEXT NOT NULL, + "ruleContent" TEXT NOT NULL, + "imageFileIds" TEXT[], + "rulesetId" TEXT NOT NULL, + "parentRuleId" TEXT, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "dateUpdated" TIMESTAMP(3), + "dateDeleted" TIMESTAMP(3), + "createdByUserId" TEXT NOT NULL, + "updatedByUserId" TEXT, + "deletedByUserId" TEXT, + + CONSTRAINT "Rule_pkey" PRIMARY KEY ("ruleId") +); + +-- CreateTable +CREATE TABLE "public"."Rule_Status_Change" ( + "historyId" TEXT NOT NULL, + "projectRuleId" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdByUserId" TEXT NOT NULL, + "dateDeleted" TIMESTAMP(3), + "deletedByUserId" TEXT, + "newStatus" "public"."Rule_Completion" NOT NULL, + "note" TEXT NOT NULL, + + CONSTRAINT "Rule_Status_Change_pkey" PRIMARY KEY ("historyId") +); + +-- CreateTable +CREATE TABLE "public"."Project_Rule" ( + "projectRuleId" TEXT NOT NULL, + "ruleId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "currentStatus" "public"."Rule_Completion" NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdByUserId" TEXT NOT NULL, + "dateDeleted" TIMESTAMP(3), + "deletedByUserId" TEXT, + + CONSTRAINT "Project_Rule_pkey" PRIMARY KEY ("projectRuleId") +); + +-- CreateTable +CREATE TABLE "public"."_ruleReferences" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_ruleReferences_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "public"."_teamRules" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_teamRules_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "Ruleset_Type_organizationId_idx" ON "public"."Ruleset_Type"("organizationId"); + +-- CreateIndex +CREATE INDEX "Rule_parentRuleId_rulesetId_ruleCode_idx" ON "public"."Rule"("parentRuleId", "rulesetId", "ruleCode"); + +-- CreateIndex +CREATE UNIQUE INDEX "Rule_rulesetId_ruleCode_key" ON "public"."Rule"("rulesetId", "ruleCode"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_Rule_ruleId_projectId_key" ON "public"."Project_Rule"("ruleId", "projectId"); + +-- CreateIndex +CREATE INDEX "_ruleReferences_B_index" ON "public"."_ruleReferences"("B"); + +-- CreateIndex +CREATE INDEX "_teamRules_B_index" ON "public"."_teamRules"("B"); + +-- AddForeignKey +ALTER TABLE "public"."Ruleset_Type" ADD CONSTRAINT "Ruleset_Type_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "public"."User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Ruleset_Type" ADD CONSTRAINT "Ruleset_Type_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Ruleset_Type" ADD CONSTRAINT "Ruleset_Type_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Ruleset" ADD CONSTRAINT "Ruleset_rulesetTypeId_fkey" FOREIGN KEY ("rulesetTypeId") REFERENCES "public"."Ruleset_Type"("rulesetTypeId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Ruleset" ADD CONSTRAINT "Ruleset_carId_fkey" FOREIGN KEY ("carId") REFERENCES "public"."Car"("carId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Ruleset" ADD CONSTRAINT "Ruleset_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "public"."User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Ruleset" ADD CONSTRAINT "Ruleset_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Rule" ADD CONSTRAINT "Rule_rulesetId_fkey" FOREIGN KEY ("rulesetId") REFERENCES "public"."Ruleset"("rulesetId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Rule" ADD CONSTRAINT "Rule_parentRuleId_fkey" FOREIGN KEY ("parentRuleId") REFERENCES "public"."Rule"("ruleId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Rule" ADD CONSTRAINT "Rule_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "public"."User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Rule" ADD CONSTRAINT "Rule_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Rule" ADD CONSTRAINT "Rule_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Rule_Status_Change" ADD CONSTRAINT "Rule_Status_Change_projectRuleId_fkey" FOREIGN KEY ("projectRuleId") REFERENCES "public"."Project_Rule"("projectRuleId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Rule_Status_Change" ADD CONSTRAINT "Rule_Status_Change_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "public"."User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Rule_Status_Change" ADD CONSTRAINT "Rule_Status_Change_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Project_Rule" ADD CONSTRAINT "Project_Rule_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "public"."Rule"("ruleId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Project_Rule" ADD CONSTRAINT "Project_Rule_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("projectId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Project_Rule" ADD CONSTRAINT "Project_Rule_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "public"."User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Project_Rule" ADD CONSTRAINT "Project_Rule_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_ruleReferences" ADD CONSTRAINT "_ruleReferences_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."Rule"("ruleId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_ruleReferences" ADD CONSTRAINT "_ruleReferences_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."Rule"("ruleId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_teamRules" ADD CONSTRAINT "_teamRules_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."Rule"("ruleId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_teamRules" ADD CONSTRAINT "_teamRules_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."Team"("teamId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 3d3ba18d35..def9c240c9 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -158,6 +158,12 @@ enum Review_Status { APPROVED } +enum Rule_Completion { + REVIEW // all rules start as REVIEW + INCOMPLETE // rules that need an action + COMPLETED +} + model User { userId String @id @default(uuid()) firstName String @@ -270,6 +276,17 @@ model User { deletedSponsorTiers Sponsor_Tier[] financeDelegateForOrganizations Organization[] @relation(name: "financeDelegates") assignedReimbursementRequests Reimbursement_Request[] @relation(name: "reimbursementRequestAssignee") + createdRuleStatuses Rule_Status_Change[] @relation(name: "ruleStatusCreator") + deletedRuleStatuses Rule_Status_Change[] @relation(name: "ruleStatusDeletor") + 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") @@ -314,6 +331,7 @@ model Team { organization Organization @relation(fields: [organizationId], references: [organizationId]) checklists Checklist[] projectTemplates Project_Template[] + rules Rule[] @relation(name: "teamRules") @@index([headId]) @@index([organizationId]) @@ -540,6 +558,7 @@ model Project { featuredByOrganization Organization? @relation(fields: [featuredByOrganizationId], references: [organizationId]) abbreviation String? parts Part[] + rules Project_Rule[] @relation(name: "projectsForRule") @@index([carId]) } @@ -1294,6 +1313,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]) } @@ -1357,6 +1377,7 @@ model Organization { sponsorTiers Sponsor_Tier[] indexCodes Index_Code[] financeDelegates User[] @relation(name: "financeDelegates") + rulesetTypes Ruleset_Type[] guestDefinitions Guest_Definition[] shops Shop[] machineries Machinery[] @@ -1700,6 +1721,101 @@ model Reimbursement_Request_Comment { @@index([reimbursementRequestId]) } +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") + dateCreated DateTime @default(now()) + dateUpdated DateTime? @updatedAt + dateDeleted DateTime? + createdByUserId String + createdBy User @relation(name: "ruleCreator", fields: [createdByUserId], references: [userId]) + updatedByUserId String? + updatedBy User? @relation(name: "ruleUpdater", fields: [updatedByUserId], references: [userId]) + deletedByUserId String? + deletedBy User? @relation(name: "ruleDeletor", fields: [deletedByUserId], references: [userId]) + + @@unique([rulesetId, ruleCode]) + @@index([parentRuleId, rulesetId, ruleCode]) +} + +model Rule_Status_Change { + historyId String @id @default(uuid()) + projectRule Project_Rule @relation(name: "ruleStatusHistory", fields: [projectRuleId], references: [projectRuleId]) + projectRuleId String + dateCreated DateTime @default(now()) + createdByUserId String + createdBy User @relation(name: "ruleStatusCreator", fields: [createdByUserId], references: [userId]) + dateDeleted DateTime? + deletedByUserId String? + deletedBy User? @relation(name: "ruleStatusDeletor", fields: [deletedByUserId], references: [userId]) + newStatus Rule_Completion + note String +} + +model Project_Rule { + projectRuleId String @id @default(uuid()) + ruleId String + rule Rule @relation(name: "rulesInProject", fields: [ruleId], references: [ruleId]) + projectId String + project Project @relation(name: "projectsForRule", fields: [projectId], references: [projectId]) + currentStatus Rule_Completion + statusHistory Rule_Status_Change[] @relation(name: "ruleStatusHistory") + dateCreated DateTime @default(now()) + 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..622f2187d9 --- /dev/null +++ b/src/backend/src/prisma/seed-data/rules.seed.ts @@ -0,0 +1,141 @@ +import type { Prisma } from '@prisma/client'; +import { Organization } from '@prisma/client'; +import RulesService from '../../services/rules.services.js'; +import { User } from 'shared'; + +// rules +const topLevelRule = (rulesetId: string, userCreatedId: string): Prisma.RuleCreateInput => { + return { + ruleCode: 'T', + ruleContent: 'PART T - GENERAL TECHNICAL REQUIREMENTS', + imageFileIds: [], + dateCreated: new Date('2025-09-01T10:00:00Z'), + ruleset: { connect: { rulesetId } }, + createdBy: { connect: { userId: userCreatedId } } + }; +}; + +const secondLevelRule = (rulesetId: string, userCreatedId: string, parentRuleId: string): Prisma.RuleCreateInput => { + return { + ruleCode: 'T2', + ruleContent: 'ARTICLE T2 GENERAL DESIGN REQUIREMENTS', + imageFileIds: [], + dateCreated: new Date('2025-09-01T10:00:00Z'), + ruleset: { connect: { rulesetId } }, + createdBy: { connect: { userId: userCreatedId } }, + parentRule: { connect: { ruleId: parentRuleId } } + }; +}; + +const thirdLevelRule = (rulesetId: string, userCreatedId: string, parentRuleId: string): Prisma.RuleCreateInput => { + return { + ruleCode: 'T2.1', + ruleContent: 'T2.1 Vehicle Configuration', + imageFileIds: [], + dateCreated: new Date('2025-09-01T10:00:00Z'), + ruleset: { connect: { rulesetId } }, + createdBy: { connect: { userId: userCreatedId } }, + parentRule: { connect: { ruleId: parentRuleId } } + }; +}; + +const leafRule = (rulesetId: string, userCreatedId: string, parentRuleId: string): Prisma.RuleCreateInput => { + return { + ruleCode: 'T2.1.1', + ruleContent: + 'The vehicle must be open-wheeled and open-cockpit (a formula style body) with four (4) wheels that are not in a straight line.', + imageFileIds: [], + dateCreated: new Date('2025-09-01T10:00:00Z'), + ruleset: { connect: { rulesetId } }, + createdBy: { connect: { userId: userCreatedId } }, + parentRule: { connect: { ruleId: parentRuleId } } + }; +}; + +// ruleset types +const rulesetType1 = (userCreatedId: string, organizationId: string): Prisma.Ruleset_TypeCreateInput => { + return { + name: 'FSAE', + createdBy: { connect: { userId: userCreatedId } }, + organization: { connect: { organizationId } } + }; +}; + +const rulesetType2 = (userCreatedId: string, organizationId: string): Prisma.Ruleset_TypeCreateInput => { + return { + name: 'FHE', + createdBy: { connect: { userId: userCreatedId } }, + organization: { connect: { organizationId } } + }; +}; + +const emptyRulesetType = (userCreatedId: string, organizationId: string): Prisma.Ruleset_TypeCreateInput => { + return { + name: 'Empty Ruleset Type', + createdBy: { connect: { userId: userCreatedId } }, + organization: { connect: { organizationId } } + }; +}; + +// rulesets +const ruleset1 = (carId: string, userCreatedId: string, rulesetTypeId: string): Prisma.RulesetCreateInput => { + return { + name: 'FSAE Rules 2025', + fileId: 'fsae-rules-2025', + active: true, + dateCreated: new Date('2025-01-01T10:00:00Z'), + car: { connect: { carId } }, + createdBy: { connect: { userId: userCreatedId } }, + rulesetType: { connect: { rulesetTypeId } } + }; +}; + +const secondActiveRuleset = (carId: string, userCreatedId: string, rulesetTypeId: string): Prisma.RulesetCreateInput => { + return { + name: 'Another Active FSAE Rules 2025 Revision', + fileId: '2active-fsae-rules-2025', + 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 { + currentStatus: 'REVIEW', + rule: { connect: { ruleId } }, + project: { connect: { projectId } }, + createdBy: { connect: { userId: createdByUserId } } + }; +}; + +const projectRule2 = (projectId: string, ruleId: string, createdByUserId: string): Prisma.Project_RuleCreateInput => { + return { + currentStatus: 'REVIEW', + 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; +}; + +export const ruleSeedData = { + topLevelRule, + secondLevelRule, + thirdLevelRule, + leafRule, + rulesetType1, + rulesetType2, + emptyRulesetType, + ruleset1, + secondActiveRuleset, + projectRule1, + projectRule2 +}; diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 8b641d8659..fd8527dcb1 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -22,7 +22,15 @@ import { dbSeedAllTeams } from './seed-data/teams.seed.js'; import { seedReimbursementRequests } from './seed-data/reimbursement-requests.seed.js'; import ChangeRequestsService from '../services/change-requests.services.js'; import TeamsService from '../services/teams.services.js'; -import { DayOfWeek, MaterialStatus, RoleEnum, StandardChangeRequest, WbsElementStatus, WorkPackageStage } from 'shared'; +import { + DesignReviewStatus, + MaterialStatus, + RoleEnum, + SpecialPermission, + StandardChangeRequest, + WbsElementStatus, + WorkPackageStage +} from 'shared'; import TasksService from '../services/tasks.services.js'; import { seedProject } from './seed-data/projects.seed.js'; import { seedWorkPackage } from './seed-data/work-packages.seed.js'; @@ -36,10 +44,14 @@ import { writeFileSync, readFileSync } from 'fs'; import WbsElementTemplatesService from '../services/wbs-element-templates.services.js'; import RecruitmentServices from '../services/recruitment.services.js'; import OrganizationsService from '../services/organizations.services.js'; +import { seedGraph } from './seed-data/statistics.seed.js'; import AnnouncementService from '../services/announcement.services.js'; import OnboardingServices from '../services/onboarding.services.js'; import { dbSeedAllParts, dbSeedAllPartTags } from './seed-data/parts.seed.js'; import FinanceServices from '../services/finance.services.js'; +import { ruleSeedData } from './seed-data/rules.seed.js'; +import RulesService from '../services/rules.services.js'; +import { seedRulesetType } from './seed-data/rules.seed.js'; import CalendarService from '../services/calendar.services.js'; const prisma = new PrismaClient(); @@ -263,7 +275,7 @@ const performSeed: () => Promise = async () => { const trang = await createUser(dbSeedAllUsers.trang, RoleEnum.MEMBER, organizationId); const regina = await createUser(dbSeedAllUsers.regina, RoleEnum.MEMBER, organizationId); const patrick = await createUser(dbSeedAllUsers.patrick, RoleEnum.MEMBER, organizationId); - const spongebob = await createUser(dbSeedAllUsers.spongebob, RoleEnum.MEMBER, organizationId); + const spongebob = await createUser(dbSeedAllUsers.spongebob, RoleEnum.GUEST, organizationId); await UsersService.updateUserRole(cyborg.userId, thomasEmrax, 'APP_ADMIN', ner); @@ -425,14 +437,6 @@ const performSeed: () => Promise = async () => { ner ); - // Set finance delegates for the organization - await OrganizationsService.setFinanceDelegates(thomasEmrax, organizationId, [ - monopolyMan.userId, - mrKrabs.userId, - richieRich.userId, - johnBoddy.userId - ]); - await TeamsService.setTeamMembers( aang, avatarBenders.teamId, @@ -1120,8 +1124,8 @@ const performSeed: () => Promise = async () => { 'Bodywork Concept of Design', changeRequestProject1Id, WorkPackageStage.Design, - weeksAgo(12).toISOString().split('T')[0], - 6, + '01/01/2023', + 3, [], [], thomasEmrax, @@ -1140,7 +1144,7 @@ const performSeed: () => Promise = async () => { 'ACTIVATION', thomasEmrax.userId, joeShmoe.userId, - weeksAgo(12), + new Date('2024-03-25T04:00:00.000Z'), true, ner ); @@ -1166,7 +1170,7 @@ const performSeed: () => Promise = async () => { 'Adhesive Shear Strength Test', changeRequestProject1Id, WorkPackageStage.Research, - weeksAgo(10).toISOString().split('T')[0], + '01/22/2023', 5, [], [], @@ -1184,8 +1188,8 @@ const performSeed: () => Promise = async () => { 'Manufacture Wiring Harness', changeRequestProject5Id, WorkPackageStage.Manufacturing, - weeksAgo(9).toISOString().split('T')[0], - 4, + '02/01/2023', + 3, [], [], thomasEmrax, @@ -1204,7 +1208,7 @@ const performSeed: () => Promise = async () => { CR_Type.ACTIVATION, regina.userId, janis.userId, - weeksAgo(9), + new Date('2023-08-21T04:00:00.000Z'), true, ner ); @@ -1217,8 +1221,8 @@ const performSeed: () => Promise = async () => { 'Install Wiring Harness', changeRequestProject5Id, WorkPackageStage.Install, - weeksAgo(5).toISOString().split('T')[0], - 6, + '04/01/2023', + 7, [], [], thomasEmrax, @@ -1237,7 +1241,7 @@ const performSeed: () => Promise = async () => { CR_Type.ACTIVATION, joeShmoe.userId, thomasEmrax.userId, - weeksAgo(5), + new Date('2023-10-02T04:00:00.000Z'), true, ner ); @@ -1250,7 +1254,7 @@ const performSeed: () => Promise = async () => { 'Design Plush', changeRequestProject6Id, WorkPackageStage.Design, - weeksAgo(16).toISOString().split('T')[0], + '04/02/2023', 7, [], [], @@ -1270,7 +1274,7 @@ const performSeed: () => Promise = async () => { CR_Type.ACTIVATION, katara.userId, aang.userId, - weeksAgo(16), + new Date('2023-05-08T04:00:00.000Z'), true, ner ); @@ -1283,8 +1287,8 @@ const performSeed: () => Promise = async () => { 'Put Plush Together', changeRequestProject6Id, WorkPackageStage.Manufacturing, - weeksAgo(9).toISOString().split('T')[0], - 5, + '04/02/2023', + 7, [], [], aang, @@ -1303,7 +1307,7 @@ const performSeed: () => Promise = async () => { CR_Type.ACTIVATION, katara.userId, aang.userId, - weeksAgo(9), + new Date('2023-07-31T04:00:00.000Z'), true, ner ); @@ -1316,8 +1320,8 @@ const performSeed: () => Promise = async () => { 'Plush Testing', changeRequestProject6Id, WorkPackageStage.Testing, - weeksAgo(4).toISOString().split('T')[0], - 4, + '04/02/2023', + 3, [], [], aang, @@ -1336,7 +1340,7 @@ const performSeed: () => Promise = async () => { CR_Type.ACTIVATION, katara.userId, aang.userId, - weeksAgo(4), + new Date('2023-10-09T04:00:00.000Z'), true, ner ); @@ -1350,8 +1354,8 @@ const performSeed: () => Promise = async () => { 'Design Laser Canon', changeRequestProject7Id, WorkPackageStage.Design, - weeksAgo(8).toISOString().split('T')[0], - 5, + '01/01/2023', + 3, [], [], zatanna, @@ -1370,7 +1374,7 @@ const performSeed: () => Promise = async () => { CR_Type.ACTIVATION, zatanna.userId, lexLuther.userId, - weeksAgo(8), + new Date('2024-03-25T04:00:00.000Z'), true, ner ); @@ -1383,8 +1387,8 @@ const performSeed: () => Promise = async () => { 'Laser Canon Research', changeRequestProject7Id, WorkPackageStage.Research, - weeksAgo(3).toISOString().split('T')[0], - 6, + '01/22/2023', + 5, [], [], zatanna, @@ -1401,8 +1405,8 @@ const performSeed: () => Promise = async () => { 'Laser Canon Testing', changeRequestProject7Id, WorkPackageStage.Testing, - weeksFromNow(3).toISOString().split('T')[0], - 4, + '02/15/2023', + 3, [], [], zatanna, @@ -1420,8 +1424,8 @@ const performSeed: () => Promise = async () => { 'Stadium Research', changeRequestProject8Id, WorkPackageStage.Research, - weeksAgo(14).toISOString().split('T')[0], - 7, + '02/01/2023', + 5, [], [], mikeMacdonald, @@ -1440,7 +1444,7 @@ const performSeed: () => Promise = async () => { CR_Type.ACTIVATION, mikeMacdonald.userId, ryanGiggs.userId, - weeksAgo(14), + new Date('2023-08-21T04:00:00.000Z'), true, ner ); @@ -1453,8 +1457,8 @@ const performSeed: () => Promise = async () => { 'Stadium Install', changeRequestProject8Id, WorkPackageStage.Install, - weeksAgo(7).toISOString().split('T')[0], - 6, + '03/01/2023', + 8, [], [], mikeMacdonald, @@ -1471,8 +1475,8 @@ const performSeed: () => Promise = async () => { 'Stadium Testing', changeRequestProject8Id, WorkPackageStage.Testing, - weeksAgo(1).toISOString().split('T')[0], - 5, + '06/01/2023', + 3, [], [], mikeMacdonald, @@ -1535,7 +1539,7 @@ const performSeed: () => Promise = async () => { CR_Type.ACTIVATION, thomasEmrax.userId, joeShmoe.userId, - weeksAgo(9), + new Date('02/01/2023'), true, ner ); @@ -1815,7 +1819,7 @@ const performSeed: () => Promise = async () => { /** * Reimbursements */ - const vendorTesla = await ReimbursementRequestService.createVendor( + const vendor = await ReimbursementRequestService.createVendor( thomasEmrax, 'Tesla', ner, @@ -1826,7 +1830,7 @@ const performSeed: () => Promise = async () => { 'racecar228!', 'SAVE50!' ); - const vendorAmazon = await ReimbursementRequestService.createVendor( + await ReimbursementRequestService.createVendor( thomasEmrax, 'Amazon', ner, @@ -1837,7 +1841,7 @@ const performSeed: () => Promise = async () => { 'racecare228!', 'SAVE20!' ); - const vendorGoogle = await ReimbursementRequestService.createVendor( + await ReimbursementRequestService.createVendor( thomasEmrax, 'Google', ner, @@ -1848,7 +1852,7 @@ const performSeed: () => Promise = async () => { 'racecar228!', 'SAVE50!' ); - const vendorMicrosoft = await ReimbursementRequestService.createVendor( + await ReimbursementRequestService.createVendor( thomasEmrax, 'Microsoft', ner, @@ -1859,7 +1863,7 @@ const performSeed: () => Promise = async () => { 'secure123!', 'WELCOME10' ); - const vendorApple = await ReimbursementRequestService.createVendor( + await ReimbursementRequestService.createVendor( thomasEmrax, 'Apple', ner, @@ -1870,7 +1874,7 @@ const performSeed: () => Promise = async () => { 'appl3Secure!', 'APPLE30' ); - const vendorCostco = await ReimbursementRequestService.createVendor( + await ReimbursementRequestService.createVendor( thomasEmrax, 'Costco', ner, @@ -1881,7 +1885,7 @@ const performSeed: () => Promise = async () => { 'bulkBuy22!', 'BULKDEAL' ); - const vendorWalmart = await ReimbursementRequestService.createVendor( + await ReimbursementRequestService.createVendor( thomasEmrax, 'Walmart', ner, @@ -1892,7 +1896,7 @@ const performSeed: () => Promise = async () => { 'WalMartP@ss1', 'ROLLBACK15' ); - const vendorTarget = await ReimbursementRequestService.createVendor( + await ReimbursementRequestService.createVendor( thomasEmrax, 'Target', ner, @@ -2102,109 +2106,122 @@ const performSeed: () => Promise = async () => { 3010 ); - // Add userSecureSettings for users who will create reimbursement requests - const usersNeedingSecureSettings = [ - { user: joeShmoe, varName: 'joeShmoe' }, - { user: batman, varName: 'batman' }, - { user: superman, varName: 'superman' }, - { user: flash, varName: 'flash' }, - { user: aquaman, varName: 'aquaman' }, - { user: wonderwoman, varName: 'wonderwoman' }, - { user: greenLantern, varName: 'greenLantern' }, - { user: cyborg, varName: 'cyborg' }, - { user: martianManhunter, varName: 'martianManhunter' }, - { user: nightwing, varName: 'nightwing' }, - { user: aang, varName: 'aang' }, - { user: katara, varName: 'katara' }, - { user: sokka, varName: 'sokka' }, - { user: toph, varName: 'toph' }, - { user: zuko, varName: 'zuko' }, - { user: regina, varName: 'regina' }, - { user: cady, varName: 'cady' }, - { user: gretchen, varName: 'gretchen' }, - { user: karen, varName: 'karen' }, - { user: spongebob, varName: 'spongebob' }, - { user: patrick, varName: 'patrick' } - ]; - - const updatedUsers: any = {}; - - for (let i = 0; i < usersNeedingSecureSettings.length; i++) { - const { user, varName } = usersNeedingSecureSettings[i]; - await prisma.user_Secure_Settings.create({ - data: { - userSecureSettingsId: `secure-${user.userId}`, - userId: user.userId, - nuid: `00123456${i.toString().padStart(2, '0')}`, - phoneNumber: `123456${i.toString().padStart(4, '0')}`, - street: `${100 + i} Main St`, - city: 'Boston', - state: 'MA', - zipcode: '02115' + const reimbursement1 = await ReimbursementRequestService.createReimbursementRequest( + thomasEmrax, + vendor.vendorId, + indexCodeCash.indexCodeId, + [], + [ + { + name: 'GLUE', + reason: { + carNumber: 0, + projectNumber: 1, + workPackageNumber: 0 + }, + cost: 200000, + refundSources: [ + { + indexCode: indexCodeBudget, + amount: 150000 + }, + { + indexCode: indexCodeCash, + amount: 50000 + } + ] } - }); - - // Re-fetch user with secure settings - const updatedUser = await prisma.user.findUnique({ - where: { userId: user.userId }, - include: { userSettings: true, userSecureSettings: true, roles: true } - }); - updatedUsers[varName] = updatedUser; - } - - // Seed comprehensive reimbursement requests with various statuses and assignees - const seededReimbursementRequests = await seedReimbursementRequests( - { - thomasEmrax, - joeShmoe: updatedUsers.joeShmoe, - batman: updatedUsers.batman, - superman: updatedUsers.superman, - flash: updatedUsers.flash, - aquaman: updatedUsers.aquaman, - wonderwoman: updatedUsers.wonderwoman, - greenLantern: updatedUsers.greenLantern, - cyborg: updatedUsers.cyborg, - martianManhunter: updatedUsers.martianManhunter, - robin: updatedUsers.nightwing, // Using nightwing as robin since robin wasn't stored in a variable - nightwing: updatedUsers.nightwing, - aang: updatedUsers.aang, - katara: updatedUsers.katara, - sokka: updatedUsers.sokka, - toph: updatedUsers.toph, - zuko: updatedUsers.zuko, - monopolyMan, - mrKrabs, - richieRich, - johnBoddy, - regina: updatedUsers.regina, - cady: updatedUsers.cady, - gretchen: updatedUsers.gretchen, - karen: updatedUsers.karen, - spongebob: updatedUsers.spongebob, - patrick: updatedUsers.patrick - }, - { - tesla: vendorTesla, - amazon: vendorAmazon, - google: vendorGoogle, - microsoft: vendorMicrosoft, - apple: vendorApple, - costco: vendorCostco, - walmart: vendorWalmart, - target: vendorTarget - }, - { - cash: indexCodeCash, - budget: indexCodeBudget - }, - { - equipment: accountCode, - things: accountCode2, - stuff: accountCode3 - }, + ], + accountCode.accountCodeId, + 100, ner ); + const reimbursement3 = await ReimbursementRequestService.createReimbursementRequest( + thomasEmrax, + vendor.vendorId, + indexCodeBudget.indexCodeId, + [], + [ + { + name: 'BOX', + reason: { + carNumber: 0, + projectNumber: 1, + workPackageNumber: 0 + }, + cost: 200000, + refundSources: [ + { + indexCode: indexCodeBudget, + amount: 150000 + }, + { + indexCode: indexCodeCash, + amount: 50000 + } + ] + } + ], + accountCode.accountCodeId, + 200, + ner, + new Date() + ); + + const reimbursement2 = await ReimbursementRequestService.createReimbursementRequest( + thomasEmrax, + vendor.vendorId, + indexCodeBudget.indexCodeId, + [], + [ + { + name: 'BOX', + reason: { + carNumber: 0, + projectNumber: 1, + workPackageNumber: 0 + }, + cost: 10000, + refundSources: [ + { + indexCode: indexCodeBudget, + amount: 7000 + }, + { + indexCode: indexCodeCash, + amount: 3000 + } + ] + } + ], + accountCode.accountCodeId, + 20000, + ner, + new Date() + ); + + ReimbursementRequestService.createReimbursementRequestComment( + thomasEmrax, + ner, + 'Thomas Followed up - "Please upload reciept"', + reimbursement1.reimbursementRequestId + ); + + ReimbursementRequestService.createReimbursementRequestComment( + batman, + ner, + 'Batman Uploaded Receipt', + reimbursement1.reimbursementRequestId + ); + + ReimbursementRequestService.createReimbursementRequestComment( + thomasEmrax, + ner, + 'Thomas Submmited to SABO', + reimbursement1.reimbursementRequestId + ); + const otherProductReasonConsumables = await ReimbursementRequestService.createOtherReasonReimbursementProduct( 'CONSUMABLES', 10, @@ -2336,7 +2353,7 @@ const performSeed: () => Promise = async () => { undefined, undefined, undefined, - seededReimbursementRequests[0]?.reimbursementRequestId + reimbursement1.reimbursementRequestId ); // Need to do this because the design review cannot be scheduled for a past day @@ -2408,7 +2425,7 @@ const performSeed: () => Promise = async () => { 'Slim and Light Car', newWorkPackageChangeRequest.crId, WorkPackageStage.Design, - weeksAgo(2).toISOString().split('T')[0], + '01/22/2024', 5, [], [], @@ -2530,10 +2547,10 @@ const performSeed: () => Promise = async () => { { userId: regina.userId, title: 'Chief Electrical Engineer' } ]); - await RecruitmentServices.createMilestone(batman, 'Club fair!', 'Also meet us at:', daysAgo(120), ner); - await RecruitmentServices.createMilestone(batman, 'Applications Open', '', daysAgo(70), ner); - await RecruitmentServices.createMilestone(batman, 'Applications Close', '', daysAgo(56), ner); - await RecruitmentServices.createMilestone(batman, 'Decision Day!', '', daysAgo(49), ner); + await RecruitmentServices.createMilestone(batman, 'Club fair!', 'Also meet us at:', new Date('9/3/24'), ner); + await RecruitmentServices.createMilestone(batman, 'Applications Open', '', new Date('11/13/24'), ner); + await RecruitmentServices.createMilestone(batman, 'Applications Close', '', new Date('11/27/24'), ner); + await RecruitmentServices.createMilestone(batman, 'Decision Day!', '', new Date('12/4/24'), ner); await RecruitmentServices.createOrganizationFaq(batman, 'Who is the Chief Software Engineer?', 'Peyton McKee', ner); await RecruitmentServices.createOrganizationFaq( @@ -3035,7 +3052,7 @@ const performSeed: () => Promise = async () => { 'Google', true, 5000, - daysAgo(90), + new Date(12, 1, 24), [2024, 2025], goldSponsorTier.sponsorTierId, true, @@ -3048,13 +3065,404 @@ const performSeed: () => Promise = async () => { await FinanceServices.createSponsorTask( thomasEmrax, ner, - daysFromNow(30), + new Date(12, 1, 25), 'notes...', sponsor.sponsorId, - daysAgo(60), + new Date(7, 5, 25), thomasEmrax.userId ); + /** + * Rules + */ + + // ruleset types + const fsaeRulesetType = await prisma.ruleset_Type.create({ + data: ruleSeedData.rulesetType1(batman.userId, ner.organizationId) + }); + + await prisma.ruleset_Type.create({ + data: ruleSeedData.rulesetType2(batman.userId, ner.organizationId) + }); + + await prisma.ruleset_Type.create({ + data: ruleSeedData.emptyRulesetType(batman.userId, ner.organizationId) + }); + + // rulesets + const ruleset1 = await prisma.ruleset.create({ + data: ruleSeedData.ruleset1(fergus.carId, batman.userId, fsaeRulesetType.rulesetTypeId) + }); + + await prisma.ruleset.create({ + data: ruleSeedData.secondActiveRuleset(fergus.carId, batman.userId, fsaeRulesetType.rulesetTypeId) + }); + + const fsae2025Ruleset = await prisma.ruleset.create({ + data: { + fileId: 'fsae-2025-rules-file-id', + name: '2025 FSAE Electric Rules', + active: true, + rulesetTypeId: fsaeRulesetType.rulesetTypeId, + carId: fergus.carId, + createdByUserId: batman.userId + } + }); + + const fsae2024Ruleset = await prisma.ruleset.create({ + data: { + fileId: 'fsae-2024-rules-file-id', + name: '2024 FSAE Electric Rules', + active: false, + rulesetTypeId: fsaeRulesetType.rulesetTypeId, + carId: fergus.carId, + createdByUserId: batman.userId + } + }); + + // rules + const ruleT = await prisma.rule.create({ data: ruleSeedData.topLevelRule(ruleset1.rulesetId, batman.userId) }); + const ruleT2 = await prisma.rule.create({ + data: ruleSeedData.secondLevelRule(ruleset1.rulesetId, batman.userId, ruleT.ruleId) + }); + const ruleT21 = await prisma.rule.create({ + data: ruleSeedData.thirdLevelRule(ruleset1.rulesetId, batman.userId, ruleT2.ruleId) + }); + const ruleT211 = await prisma.rule.create({ + data: ruleSeedData.leafRule(ruleset1.rulesetId, batman.userId, ruleT21.ruleId) + }); + + // project rules + await RulesService.createProjectRule(batman, ner, ruleT211.ruleId, project1Id); + + // Technical Rules Section + const techRule = await prisma.rule.create({ + data: { + ruleCode: 'T.1', + ruleContent: 'Technical Rules - All technical requirements for the vehicle must be met to compete', + rulesetId: fsae2025Ruleset.rulesetId, + createdByUserId: batman.userId + } + }); + + const vehicleConfigRule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.1', + ruleContent: 'Vehicle Configuration - The vehicle must be a four-wheeled, open-wheel, open-cockpit vehicle', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: techRule.ruleId, + createdByUserId: thomasEmrax.userId, + imageFileIds: [] + } + }); + + const wheelRule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.1.1', + ruleContent: 'All four wheels must be visible when viewed from above. Wheels must not exceed 13 inches in diameter', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: vehicleConfigRule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + const wheelbaseRule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.1.2', + ruleContent: 'The wheelbase must be at least 1525 mm (60 inches)', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: vehicleConfigRule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + const trackWidthRule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.1.3', + ruleContent: 'The smaller track width must be no less than 75% of the wheelbase', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: vehicleConfigRule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + // Powertrain Rules + const powertrainRule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.2', + ruleContent: 'Powertrain - Electric powertrain systems must comply with all electrical safety requirements', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: techRule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + const motorRule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.2.1', + ruleContent: 'The maximum nominal voltage of the accumulator must not exceed 600 VDC', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: powertrainRule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + const motorPowerRule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.2.2', + ruleContent: 'The maximum continuous power delivered by the accumulator must not exceed 80 kW', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: powertrainRule.ruleId, + createdByUserId: joeBlow.userId + } + }); + + // Chassis Rules + const chassisRule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.3', + ruleContent: 'Chassis and Frame - The chassis must provide adequate driver protection', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: techRule.ruleId, + createdByUserId: batman.userId, + imageFileIds: ['chassis-spec-drawing-1', 'chassis-spec-drawing-2'] + } + }); + + const chassisMaterialRule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.3.1', + ruleContent: 'The frame must be a space frame design or a carbon fiber monocoque meeting specific standards', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: chassisRule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + // Safety Rules Section + const safetyRule = await prisma.rule.create({ + data: { + ruleCode: 'S.1', + ruleContent: 'Safety Rules - All safety requirements must be met before the vehicle is allowed to compete', + rulesetId: fsae2025Ruleset.rulesetId, + createdByUserId: batman.userId + } + }); + + const frameRule = await prisma.rule.create({ + data: { + ruleCode: 'S.1.1', + ruleContent: + 'Frame Requirements - The main hoop must be directly behind the driver and be the tallest part of the car', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: safetyRule.ruleId, + createdByUserId: batman.userId + } + }); + + const rollHoopRule = await prisma.rule.create({ + data: { + ruleCode: 'S.1.1.1', + ruleContent: 'The main roll hoop must extend from the lowest chassis frame members on one side to the other', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: frameRule.ruleId, + createdByUserId: superman.userId + } + }); + + const harnessRule = await prisma.rule.create({ + data: { + ruleCode: 'S.1.2', + ruleContent: 'Harness - A 5-point or 6-point harness must be used, meeting SFI 16.1 or FIA 8853/98 standards', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: safetyRule.ruleId, + createdByUserId: superman.userId + } + }); + + const fireExtinguisherRule = await prisma.rule.create({ + data: { + ruleCode: 'S.1.3', + ruleContent: 'Fire Extinguisher - An onboard fire extinguisher system must be installed and accessible', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: safetyRule.ruleId, + createdByUserId: batman.userId + } + }); + + // Braking System Rules with Cross-References + const brakingRule = await prisma.rule.create({ + data: { + ruleCode: 'T.2.1', + ruleContent: + 'Braking System - The vehicle must have a braking system that acts on all four wheels and operates on two independent hydraulic circuits', + rulesetId: fsae2025Ruleset.rulesetId, + createdByUserId: thomasEmrax.userId, + referencedRule: { + connect: [{ ruleId: vehicleConfigRule.ruleId }, { ruleId: wheelRule.ruleId }] + } + } + }); + + const brakePedalRule = await prisma.rule.create({ + data: { + ruleCode: 'T.2.1.1', + ruleContent: 'The brake pedal must be capable of locking all four wheels in both dry and wet conditions', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: brakingRule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + // Electrical System Rules with References + const electricalSystemRule = await prisma.rule.create({ + data: { + ruleCode: 'T.3.1', + ruleContent: 'Electrical System - All high voltage components must be protected and isolated per safety requirements', + rulesetId: fsae2025Ruleset.rulesetId, + createdByUserId: thomasEmrax.userId, + imageFileIds: ['electrical-diagram-1', 'electrical-diagram-2', 'electrical-diagram-3'], + referencedRule: { + connect: [{ ruleId: powertrainRule.ruleId }, { ruleId: safetyRule.ruleId }] + } + } + }); + + const shutdownCircuitRule = await prisma.rule.create({ + data: { + ruleCode: 'T.3.1.1', + ruleContent: 'A shutdown circuit must be installed that disables the tractive system when activated', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: electricalSystemRule.ruleId, + createdByUserId: joeBlow.userId + } + }); + + const shutdownButtonRule = await prisma.rule.create({ + data: { + ruleCode: 'T.3.1.2', + ruleContent: 'Shutdown buttons must be located on both sides of the vehicle and be easily accessible', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: electricalSystemRule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + // Accumulator Container Rules + const accumulatorRule = await prisma.rule.create({ + data: { + ruleCode: 'T.3.2', + ruleContent: 'Accumulator Container - The accumulator container must protect the cells from impact and debris', + rulesetId: fsae2025Ruleset.rulesetId, + createdByUserId: batman.userId, + referencedRule: { + connect: [{ ruleId: safetyRule.ruleId }] + } + } + }); + + const accumulatorMountingRule = await prisma.rule.create({ + data: { + ruleCode: 'T.3.2.1', + ruleContent: 'The accumulator container must be rigidly mounted to the frame', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: accumulatorRule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + // General Rules (Orphan - no parent) + const generalRule = await prisma.rule.create({ + data: { + ruleCode: 'G.1', + ruleContent: + 'General - All rules are subject to interpretation by competition officials. When in doubt, contact the rules committee', + rulesetId: fsae2025Ruleset.rulesetId, + createdByUserId: batman.userId + } + }); + + const competitionEligibilityRule = await prisma.rule.create({ + data: { + ruleCode: 'G.2', + ruleContent: 'Competition Eligibility - Teams must register before the deadline and submit all required documentation', + rulesetId: fsae2025Ruleset.rulesetId, + createdByUserId: superman.userId + } + }); + + // Driver Requirements + const driverRule = await prisma.rule.create({ + data: { + ruleCode: 'S.2', + ruleContent: 'Driver Requirements - All drivers must meet safety equipment and training requirements', + rulesetId: fsae2025Ruleset.rulesetId, + createdByUserId: superman.userId + } + }); + + const helmetRule = await prisma.rule.create({ + data: { + ruleCode: 'S.2.1', + ruleContent: 'Helmet - Driver must wear a helmet meeting Snell SA2020, FIA 8859-2015, or equivalent standards', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: driverRule.ruleId, + createdByUserId: batman.userId + } + }); + + const suitRule = await prisma.rule.create({ + data: { + ruleCode: 'S.2.2', + ruleContent: 'Suit - Driver must wear a driving suit meeting SFI 3.2A/1 or FIA 8856-2000 standards', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: driverRule.ruleId, + createdByUserId: superman.userId + } + }); + + // Suspension Rules + const suspensionRule = await prisma.rule.create({ + data: { + ruleCode: 'T.4.1', + ruleContent: 'Suspension - All vehicles must have a fully operational suspension system on all wheels', + rulesetId: fsae2025Ruleset.rulesetId, + createdByUserId: thomasEmrax.userId, + referencedRule: { + connect: [{ ruleId: wheelRule.ruleId }] + } + } + }); + + const suspensionTravelRule = await prisma.rule.create({ + data: { + ruleCode: 'T.4.1.1', + ruleContent: 'The suspension must have at least 50.8 mm (2 inches) of travel', + rulesetId: fsae2025Ruleset.rulesetId, + parentRuleId: suspensionRule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + // Adding some rules to the 2024 ruleset as well + const tech2024Rule = await prisma.rule.create({ + data: { + ruleCode: 'T.1', + ruleContent: 'Technical Rules - 2024 Edition', + rulesetId: fsae2024Ruleset.rulesetId, + createdByUserId: batman.userId + } + }); + + const vehicle2024Rule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.1', + ruleContent: 'Vehicle must be four-wheeled (2024 rules)', + rulesetId: fsae2024Ruleset.rulesetId, + parentRuleId: tech2024Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); // Create shops for machinery const advancedShop = await prisma.shop.create({ data: { diff --git a/src/backend/src/routes/rules.routes.ts b/src/backend/src/routes/rules.routes.ts new file mode 100644 index 0000000000..7fd70825b0 --- /dev/null +++ b/src/backend/src/routes/rules.routes.ts @@ -0,0 +1,108 @@ +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( + '/projectRule/:projectRuleId/editStatus', + nonEmptyString(body('newStatus')), + validateInputs, + RulesController.editProjectRuleStatus +); + +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..86bd54f5bd --- /dev/null +++ b/src/backend/src/services/rules.services.ts @@ -0,0 +1,1466 @@ +import { Organization, Rule, Rule_Completion } from '@prisma/client'; +import { + isAdmin, + isLeadership, + ProjectRule, + RulesetType, + notGuest, + User, + Rule as SharedRule, + isHead, + Ruleset +} from 'shared'; +import prisma from '../prisma/prisma'; +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'); + } + + const projectRule = await prisma.project_Rule.create({ + data: { + ruleId, + projectId, + currentStatus: Rule_Completion.REVIEW, + createdByUserId: submitter.userId + }, + ...getProjectRuleQueryArgs() + }); + + 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); + } + + /** + * Updates the status of a project rule + * Such as changing a project rule from INCOMPLETE to COMPLETED + * @param submitter the user updating the status + * @param organization the organization of the rule + * @param projectRuleId the id of the project rule to update + * @param newStatus the new status of the project rule + * @returns the project rule with updated status + */ + static async editProjectRuleStatus( + submitter: User, + organization: Organization, + projectRuleId: string, + newStatus: Rule_Completion + ): Promise { + // Ensure new satus is a valid Rule_Completion value + if (!Object.values(Rule_Completion).includes(newStatus as Rule_Completion)) { + throw new HttpException(400, `status must be one of: ${Object.values(Rule_Completion).join(', ')}`); + } + + if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) { + throw new AccessDeniedException('You do not have permissions to update a project rule status'); + } + + const projectRule = await prisma.project_Rule.findUnique({ + where: { projectRuleId }, + include: { rule: { include: { ruleset: { include: { car: { include: { wbsElement: true } } } } } } } + }); + + if (!projectRule) { + throw new NotFoundException('Project Rule', projectRuleId); + } + + if (projectRule.rule.ruleset.car.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Project Rule'); + } + + // If the status does not change, simply return the project rule + if (projectRule.currentStatus === newStatus) { + const originalProjectRule = await prisma.project_Rule.findUnique({ + where: { projectRuleId }, + ...getProjectRuleQueryArgs() + }); + return projectRuleTransformer(originalProjectRule); + } + + const newStatusHistory = { + createdByUserId: submitter.userId, + newStatus, + note: `${submitter.firstName} ${submitter.lastName} marked as ${newStatus}` + }; + + const updatedProjectRule = await prisma.project_Rule.update({ + where: { projectRuleId }, + data: { currentStatus: newStatus, statusHistory: { create: newStatusHistory } }, + ...getProjectRuleQueryArgs() + }); + + return projectRuleTransformer(updatedProjectRule); + } + + /** + * 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, 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 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 rules = await prisma.rule.findMany({ + where: { + rulesetId, + teams: { + some: { + teamId, + organizationId + } + }, + projects: { + none: {} + }, + 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..8dea729b74 --- /dev/null +++ b/src/backend/src/transformers/rules.transformer.ts @@ -0,0 +1,66 @@ +import { Prisma } from '@prisma/client'; +import { Rule, ProjectRule, Ruleset, RulesetType } from 'shared'; +import { RulesetQueryArgs, RulePreviewQueryArgs } from '../prisma-query-args/rules.query-args'; + +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 + })) + }; +}; + +export const projectRuleTransformer = (projectRule: any): ProjectRule => { + return { + projectRuleId: projectRule.projectRuleId, + rule: ruleTransformer(projectRule.rule), + projectId: projectRule.projectId, + currentStatus: projectRule.currentStatus, + statusHistory: projectRule.statusHistory + }; +}; + +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 14cb219315..93a91f0230 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 cda5e85f94..ffa538c2da 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -115,6 +115,11 @@ export const resetUsers = async () => { await prisma.part_Review.deleteMany(); await prisma.part_Submission.deleteMany(); await prisma.part.deleteMany(); + await prisma.rule_Status_Change.deleteMany(); + await prisma.project_Rule.deleteMany(); + await prisma.rule.deleteMany(); + await prisma.ruleset.deleteMany(); + await prisma.ruleset_Type.deleteMany(); await prisma.project.deleteMany(); await prisma.frequentlyAskedQuestion.deleteMany(); await prisma.material.deleteMany(); @@ -130,6 +135,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..3304562f7a --- /dev/null +++ b/src/backend/tests/unit/rule.test.ts @@ -0,0 +1,1953 @@ +import RulesService from '../../src/services/rules.services'; +import { Organization, User, Project, Car, Ruleset_Type, Ruleset, Rule_Completion, 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('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', + applyInterestImageId: '1', + exploreAsGuestImageId: '1', + 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.statusHistory).toEqual([]); + expect(projectRule.currentStatus).toBe(Rule_Completion.REVIEW); + }); + 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.statusHistory).toEqual([]); + expect(projectRule.currentStatus).toBe(Rule_Completion.REVIEW); + }); + 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') + ); + }); + + // Updating Project Rule Status + it('Updates a project rule status successfully', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + + const updatedProjectRule = await RulesService.editProjectRuleStatus( + admin, + organization, + projectRule.projectRuleId, + Rule_Completion.COMPLETED + ); + + expect(updatedProjectRule.projectRuleId).toBe(projectRule.projectRuleId); + expect(updatedProjectRule.currentStatus).toBe(Rule_Completion.COMPLETED); + expect(updatedProjectRule.statusHistory.length).toBe(1); + expect(updatedProjectRule.statusHistory[0].newStatus).toBe(Rule_Completion.COMPLETED); + expect(updatedProjectRule.statusHistory[0].projectRuleId).toBe(projectRule.projectRuleId); + expect(updatedProjectRule.statusHistory[0].createdBy.userId).toBe(admin.userId); + expect(new Date(updatedProjectRule.statusHistory[0].dateCreated).getTime()).toBeGreaterThan(Date.now() - 10000); + }); + + it('Updates a project rule status to the same status', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + + const updatedProjectRule = await RulesService.editProjectRuleStatus( + admin, + organization, + projectRule.projectRuleId, + Rule_Completion.REVIEW + ); + + expect(updatedProjectRule.projectRuleId).toBe(projectRule.projectRuleId); + expect(updatedProjectRule.currentStatus).toBe(Rule_Completion.REVIEW); + expect(updatedProjectRule.statusHistory).toHaveLength(0); + }); + + it('Update project rule fails if user does not have permission', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + + await expect( + async () => + await RulesService.editProjectRuleStatus( + nonLeadership, + organization, + projectRule.projectRuleId, + Rule_Completion.REVIEW + ) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update a project rule status')); + }); + }); + + 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); + + await RulesService.editProjectRuleStatus(admin, organization, projectRule.projectRuleId, Rule_Completion.COMPLETED); + await RulesService.editProjectRuleStatus(admin, organization, projectRule.projectRuleId, Rule_Completion.INCOMPLETE); + + const deletedProjectRule = await RulesService.deleteProjectRule(projectRule.projectRuleId, admin, organization); + + expect(deletedProjectRule).toBeDefined(); + expect(deletedProjectRule.projectRuleId).toBe(projectRule.projectRuleId); + + const statusChanges = await prisma.rule_Status_Change.findMany({ + where: { projectRuleId: projectRule.projectRuleId } + }); + expect(statusChanges.length).toBeGreaterThan(0); + statusChanges.forEach((statusChange) => { + expect(statusChange.dateDeleted).toBeDefined(); + // expect(statusChange.deletedByUserId).toBe(admin.userId); + }); + }); + 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, 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, organization.organizationId) + ).rejects.toThrow(InvalidOrganizationException); + }); + it('fails if ruleset does not exist', async () => { + await expect( + RulesService.getUnassignedRulesForRuleset('nonexistent-ruleset-id', testTeam.teamId, organization.organizationId) + ).rejects.toThrow(new NotFoundException('Ruleset', 'nonexistent-ruleset-id')); + }); + it('fails if team does not exist', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + await expect( + RulesService.getUnassignedRulesForRuleset(ruleset1.rulesetId, 'fake-team-id', organization.organizationId) + ).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, + currentStatus: Rule_Completion.REVIEW, + createdByUserId: admin.userId + } + }); + const rules = await RulesService.getUnassignedRulesForRuleset( + ruleset1.rulesetId, + testTeam.teamId, + 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, + 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..071e0bc891 --- /dev/null +++ b/src/frontend/src/apis/rules.api.ts @@ -0,0 +1,223 @@ +/* + * 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, RuleCompletion, 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) => { + return axios.get(apiUrls.rulesGetUnassignedRulesForRuleset(rulesetId, teamId), { + 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)); +}; + +/** + * Updates project rule status + */ +export const editProjectRuleStatus = (projectRuleId: string, newStatus: RuleCompletion) => { + return axios.post(apiUrls.rulesEditProjectRuleStatus(projectRuleId), { newStatus }); +}; + +/** + * 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..7d187ed7b4 --- /dev/null +++ b/src/frontend/src/apis/transformers/rules.transformers.ts @@ -0,0 +1,65 @@ +/* + * 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), + statusHistory: (projectRule.statusHistory || []).map((history) => ({ + ...history, + dateCreated: new Date(history.dateCreated) + })) + }; +}; + +/** + * 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 8ab368c60c..cd1431a25b 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'; @@ -130,6 +131,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..08113c250f --- /dev/null +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -0,0 +1,556 @@ +/* + * 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, RuleCompletion, Ruleset, RulesetType } from 'shared'; +import { + createRulesetType, + getAllRulesetTypes, + getActiveRuleset, + getProjectRules, + getUnassignedRulesForRuleset, + createProjectRule, + deleteProjectRule, + editProjectRuleStatus, + 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) => { + return useQuery( + ['rules', 'unassigned', rulesetId, teamId], + async () => { + const { data } = await getUnassignedRulesForRuleset(rulesetId, teamId); + return data; + }, + { enabled: !!rulesetId && !!teamId } + ); +}; + +/** + * 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 update project rule status. + */ +export const useEditProjectRuleStatus = (rulesetId: string, projectId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['rules', 'projectRules', 'editStatus'], + async ({ projectRuleId, newStatus }) => { + const { data } = await editProjectRuleStatus(projectRuleId, newStatus); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rules', 'projectRules', rulesetId, projectId]); + } + } + ); +}; + +/** + * 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 6fd7b11f4d..5a988cc429 100644 --- a/src/frontend/src/layouts/Sidebar/Sidebar.tsx +++ b/src/frontend/src/layouts/Sidebar/Sidebar.tsx @@ -15,6 +15,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'; @@ -99,6 +100,11 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid icon: , route: routes.RETROSPECTIVE }, + { + name: 'Rules', + icon: , + route: routes.RULES + }, { name: 'Info', icon: , diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx new file mode 100644 index 0000000000..03745e7b78 --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx @@ -0,0 +1,249 @@ +/* + * 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; + onSubmit: (ruleIds: string[]) => void; +} + +const AddRuleModal = ({ open, onHide, rulesetId, teamId, onSubmit }: AddRuleModalProps) => { + const theme = useTheme(); + const [selectedRuleIds, setSelectedRuleIds] = useState([]); + + const { data: unassignedRules, isLoading, isError } = useUnassignedRulesForRuleset(rulesetId, teamId); + + 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 ( + + + {isLoading ? ( + + + + ) : isError ? ( + Failed to load rules + ) : !unassignedRules || unassignedRules.length === 0 ? ( + + No unassigned rules available for this 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..e29bfeef92 --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -0,0 +1,447 @@ +/* + * 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 +} from '@mui/material'; +import { Project, ProjectRule, Rule, RuleCompletion } from 'shared'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import ErrorPage from '../../../ErrorPage'; +import RuleRow from '../../../RulesPage/RuleRow'; +import UpdateStatusPopover from './UpdateStatusPopover'; +import AddRuleModal from './AddRuleModal'; +import { + useAllRulesetTypes, + useActiveRuleset, + useProjectRules, + useEditProjectRuleStatus, + useCreateProjectRule +} from '../../../../hooks/rules.hooks'; +import { useToast } from '../../../../hooks/toasts.hooks'; +import { InfoOutlined } from '@mui/icons-material'; +import { RuleHistoryModal } from './RuleHistoryModal'; + +interface ProjectRulesTabProps { + project: Project; +} + +/** + * Get the status chip configuration + */ +const getStatusConfig = (status: RuleCompletion) => { + switch (status) { + case RuleCompletion.COMPLETED: + return { label: 'Complete', color: '#4caf50' }; + case RuleCompletion.INCOMPLETE: + return { label: 'Incomplete', color: '#f44336' }; + case RuleCompletion.REVIEW: + default: + return { label: 'Review', color: '#ff9800' }; + } +}; + +export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { + const toast = useToast(); + const theme = useTheme(); + + // 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); + + const [selectedRuleForHistory, setSelectedRuleForHistory] = useState(null); + const [showHistoryModal, setShowHistoryModal] = useState(false); + + // 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: editStatusMutation, isLoading: isUpdatingStatus } = useEditProjectRuleStatus( + 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]); + + // Helper function to get all descendant leaf rules for a given rule + const getDescendantLeafRules = (rule: Rule): Rule[] => { + const children = allRules.filter((r) => r.parentRule?.ruleId === rule.ruleId); + if (children.length === 0) { + // This is a leaf rule + return [rule]; + } + // Recursively get leaf rules from all children + return children.flatMap((child) => getDescendantLeafRules(child)); + }; + + // Helper function to calculate aggregated status from leaf rules + const getAggregatedStatus = (rule: Rule): RuleCompletion => { + const leafRules = getDescendantLeafRules(rule); + if (leafRules.length === 0) { + return RuleCompletion.REVIEW; + } + + const leafStatuses = leafRules.map((leafRule) => { + const projectRule = projectRules?.find((pr) => pr.rule.ruleId === leafRule.ruleId); + return projectRule?.currentStatus || RuleCompletion.REVIEW; + }); + + if (leafStatuses.every((s) => s === RuleCompletion.COMPLETED)) { + return RuleCompletion.COMPLETED; + } + + if (leafStatuses.some((s) => s === RuleCompletion.INCOMPLETE)) { + return RuleCompletion.INCOMPLETE; + } + + return RuleCompletion.REVIEW; + }; + + // Handle status update + const handleStatusUpdate = async (projectRuleId: string, newStatus: RuleCompletion) => { + try { + await editStatusMutation({ projectRuleId, newStatus }); + toast.success('Rule status 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); + }; + + // Loading state + if (rulesetTypesLoading) { + return ; + } + + // Error state + if (rulesetTypesError) { + 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 + const renderRightContent = (rule: Rule) => { + const hasChildren = allRules.some((r) => r.parentRule?.ruleId === rule.ruleId); + const isLeafRule = !hasChildren; + + // Get status - for leaf rules use their own status, for parents calculate from children + const status = isLeafRule + ? projectRules?.find((pr) => pr.rule.ruleId === rule.ruleId)?.currentStatus || RuleCompletion.REVIEW + : getAggregatedStatus(rule); + const statusConfig = getStatusConfig(status); + + const projectRule = projectRules?.find((pr) => pr.rule.ruleId === rule.ruleId); + + return ( + <> + ) => { + e.stopPropagation(); + handleStatusClick(e, rule); + } + : undefined + } + sx={{ + backgroundColor: statusConfig.color, + color: 'white', + fontSize: '11px', + fontWeight: 600, + px: 0.75, + py: 0.25, + borderRadius: '3px', + cursor: isLeafRule ? 'pointer' : 'default', + display: 'inline-flex', + alignItems: 'center', + whiteSpace: 'nowrap', + '&:hover': isLeafRule + ? { + opacity: 0.85 + } + : {} + }} + > + {statusConfig.label} + + {isLeafRule && projectRule && projectRule.statusHistory && projectRule.statusHistory.length > 0 && ( + { + e.stopPropagation(); + setSelectedRuleForHistory(rule); + setShowHistoryModal(true); + }} + sx={{ + padding: '2px', + color: 'text.secondary', + '&:hover': { + color: 'primary.main' + } + }} + > + + + )} + + ); + }; + + const 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) => ( + + ))} + + + + {/* Rules Content */} + {activeRulesetLoading || projectRulesLoading ? ( + + + + ) : hasNoActiveRuleset ? ( + + + No active ruleset configured for this ruleset type. + + + ) : projectRulesError ? ( + Failed to load rules + ) : topLevelRules.length === 0 ? ( + + + No rules assigned to this project yet. + + + ) : ( + + + + + {topLevelRules.map((rule) => ( + + ))} + +
+
+
+ )} + + {/* Add Rule Button */} + + + + + + + + {/* Update Status Popover */} + {selectedProjectRule && ( + + )} + + { + setShowHistoryModal(false); + setSelectedRuleForHistory(null); + }} + rule={selectedRuleForHistory} + projectRules={projectRules} + /> + + {/* Add Rule Modal */} + {activeRuleset && teamId && ( + setAddRuleModalOpen(false)} + rulesetId={activeRuleset.rulesetId} + teamId={teamId} + onSubmit={handleAddRules} + /> + )} + + {/* Loading overlay */} + {(isUpdatingStatus || isCreating) && ( + + + + )} + + ); +}; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/RuleHistoryModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/RuleHistoryModal.tsx new file mode 100644 index 0000000000..637a861c0a --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/RuleHistoryModal.tsx @@ -0,0 +1,101 @@ +import { Box, Typography } from '@mui/material'; +import NERModal from '../../../../components/NERModal'; +import NERFailButton from '../../../../components/NERFailButton'; +import { Rule, ProjectRule, RuleCompletion } from 'shared'; + +interface RuleHistoryModalProps { + open: boolean; + onClose: () => void; + rule: Rule | null; + projectRules?: ProjectRule[]; +} + +/** + * Get the status chip configuration + */ +const getStatusConfig = (status: RuleCompletion) => { + switch (status) { + case RuleCompletion.COMPLETED: + return { label: 'Complete', color: '#4caf50' }; + case RuleCompletion.INCOMPLETE: + return { label: 'Incomplete', color: '#f44336' }; + case RuleCompletion.REVIEW: + default: + return { label: 'Review', color: '#ff9800' }; + } +}; + +export const RuleHistoryModal = ({ open, onClose, rule, projectRules }: RuleHistoryModalProps) => { + if (!rule) return null; + + const projectRule = projectRules?.find((pr) => pr.rule.ruleId === rule.ruleId); + const statusHistory = projectRule?.statusHistory || []; + + const formatDate = (date: Date) => { + return new Intl.DateTimeFormat('en-US', { + month: 'numeric', + day: 'numeric', + year: 'numeric' + }).format(date); + }; + + const formatUserName = (user: { firstName: string; lastName: string }) => { + return `${user.firstName} ${user.lastName}`; + }; + + const getStatusLabel = (status: RuleCompletion) => { + const config = getStatusConfig(status); + return config.label; + }; + + return ( + + + + + {statusHistory.map((history) => ( + + + •{formatDate(history.dateCreated)} - {formatUserName(history.createdBy)} Marked as{' '} + {getStatusLabel(history.newStatus)} + + + ))} + + + + Exit + + + + ); +}; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx new file mode 100644 index 0000000000..ff5f877ed6 --- /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 { ProjectRule, RuleCompletion } from 'shared'; + +interface UpdateStatusPopoverProps { + anchorEl: HTMLElement | null; + onClose: () => void; + projectRule: ProjectRule; + onStatusChange: (projectRuleId: string, newStatus: RuleCompletion) => void; +} + +const UpdateStatusPopover = ({ anchorEl, onClose, projectRule, onStatusChange }: UpdateStatusPopoverProps) => { + const open = Boolean(anchorEl); + + const handleStatusChange = (status: RuleCompletion) => { + onStatusChange(projectRule.projectRuleId, status); + onClose(); + }; + + const statusOptions = [ + { value: RuleCompletion.COMPLETED, label: 'Completed' }, + { value: RuleCompletion.INCOMPLETE, 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 82ca5e59d7..38458cacee 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx @@ -35,6 +35,7 @@ import ChangeRequestTab from '../../../components/ChangeRequestTab'; import PartsReviewPage from './PartReview/PartsReviewPage'; import ActionsMenu from '../../../components/ActionsMenu'; import { useMyTeamAsHead } from '../../../hooks/teams.hooks'; +import { ProjectRulesTab } from './ProjectRules/ProjectRulesTab'; interface ProjectViewContainerProps { project: Project; @@ -193,7 +194,8 @@ const ProjectViewContainer: React.FC = ({ project, en { tabUrlValue: 'changes', tabName: 'Changes' }, { tabUrlValue: 'gantt', tabName: 'Gantt' }, { tabUrlValue: 'change-requests', tabName: 'Change Requests' }, - { tabUrlValue: 'parts-review', tabName: 'Parts Review' } + { tabUrlValue: 'parts-review', tabName: 'Parts Review' }, + { tabUrlValue: 'rules', tabName: 'Rules' } ]} baseUrl={`${routes.PROJECTS}/${wbsNum}`} defaultTab="overview" @@ -216,8 +218,10 @@ const ProjectViewContainer: React.FC = ({ project, en ) : tab === 6 ? ( - ) : ( + ) : tab === 7 ? ( + ) : ( + )} {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..42c6b7ec53 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx @@ -0,0 +1,344 @@ +/* + * 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, 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 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)); + setIsInitialized(true); + }, [rules, teams, isInitialized]); + + 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 (teamsLoading) { + return ; + } + + if (teamsError) { + 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..2904a5ed3e --- /dev/null +++ b/src/frontend/src/pages/RulesPage/RuleRow.tsx @@ -0,0 +1,195 @@ +/* + * 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; +} + +/** + * 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 = '20%', + middleWidth = '70%', + rightWidth = '10%', + initiallyExpanded = false +}) => { + const [isExpanded, setIsExpanded] = useState(initiallyExpanded); + const hasSubRules = rule.subRuleIds.length > 0; + + // Lazy load if allRules not provided + const { data: fetchedSubRules = [] } = useGetChildRules(rule.ruleId, !allRules && isExpanded && hasSubRules); + + // Use allRules if provided, otherwise use fetched + const subRules = allRules ? allRules.filter((r) => rule.subRuleIds.includes(r.ruleId)) : fetchedSubRules; + + const 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 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..d3618acb02 --- /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 (isLoading) return ; + if (isError) 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..e1457510e8 --- /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 (isRulesetLoading || isRulesLoading) { + return ; + } + + if (isRulesetError) { + return ; + } + + if (isRulesError) { + 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/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..56f556f019 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Box, Paper, Table, TableBody, TableContainer } from '@mui/material'; +import { Rule } from 'shared'; +import RuleRow from '../RuleRow'; + +interface RulesetGeneralViewProps { + allRules: Rule[]; +} + +/** + * general view for displaying all top-level rules as dropdowns + */ +const RulesetGeneralView: React.FC = ({ allRules }) => { + const topLevelRules = allRules.filter((rule) => !rule.parentRule); + + return ( + + + + + {topLevelRules.map((rule) => ( + null} + backgroundColor="#9d9d9d" + textColor="#000000" + hoverColor="#5e5e5e" + rowHeight="10px" + verticalPadding="5px" + /> + ))} + +
+
+
+ ); +}; + +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..003dbc408b --- /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 (isLoading) return ; + if (error) 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..fe84ccc53e --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx @@ -0,0 +1,129 @@ +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: [] + })); + + // 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: [] + })) + ); + + // 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: [] + })); + + // 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: [] + } + : 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..4ef2c1c2eb --- /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 (isLoading) return ; + if (error) 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 5194281381..4bad959451 100644 --- a/src/frontend/src/utils/routes.ts +++ b/src/frontend/src/utils/routes.ts @@ -79,6 +79,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, @@ -143,5 +149,10 @@ export const routes = { EDIT_GRAPH, GRAPH_COLLECTION_BY_ID, - 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..c0804c7158 --- /dev/null +++ b/src/frontend/src/utils/rules.utils.ts @@ -0,0 +1,22 @@ +/* + * 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) + * @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; +}; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 316e21837d..86102f34b9 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -435,6 +435,36 @@ const retrospectiveTimelines = (startDate?: Date, endDate?: Date) => (endDate ? `end=${encodeURIComponent(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) => + `${rules()}/ruleset/${rulesetId}/team/${teamId}/rules/unassigned`; +const rulesCreateProjectRule = () => `${rules()}/projectRule/create`; +const rulesDeleteProjectRule = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/delete`; +const rulesEditProjectRuleStatus = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/editStatus`; +const 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`; @@ -785,6 +815,33 @@ export const apiUrls = { retrospectiveTimelines, retrospectiveBudgets, + rules, + rulesTopLevel, + rulesToggleTeam, + rulesChildRules, + rulesTeamRulesInRulesetType, + ruleset, + rulesetTypes, + rulesetsByType, + rulesetTypeCreate, + rulesetsCreate, + rulesetById, + ruleCreate, + rulesGetActiveRuleset, + rulesGetProjectRules, + rulesGetUnassignedRulesForRuleset, + rulesCreateProjectRule, + rulesDeleteProjectRule, + rulesEditProjectRuleStatus, + 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 a7cfa44458..71f74853d2 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -29,5 +29,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..a538320540 --- /dev/null +++ b/src/shared/src/types/rules-types.ts @@ -0,0 +1,79 @@ +/* + * 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 { User } from './user-types.js'; + +export enum RuleCompletion { + REVIEW = 'REVIEW', + INCOMPLETE = 'INCOMPLETE', + COMPLETED = 'COMPLETED' +} + +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; + }>; +} + +export interface RuleStatusChange { + historyId: string; + projectRuleId: string; + createdBy: User; + dateCreated: Date; + newStatus: RuleCompletion; + note: string; +} + +export interface ProjectRule { + projectRuleId: string; + rule: Rule; + projectId: string; + currentStatus: RuleCompletion; + statusHistory: RuleStatusChange[]; +} + +export interface RulesetPreview { + name: string; + dateCreated: Date; + active: boolean; + assignedPercentage: number; + car: { + carId: string; + name: string; + }; +}